diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index 8702b6e..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - $(WarningsNotAsErrors); - AL0025;AL0026;AL0039;AL0070;AL0081;AL0101;AL0114;AL0137; - RS0030; - CA1002;CA1032;CA1034;CA1052;CA1056;CA1307;CA1725;CA1819;CA1822;CA1823;CA1852;CA1859; - CA2000;CA2012;CA2201;CA5394; - IDE0370;IDE1006 - - - - diff --git a/PaperlessREST.Tests/DocumentBuilder.cs b/PaperlessREST.Tests/DocumentBuilder.cs index a153593..e25387a 100644 --- a/PaperlessREST.Tests/DocumentBuilder.cs +++ b/PaperlessREST.Tests/DocumentBuilder.cs @@ -6,7 +6,7 @@ public sealed class DocumentBuilder private const string DefaultStoragePathFormat = "documents/{0:yyyy-MM}/{1}.pdf"; private const string DefaultExtractedContent = "Extracted content"; private string? _content; - private DateTimeOffset _createdAt = DateTimeOffset.UtcNow; + private DateTimeOffset _createdAt = TimeProvider.System.GetUtcNow(); private string _fileName = DefaultFileName; private Guid _id = Guid.CreateVersion7(); @@ -68,7 +68,7 @@ public DocumentBuilder WithProcessedAt(DateTimeOffset? processedAt) public DocumentBuilder WithSummary(string? summary, DateTimeOffset? generatedAt = null) { _summary = summary; - _summaryGeneratedAt = generatedAt ?? (_summary is not null ? DateTimeOffset.UtcNow : null); + _summaryGeneratedAt = generatedAt ?? (_summary is not null ? TimeProvider.System.GetUtcNow() : null); return this; } @@ -84,7 +84,7 @@ public DocumentBuilder AsCompleted(string content = DefaultExtractedContent) { _status = DocumentStatus.Completed; _content = content; - _processedAt = DateTimeOffset.UtcNow; + _processedAt = TimeProvider.System.GetUtcNow(); return this; } @@ -92,7 +92,7 @@ public DocumentBuilder AsFailed() { _status = DocumentStatus.Failed; _content = null; - _processedAt = DateTimeOffset.UtcNow; + _processedAt = TimeProvider.System.GetUtcNow(); return this; } diff --git a/PaperlessREST.Tests/GlobalUsings.cs b/PaperlessREST.Tests/GlobalUsings.cs index 13e9a53..474d20f 100644 --- a/PaperlessREST.Tests/GlobalUsings.cs +++ b/PaperlessREST.Tests/GlobalUsings.cs @@ -1,5 +1,6 @@ // System namespaces +global using System.Diagnostics.CodeAnalysis; global using System.IO.Abstractions; global using System.Net; global using System.Net.Http.Headers; diff --git a/PaperlessREST.Tests/Integration/BatchOrchestratorIntegrationTests.cs b/PaperlessREST.Tests/Integration/BatchOrchestratorIntegrationTests.cs index ab615ab..2f12731 100644 --- a/PaperlessREST.Tests/Integration/BatchOrchestratorIntegrationTests.cs +++ b/PaperlessREST.Tests/Integration/BatchOrchestratorIntegrationTests.cs @@ -31,7 +31,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Archive).Should().ContainSingle(); @@ -57,7 +57,7 @@ await _fileSystem.File.WriteAllTextAsync( #region Fields - private static readonly IJobCancellationToken TestToken = new TestJobCancellationToken(); + private static readonly IJobCancellationToken s_testToken = new TestJobCancellationToken(); private readonly IDbContextFactory _dbFactory; private readonly IFileSystem _fileSystem; @@ -97,7 +97,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Archive) @@ -111,7 +111,7 @@ public async Task ProcessAsync_OrphanClaimedFile_Processes() { // Arrange Guid docId = await SeedDocumentAsync($"{TestFilePrefix}-orphan.pdf"); - string xmlContent = CreateXmlContent(DateOnly.FromDateTime(DateTime.UtcNow), (docId, 25)); + string xmlContent = CreateXmlContent(DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime), (docId, 25)); string claimedPath = _fileSystem.Path.Combine(_paths.Input, "orphan.xml.processing"); await _fileSystem.File.WriteAllTextAsync( @@ -120,7 +120,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Input).Should().BeEmpty("orphan should be processed"); @@ -140,7 +140,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Input) @@ -157,7 +157,7 @@ public async Task ProcessAsync_InputDirectoryMissing_CreatesDirectoryAndContinue _fileSystem.Directory.Exists(_paths.Input).Should().BeFalse("precondition: input dir should be deleted"); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.Exists(_paths.Input).Should().BeTrue("orchestrator should create input dir if missing"); @@ -171,7 +171,7 @@ public async Task ProcessAsync_ValidXmlFile_MovesToArchive() await CreateXmlFileAsync("daily-report.xml", (docId, 15)); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Input).Should().BeEmpty("file should be moved out of input"); @@ -194,7 +194,7 @@ public async Task ProcessAsync_MultipleDocumentsInOneFile_AggregatesAndUpserts() await CreateXmlFileAsync("multi-doc.xml", (doc1, 10), (doc2, 20)); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Archive).Should().ContainSingle(); @@ -210,7 +210,7 @@ public async Task ProcessAsync_DuplicateDocumentIdInSameFile_Aggregates() await CreateXmlFileAsync("duplicates.xml", (docId, 5), (docId, 8)); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Archive).Should().ContainSingle(); @@ -224,10 +224,10 @@ public async Task ProcessAsync_SameDocumentProcessedTwice_AccumulatesCounts() Guid docId = await SeedDocumentAsync($"{TestFilePrefix}-monthly.pdf"); await CreateXmlFileAsync("morning.xml", (docId, 5)); - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); await CreateXmlFileAsync("afternoon.xml", (docId, 8)); - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert await VerifyAccessRecordAsync(docId, 13, "should accumulate 5 + 8"); @@ -248,7 +248,7 @@ public async Task ProcessAsync_UnknownDocumentId_FiltersAndArchives() await CreateXmlFileAsync("mixed.xml", (knownId, 10), (unknownId, 99)); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Archive).Should().ContainSingle("file should still archive"); @@ -270,7 +270,7 @@ public async Task ProcessAsync_AllDocumentsUnknown_StillArchives() await CreateXmlFileAsync("all-unknown.xml", (unknownId1, 10), (unknownId2, 20)); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Archive).Should().ContainSingle("file should archive"); @@ -296,7 +296,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Error).Should().ContainSingle("malformed XML should go to error"); @@ -313,7 +313,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Error).Should().ContainSingle(); @@ -338,7 +338,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Error).Should().ContainSingle(); @@ -362,7 +362,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Error).Should().ContainSingle(); @@ -386,7 +386,7 @@ await _fileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken); // Act - await _orchestrator.ProcessAsync(TestToken); + await _orchestrator.ProcessAsync(s_testToken); // Assert _fileSystem.Directory.GetFiles(_paths.Error).Should().ContainSingle(); @@ -413,7 +413,7 @@ private async Task SeedDocumentAsync(string fileName) private async Task CreateXmlFileAsync(string fileName, params (Guid id, int count)[] documents) { - DateOnly date = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly date = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime); string xmlContent = CreateXmlContent(date, documents); await _fileSystem.File.WriteAllTextAsync( _fileSystem.Path.Combine(_paths.Input, fileName), @@ -438,7 +438,7 @@ private async Task VerifyAccessRecordAsync(Guid documentId, long expectedCount, { await using DocumentPersistence db = await _dbFactory.CreateDbContextAsync( TestContext.Current.CancellationToken); - DateOnly date = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly date = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime); DailyDocumentAccess? access = await db.DailyDocumentAccesses .FirstOrDefaultAsync( @@ -446,7 +446,7 @@ private async Task VerifyAccessRecordAsync(Guid documentId, long expectedCount, TestContext.Current.CancellationToken); access.Should().NotBeNull(because ?? "record should exist"); - access!.AccessCount.Should().Be(expectedCount, because ?? "access count should match"); + access.AccessCount.Should().Be(expectedCount, because ?? "access count should match"); } private void EnsureCleanDirectories() diff --git a/PaperlessREST.Tests/Integration/DocumentAccessRepositoryIntegrationTests.cs b/PaperlessREST.Tests/Integration/DocumentAccessRepositoryIntegrationTests.cs index 1902f65..c459f81 100644 --- a/PaperlessREST.Tests/Integration/DocumentAccessRepositoryIntegrationTests.cs +++ b/PaperlessREST.Tests/Integration/DocumentAccessRepositoryIntegrationTests.cs @@ -70,7 +70,8 @@ private async Task SeedDocumentAsync(string fileName) private readonly DatabaseFixture _fixture; private readonly List _createdDocIds = []; private AsyncServiceScope _scope; - private IDocumentAccessRepository _repository = null!; + private IDocumentAccessRepository? _repository; + private IDocumentAccessRepository Repository => _repository ?? throw new InvalidOperationException("Repository not initialized."); #endregion @@ -122,7 +123,7 @@ public async Task GetExistingDocumentIdsAsync_ReturnsOnlyExistingIds() Guid nonExistingId = Guid.CreateVersion7(); // Act - Guid[] result = await _repository.GetExistingDocumentIdsAsync( + Guid[] result = await Repository.GetExistingDocumentIdsAsync( [existingId, nonExistingId], TestContext.Current.CancellationToken); @@ -137,7 +138,7 @@ public async Task GetExistingDocumentIdsAsync_EmptyInput_ReturnsEmptyArray() Guid[] emptyIds = []; // Act - Guid[] result = await _repository.GetExistingDocumentIdsAsync( + Guid[] result = await Repository.GetExistingDocumentIdsAsync( emptyIds, TestContext.Current.CancellationToken); @@ -154,7 +155,7 @@ public async Task GetExistingDocumentIdsAsync_AllIdsExist_ReturnsAll() Guid id3 = await SeedDocumentAsync($"{TestFilePrefix}-all3-{Guid.NewGuid():N}.pdf"); // Act - Guid[] result = await _repository.GetExistingDocumentIdsAsync( + Guid[] result = await Repository.GetExistingDocumentIdsAsync( [id1, id2, id3], TestContext.Current.CancellationToken); @@ -173,7 +174,7 @@ public async Task GetExistingDocumentIdsAsync_NoIdsExist_ReturnsEmptyArray() Guid nonExisting2 = Guid.CreateVersion7(); // Act - Guid[] result = await _repository.GetExistingDocumentIdsAsync( + Guid[] result = await Repository.GetExistingDocumentIdsAsync( [nonExisting1, nonExisting2], TestContext.Current.CancellationToken); @@ -189,11 +190,11 @@ public async Task GetExistingDocumentIdsAsync_NoIdsExist_ReturnsEmptyArray() public async Task UpsertDailyAccessAsync_EmptyItems_DoesNothing() { // Arrange - DateOnly date = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly date = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime); (Guid DocumentId, long AccessCount)[] emptyItems = []; // Act - should return without error - await _repository.UpsertDailyAccessAsync(date, emptyItems, TestContext.Current.CancellationToken); + await Repository.UpsertDailyAccessAsync(date, emptyItems, TestContext.Current.CancellationToken); // Assert - no exception thrown, method returns early } @@ -203,21 +204,21 @@ public async Task UpsertDailyAccessAsync_InsertsNewRecord() { // Arrange Guid docId = await SeedDocumentAsync($"{TestFilePrefix}-upsert-insert-{Guid.NewGuid():N}.pdf"); - DateOnly date = DateOnly.FromDateTime(DateTime.UtcNow); - const long accessCount = 42; + DateOnly date = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime); + const long AccessCount = 42; try { // Act - await _repository.UpsertDailyAccessAsync( + await Repository.UpsertDailyAccessAsync( date, - [(docId, accessCount)], + [(docId, AccessCount)], TestContext.Current.CancellationToken); // Assert DailyDocumentAccess? record = await GetDailyAccessAsync(docId, date); record.Should().NotBeNull(); - record!.AccessCount.Should().Be(accessCount); + record!.AccessCount.Should().Be(AccessCount); record.DocumentId.Should().Be(docId); record.LogDate.Should().Be(date); } @@ -232,29 +233,29 @@ public async Task UpsertDailyAccessAsync_UpdatesExistingRecord_IncrementsAccessC { // Arrange Guid docId = await SeedDocumentAsync($"{TestFilePrefix}-upsert-update-{Guid.NewGuid():N}.pdf"); - DateOnly date = DateOnly.FromDateTime(DateTime.UtcNow); - const long initialCount = 10; - const long additionalCount = 25; - const long expectedTotal = initialCount + additionalCount; + DateOnly date = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime); + const long InitialCount = 10; + const long AdditionalCount = 25; + const long ExpectedTotal = InitialCount + AdditionalCount; try { // Insert initial record - await _repository.UpsertDailyAccessAsync( + await Repository.UpsertDailyAccessAsync( date, - [(docId, initialCount)], + [(docId, InitialCount)], TestContext.Current.CancellationToken); // Act - upsert again with additional count - await _repository.UpsertDailyAccessAsync( + await Repository.UpsertDailyAccessAsync( date, - [(docId, additionalCount)], + [(docId, AdditionalCount)], TestContext.Current.CancellationToken); // Assert - count should be incremented DailyDocumentAccess? record = await GetDailyAccessAsync(docId, date); record.Should().NotBeNull(); - record!.AccessCount.Should().Be(expectedTotal); + record!.AccessCount.Should().Be(ExpectedTotal); } finally { @@ -268,16 +269,16 @@ public async Task UpsertDailyAccessAsync_MultipleItems_InsertsAll() // Arrange Guid docId1 = await SeedDocumentAsync($"{TestFilePrefix}-upsert-multi1-{Guid.NewGuid():N}.pdf"); Guid docId2 = await SeedDocumentAsync($"{TestFilePrefix}-upsert-multi2-{Guid.NewGuid():N}.pdf"); - DateOnly date = DateOnly.FromDateTime(DateTime.UtcNow); - const long count1 = 100; - const long count2 = 200; + DateOnly date = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime); + const long Count1 = 100; + const long Count2 = 200; try { // Act - await _repository.UpsertDailyAccessAsync( + await Repository.UpsertDailyAccessAsync( date, - [(docId1, count1), (docId2, count2)], + [(docId1, Count1), (docId2, Count2)], TestContext.Current.CancellationToken); // Assert @@ -285,10 +286,10 @@ await _repository.UpsertDailyAccessAsync( DailyDocumentAccess? record2 = await GetDailyAccessAsync(docId2, date); record1.Should().NotBeNull(); - record1!.AccessCount.Should().Be(count1); + record1!.AccessCount.Should().Be(Count1); record2.Should().NotBeNull(); - record2!.AccessCount.Should().Be(count2); + record2!.AccessCount.Should().Be(Count2); } finally { @@ -302,22 +303,22 @@ public async Task UpsertDailyAccessAsync_SameDocumentDifferentDates_CreatesSepar { // Arrange Guid docId = await SeedDocumentAsync($"{TestFilePrefix}-upsert-dates-{Guid.NewGuid():N}.pdf"); - DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly today = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime); DateOnly yesterday = today.AddDays(-1); - const long todayCount = 50; - const long yesterdayCount = 30; + const long TodayCount = 50; + const long YesterdayCount = 30; try { // Act - await _repository.UpsertDailyAccessAsync( + await Repository.UpsertDailyAccessAsync( today, - [(docId, todayCount)], + [(docId, TodayCount)], TestContext.Current.CancellationToken); - await _repository.UpsertDailyAccessAsync( + await Repository.UpsertDailyAccessAsync( yesterday, - [(docId, yesterdayCount)], + [(docId, YesterdayCount)], TestContext.Current.CancellationToken); // Assert - should have two separate records @@ -325,10 +326,10 @@ await _repository.UpsertDailyAccessAsync( DailyDocumentAccess? yesterdayRecord = await GetDailyAccessAsync(docId, yesterday); todayRecord.Should().NotBeNull(); - todayRecord!.AccessCount.Should().Be(todayCount); + todayRecord!.AccessCount.Should().Be(TodayCount); yesterdayRecord.Should().NotBeNull(); - yesterdayRecord!.AccessCount.Should().Be(yesterdayCount); + yesterdayRecord!.AccessCount.Should().Be(YesterdayCount); } finally { diff --git a/PaperlessREST.Tests/Integration/DocumentEndpointTests.cs b/PaperlessREST.Tests/Integration/DocumentEndpointTests.cs index 5f62d71..ab29129 100644 --- a/PaperlessREST.Tests/Integration/DocumentEndpointTests.cs +++ b/PaperlessREST.Tests/Integration/DocumentEndpointTests.cs @@ -47,7 +47,7 @@ public async Task Upload_ValidPdf_Returns202WithLocation() { // Arrange string uniqueFileName = $"{TestFilePrefix}-upload-{Guid.NewGuid():N}.pdf"; - MultipartFormDataContent content = await CreatePdfUploadAsync(uniqueFileName); + using MultipartFormDataContent content = await CreatePdfUploadAsync(uniqueFileName); // Act HttpResponseMessage response = await _fixture.Client.PostAsync( @@ -165,6 +165,9 @@ private async Task SeedDocumentAsync(string fileName) return entity.Id; } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "ByteArrayContent ownership transfers to MultipartFormDataContent on Add(); " + + "the inner try/catch covers the pre-transfer window. Standard .NET HttpClient pattern.")] private async Task CreatePdfUploadAsync(string fileName) { string tempPath = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}.pdf"); @@ -174,11 +177,28 @@ private async Task CreatePdfUploadAsync(string fileNam File.Delete(pdfPath); - MultipartFormDataContent content = new(); - ByteArrayContent byteContent = new(pdfBytes); - byteContent.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentTypePdf); - content.Add(byteContent, "file", fileName); - return content; + var content = new MultipartFormDataContent(); + try + { + var fileContent = new ByteArrayContent(pdfBytes); + try + { + fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentTypePdf); + content.Add(fileContent, "file", fileName); + } + catch + { + fileContent.Dispose(); + throw; + } + + return content; + } + catch + { + content.Dispose(); + throw; + } } #endregion diff --git a/PaperlessREST.Tests/Integration/DocumentRepositoryIntegrationTests.cs b/PaperlessREST.Tests/Integration/DocumentRepositoryIntegrationTests.cs index 7698d0a..7427d82 100644 --- a/PaperlessREST.Tests/Integration/DocumentRepositoryIntegrationTests.cs +++ b/PaperlessREST.Tests/Integration/DocumentRepositoryIntegrationTests.cs @@ -28,7 +28,8 @@ public DocumentRepositoryIntegrationTests(DatabaseFixture fixture) private readonly DatabaseFixture _fixture; private AsyncServiceScope _scope; - private IDocumentRepository _repository = null!; + private IDocumentRepository? _repository; + private IDocumentRepository Repository => _repository ?? throw new InvalidOperationException("Repository not initialized."); #endregion @@ -56,7 +57,7 @@ public async Task AddAsync_ValidDocument_PersistsToDatabase() .Build(); // Act - Document saved = await _repository.AddAsync(document, TestContext.Current.CancellationToken); + Document saved = await Repository.AddAsync(document, TestContext.Current.CancellationToken); // Assert saved.Id.Should().Be(document.Id); @@ -84,7 +85,7 @@ public async Task AddAsync_SetsStoragePath() .Build(); // Act - Document saved = await _repository.AddAsync(document, TestContext.Current.CancellationToken); + Document saved = await Repository.AddAsync(document, TestContext.Current.CancellationToken); // Assert saved.StoragePath.Should().Be(StoragePath); @@ -111,10 +112,10 @@ public async Task GetByIdAsync_ExistingDocument_ReturnsWithAllProperties() .WithSummary(AiSummary) .AsCompleted(OcrContent) .Build(); - await _repository.AddAsync(original, TestContext.Current.CancellationToken); + await Repository.AddAsync(original, TestContext.Current.CancellationToken); // Act - Document? found = await _repository.GetByIdAsync(original.Id, TestContext.Current.CancellationToken); + Document? found = await Repository.GetByIdAsync(original.Id, TestContext.Current.CancellationToken); // Assert found.Should().NotBeNull(); @@ -130,7 +131,7 @@ public async Task GetByIdAsync_NonExistent_ReturnsNull() Guid nonExistentId = Guid.CreateVersion7(); // Act - Document? result = await _repository.GetByIdAsync(nonExistentId, TestContext.Current.CancellationToken); + Document? result = await Repository.GetByIdAsync(nonExistentId, TestContext.Current.CancellationToken); // Assert result.Should().BeNull(); @@ -147,12 +148,12 @@ public async Task UpdateAsync_MarkAsCompleted_UpdatesStatusAndContent() Document original = new DocumentBuilder() .WithFileName($"{TestFilePrefix}-update-{Guid.NewGuid():N}.pdf") .Build(); - Document saved = await _repository.AddAsync(original, TestContext.Current.CancellationToken); + Document saved = await Repository.AddAsync(original, TestContext.Current.CancellationToken); saved.MarkAsCompleted(OcrContent, TimeProvider.System); // Act - bool updated = await _repository.UpdateAsync(saved, TestContext.Current.CancellationToken); + bool updated = await Repository.UpdateAsync(saved, TestContext.Current.CancellationToken); // Assert updated.Should().BeTrue(); @@ -175,12 +176,12 @@ public async Task UpdateAsync_MarkAsFailed_UpdatesStatus() Document original = new DocumentBuilder() .WithFileName($"{TestFilePrefix}-fail-{Guid.NewGuid():N}.pdf") .Build(); - Document saved = await _repository.AddAsync(original, TestContext.Current.CancellationToken); + Document saved = await Repository.AddAsync(original, TestContext.Current.CancellationToken); saved.MarkAsFailed(TimeProvider.System); // Act - bool updated = await _repository.UpdateAsync(saved, TestContext.Current.CancellationToken); + bool updated = await Repository.UpdateAsync(saved, TestContext.Current.CancellationToken); // Assert updated.Should().BeTrue(); @@ -205,7 +206,7 @@ public async Task UpdateAsync_NonExistent_ReturnsFalse() .Build(); // Act - bool result = await _repository.UpdateAsync(document, TestContext.Current.CancellationToken); + bool result = await Repository.UpdateAsync(document, TestContext.Current.CancellationToken); // Assert result.Should().BeFalse(); @@ -222,15 +223,15 @@ public async Task DeleteAsync_ExistingDocument_RemovesFromDatabase() Document document = new DocumentBuilder() .WithFileName($"{TestFilePrefix}-delete-{Guid.NewGuid():N}.pdf") .Build(); - await _repository.AddAsync(document, TestContext.Current.CancellationToken); + await Repository.AddAsync(document, TestContext.Current.CancellationToken); // Act - bool deleted = await _repository.DeleteAsync(document.Id, TestContext.Current.CancellationToken); + bool deleted = await Repository.DeleteAsync(document.Id, TestContext.Current.CancellationToken); // Assert deleted.Should().BeTrue(); - Document? found = await _repository.GetByIdAsync(document.Id, TestContext.Current.CancellationToken); + Document? found = await Repository.GetByIdAsync(document.Id, TestContext.Current.CancellationToken); found.Should().BeNull(); } @@ -241,7 +242,7 @@ public async Task DeleteAsync_NonExistent_ReturnsFalse() Guid nonExistentId = Guid.CreateVersion7(); // Act - bool deleted = await _repository.DeleteAsync(nonExistentId, TestContext.Current.CancellationToken); + bool deleted = await Repository.DeleteAsync(nonExistentId, TestContext.Current.CancellationToken); // Assert deleted.Should().BeFalse(); @@ -259,12 +260,12 @@ public async Task UpdateSummaryAsync_ExistingDocument_UpdatesOnlySummaryFields() .WithFileName($"{TestFilePrefix}-summary-{Guid.NewGuid():N}.pdf") .AsCompleted(OcrContent) .Build(); - await _repository.AddAsync(document, TestContext.Current.CancellationToken); + await Repository.AddAsync(document, TestContext.Current.CancellationToken); - DateTimeOffset generatedAt = DateTimeOffset.UtcNow; + DateTimeOffset generatedAt = TimeProvider.System.GetUtcNow(); // Act - bool updated = await _repository.UpdateSummaryAsync( + bool updated = await Repository.UpdateSummaryAsync( document.Id, AiSummary, generatedAt, @@ -273,7 +274,7 @@ public async Task UpdateSummaryAsync_ExistingDocument_UpdatesOnlySummaryFields() // Assert updated.Should().BeTrue(); - Document? found = await _repository.GetByIdAsync(document.Id, TestContext.Current.CancellationToken); + Document? found = await Repository.GetByIdAsync(document.Id, TestContext.Current.CancellationToken); found.Should().NotBeNull(); found!.Summary.Should().Be(AiSummary); found.SummaryGeneratedAt.Should().BeCloseTo(generatedAt, TimeSpan.FromSeconds(1)); @@ -289,10 +290,10 @@ public async Task UpdateSummaryAsync_NonExistent_ReturnsFalse() Guid nonExistentId = Guid.CreateVersion7(); // Act - bool updated = await _repository.UpdateSummaryAsync( + bool updated = await Repository.UpdateSummaryAsync( nonExistentId, AiSummary, - DateTimeOffset.UtcNow, + TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken); // Assert @@ -311,18 +312,18 @@ public async Task GetDocumentsPagedAsync_ReturnsNewestFirst() // Add documents with slight delays to ensure distinct GUIDv7s Document oldest = new DocumentBuilder().WithFileName($"{testPrefix}-old.pdf").Build(); - await _repository.AddAsync(oldest, TestContext.Current.CancellationToken); + await Repository.AddAsync(oldest, TestContext.Current.CancellationToken); await Task.Delay(10, TestContext.Current.CancellationToken); Document middle = new DocumentBuilder().WithFileName($"{testPrefix}-mid.pdf").Build(); - await _repository.AddAsync(middle, TestContext.Current.CancellationToken); + await Repository.AddAsync(middle, TestContext.Current.CancellationToken); await Task.Delay(10, TestContext.Current.CancellationToken); Document newest = new DocumentBuilder().WithFileName($"{testPrefix}-new.pdf").Build(); - await _repository.AddAsync(newest, TestContext.Current.CancellationToken); + await Repository.AddAsync(newest, TestContext.Current.CancellationToken); // Act - (List results, bool hasMore) = await _repository + (List results, bool hasMore) = await Repository .GetDocumentsPagedAsync(50, null, TestContext.Current.CancellationToken); List filtered = results.Where(d => d.FileName.StartsWith(testPrefix, StringComparison.Ordinal)).ToList(); @@ -342,14 +343,14 @@ public async Task GetDocumentsPagedAsync_RespectsPageSize() for (int i = 0; i < 5; i++) { - await _repository.AddAsync( + await Repository.AddAsync( new DocumentBuilder().WithFileName($"{testPrefix}-{i}.pdf").Build(), TestContext.Current.CancellationToken); await Task.Delay(5, TestContext.Current.CancellationToken); // Ensure distinct GUIDv7s } // Act - (List results, bool hasMore) = await _repository + (List results, bool hasMore) = await Repository .GetDocumentsPagedAsync(3, null, TestContext.Current.CancellationToken); // Assert @@ -367,18 +368,18 @@ public async Task GetDocumentsPagedAsync_WithCursor_ReturnsNextPage() for (int i = 0; i < 5; i++) { Document doc = new DocumentBuilder().WithFileName($"{testPrefix}-{i}.pdf").Build(); - Document added = await _repository.AddAsync(doc, TestContext.Current.CancellationToken); + Document added = await Repository.AddAsync(doc, TestContext.Current.CancellationToken); addedDocs.Add(added); await Task.Delay(5, TestContext.Current.CancellationToken); } // Act - Get first page - (List firstPage, bool hasMoreFirst) = await _repository + (List firstPage, bool hasMoreFirst) = await Repository .GetDocumentsPagedAsync(2, null, TestContext.Current.CancellationToken); // Get second page using cursor from first page Guid cursor = firstPage[^1].Id; - (List secondPage, bool hasMoreSecond) = await _repository + (List secondPage, bool hasMoreSecond) = await Repository .GetDocumentsPagedAsync(2, cursor, TestContext.Current.CancellationToken); // Assert @@ -397,14 +398,14 @@ public async Task GetDocumentsPagedAsync_LastPage_HasMoreIsFalse() for (int i = 0; i < 3; i++) { - await _repository.AddAsync( + await Repository.AddAsync( new DocumentBuilder().WithFileName($"{testPrefix}-{i}.pdf").Build(), TestContext.Current.CancellationToken); await Task.Delay(5, TestContext.Current.CancellationToken); } // Act - Request more than available - (List results, bool hasMore) = await _repository + (List results, bool hasMore) = await Repository .GetDocumentsPagedAsync(100, null, TestContext.Current.CancellationToken); // Assert diff --git a/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs b/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs index 4523e6e..4748265 100644 --- a/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs +++ b/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs @@ -68,7 +68,8 @@ public SharedRestContainerFixture() private readonly PostgreSqlContainer _postgres; private readonly RabbitMqContainer _rabbit; - private WebApplicationFactory _factory = null!; + private WebApplicationFactory? _factory; + private WebApplicationFactory Factory => _factory ?? throw new InvalidOperationException("Factory not initialized."); #endregion @@ -103,37 +104,17 @@ await Task.WhenAll( Environment.SetEnvironmentVariable("ELASTICSEARCH__URI", $"http://{_elastic.Hostname}:{_elastic.GetMappedPublicPort(9200)}"); - IMinioClient? minioClient = new MinioClient() + using MinioClient minioClient = new(); + minioClient .WithEndpoint(minioEndpoint) .WithCredentials(_minio.GetAccessKey(), _minio.GetSecretKey()) .Build(); await minioClient.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucketName)); - _factory = new WebApplicationFactory() - .WithWebHostBuilder(builder => - { - builder.ConfigureTestServices(services => - { - services.RemoveAll(); - - services.RemoveAll>(); - - NpgsqlDataSource dataSource = new NpgsqlDataSourceBuilder(_postgres.GetConnectionString()) - .MapEnum("document_status") - .Build(); - - services.AddPooledDbContextFactory(opts => - opts.UseNpgsql(dataSource)); - - services.RemoveAll(); - services.AddSingleton(new MemoryStorage()); + _factory = new ConfiguredWebApplicationFactory(_postgres.GetConnectionString()); - services.AddFakeLogging(); - }); - }); - - Client = _factory.CreateClient(); - Services = _factory.Services; + Client = Factory.CreateClient(); + Services = Factory.Services; DbFactory = Services.GetRequiredService>(); await using DocumentPersistence db = await DbFactory.CreateDbContextAsync(); @@ -142,7 +123,8 @@ await Task.WhenAll( public async ValueTask DisposeAsync() { - await _factory.DisposeAsync(); + if (_factory is not null) + await _factory.DisposeAsync(); await Task.WhenAll( _postgres.DisposeAsync().AsTask(), _rabbit.DisposeAsync().AsTask(), @@ -152,4 +134,30 @@ await Task.WhenAll( } #endregion + + private sealed class ConfiguredWebApplicationFactory(string postgresConnectionString) + : WebApplicationFactory + { + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + + services.RemoveAll>(); + + NpgsqlDataSource dataSource = new NpgsqlDataSourceBuilder(postgresConnectionString) + .MapEnum("document_status") + .Build(); + + services.AddPooledDbContextFactory(opts => + opts.UseNpgsql(dataSource)); + + services.RemoveAll(); + services.AddSingleton(new MemoryStorage()); + + services.AddFakeLogging(); + }); + } + } } diff --git a/PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs b/PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs new file mode 100644 index 0000000..b4938cd --- /dev/null +++ b/PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs @@ -0,0 +1,98 @@ +using PaperlessREST.Features.BatchProcessing.Application; + +namespace PaperlessREST.Tests.Unit; + +/// +/// Unit tests for the static error factories in and +/// . These are tiny shape-only tests: each factory is one expression +/// producing an with a well-known code, type, and description. Coverage +/// comes from invoking each factory once and verifying the code/type contract. +/// +public sealed class BatchAndReportErrorsTests +{ + [Fact] + public void ReportErrors_FileNotFound_ReturnsNotFoundWithPath() + { + Error e = ReportErrors.FileNotFound("/tmp/missing.xml"); + e.Type.Should().Be(ErrorType.NotFound); + e.Code.Should().Be("Report.FileNotFound"); + e.Description.Should().Contain("/tmp/missing.xml"); + } + + [Fact] + public void ReportErrors_InvalidXml_ReturnsValidationWithDetails() + { + Error e = ReportErrors.InvalidXml("unclosed tag"); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Report.InvalidXml"); + e.Description.Should().Contain("unclosed tag"); + } + + [Fact] + public void ReportErrors_InvalidSchema_ReturnsValidationWithDetails() + { + Error e = ReportErrors.InvalidSchema("schema mismatch"); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Report.InvalidSchema"); + e.Description.Should().Contain("schema mismatch"); + } + + [Fact] + public void ReportErrors_InvalidDate_ReturnsValidationWithRawValue() + { + Error e = ReportErrors.InvalidDate("01/15/2024"); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Report.InvalidDate"); + e.Description.Should().Contain("01/15/2024"); + e.Description.Should().Contain("yyyy-MM-dd"); + } + + [Fact] + public void ReportErrors_InvalidGuid_ReturnsValidationWithIndex() + { + Error e = ReportErrors.InvalidGuid(7); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Report.InvalidGuid"); + e.Description.Should().Contain("index 7"); + } + + [Fact] + public void BatchErrors_PathRequired_FormatsPropertyAndSection() + { + Error e = BatchErrors.PathRequired("InputPath"); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Batch.PathRequired"); + e.Description.Should().Contain("InputPath"); + e.Description.Should().Contain(BatchOptions.SectionName); + } + + [Fact] + public void BatchErrors_InvalidPath_IncludesPropertyAndDetails() + { + Error e = BatchErrors.InvalidPath("ArchivePath", "not absolute"); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Batch.InvalidPath"); + e.Description.Should().Contain("ArchivePath"); + e.Description.Should().Contain("not absolute"); + } + + [Fact] + public void BatchErrors_PathsNotDistinct_DescribesTheThreeAffectedFields() + { + Error e = BatchErrors.PathsNotDistinct(); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Batch.PathsNotDistinct"); + e.Description.Should().Contain("InputPath"); + e.Description.Should().Contain("ArchivePath"); + e.Description.Should().Contain("ErrorPath"); + } + + [Fact] + public void BatchErrors_InvalidTimeZone_QuotesOfferingValue() + { + Error e = BatchErrors.InvalidTimeZone("Mars/Olympus"); + e.Type.Should().Be(ErrorType.Validation); + e.Code.Should().Be("Batch.InvalidTimeZone"); + e.Description.Should().Contain("Mars/Olympus"); + } +} diff --git a/PaperlessREST.Tests/Unit/BatchOrchestratorTests.cs b/PaperlessREST.Tests/Unit/BatchOrchestratorTests.cs index e873138..f0d1576 100644 --- a/PaperlessREST.Tests/Unit/BatchOrchestratorTests.cs +++ b/PaperlessREST.Tests/Unit/BatchOrchestratorTests.cs @@ -40,7 +40,7 @@ public ProcessAsync() _logger = new FakeLogger(_logCollector); // Setup default time provider behavior - loose mock, not verified - _timeProvider.Setup(t => t.GetUtcNow()).Returns(DateTimeOffset.UtcNow); + _timeProvider.Setup(t => t.GetUtcNow()).Returns(TimeProvider.System.GetUtcNow()); // Initialize directory structure _fileSystem.Directory.CreateDirectory(InputPath); @@ -653,7 +653,7 @@ await sut.ProcessFileAsync( private BatchOrchestrator CreateSut() { - _time.Setup(t => t.GetUtcNow()).Returns(DateTimeOffset.UtcNow); + _time.Setup(t => t.GetUtcNow()).Returns(TimeProvider.System.GetUtcNow()); return new BatchOrchestrator( Options.Create(CreateOptions()), _fs.Object, diff --git a/PaperlessREST.Tests/Unit/ContractViolationExceptionTests.cs b/PaperlessREST.Tests/Unit/ContractViolationExceptionTests.cs new file mode 100644 index 0000000..ca611ce --- /dev/null +++ b/PaperlessREST.Tests/Unit/ContractViolationExceptionTests.cs @@ -0,0 +1,140 @@ +using PaperlessREST.Host.Extensions; + +namespace PaperlessREST.Tests.Unit; + +/// +/// Unit tests for and the related diagnostic records. +/// Covers each factory method, the projection, +/// and the single-vs-multiple-errors branches of the internal message builder. +/// +public sealed class ContractViolationExceptionTests +{ + private static readonly Error s_actualError = Error.Conflict("Document.Locked", "locked for edit"); + private static readonly Error s_secondError = Error.Validation("Document.BadField", "field x bad"); + + [Fact] + public void Constructor_OneError_BuildsMessageWithoutAggregateSuffix() + { + ContractViolationException ex = new( + "GetDocumentById", [ErrorType.NotFound], s_actualError, [s_actualError]); + + ex.EndpointOperation.Should().Be("GetDocumentById"); + ex.ExpectedErrorTypes.Should().Equal(ErrorType.NotFound); + ex.ActualError.Should().Be(s_actualError); + ex.AllErrors.Should().HaveCount(1); + ex.Message.Should().Contain("Contract violation in GetDocumentById"); + ex.Message.Should().Contain("Expected [NotFound]"); + ex.Message.Should().Contain("but received Conflict"); + ex.Message.Should().Contain("Document.Locked"); + ex.Message.Should().NotContain("more error(s)"); + } + + [Fact] + public void Constructor_MultipleErrors_BuildsMessageWithAggregateSuffix() + { + ContractViolationException ex = new( + "UpdateDocument", + [ErrorType.Validation, ErrorType.NotFound], + s_actualError, + [s_actualError, s_secondError]); + + ex.AllErrors.Should().HaveCount(2); + ex.Message.Should().Contain("Expected [Validation, NotFound]"); + ex.Message.Should().Contain("(+ 1 more error(s))"); + } + + [Fact] + public void GetDiagnostics_ReturnsStructuredProjection() + { + Error withMetadata = Error.Custom( + (int)ErrorType.Conflict, "Document.Locked", "locked", + new Dictionary { ["CurrentState"] = "Locked" }); + + ContractViolationException ex = new( + "PUT /documents/{id}", + [ErrorType.NotFound, ErrorType.Conflict], + withMetadata, + [withMetadata, s_secondError]); + + ContractViolationDiagnostics diag = ex.GetDiagnostics(); + + diag.Operation.Should().Be("PUT /documents/{id}"); + diag.ExpectedErrorTypes.Should().Equal("NotFound", "Conflict"); + diag.ActualErrorType.Should().Be("Conflict"); + diag.ErrorCode.Should().Be("Document.Locked"); + diag.ErrorDescription.Should().Be("locked"); + diag.AllErrors.Should().HaveCount(2); + diag.AllErrors[0].Should().Be(new ErrorDetail("Conflict", "Document.Locked", "locked")); + diag.AllErrors[1].Should().Be(new ErrorDetail("Validation", "Document.BadField", "field x bad")); + diag.Metadata.Should().NotBeNull(); + diag.Metadata!["CurrentState"].Should().Be("Locked"); + } + + [Fact] + public void ForNotFoundOnly_BuildsWithCallerNameAndNotFoundExpectation() + { + ContractViolationException ex = ContractViolationException.ForNotFoundOnly( + s_actualError, [s_actualError], "GetById"); + + ex.EndpointOperation.Should().Be("GetById"); + ex.ExpectedErrorTypes.Should().Equal(ErrorType.NotFound); + } + + [Fact] + public void ForNotFoundOnly_DefaultCallerName_UsesCallingMember() + { + ContractViolationException ex = ForNotFoundOnly_DefaultCallerName_UsesCallingMember_Helper(); + + ex.EndpointOperation.Should().Be(nameof(ForNotFoundOnly_DefaultCallerName_UsesCallingMember_Helper)); + } + + private static ContractViolationException ForNotFoundOnly_DefaultCallerName_UsesCallingMember_Helper() => + ContractViolationException.ForNotFoundOnly(s_actualError, [s_actualError]); + + [Fact] + public void ForValidationOnly_BuildsWithValidationExpectation() + { + ContractViolationException ex = ContractViolationException.ForValidationOnly( + s_actualError, [s_actualError], "Validate"); + + ex.ExpectedErrorTypes.Should().Equal(ErrorType.Validation); + } + + [Fact] + public void ForNotFoundOrConflict_BuildsWithBothTypes() + { + ContractViolationException ex = ContractViolationException.ForNotFoundOrConflict( + s_actualError, [s_actualError], "UpdateOrCreate"); + + ex.ExpectedErrorTypes.Should().Equal(ErrorType.NotFound, ErrorType.Conflict); + } + + [Fact] + public void ForCrudOperation_BuildsWithValidationNotFoundConflict() + { + ContractViolationException ex = ContractViolationException.ForCrudOperation( + s_actualError, [s_actualError], "Crud"); + + ex.ExpectedErrorTypes.Should() + .Equal(ErrorType.Validation, ErrorType.NotFound, ErrorType.Conflict); + } + + [Fact] + public void For_BuildsWithCustomExpectedTypes() + { + ContractViolationException ex = ContractViolationException.For( + s_actualError, [s_actualError], "PostThing", ErrorType.Failure, ErrorType.Unexpected); + + ex.ExpectedErrorTypes.Should().Equal(ErrorType.Failure, ErrorType.Unexpected); + ex.EndpointOperation.Should().Be("PostThing"); + } + + [Fact] + public void Exception_IsInvalidOperationException() + { + ContractViolationException ex = ContractViolationException.ForNotFoundOnly( + s_actualError, [s_actualError], "GetById"); + + ex.Should().BeAssignableTo(); + } +} diff --git a/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs b/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs new file mode 100644 index 0000000..7f71ab8 --- /dev/null +++ b/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Net.Sockets; + +namespace PaperlessREST.Tests.Unit; + +/// +/// Covers the storage-exception mapping inside : +/// the four TryMapStorageException arms, plus the rethrow path when the storage exception +/// doesn't match any known shape (the _ => null case). +/// +public sealed class DocumentServiceStorageMappingTests : IDisposable +{ + private readonly FakeLogCollector _logCollector = new(); + private readonly FakeLogger _logger; + private readonly MockRepository _mocks = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty }; + private readonly Mock _publisher; + private readonly Mock _repository; + private readonly Mock _search; + private readonly Mock _storage; + private readonly FakeTimeProvider _timeProvider = new(); + + public DocumentServiceStorageMappingTests() + { + _repository = _mocks.Create(); + _storage = _mocks.Create(); + _search = _mocks.Create(); + _publisher = _mocks.Create(); + _logger = new FakeLogger(_logCollector); + } + + public void Dispose() + { + TestContext.Current.SendDiagnosticMessage("Full logs:\n{0}", _logCollector.GetFullLoggerText()); + } + + private DocumentService CreateSut() => + new(_repository.Object, _storage.Object, _search.Object, _publisher.Object, _timeProvider, _logger); + + private void SetupStorageThrows(Exception toThrow) => + _storage.Setup(s => s.UploadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(toThrow); + + [Fact] + public async Task UploadDocumentAsync_TimeoutException_ReturnsStorageTimeoutError() + { + SetupStorageThrows(new TimeoutException("timed out")); + + ErrorOr result = await CreateSut().UploadDocumentAsync( + UploadDocumentRequestBuilder.ValidPdf().Build(), + TestContext.Current.CancellationToken); + + result.IsError.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Unexpected); + result.FirstError.Code.Should().Be("Document.StorageTimeout"); + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Warning && l.Message.Contains("StorageTimeout", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task UploadDocumentAsync_HttpServerError_ReturnsStorageServerError() + { + SetupStorageThrows(new HttpRequestException("502 bad gateway", null, HttpStatusCode.BadGateway)); + + ErrorOr result = await CreateSut().UploadDocumentAsync( + UploadDocumentRequestBuilder.ValidPdf().Build(), + TestContext.Current.CancellationToken); + + result.IsError.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Unexpected); + result.FirstError.Code.Should().Be("Document.StorageServerError"); + result.FirstError.Description.Should().Contain("502"); + } + + [Fact] + public async Task UploadDocumentAsync_HttpClientError_DoesNotMatchAndRethrows() + { + // Only 5xx maps; 4xx fall through to the `_ => null` arm and re-throw. + HttpRequestException original = new("400 bad request", null, HttpStatusCode.BadRequest); + SetupStorageThrows(original); + + Func act = () => CreateSut().UploadDocumentAsync( + UploadDocumentRequestBuilder.ValidPdf().Build(), + TestContext.Current.CancellationToken); + + (await act.Should().ThrowAsync()) + .Which.Should().BeSameAs(original); + } + + [Fact] + public async Task UploadDocumentAsync_SocketUnderlyingIOException_ReturnsConnectionFailedError() + { + SetupStorageThrows(new IOException("network down", new SocketException())); + + ErrorOr result = await CreateSut().UploadDocumentAsync( + UploadDocumentRequestBuilder.ValidPdf().Build(), + TestContext.Current.CancellationToken); + + result.IsError.Should().BeTrue(); + result.FirstError.Code.Should().Be("Document.StorageConnectionFailed"); + } + + [Fact] + public async Task UploadDocumentAsync_UnknownException_PropagatesToCaller() + { + InvalidOperationException boom = new("totally unrelated"); + SetupStorageThrows(boom); + + Func act = () => CreateSut().UploadDocumentAsync( + UploadDocumentRequestBuilder.ValidPdf().Build(), + TestContext.Current.CancellationToken); + + (await act.Should().ThrowAsync()) + .Which.Should().BeSameAs(boom); + } + + // ─── ProcessOcrResultAsync state-transition-failure branch ────────────── + + [Fact] + public async Task ProcessOcrResultAsync_AlreadyCompleted_ReturnsCannotCompleteError() + { + // Already-completed documents can't be re-completed (MarkAsCompleted rejects + // non-Pending status). Exercises the `transitionResult.IsError` arm in + // ProcessOcrResultAsync that the existing Pending-state tests never hit. + Document doc = new DocumentBuilder().AsCompleted().Build(); + + _repository.Setup(r => r.GetByIdAsync(doc.Id, It.IsAny())).ReturnsAsync(doc); + + ErrorOr result = await CreateSut().ProcessOcrResultAsync( + doc.Id, "Completed", "ocr content", + TestContext.Current.CancellationToken); + + result.IsError.Should().BeTrue(); + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Warning && l.Message.Contains("state transition failed", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ProcessOcrResultAsync_AlreadyFailed_CannotBeFailedAgain() + { + Document doc = new DocumentBuilder().AsFailed().Build(); + + _repository.Setup(r => r.GetByIdAsync(doc.Id, It.IsAny())).ReturnsAsync(doc); + + ErrorOr result = await CreateSut().ProcessOcrResultAsync( + doc.Id, "Failed", null, + TestContext.Current.CancellationToken); + + result.IsError.Should().BeTrue(); + } +} diff --git a/PaperlessREST.Tests/Unit/DocumentServiceTests.cs b/PaperlessREST.Tests/Unit/DocumentServiceTests.cs index 30f4d48..6f5dcdc 100644 --- a/PaperlessREST.Tests/Unit/DocumentServiceTests.cs +++ b/PaperlessREST.Tests/Unit/DocumentServiceTests.cs @@ -240,7 +240,7 @@ public async Task UpdateDocumentSummaryAsync_DocumentFound_UpdatesAndReturnsTrue { // Arrange Document doc = new DocumentBuilder().AsCompleted().Build(); - DateTimeOffset generatedAt = DateTimeOffset.UtcNow; + DateTimeOffset generatedAt = TimeProvider.System.GetUtcNow(); _repository.Setup(r => r.UpdateSummaryAsync(doc.Id, GenAiSummary, generatedAt, It.IsAny())) @@ -262,7 +262,7 @@ public async Task UpdateDocumentSummaryAsync_DocumentFound_LogsSummaryLength() { // Arrange Document doc = new DocumentBuilder().AsCompleted().Build(); - DateTimeOffset generatedAt = DateTimeOffset.UtcNow; + DateTimeOffset generatedAt = TimeProvider.System.GetUtcNow(); _repository.Setup(r => r.UpdateSummaryAsync(doc.Id, GenAiSummary, generatedAt, It.IsAny())) @@ -290,7 +290,7 @@ public async Task UpdateDocumentSummaryAsync_DocumentNotFound_ReturnsFalse() { // Arrange Guid missingId = Guid.CreateVersion7(); - DateTimeOffset generatedAt = DateTimeOffset.UtcNow; + DateTimeOffset generatedAt = TimeProvider.System.GetUtcNow(); _repository.Setup(r => r.UpdateSummaryAsync(missingId, GenAiSummary, generatedAt, It.IsAny())) @@ -312,7 +312,7 @@ public async Task UpdateDocumentSummaryAsync_DocumentNotFound_LogsWarning() { // Arrange Guid missingId = Guid.CreateVersion7(); - DateTimeOffset generatedAt = DateTimeOffset.UtcNow; + DateTimeOffset generatedAt = TimeProvider.System.GetUtcNow(); _repository.Setup(r => r.UpdateSummaryAsync(missingId, GenAiSummary, generatedAt, It.IsAny())) @@ -394,7 +394,7 @@ public async Task SearchDocumentsAsync_DelegatesToSearchService() Id = Guid.CreateVersion7(), FileName = "result1.pdf", Status = "Completed", - CreatedAt = DateTimeOffset.UtcNow, + CreatedAt = TimeProvider.System.GetUtcNow(), Content = "Content 1" }, new DocumentSearchResult @@ -402,7 +402,7 @@ public async Task SearchDocumentsAsync_DelegatesToSearchService() Id = Guid.CreateVersion7(), FileName = "result2.pdf", Status = "Completed", - CreatedAt = DateTimeOffset.UtcNow, + CreatedAt = TimeProvider.System.GetUtcNow(), Content = "Content 2" } ]; diff --git a/PaperlessREST.Tests/Unit/DocumentTests.cs b/PaperlessREST.Tests/Unit/DocumentTests.cs index 2e0b5ab..a63abf6 100644 --- a/PaperlessREST.Tests/Unit/DocumentTests.cs +++ b/PaperlessREST.Tests/Unit/DocumentTests.cs @@ -10,8 +10,8 @@ public sealed class DocumentTests private const string TestContent = "content"; private const string TestSummary = "S"; - private static readonly DateTimeOffset FixedTime = new(2025, 6, 15, 10, 30, 0, TimeSpan.Zero); - private readonly FakeTimeProvider _timeProvider = new(FixedTime); + private static readonly DateTimeOffset s_fixedTime = new(2025, 6, 15, 10, 30, 0, TimeSpan.Zero); + private readonly FakeTimeProvider _timeProvider = new(s_fixedTime); public static IEnumerable> CompleteNotPending() { @@ -39,8 +39,8 @@ public void CreateFromUpload_SetsDefaults() document.Id.Should().NotBeEmpty(); document.FileName.Should().Be(TestFileName); document.Status.Should().Be(DocumentStatus.Pending); - document.CreatedAt.Should().Be(FixedTime); - document.StoragePath.Should().Match($"documents/{FixedTime.UtcDateTime:yyyy-MM}/{document.Id}.pdf"); + document.CreatedAt.Should().Be(s_fixedTime); + document.StoragePath.Should().Match($"documents/{s_fixedTime.UtcDateTime:yyyy-MM}/{document.Id}.pdf"); document.Content.Should().BeNull(); document.ProcessedAt.Should().BeNull(); } @@ -73,7 +73,7 @@ public void MarkAsCompleted_WhenPending_Transitions() result.IsError.Should().BeFalse(); document.Status.Should().Be(DocumentStatus.Completed); document.Content.Should().Be(TestContent); - document.ProcessedAt.Should().Be(FixedTime); + document.ProcessedAt.Should().Be(s_fixedTime); } [Theory] @@ -104,7 +104,7 @@ public void MarkAsFailed_WhenPending_Transitions() result.IsError.Should().BeFalse(); document.Status.Should().Be(DocumentStatus.Failed); document.Content.Should().BeNull(); - document.ProcessedAt.Should().Be(FixedTime); + document.ProcessedAt.Should().Be(s_fixedTime); } [Theory] @@ -115,7 +115,7 @@ public void UpdateSummary_Works(DocumentStatus status) { // Arrange Document document = new DocumentBuilder().WithStatus(status).Build(); - DateTimeOffset at = DateTimeOffset.UtcNow; + DateTimeOffset at = TimeProvider.System.GetUtcNow(); // Act document.UpdateSummary(TestSummary, at); diff --git a/PaperlessREST.Tests/Unit/EndpointsTests.cs b/PaperlessREST.Tests/Unit/EndpointsTests.cs index a4441c0..6d26a80 100644 --- a/PaperlessREST.Tests/Unit/EndpointsTests.cs +++ b/PaperlessREST.Tests/Unit/EndpointsTests.cs @@ -150,7 +150,7 @@ public async Task SearchDocuments_WhenMatchesFound_ReturnsOkWithResults() Id = PairA.Doc.Id, FileName = PairA.Doc.FileName, Status = StatusCompleted, - CreatedAt = DateTimeOffset.UtcNow, + CreatedAt = TimeProvider.System.GetUtcNow(), Content = ContentA }, new() @@ -158,7 +158,7 @@ public async Task SearchDocuments_WhenMatchesFound_ReturnsOkWithResults() Id = PairB.Doc.Id, FileName = PairB.Doc.FileName, Status = StatusPending, - CreatedAt = DateTimeOffset.UtcNow + CreatedAt = TimeProvider.System.GetUtcNow() } ]; _service.Setup(s => s.SearchDocumentsAsync(query.Query, query.Limit, TestContext.Current.CancellationToken)) @@ -299,22 +299,22 @@ private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable sou private static class PairA { - private static readonly DocumentBuilder Builder = new DocumentBuilder() + private static readonly DocumentBuilder s_builder = new DocumentBuilder() .WithFileName(FileA) .WithStatus(DocumentStatus.Completed) .WithContent(ContentA); - public static readonly Document Doc = Builder.Build(); - public static readonly DocumentDto Dto = Builder.BuildDto(); + public static readonly Document Doc = s_builder.Build(); + public static readonly DocumentDto Dto = s_builder.BuildDto(); } private static class PairB { - private static readonly DocumentBuilder Builder = new DocumentBuilder() + private static readonly DocumentBuilder s_builder = new DocumentBuilder() .WithFileName(FileB) .WithStatus(DocumentStatus.Pending); - public static readonly Document Doc = Builder.Build(); - public static readonly DocumentDto Dto = Builder.BuildDto(); + public static readonly Document Doc = s_builder.Build(); + public static readonly DocumentDto Dto = s_builder.BuildDto(); } } diff --git a/PaperlessREST.Tests/Unit/ExceptionHandlerTests.cs b/PaperlessREST.Tests/Unit/ExceptionHandlerTests.cs index 16c07dd..02661cc 100644 --- a/PaperlessREST.Tests/Unit/ExceptionHandlerTests.cs +++ b/PaperlessREST.Tests/Unit/ExceptionHandlerTests.cs @@ -231,7 +231,7 @@ public async Task TryHandleAsync_PassesHttpContextToWriter() { // Arrange HttpContext context = _setup.CreateHttpContext(); - Exception exception = new(ExceptionHandlerConstants.TestExceptionMessage); + InvalidOperationException exception = new(ExceptionHandlerConstants.TestExceptionMessage); ProblemDetailsContext? capturedContext = null; _setup.ProblemDetails.Setup(p => p.TryWriteAsync(It.IsAny())) diff --git a/PaperlessREST.Tests/Unit/GenAiResultListenerTests.cs b/PaperlessREST.Tests/Unit/GenAiResultListenerTests.cs index d5d12c9..2c25433 100644 --- a/PaperlessREST.Tests/Unit/GenAiResultListenerTests.cs +++ b/PaperlessREST.Tests/Unit/GenAiResultListenerTests.cs @@ -69,7 +69,7 @@ private static GenAIEvent CreateGenAiEvent( string? summary = ValidSummary, DateTimeOffset? generatedAt = null, string? errorMessage = null) => - new(documentId ?? Guid.CreateVersion7(), summary, generatedAt ?? DateTimeOffset.UtcNow, errorMessage); + new(documentId ?? Guid.CreateVersion7(), summary, generatedAt ?? TimeProvider.System.GetUtcNow(), errorMessage); // ═══════════════════════════════════════════════════════════════ // TESTS: ProcessGenAiEventAsync - Success Path (With Summary) @@ -91,7 +91,7 @@ public async Task ProcessGenAiEventAsync_WithSummary_UpdatesDocumentAndPublishes _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -116,7 +116,7 @@ public async Task ProcessGenAiEventAsync_WithSummary_LogsSuccessfulUpdate() _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -147,7 +147,7 @@ public async Task ProcessGenAiEventAsync_WithSummary_DocumentNotFound_LogsWarnin _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -174,7 +174,7 @@ public async Task ProcessGenAiEventAsync_DocumentNotFound_DoesNotPublishToSse() _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -196,7 +196,7 @@ public async Task ProcessGenAiEventAsync_EmptySummary_LogsWarningPublishesAndAck _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -218,7 +218,7 @@ public async Task ProcessGenAiEventAsync_NullSummary_LogsWarningPublishesAndAcks _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -240,7 +240,7 @@ public async Task ProcessGenAiEventAsync_WhitespaceSummary_TreatedAsEmpty() _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -262,7 +262,7 @@ public async Task ProcessGenAiEventAsync_EmptySummary_LogsWarningWithErrorMessag _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -283,7 +283,7 @@ public async Task ProcessGenAiEventAsync_EmptySummary_NullErrorMessage_LogsUnkno _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -315,7 +315,7 @@ public async Task ProcessGenAiEventAsync_ExceptionThrown_LogsErrorAndNacks() _consumer.Setup(c => c.NackAsync(It.IsAny())).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -339,7 +339,7 @@ public async Task ProcessGenAiEventAsync_ExceptionThrown_DoesNotCallAck() _consumer.Setup(c => c.NackAsync(It.IsAny())).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -363,7 +363,7 @@ public async Task ProcessGenAiEventAsync_ExceptionThrown_LogsError() _consumer.Setup(c => c.NackAsync(It.IsAny())).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -396,7 +396,7 @@ public async Task ProcessGenAiEventAsync_ReceivesEvent_LogsDocumentId() _sseStream.Setup(s => s.Publish(genAiEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - GenAiResultListener sut = CreateSut(); + using GenAiResultListener sut = CreateSut(); // Act await sut.ProcessGenAiEventAsync(genAiEvent, _consumer.Object, TestContext.Current.CancellationToken); diff --git a/PaperlessREST.Tests/Unit/GlobalExceptionHandlerTests.cs b/PaperlessREST.Tests/Unit/GlobalExceptionHandlerTests.cs index b1f1e76..41b0f36 100644 --- a/PaperlessREST.Tests/Unit/GlobalExceptionHandlerTests.cs +++ b/PaperlessREST.Tests/Unit/GlobalExceptionHandlerTests.cs @@ -317,7 +317,8 @@ public async Task TryHandleAsync_LogsWithCorrectLevelAndCode() public async Task TryHandleAsync_UsesActivityIdWhenAvailable() { // Arrange - using Activity activity = new Activity(TestActivityName).Start(); + using var activity = new Activity(TestActivityName); + activity.Start(); DefaultHttpContext httpContext = CreateHttpContext(); SetupProblemDetailsService(); GlobalExceptionHandler sut = CreateSut(); @@ -405,7 +406,7 @@ public sealed class ProblemDetailsEnricherTests : IDisposable private const string ExpectedTimestamp = "2024-11-29T12:00:00.0000000+00:00"; private const string GenericInternalErrorSubstring = "internal error"; - private static readonly DateTimeOffset FixedTime = new(2024, 11, 29, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset s_fixedTime = new(2024, 11, 29, 12, 0, 0, TimeSpan.Zero); private readonly Mock _hostEnvironment; private readonly MockRepository _mocks = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty }; @@ -415,7 +416,7 @@ public ProblemDetailsEnricherTests() { _hostEnvironment = _mocks.Create(); _timeProvider = _mocks.Create(); - _timeProvider.Setup(t => t.GetUtcNow()).Returns(FixedTime); + _timeProvider.Setup(t => t.GetUtcNow()).Returns(s_fixedTime); // Default environment - tests override when needed _hostEnvironment.Setup(e => e.EnvironmentName).Returns(Environments.Production); } @@ -430,7 +431,8 @@ public void Dispose() public void Enrich_WithActivity_UsesActivityId() { // Arrange - using Activity activity = new Activity(TestActivityName).Start(); + using var activity = new Activity(TestActivityName); + activity.Start(); (ProblemDetailsEnricher sut, ProblemDetailsContext context) = CreateSutAndContext(); // Act diff --git a/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs b/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs new file mode 100644 index 0000000..3ec2f2e --- /dev/null +++ b/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs @@ -0,0 +1,259 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using RabbitMQ.Client.Exceptions; + +namespace PaperlessREST.Tests.Unit; + +/// +/// Lifecycle tests for the BackgroundService and +/// overrides. Cover the consumer factory wiring, +/// the consume-loop, and the generic-exception catch branch on the GenAI listener. +/// +/// Completion is signalled via set from a mock callback +/// (per CLAUDE.md): never poll a log snapshot. +/// +/// +/// The OperationInterruptedException "no queue" branch is intentionally not unit-tested: +/// constructing that exception requires RabbitMQ-internal ShutdownEventArgs types that aren't +/// in the test project's surface, and the branch is a one-off shutdown helper — covered +/// operationally on a missing-queue restart, not via fake-broker reproduction here. +/// +/// +public sealed class GenAiResultListenerExecuteAsyncTests : IDisposable +{ + private readonly Mock> _consumer; + private readonly Mock _consumerFactory; + private readonly Mock _documentService; + private readonly FakeLogCollector _logCollector = new(); + private readonly FakeLogger _logger; + private readonly MockRepository _mocks = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty }; + private readonly Mock _scope; + private readonly Mock _scopeFactory; + private readonly Mock _serviceProvider; + private readonly Mock> _sseStream; + + public GenAiResultListenerExecuteAsyncTests() + { + _scopeFactory = _mocks.Create(); + _scope = _mocks.Create(); + _serviceProvider = _mocks.Create(); + _documentService = _mocks.Create(); + _consumerFactory = _mocks.Create(); + _consumer = _mocks.Create>(); + _sseStream = _mocks.Create>(); + _logger = new FakeLogger(_logCollector); + + _scope.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); + _scopeFactory.Setup(f => f.CreateScope()).Returns(_scope.Object); + _scope.Setup(s => s.ServiceProvider).Returns(_serviceProvider.Object); + _serviceProvider.Setup(p => p.GetService(typeof(IDocumentService))).Returns(_documentService.Object); + _consumer.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); + } + + public void Dispose() + { + TestContext.Current.SendDiagnosticMessage("Full logs:\n{0}", _logCollector.GetFullLoggerText()); + } + + private GenAiResultListener CreateSut() => + new(_consumerFactory.Object, _scopeFactory.Object, _sseStream.Object, _logger); + + [Fact] + public async Task ExecuteAsync_HappyPath_LogsStartedConsumesAndStopped() + { + GenAIEvent evt = new(Guid.CreateVersion7(), "summary", TimeProvider.System.GetUtcNow(), null); + TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); + + _consumerFactory.Setup(f => f.CreateConsumerAsync()) + .ReturnsAsync(_consumer.Object); + _consumer.Setup(c => c.ConsumeAsync(It.IsAny())) + .Returns((CancellationToken ct) => ListenerStreams.SingleThenWait(evt, ct)); + _documentService.Setup(s => s.UpdateDocumentSummaryAsync( + evt.DocumentId, evt.Summary!, evt.GeneratedAt, It.IsAny())) + .ReturnsAsync(Result.Updated); + _sseStream.Setup(s => s.Publish(evt)); + _consumer.Setup(c => c.AckAsync()) + .Returns(() => + { + processed.TrySetResult(true); + return Task.CompletedTask; + }); + + using GenAiResultListener sut = CreateSut(); + using CancellationTokenSource cts = new(); + + await sut.StartAsync(cts.Token); + await processed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await cts.CancelAsync(); + await sut.ExecuteTask!; + + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Information && l.Message.Contains("GenAI Result Listener started")); + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Information && l.Message.Contains("GenAI Result Listener stopped")); + } + + [Fact] + public async Task ExecuteAsync_NoQueueOperationInterrupted_LogsWarningAndAwaitsCancellation() + { + // Constructing OperationInterruptedException through its real ctor requires RabbitMQ- + // internal ShutdownEventArgs types not in the test surface. To exercise the listener's + // "no queue" catch arm, we sidestep the ctor with GetUninitializedObject and inject the + // Message field via reflection — the catch's `when (ex.Message.Contains("no queue"))` + // matches purely on text, so a bare exception with the right message is sufficient. + OperationInterruptedException ex = + (OperationInterruptedException)RuntimeHelpers.GetUninitializedObject(typeof(OperationInterruptedException)); + FieldInfo? messageField = typeof(Exception).GetField("_message", BindingFlags.Instance | BindingFlags.NonPublic); + messageField!.SetValue(ex, "NOT_FOUND - no queue 'GenAI'"); + + _consumerFactory.Setup(f => f.CreateConsumerAsync()).ThrowsAsync(ex); + + using GenAiResultListener sut = CreateSut(); + using CancellationTokenSource cts = new(); + + await sut.StartAsync(cts.Token); + + // The listener should now be sleeping in Task.Delay(Timeout.Infinite, stoppingToken) + // inside the catch arm. Wait for the warning, then cancel to unblock. + TimeSpan timeout = TimeSpan.FromSeconds(5); + using CancellationTokenSource waitCts = new(timeout); + while (!waitCts.IsCancellationRequested && + !_logCollector.GetSnapshot().Any(l => + l.Level == LogLevel.Warning && + l.Message.Contains("GenAI Result Listener disabled", StringComparison.OrdinalIgnoreCase))) + { + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + await cts.CancelAsync(); + + Func awaitExecute = async () => await sut.ExecuteTask!; + await awaitExecute.Should().ThrowAsync(); + + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Warning && + l.Message.Contains("GenAI Result Listener disabled", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ExecuteAsync_UnexpectedException_LogsErrorAndRethrows() + { + InvalidOperationException boom = new("broker down"); + _consumerFactory.Setup(f => f.CreateConsumerAsync()) + .ThrowsAsync(boom); + + using GenAiResultListener sut = CreateSut(); + using CancellationTokenSource cts = new(); + + Func startAndAwait = async () => + { + await sut.StartAsync(cts.Token); + await sut.ExecuteTask!; + }; + + await startAndAwait.Should().ThrowAsync().WithMessage("broker down"); + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Error && l.Message.Contains("Unexpected error", StringComparison.OrdinalIgnoreCase)); + } + +} + +public sealed class OcrResultListenerExecuteAsyncTests : IDisposable +{ + private readonly Mock> _consumer; + private readonly Mock _consumerFactory; + private readonly Mock _documentService; + private readonly FakeLogCollector _logCollector = new(); + private readonly FakeLogger _logger; + private readonly MockRepository _mocks = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty }; + private readonly Mock _scope; + private readonly Mock _scopeFactory; + private readonly Mock _serviceProvider; + private readonly Mock> _sseStream; + + public OcrResultListenerExecuteAsyncTests() + { + _scopeFactory = _mocks.Create(); + _scope = _mocks.Create(); + _serviceProvider = _mocks.Create(); + _documentService = _mocks.Create(); + _consumerFactory = _mocks.Create(); + _consumer = _mocks.Create>(); + _sseStream = _mocks.Create>(); + _logger = new FakeLogger(_logCollector); + + _scope.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); + _scopeFactory.Setup(f => f.CreateScope()).Returns(_scope.Object); + _scope.Setup(s => s.ServiceProvider).Returns(_serviceProvider.Object); + _serviceProvider.Setup(p => p.GetService(typeof(IDocumentService))).Returns(_documentService.Object); + _consumer.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); + } + + public void Dispose() + { + TestContext.Current.SendDiagnosticMessage("Full logs:\n{0}", _logCollector.GetFullLoggerText()); + } + + private OcrResultListener CreateSut() => + new(_consumerFactory.Object, _scopeFactory.Object, _sseStream.Object, _logger); + + [Fact] + public async Task ExecuteAsync_HappyPath_LogsStartedConsumesAndStopped() + { + OcrEvent evt = new(Guid.CreateVersion7(), "Completed", "ocr text", TimeProvider.System.GetUtcNow()); + TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); + + _consumerFactory.Setup(f => f.CreateConsumerAsync()) + .ReturnsAsync(_consumer.Object); + _consumer.Setup(c => c.ConsumeAsync(It.IsAny())) + .Returns((CancellationToken ct) => ListenerStreams.SingleThenWait(evt, ct)); + _documentService.Setup(s => s.ProcessOcrResultAsync( + evt.JobId, "Completed", "ocr text", It.IsAny())) + .ReturnsAsync(Result.Updated); + _sseStream.Setup(s => s.Publish(evt)); + _consumer.Setup(c => c.AckAsync()) + .Returns(() => + { + processed.TrySetResult(true); + return Task.CompletedTask; + }); + + using OcrResultListener sut = CreateSut(); + using CancellationTokenSource cts = new(); + + await sut.StartAsync(cts.Token); + await processed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await cts.CancelAsync(); + await sut.ExecuteTask!; + + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Information && l.Message.Contains("OCR Result Listener started")); + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Information && l.Message.Contains("OCR Result Listener stopped")); + } + +} + +/// +/// Helpers for fabricating streams in listener lifecycle tests. +/// Each stream completes cleanly on token cancellation so the consume-loop in +/// exits and the "stopped" log gets emitted. +/// +internal static class ListenerStreams +{ + /// Yields one item, then waits indefinitely; completes cleanly on token cancellation. + public static async IAsyncEnumerable SingleThenWait( + T item, [EnumeratorCancellation] CancellationToken ct = default) + { + yield return item; + + try + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + +} diff --git a/PaperlessREST.Tests/Unit/MappingTests.cs b/PaperlessREST.Tests/Unit/MappingTests.cs index 2449658..bfbaa99 100644 --- a/PaperlessREST.Tests/Unit/MappingTests.cs +++ b/PaperlessREST.Tests/Unit/MappingTests.cs @@ -31,7 +31,7 @@ public void DocumentEntity_ToDocument_MapsAllProperties() // Arrange DocumentEntity entity = DocumentBuilder.Completed(TestOcrContent).WithId(Guid.CreateVersion7()) .WithFileName(TestFileName) - .WithSummary(TestSummary, DateTimeOffset.UtcNow.AddHours(-1)).BuildEntity(); + .WithSummary(TestSummary, TimeProvider.System.GetUtcNow().AddHours(-1)).BuildEntity(); // Act Document document = entity.ToDocument(); diff --git a/PaperlessREST.Tests/Unit/OcrResultListenerTests.cs b/PaperlessREST.Tests/Unit/OcrResultListenerTests.cs index 62a32a7..475e44e 100644 --- a/PaperlessREST.Tests/Unit/OcrResultListenerTests.cs +++ b/PaperlessREST.Tests/Unit/OcrResultListenerTests.cs @@ -69,7 +69,7 @@ private static OcrEvent CreateOcrEvent( Guid? jobId = null, string status = CompletedStatus, string? text = ExtractedContent) => - new(jobId ?? Guid.CreateVersion7(), status, text, DateTimeOffset.UtcNow); + new(jobId ?? Guid.CreateVersion7(), status, text, TimeProvider.System.GetUtcNow()); // ═══════════════════════════════════════════════════════════════ // TESTS: ProcessMessage - Completed Status Success Path @@ -91,7 +91,7 @@ public async Task ProcessMessage_CompletedStatus_ProcessesAndPublishesToSse() _sseStream.Setup(s => s.Publish(ocrEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -115,7 +115,7 @@ public async Task ProcessMessage_CompletedStatus_LogsSuccessfulProcessing() _sseStream.Setup(s => s.Publish(ocrEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -147,7 +147,7 @@ public async Task ProcessMessage_FailedStatus_PassesNullContentToService() _sseStream.Setup(s => s.Publish(ocrEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -184,7 +184,7 @@ public async Task ProcessMessage_NonCompletedStatus_PassesNullContent(string sta _sseStream.Setup(s => s.Publish(ocrEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -208,7 +208,7 @@ public async Task ProcessMessage_CompletedStatusWithNullText_PassesNullContent() _sseStream.Setup(s => s.Publish(ocrEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -235,7 +235,7 @@ public async Task ProcessMessage_ProcessingReturnsError_CallsNackWithoutRequeue( _consumer.Setup(c => c.NackAsync(false)).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -258,7 +258,7 @@ public async Task ProcessMessage_ProcessingReturnsError_DoesNotPublishToSse() _consumer.Setup(c => c.NackAsync(false)).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -287,7 +287,7 @@ public async Task ProcessMessage_ExceptionThrown_CallsNackWithRequeue() _consumer.Setup(c => c.NackAsync(It.IsAny())).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -312,7 +312,7 @@ public async Task ProcessMessage_ExceptionThrown_LogsError() _consumer.Setup(c => c.NackAsync(It.IsAny())).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -339,7 +339,7 @@ public async Task ProcessMessage_ExceptionThrown_DoesNotCallAck() _consumer.Setup(c => c.NackAsync(It.IsAny())).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); @@ -369,7 +369,7 @@ public async Task ProcessMessage_ReceivesEvent_LogsJobIdAndStatus() _sseStream.Setup(s => s.Publish(ocrEvent)); _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrResultListener sut = CreateSut(); + using OcrResultListener sut = CreateSut(); // Act await sut.ProcessMessage(ocrEvent, _consumer.Object, TestContext.Current.CancellationToken); diff --git a/PaperlessREST.Tests/Unit/ReportProcessorTests.cs b/PaperlessREST.Tests/Unit/ReportProcessorTests.cs index 44e37cd..08d5754 100644 --- a/PaperlessREST.Tests/Unit/ReportProcessorTests.cs +++ b/PaperlessREST.Tests/Unit/ReportProcessorTests.cs @@ -49,7 +49,7 @@ public sealed class ReportProcessorTests : IDisposable """; - private static readonly DateOnly ValidDateOnly = new(2024, 1, 15); + private static readonly DateOnly s_validDateOnly = new(2024, 1, 15); private readonly MockFileSystem _fileSystem = new(); private readonly FakeLogCollector _logCollector = new(); private readonly FakeLogger _logger; @@ -446,7 +446,7 @@ public async Task ProcessAsync_ValidDocuments_CallsRepositoryWithCorrectDate() .ReturnsAsync([docId]); _repo.Setup(r => r.UpsertDailyAccessAsync( - ValidDateOnly, + s_validDateOnly, It.IsAny<(Guid DocumentId, long AccessCount)[]>(), It.IsAny())) .Returns(Task.CompletedTask); @@ -459,7 +459,7 @@ public async Task ProcessAsync_ValidDocuments_CallsRepositoryWithCorrectDate() // Assert _repo.Verify( r => r.UpsertDailyAccessAsync( - ValidDateOnly, + s_validDateOnly, It.IsAny<(Guid, long)[]>(), It.IsAny()), Times.Once); diff --git a/PaperlessREST.Tests/Unit/RichProblemDetailsFactoryTests.cs b/PaperlessREST.Tests/Unit/RichProblemDetailsFactoryTests.cs new file mode 100644 index 0000000..368a952 --- /dev/null +++ b/PaperlessREST.Tests/Unit/RichProblemDetailsFactoryTests.cs @@ -0,0 +1,284 @@ +using PaperlessREST.Host.Extensions; + +namespace PaperlessREST.Tests.Unit; + +/// +/// Unit tests for and . +/// Covers every branch, metadata projection (PascalCase → camelCase), and +/// the RFC 7807 extensions wired in for retryAfter / currentState / validation cases. +/// +public sealed class RichProblemDetailsFactoryTests +{ + private const string ResourcePath = "documents/2025-01/abc.pdf"; + private const string RequestPath = "/api/documents/42"; + private const string TraceId = "test-trace-id"; + + public static IEnumerable> ErrorTypeStatusCases() + { + yield return new TheoryDataRow(ErrorType.Failure, StatusCodes.Status500InternalServerError) + .WithTestDisplayName("Failure → 500"); + yield return new TheoryDataRow(ErrorType.Unexpected, StatusCodes.Status503ServiceUnavailable) + .WithTestDisplayName("Unexpected → 503"); + yield return new TheoryDataRow(ErrorType.Validation, StatusCodes.Status422UnprocessableEntity) + .WithTestDisplayName("Validation → 422"); + yield return new TheoryDataRow(ErrorType.Conflict, StatusCodes.Status409Conflict) + .WithTestDisplayName("Conflict → 409"); + yield return new TheoryDataRow(ErrorType.NotFound, StatusCodes.Status404NotFound) + .WithTestDisplayName("NotFound → 404"); + yield return new TheoryDataRow(ErrorType.Unauthorized, StatusCodes.Status401Unauthorized) + .WithTestDisplayName("Unauthorized → 401"); + yield return new TheoryDataRow(ErrorType.Forbidden, StatusCodes.Status403Forbidden) + .WithTestDisplayName("Forbidden → 403"); + } + + [Theory] + [MemberData(nameof(ErrorTypeStatusCases))] + public void CreateFromError_MapsErrorTypeToStatusCode(ErrorType type, int expectedStatus) + { + Error error = Error.Custom((int)type, "Some.Code", "description"); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Status.Should().Be(expectedStatus); + } + + [Fact] + public void CreateFromError_PascalCaseCode_RendersKebabCaseUrn() + { + Error error = Error.NotFound("Document.NotFound", "missing"); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Type.Should().Be("urn:paperless:error:document.-not-found"); + problem.Title.Should().Be("Document.NotFound"); + problem.Detail.Should().Be("missing"); + } + + [Fact] + public void CreateFromError_HttpContextProvided_PopulatesInstanceAndCorrelationId() + { + DefaultHttpContext ctx = new() { TraceIdentifier = TraceId }; + ctx.Request.Path = RequestPath; + Error error = Error.NotFound("Document.NotFound", "missing"); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error, ctx); + + problem.Instance.Should().Be(RequestPath); + problem.Extensions.Should().ContainKey("correlationId").WhoseValue.Should().Be(TraceId); + } + + [Fact] + public void CreateFromError_NoHttpContext_LeavesInstanceNullAndOmitsCorrelationId() + { + Error error = Error.NotFound("Document.NotFound", "missing"); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Instance.Should().BeNull(); + problem.Extensions.Should().NotContainKey("correlationId"); + } + + [Fact] + public void CreateFromError_MetadataKeys_AreCamelCasedIntoExtensions() + { + Error error = Error.Custom( + (int)ErrorType.NotFound, "Document.NotFound", "missing", + new Dictionary { ["DocumentId"] = "abc", ["Suggestion"] = "try-newer" }); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Extensions.Should().ContainKey("documentId").WhoseValue.Should().Be("abc"); + problem.Extensions.Should().ContainKey("suggestion").WhoseValue.Should().Be("try-newer"); + } + + [Fact] + public void CreateFromError_Unexpected_WithRetryAfterMetadata_PreservesValue() + { + Error error = Error.Custom( + (int)ErrorType.Unexpected, "Document.StorageUnavailable", "down", + new Dictionary { ["RetryAfter"] = 60 }); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Status.Should().Be(StatusCodes.Status503ServiceUnavailable); + problem.Extensions.Should().ContainKey("retryAfter").WhoseValue.Should().Be(60); + } + + [Fact] + public void CreateFromError_UnexpectedWithoutRetryAfter_DefaultsTo30Seconds() + { + Error error = Error.Custom((int)ErrorType.Unexpected, "Backend.Down", "no metadata"); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Extensions.Should().ContainKey("retryAfter").WhoseValue.Should().Be(30); + } + + [Fact] + public void CreateFromError_Conflict_WithCurrentStateMetadata_SetsCurrentState() + { + Error error = Error.Custom( + (int)ErrorType.Conflict, "Document.Locked", "locked", + new Dictionary { ["CurrentState"] = "Locked" }); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Status.Should().Be(StatusCodes.Status409Conflict); + problem.Extensions.Should().ContainKey("currentState").WhoseValue.Should().Be("Locked"); + } + + [Fact] + public void CreateFromError_ConflictWithoutCurrentState_OmitsExtension() + { + Error error = Error.Conflict("Document.Locked", "locked"); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + // The error has no metadata at all, so currentState should not appear from the switch branch. + problem.Extensions.Should().NotContainKey("currentState"); + } + + [Fact] + public void CreateFromError_Validation_DoesNotAddRetryAfter() + { + Error error = Error.Validation("Document.Invalid", "bad"); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Status.Should().Be(StatusCodes.Status422UnprocessableEntity); + problem.Extensions.Should().NotContainKey("retryAfter"); + } + + [Fact] + public void CreateFromError_UnsupportedErrorType_ThrowsArgumentOutOfRange() + { + Error error = Error.Custom(999, "Bogus.Type", "bogus"); + + Action act = () => RichProblemDetailsFactory.CreateFromError(error); + + act.Should().Throw(); + } + + [Fact] + public void CreateProblemResult_WrapsCreateFromErrorInProblemHttpResult() + { + Error error = Error.NotFound("Document.NotFound", "missing"); + + ProblemHttpResult result = RichProblemDetailsFactory.CreateProblemResult(error); + + result.StatusCode.Should().Be(StatusCodes.Status404NotFound); + result.ProblemDetails.Title.Should().Be("Document.NotFound"); + } + + [Fact] + public void CreateProblemResult_WithHttpContext_PropagatesExtensions() + { + DefaultHttpContext ctx = new() { TraceIdentifier = TraceId }; + ctx.Request.Path = RequestPath; + Error error = Error.NotFound("Document.NotFound", "missing"); + + ProblemHttpResult result = RichProblemDetailsFactory.CreateProblemResult(error, ctx); + + result.ProblemDetails.Instance.Should().Be(RequestPath); + result.ProblemDetails.Extensions.Should().ContainKey("correlationId"); + } + + // ─── ErrorMetadataExtensions ──────────────────────────────────────────────── + + [Fact] + public void StorageUnavailable_BuildsErrorWithRetryAfterAndAffectedResource() + { + Error error = ErrorMetadataExtensions.StorageUnavailable(ResourcePath, 45); + + error.Type.Should().Be(ErrorType.Unexpected); + error.Code.Should().Be("Document.StorageUnavailable"); + error.Description.Should().Contain(ResourcePath); + error.Metadata.Should().NotBeNull(); + error.Metadata!["RetryAfter"].Should().Be(45); + error.Metadata["AffectedResource"].Should().Be(ResourcePath); + } + + [Fact] + public void StorageUnavailable_DefaultRetryIs30() + { + Error error = ErrorMetadataExtensions.StorageUnavailable(ResourcePath); + + error.Metadata!["RetryAfter"].Should().Be(30); + } + + [Fact] + public void DocumentLocked_BuildsConflictErrorWithLockMetadata() + { + Guid id = Guid.CreateVersion7(); + DateTimeOffset until = DateTimeOffset.UnixEpoch.AddYears(60); + + Error error = ErrorMetadataExtensions.DocumentLocked(id, "alice", until); + + error.Type.Should().Be(ErrorType.Conflict); + error.Code.Should().Be("Document.Locked"); + error.Metadata.Should().NotBeNull(); + error.Metadata!["DocumentId"].Should().Be(id); + error.Metadata["LockedBy"].Should().Be("alice"); + error.Metadata["LockedUntil"].Should().Be(until); + error.Metadata["CurrentState"].Should().Be("Locked"); + } + + [Fact] + public void InvalidField_WithAttemptedValue_IncludesItInMetadata() + { + Error error = ErrorMetadataExtensions.InvalidField("Email", "bad format", "not-an-email"); + + error.Type.Should().Be(ErrorType.Validation); + error.Code.Should().Be("Validation.Email"); + error.Metadata.Should().NotBeNull(); + error.Metadata!["Field"].Should().Be("Email"); + error.Metadata["Reason"].Should().Be("bad format"); + error.Metadata["AttemptedValue"].Should().Be("not-an-email"); + } + + [Fact] + public void InvalidField_WithoutAttemptedValue_OmitsThatKey() + { + Error error = ErrorMetadataExtensions.InvalidField("Email", "bad format"); + + error.Metadata.Should().NotBeNull(); + error.Metadata!.Should().NotContainKey("AttemptedValue"); + } + + [Fact] + public void DocumentNotFound_WithSuggestion_IncludesIt() + { + Guid id = Guid.CreateVersion7(); + + Error error = ErrorMetadataExtensions.DocumentNotFound(id, "use newer id"); + + error.Type.Should().Be(ErrorType.NotFound); + error.Code.Should().Be("Document.NotFound"); + error.Metadata.Should().NotBeNull(); + error.Metadata!["DocumentId"].Should().Be(id); + error.Metadata.Should().ContainKey("SearchedAt"); + error.Metadata["Suggestion"].Should().Be("use newer id"); + } + + [Fact] + public void DocumentNotFound_WithoutSuggestion_OmitsThatKey() + { + Error error = ErrorMetadataExtensions.DocumentNotFound(Guid.CreateVersion7()); + + error.Metadata.Should().NotBeNull(); + error.Metadata!.Should().NotContainKey("Suggestion"); + error.Metadata.Should().ContainKey("SearchedAt"); + } + + [Fact] + public void CreateFromError_MetadataFromExtensionHelper_PropagatesCorrectly() + { + Error error = ErrorMetadataExtensions.StorageUnavailable(ResourcePath, 90); + + ProblemDetails problem = RichProblemDetailsFactory.CreateFromError(error); + + problem.Status.Should().Be(StatusCodes.Status503ServiceUnavailable); + problem.Extensions.Should().ContainKey("retryAfter").WhoseValue.Should().Be(90); + problem.Extensions.Should().ContainKey("affectedResource").WhoseValue.Should().Be(ResourcePath); + } +} diff --git a/PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs b/PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs new file mode 100644 index 0000000..32b7c48 --- /dev/null +++ b/PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs @@ -0,0 +1,306 @@ +using PaperlessREST.Host.Extensions; + +namespace PaperlessREST.Tests.Unit; + +/// +/// Unit tests for , the bridge between +/// service results and ASP.NET typed unions. +/// Exercises every result-shape (Ok / Validation / Failure / Unexpected / NotFound / Deleted) plus +/// the contract-violation throw paths for unexpected error types. +/// +public sealed class TypedErrorOrAsyncExtensionsTests +{ + private const string RouteName = "GetDocumentById"; + + private sealed record Doc(Guid Id, string FileName); + + private sealed record DocResponse(string Id, string FileName); + + private static DocResponse Map(Doc d) => new(d.Id.ToString(), d.FileName); + + private static object RouteValues(Doc d) => new { id = d.Id }; + + // ─── ErrorOr.ToOkOr404 (sync overload) ─────────────────────────────── + + [Fact] + public void ToOkOr404_Sync_SuccessfulResult_ReturnsOk() + { + Doc doc = new(Guid.CreateVersion7(), "f.pdf"); + ErrorOr result = doc; + + Results, NotFound> output = result.ToOkOr404(Map); + + output.Result.Should().BeOfType>() + .Which.Value!.FileName.Should().Be("f.pdf"); + } + + [Fact] + public void ToOkOr404_Sync_NotFound_ReturnsNotFound() + { + ErrorOr result = Error.NotFound("Doc.NotFound", "missing"); + + Results, NotFound> output = result.ToOkOr404(Map); + + output.Result.Should().BeOfType(); + } + + [Fact] + public void ToOkOr404_Sync_UnexpectedErrorType_ThrowsContractViolation() + { + ErrorOr result = Error.Conflict("Doc.Locked", "locked"); + + Action act = () => _ = result.ToOkOr404(Map, "GetDocumentById"); + + act.Should().Throw() + .Which.ExpectedErrorTypes.Should().Equal(ErrorType.NotFound); + } + + // ─── ErrorOr.ToNoContentOr404 (sync overload) ─────────────────── + + [Fact] + public void ToNoContentOr404_Sync_Success_ReturnsNoContent() + { + ErrorOr result = Result.Deleted; + + Results output = result.ToNoContentOr404(); + + output.Result.Should().BeOfType(); + } + + [Fact] + public void ToNoContentOr404_Sync_NotFound_ReturnsNotFound() + { + ErrorOr result = Error.NotFound("Doc.NotFound", "missing"); + + Results output = result.ToNoContentOr404(); + + output.Result.Should().BeOfType(); + } + + [Fact] + public void ToNoContentOr404_Sync_UnexpectedErrorType_ThrowsContractViolation() + { + ErrorOr result = Error.Conflict("Doc.Locked", "locked"); + + Action act = () => _ = result.ToNoContentOr404("Delete"); + + act.Should().Throw(); + } + + // ─── ValueTask>.ToOkOr404 (async overload) ──────────────────── + + [Fact] + public async Task ToOkOr404_ValueTask_Success_ReturnsOk() + { + Doc doc = new(Guid.CreateVersion7(), "vf.pdf"); + ValueTask> task = new(ErrorOrFactory.From(doc)); + + Results, NotFound> output = await task.ToOkOr404(Map); + + output.Result.Should().BeOfType>() + .Which.Value!.FileName.Should().Be("vf.pdf"); + } + + [Fact] + public async Task ToOkOr404_ValueTask_NotFound_ReturnsNotFound() + { + ValueTask> task = new(Error.NotFound("Doc.NotFound", "missing")); + + Results, NotFound> output = await task.ToOkOr404(Map); + + output.Result.Should().BeOfType(); + } + + // ─── ValueTask>.ToNoContentOr404 (async overload) ────── + + [Fact] + public async Task ToNoContentOr404_ValueTask_Success_ReturnsNoContent() + { + ValueTask> task = new(ErrorOrFactory.From(Result.Deleted)); + + Results output = await task.ToNoContentOr404(); + + output.Result.Should().BeOfType(); + } + + [Fact] + public async Task ToNoContentOr404_ValueTask_NotFound_ReturnsNotFound() + { + ValueTask> task = new(Error.NotFound("Doc.NotFound", "missing")); + + Results output = await task.ToNoContentOr404(); + + output.Result.Should().BeOfType(); + } + + // ─── Task>.ToOkOr404 (Task overload — delegates to ValueTask) ─ + + [Fact] + public async Task ToOkOr404_Task_Success_ReturnsOk() + { + Doc doc = new(Guid.CreateVersion7(), "tf.pdf"); + Task> task = Task.FromResult>(doc); + + Results, NotFound> output = await task.ToOkOr404(Map); + + output.Result.Should().BeOfType>(); + } + + [Fact] + public async Task ToNoContentOr404_Task_Success_ReturnsNoContent() + { + Task> task = Task.FromResult>(Result.Deleted); + + Results output = await task.ToNoContentOr404(); + + output.Result.Should().BeOfType(); + } + + // ─── ToAcceptedAtRouteOrProblem ───────────────────────────────────────── + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Success_ReturnsAcceptedAtRoute() + { + Doc doc = new(Guid.CreateVersion7(), "accepted.pdf"); + ValueTask> task = new(ErrorOrFactory.From(doc)); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + AcceptedAtRoute? accepted = output.Result as AcceptedAtRoute; + accepted.Should().NotBeNull(); + accepted!.RouteName.Should().Be(RouteName); + accepted.Value!.FileName.Should().Be("accepted.pdf"); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_ValidationError_Returns422WithGroupedErrors() + { + Error[] errors = + [ + Error.Validation("FileName", "is required"), + Error.Validation("FileName", "must be PDF"), + Error.Validation("FileSize", "too large") + ]; + ValueTask> task = new(errors); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + ValidationProblem? vp = output.Result as ValidationProblem; + vp.Should().NotBeNull(); + HttpValidationProblemDetails details = vp!.ProblemDetails; + details.Errors.Should().ContainKey("FileName"); + details.Errors["FileName"].Should().BeEquivalentTo(["is required", "must be PDF"]); + details.Errors.Should().ContainKey("FileSize"); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Failure_Returns500WithKebabCaseUrn() + { + Error failure = Error.Failure("Document.UploadFailed", "storage broke"); + ValueTask> task = new(failure); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + ProblemHttpResult? problem = output.Result as ProblemHttpResult; + problem.Should().NotBeNull(); + problem!.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + problem.ProblemDetails.Title.Should().Be("Document.UploadFailed"); + problem.ProblemDetails.Type.Should().Be("urn:paperless:error:document.-upload-failed"); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_FailureWithMetadata_PropagatesAsCamelCasedExtensions() + { + Error failure = Error.Custom( + (int)ErrorType.Failure, "Document.UploadFailed", "storage broke", + new Dictionary { ["AttemptCount"] = 3, ["LastTriedAt"] = "2026-05-15" }); + ValueTask> task = new(failure); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + ProblemHttpResult problem = (ProblemHttpResult)output.Result!; + problem.ProblemDetails.Extensions.Should().ContainKey("attemptCount").WhoseValue.Should().Be(3); + problem.ProblemDetails.Extensions.Should().ContainKey("lastTriedAt").WhoseValue.Should().Be("2026-05-15"); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Unexpected_Returns503WithRetryAfterFromMetadata() + { + Error unavailable = Error.Custom( + (int)ErrorType.Unexpected, "Document.StorageUnavailable", "down", + new Dictionary { ["RetryAfter"] = 90, ["AffectedResource"] = "docs/x" }); + ValueTask> task = new(unavailable); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + ProblemHttpResult problem = (ProblemHttpResult)output.Result!; + problem.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + problem.ProblemDetails.Extensions["retryAfter"].Should().Be(90); + problem.ProblemDetails.Extensions.Should().ContainKey("affectedResource"); + problem.ProblemDetails.Extensions["affectedResource"].Should().Be("docs/x"); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_UnexpectedWithoutMetadata_DefaultsRetryAfterTo30() + { + Error unavailable = Error.Unexpected("Backend.Down", "no metadata"); + ValueTask> task = new(unavailable); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + ProblemHttpResult problem = (ProblemHttpResult)output.Result!; + problem.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + problem.ProblemDetails.Extensions["retryAfter"].Should().Be(30); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_UnexpectedRetryAfterOnly_OmitsOtherExtensions() + { + // Exercises BuildServiceUnavailableExtensions early return when only RetryAfter is set + // (the "Where(kvp => kvp.Key != "RetryAfter")" filter yields nothing). + Error unavailable = Error.Custom( + (int)ErrorType.Unexpected, "Backend.Down", "only retry", + new Dictionary { ["RetryAfter"] = 5 }); + ValueTask> task = new(unavailable); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + ProblemHttpResult problem = (ProblemHttpResult)output.Result!; + problem.ProblemDetails.Extensions.Should().ContainKey("retryAfter"); + problem.ProblemDetails.Extensions.Should().NotContainKey("affectedResource"); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_UnsupportedErrorType_ThrowsContractViolation() + { + ValueTask> task = new(Error.NotFound("Doc.NotFound", "missing")); + + Func act = async () => _ = await task.ToAcceptedAtRouteOrProblem( + Map, RouteName, RouteValues, "PostDocument"); + + (await act.Should().ThrowAsync()) + .Which.ExpectedErrorTypes.Should().Equal( + ErrorType.Validation, ErrorType.Failure, ErrorType.Unexpected); + } + + // ─── Task overload also routes through the same internals ────────────── + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Task_Success_ReturnsAcceptedAtRoute() + { + Doc doc = new(Guid.CreateVersion7(), "task-success.pdf"); + Task> task = Task.FromResult>(doc); + + Results, ValidationProblem, ProblemHttpResult> output = + await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + + output.Result.Should().BeOfType>(); + } +} diff --git a/PaperlessREST/Configuration/ElasticsearchOptions.cs b/PaperlessREST/Configuration/ElasticsearchOptions.cs index 877166c..9586b09 100644 --- a/PaperlessREST/Configuration/ElasticsearchOptions.cs +++ b/PaperlessREST/Configuration/ElasticsearchOptions.cs @@ -11,17 +11,3 @@ public sealed record ElasticsearchOptions [Required(ErrorMessage = $"{SectionName}:DefaultIndex is required")] public required string DefaultIndex { get; init; } } - -public static class ElasticsearchOptionsExtensions -{ - extension(ElasticsearchOptions opts) - { - /// - /// Creates a configured ElasticsearchClient instance. - /// - public ElasticsearchClient CreateClient() => - new(new ElasticsearchClientSettings(opts.Uri) - .DefaultIndex(opts.DefaultIndex) - .ThrowExceptions()); - } -} diff --git a/PaperlessREST/Configuration/MinioOptions.cs b/PaperlessREST/Configuration/MinioOptions.cs index 47bd494..6c321eb 100644 --- a/PaperlessREST/Configuration/MinioOptions.cs +++ b/PaperlessREST/Configuration/MinioOptions.cs @@ -25,27 +25,17 @@ public static class MinioOptionsExtensions extension(MinioOptions opts) { /// - /// Parses the Endpoint string into a Uri, adding http(s):// scheme if missing. + /// Parses the Endpoint string into a Uri, adding http(s):// scheme if missing. /// public Uri EndpointUri { get { - var endpoint = opts.Endpoint.Contains("://") + string endpoint = opts.Endpoint.Contains("://", StringComparison.Ordinal) ? opts.Endpoint : $"{(opts.UseSsl ? "https" : "http")}://{opts.Endpoint}"; return new Uri(endpoint); } } - - /// - /// Creates a configured MinioClient instance. - /// - public IMinioClient CreateClient() => - new MinioClient() - .WithEndpoint(opts.EndpointUri.Host, opts.EndpointUri.Port) - .WithCredentials(opts.AccessKey, opts.SecretKey) - .WithSSL(opts.UseSsl) - .Build(); } } diff --git a/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs b/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs index 455989f..32dcafb 100644 --- a/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs +++ b/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs @@ -57,7 +57,8 @@ private XmlSchemaSet LoadSchemas() string schemaPath = fs.Path.Combine(AppContext.BaseDirectory, "Schemas", SchemaFileName); using Stream schemaStream = fs.FileStream.New(schemaPath, FileMode.Open, FileAccess.Read, FileShare.Read); - schemas.Add("", XmlReader.Create(schemaStream)); + using XmlReader schemaReader = XmlReader.Create(schemaStream); + schemas.Add("", schemaReader); schemas.Compile(); return schemas; } diff --git a/PaperlessREST/Features/DocumentManagement/Presentation/Endpoints/DocumentEndpoints.cs b/PaperlessREST/Features/DocumentManagement/Presentation/Endpoints/DocumentEndpoints.cs index 24dc1f5..b0bd226 100644 --- a/PaperlessREST/Features/DocumentManagement/Presentation/Endpoints/DocumentEndpoints.cs +++ b/PaperlessREST/Features/DocumentManagement/Presentation/Endpoints/DocumentEndpoints.cs @@ -162,12 +162,14 @@ public static async Task>> SearchDocuments( /// Document with the specified ID does not exist. /// Domain error: DocumentErrors.NotFound /// - public static Task, NotFound>> GetDocumentById( + public static async Task, NotFound>> GetDocumentById( Guid id, IDocumentService documentService, - CancellationToken cancellationToken) => - documentService.GetDocumentByIdAsync(id, cancellationToken) - .ToOkOr404(doc => doc.ToDocumentDto()); + CancellationToken cancellationToken) + { + ErrorOr result = await documentService.GetDocumentByIdAsync(id, cancellationToken); + return result.ToOkOr404(doc => doc.ToDocumentDto()); + } /// /// Retrieves the AI-generated summary for a document. @@ -185,12 +187,14 @@ public static Task, NotFound>> GetDocumentById( /// Document with the specified ID does not exist. /// Domain error: DocumentErrors.NotFound /// - public static Task, NotFound>> GetSummary( + public static async Task, NotFound>> GetSummary( Guid id, IDocumentService documentService, - CancellationToken cancellationToken) => - documentService.GetDocumentByIdAsync(id, cancellationToken) - .ToOkOr404(doc => new SummaryDto { Summary = doc.Summary }); + CancellationToken cancellationToken) + { + ErrorOr result = await documentService.GetDocumentByIdAsync(id, cancellationToken); + return result.ToOkOr404(doc => new SummaryDto { Summary = doc.Summary }); + } // ═══════════════════════════════════════════════════════════════════════════ // Commands - use ToAcceptedAtRouteOrProblem / ToNoContentOr404 diff --git a/PaperlessREST/Host/Extensions/RichProblemDetailsFactory.cs b/PaperlessREST/Host/Extensions/RichProblemDetailsFactory.cs index 217d55a..c2a339e 100644 --- a/PaperlessREST/Host/Extensions/RichProblemDetailsFactory.cs +++ b/PaperlessREST/Host/Extensions/RichProblemDetailsFactory.cs @@ -174,7 +174,11 @@ public static Error InvalidField(string fieldName, string reason, object? attemp /// public static Error DocumentNotFound(Guid id, string? suggestion = null) { - Dictionary metadata = new() { ["DocumentId"] = id, ["SearchedAt"] = DateTimeOffset.UtcNow }; + Dictionary metadata = new() + { + ["DocumentId"] = id, + ["SearchedAt"] = TimeProvider.System.GetUtcNow() + }; if (suggestion is not null) { diff --git a/PaperlessREST/Host/Extensions/ServiceCollectionExtensions.cs b/PaperlessREST/Host/Extensions/ServiceCollectionExtensions.cs index 7f57ab0..791d34a 100644 --- a/PaperlessREST/Host/Extensions/ServiceCollectionExtensions.cs +++ b/PaperlessREST/Host/Extensions/ServiceCollectionExtensions.cs @@ -240,9 +240,15 @@ private IServiceCollection AddObjectStorage() .BindConfiguration(MinioOptions.SectionName) .ValidateDataAnnotations(); - // Use extension method from MinioOptionsExtensions services.AddSingleton(sp => - sp.GetRequiredService>().Value.CreateClient()); + { + MinioOptions opts = sp.GetRequiredService>().Value; + return new MinioClient() + .WithEndpoint(opts.EndpointUri.Host, opts.EndpointUri.Port) + .WithCredentials(opts.AccessKey, opts.SecretKey) + .WithSSL(opts.UseSsl) + .Build(); + }); return services; } @@ -254,7 +260,13 @@ private IServiceCollection AddSearchEngine() .ValidateDataAnnotations(); services.AddSingleton(sp => - sp.GetRequiredService>().Value.CreateClient()); + { + ElasticsearchOptions opts = sp.GetRequiredService>().Value; + return new ElasticsearchClient( + new ElasticsearchClientSettings(opts.Uri) + .DefaultIndex(opts.DefaultIndex) + .ThrowExceptions()); + }); return services; } diff --git a/PaperlessREST/Host/Extensions/TypedErrorOrAsyncExtensions.cs b/PaperlessREST/Host/Extensions/TypedErrorOrAsyncExtensions.cs index b3705ae..c8859ba 100644 --- a/PaperlessREST/Host/Extensions/TypedErrorOrAsyncExtensions.cs +++ b/PaperlessREST/Host/Extensions/TypedErrorOrAsyncExtensions.cs @@ -58,18 +58,16 @@ private static string ToKebabCase(string value) => // ValueTask> Extensions // ═══════════════════════════════════════════════════════════════════════════ - extension(ValueTask> task) + extension(ErrorOr result) { /// /// Converts to (200) or (404). /// [MustUseReturnValue] - public async Task, NotFound>> ToOkOr404( + public Results, NotFound> ToOkOr404( [InstantHandle] Func mapper, [CallerMemberName] string callerName = "") { - ErrorOr result = await task.ConfigureAwait(false); - if (!result.IsError) { return TypedResults.Ok(mapper(result.Value)); @@ -79,6 +77,40 @@ public async Task, NotFound>> ToOkOr404( ? TypedResults.NotFound() : throw ContractViolationException.ForNotFoundOnly(result.FirstError, result.Errors, callerName); } + } + + extension(ErrorOr result) + { + /// + /// Converts to (204) or (404). + /// + [MustUseReturnValue] + public Results ToNoContentOr404([CallerMemberName] string callerName = "") + { + if (!result.IsError) + { + return TypedResults.NoContent(); + } + + return result.FirstError.Type == ErrorType.NotFound + ? TypedResults.NotFound() + : throw ContractViolationException.ForNotFoundOnly(result.FirstError, result.Errors, callerName); + } + } + + extension(ValueTask> task) + { + /// + /// Converts to (200) or (404). + /// + [MustUseReturnValue] + public async Task, NotFound>> ToOkOr404( + [InstantHandle] Func mapper, + [CallerMemberName] string callerName = "") + { + ErrorOr result = await task.ConfigureAwait(false); + return result.ToOkOr404(mapper, callerName); + } /// /// Converts to (202), @@ -120,15 +152,7 @@ public async Task, ValidationProblem, ProblemHt public async Task> ToNoContentOr404([CallerMemberName] string callerName = "") { ErrorOr result = await task.ConfigureAwait(false); - - if (!result.IsError) - { - return TypedResults.NoContent(); - } - - return result.FirstError.Type == ErrorType.NotFound - ? TypedResults.NotFound() - : throw ContractViolationException.ForNotFoundOnly(result.FirstError, result.Errors, callerName); + return result.ToNoContentOr404(callerName); } } diff --git a/PaperlessServices.Tests/GlobalUsings.cs b/PaperlessServices.Tests/GlobalUsings.cs index d969c52..6dd0eb5 100644 --- a/PaperlessServices.Tests/GlobalUsings.cs +++ b/PaperlessServices.Tests/GlobalUsings.cs @@ -1,5 +1,6 @@ // System namespaces +global using System.Diagnostics.CodeAnalysis; global using System.Text.Json; // Microsoft namespaces diff --git a/PaperlessServices.Tests/Integration/FakeTextSummarizer.cs b/PaperlessServices.Tests/Integration/FakeTextSummarizer.cs index fccaf4b..341012e 100644 --- a/PaperlessServices.Tests/Integration/FakeTextSummarizer.cs +++ b/PaperlessServices.Tests/Integration/FakeTextSummarizer.cs @@ -6,7 +6,6 @@ internal sealed class FakeTextSummarizer : ITextSummarizer public Task SummarizeAsync(string text, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(text); cancellationToken.ThrowIfCancellationRequested(); string preview = text.Length <= 64 ? text : text[..64] + "…"; diff --git a/PaperlessServices.Tests/Integration/OcrIntegrationTests.cs b/PaperlessServices.Tests/Integration/OcrIntegrationTests.cs index 3eac3d3..5a9974e 100644 --- a/PaperlessServices.Tests/Integration/OcrIntegrationTests.cs +++ b/PaperlessServices.Tests/Integration/OcrIntegrationTests.cs @@ -10,7 +10,7 @@ public async Task ExtractsInvoiceNumber() { // Arrange string storagePath = await fixture.UploadPdfAsync("INV-2024-001"); - OcrCommand command = new(Guid.NewGuid(), "invoice.pdf", storagePath, DateTimeOffset.UtcNow.AddMinutes(-5)); + OcrCommand command = new(Guid.NewGuid(), "invoice.pdf", storagePath, TimeProvider.System.GetUtcNow().AddMinutes(-5)); // Act ErrorOr errorOrResult = @@ -32,7 +32,7 @@ public async Task ProcessMultipleDocuments_Concurrently() IEnumerable>> tasks = Enumerable.Range(1, 3).Select(async i => { string path = await fixture.UploadPdfAsync($"Document {i} content. Amount: ${i * 1000:N2}"); - OcrCommand command = new(Guid.NewGuid(), $"doc-{i}.pdf", path, DateTimeOffset.UtcNow.AddMinutes(-5)); + OcrCommand command = new(Guid.NewGuid(), $"doc-{i}.pdf", path, TimeProvider.System.GetUtcNow().AddMinutes(-5)); return await OcrProcessor.ProcessDocumentAsync(command, TestContext.Current.CancellationToken); }); @@ -55,10 +55,10 @@ public class GenAiIntegrationTests(SharedContainerFixture fixture, ITestOutputHe [Fact] public async Task SummarizesFinancialReport() { - const string report = "Q3 2024 Report: Revenue $4.2M (+28% YoY), Expenses $2.1M (+12%)"; + const string Report = "Q3 2024 Report: Revenue $4.2M (+28% YoY), Expenses $2.1M (+12%)"; string? summary = await TextSummarizer.SummarizeAsync( - report, + Report, TestContext.Current.CancellationToken); summary.Should().NotBeNullOrWhiteSpace(); diff --git a/PaperlessServices.Tests/Integration/SearchIndexIntegrationTests.cs b/PaperlessServices.Tests/Integration/SearchIndexIntegrationTests.cs index 146bf3c..99c1c2a 100644 --- a/PaperlessServices.Tests/Integration/SearchIndexIntegrationTests.cs +++ b/PaperlessServices.Tests/Integration/SearchIndexIntegrationTests.cs @@ -13,12 +13,12 @@ public async Task IndexDocument_StoresInElasticsearch() { // Arrange Guid id = Guid.NewGuid(); - const string fileName = "test.pdf"; - const string content = "Test content"; - await fixture.UploadPdfAsync(content); + const string FileName = "test.pdf"; + const string Content = "Test content"; + await fixture.UploadPdfAsync(Content); // Act - await SearchIndex.IndexDocumentAsync(id, fileName, content, DateTimeOffset.UtcNow.AddMinutes(-5), + await SearchIndex.IndexDocumentAsync(id, FileName, Content, TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - poll until indexed @@ -27,8 +27,8 @@ await SearchIndex.IndexDocumentAsync(id, fileName, content, DateTimeOffset.UtcNo TestContext.Current.CancellationToken); response.IsSuccess().Should().BeTrue(); - response.Source.GetProperty("fileName").GetString().Should().Be(fileName); - response.Source.GetProperty("content").GetString().Should().Be(content); + response.Source.GetProperty("fileName").GetString().Should().Be(FileName); + response.Source.GetProperty("content").GetString().Should().Be(Content); } [Fact] @@ -39,7 +39,7 @@ public async Task HelloWorldPdf_SearchableByHello() await fixture.UploadPdfAsync("Hello World!"); // Act - await SearchIndex.IndexDocumentAsync(id, "HelloWorld.pdf", "Hello World!", DateTimeOffset.UtcNow.AddMinutes(-5), + await SearchIndex.IndexDocumentAsync(id, "HelloWorld.pdf", "Hello World!", TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - poll until searchable @@ -58,7 +58,7 @@ public async Task OcrProcessor_IndexesDocument() // Arrange Guid jobId = Guid.NewGuid(); string storagePath = await fixture.UploadPdfAsync("Hello World!"); - OcrCommand command = new(jobId, "HelloWorld.pdf", storagePath, DateTimeOffset.UtcNow.AddMinutes(-5)); + OcrCommand command = new(jobId, "HelloWorld.pdf", storagePath, TimeProvider.System.GetUtcNow().AddMinutes(-5)); // Act ErrorOr errorOrResult = await OcrProcessor.ProcessDocumentAsync(command, @@ -95,11 +95,11 @@ public async Task MultipleDocuments_SearchCorrectly() Guid testId = Guid.NewGuid(); await SearchIndex.IndexDocumentAsync(helloId, "HelloWorld.pdf", "Hello World", - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); await SearchIndex.IndexDocumentAsync(testId, "TestDoc.pdf", "Test document", - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Act & Assert - Hello document @@ -135,7 +135,7 @@ public async Task IndexDocument_LogsActivity() await fixture.UploadPdfAsync("Test"); // Act - await SearchIndex.IndexDocumentAsync(id, "test.pdf", "Test", DateTimeOffset.UtcNow.AddMinutes(-5), + await SearchIndex.IndexDocumentAsync(id, "test.pdf", "Test", TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert diff --git a/PaperlessServices.Tests/Integration/WorkerTestBase.cs b/PaperlessServices.Tests/Integration/WorkerTestBase.cs index 27e0d74..4de2ea7 100644 --- a/PaperlessServices.Tests/Integration/WorkerTestBase.cs +++ b/PaperlessServices.Tests/Integration/WorkerTestBase.cs @@ -67,11 +67,11 @@ await Task.WhenAll( string minioEndpoint = $"{_minio.Hostname}:{_minio.GetMappedPublicPort(MinioPort)}"; - IMinioClient minioClient = new MinioClient() + using MinioClient minioClient = new(); + minioClient .WithEndpoint(minioEndpoint) .WithCredentials(_minio.GetAccessKey(), _minio.GetSecretKey()) .Build(); - await minioClient.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucketName)); HostApplicationBuilder builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); @@ -125,7 +125,7 @@ public async Task UploadPdfAsync(string content) string fileName = $"test-{Guid.NewGuid():N}.pdf"; string pdfPath = await Pdf.Create(Dye.White).AddText(content).SaveAsync(fileName); - string storageKey = $"documents/{DateTime.UtcNow:yyyy-MM}/{Guid.NewGuid():N}/{fileName}"; + string storageKey = $"documents/{TimeProvider.System.GetUtcNow():yyyy-MM}/{Guid.NewGuid():N}/{fileName}"; IMinioClient client = Services.GetRequiredService(); await using FileStream stream = File.OpenRead(pdfPath); diff --git a/PaperlessServices.Tests/Unit/OcrProcessorTests.cs b/PaperlessServices.Tests/Unit/OcrProcessorTests.cs index ca4d7e0..1d61b69 100644 --- a/PaperlessServices.Tests/Unit/OcrProcessorTests.cs +++ b/PaperlessServices.Tests/Unit/OcrProcessorTests.cs @@ -9,7 +9,7 @@ public sealed class OcrProcessorTests : IDisposable private const string ValidFileName = "document.pdf"; private const string ValidStoragePath = "documents/2024-01/abc123.pdf"; private const string ExtractedOcrText = "This is the extracted text from the PDF document."; - private static readonly Guid TestJobId = Guid.Parse("12345678-1234-1234-1234-123456789abc"); + private static readonly Guid s_testJobId = Guid.Parse("12345678-1234-1234-1234-123456789abc"); private readonly FakeLogCollector _logCollector = new(); private readonly FakeLogger _logger; @@ -50,7 +50,7 @@ private static OcrCommand CreateCommand( string fileName = ValidFileName, string storagePath = ValidStoragePath, DateTimeOffset? createdAt = null) => - new(jobId ?? TestJobId, fileName, storagePath, createdAt ?? DateTimeOffset.UtcNow.AddMinutes(-5)); + new(jobId ?? s_testJobId, fileName, storagePath, createdAt ?? TimeProvider.System.GetUtcNow().AddMinutes(-5)); private static MemoryStream CreateValidPdfStream() => new([0x25, 0x50, 0x44, 0x46]); // %PDF header @@ -69,7 +69,7 @@ public async Task ProcessDocumentAsync_ValidCommand_DownloadsFromStorage() _pdfExtractor.Setup(p => p.ExtractTextAsync(It.IsAny())) .ReturnsAsync(ExtractedOcrText); _searchIndex.Setup(i => i.IndexDocumentAsync( - TestJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) + s_testJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); OcrProcessor sut = CreateSut(); @@ -91,7 +91,7 @@ public async Task ProcessDocumentAsync_ValidCommand_ExtractsTextWithOcr() _pdfExtractor.Setup(p => p.ExtractTextAsync(It.IsAny())) .ReturnsAsync(ExtractedOcrText); _searchIndex.Setup(i => i.IndexDocumentAsync( - TestJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) + s_testJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); OcrProcessor sut = CreateSut(); @@ -113,7 +113,7 @@ public async Task ProcessDocumentAsync_ValidCommand_IndexesDocumentInSearch() _pdfExtractor.Setup(p => p.ExtractTextAsync(It.IsAny())) .ReturnsAsync(ExtractedOcrText); _searchIndex.Setup(i => i.IndexDocumentAsync( - TestJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) + s_testJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); OcrProcessor sut = CreateSut(); @@ -135,7 +135,7 @@ public async Task ProcessDocumentAsync_ValidCommand_ReturnsCompletedOcrEvent() _pdfExtractor.Setup(p => p.ExtractTextAsync(It.IsAny())) .ReturnsAsync(ExtractedOcrText); _searchIndex.Setup(i => i.IndexDocumentAsync( - TestJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) + s_testJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); OcrProcessor sut = CreateSut(); @@ -146,7 +146,7 @@ public async Task ProcessDocumentAsync_ValidCommand_ReturnsCompletedOcrEvent() // Assert result.IsError.Should().BeFalse(); - result.Value.JobId.Should().Be(TestJobId); + result.Value.JobId.Should().Be(s_testJobId); result.Value.Status.Should().Be("Completed"); result.Value.Text.Should().Be(ExtractedOcrText); } @@ -166,7 +166,7 @@ public async Task ProcessDocumentAsync_Success_LogsInformation() _pdfExtractor.Setup(p => p.ExtractTextAsync(It.IsAny())) .ReturnsAsync(ExtractedOcrText); _searchIndex.Setup(i => i.IndexDocumentAsync( - TestJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) + s_testJobId, ValidFileName, ExtractedOcrText, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); OcrProcessor sut = CreateSut(); @@ -333,7 +333,7 @@ public async Task ProcessDocumentAsync_EmptyExtractedText_StillReturnsSuccess() _pdfExtractor.Setup(p => p.ExtractTextAsync(It.IsAny())) .ReturnsAsync(string.Empty); _searchIndex.Setup(i => i.IndexDocumentAsync( - TestJobId, ValidFileName, string.Empty, It.IsAny(), It.IsAny())) + s_testJobId, ValidFileName, string.Empty, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); OcrProcessor sut = CreateSut(); @@ -360,7 +360,7 @@ public async Task ProcessDocumentAsync_LargeExtractedText_HandlesCorrectly() _pdfExtractor.Setup(p => p.ExtractTextAsync(It.IsAny())) .ReturnsAsync(largeText); _searchIndex.Setup(i => i.IndexDocumentAsync( - TestJobId, ValidFileName, largeText, It.IsAny(), It.IsAny())) + s_testJobId, ValidFileName, largeText, It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); OcrProcessor sut = CreateSut(); diff --git a/PaperlessServices.Tests/Unit/OcrWorkerTests.cs b/PaperlessServices.Tests/Unit/OcrWorkerTests.cs index 90888f4..619defd 100644 --- a/PaperlessServices.Tests/Unit/OcrWorkerTests.cs +++ b/PaperlessServices.Tests/Unit/OcrWorkerTests.cs @@ -74,7 +74,7 @@ public async Task SuccessfulOcr_AcknowledgesMessage() _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); } @@ -96,7 +96,7 @@ public async Task SuccessfulOcr_LogsCompletion() _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); @@ -123,7 +123,7 @@ public async Task SuccessfulOcr_LogsProcessingStart() _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); @@ -148,7 +148,7 @@ public async Task OcrFails_AcknowledgesMessage() _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); } @@ -168,7 +168,7 @@ public async Task OcrFails_LogsWarning() _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); @@ -193,7 +193,7 @@ public async Task OcrFails_LogsErrorCode() _consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); @@ -215,7 +215,7 @@ public async Task ProcessorThrows_NacksMessage() _consumer.Setup(c => c.NackAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); } @@ -232,7 +232,7 @@ public async Task ProcessorThrows_DoesNotAck() _consumer.Setup(c => c.NackAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); @@ -251,7 +251,7 @@ public async Task ProcessorThrows_LogsError() _consumer.Setup(c => c.NackAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); @@ -273,7 +273,7 @@ public async Task ProcessorThrows_LogsJobId() _consumer.Setup(c => c.NackAsync()).Returns(Task.CompletedTask); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.ProcessMessage(command, _consumer.Object, TestContext.Current.CancellationToken); @@ -367,7 +367,7 @@ public async Task EmptyStream_CompletesGracefully() _consumer.Setup(c => c.ConsumeAsync(It.IsAny())) .Returns(CreateAsyncEnumerable()); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.StartAsync(cts.Token); @@ -406,7 +406,7 @@ public async Task ProcessesMultipleMessages() _ocrProcessor.Setup(p => p.ProcessDocumentAsync(command2, It.IsAny())) .ReturnsAsync(CreateSuccessEvent(jobId2)); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.StartAsync(cts.Token); @@ -445,7 +445,7 @@ public async Task CancellationStopsProcessing() return CreateSuccessEvent(cmd.JobId); }); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.StartAsync(cts.Token); @@ -489,7 +489,7 @@ public async Task ProcessorThrows_ContinuesWithNextMessage() _ocrProcessor.Setup(p => p.ProcessDocumentAsync(command3, It.IsAny())) .ReturnsAsync(CreateSuccessEvent(jobId3)); - OcrWorker sut = CreateSut(); + using OcrWorker sut = CreateSut(); await sut.StartAsync(cts.Token); @@ -527,10 +527,10 @@ private static OcrCommand CreateCommand( string fileName = ValidFileName, string storagePath = ValidStoragePath, DateTimeOffset? createdAt = null) => - new(jobId ?? Guid.CreateVersion7(), fileName, storagePath, createdAt ?? DateTimeOffset.UtcNow.AddMinutes(-5)); + new(jobId ?? Guid.CreateVersion7(), fileName, storagePath, createdAt ?? TimeProvider.System.GetUtcNow().AddMinutes(-5)); private static OcrEvent CreateSuccessEvent(Guid jobId) => - new(jobId, "Completed", ExtractedOcrText, DateTimeOffset.UtcNow); + new(jobId, "Completed", ExtractedOcrText, TimeProvider.System.GetUtcNow()); private static async IAsyncEnumerable CreateAsyncEnumerable(params OcrCommand[] commands) { diff --git a/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs b/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs index dc6a322..eaee090 100644 --- a/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs +++ b/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs @@ -21,28 +21,31 @@ public sealed class SearchIndexServiceTests : IDisposable private const string TestIndexName = "test-documents"; private const string TestFileName = "document.pdf"; private const string TestContent = "This is the extracted OCR content from the document."; - private static readonly Guid TestDocumentId = Guid.Parse("12345678-1234-1234-1234-123456789abc"); + private static readonly Guid s_testDocumentId = Guid.Parse("12345678-1234-1234-1234-123456789abc"); // ═══════════════════════════════════════════════════════════════ // CONSTRUCTION // ═══════════════════════════════════════════════════════════════ private readonly FakeLogCollector _logCollector = new(); + private readonly ElasticsearchClientSettings _settings; private readonly SearchIndexService _sut; private readonly FakeTimeProvider _timeProvider = new(); + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "_settings is stored as a field and disposed in Dispose() via the IDisposable cast; " + + "the analyzer can't follow disposal through the cast.")] public SearchIndexServiceTests() { FakeLogger logger = new(_logCollector); // Configure client to fail fast - unreachable endpoint tests resilience - ElasticsearchClientSettings settings = new ElasticsearchClientSettings(new Uri(UnreachableHost)) + _settings = new ElasticsearchClientSettings(new Uri(UnreachableHost)) .DefaultIndex(TestIndexName) .DisableDirectStreaming() .RequestTimeout(TimeSpan.FromMilliseconds(100)) .ThrowExceptions(false); - - ElasticsearchClient client = new(settings); + ElasticsearchClient client = new(_settings); IOptions options = Options.Create(new ElasticsearchOptions { @@ -56,8 +59,11 @@ public SearchIndexServiceTests() // DISPOSAL // ═══════════════════════════════════════════════════════════════ - public void Dispose() => + public void Dispose() + { TestContext.Current.SendDiagnosticMessage("Full logs:\n{0}", _logCollector.GetFullLoggerText()); + (_settings as IDisposable)?.Dispose(); + } // ═══════════════════════════════════════════════════════════════ // TESTS: IndexDocumentAsync - Resilience (Elasticsearch Unavailable) @@ -68,10 +74,10 @@ public async Task IndexDocumentAsync_WhenElasticsearchUnavailable_DoesNotThrow() { // Arrange & Act Func act = () => _sut.IndexDocumentAsync( - TestDocumentId, + s_testDocumentId, TestFileName, TestContent, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - Service is resilient to ES failures, never blocks OCR @@ -83,10 +89,10 @@ public async Task IndexDocumentAsync_WhenElasticsearchUnavailable_LogsWarning() { // Act await _sut.IndexDocumentAsync( - TestDocumentId, + s_testDocumentId, TestFileName, TestContent, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - Warning logged for observability @@ -99,16 +105,16 @@ public async Task IndexDocumentAsync_WhenElasticsearchUnavailable_LogsDocumentId { // Act await _sut.IndexDocumentAsync( - TestDocumentId, + s_testDocumentId, TestFileName, TestContent, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - Document ID in log for troubleshooting IReadOnlyList logs = _logCollector.GetSnapshot(); logs.Select(r => r.Message) - .Should().Contain(m => m.Contains(TestDocumentId.ToString(), StringComparison.OrdinalIgnoreCase)); + .Should().Contain(m => m.Contains(s_testDocumentId.ToString(), StringComparison.OrdinalIgnoreCase)); } // ═══════════════════════════════════════════════════════════════ @@ -120,10 +126,10 @@ public async Task IndexDocumentAsync_WithNullFileName_DoesNotThrow() { // Act Func act = () => _sut.IndexDocumentAsync( - TestDocumentId, + s_testDocumentId, null!, TestContent, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - Service handles null gracefully @@ -135,10 +141,10 @@ public async Task IndexDocumentAsync_WithEmptyContent_DoesNotThrow() { // Act - OCR might extract no text from some PDFs Func act = () => _sut.IndexDocumentAsync( - TestDocumentId, + s_testDocumentId, TestFileName, string.Empty, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - Empty content is valid @@ -153,7 +159,7 @@ public async Task IndexDocumentAsync_WithEmptyGuid_DoesNotThrow() Guid.Empty, TestFileName, TestContent, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert - Invalid IDs shouldn't crash the service @@ -173,10 +179,10 @@ public async Task IndexDocumentAsync_WithCancelledToken_DoesNotThrow() // Act Func act = () => _sut.IndexDocumentAsync( - TestDocumentId, + s_testDocumentId, TestFileName, TestContent, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), cts.Token); // Assert - Cancelled operations handled gracefully @@ -208,7 +214,7 @@ public async Task IndexDocumentAsync_WithVariousContentTypes_DoesNotThrow(string Guid.CreateVersion7(), TestFileName, content, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert @@ -226,7 +232,7 @@ public async Task IndexDocumentAsync_WithVeryLongContent_DoesNotThrow() Guid.CreateVersion7(), TestFileName, longContent, - DateTimeOffset.UtcNow.AddMinutes(-5), + TimeProvider.System.GetUtcNow().AddMinutes(-5), TestContext.Current.CancellationToken); // Assert @@ -241,7 +247,7 @@ public async Task IndexDocumentAsync_WithVeryLongContent_DoesNotThrow() public async Task IndexDocumentAsync_ConcurrentCalls_DoNotThrow() { // Arrange - Multiple documents indexed concurrently - DateTimeOffset createdAt = DateTimeOffset.UtcNow.AddMinutes(-5); + DateTimeOffset createdAt = TimeProvider.System.GetUtcNow().AddMinutes(-5); Task[] tasks = [ _sut.IndexDocumentAsync(Guid.CreateVersion7(), "doc1.pdf", "Content 1", createdAt, CancellationToken.None), @@ -264,25 +270,25 @@ public async Task IndexDocumentAsync_ConcurrentCalls_DoNotThrow() public void LogIndexResult_WhenValid_LogsInformation() { // Act - _sut.LogIndexResult(TestDocumentId, true); + _sut.LogIndexResult(s_testDocumentId, true); // Assert IReadOnlyList logs = _logCollector.GetSnapshot(); logs.Should().ContainSingle(r => r.Level == LogLevel.Information && - r.Message.Contains(TestDocumentId.ToString(), StringComparison.OrdinalIgnoreCase)); + r.Message.Contains(s_testDocumentId.ToString(), StringComparison.OrdinalIgnoreCase)); } [Fact] public void LogIndexResult_WhenInvalid_LogsWarning() { // Act - _sut.LogIndexResult(TestDocumentId, false); + _sut.LogIndexResult(s_testDocumentId, false); // Assert IReadOnlyList logs = _logCollector.GetSnapshot(); logs.Should().ContainSingle(r => r.Level == LogLevel.Warning && - r.Message.Contains(TestDocumentId.ToString(), StringComparison.OrdinalIgnoreCase)); + r.Message.Contains(s_testDocumentId.ToString(), StringComparison.OrdinalIgnoreCase)); } } diff --git a/PaperlessServices.Tests/Unit/SearchIndexServiceThrowingTests.cs b/PaperlessServices.Tests/Unit/SearchIndexServiceThrowingTests.cs new file mode 100644 index 0000000..e9af1d2 --- /dev/null +++ b/PaperlessServices.Tests/Unit/SearchIndexServiceThrowingTests.cs @@ -0,0 +1,57 @@ +namespace PaperlessServices.Tests.Unit; + +/// +/// Covers the catch arm in by configuring +/// the client to throw on transport failure (vs. the sibling tests which use +/// ThrowExceptions(false) and exercise the non-throwing failure path). +/// +public sealed class SearchIndexServiceThrowingTests : IDisposable +{ + private const string UnreachableHost = "http://127.0.0.1:1"; + private const string TestIndexName = "throwing-index"; + + private readonly FakeLogCollector _logCollector = new(); + private readonly ElasticsearchClientSettings _settings; + private readonly SearchIndexService _sut; + + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "_settings stored in field and disposed in Dispose")] + public SearchIndexServiceThrowingTests() + { + FakeLogger logger = new(_logCollector); + + _settings = new ElasticsearchClientSettings(new Uri(UnreachableHost)) + .DefaultIndex(TestIndexName) + .RequestTimeout(TimeSpan.FromMilliseconds(50)) + .ThrowExceptions(true); + + ElasticsearchClient client = new(_settings); + IOptions options = Options.Create(new ElasticsearchOptions + { + Uri = UnreachableHost, DefaultIndex = TestIndexName + }); + + _sut = new SearchIndexService(client, options, new FakeTimeProvider(), logger); + } + + public void Dispose() + { + TestContext.Current.SendDiagnosticMessage("Full logs:\n{0}", _logCollector.GetFullLoggerText()); + (_settings as IDisposable)?.Dispose(); + } + + [Fact] + public async Task IndexDocumentAsync_ClientThrows_LogsWarningAndDoesNotPropagate() + { + Func act = () => _sut.IndexDocumentAsync( + Guid.CreateVersion7(), "throwing.pdf", "content", + TimeProvider.System.GetUtcNow(), + TestContext.Current.CancellationToken); + + await act.Should().NotThrowAsync(); + + _logCollector.GetSnapshot().Should().Contain(l => + l.Level == LogLevel.Warning && + l.Message.Contains("Failed to index", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/PaperlessUI.Blazor/Components/Pages/Weather.razor b/PaperlessUI.Blazor/Components/Pages/Weather.razor index f437e5e..0062694 100644 --- a/PaperlessUI.Blazor/Components/Pages/Weather.razor +++ b/PaperlessUI.Blazor/Components/Pages/Weather.razor @@ -1,5 +1,6 @@ @page "/weather" @attribute [StreamRendering] +@using System.Security.Cryptography Weather @@ -44,13 +45,13 @@ else // Simulate asynchronous loading to demonstrate streaming rendering await Task.Delay(500); - var startDate = DateOnly.FromDateTime(DateTime.Now); + var startDate = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().LocalDateTime); var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = startDate.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] + TemperatureC = RandomNumberGenerator.GetInt32(-20, 55), + Summary = summaries[RandomNumberGenerator.GetInt32(summaries.Length)] }).ToArray(); } diff --git a/Pipeline/Components/ITest.cs b/Pipeline/Components/ITest.cs index 9233bd8..806131b 100644 --- a/Pipeline/Components/ITest.cs +++ b/Pipeline/Components/ITest.cs @@ -42,7 +42,7 @@ internal interface ITest : ICompile .Description("Run unit tests only") .DependsOn(x => x.Compile) .Executes(() => RunTests(new TestOptions( - NamespaceFilter: "*.Unit.*", + NamespaceFilter: "*.Unit", ReportPrefix: "Unit"))); Target IntegrationTests => d => d @@ -50,7 +50,7 @@ internal interface ITest : ICompile .DependsOn(x => x.Compile) .TryDependsOn() .Executes(() => RunTests(new TestOptions( - NamespaceFilter: "*.Integration.*", + NamespaceFilter: "*.Integration", ReportPrefix: "Integration"))); // ══════════════════════════════════════════════════════════════════════════ diff --git a/global.json b/global.json index 8b810e1..46a26bd 100644 --- a/global.json +++ b/global.json @@ -4,9 +4,9 @@ "rollForward": "latestFeature" }, "msbuild-sdks": { - "ANcpLua.NET.Sdk": "3.4.27", - "ANcpLua.NET.Sdk.Web": "3.4.27", - "ANcpLua.NET.Sdk.Test": "3.4.27" + "ANcpLua.NET.Sdk": "3.4.29", + "ANcpLua.NET.Sdk.Web": "3.4.29", + "ANcpLua.NET.Sdk.Test": "3.4.29" }, "test": { "runner": "Microsoft.Testing.Platform"