From 1b9b2c5373cc644803c4d7e5787113b4f15890ae Mon Sep 17 00:00:00 2001 From: ancplua Date: Wed, 13 May 2026 10:52:28 +0200 Subject: [PATCH] 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"