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