diff --git a/PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs b/PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs
index b4938cd..e3f67d8 100644
--- a/PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs
+++ b/PaperlessREST.Tests/Unit/BatchAndReportErrorsTests.cs
@@ -3,10 +3,9 @@
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.
+/// Unit tests for the static error factories in .
+/// Tiny shape-only tests: each factory is one expression producing an
+/// with a well-known code, type, and description.
///
public sealed class BatchAndReportErrorsTests
{
@@ -19,15 +18,6 @@ public void ReportErrors_FileNotFound_ReturnsNotFoundWithPath()
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()
{
@@ -55,44 +45,4 @@ public void ReportErrors_InvalidGuid_ReturnsValidationWithIndex()
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/ContractViolationExceptionTests.cs b/PaperlessREST.Tests/Unit/ContractViolationExceptionTests.cs
index ca611ce..69b1d4d 100644
--- a/PaperlessREST.Tests/Unit/ContractViolationExceptionTests.cs
+++ b/PaperlessREST.Tests/Unit/ContractViolationExceptionTests.cs
@@ -2,139 +2,222 @@
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");
+ private const string Op = "GetById";
+ private const string Code = "Document.NotFound";
+ private const string Desc = "Document 42 not found";
+
+ private static Error MakeError(ErrorType type, string code = Code, string description = Desc,
+ Dictionary? metadata = null) =>
+ type switch
+ {
+ ErrorType.NotFound => Error.NotFound(code, description, metadata),
+ ErrorType.Validation => Error.Validation(code, description, metadata),
+ ErrorType.Conflict => Error.Conflict(code, description, metadata),
+ ErrorType.Failure => Error.Failure(code, description, metadata),
+ ErrorType.Unexpected => Error.Unexpected(code, description, metadata),
+ ErrorType.Unauthorized => Error.Unauthorized(code, description, metadata),
+ ErrorType.Forbidden => Error.Forbidden(code, description, metadata),
+ _ => Error.Custom((int)type, code, description, metadata)
+ };
[Fact]
- public void Constructor_OneError_BuildsMessageWithoutAggregateSuffix()
+ public void ForNotFoundOnly_PopulatesExpectedTypesAndMessage()
{
- 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");
+ Error err = MakeError(ErrorType.NotFound);
+ ContractViolationException ex = ContractViolationException.ForNotFoundOnly(err, [err], Op);
+
+ ex.EndpointOperation.Should().Be(Op);
+ ex.ExpectedErrorTypes.Should().ContainSingle().Which.Should().Be(ErrorType.NotFound);
+ ex.ActualError.Should().Be(err);
+ ex.AllErrors.Should().ContainSingle().Which.Should().Be(err);
+ ex.Message.Should().Contain("Contract violation in GetById")
+ .And.Contain("Expected [NotFound]")
+ .And.Contain($"Error: {Code} - {Desc}");
ex.Message.Should().NotContain("more error(s)");
}
[Fact]
- public void Constructor_MultipleErrors_BuildsMessageWithAggregateSuffix()
+ public void ForValidationOnly_PopulatesExpectedTypes()
{
- ContractViolationException ex = new(
- "UpdateDocument",
- [ErrorType.Validation, ErrorType.NotFound],
- s_actualError,
- [s_actualError, s_secondError]);
+ Error err = MakeError(ErrorType.Validation, "Validation.PageSize", "PageSize is required");
+ ContractViolationException ex = ContractViolationException.ForValidationOnly(err, [err], Op);
- ex.AllErrors.Should().HaveCount(2);
- ex.Message.Should().Contain("Expected [Validation, NotFound]");
- ex.Message.Should().Contain("(+ 1 more error(s))");
+ ex.ExpectedErrorTypes.Should().ContainSingle().Which.Should().Be(ErrorType.Validation);
+ ex.Message.Should().Contain("Expected [Validation]");
}
[Fact]
- public void GetDiagnostics_ReturnsStructuredProjection()
+ public void ForNotFoundOrConflict_ListsBothTypes()
{
- Error withMetadata = Error.Custom(
- (int)ErrorType.Conflict, "Document.Locked", "locked",
- new Dictionary { ["CurrentState"] = "Locked" });
+ Error err = MakeError(ErrorType.NotFound);
+ ContractViolationException ex = ContractViolationException.ForNotFoundOrConflict(err, [err], Op);
- ContractViolationException ex = new(
- "PUT /documents/{id}",
- [ErrorType.NotFound, ErrorType.Conflict],
- withMetadata,
- [withMetadata, s_secondError]);
+ ex.ExpectedErrorTypes.Should().BeEquivalentTo(new[] { ErrorType.NotFound, ErrorType.Conflict },
+ opts => opts.WithStrictOrdering());
+ ex.Message.Should().Contain("Expected [NotFound, Conflict]");
+ }
- ContractViolationDiagnostics diag = ex.GetDiagnostics();
+ [Fact]
+ public void ForCrudOperation_ListsThreeTypes()
+ {
+ Error err = MakeError(ErrorType.NotFound);
+ ContractViolationException ex = ContractViolationException.ForCrudOperation(err, [err], Op);
- 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");
+ ex.ExpectedErrorTypes.Should().BeEquivalentTo(
+ new[] { ErrorType.Validation, ErrorType.NotFound, ErrorType.Conflict },
+ opts => opts.WithStrictOrdering());
+ ex.Message.Should().Contain("Expected [Validation, NotFound, Conflict]");
}
[Fact]
- public void ForNotFoundOnly_BuildsWithCallerNameAndNotFoundExpectation()
+ public void For_WithCustomTypes_RoundTripsParamsArray()
{
- ContractViolationException ex = ContractViolationException.ForNotFoundOnly(
- s_actualError, [s_actualError], "GetById");
+ Error err = MakeError(ErrorType.Failure, "Document.StorageFailed", "Storage failed");
+ ContractViolationException ex = ContractViolationException.For(
+ err, [err], Op, ErrorType.Failure, ErrorType.Unexpected);
- ex.EndpointOperation.Should().Be("GetById");
- ex.ExpectedErrorTypes.Should().Equal(ErrorType.NotFound);
+ ex.ExpectedErrorTypes.Should().BeEquivalentTo(
+ new[] { ErrorType.Failure, ErrorType.Unexpected }, opts => opts.WithStrictOrdering());
+ ex.Message.Should().Contain("Expected [Failure, Unexpected]");
}
[Fact]
- public void ForNotFoundOnly_DefaultCallerName_UsesCallingMember()
+ public void BuildMessage_SingleError_OmitsMoreErrorsSuffix()
{
- ContractViolationException ex = ForNotFoundOnly_DefaultCallerName_UsesCallingMember_Helper();
+ Error err = MakeError(ErrorType.NotFound);
+ ContractViolationException ex = ContractViolationException.ForNotFoundOnly(err, [err], Op);
- ex.EndpointOperation.Should().Be(nameof(ForNotFoundOnly_DefaultCallerName_UsesCallingMember_Helper));
+ ex.Message.Should().NotContain("more error(s)");
}
- private static ContractViolationException ForNotFoundOnly_DefaultCallerName_UsesCallingMember_Helper() =>
- ContractViolationException.ForNotFoundOnly(s_actualError, [s_actualError]);
+ [Fact]
+ public void BuildMessage_ThreeErrors_AppendsExactSuffix()
+ {
+ Error first = MakeError(ErrorType.Validation, "Validation.A", "A");
+ Error second = MakeError(ErrorType.Validation, "Validation.B", "B");
+ Error third = MakeError(ErrorType.Validation, "Validation.C", "C");
+
+ ContractViolationException ex = ContractViolationException.ForValidationOnly(
+ first, [first, second, third], Op);
+
+ ex.Message.Should().EndWith("(+ 2 more error(s))");
+ }
[Fact]
- public void ForValidationOnly_BuildsWithValidationExpectation()
+ public void GetDiagnostics_AllErrorsMatchInputOrder()
{
+ Error first = MakeError(ErrorType.Validation, "Validation.A", "A");
+ Error second = MakeError(ErrorType.Validation, "Validation.B", "B");
ContractViolationException ex = ContractViolationException.ForValidationOnly(
- s_actualError, [s_actualError], "Validate");
+ first, [first, second], Op);
+
+ ContractViolationDiagnostics diag = ex.GetDiagnostics();
+
+ diag.Operation.Should().Be(Op);
+ diag.ExpectedErrorTypes.Should().Equal("Validation");
+ diag.ActualErrorType.Should().Be("Validation");
+ diag.ErrorCode.Should().Be("Validation.A");
+ diag.ErrorDescription.Should().Be("A");
+ diag.AllErrors.Should().HaveCount(2);
+ diag.AllErrors[0].Should().Be(new ErrorDetail("Validation", "Validation.A", "A"));
+ diag.AllErrors[1].Should().Be(new ErrorDetail("Validation", "Validation.B", "B"));
+ }
+
+ [Fact]
+ public void GetDiagnostics_WithMetadata_PopulatesMetadataDictionary()
+ {
+ Dictionary meta = new() { ["RetryAfter"] = 30, ["AffectedResource"] = "x.pdf" };
+ Error err = MakeError(ErrorType.Unexpected, "Document.StorageUnavailable", "tmp", meta);
+ ContractViolationException ex = ContractViolationException.For(err, [err], Op, ErrorType.Unexpected);
+
+ ContractViolationDiagnostics diag = ex.GetDiagnostics();
- ex.ExpectedErrorTypes.Should().Equal(ErrorType.Validation);
+ diag.Metadata.Should().NotBeNull();
+ diag.Metadata!["RetryAfter"].Should().Be(30);
+ diag.Metadata["AffectedResource"].Should().Be("x.pdf");
}
[Fact]
- public void ForNotFoundOrConflict_BuildsWithBothTypes()
+ public void GetDiagnostics_WithNullMetadata_LeavesMetadataNull()
{
- ContractViolationException ex = ContractViolationException.ForNotFoundOrConflict(
- s_actualError, [s_actualError], "UpdateOrCreate");
+ Error err = MakeError(ErrorType.NotFound);
+ ContractViolationException ex = ContractViolationException.ForNotFoundOnly(err, [err], Op);
- ex.ExpectedErrorTypes.Should().Equal(ErrorType.NotFound, ErrorType.Conflict);
+ ContractViolationDiagnostics diag = ex.GetDiagnostics();
+
+ diag.Metadata.Should().BeNull();
}
[Fact]
- public void ForCrudOperation_BuildsWithValidationNotFoundConflict()
+ public void ContractViolationDiagnostics_RecordEquality_HoldsWhenReferenceArraysAreShared()
{
- ContractViolationException ex = ContractViolationException.ForCrudOperation(
- s_actualError, [s_actualError], "Crud");
+ // Records compare arrays by reference (no value semantics on T[]).
+ // Share the same array instances so equality holds.
+ string[] expectedTypes = ["NotFound"];
+ ErrorDetail[] errs = [new("NotFound", "X", "Y")];
+ ContractViolationDiagnostics left = new("Op", expectedTypes, "NotFound", "X", "Y", errs, null);
+ ContractViolationDiagnostics right = new("Op", expectedTypes, "NotFound", "X", "Y", errs, null);
+
+ left.Should().Be(right);
+ (left == right).Should().BeTrue();
+ left.GetHashCode().Should().Be(right.GetHashCode());
+ }
- ex.ExpectedErrorTypes.Should()
- .Equal(ErrorType.Validation, ErrorType.NotFound, ErrorType.Conflict);
+ [Fact]
+ public void ContractViolationDiagnostics_RecordEquality_FailsWhenArraysAreDifferentInstances()
+ {
+ // Counterpart to the shared-reference case: documents that the synthesized
+ // equality uses reference equality on T[] members.
+ ContractViolationDiagnostics left = new(
+ "Op", ["NotFound"], "NotFound", "X", "Y", [new ErrorDetail("NotFound", "X", "Y")], null);
+ ContractViolationDiagnostics right = new(
+ "Op", ["NotFound"], "NotFound", "X", "Y", [new ErrorDetail("NotFound", "X", "Y")], null);
+
+ left.Should().NotBe(right);
}
[Fact]
- public void For_BuildsWithCustomExpectedTypes()
+ public void ContractViolationDiagnostics_WithExpression_ReturnsNewInstanceWithUpdatedProperty()
{
- ContractViolationException ex = ContractViolationException.For(
- s_actualError, [s_actualError], "PostThing", ErrorType.Failure, ErrorType.Unexpected);
+ ContractViolationDiagnostics original = new(
+ "Op", ["NotFound"], "NotFound", "X", "Y", [new ErrorDetail("NotFound", "X", "Y")], null);
+ ContractViolationDiagnostics mutated = original with { Operation = "Other" };
+
+ mutated.Operation.Should().Be("Other");
+ mutated.Should().NotBe(original);
+ mutated.ErrorCode.Should().Be(original.ErrorCode);
+ }
+
+ [Fact]
+ public void ErrorDetail_RecordEquality_HoldsForSameValues()
+ {
+ ErrorDetail left = new("NotFound", "X", "Y");
+ ErrorDetail right = new("NotFound", "X", "Y");
+
+ left.Should().Be(right);
+ (left == right).Should().BeTrue();
+ left.GetHashCode().Should().Be(right.GetHashCode());
+ }
+
+ [Fact]
+ public void ErrorDetail_WithExpression_ReturnsNewInstance()
+ {
+ ErrorDetail original = new("NotFound", "X", "Y");
+ ErrorDetail mutated = original with { Code = "Z" };
- ex.ExpectedErrorTypes.Should().Equal(ErrorType.Failure, ErrorType.Unexpected);
- ex.EndpointOperation.Should().Be("PostThing");
+ mutated.Code.Should().Be("Z");
+ mutated.Should().NotBe(original);
+ mutated.Type.Should().Be(original.Type);
}
[Fact]
- public void Exception_IsInvalidOperationException()
+ public void ForNotFoundOnly_DefaultsOperationToCallerMemberName()
{
- ContractViolationException ex = ContractViolationException.ForNotFoundOnly(
- s_actualError, [s_actualError], "GetById");
+ Error err = MakeError(ErrorType.NotFound);
+ ContractViolationException ex = ContractViolationException.ForNotFoundOnly(err, [err]);
- ex.Should().BeAssignableTo();
+ ex.EndpointOperation.Should().Be(nameof(ForNotFoundOnly_DefaultsOperationToCallerMemberName));
}
}
diff --git a/PaperlessREST.Tests/Unit/DocumentServiceTests.cs b/PaperlessREST.Tests/Unit/DocumentServiceTests.cs
index 6f5dcdc..88966a2 100644
--- a/PaperlessREST.Tests/Unit/DocumentServiceTests.cs
+++ b/PaperlessREST.Tests/Unit/DocumentServiceTests.cs
@@ -598,4 +598,88 @@ public async Task DeleteDocumentAsync_SearchDeleteFails_LogsWarning()
l.Level == LogLevel.Warning &&
l.Message.Contains("search index", StringComparison.OrdinalIgnoreCase));
}
+
+ // ═══════════════════════════════════════════════════════════════
+ // TESTS: UploadDocumentAsync - Unknown Storage Exception (rethrown)
+ // Covers DocumentService.cs lines 107-108 (TryMapStorageException returns null → throw)
+ // ═══════════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task UploadDocumentAsync_UnknownStorageException_PropagatesOriginalException()
+ {
+ // Arrange — an exception type not handled by TryMapStorageException
+ UploadDocumentRequest request = UploadDocumentRequestBuilder.ValidPdf().Build();
+ InvalidOperationException expected = new("Unknown infrastructure failure");
+
+ _storage.Setup(s => s.UploadAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(expected);
+
+ DocumentService sut = CreateSut();
+
+ // Act
+ Func act = () => sut.UploadDocumentAsync(request, TestContext.Current.CancellationToken);
+
+ // Assert — original exception propagates to caller, not mapped to ErrorOr
+ InvalidOperationException thrown = (await act.Should().ThrowAsync()).Which;
+ thrown.Should().BeSameAs(expected);
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // TESTS: ProcessOcrResultAsync - State Transition Failure
+ // Covers DocumentService.cs lines 148-151 (transitionResult.IsError branch)
+ // ═══════════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task ProcessOcrResultAsync_DocumentAlreadyCompleted_ReturnsCannotCompleteError()
+ {
+ // Arrange — document already in Completed state, OCR re-arrival
+ Document doc = new DocumentBuilder().AsCompleted().Build();
+
+ _repository.Setup(r => r.GetByIdAsync(doc.Id, It.IsAny()))
+ .ReturnsAsync(doc);
+
+ DocumentService sut = CreateSut();
+
+ // Act
+ ErrorOr result = await sut.ProcessOcrResultAsync(doc.Id, "Completed", ExtractedOcrContent,
+ TestContext.Current.CancellationToken);
+
+ // Assert — exact error code from DocumentErrors.CannotComplete
+ result.IsError.Should().BeTrue();
+ result.FirstError.Type.Should().Be(ErrorType.Validation);
+ result.FirstError.Code.Should().Be("Document.CannotComplete");
+ result.FirstError.Description.Should().Contain("Completed");
+ }
+
+ [Fact]
+ public async Task ProcessOcrResultAsync_TransitionFailure_LogsWarningAndDoesNotUpdateRepository()
+ {
+ // Arrange — document already in Failed state; MarkAsFailed should error
+ Document doc = new DocumentBuilder().AsFailed().Build();
+
+ _repository.Setup(r => r.GetByIdAsync(doc.Id, It.IsAny()))
+ .ReturnsAsync(doc);
+
+ DocumentService sut = CreateSut();
+
+ // Act
+ ErrorOr result = await sut.ProcessOcrResultAsync(doc.Id, "Failed", null,
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ result.IsError.Should().BeTrue();
+ result.FirstError.Code.Should().Be("Document.CannotFail");
+
+ _logCollector.GetSnapshot()
+ .Should().Contain(l =>
+ l.Level == LogLevel.Warning &&
+ l.Message.Contains("state transition failed", StringComparison.OrdinalIgnoreCase));
+
+ // Repository.UpdateAsync is intentionally NOT set up; MockBehavior.Strict will fail
+ // the test in Dispose if it were called. This proves the short-circuit.
+ }
}
diff --git a/PaperlessREST.Tests/Unit/ListenerLifecycleTests.cs b/PaperlessREST.Tests/Unit/ListenerLifecycleTests.cs
new file mode 100644
index 0000000..ddb4d49
--- /dev/null
+++ b/PaperlessREST.Tests/Unit/ListenerLifecycleTests.cs
@@ -0,0 +1,578 @@
+// Lifecycle / ExecuteAsync coverage for GenAiResultListener and OcrResultListener.
+// The per-message internal helpers (ProcessGenAiEventAsync, ProcessMessage) are
+// covered by GenAiResultListenerTests / OcrResultListenerTests. This file targets
+// the BackgroundService.ExecuteAsync branches: started/stopped logs, the
+// await-foreach driver, the OperationInterruptedException filters, the generic
+// catch-and-rethrow, and the stoppingToken.IsCancellationRequested break.
+//
+// Synchronisation strategy follows the canonical OcrWorker.EmptyStream_CompletesGracefully
+// fix documented in CLAUDE.md ("BackgroundService race in tests"): BackgroundService.StartAsync
+// returns before ExecuteAsync runs, so we never wait on a log predicate that is already
+// true. Completion is signalled via TaskCompletionSource set inside the fake consumer's
+// DisposeAsync (which only fires after `await using` unwinds — i.e. after the foreach loop).
+
+using System.Runtime.CompilerServices;
+using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
+using RabbitMQ.Client.Exceptions;
+
+namespace PaperlessREST.Tests.Unit;
+
+public sealed class ListenerLifecycleTests
+{
+ // ═══════════════════════════════════════════════════════════════
+ // GenAiResultListener — ExecuteAsync branches
+ // ═══════════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task GenAi_ExecuteAsync_HappyPath_LogsStartedAndStopped()
+ {
+ // Arrange
+ GenAiHarness h = new();
+ Guid documentId = Guid.CreateVersion7();
+ GenAIEvent evt = new(documentId, "summary text", TimeProvider.System.GetUtcNow(), null);
+
+ TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ h.Consumer.As().Setup(d => d.DisposeAsync())
+ .Returns(() => { disposed.TrySetResult(); return ValueTask.CompletedTask; });
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ReturnsAsync(h.Consumer.Object);
+
+ h.Consumer.Setup(c => c.ConsumeAsync(It.IsAny()))
+ .Returns((CancellationToken ct) => Yield(new[] { evt }, ct));
+
+ h.DocumentService.Setup(s => s.UpdateDocumentSummaryAsync(
+ documentId, "summary text", evt.GeneratedAt, It.IsAny()))
+ .ReturnsAsync(Result.Updated);
+
+ h.SseStream.Setup(s => s.Publish(evt));
+ h.Consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask);
+
+ using GenAiResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ // Act
+ await sut.StartAsync(cts.Token);
+ await disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await sut.StopAsync(CancellationToken.None);
+
+ // Assert
+ IReadOnlyList logs = h.LogCollector.GetSnapshot();
+ logs.Should().Contain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("GenAI Result Listener started", StringComparison.OrdinalIgnoreCase));
+ logs.Should().Contain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("GenAI Result Listener stopped", StringComparison.OrdinalIgnoreCase));
+ h.Consumer.Verify(c => c.AckAsync(), Times.Once);
+ }
+
+ [Fact]
+ public async Task GenAi_ExecuteAsync_NoQueue_LogsWarningAndWaitsForCancellation()
+ {
+ // Arrange — CreateConsumerAsync throws OperationInterruptedException with "no queue" message.
+ // Handler should log Warning + call Task.Delay(Timeout.Infinite, stoppingToken).
+ // Cancelling the stoppingToken makes ExecuteAsync return cleanly (no rethrow).
+ GenAiHarness h = new();
+ ShutdownEventArgs reason = new(
+ ShutdownInitiator.Application, 404, "no queue 'GenAIEvent' in vhost '/'",
+ cause: new object(), cancellationToken: CancellationToken.None);
+ OperationInterruptedException noQueue = new(reason);
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ThrowsAsync(noQueue);
+
+ using GenAiResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ // Act
+ await sut.StartAsync(cts.Token);
+
+ // Wait until the "disabled" warning lands — that proves the when-filter matched.
+ // Then cancel so the Task.Delay(Timeout.Infinite, stoppingToken) unblocks.
+ await WaitForLogAsync(
+ h.LogCollector,
+ l => l.Level == LogLevel.Warning &&
+ l.Message.Contains("GenAI Result Listener disabled", StringComparison.OrdinalIgnoreCase),
+ TestContext.Current.CancellationToken);
+
+ await cts.CancelAsync();
+ await sut.StopAsync(CancellationToken.None);
+
+ // Assert
+ h.LogCollector.GetSnapshot().Should().Contain(l =>
+ l.Level == LogLevel.Warning &&
+ l.Message.Contains("disabled", StringComparison.OrdinalIgnoreCase) &&
+ l.Message.Contains("GenAIEvent queue", StringComparison.OrdinalIgnoreCase));
+ // Generic error path must NOT have been taken.
+ h.LogCollector.GetSnapshot().Should().NotContain(l =>
+ l.Level == LogLevel.Error &&
+ l.Message.Contains("Unexpected error", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task GenAi_ExecuteAsync_OperationInterrupted_WithoutNoQueue_LogsErrorAndRethrows()
+ {
+ // Arrange — same exception type, message does NOT contain "no queue".
+ // The `when (...)` filter must be false; the generic catch logs Error and rethrows.
+ GenAiHarness h = new();
+ ShutdownEventArgs reason = new(
+ ShutdownInitiator.Peer, 320, "connection lost",
+ cause: new object(), cancellationToken: CancellationToken.None);
+ OperationInterruptedException connectionLost = new(reason);
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ThrowsAsync(connectionLost);
+
+ using GenAiResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ // Act — BackgroundService captures the ExecuteAsync task; the throw is surfaced
+ // via ExecuteTask, not StartAsync (which only awaits the first yield-point).
+ await sut.StartAsync(cts.Token);
+
+ Func awaitExecute = () => sut.ExecuteTask!;
+ (await awaitExecute.Should().ThrowAsync())
+ .Which.Message.Should().Contain("connection lost");
+
+ // StopAsync after a faulted ExecuteTask must not throw; the host swallows the captured fault.
+ await sut.StopAsync(CancellationToken.None);
+
+ h.LogCollector.GetSnapshot().Should().Contain(l =>
+ l.Level == LogLevel.Error &&
+ l.Message.Contains("Unexpected error", StringComparison.OrdinalIgnoreCase));
+ // "Disabled" warning path must NOT have been taken.
+ h.LogCollector.GetSnapshot().Should().NotContain(l =>
+ l.Level == LogLevel.Warning &&
+ l.Message.Contains("disabled", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task GenAi_ExecuteAsync_ConsumeAsyncThrows_GenericCatchLogsAndRethrows()
+ {
+ // Arrange — non-OperationInterruptedException mid-iteration. Generic catch logs + rethrows.
+ GenAiHarness h = new();
+
+ h.Consumer.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask);
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ReturnsAsync(h.Consumer.Object);
+
+ h.Consumer.Setup(c => c.ConsumeAsync(It.IsAny()))
+ .Returns(ThrowingStream(new InvalidOperationException("rabbit died")));
+
+ using GenAiResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ // Act
+ await sut.StartAsync(cts.Token);
+
+ // The exception surfaces on the captured ExecuteTask, not on StartAsync.
+ Func awaitExecute = () => sut.ExecuteTask!;
+ (await awaitExecute.Should().ThrowAsync())
+ .Which.Message.Should().Be("rabbit died");
+
+ await sut.StopAsync(CancellationToken.None);
+
+ h.LogCollector.GetSnapshot().Should().Contain(l =>
+ l.Level == LogLevel.Error &&
+ l.Message.Contains("Unexpected error", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task GenAi_ExecuteAsync_StoppingTokenCancelled_BreaksLoopAfterFirstEvent()
+ {
+ // Arrange — gated stream: yield event #1, wait on a TCS before considering yielding #2/#3.
+ // The test cancels the stoppingToken before releasing the gate, which trips
+ // `if (stoppingToken.IsCancellationRequested) break;` on the next iteration boundary.
+ GenAiHarness h = new();
+ Guid id1 = Guid.CreateVersion7();
+ Guid id2 = Guid.CreateVersion7();
+ Guid id3 = Guid.CreateVersion7();
+ GenAIEvent e1 = new(id1, "first", TimeProvider.System.GetUtcNow(), null);
+ GenAIEvent e2 = new(id2, "second", TimeProvider.System.GetUtcNow(), null);
+ GenAIEvent e3 = new(id3, "third", TimeProvider.System.GetUtcNow(), null);
+
+ TaskCompletionSource firstAckObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ h.Consumer.As().Setup(d => d.DisposeAsync())
+ .Returns(() => { disposed.TrySetResult(); return ValueTask.CompletedTask; });
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ReturnsAsync(h.Consumer.Object);
+
+ h.Consumer.Setup(c => c.ConsumeAsync(It.IsAny()))
+ .Returns((CancellationToken ct) => GatedStream(new[] { e1, e2, e3 }, gate.Task, ct));
+
+ h.DocumentService.Setup(s => s.UpdateDocumentSummaryAsync(
+ id1, "first", e1.GeneratedAt, It.IsAny()))
+ .ReturnsAsync(Result.Updated);
+
+ h.SseStream.Setup(s => s.Publish(e1));
+ h.Consumer.Setup(c => c.AckAsync())
+ .Returns(() => { firstAckObserved.TrySetResult(); return Task.CompletedTask; });
+
+ using GenAiResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ // Act
+ await sut.StartAsync(cts.Token);
+
+ // Wait for event #1 to land in AckAsync. At this point, the producer is parked on `gate`.
+ await firstAckObserved.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+
+ // Cancel BEFORE releasing the gate so the next iteration of the foreach sees a cancelled token.
+ await cts.CancelAsync();
+ gate.TrySetResult();
+
+ await disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await sut.StopAsync(CancellationToken.None);
+
+ // Assert — exactly one document processed; events #2 and #3 never touched.
+ h.DocumentService.Verify(s => s.UpdateDocumentSummaryAsync(
+ id1, "first", e1.GeneratedAt, It.IsAny()), Times.Once);
+ h.DocumentService.Verify(s => s.UpdateDocumentSummaryAsync(
+ id2, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ h.DocumentService.Verify(s => s.UpdateDocumentSummaryAsync(
+ id3, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ h.Consumer.Verify(c => c.AckAsync(), Times.Once);
+ }
+
+ [Fact]
+ public async Task GenAi_ExecuteAsync_TokenCancelledBetweenYields_BodyBreakCheckFires()
+ {
+ // Arrange — iterator deliberately does NOT honor the [EnumeratorCancellation] token, so
+ // after the test cancels the stoppingToken the iterator still yields the next event.
+ // That is what trips the `if (stoppingToken.IsCancellationRequested) { break; }` block
+ // inside the foreach body (lines 20-22 of GenAiResultListener.cs) — coverage that the
+ // gated/throwing iterators cannot reach because they all surface OCE before re-entering
+ // the body. The clean "stopped" log proves the loop exited via `break`, not via throw.
+ GenAiHarness h = new();
+ Guid id1 = Guid.CreateVersion7();
+ Guid id2 = Guid.CreateVersion7();
+ GenAIEvent e1 = new(id1, "first", TimeProvider.System.GetUtcNow(), null);
+ GenAIEvent e2 = new(id2, "second", TimeProvider.System.GetUtcNow(), null);
+
+ TaskCompletionSource firstAckObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ using CancellationTokenSource cts = new();
+
+ h.Consumer.As().Setup(d => d.DisposeAsync())
+ .Returns(() => { disposed.TrySetResult(); return ValueTask.CompletedTask; });
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ReturnsAsync(h.Consumer.Object);
+
+ h.Consumer.Setup(c => c.ConsumeAsync(It.IsAny()))
+ .Returns(YieldAfterCancel(e1, e2, firstAckObserved, cts));
+
+ h.DocumentService.Setup(s => s.UpdateDocumentSummaryAsync(
+ id1, "first", e1.GeneratedAt, It.IsAny()))
+ .ReturnsAsync(Result.Updated);
+
+ h.SseStream.Setup(s => s.Publish(e1));
+ h.Consumer.Setup(c => c.AckAsync())
+ .Returns(() => { firstAckObserved.TrySetResult(); return Task.CompletedTask; });
+
+ using GenAiResultListener sut = h.CreateSut();
+
+ // Act
+ await sut.StartAsync(cts.Token);
+ await disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await sut.StopAsync(CancellationToken.None);
+
+ // Assert — e1 processed exactly once; e2 yielded but tripped the body's break before any
+ // downstream call. The "stopped" log proves the foreach exited cleanly via break (no throw).
+ h.DocumentService.Verify(s => s.UpdateDocumentSummaryAsync(
+ id1, "first", e1.GeneratedAt, It.IsAny()), Times.Once);
+ h.DocumentService.Verify(s => s.UpdateDocumentSummaryAsync(
+ id2, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ h.Consumer.Verify(c => c.AckAsync(), Times.Once);
+ h.SseStream.Verify(s => s.Publish(e2), Times.Never);
+
+ IReadOnlyList logs = h.LogCollector.GetSnapshot();
+ logs.Should().Contain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("GenAI Result Listener stopped", StringComparison.OrdinalIgnoreCase));
+ logs.Should().NotContain(l => l.Level == LogLevel.Error);
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // OcrResultListener — ExecuteAsync branches
+ // ═══════════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task Ocr_ExecuteAsync_HappyPath_LogsStartedAndStopped()
+ {
+ OcrHarness h = new();
+ Guid jobId = Guid.CreateVersion7();
+ OcrEvent evt = new(jobId, "Completed", "extracted text", TimeProvider.System.GetUtcNow());
+
+ TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ h.Consumer.As().Setup(d => d.DisposeAsync())
+ .Returns(() => { disposed.TrySetResult(); return ValueTask.CompletedTask; });
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ReturnsAsync(h.Consumer.Object);
+
+ h.Consumer.Setup(c => c.ConsumeAsync(It.IsAny()))
+ .Returns((CancellationToken ct) => Yield(new[] { evt }, ct));
+
+ h.DocumentService.Setup(s => s.ProcessOcrResultAsync(
+ jobId, "Completed", "extracted text", It.IsAny()))
+ .ReturnsAsync(Result.Updated);
+
+ h.SseStream.Setup(s => s.Publish(evt));
+ h.Consumer.Setup(c => c.AckAsync()).Returns(Task.CompletedTask);
+
+ using OcrResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ await sut.StartAsync(cts.Token);
+ await disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await sut.StopAsync(CancellationToken.None);
+
+ IReadOnlyList logs = h.LogCollector.GetSnapshot();
+ logs.Should().Contain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("OCR Result Listener started", StringComparison.OrdinalIgnoreCase));
+ logs.Should().Contain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("OCR Result Listener stopped", StringComparison.OrdinalIgnoreCase));
+ h.Consumer.Verify(c => c.AckAsync(), Times.Once);
+ }
+
+ [Fact]
+ public async Task Ocr_ExecuteAsync_ConsumeAsyncThrows_ExceptionPropagatesUncaught()
+ {
+ // OcrResultListener has NO try/catch around the foreach — exceptions propagate.
+ OcrHarness h = new();
+
+ h.Consumer.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask);
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ReturnsAsync(h.Consumer.Object);
+
+ h.Consumer.Setup(c => c.ConsumeAsync(It.IsAny()))
+ .Returns(ThrowingStream(new InvalidOperationException("ocr stream died")));
+
+ using OcrResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ await sut.StartAsync(cts.Token);
+
+ Func awaitExecute = () => sut.ExecuteTask!;
+ (await awaitExecute.Should().ThrowAsync())
+ .Which.Message.Should().Be("ocr stream died");
+
+ await sut.StopAsync(CancellationToken.None);
+
+ // "stopped" log line must NOT be reached when the exception escapes mid-foreach.
+ h.LogCollector.GetSnapshot().Should().NotContain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("OCR Result Listener stopped", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task Ocr_ExecuteAsync_CreateConsumerThrows_ExceptionPropagatesUncaught()
+ {
+ // Factory failure happens before the `await using`, so nothing to dispose.
+ OcrHarness h = new();
+
+ h.ConsumerFactory.Setup(f => f.CreateConsumerAsync())
+ .ThrowsAsync(new InvalidOperationException("factory exploded"));
+
+ using OcrResultListener sut = h.CreateSut();
+ using CancellationTokenSource cts = new();
+
+ await sut.StartAsync(cts.Token);
+
+ Func awaitExecute = () => sut.ExecuteTask!;
+ (await awaitExecute.Should().ThrowAsync())
+ .Which.Message.Should().Be("factory exploded");
+
+ await sut.StopAsync(CancellationToken.None);
+
+ // "started" log fires before factory call; "stopped" must NOT be present.
+ h.LogCollector.GetSnapshot().Should().Contain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("OCR Result Listener started", StringComparison.OrdinalIgnoreCase));
+ h.LogCollector.GetSnapshot().Should().NotContain(l =>
+ l.Level == LogLevel.Information &&
+ l.Message.Contains("OCR Result Listener stopped", StringComparison.OrdinalIgnoreCase));
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // Async-enumerable helpers — TaskCompletionSource-controlled streams.
+ // No Task.Delay polling; cancellation is observed cooperatively.
+ // ═══════════════════════════════════════════════════════════════
+
+ private static async IAsyncEnumerable Yield(
+ IEnumerable items,
+ [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ foreach (T item in items)
+ {
+ ct.ThrowIfCancellationRequested();
+ await Task.Yield();
+ yield return item;
+ }
+ }
+
+ private static async IAsyncEnumerable ThrowingStream(Exception exception)
+ {
+ await Task.Yield();
+ throw exception;
+#pragma warning disable CS0162 // Unreachable — required so the compiler treats this as an iterator.
+ yield break;
+#pragma warning restore CS0162
+ }
+
+ private static async IAsyncEnumerable GatedStream(
+ IReadOnlyList items,
+ Task gate,
+ [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ // Yield the first item immediately; for each subsequent item, wait on the gate so the
+ // test can deterministically cancel the token before the producer yields again.
+ for (int i = 0; i < items.Count; i++)
+ {
+ if (i > 0)
+ {
+ await gate.WaitAsync(ct).ConfigureAwait(false);
+ }
+
+ ct.ThrowIfCancellationRequested();
+ yield return items[i];
+ }
+ }
+
+ // Deliberately non-cooperative iterator: yields the second event AFTER the stoppingToken is
+ // cancelled, without honoring the [EnumeratorCancellation] token. Required to exercise the
+ // body-internal `if (stoppingToken.IsCancellationRequested) break;` check, which only fires
+ // when the iterator hands a yielded value back into a cancelled foreach body.
+ private static async IAsyncEnumerable YieldAfterCancel(
+ T first,
+ T second,
+ TaskCompletionSource firstAckObserved,
+ CancellationTokenSource cts)
+ {
+ yield return first;
+ // Wait until the body has acked the first event — without forwarding the token, since
+ // honoring it would short-circuit the iterator and skip the second yield entirely.
+ await firstAckObserved.Task.ConfigureAwait(false);
+ await cts.CancelAsync().ConfigureAwait(false);
+ // At this point the next MoveNextAsync hands `second` back to the foreach body, which
+ // must observe the cancellation and break.
+ yield return second;
+ }
+
+ // Local poll-and-wait for a log predicate — small, self-contained, no Task.Delay loops
+ // outside of explicit polling intervals (kept short, bounded by the test cancellation token).
+ private static async Task WaitForLogAsync(
+ FakeLogCollector source,
+ Func predicate,
+ CancellationToken ct)
+ {
+ using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(TimeSpan.FromSeconds(5));
+
+ while (!timeout.IsCancellationRequested)
+ {
+ if (source.GetSnapshot().Any(predicate))
+ {
+ return;
+ }
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(20), timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+
+ source.GetSnapshot().Should().Contain(l => predicate(l),
+ "the expected log entry must arrive within the timeout");
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // Test harnesses — encapsulate the strict-mock graph so each test is short.
+ // ═══════════════════════════════════════════════════════════════
+
+ private sealed class GenAiHarness
+ {
+ public MockRepository Mocks { get; } = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty };
+ public Mock ScopeFactory { get; }
+ public Mock Scope { get; }
+ public Mock ServiceProvider { get; }
+ public Mock DocumentService { get; }
+ public Mock ConsumerFactory { get; }
+ public Mock> Consumer { get; }
+ public Mock> SseStream { get; }
+ public FakeLogCollector LogCollector { get; } = new();
+ public FakeLogger Logger { get; }
+
+ public GenAiHarness()
+ {
+ 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);
+
+ // .As<>() must precede .Object access.
+ 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);
+ }
+
+ public GenAiResultListener CreateSut() =>
+ new(ConsumerFactory.Object, ScopeFactory.Object, SseStream.Object, Logger);
+ }
+
+ private sealed class OcrHarness
+ {
+ public MockRepository Mocks { get; } = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty };
+ public Mock ScopeFactory { get; }
+ public Mock Scope { get; }
+ public Mock ServiceProvider { get; }
+ public Mock DocumentService { get; }
+ public Mock ConsumerFactory { get; }
+ public Mock> Consumer { get; }
+ public Mock> SseStream { get; }
+ public FakeLogCollector LogCollector { get; } = new();
+ public FakeLogger Logger { get; }
+
+ public OcrHarness()
+ {
+ 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);
+ }
+
+ public OcrResultListener CreateSut() =>
+ new(ConsumerFactory.Object, ScopeFactory.Object, SseStream.Object, Logger);
+ }
+}
diff --git a/PaperlessREST.Tests/Unit/OpenApiMetadataExtensionsTests.cs b/PaperlessREST.Tests/Unit/OpenApiMetadataExtensionsTests.cs
new file mode 100644
index 0000000..bb31c7a
--- /dev/null
+++ b/PaperlessREST.Tests/Unit/OpenApiMetadataExtensionsTests.cs
@@ -0,0 +1,137 @@
+using PaperlessREST.Host.Extensions;
+
+namespace PaperlessREST.Tests.Unit;
+
+public sealed class OpenApiMetadataExtensionsTests
+{
+ private static WebApplication BuildApp()
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ return builder.Build();
+ }
+
+ private static IReadOnlyCollection StatusCodesFor(WebApplication app, Action configure)
+ {
+ string path = "/" + Guid.NewGuid();
+ RouteHandlerBuilder route = app.MapGet(path, () => "ok");
+ configure(route);
+
+ // Materialize endpoints so Finally callbacks run and metadata is populated.
+ HashSet codes = [];
+ IEndpointRouteBuilder erb = app;
+ foreach (EndpointDataSource ds in erb.DataSources)
+ {
+ foreach (Endpoint endpoint in ds.Endpoints)
+ {
+ if (endpoint is RouteEndpoint rep && rep.RoutePattern.RawText == path)
+ {
+ foreach (IProducesResponseTypeMetadata m in rep.Metadata
+ .OfType())
+ {
+ codes.Add(m.StatusCode);
+ }
+ }
+ }
+ }
+
+ return codes;
+ }
+
+ [Fact]
+ public async Task ProducesNotFound_AddsStatus404Metadata()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesNotFound());
+
+ codes.Should().Contain(StatusCodes.Status404NotFound);
+ }
+
+ [Fact]
+ public async Task ProducesConflict_AddsStatus409Metadata()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesConflict());
+
+ codes.Should().Contain(StatusCodes.Status409Conflict);
+ }
+
+ [Fact]
+ public async Task ProducesServiceUnavailable_AddsStatus503Metadata()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesServiceUnavailable());
+
+ codes.Should().Contain(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [Fact]
+ public async Task ProducesGetByIdErrors_AddsStatus404Metadata()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesGetByIdErrors());
+
+ codes.Should().Contain(StatusCodes.Status404NotFound);
+ }
+
+ [Fact]
+ public async Task ProducesDeleteErrors_DefaultCanConflictFalse_AddsOnly404()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesDeleteErrors());
+
+ codes.Should().Contain(StatusCodes.Status404NotFound);
+ codes.Should().NotContain(StatusCodes.Status409Conflict);
+ }
+
+ [Fact]
+ public async Task ProducesDeleteErrors_CanConflictTrue_AddsBoth404And409()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesDeleteErrors(canConflict: true));
+
+ codes.Should().Contain(StatusCodes.Status404NotFound);
+ codes.Should().Contain(StatusCodes.Status409Conflict);
+ }
+
+ [Fact]
+ public async Task ProducesWriteErrors_Adds400_500_And503()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesWriteErrors());
+
+ codes.Should().Contain(StatusCodes.Status400BadRequest);
+ codes.Should().Contain(StatusCodes.Status500InternalServerError);
+ codes.Should().Contain(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [Fact]
+ public async Task ProducesDocumentUploadErrors_DelegatesToWriteErrors_AddsSameCodes()
+ {
+ await using WebApplication app = BuildApp();
+
+ IReadOnlyCollection codes = StatusCodesFor(app, r => r.ProducesDocumentUploadErrors());
+
+ codes.Should().Contain(StatusCodes.Status400BadRequest);
+ codes.Should().Contain(StatusCodes.Status500InternalServerError);
+ codes.Should().Contain(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ [Fact]
+ public async Task ProducesDeleteErrors_ReturnsBuilderForChaining()
+ {
+ await using WebApplication app = BuildApp();
+ RouteHandlerBuilder route = app.MapGet("/chain-" + Guid.NewGuid(), () => "ok");
+
+ RouteHandlerBuilder chained = route.ProducesDeleteErrors(canConflict: true);
+
+ chained.Should().BeSameAs(route);
+ }
+}
diff --git a/PaperlessREST.Tests/Unit/ReportProcessorTests.cs b/PaperlessREST.Tests/Unit/ReportProcessorTests.cs
index 08d5754..e561e4b 100644
--- a/PaperlessREST.Tests/Unit/ReportProcessorTests.cs
+++ b/PaperlessREST.Tests/Unit/ReportProcessorTests.cs
@@ -539,6 +539,37 @@ public async Task ProcessAsync_MalformedSchema_ReturnsValidationError()
result.FirstError.Type.Should().Be(ErrorType.Validation);
}
+ // ═══════════════════════════════════════════════════════════════
+ // TESTS: ProcessAsync - InvalidDate path AFTER schema validation passes
+ // Covers ReportProcessor.cs lines 100-103: DateOnly.TryParseExact failure
+ // for a date string that survives xs:date schema validation but is not
+ // yyyy-MM-dd (e.g., timezone-suffixed date).
+ // ═══════════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task ProcessAsync_DateWithTimezone_FailsDateOnlyTryParseExact()
+ {
+ // Arrange — "2024-01-15+02:00" satisfies xs:date (which permits timezone suffix)
+ // but DateOnly.TryParseExact("yyyy-MM-dd", ...) rejects it.
+ const string xmlContent = """
+
+
+
+ """;
+
+ string filePath = CreateTestFile("date-with-tz.xml", xmlContent);
+ ReportProcessor sut = CreateSut();
+
+ // Act
+ ErrorOr result = await sut.ProcessAsync(filePath, TestContext.Current.CancellationToken);
+
+ // Assert — must be the InvalidDate factory, distinct from InvalidSchema
+ result.IsError.Should().BeTrue();
+ result.FirstError.Type.Should().Be(ErrorType.Validation);
+ result.FirstError.Code.Should().Be("Report.InvalidDate");
+ result.FirstError.Description.Should().Contain("2024-01-15+02:00");
+ }
+
private string CreateTestFile(string fileName, string content)
{
string baseDir = AppContext.BaseDirectory;
diff --git a/PaperlessREST.Tests/Unit/RichProblemDetailsFactoryTests.cs b/PaperlessREST.Tests/Unit/RichProblemDetailsFactoryTests.cs
deleted file mode 100644
index 368a952..0000000
--- a/PaperlessREST.Tests/Unit/RichProblemDetailsFactoryTests.cs
+++ /dev/null
@@ -1,284 +0,0 @@
-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/ServiceCollectionExtensionsTests.cs b/PaperlessREST.Tests/Unit/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..4f2b4b9
--- /dev/null
+++ b/PaperlessREST.Tests/Unit/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,568 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using Asp.Versioning.ApiExplorer;
+using Hangfire.Common;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.OpenApi;
+using PaperlessREST.API;
+using PaperlessREST.Host.Extensions;
+using Scalar.AspNetCore;
+
+namespace PaperlessREST.Tests.Unit;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ private const string Bucket = "paperless-test-bucket";
+
+ // ──────────────────────────────────────────────────────────────────
+ // EnsureStorageBucketAsync
+ // ──────────────────────────────────────────────────────────────────
+
+ private static IServiceProvider BuildMinioServiceProvider(IMinioClient client)
+ {
+ ServiceCollection services = new();
+ services.AddSingleton(client);
+ services.AddSingleton>(Options.Create(new MinioOptions
+ {
+ Endpoint = "localhost:9000",
+ AccessKey = "k",
+ SecretKey = "s",
+ BucketName = Bucket
+ }));
+ return services.BuildServiceProvider();
+ }
+
+ [Fact]
+ public async Task EnsureStorageBucketAsync_BucketAlreadyExists_DoesNotCallMakeBucket()
+ {
+ Mock minio = new(MockBehavior.Strict);
+ minio.Setup(c => c.BucketExistsAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(true);
+
+ FakeLogCollector logs = new();
+ FakeLogger logger = new(logs);
+ IServiceProvider sp = BuildMinioServiceProvider(minio.Object);
+
+ await sp.EnsureStorageBucketAsync(logger);
+
+ minio.Verify(c => c.MakeBucketAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task EnsureStorageBucketAsync_BucketMissing_CreatesAndLogsInformation()
+ {
+ Mock minio = new(MockBehavior.Strict);
+ minio.Setup(c => c.BucketExistsAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+ minio.Setup(c => c.MakeBucketAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ FakeLogCollector logs = new();
+ FakeLogger logger = new(logs);
+ IServiceProvider sp = BuildMinioServiceProvider(minio.Object);
+
+ await sp.EnsureStorageBucketAsync(logger);
+
+ minio.Verify(c => c.MakeBucketAsync(It.IsAny(), It.IsAny()), Times.Once);
+ FakeLogRecord rec = logs.GetSnapshot().Should().ContainSingle(r => r.Level == LogLevel.Information).Subject;
+ rec.Message.Should().Contain(Bucket).And.Contain("created");
+ }
+
+ [Fact]
+ public async Task EnsureStorageBucketAsync_BucketRaceCondition_SwallowsAlreadyOwnedAndLogsDebug()
+ {
+ Mock minio = new(MockBehavior.Strict);
+ minio.Setup(c => c.BucketExistsAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+ minio.Setup(c => c.MakeBucketAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new ArgumentException("Bucket already owned by you"));
+
+ FakeLogCollector logs = new();
+ FakeLogger logger = new(logs);
+ IServiceProvider sp = BuildMinioServiceProvider(minio.Object);
+
+ Func act = async () => await sp.EnsureStorageBucketAsync(logger);
+
+ await act.Should().NotThrowAsync();
+ FakeLogRecord rec = logs.GetSnapshot().Should().ContainSingle(r => r.Level == LogLevel.Debug).Subject;
+ rec.Message.Should().Contain(Bucket).And.Contain("already exists");
+ }
+
+ [Fact]
+ public async Task EnsureStorageBucketAsync_BucketCreationFails_NonAlreadyOwnedArgumentException_Rethrows()
+ {
+ Mock minio = new(MockBehavior.Strict);
+ minio.Setup(c => c.BucketExistsAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+ ArgumentException differentArg = new("Bucket name invalid");
+ minio.Setup(c => c.MakeBucketAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(differentArg);
+
+ IServiceProvider sp = BuildMinioServiceProvider(minio.Object);
+
+ Func act = async () => await sp.EnsureStorageBucketAsync(NullLogger.Instance);
+
+ ArgumentException thrown = (await act.Should().ThrowAsync()).Which;
+ thrown.Message.Should().Be("Bucket name invalid");
+ }
+
+ // ──────────────────────────────────────────────────────────────────
+ // RegisterRecurringJobs
+ // ──────────────────────────────────────────────────────────────────
+
+ private static IServiceProvider BuildJobManagerServiceProvider(
+ IRecurringJobManager mgr,
+ BatchOptions opts)
+ {
+ ServiceCollection services = new();
+ services.AddSingleton(mgr);
+ services.AddSingleton>(Options.Create(opts));
+ return services.BuildServiceProvider();
+ }
+
+ private static BatchOptions MakeBatchOptions(string cron = "0 0 * * *") => new()
+ {
+ InputPath = "/in",
+ ArchivePath = "/arch",
+ ErrorPath = "/err",
+ FilePattern = "*.xml",
+ CronExpression = cron,
+ TimeZoneId = "UTC"
+ };
+
+ [Fact]
+ public void RegisterRecurringJobs_SchedulesJobWithConfiguredCronAndTimeZone()
+ {
+ Mock mgr = new();
+
+ BatchOptions opts = MakeBatchOptions();
+ IServiceProvider sp = BuildJobManagerServiceProvider(mgr.Object, opts);
+
+ FakeLogCollector logs = new();
+ FakeLogger logger = new(logs);
+
+ sp.RegisterRecurringJobs(logger);
+
+ mgr.Verify(m => m.AddOrUpdate(
+ BatchOptions.JobId,
+ It.IsAny(),
+ "0 0 * * *",
+ It.Is(o => o.TimeZone!.Id == "UTC")),
+ Times.Once);
+ FakeLogRecord rec = logs.GetSnapshot().Should().ContainSingle(r => r.Level == LogLevel.Information).Subject;
+ rec.Message.Should().Contain(BatchOptions.JobId)
+ .And.Contain("0 0 * * *")
+ .And.Contain("UTC");
+ }
+
+ // ──────────────────────────────────────────────────────────────────
+ // IServiceProvider extension property accessors
+ // ──────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Minio_AccessorReturnsRegisteredClient()
+ {
+ Mock minio = new(MockBehavior.Strict);
+ ServiceCollection services = new();
+ services.AddSingleton(minio.Object);
+ IServiceProvider sp = services.BuildServiceProvider();
+
+ sp.Minio.Should().BeSameAs(minio.Object);
+ }
+
+ [Fact]
+ public void MinioOpts_AccessorReturnsConfiguredOptions()
+ {
+ MinioOptions opts = new()
+ {
+ Endpoint = "host:9000",
+ AccessKey = "k",
+ SecretKey = "s",
+ BucketName = "b"
+ };
+ ServiceCollection services = new();
+ services.AddSingleton>(Options.Create(opts));
+ IServiceProvider sp = services.BuildServiceProvider();
+
+ sp.MinioOpts.Should().BeSameAs(opts);
+ }
+
+ [Fact]
+ public void BatchOpts_AccessorReturnsConfiguredOptions()
+ {
+ BatchOptions opts = MakeBatchOptions();
+ ServiceCollection services = new();
+ services.AddSingleton>(Options.Create(opts));
+ IServiceProvider sp = services.BuildServiceProvider();
+
+ sp.BatchOpts.Should().BeSameAs(opts);
+ }
+
+ [Fact]
+ public void DbFactory_AccessorReturnsRegisteredFactory()
+ {
+ Mock> factory = new(MockBehavior.Strict);
+ ServiceCollection services = new();
+ services.AddSingleton(factory.Object);
+ IServiceProvider sp = services.BuildServiceProvider();
+
+ sp.DbFactory.Should().BeSameAs(factory.Object);
+ }
+
+ // ──────────────────────────────────────────────────────────────────
+ // WebApplication.IsDev
+ // ──────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void IsDev_WhenEnvironmentIsDevelopment_ReturnsTrue()
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ EnvironmentName = "Development"
+ });
+ WebApplication app = builder.Build();
+
+ app.IsDev.Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsDev_WhenEnvironmentIsProduction_ReturnsFalse()
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ EnvironmentName = "Production"
+ });
+ WebApplication app = builder.Build();
+
+ app.IsDev.Should().BeFalse();
+ }
+
+ // ──────────────────────────────────────────────────────────────────
+ // AddDependencies-backed lambdas (ProblemDetails, Hangfire, OpenApi, ApiExplorer)
+ // and MapEndpoints in Development environment.
+ // ──────────────────────────────────────────────────────────────────
+
+ private static WebApplicationBuilder CreateWiredBuilder(string environment)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ EnvironmentName = environment
+ });
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:PaperlessDb"] = "Host=localhost;Database=test;Username=u;Password=p",
+ ["ConnectionStrings:Hangfire"] = "Host=localhost;Database=hf;Username=u;Password=p",
+ ["RabbitMQ:Uri"] = "amqp://guest:guest@localhost:5672/",
+ ["Storage:Minio:Endpoint"] = "localhost:9000",
+ ["Storage:Minio:AccessKey"] = "k",
+ ["Storage:Minio:SecretKey"] = "s",
+ ["Storage:Minio:BucketName"] = "b",
+ ["Elasticsearch:Uri"] = "http://localhost:9200",
+ ["Elasticsearch:DefaultIndex"] = "docs",
+ ["BatchProcessing:InputPath"] = "/in",
+ ["BatchProcessing:ArchivePath"] = "/arch",
+ ["BatchProcessing:ErrorPath"] = "/err",
+ ["BatchProcessing:FilePattern"] = "*.xml",
+ ["BatchProcessing:CronExpression"] = "0 0 * * *",
+ ["BatchProcessing:TimeZoneId"] = "UTC"
+ });
+ builder.AddDependencies();
+
+ // Swap PostgreSQL JobStorage for in-memory so resolving IHostedService doesn't
+ // require a running database. The production lambdas under test live in
+ // AddHangfireServer's opts callback — JobStorage choice is orthogonal.
+ builder.Services.RemoveAll();
+ builder.Services.AddSingleton(new MemoryStorage());
+ return builder;
+ }
+
+ private static HashSet CollectMappedPatterns(WebApplication app) =>
+ ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(s => s.Endpoints)
+ .OfType()
+ .Select(e => e.RoutePattern.RawText ?? string.Empty)
+ .ToHashSet(StringComparer.Ordinal);
+
+ [Fact]
+ public void MapEndpoints_WhenIsDev_RegistersDevelopmentOnlyRoutes()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Development");
+ WebApplication app = builder.Build();
+
+ app.MapEndpoints();
+
+ HashSet patterns = CollectMappedPatterns(app);
+
+ // Dev-only routes from the IsDev=true branch
+ patterns.Should().Contain(p => p.StartsWith("/openapi/", StringComparison.Ordinal));
+ patterns.Should().Contain(p => p.StartsWith("/docs/", StringComparison.Ordinal) || p == "/docs");
+ patterns.Should().Contain(p => p.StartsWith("/hangfire", StringComparison.Ordinal));
+ // Always-mapped routes prove MapEndpoints completed past the IsDev block
+ patterns.Should().Contain("/health");
+ // And the SSE / document endpoints are always mapped too
+ patterns.Should().Contain(p => p.Contains("/documents", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void MapEndpoints_WhenNotDev_OmitsDevelopmentOnlyRoutes()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Production");
+ WebApplication app = builder.Build();
+
+ app.MapEndpoints();
+
+ HashSet patterns = CollectMappedPatterns(app);
+
+ patterns.Should().NotContain(p => p.StartsWith("/docs", StringComparison.Ordinal));
+ patterns.Should().NotContain(p => p.StartsWith("/hangfire", StringComparison.Ordinal));
+ patterns.Should().NotContain(p => p.StartsWith("/openapi/", StringComparison.Ordinal));
+ patterns.Should().Contain("/health");
+ }
+
+ [Fact]
+ public void MapEndpoints_WhenIsDev_ScalarConfigureCallback_SetsTitleServersAndTheme()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Development");
+ WebApplication app = builder.Build();
+
+ app.MapEndpoints();
+
+ // Scalar's MapScalarApiReference captures the options Action inside the request delegate
+ // (it runs lazily on HTTP request, not at map time). Extract it via the documented
+ // internal field path and invoke it manually to verify the production lambda body.
+ RouteEndpoint scalarEndpoint = ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(s => s.Endpoints)
+ .OfType()
+ .Single(e => e.RoutePattern.RawText == "/docs/{documentName?}");
+
+ RequestDelegate requestDelegate = scalarEndpoint.RequestDelegate!;
+ object generatedTarget = requestDelegate.Target!;
+ FieldInfo handlerField = generatedTarget.GetType()
+ .GetField("handler", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)!;
+ Delegate handlerDelegate = (Delegate)handlerField.GetValue(generatedTarget)!;
+ object scalarClosure = handlerDelegate.Target!;
+ FieldInfo configureField = scalarClosure.GetType()
+ .GetField("configureOptions", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)!;
+ Action productionConfigure =
+ (Action)configureField.GetValue(scalarClosure)!;
+
+ ScalarOptions scalarOpts = new();
+ DefaultHttpContext http = new();
+
+ productionConfigure(scalarOpts, http);
+
+ scalarOpts.Title.Should().Be("Paperless OCR API");
+ scalarOpts.Servers.Should().NotBeNull();
+ scalarOpts.Servers!.Single().Url.Should().Be("http://localhost/");
+ scalarOpts.Theme.Should().Be(ScalarTheme.Kepler);
+ }
+
+ private static Action GetInlineProblemDetailsConfigure(IServiceCollection services)
+ {
+ // AddProblemDetails(opts => ...) registers a ConfigureNamedOptions
+ // whose Action is the production lambda at L141-146. Find it (ImplementationInstance, NOT
+ // the ProblemDetailsEnricher transient).
+ foreach (ServiceDescriptor d in services)
+ {
+ if (d.ServiceType != typeof(IConfigureOptions) ||
+ d.ImplementationInstance is not ConfigureNamedOptions named ||
+ named.Action is null)
+ {
+ continue;
+ }
+
+ return named.Action;
+ }
+
+ throw new InvalidOperationException("Inline ProblemDetails configure action not found.");
+ }
+
+ [Fact]
+ public void AddDependencies_ProblemDetailsCustomization_PopulatesTraceIdAndInstanceFromHttpContextWhenNoActivity()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Production");
+ Action configure = GetInlineProblemDetailsConfigure(builder.Services);
+
+ ProblemDetailsOptions opts = new();
+ configure(opts);
+ opts.CustomizeProblemDetails.Should().NotBeNull();
+
+ Activity? saved = Activity.Current;
+ Activity.Current = null;
+ try
+ {
+ DefaultHttpContext http = new();
+ http.Request.Method = "POST";
+ http.Request.Path = "/api/v1/documents";
+ http.TraceIdentifier = "trace-from-context-42";
+ ProblemDetailsContext ctx = new()
+ {
+ HttpContext = http,
+ ProblemDetails = new ProblemDetails()
+ };
+
+ opts.CustomizeProblemDetails!(ctx);
+
+ ctx.ProblemDetails.Extensions.Should().ContainKey("trace_id")
+ .WhoseValue.Should().Be("trace-from-context-42");
+ ctx.ProblemDetails.Extensions.Should().ContainKey("instance")
+ .WhoseValue.Should().Be("POST /api/v1/documents");
+ }
+ finally
+ {
+ Activity.Current = saved;
+ }
+ }
+
+ [Fact]
+ public void AddDependencies_ProblemDetailsCustomization_UsesActivityIdWhenAvailable()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Production");
+ Action configure = GetInlineProblemDetailsConfigure(builder.Services);
+
+ ProblemDetailsOptions opts = new();
+ configure(opts);
+ opts.CustomizeProblemDetails.Should().NotBeNull();
+
+ using Activity activity = new("unit-test-span");
+ activity.Start();
+ string expectedTrace = activity.Id!;
+
+ DefaultHttpContext http = new();
+ http.Request.Method = "DELETE";
+ http.Request.Path = "/api/v1/documents/abc";
+ http.TraceIdentifier = "would-be-fallback";
+ ProblemDetailsContext ctx = new()
+ {
+ HttpContext = http,
+ ProblemDetails = new ProblemDetails()
+ };
+
+ opts.CustomizeProblemDetails!(ctx);
+
+ ctx.ProblemDetails.Extensions["trace_id"].Should().Be(expectedTrace);
+ ctx.ProblemDetails.Extensions["instance"].Should().Be("DELETE /api/v1/documents/abc");
+ }
+
+ [Fact]
+ public void AddDependencies_HangfireServerOptions_SetWorkerCountAndServerName()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Production");
+
+ // Find only the BackgroundJobServerHostedService factory and invoke it directly,
+ // avoiding resolution of other IHostedService entries (RabbitMQ listeners would
+ // try to dial 127.0.0.1:5672 and fail).
+ ServiceDescriptor jobServerDescriptor = builder.Services.Single(d =>
+ d.ServiceType == typeof(IHostedService) &&
+ d.ImplementationFactory is not null &&
+ d.ImplementationFactory.GetType().GenericTypeArguments[1].FullName == "Hangfire.BackgroundJobServerHostedService");
+
+ WebApplication app = builder.Build();
+ object jobServer = jobServerDescriptor.ImplementationFactory!(app.Services);
+ FieldInfo optionsField = jobServer.GetType().GetField("_options",
+ BindingFlags.NonPublic | BindingFlags.Instance)!;
+ BackgroundJobServerOptions opts = (BackgroundJobServerOptions)optionsField.GetValue(jobServer)!;
+
+ opts.WorkerCount.Should().Be(Environment.ProcessorCount);
+ opts.ServerName.Should().StartWith(Environment.MachineName + "-");
+ // Trailing token must be a 32-char "N"-format GUID with no dashes.
+ string suffix = opts.ServerName![(Environment.MachineName.Length + 1)..];
+ suffix.Should().HaveLength(32);
+ suffix.Should().MatchRegex("^[0-9a-f]{32}$");
+ }
+
+ private static Action GetOpenApiConfigure(IServiceCollection services)
+ {
+ // AddOpenApi(Action) registers Configure("v1", action). Find the
+ // ConfigureNamedOptions whose Name == "v1".
+ foreach (ServiceDescriptor d in services)
+ {
+ if (d.ServiceType != typeof(IConfigureOptions) ||
+ d.ImplementationInstance is not ConfigureNamedOptions named ||
+ named.Action is null)
+ {
+ continue;
+ }
+
+ return named.Action;
+ }
+
+ throw new InvalidOperationException("OpenApi configure action not found.");
+ }
+
+ [Fact]
+ public void AddDependencies_OpenApiCreateSchemaReferenceId_ReturnsNullForEnumAndDefaultForOther()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Production");
+ Action configure = GetOpenApiConfigure(builder.Services);
+
+ OpenApiOptions opts = new();
+ configure(opts);
+ opts.CreateSchemaReferenceId.Should().NotBeNull();
+
+ JsonSerializerOptions jsonOpts = new();
+ JsonTypeInfo enumInfo = JsonTypeInfo.CreateJsonTypeInfo(typeof(DayOfWeek), jsonOpts);
+ JsonTypeInfo dtoInfo = JsonTypeInfo.CreateJsonTypeInfo(typeof(DocumentDto), jsonOpts);
+
+ string? enumId = opts.CreateSchemaReferenceId!(enumInfo);
+ string? dtoId = opts.CreateSchemaReferenceId!(dtoInfo);
+
+ enumId.Should().BeNull();
+ dtoId.Should().Be(OpenApiOptions.CreateDefaultSchemaReferenceId(dtoInfo));
+ dtoId.Should().Be(nameof(DocumentDto));
+ }
+
+ [Fact]
+ public async Task AddDependencies_OpenApiDocumentTransformer_SetsTitleVersionAndDescription()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Production");
+ Action configure = GetOpenApiConfigure(builder.Services);
+
+ OpenApiOptions opts = new();
+ configure(opts);
+
+ FieldInfo transformersField = typeof(OpenApiOptions).GetField("DocumentTransformers",
+ BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)!;
+ System.Collections.IList transformers = (System.Collections.IList)transformersField.GetValue(opts)!;
+ transformers.Count.Should().Be(1);
+
+ object delegateTransformer = transformers[0]!;
+ // DelegateOpenApiDocumentTransformer wraps the user Func in _documentTransformer.
+ FieldInfo delegateField = delegateTransformer.GetType().GetField("_documentTransformer",
+ BindingFlags.NonPublic | BindingFlags.Instance)!;
+ Func productionDelegate =
+ (Func)
+ delegateField.GetValue(delegateTransformer)!;
+
+ OpenApiDocument doc = new();
+ OpenApiDocumentTransformerContext ctx = new()
+ {
+ DocumentName = "v1",
+ DescriptionGroups = Array.Empty(),
+ ApplicationServices = new ServiceCollection().BuildServiceProvider()
+ };
+
+ await productionDelegate(doc, ctx, TestContext.Current.CancellationToken);
+
+ doc.Info.Should().NotBeNull();
+ doc.Info!.Title.Should().Be("Paperless OCR API");
+ doc.Info!.Version.Should().Be("v1");
+ doc.Info!.Description.Should().Be("API for uploading and processing PDF documents with OCR");
+ }
+
+ [Fact]
+ public void AddDependencies_ApiExplorerOptions_SetsGroupNameFormatAndSubstituteApiVersionInUrl()
+ {
+ WebApplicationBuilder builder = CreateWiredBuilder("Production");
+ WebApplication app = builder.Build();
+
+ ApiExplorerOptions opts = app.Services.GetRequiredService>().Value;
+
+ opts.GroupNameFormat.Should().Be("'v'VVV");
+ opts.SubstituteApiVersionInUrl.Should().BeTrue();
+ }
+}
diff --git a/PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs b/PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs
index 32b7c48..f608460 100644
--- a/PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs
+++ b/PaperlessREST.Tests/Unit/TypedErrorOrAsyncExtensionsTests.cs
@@ -2,305 +2,396 @@
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 const string RouteName = "GetById";
- private sealed record Doc(Guid Id, string FileName);
+ // Helpers return Task-wrapped ErrorOr; Task doesn't trigger CA2012.
+ // Tests target the Task> overload, which internally delegates to
+ // the ValueTask> overload, exercising both layers.
+ private static Task> TaskValue(T value) =>
+ Task.FromResult>(value);
- private sealed record DocResponse(string Id, string FileName);
+ private static Task> TaskFromError(Error err) =>
+ Task.FromResult>(err);
- private static DocResponse Map(Doc d) => new(d.Id.ToString(), d.FileName);
+ private static Task> TaskFromErrors(List errors) =>
+ Task.FromResult>(errors);
- private static object RouteValues(Doc d) => new { id = d.Id };
-
- // ─── ErrorOr.ToOkOr404 (sync overload) ───────────────────────────────
+ // ─── ErrorOr.ToOkOr404 (sync) ─────────────────────────────────────────
[Fact]
- public void ToOkOr404_Sync_SuccessfulResult_ReturnsOk()
+ public void ToOkOr404_Sync_Success_ReturnsOkWithMappedValue()
{
- Doc doc = new(Guid.CreateVersion7(), "f.pdf");
- ErrorOr result = doc;
-
- Results, NotFound> output = result.ToOkOr404(Map);
+ ErrorOr result = (ErrorOr)7;
+ Results, NotFound> typed = result.ToOkOr404(v => $"v={v}");
- output.Result.Should().BeOfType>()
- .Which.Value!.FileName.Should().Be("f.pdf");
+ Ok? ok = typed.Result.Should().BeOfType>().Subject;
+ ok.Value.Should().Be("v=7");
}
[Fact]
- public void ToOkOr404_Sync_NotFound_ReturnsNotFound()
+ public void ToOkOr404_Sync_NotFound_ReturnsNotFoundResult()
{
- ErrorOr result = Error.NotFound("Doc.NotFound", "missing");
+ ErrorOr result = (ErrorOr)Error.NotFound("X", "missing");
+ Results, NotFound> typed = result.ToOkOr404(v => v.ToString());
- Results, NotFound> output = result.ToOkOr404(Map);
-
- output.Result.Should().BeOfType();
+ typed.Result.Should().BeOfType();
}
[Fact]
- public void ToOkOr404_Sync_UnexpectedErrorType_ThrowsContractViolation()
+ public void ToOkOr404_Sync_NonNotFoundError_ThrowsContractViolation()
{
- ErrorOr result = Error.Conflict("Doc.Locked", "locked");
+ ErrorOr result = (ErrorOr)Error.Conflict("X", "conflict");
- Action act = () => _ = result.ToOkOr404(Map, "GetDocumentById");
+ Action act = () => result.ToOkOr404(v => v.ToString());
- act.Should().Throw()
- .Which.ExpectedErrorTypes.Should().Equal(ErrorType.NotFound);
+ ContractViolationException ex = act.Should().Throw().Which;
+ ex.ExpectedErrorTypes.Should().ContainSingle().Which.Should().Be(ErrorType.NotFound);
+ ex.ActualError.Code.Should().Be("X");
}
- // ─── ErrorOr.ToNoContentOr404 (sync overload) ───────────────────
+ // ─── ErrorOr.ToNoContentOr404 (sync) ────────────────────────────
[Fact]
public void ToNoContentOr404_Sync_Success_ReturnsNoContent()
{
- ErrorOr result = Result.Deleted;
-
- Results output = result.ToNoContentOr404();
+ ErrorOr result = (ErrorOr)Result.Deleted;
+ Results typed = result.ToNoContentOr404();
- output.Result.Should().BeOfType();
+ typed.Result.Should().BeOfType();
}
[Fact]
- public void ToNoContentOr404_Sync_NotFound_ReturnsNotFound()
+ public void ToNoContentOr404_Sync_NotFound_ReturnsNotFoundResult()
{
- ErrorOr result = Error.NotFound("Doc.NotFound", "missing");
+ ErrorOr result = (ErrorOr)Error.NotFound("X", "missing");
+ Results typed = result.ToNoContentOr404();
- Results output = result.ToNoContentOr404();
-
- output.Result.Should().BeOfType();
+ typed.Result.Should().BeOfType();
}
[Fact]
- public void ToNoContentOr404_Sync_UnexpectedErrorType_ThrowsContractViolation()
+ public void ToNoContentOr404_Sync_NonNotFoundError_ThrowsContractViolation()
{
- ErrorOr result = Error.Conflict("Doc.Locked", "locked");
+ ErrorOr result = (ErrorOr)Error.Failure("X", "boom");
- Action act = () => _ = result.ToNoContentOr404("Delete");
+ Action act = () => result.ToNoContentOr404();
- act.Should().Throw();
+ ContractViolationException ex = act.Should().Throw().Which;
+ ex.ExpectedErrorTypes.Should().ContainSingle().Which.Should().Be(ErrorType.NotFound);
}
- // ─── ValueTask>.ToOkOr404 (async overload) ────────────────────
+ // ─── ValueTask>.ToOkOr404 ──────────────────────────────────────
[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);
+ Results