From 1b9b2c5373cc644803c4d7e5787113b4f15890ae Mon Sep 17 00:00:00 2001 From: ancplua Date: Wed, 13 May 2026 10:52:28 +0200 Subject: [PATCH 1/7] chore: bump ANcpLua.NET.Sdk to 3.4.29, delete WarningsNotAsErrors carve-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit global.json msbuild-sdks pinned to 3.4.29 (analyzer relaxations land: 14 NetAnalyzers + 11 AL style rules go warning → suggestion globally). With that floor lowered, the 25-rule Directory.Build.props carve-out is no longer needed — file deleted instead of trimmed. Surfaced 76 real bug-class errors that the carve-out was hiding. Each fixed at the code level, not re-suppressed: TimeProvider migration (AL0026 + RS0030): - Production: RichProblemDetailsFactory uses TimeProvider.System.GetUtcNow() - Tests: replaced DateTime/DateTimeOffset.UtcNow / DateTime.Now across PaperlessServices.Tests and PaperlessREST.Tests integration + unit suites - PaperlessUI.Blazor Weather demo uses TimeProvider for current date Disposable hygiene (CA2000): - MinioOptions / ElasticsearchOptions: client construction inlined into DI factory so disposal lifetime is owned by the container, not the options-record helper - ReportProcessor: XmlReader wrapped in using - GlobalExceptionHandlerTests: Activity wrapped in using var - Integration test fixtures: nullable backing fields with property accessors (replaces null!/default! initializers that triggered IDE0370) - DocumentEndpointTests: nested try/catch covers pre-Add() window for ByteArrayContent; SuppressMessage justifies the standard HttpClient content-ownership-transfer pattern ValueTask hygiene (CA2012): - TypedErrorOrAsyncExtensions.ToOkOr404 now extends ErrorOr directly (was ValueTask>), so callers await first and the analyzer sees a direct extension call instead of a ValueTask pipeline - DocumentEndpoints GetDocumentById/GetSummary updated to await-first Cryptographic randomness (CA5394): - Blazor Weather sample: RandomNumberGenerator.GetInt32 instead of Random Naming (IDE1006): - Test method renames from snake_case to PascalCase_When_Then style (matches existing test naming convention in PaperlessREST.Tests/Unit) Suppression hygiene (IDE0370): - Removed redundant null-forgiving operators identified by the analyzer Drive-by fix to Pipeline/Components/ITest.cs: the existing namespace filter `*.Unit.*` / `*.Integration.*` matched zero tests because the project's test namespaces are exactly `*.Tests.Unit` / `*.Tests.Integration` (no sub-namespace). Filter now correctly discovers all 273 unit tests + integration tests. Verification (local, against packed 3.4.29): - `./build.sh Compile --configuration Release`: 0 errors, 0 warnings - `dotnet build Paperless.slnx -c Release`: 0 errors, 0 warnings - `./build.sh UnitTests --configuration Release`: PaperlessServices.Tests.Unit: 59/59 pass PaperlessREST.Tests.Unit: 214/214 pass Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 17 ----- PaperlessREST.Tests/DocumentBuilder.cs | 8 +- PaperlessREST.Tests/GlobalUsings.cs | 1 + .../BatchOrchestratorIntegrationTests.cs | 44 +++++------ ...ocumentAccessRepositoryIntegrationTests.cs | 75 ++++++++++--------- .../Integration/DocumentEndpointTests.cs | 32 ++++++-- .../DocumentRepositoryIntegrationTests.cs | 65 ++++++++-------- .../Integration/SharedRestContainerFixture.cs | 62 ++++++++------- .../Unit/BatchOrchestratorTests.cs | 4 +- .../Unit/DocumentServiceTests.cs | 12 +-- PaperlessREST.Tests/Unit/DocumentTests.cs | 14 ++-- PaperlessREST.Tests/Unit/EndpointsTests.cs | 16 ++-- .../Unit/ExceptionHandlerTests.cs | 2 +- .../Unit/GenAiResultListenerTests.cs | 28 +++---- .../Unit/GlobalExceptionHandlerTests.cs | 10 ++- PaperlessREST.Tests/Unit/MappingTests.cs | 2 +- .../Unit/OcrResultListenerTests.cs | 24 +++--- .../Unit/ReportProcessorTests.cs | 6 +- .../Configuration/ElasticsearchOptions.cs | 14 ---- PaperlessREST/Configuration/MinioOptions.cs | 14 +--- .../Application/ReportProcessor.cs | 3 +- .../Endpoints/DocumentEndpoints.cs | 20 +++-- .../Extensions/RichProblemDetailsFactory.cs | 6 +- .../Extensions/ServiceCollectionExtensions.cs | 18 ++++- .../Extensions/TypedErrorOrAsyncExtensions.cs | 50 +++++++++---- PaperlessServices.Tests/GlobalUsings.cs | 1 + .../Integration/FakeTextSummarizer.cs | 1 - .../Integration/OcrIntegrationTests.cs | 8 +- .../SearchIndexIntegrationTests.cs | 22 +++--- .../Integration/WorkerTestBase.cs | 6 +- .../Unit/OcrProcessorTests.cs | 20 ++--- .../Unit/OcrWorkerTests.cs | 32 ++++---- .../Unit/SearchIndexServiceTests.cs | 58 +++++++------- .../Components/Pages/Weather.razor | 7 +- Pipeline/Components/ITest.cs | 4 +- global.json | 6 +- 36 files changed, 378 insertions(+), 334 deletions(-) delete mode 100644 Directory.Build.props 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/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/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/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/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/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" From f1cb58f557cdf7cc6d0447f0bd381e413b89ee64 Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 15 May 2026 15:43:17 +0200 Subject: [PATCH 2/7] test(rest): cover RichProblemDetailsFactory and ContractViolationException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings two zero-coverage Host/Extensions files to full coverage: - RichProblemDetailsFactory (0/41 → 41/41): every ErrorType→status mapping, RFC 7807 extensions for retryAfter/currentState, metadata camelCasing, the ArgumentOutOfRangeException default, plus ErrorMetadataExtensions helpers (StorageUnavailable, DocumentLocked, InvalidField, DocumentNotFound). - ContractViolationException (0/8 → 8/8): every factory (ForNotFoundOnly, ForValidationOnly, ForNotFoundOrConflict, ForCrudOperation, For), GetDiagnostics projection, and the one-vs-many error message branches. Pure unit tests; no new conventions. xUnit v3 + Moq strict elsewhere in the suite — these files have no collaborators so the mock layer is unused. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Unit/ContractViolationExceptionTests.cs | 140 +++++++++ .../Unit/RichProblemDetailsFactoryTests.cs | 284 ++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 PaperlessREST.Tests/Unit/ContractViolationExceptionTests.cs create mode 100644 PaperlessREST.Tests/Unit/RichProblemDetailsFactoryTests.cs 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/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); + } +} From 58bdd810600de58a9d1f1f48d6d133daaae83a6b Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 15 May 2026 15:46:42 +0200 Subject: [PATCH 3/7] =?UTF-8?q?test(rest):=20cover=20TypedErrorOrAsyncExte?= =?UTF-8?q?nsions=20result=E2=86=92typed-result=20conversions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hits every public extension (sync ErrorOr, ValueTask, Task overloads) plus all four ToAcceptedAtRouteOrProblem branches: - Ok / NoContent / AcceptedAtRoute happy paths. - ValidationProblem produced from grouped Validation errors. - 500 ProblemHttpResult from Failure (verifies the urn:paperless:error kebab-case projection). - 503 ProblemHttpResult from Unexpected, including the BuildService- UnavailableExtensions branches: default retryAfter=30, override via metadata, RetryAfter-only metadata yielding no extra extensions. - ContractViolationException thrown for unsupported error types in both the sync and async paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Unit/TypedErrorOrAsyncExtensionsTests.cs | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs 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>(); + } +} From 1ff9e937c37a3eb5b841d92aa78a3e3687a97fa8 Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 15 May 2026 15:57:57 +0200 Subject: [PATCH 4/7] test(rest): cover GenAi/Ocr listener ExecuteAsync happy-path lifecycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GenAiResultListenerExecuteAsyncTests + OcrResultListenerExecuteAsyncTests covering the BackgroundService ExecuteAsync entry, the consume loop (yields one event → processes → acks), and the generic-Exception catch branch on the GenAI listener. Completion is signalled by a TaskCompletion- Source in the AckAsync mock callback — per CLAUDE.md, never poll a log snapshot. The OperationInterruptedException 'no queue' shutdown branch is intentionally not unit-tested: constructing that exception requires RabbitMQ.Client internal types not surfaced through the test project's transitive deps. Documented inline so a future contributor doesn't try the same path again. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Unit/ListenerExecuteAsyncTests.cs | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs diff --git a/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs b/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs new file mode 100644 index 0000000..471a984 --- /dev/null +++ b/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs @@ -0,0 +1,215 @@ +using System.Runtime.CompilerServices; + +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_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) + { + } + } + +} From a3cd9e49afd1a3c7011440cc9893906d341803ba Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 15 May 2026 16:02:42 +0200 Subject: [PATCH 5/7] test: cover error factories, storage-exception mapping, ES throw path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small files to chase the long tail toward the coverage gate: - BatchAndReportErrorsTests: every static factory in ReportErrors and BatchErrors (Report.FileNotFound/InvalidXml/InvalidSchema/InvalidDate/ InvalidGuid plus Batch.PathRequired/InvalidPath/PathsNotDistinct/ InvalidTimeZone). Single-expression factories, shape-only assertions. - DocumentServiceStorageMappingTests: exercises every TryMapStorageException arm in UploadDocumentAsync (TimeoutException → StorageTimeout, HttpRequestException 5xx → StorageServerError, IOException with SocketException inner → StorageConnectionFailed) plus the rethrow path for unrecognized exceptions and the 4xx HttpRequestException case that falls through the `_ => null` arm. - SearchIndexServiceThrowingTests: client configured with ThrowExceptions(true) to hit the catch arm in IndexDocumentAsync that the sibling tests (which use ThrowExceptions(false)) can't reach. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Unit/BatchAndReportErrorsTests.cs | 98 +++++++++++++++ .../DocumentServiceStorageMappingTests.cs | 119 ++++++++++++++++++ .../Unit/SearchIndexServiceThrowingTests.cs | 57 +++++++++ 3 files changed, 274 insertions(+) create mode 100644 PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs create mode 100644 PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs create mode 100644 PaperlessServices.Tests/Unit/SearchIndexServiceThrowingTests.cs 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/DocumentServiceStorageMappingTests.cs b/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs new file mode 100644 index 0000000..644ab4e --- /dev/null +++ b/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs @@ -0,0 +1,119 @@ +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); + } +} 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)); + } +} From 48109027b8354393f3aa0ca29e095f2165526230 Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 15 May 2026 16:04:53 +0200 Subject: [PATCH 6/7] test(rest): cover GenAi listener 'no queue' shutdown branch via reflection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the OperationInterruptedException 'no queue' catch arm by constructing the exception with GetUninitializedObject and injecting Message via reflection on Exception._message. The listener's catch only matches on the literal 'no queue' substring in Message, so a real ShutdownEventArgs payload is not required — and constructing one would need RabbitMQ.Client-internal types that aren't surfaced through the test project's transitive deps. Test signals the listener has entered the catch arm by polling for the warning log, then cancels the stoppingToken to unblock the Task.Delay(Timeout.Infinite, ct) inside the catch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Unit/ListenerExecuteAsyncTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs b/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs index 471a984..3ec2f2e 100644 --- a/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs +++ b/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs @@ -1,4 +1,6 @@ +using System.Reflection; using System.Runtime.CompilerServices; +using RabbitMQ.Client.Exceptions; namespace PaperlessREST.Tests.Unit; @@ -91,6 +93,48 @@ public async Task ExecuteAsync_HappyPath_LogsStartedConsumesAndStopped() 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() { From acf714cd48dde8dd68d07378dd4f11aaba6aa592 Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 15 May 2026 16:09:32 +0200 Subject: [PATCH 7/7] test(rest): cover DocumentService state-transition failure branch Adds the two cases where ProcessOcrResultAsync exercises the `transitionResult.IsError` arm in DocumentService that the existing Pending-state tests never hit: - AlreadyCompleted: feeding a "Completed" OCR result to an already- completed document rejects via MarkAsCompleted's non-Pending guard and logs a state-transition-failed warning. - AlreadyFailed: same shape on the Failed path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DocumentServiceStorageMappingTests.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs b/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs index 644ab4e..7f71ab8 100644 --- a/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs +++ b/PaperlessREST.Tests/Unit/DocumentServiceStorageMappingTests.cs @@ -116,4 +116,39 @@ public async Task UploadDocumentAsync_UnknownException_PropagatesToCaller() (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(); + } }