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, NotFound> typed = await TaskValue(42).ToOkOr404(v => v.ToString()); - output.Result.Should().BeOfType>() - .Which.Value!.FileName.Should().Be("vf.pdf"); + Ok? ok = typed.Result.Should().BeOfType>().Subject; + ok.Value.Should().Be("42"); } [Fact] public async Task ToOkOr404_ValueTask_NotFound_ReturnsNotFound() { - ValueTask> task = new(Error.NotFound("Doc.NotFound", "missing")); + Results, NotFound> typed = + await TaskFromError(Error.NotFound("X", "missing")).ToOkOr404(v => v.ToString()); + + typed.Result.Should().BeOfType(); + } - Results, NotFound> output = await task.ToOkOr404(Map); + [Fact] + public async Task ToOkOr404_ValueTask_NonNotFoundError_ThrowsContractViolation() + { + Func act = async () => + await TaskFromError(Error.Failure("X", "boom")).ToOkOr404(v => v.ToString()); - output.Result.Should().BeOfType(); + ContractViolationException ex = (await act.Should().ThrowAsync()).Which; + ex.ExpectedErrorTypes.Should().ContainSingle().Which.Should().Be(ErrorType.NotFound); } - // ─── ValueTask>.ToNoContentOr404 (async overload) ────── + // ─── ValueTask>.ToNoContentOr404 ──────────────────────── [Fact] public async Task ToNoContentOr404_ValueTask_Success_ReturnsNoContent() { - ValueTask> task = new(ErrorOrFactory.From(Result.Deleted)); - - Results output = await task.ToNoContentOr404(); + Results typed = await TaskValue(Result.Deleted).ToNoContentOr404(); - output.Result.Should().BeOfType(); + typed.Result.Should().BeOfType(); } [Fact] public async Task ToNoContentOr404_ValueTask_NotFound_ReturnsNotFound() { - ValueTask> task = new(Error.NotFound("Doc.NotFound", "missing")); + Results typed = + await TaskFromError(Error.NotFound("X", "missing")).ToNoContentOr404(); - Results output = await task.ToNoContentOr404(); + typed.Result.Should().BeOfType(); + } + + [Fact] + public async Task ToNoContentOr404_ValueTask_NonNotFoundError_ThrowsContractViolation() + { + Func act = async () => + await TaskFromError(Error.Conflict("X", "conflict")).ToNoContentOr404(); - output.Result.Should().BeOfType(); + await act.Should().ThrowAsync(); } - // ─── Task>.ToOkOr404 (Task overload — delegates to ValueTask) ─ + // ─── Task>.ToOkOr404 (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> typed = await TaskValue(99).ToOkOr404(v => $"#{v}"); - Results, NotFound> output = await task.ToOkOr404(Map); + Ok? ok = typed.Result.Should().BeOfType>().Subject; + ok.Value.Should().Be("#99"); + } - output.Result.Should().BeOfType>(); + [Fact] + public async Task ToOkOr404_Task_NotFound_ReturnsNotFound() + { + Results, NotFound> typed = + await TaskFromError(Error.NotFound("X", "missing")).ToOkOr404(v => v.ToString()); + + typed.Result.Should().BeOfType(); } + // ─── Task>.ToNoContentOr404 (delegates) ───────────────── + [Fact] public async Task ToNoContentOr404_Task_Success_ReturnsNoContent() { - Task> task = Task.FromResult>(Result.Deleted); + Results typed = await TaskValue(Result.Deleted).ToNoContentOr404(); + + typed.Result.Should().BeOfType(); + } - Results output = await task.ToNoContentOr404(); + [Fact] + public async Task ToNoContentOr404_Task_NotFound_ReturnsNotFound() + { + Results typed = + await TaskFromError(Error.NotFound("X", "missing")).ToNoContentOr404(); - output.Result.Should().BeOfType(); + typed.Result.Should().BeOfType(); } - // ─── ToAcceptedAtRouteOrProblem ───────────────────────────────────────── + // ─── ToAcceptedAtRouteOrProblem — success ──────────────────────────────── [Fact] - public async Task ToAcceptedAtRouteOrProblem_Success_ReturnsAcceptedAtRoute() + public async Task ToAcceptedAtRouteOrProblem_ValueTask_Success_ReturnsAcceptedAtRouteWithMappedValueAndRouteValues() { - Doc doc = new(Guid.CreateVersion7(), "accepted.pdf"); - ValueTask> task = new(ErrorOrFactory.From(doc)); + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskValue(7).ToAcceptedAtRouteOrProblem( + v => $"v={v}", + RouteName, + v => new { id = v }); + + AcceptedAtRoute? accepted = typed.Result.Should().BeOfType>().Subject; + accepted.RouteName.Should().Be(RouteName); + accepted.Value.Should().Be("v=7"); + accepted.RouteValues["id"].Should().Be(7); + } - Results, ValidationProblem, ProblemHttpResult> output = - await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Task_Success_ReturnsAcceptedAtRoute() + { + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskValue(5).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); - AcceptedAtRoute? accepted = output.Result as AcceptedAtRoute; - accepted.Should().NotBeNull(); - accepted!.RouteName.Should().Be(RouteName); - accepted.Value!.FileName.Should().Be("accepted.pdf"); + AcceptedAtRoute? accepted = typed.Result.Should().BeOfType>().Subject; + accepted.Value.Should().Be(5); } + // ─── Validation path ───────────────────────────────────────────────────── + [Fact] - public async Task ToAcceptedAtRouteOrProblem_ValidationError_Returns422WithGroupedErrors() + public async Task ToAcceptedAtRouteOrProblem_Validation_GroupsErrorsByCode() { - Error[] errors = + List errors = [ - Error.Validation("FileName", "is required"), - Error.Validation("FileName", "must be PDF"), - Error.Validation("FileSize", "too large") + Error.Validation("FileName", "FileName required"), + Error.Validation("FileName", "FileName must be PDF"), + Error.Validation("Size", "Size too large") ]; - ValueTask> task = new(errors); - Results, ValidationProblem, ProblemHttpResult> output = - await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromErrors(errors).ToAcceptedAtRouteOrProblem( + v => v, RouteName, v => new { id = v }); - ValidationProblem? vp = output.Result as ValidationProblem; - vp.Should().NotBeNull(); - HttpValidationProblemDetails details = vp!.ProblemDetails; + ValidationProblem? vp = typed.Result.Should().BeOfType().Subject; + HttpValidationProblemDetails details = vp.ProblemDetails; details.Errors.Should().ContainKey("FileName"); - details.Errors["FileName"].Should().BeEquivalentTo(["is required", "must be PDF"]); - details.Errors.Should().ContainKey("FileSize"); + details.Errors["FileName"].Should().BeEquivalentTo("FileName required", "FileName must be PDF"); + details.Errors.Should().ContainKey("Size"); + details.Errors["Size"].Should().BeEquivalentTo("Size too large"); + details.Errors.Should().HaveCount(2); } + // ─── Failure → 500, kebab URN, camelCase extensions ────────────────────── + [Fact] - public async Task ToAcceptedAtRouteOrProblem_Failure_Returns500WithKebabCaseUrn() + public async Task ToAcceptedAtRouteOrProblem_Failure_ReturnsServerErrorWithKebabUrnAndCamelCaseExtensions() { - Error failure = Error.Failure("Document.UploadFailed", "storage broke"); - ValueTask> task = new(failure); + Dictionary metadata = new() + { + ["StoragePath"] = "documents/abc.pdf", + ["AttemptCount"] = 3 + }; + Error err = Error.Failure("Document.StorageFailed", "Failed to store", metadata); + + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); + + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; + problem.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + // ToKebabCase emits a dash before every internal uppercase char, including + // the one right after the dot in "Document.StorageFailed". + problem.ProblemDetails.Type.Should().Be("urn:paperless:error:document.-storage-failed"); + problem.ProblemDetails.Title.Should().Be("Document.StorageFailed"); + problem.ProblemDetails.Detail.Should().Be("Failed to store"); + problem.ProblemDetails.Extensions["storagePath"].Should().Be("documents/abc.pdf"); + problem.ProblemDetails.Extensions["attemptCount"].Should().Be(3); + } - Results, ValidationProblem, ProblemHttpResult> output = - await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Failure_NullMetadata_OmitsExtensions() + { + Error err = Error.Failure("Plain.Failure", "no metadata"); + + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); - 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"); + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; + problem.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + // "Plain.Failure" → 'P' becomes 'p' (i=0), '.' stays, 'F' becomes '-f'. + problem.ProblemDetails.Type.Should().Be("urn:paperless:error:plain.-failure"); + problem.ProblemDetails.Extensions.Should().NotContainKey("storagePath"); } [Fact] - public async Task ToAcceptedAtRouteOrProblem_FailureWithMetadata_PropagatesAsCamelCasedExtensions() + public async Task ToAcceptedAtRouteOrProblem_Failure_EmptyMetadata_OmitsExtensions() { - Error failure = Error.Custom( - (int)ErrorType.Failure, "Document.UploadFailed", "storage broke", - new Dictionary { ["AttemptCount"] = 3, ["LastTriedAt"] = "2026-05-15" }); - ValueTask> task = new(failure); + Dictionary emptyMeta = []; + Error err = Error.Failure("Plain.Failure", "empty meta", emptyMeta); - Results, ValidationProblem, ProblemHttpResult> output = - await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); - 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"); + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; + problem.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + problem.ProblemDetails.Extensions.Should().NotContainKey("retryAfter"); } + // ─── Unexpected → 503 retryAfter ───────────────────────────────────────── + [Fact] - public async Task ToAcceptedAtRouteOrProblem_Unexpected_Returns503WithRetryAfterFromMetadata() + public async Task ToAcceptedAtRouteOrProblem_Unexpected_WithRetryAfterMetadata_UsesProvidedValue() { - Error unavailable = Error.Custom( - (int)ErrorType.Unexpected, "Document.StorageUnavailable", "down", - new Dictionary { ["RetryAfter"] = 90, ["AffectedResource"] = "docs/x" }); - ValueTask> task = new(unavailable); + Dictionary metadata = new() + { + ["RetryAfter"] = 120, + ["AffectedResource"] = "documents/x.pdf" + }; + Error err = Error.Unexpected("Document.StorageUnavailable", "tmp", metadata); - Results, ValidationProblem, ProblemHttpResult> output = - await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); - ProblemHttpResult problem = (ProblemHttpResult)output.Result!; + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; 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"); + problem.ProblemDetails.Type.Should().Be("urn:paperless:error:document.-storage-unavailable"); + problem.ProblemDetails.Extensions["retryAfter"].Should().Be(120); + problem.ProblemDetails.Extensions["affectedResource"].Should().Be("documents/x.pdf"); + problem.ProblemDetails.Extensions.Should().NotContainKey("RetryAfter"); } [Fact] - public async Task ToAcceptedAtRouteOrProblem_UnexpectedWithoutMetadata_DefaultsRetryAfterTo30() + public async Task ToAcceptedAtRouteOrProblem_Unexpected_WithoutRetryAfterMetadata_Defaults30() { - Error unavailable = Error.Unexpected("Backend.Down", "no metadata"); - ValueTask> task = new(unavailable); + Dictionary metadata = new() { ["AffectedResource"] = "x.pdf" }; + Error err = Error.Unexpected("Document.StorageUnavailable", "tmp", metadata); - Results, ValidationProblem, ProblemHttpResult> output = - await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); - ProblemHttpResult problem = (ProblemHttpResult)output.Result!; + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; problem.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); problem.ProblemDetails.Extensions["retryAfter"].Should().Be(30); + problem.ProblemDetails.Extensions["affectedResource"].Should().Be("x.pdf"); } [Fact] - public async Task ToAcceptedAtRouteOrProblem_UnexpectedRetryAfterOnly_OmitsOtherExtensions() + public async Task ToAcceptedAtRouteOrProblem_Unexpected_NullMetadata_Defaults30AndEmpty() { - // 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"); + Error err = Error.Unexpected("Document.SearchUnavailable", "Search down"); + + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); + + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; + problem.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + problem.ProblemDetails.Extensions["retryAfter"].Should().Be(30); } [Fact] - public async Task ToAcceptedAtRouteOrProblem_UnsupportedErrorType_ThrowsContractViolation() + public async Task ToAcceptedAtRouteOrProblem_Unexpected_EmptyMetadata_Defaults30NoExtras() { - ValueTask> task = new(Error.NotFound("Doc.NotFound", "missing")); + Dictionary emptyMeta = []; + Error err = Error.Unexpected("Document.SearchUnavailable", "Search down", emptyMeta); - Func act = async () => _ = await task.ToAcceptedAtRouteOrProblem( - Map, RouteName, RouteValues, "PostDocument"); + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); - (await act.Should().ThrowAsync()) - .Which.ExpectedErrorTypes.Should().Equal( - ErrorType.Validation, ErrorType.Failure, ErrorType.Unexpected); + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; + problem.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + problem.ProblemDetails.Extensions["retryAfter"].Should().Be(30); + problem.ProblemDetails.Extensions.Should().ContainSingle(kv => kv.Key == "retryAfter"); } - // ─── Task overload also routes through the same internals ────────────── + // ─── Unhandled error type → contract violation ────────────────────────── [Fact] - public async Task ToAcceptedAtRouteOrProblem_Task_Success_ReturnsAcceptedAtRoute() + public async Task ToAcceptedAtRouteOrProblem_UnhandledErrorType_ThrowsContractViolation() + { + Error err = Error.Conflict("Document.Locked", "locked"); + + Func act = async () => + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); + + ContractViolationException ex = (await act.Should().ThrowAsync()).Which; + ex.ExpectedErrorTypes.Should().BeEquivalentTo( + new[] { ErrorType.Validation, ErrorType.Failure, ErrorType.Unexpected }, + opts => opts.WithStrictOrdering()); + ex.ActualError.Code.Should().Be("Document.Locked"); + } + + // ─── ToKebabCase indirect coverage — mid-string uppercase forces dash ──── + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Failure_LowercaseStartCamelMid_DashesOnlyInternalUppercase() + { + Error err = Error.Failure("iPadOrNot", "x"); + + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); + + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; + problem.ProblemDetails.Type.Should().Be("urn:paperless:error:i-pad-or-not"); + } + + [Fact] + public async Task ToAcceptedAtRouteOrProblem_Failure_SingleLowercaseToken_NoDashes() { - Doc doc = new(Guid.CreateVersion7(), "task-success.pdf"); - Task> task = Task.FromResult>(doc); + Error err = Error.Failure("simple", "x"); - Results, ValidationProblem, ProblemHttpResult> output = - await task.ToAcceptedAtRouteOrProblem(Map, RouteName, RouteValues); + Results, ValidationProblem, ProblemHttpResult> typed = + await TaskFromError(err).ToAcceptedAtRouteOrProblem(v => v, RouteName, v => new { id = v }); - output.Result.Should().BeOfType>(); + ProblemHttpResult? problem = typed.Result.Should().BeOfType().Subject; + problem.ProblemDetails.Type.Should().Be("urn:paperless:error:simple"); } } diff --git a/PaperlessREST.Tests/Unit/UploadDocumentRequestDtoTests.cs b/PaperlessREST.Tests/Unit/UploadDocumentRequestDtoTests.cs new file mode 100644 index 0000000..6d63a04 --- /dev/null +++ b/PaperlessREST.Tests/Unit/UploadDocumentRequestDtoTests.cs @@ -0,0 +1,47 @@ +namespace PaperlessREST.Tests.Unit; + +/// +/// Coverage for the record's compiler-generated +/// copy constructor (used by with expressions). The other members (File getter/setter) +/// are exercised by every test that uploads a document. +/// +public sealed class UploadDocumentRequestDtoTests +{ + [Fact] + public void With_NewFile_ProducesNewInstanceWithCopiedThenOverriddenValue() + { + // Arrange — initial request via the production constructor + Mock originalFile = new(); + originalFile.Setup(f => f.FileName).Returns("original.pdf"); + UploadDocumentRequest original = new() { File = originalFile.Object }; + + Mock replacementFile = new(); + replacementFile.Setup(f => f.FileName).Returns("replacement.pdf"); + + // Act — exercises the synthesized copy-ctor `(UploadDocumentRequest other)` + UploadDocumentRequest copy = original with { File = replacementFile.Object }; + + // Assert + copy.Should().NotBeSameAs(original); + copy.File.Should().BeSameAs(replacementFile.Object); + copy.File.FileName.Should().Be("replacement.pdf"); + original.File.FileName.Should().Be("original.pdf"); + } + + [Fact] + public void With_EmptyChange_CopiesAllValuesPreservingFile() + { + // Arrange + Mock file = new(); + file.Setup(f => f.FileName).Returns("doc.pdf"); + UploadDocumentRequest original = new() { File = file.Object }; + + // Act — `with { }` still routes through the copy-ctor + UploadDocumentRequest copy = original with { }; + + // Assert — record value equality + copy-ctor preserves reference + copy.Should().NotBeSameAs(original); + copy.Should().Be(original); + copy.File.Should().BeSameAs(file.Object); + } +} diff --git a/PaperlessREST/Features/BatchProcessing/Application/ReportErrors.cs b/PaperlessREST/Features/BatchProcessing/Application/ReportErrors.cs index e41e253..5515358 100644 --- a/PaperlessREST/Features/BatchProcessing/Application/ReportErrors.cs +++ b/PaperlessREST/Features/BatchProcessing/Application/ReportErrors.cs @@ -6,10 +6,6 @@ public static Error FileNotFound(string path) => Error.NotFound( "Report.FileNotFound", $"File not found: {path}"); - public static Error InvalidXml(string details) => Error.Validation( - "Report.InvalidXml", - $"File is not valid XML: {details}"); - public static Error InvalidSchema(string details) => Error.Validation( "Report.InvalidSchema", $"XML does not match expected schema: {details}"); @@ -23,23 +19,3 @@ public static Error InvalidGuid(int index) => Error.Validation( $"Document at index {index} has invalid or empty GUID"); } - - -public static class BatchErrors -{ - public static Error PathRequired(string property) => Error.Validation( - "Batch.PathRequired", - $"{BatchOptions.SectionName}:{property} is required"); - - public static Error InvalidPath(string property, string details) => Error.Validation( - "Batch.InvalidPath", - $"{BatchOptions.SectionName}:{property} is not a valid path: {details}"); - - public static Error PathsNotDistinct() => Error.Validation( - "Batch.PathsNotDistinct", - $"{BatchOptions.SectionName} paths (InputPath, ArchivePath, ErrorPath) must be distinct"); - - public static Error InvalidTimeZone(string timeZoneId) => Error.Validation( - "Batch.InvalidTimeZone", - $"{BatchOptions.SectionName}:TimeZoneId '{timeZoneId}' is not a valid system timezone"); -} diff --git a/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs b/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs index 32dcafb..867febb 100644 --- a/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs +++ b/PaperlessREST/Features/BatchProcessing/Application/ReportProcessor.cs @@ -122,12 +122,11 @@ private XmlSchemaSet LoadSchemas() { return ReportErrors.FileNotFound(path); } - catch (XmlException ex) - { - return ReportErrors.InvalidXml(ex.Message); - } catch (InvalidOperationException ex) { + // XmlSerializer.Deserialize wraps both XmlException (well-formedness) + // and XmlSchemaException (validation) as InvalidOperationException, so a + // separate `catch (XmlException)` branch is unreachable. return ReportErrors.InvalidSchema(ex.Message); } catch (XmlSchemaException ex) diff --git a/PaperlessREST/Features/DocumentManagement/Application/DocumentErrors.cs b/PaperlessREST/Features/DocumentManagement/Application/DocumentErrors.cs index 9d86fc1..02aa352 100644 --- a/PaperlessREST/Features/DocumentManagement/Application/DocumentErrors.cs +++ b/PaperlessREST/Features/DocumentManagement/Application/DocumentErrors.cs @@ -53,13 +53,6 @@ public static Error NotFound(Guid id) => Error.NotFound( "Document.NotFound", $"Document {id} not found"); - /// - /// Invalid state transition attempted (e.g., completing an already-completed document). - /// - public static Error InvalidStateTransition(DocumentStatus from, DocumentStatus to) => Error.Validation( - "Document.InvalidStateTransition", - $"Cannot transition from {from} to {to}"); - /// /// Cannot mark document as completed - not in Pending state. /// @@ -73,61 +66,4 @@ public static Error CannotComplete(DocumentStatus currentStatus) => Error.Valida public static Error CannotFail(DocumentStatus currentStatus) => Error.Validation( "Document.CannotFail", $"Cannot fail document in {currentStatus} status"); - - /// - /// Storage service (MinIO) is temporarily unavailable. - /// - /// - /// This is a transient error - the client should retry after the Retry-After header. - /// - public static Error StorageUnavailable(string storagePath) => Error.Unexpected( - "Document.StorageUnavailable", - $"Storage service temporarily unavailable for {storagePath}"); - - public static Error StorageTimeout(string storagePath) => Error.Unexpected( - "Document.StorageTimeout", - $"Storage service did not respond within timeout for {storagePath}"); - - public static Error StorageServerError(string storagePath, int statusCode) => Error.Unexpected( - "Document.StorageServerError", - $"Storage service returned {statusCode} for {storagePath}"); - - public static Error StorageConnectionFailed(string storagePath) => Error.Unexpected( - "Document.StorageConnectionFailed", - $"Cannot connect to storage service for {storagePath}"); - - /// - /// Search service (Elasticsearch) is temporarily unavailable. - /// - public static Error SearchUnavailable() => Error.Unexpected( - "Document.SearchUnavailable", - "Search service temporarily unavailable"); - - /// - /// Message broker (RabbitMQ) is temporarily unavailable. - /// - public static Error MessageBrokerUnavailable() => Error.Unexpected( - "Document.MessageBrokerUnavailable", - "Message broker temporarily unavailable"); - - public static Error MessageBrokerUnavailable(string reason) => Error.Unexpected( - "Document.MessageBrokerUnavailable", - $"Message broker temporarily unavailable: {reason}"); - - /// - /// Permanent storage failure - file may be corrupted or permissions issue. - /// - /// - /// This is NOT a transient error - requires investigation. - /// - public static Error StorageFailed(string storagePath) => Error.Failure( - "Document.StorageFailed", - $"Failed to store document at {storagePath}"); - - /// - /// Permanent delete failure - database constraint or orphaned data. - /// - public static Error DeleteFailed(Guid id) => Error.Failure( - "Document.DeleteFailed", - $"Failed to delete document {id}"); } diff --git a/PaperlessREST/Host/Extensions/RichProblemDetailsFactory.cs b/PaperlessREST/Host/Extensions/RichProblemDetailsFactory.cs deleted file mode 100644 index c2a339e..0000000 --- a/PaperlessREST/Host/Extensions/RichProblemDetailsFactory.cs +++ /dev/null @@ -1,194 +0,0 @@ -namespace PaperlessREST.Host.Extensions; - -/// -/// RFC 7807 Problem Details extensions for domain errors. -/// -/// -/// -/// RFC 7807 allows custom extensions beyond the standard fields (type, title, status, detail, instance). -/// This enables rich, machine-readable error responses that clients can programmatically handle. -/// -/// -/// Example response with extensions: -/// -/// -/// { -/// "type": "urn:paperless:error:document-storage-unavailable", -/// "title": "Document.StorageUnavailable", -/// "status": 503, -/// "detail": "Storage service temporarily unavailable for documents/2025-01/abc.pdf", -/// "retryAfter": 30, -/// "affectedResource": "documents/2025-01/abc.pdf", -/// "correlationId": "abc123" -/// } -/// -/// -public static class RichProblemDetailsFactory -{ - /// - /// Creates a with RFC 7807 extensions from an . - /// - /// The domain error. - /// The HTTP context for trace information. - /// A with extensions from error metadata. - public static ProblemDetails CreateFromError(Error error, HttpContext? httpContext = null) - { - int statusCode = MapErrorTypeToStatusCode(error.Type); - - ProblemDetails problem = new() - { - Type = $"urn:paperless:error:{ToKebabCase(error.Code)}", - Title = error.Code, - Status = statusCode, - Detail = error.Description, - Instance = httpContext?.Request.Path - }; - - // Add trace ID if available - if (httpContext?.TraceIdentifier is { } traceId) - { - problem.Extensions["correlationId"] = traceId; - } - - // Add error metadata as RFC 7807 extensions - if (error.Metadata is { Count: > 0 }) - { - foreach (KeyValuePair kvp in error.Metadata) - { - // Convert PascalCase to camelCase for JSON conventions - string key = char.ToLowerInvariant(kvp.Key[0]) + kvp.Key[1..]; - problem.Extensions[key] = kvp.Value; - } - } - - // Add standard extensions based on error type - switch (error.Type) - { - case ErrorType.Unexpected: - // 503: Add Retry-After hint - problem.Extensions["retryAfter"] = error.Metadata?.GetValueOrDefault("RetryAfter") ?? 30; - break; - - case ErrorType.Conflict: - // 409: Add current state info if available - if (error.Metadata?.TryGetValue("CurrentState", out object? state) == true) - { - problem.Extensions["currentState"] = state; - } - - break; - - case ErrorType.Validation: - // 422: Could add field-level errors here - break; - case ErrorType.Failure: - case ErrorType.NotFound: - case ErrorType.Unauthorized: - case ErrorType.Forbidden: - break; - default: - throw new ArgumentOutOfRangeException(nameof(error), error.Type, "Unsupported error type"); - } - - return problem; - } - - /// - /// Creates a from an . - /// - public static ProblemHttpResult CreateProblemResult(Error error, HttpContext? httpContext = null) - { - ProblemDetails problem = CreateFromError(error, httpContext); - return TypedResults.Problem(problem); - } - - private static int MapErrorTypeToStatusCode(ErrorType type) => type switch - { - ErrorType.Failure => StatusCodes.Status500InternalServerError, - ErrorType.Unexpected => StatusCodes.Status503ServiceUnavailable, - ErrorType.Validation => StatusCodes.Status422UnprocessableEntity, - ErrorType.Conflict => StatusCodes.Status409Conflict, - ErrorType.NotFound => StatusCodes.Status404NotFound, - ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, - ErrorType.Forbidden => StatusCodes.Status403Forbidden, - _ => StatusCodes.Status500InternalServerError - }; - - private static string ToKebabCase(string value) => - string.Concat(value.Select((c, i) => - i > 0 && char.IsUpper(c) ? $"-{char.ToLowerInvariant(c)}" : char.ToLowerInvariant(c).ToString())); -} - -/// -/// Extensions for creating domain errors with rich metadata. -/// -public static class ErrorMetadataExtensions -{ - /// - /// Creates a storage unavailable error with retry hint. - /// - public static Error StorageUnavailable(string path, int retryAfterSeconds = 30) => - Error.Custom( - (int)ErrorType.Unexpected, - "Document.StorageUnavailable", - $"Storage service temporarily unavailable for {path}", - new Dictionary { ["RetryAfter"] = retryAfterSeconds, ["AffectedResource"] = path }); - - /// - /// Creates a conflict error with current state information. - /// - public static Error DocumentLocked(Guid id, string lockedBy, DateTimeOffset lockedUntil) => - Error.Custom( - (int)ErrorType.Conflict, - "Document.Locked", - $"Document {id} is locked for editing", - new Dictionary - { - ["DocumentId"] = id, - ["LockedBy"] = lockedBy, - ["LockedUntil"] = lockedUntil, - ["CurrentState"] = "Locked" - }); - - /// - /// Creates a validation error with affected field information. - /// - public static Error InvalidField(string fieldName, string reason, object? attemptedValue = null) - { - Dictionary metadata = new() { ["Field"] = fieldName, ["Reason"] = reason }; - - if (attemptedValue is not null) - { - metadata["AttemptedValue"] = attemptedValue; - } - - return Error.Custom( - (int)ErrorType.Validation, - $"Validation.{fieldName}", - $"Invalid value for {fieldName}: {reason}", - metadata); - } - - /// - /// Creates a not found error with search hints. - /// - public static Error DocumentNotFound(Guid id, string? suggestion = null) - { - Dictionary metadata = new() - { - ["DocumentId"] = id, - ["SearchedAt"] = TimeProvider.System.GetUtcNow() - }; - - if (suggestion is not null) - { - metadata["Suggestion"] = suggestion; - } - - return Error.Custom( - (int)ErrorType.NotFound, - "Document.NotFound", - $"Document {id} not found", - metadata); - } -} diff --git a/PaperlessServices.Tests/Integration/SearchIndexConcurrencyTests.cs b/PaperlessServices.Tests/Integration/SearchIndexConcurrencyTests.cs new file mode 100644 index 0000000..67ed117 --- /dev/null +++ b/PaperlessServices.Tests/Integration/SearchIndexConcurrencyTests.cs @@ -0,0 +1,151 @@ +namespace PaperlessServices.Tests.Integration; + +/// +/// Integration tests targeting the InitializeAsync concurrency branches in +/// that the standard +/// suite does not exercise. +/// +/// +/// +/// caches initialized index names in a static +/// +/// shared across instances. The shared dictionary survives between tests, so +/// 's default index is already initialized by +/// the time these tests run. To prove the uncovered branches we mint a fresh +/// $"test-{Guid.NewGuid():N}" index per test, point a freshly-constructed +/// at it, and assert against +/// output rather than indirect ES state. +/// +/// +/// Each test instantiates its own instead of +/// pulling from DI; the DI registration is a +/// singleton bound to the fixture's default index, which would route around our +/// unique-index isolation. +/// +/// +[Collection(SharedContainerCollection.Name)] +public class SearchIndexConcurrencyTests(SharedContainerFixture fixture) +{ + private ElasticsearchClient ElasticClient => fixture.Services.GetRequiredService(); + + private static SearchIndexService BuildSut( + ElasticsearchClient client, + string indexName, + FakeLogger logger) => + new( + client, + Options.Create(new ElasticsearchOptions + { + Uri = client.ElasticsearchClientSettings.NodePool.Nodes.First().Uri.ToString(), + DefaultIndex = indexName + }), + TimeProvider.System, + logger); + + private static int CountCreateIndexLogs(FakeLogCollector logs, string indexName) => + logs.GetSnapshot() + .Count(r => + r.Level == LogLevel.Information && + r.Message.Contains("Created Elasticsearch index", StringComparison.OrdinalIgnoreCase) && + r.Message.Contains(indexName, StringComparison.OrdinalIgnoreCase)); + + // ═══════════════════════════════════════════════════════════════ + // Branch (b): double-check inside lock — two concurrent first-time callers + // race the outer ContainsKey, the semaphore serializes them, the second + // caller hits the inner ContainsKey and short-circuits. + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task InitializeAsync_ConcurrentCallers_CreateIndexExactlyOnce() + { + // Arrange — unique index ensures the static cache has no entry, so both + // callers pass the outer ContainsKey check and race for the semaphore. + string indexName = $"test-{Guid.NewGuid():N}"; + FakeLogCollector collector = new(); + FakeLogger logger = new(collector); + SearchIndexService sut = BuildSut(ElasticClient, indexName, logger); + + Guid firstId = Guid.NewGuid(); + Guid secondId = Guid.NewGuid(); + + // Act — launch two indexing calls in parallel; both call InitializeAsync. + Task[] tasks = + [ + sut.IndexDocumentAsync(firstId, "doc1.pdf", "First", TimeProvider.System.GetUtcNow(), + TestContext.Current.CancellationToken), + sut.IndexDocumentAsync(secondId, "doc2.pdf", "Second", TimeProvider.System.GetUtcNow(), + TestContext.Current.CancellationToken) + ]; + await Task.WhenAll(tasks); + + // Assert — the create-log line fires exactly once. If branch (b) had not + // taken, both callers would have entered the create path and the count + // would be 2. + CountCreateIndexLogs(collector, indexName).Should().Be(1, + "the inner double-check should short-circuit the second concurrent caller after the first has created the index"); + } + + // ═══════════════════════════════════════════════════════════════ + // Branch (c): index already exists in Elasticsearch — pre-create the index, + // then have InitializeAsync find Exists == true and short-circuit without + // emitting the create-log line. + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task InitializeAsync_WhenIndexPreCreated_SkipsCreateAndLogsNothing() + { + // Arrange — pre-create the index out-of-band, before any SearchIndexService touches it. + string indexName = $"test-{Guid.NewGuid():N}"; + Elastic.Clients.Elasticsearch.IndexManagement.CreateIndexResponse preCreate = await ElasticClient.Indices + .CreateAsync(indexName, TestContext.Current.CancellationToken); + preCreate.IsValidResponse.Should().BeTrue("pre-creating the index out-of-band is a precondition for this test"); + + FakeLogCollector collector = new(); + FakeLogger logger = new(collector); + SearchIndexService sut = BuildSut(ElasticClient, indexName, logger); + + // Act — first call into the freshly-constructed service triggers InitializeAsync. + Guid documentId = Guid.NewGuid(); + await sut.IndexDocumentAsync(documentId, "exists.pdf", "Already there", + TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken); + + // Assert — no create-log line was emitted; the exists-branch was taken. + CountCreateIndexLogs(collector, indexName).Should().Be(0, + "InitializeAsync must skip the create path when ExistsAsync returns true"); + + // Document was still indexed successfully via the existing index. + GetResponse response = await fixture.WaitForDocumentAsync( + documentId.ToString(), TestContext.Current.CancellationToken); + response.Found.Should().BeTrue("document should be indexed in the pre-created index"); + } + + // ═══════════════════════════════════════════════════════════════ + // IndexDocumentAsync — explicit createdAt = null branch (no fallback). + // Production comment line 49: "null if not provided - don't fake it". + // Asserts the value-not-faked contract by verifying processedAt is the only + // timestamp persisted; the createdAt key is omitted from _source by the + // Elasticsearch client's JSON serializer (it drops nulls by default). + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task IndexDocumentAsync_WithNullCreatedAt_DoesNotFakeTimestamp() + { + // Arrange + Guid documentId = Guid.NewGuid(); + ISearchIndexService searchIndex = fixture.Services.GetRequiredService(); + + // Act — createdAt is deliberately null; production must not substitute a fallback. + await searchIndex.IndexDocumentAsync(documentId, "no-date.pdf", "no date provided", + createdAt: null, TestContext.Current.CancellationToken); + + // Assert — document is indexed, processedAt is set, createdAt is absent. + GetResponse response = await fixture.WaitForDocumentAsync( + documentId.ToString(), TestContext.Current.CancellationToken); + + response.Found.Should().BeTrue(); + response.Source.TryGetProperty("processedAt", out _).Should().BeTrue( + "processedAt is the actual indexing time and must always be set"); + response.Source.TryGetProperty("createdAt", out _).Should().BeFalse( + "a null createdAt must not be substituted with a fake timestamp; the field is dropped by the JSON serializer"); + } +} diff --git a/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs b/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs index eaee090..c25c82e 100644 --- a/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs +++ b/PaperlessServices.Tests/Unit/SearchIndexServiceTests.cs @@ -291,4 +291,59 @@ public void LogIndexResult_WhenInvalid_LogsWarning() r.Level == LogLevel.Warning && r.Message.Contains(s_testDocumentId.ToString(), StringComparison.OrdinalIgnoreCase)); } + + // ═══════════════════════════════════════════════════════════════ + // TESTS: IndexDocumentAsync - Catch Block (Exception Path) + // + // The default `_sut` uses ThrowExceptions(false), so transport failures + // return IsValidResponse=false and take the LogIndexResult-warning path + // rather than the catch. Production wires the client with + // `.ThrowExceptions()` (true), so the catch IS exercised in production. + // These tests pin that path with a dedicated client. + // ═══════════════════════════════════════════════════════════════ + + [Fact] + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "ElasticsearchClientSettings implements IDisposable only through an explicit interface; " + + "the analyzer cannot follow disposal through the using statement when the variable " + + "is initialized via a fluent chain.")] + public async Task IndexDocumentAsync_WhenClientThrows_LogsWarningAndSwallowsException() + { + // Arrange — match the production registration: ThrowExceptions(true) + using ElasticsearchClientSettings throwingSettings = new ElasticsearchClientSettings(new Uri(UnreachableHost)) + .DefaultIndex(TestIndexName) + .DisableDirectStreaming() + .RequestTimeout(TimeSpan.FromMilliseconds(100)) + .ThrowExceptions(); + ElasticsearchClient throwingClient = new(throwingSettings); + + FakeLogCollector collector = new(); + FakeLogger logger = new(collector); + SearchIndexService sut = new( + throwingClient, + Options.Create(new ElasticsearchOptions { Uri = UnreachableHost, DefaultIndex = TestIndexName }), + _timeProvider, + logger); + + // Act — the unreachable endpoint throws under ThrowExceptions(true); + // production catches it and logs a warning so OCR processing continues. + Func act = () => sut.IndexDocumentAsync( + s_testDocumentId, + TestFileName, + TestContent, + TimeProvider.System.GetUtcNow().AddMinutes(-5), + TestContext.Current.CancellationToken); + + await act.Should().NotThrowAsync( + "the catch block exists precisely so search-indexing failures do not break OCR"); + + // Assert — the catch-block log line fired (different message than LogIndexResult). + IReadOnlyList logs = collector.GetSnapshot(); + logs.Should().Contain(r => + r.Level == LogLevel.Warning && + r.Exception != null && + r.Message.Contains("Failed to index document", StringComparison.OrdinalIgnoreCase) && + r.Message.Contains(s_testDocumentId.ToString(), StringComparison.OrdinalIgnoreCase), + "the catch block logs the standard failure message together with the underlying exception"); + } } diff --git a/PaperlessServices.Tests/Unit/ServiceCollectionExtensionsTests.cs b/PaperlessServices.Tests/Unit/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..dc9d86e --- /dev/null +++ b/PaperlessServices.Tests/Unit/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,215 @@ +using PaperlessServices.Host.Extensions; + +namespace PaperlessServices.Tests.Unit; + +/// +/// Unit tests for the PaperlessServices host extension methods that cover the +/// no-arg AddOcrServices() entry point used by Program.cs (Program.cs +/// itself is excluded from coverage) and the AddGenAiServices wrapper +/// around the library's AddPaperlessGenAI. +/// +/// +/// Integration tests cover AddOcrServices(IConfiguration) end-to-end via the +/// fixture. These unit tests pin the smaller surfaces that the integration path +/// does not exercise. +/// +public sealed class ServiceCollectionExtensionsTests +{ + private static IConfiguration BuildConfiguration(Dictionary? overrides = null) + { + Dictionary settings = new() + { + // MinioOptions (Storage:Minio) — required for validate-on-start + ["Storage:Minio:Endpoint"] = "minio:9000", + ["Storage:Minio:AccessKey"] = "minioadmin", + ["Storage:Minio:SecretKey"] = "minioadmin", + ["Storage:Minio:BucketName"] = "documents", + ["Storage:Minio:UseSsl"] = "false", + // ElasticsearchOptions — required for validate-on-start + ["Elasticsearch:Uri"] = "http://elasticsearch:9200", + ["Elasticsearch:DefaultIndex"] = "documents", + // GenAI library reads its own section; key value is irrelevant to registration + ["Gemini:ApiKey"] = "test-key", + ["Gemini:Model"] = "gemini-2.0-flash" + }; + + if (overrides is not null) + { + foreach (KeyValuePair kv in overrides) + { + settings[kv.Key] = kv.Value; + } + } + + return new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + } + + // ═══════════════════════════════════════════════════════════════ + // AddOcrServices() — no-arg overload (Program.cs path) + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public void AddOcrServices_NoArg_RegistersOcrInfrastructure() + { + // Arrange + ServiceCollection services = new(); + services.AddSingleton(BuildConfiguration()); + services.AddLogging(); + + // Act — exercises the no-arg overload used by Program.cs + IServiceCollection returned = services.AddOcrServices(); + + // Assert — fluent API returns the same collection (NUKE-style chaining) + returned.Should().BeSameAs(services); + + // All four OCR registrations exist + services.Should().Contain(d => d.ServiceType == typeof(IStorageService)); + services.Should().Contain(d => d.ServiceType == typeof(ISearchIndexService)); + services.Should().Contain(d => d.ServiceType == typeof(IPdfExtractor)); + services.Should().Contain(d => d.ServiceType == typeof(IOcrProcessor)); + services.Should().Contain(d => d.ServiceType == typeof(IMinioClient)); + services.Should().Contain(d => d.ServiceType == typeof(ElasticsearchClient)); + services.Should().Contain(d => d.ServiceType == typeof(TimeProvider)); + } + + [Fact] + public void AddOcrServices_NoArg_OcrProcessorIsScoped() + { + // Arrange + ServiceCollection services = new(); + services.AddSingleton(BuildConfiguration()); + services.AddLogging(); + + // Act + services.AddOcrServices(); + + // Assert — OcrWorker creates a new scope per message, so processors must be Scoped + ServiceDescriptor processor = services.Single(d => d.ServiceType == typeof(IOcrProcessor)); + processor.Lifetime.Should().Be(ServiceLifetime.Scoped); + + ServiceDescriptor extractor = services.Single(d => d.ServiceType == typeof(IPdfExtractor)); + extractor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void AddOcrServices_NoArg_MinioAndElasticAreSingletons() + { + // Arrange + ServiceCollection services = new(); + services.AddSingleton(BuildConfiguration()); + services.AddLogging(); + + // Act + services.AddOcrServices(); + + // Assert — clients hold connection pools and must be singletons + services.Single(d => d.ServiceType == typeof(IMinioClient)).Lifetime + .Should().Be(ServiceLifetime.Singleton); + services.Single(d => d.ServiceType == typeof(ElasticsearchClient)).Lifetime + .Should().Be(ServiceLifetime.Singleton); + services.Single(d => d.ServiceType == typeof(ISearchIndexService)).Lifetime + .Should().Be(ServiceLifetime.Singleton); + services.Single(d => d.ServiceType == typeof(IStorageService)).Lifetime + .Should().Be(ServiceLifetime.Singleton); + } + + // ═══════════════════════════════════════════════════════════════ + // AddOcrServices() — MinIO endpoint-parsing branches + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public void AddOcrServices_WithSchemelessEndpoint_PrefixesHttp() + { + // Arrange — host:port form (the common case from compose.yaml / Testcontainers) + ServiceCollection services = new(); + services.AddSingleton(BuildConfiguration(new Dictionary + { + ["Storage:Minio:Endpoint"] = "minio.local:9000" + })); + services.AddLogging(); + + services.AddOcrServices(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + // Act — resolving the singleton runs the parsing branch + IMinioClient client = sp.GetRequiredService(); + + // Assert — client constructs without throwing; the schemeless path + // (`if (!endpoint.Contains("://"))` => true) is executed. + client.Should().NotBeNull(); + } + + [Fact] + public void AddOcrServices_WithSchemedEndpoint_UsesEndpointVerbatim() + { + // Arrange — full URI form. Production short-circuits the http:// prefix. + ServiceCollection services = new(); + services.AddSingleton(BuildConfiguration(new Dictionary + { + ["Storage:Minio:Endpoint"] = "http://minio.local:9000" + })); + services.AddLogging(); + + services.AddOcrServices(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + // Act — `if (!endpoint.Contains("://"))` is false, so prefix is skipped + IMinioClient client = sp.GetRequiredService(); + + // Assert + client.Should().NotBeNull(); + } + + [Fact] + public void AddOcrServices_WithUseSslTrue_BuildsClientWithSsl() + { + // Arrange — flipping UseSsl exercises the WithSSL(true) branch + ServiceCollection services = new(); + services.AddSingleton(BuildConfiguration(new Dictionary + { + ["Storage:Minio:Endpoint"] = "minio.secure:443", + ["Storage:Minio:UseSsl"] = "true" + })); + services.AddLogging(); + + services.AddOcrServices(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + // Act + IMinioClient client = sp.GetRequiredService(); + + // Assert + client.Should().NotBeNull(); + } + + // ═══════════════════════════════════════════════════════════════ + // AddGenAiServices(IConfiguration) — wrapper around AddPaperlessGenAI + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public void AddGenAiServices_ReturnsSameCollectionAndRegistersGenAi() + { + // Arrange + ServiceCollection services = new(); + services.AddLogging(); + IConfiguration config = BuildConfiguration(); + + // Act + IServiceCollection returned = services.AddGenAiServices(config); + + // Assert — fluent return contract preserved + returned.Should().BeSameAs(services); + + // AddPaperlessGenAI registers ITextSummarizer (the GenAI worker depends on it) + // and binds GeminiOptions from the Gemini configuration section. + services.Should().Contain(d => d.ServiceType == typeof(ITextSummarizer), + "AddPaperlessGenAI registers the text-summarizer HTTP client"); + services.Should().Contain(d => + d.ServiceType.FullName != null && + d.ServiceType.FullName.Contains("GeminiOptions", StringComparison.Ordinal), + "AddPaperlessGenAI binds GeminiOptions via BindConfiguration"); + } +}