diff --git a/.env.test b/.env.test index 16c2218..bc1b9b3 100644 --- a/.env.test +++ b/.env.test @@ -19,9 +19,9 @@ # gap with the production compose stack so integration tests exercise the same # migration / dialect surface the deployed REST + Services see. POSTGRES_IMAGE=postgres:17-alpine -RABBITMQ_IMAGE=rabbitmq:4.1.4-management -MINIO_IMAGE=minio/minio:RELEASE.2025-07-23T15-54-02Z -ELASTIC_IMAGE=docker.elastic.co/elasticsearch/elasticsearch:9.1.3 +RABBITMQ_IMAGE=rabbitmq:4.3.0-management +MINIO_IMAGE=minio/minio:RELEASE.2025-09-07T16-13-09Z +ELASTIC_IMAGE=docker.elastic.co/elasticsearch/elasticsearch:9.4.1 # Container credentials (placeholders; Testcontainers regenerates) POSTGRES_DB=paperless diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb34598..32ee2d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,17 +32,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup .NET 10 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - name: Cache NuGet packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .nuke/temp @@ -76,7 +76,7 @@ jobs: - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: ./Artifacts/coverage/PaperlessREST.Tests/coverage.cobertura.xml,./Artifacts/coverage/PaperlessServices.Tests/coverage.cobertura.xml flags: backend @@ -102,12 +102,12 @@ jobs: run: working-directory: PaperlessUI.Angular steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Activate pnpm via corepack run: | corepack enable corepack prepare pnpm@10.30.2 --activate - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -123,12 +123,12 @@ jobs: run: working-directory: PaperlessUI.React steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Activate pnpm via corepack run: | corepack enable corepack prepare pnpm@10.30.2 --activate - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/Directory.Packages.props b/Directory.Packages.props index 783c3bd..f29e850 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,20 +24,23 @@ + + - + - - + - - + + + + @@ -79,8 +82,8 @@ pulled by Nuke.Common) and GHSA-37gx-xxp4-5rgx / GHSA-w3x6-4m5h-cxqf (System.Security.Cryptography.Xml 9.0.0). Effective because the SDK turns on CentralPackageTransitivePinningEnabled. --> - - + + diff --git a/PaperlessREST.Tests/Integration/DatabaseFixture.cs b/PaperlessREST.Tests/Integration/DatabaseFixture.cs index f1b69dd..eb022cf 100644 --- a/PaperlessREST.Tests/Integration/DatabaseFixture.cs +++ b/PaperlessREST.Tests/Integration/DatabaseFixture.cs @@ -21,10 +21,9 @@ static DatabaseFixture() public DatabaseFixture() { - string postgresImage = Environment.GetEnvironmentVariable("POSTGRES_IMAGE") ?? "postgres:16-alpine"; + string postgresImage = Environment.GetEnvironmentVariable("POSTGRES_IMAGE") ?? "postgres:17-alpine"; - _container = new PostgreSqlBuilder() - .WithImage(postgresImage) + _container = new PostgreSqlBuilder(postgresImage) .WithWaitStrategy(Wait.ForUnixContainer() .UntilMessageIsLogged("database system is ready to accept connections")) .Build(); diff --git a/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs b/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs index 4748265..f3066b5 100644 --- a/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs +++ b/PaperlessREST.Tests/Integration/SharedRestContainerFixture.cs @@ -20,29 +20,25 @@ static SharedRestContainerFixture() public SharedRestContainerFixture() { - string postgresImage = Environment.GetEnvironmentVariable("POSTGRES_IMAGE") ?? "postgres:16-alpine"; - string rabbitImage = Environment.GetEnvironmentVariable("RABBITMQ_IMAGE") ?? "rabbitmq:3.13-management-alpine"; + string postgresImage = Environment.GetEnvironmentVariable("POSTGRES_IMAGE") ?? "postgres:17-alpine"; + string rabbitImage = Environment.GetEnvironmentVariable("RABBITMQ_IMAGE") ?? "rabbitmq:4.3.0-management"; string minioImage = Environment.GetEnvironmentVariable("MINIO_IMAGE") ?? - "minio/minio:RELEASE.2025-07-23T15-54-02Z"; + "minio/minio:RELEASE.2025-09-07T16-13-09Z"; string elasticImage = Environment.GetEnvironmentVariable("ELASTIC_IMAGE") ?? - "docker.elastic.co/elasticsearch/elasticsearch:9.1.3"; + "docker.elastic.co/elasticsearch/elasticsearch:9.4.1"; - _postgres = new PostgreSqlBuilder() - .WithImage(postgresImage) + _postgres = new PostgreSqlBuilder(postgresImage) .WithWaitStrategy(Wait.ForUnixContainer() .UntilMessageIsLogged("database system is ready to accept connections")) .Build(); - _rabbit = new RabbitMqBuilder() - .WithImage(rabbitImage) + _rabbit = new RabbitMqBuilder(rabbitImage) .Build(); - _minio = new MinioBuilder() - .WithImage(minioImage) + _minio = new MinioBuilder(minioImage) .Build(); - _elastic = new ElasticsearchBuilder() - .WithImage(elasticImage) + _elastic = new ElasticsearchBuilder(elasticImage) .WithEnvironment("discovery.type", "single-node") .WithEnvironment("xpack.security.enabled", "false") .WithEnvironment("ES_JAVA_OPTS", "-Xms512m -Xmx512m") diff --git a/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs b/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs deleted file mode 100644 index 3ec2f2e..0000000 --- a/PaperlessREST.Tests/Unit/ListenerExecuteAsyncTests.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using RabbitMQ.Client.Exceptions; - -namespace PaperlessREST.Tests.Unit; - -/// -/// Lifecycle tests for the BackgroundService and -/// overrides. Cover the consumer factory wiring, -/// the consume-loop, and the generic-exception catch branch on the GenAI listener. -/// -/// Completion is signalled via set from a mock callback -/// (per CLAUDE.md): never poll a log snapshot. -/// -/// -/// The OperationInterruptedException "no queue" branch is intentionally not unit-tested: -/// constructing that exception requires RabbitMQ-internal ShutdownEventArgs types that aren't -/// in the test project's surface, and the branch is a one-off shutdown helper — covered -/// operationally on a missing-queue restart, not via fake-broker reproduction here. -/// -/// -public sealed class GenAiResultListenerExecuteAsyncTests : IDisposable -{ - private readonly Mock> _consumer; - private readonly Mock _consumerFactory; - private readonly Mock _documentService; - private readonly FakeLogCollector _logCollector = new(); - private readonly FakeLogger _logger; - private readonly MockRepository _mocks = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty }; - private readonly Mock _scope; - private readonly Mock _scopeFactory; - private readonly Mock _serviceProvider; - private readonly Mock> _sseStream; - - public GenAiResultListenerExecuteAsyncTests() - { - _scopeFactory = _mocks.Create(); - _scope = _mocks.Create(); - _serviceProvider = _mocks.Create(); - _documentService = _mocks.Create(); - _consumerFactory = _mocks.Create(); - _consumer = _mocks.Create>(); - _sseStream = _mocks.Create>(); - _logger = new FakeLogger(_logCollector); - - _scope.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); - _scopeFactory.Setup(f => f.CreateScope()).Returns(_scope.Object); - _scope.Setup(s => s.ServiceProvider).Returns(_serviceProvider.Object); - _serviceProvider.Setup(p => p.GetService(typeof(IDocumentService))).Returns(_documentService.Object); - _consumer.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); - } - - public void Dispose() - { - TestContext.Current.SendDiagnosticMessage("Full logs:\n{0}", _logCollector.GetFullLoggerText()); - } - - private GenAiResultListener CreateSut() => - new(_consumerFactory.Object, _scopeFactory.Object, _sseStream.Object, _logger); - - [Fact] - public async Task ExecuteAsync_HappyPath_LogsStartedConsumesAndStopped() - { - GenAIEvent evt = new(Guid.CreateVersion7(), "summary", TimeProvider.System.GetUtcNow(), null); - TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); - - _consumerFactory.Setup(f => f.CreateConsumerAsync()) - .ReturnsAsync(_consumer.Object); - _consumer.Setup(c => c.ConsumeAsync(It.IsAny())) - .Returns((CancellationToken ct) => ListenerStreams.SingleThenWait(evt, ct)); - _documentService.Setup(s => s.UpdateDocumentSummaryAsync( - evt.DocumentId, evt.Summary!, evt.GeneratedAt, It.IsAny())) - .ReturnsAsync(Result.Updated); - _sseStream.Setup(s => s.Publish(evt)); - _consumer.Setup(c => c.AckAsync()) - .Returns(() => - { - processed.TrySetResult(true); - return Task.CompletedTask; - }); - - using GenAiResultListener sut = CreateSut(); - using CancellationTokenSource cts = new(); - - await sut.StartAsync(cts.Token); - await processed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - await cts.CancelAsync(); - await sut.ExecuteTask!; - - _logCollector.GetSnapshot().Should().Contain(l => - l.Level == LogLevel.Information && l.Message.Contains("GenAI Result Listener started")); - _logCollector.GetSnapshot().Should().Contain(l => - l.Level == LogLevel.Information && l.Message.Contains("GenAI Result Listener stopped")); - } - - [Fact] - public async Task ExecuteAsync_NoQueueOperationInterrupted_LogsWarningAndAwaitsCancellation() - { - // Constructing OperationInterruptedException through its real ctor requires RabbitMQ- - // internal ShutdownEventArgs types not in the test surface. To exercise the listener's - // "no queue" catch arm, we sidestep the ctor with GetUninitializedObject and inject the - // Message field via reflection — the catch's `when (ex.Message.Contains("no queue"))` - // matches purely on text, so a bare exception with the right message is sufficient. - OperationInterruptedException ex = - (OperationInterruptedException)RuntimeHelpers.GetUninitializedObject(typeof(OperationInterruptedException)); - FieldInfo? messageField = typeof(Exception).GetField("_message", BindingFlags.Instance | BindingFlags.NonPublic); - messageField!.SetValue(ex, "NOT_FOUND - no queue 'GenAI'"); - - _consumerFactory.Setup(f => f.CreateConsumerAsync()).ThrowsAsync(ex); - - using GenAiResultListener sut = CreateSut(); - using CancellationTokenSource cts = new(); - - await sut.StartAsync(cts.Token); - - // The listener should now be sleeping in Task.Delay(Timeout.Infinite, stoppingToken) - // inside the catch arm. Wait for the warning, then cancel to unblock. - TimeSpan timeout = TimeSpan.FromSeconds(5); - using CancellationTokenSource waitCts = new(timeout); - while (!waitCts.IsCancellationRequested && - !_logCollector.GetSnapshot().Any(l => - l.Level == LogLevel.Warning && - l.Message.Contains("GenAI Result Listener disabled", StringComparison.OrdinalIgnoreCase))) - { - await Task.Delay(20, TestContext.Current.CancellationToken); - } - - await cts.CancelAsync(); - - Func awaitExecute = async () => await sut.ExecuteTask!; - await awaitExecute.Should().ThrowAsync(); - - _logCollector.GetSnapshot().Should().Contain(l => - l.Level == LogLevel.Warning && - l.Message.Contains("GenAI Result Listener disabled", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public async Task ExecuteAsync_UnexpectedException_LogsErrorAndRethrows() - { - InvalidOperationException boom = new("broker down"); - _consumerFactory.Setup(f => f.CreateConsumerAsync()) - .ThrowsAsync(boom); - - using GenAiResultListener sut = CreateSut(); - using CancellationTokenSource cts = new(); - - Func startAndAwait = async () => - { - await sut.StartAsync(cts.Token); - await sut.ExecuteTask!; - }; - - await startAndAwait.Should().ThrowAsync().WithMessage("broker down"); - _logCollector.GetSnapshot().Should().Contain(l => - l.Level == LogLevel.Error && l.Message.Contains("Unexpected error", StringComparison.OrdinalIgnoreCase)); - } - -} - -public sealed class OcrResultListenerExecuteAsyncTests : IDisposable -{ - private readonly Mock> _consumer; - private readonly Mock _consumerFactory; - private readonly Mock _documentService; - private readonly FakeLogCollector _logCollector = new(); - private readonly FakeLogger _logger; - private readonly MockRepository _mocks = new(MockBehavior.Strict) { DefaultValue = DefaultValue.Empty }; - private readonly Mock _scope; - private readonly Mock _scopeFactory; - private readonly Mock _serviceProvider; - private readonly Mock> _sseStream; - - public OcrResultListenerExecuteAsyncTests() - { - _scopeFactory = _mocks.Create(); - _scope = _mocks.Create(); - _serviceProvider = _mocks.Create(); - _documentService = _mocks.Create(); - _consumerFactory = _mocks.Create(); - _consumer = _mocks.Create>(); - _sseStream = _mocks.Create>(); - _logger = new FakeLogger(_logCollector); - - _scope.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); - _scopeFactory.Setup(f => f.CreateScope()).Returns(_scope.Object); - _scope.Setup(s => s.ServiceProvider).Returns(_serviceProvider.Object); - _serviceProvider.Setup(p => p.GetService(typeof(IDocumentService))).Returns(_documentService.Object); - _consumer.As().Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); - } - - public void Dispose() - { - TestContext.Current.SendDiagnosticMessage("Full logs:\n{0}", _logCollector.GetFullLoggerText()); - } - - private OcrResultListener CreateSut() => - new(_consumerFactory.Object, _scopeFactory.Object, _sseStream.Object, _logger); - - [Fact] - public async Task ExecuteAsync_HappyPath_LogsStartedConsumesAndStopped() - { - OcrEvent evt = new(Guid.CreateVersion7(), "Completed", "ocr text", TimeProvider.System.GetUtcNow()); - TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); - - _consumerFactory.Setup(f => f.CreateConsumerAsync()) - .ReturnsAsync(_consumer.Object); - _consumer.Setup(c => c.ConsumeAsync(It.IsAny())) - .Returns((CancellationToken ct) => ListenerStreams.SingleThenWait(evt, ct)); - _documentService.Setup(s => s.ProcessOcrResultAsync( - evt.JobId, "Completed", "ocr text", It.IsAny())) - .ReturnsAsync(Result.Updated); - _sseStream.Setup(s => s.Publish(evt)); - _consumer.Setup(c => c.AckAsync()) - .Returns(() => - { - processed.TrySetResult(true); - return Task.CompletedTask; - }); - - using OcrResultListener sut = CreateSut(); - using CancellationTokenSource cts = new(); - - await sut.StartAsync(cts.Token); - await processed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - await cts.CancelAsync(); - await sut.ExecuteTask!; - - _logCollector.GetSnapshot().Should().Contain(l => - l.Level == LogLevel.Information && l.Message.Contains("OCR Result Listener started")); - _logCollector.GetSnapshot().Should().Contain(l => - l.Level == LogLevel.Information && l.Message.Contains("OCR Result Listener stopped")); - } - -} - -/// -/// Helpers for fabricating streams in listener lifecycle tests. -/// Each stream completes cleanly on token cancellation so the consume-loop in -/// exits and the "stopped" log gets emitted. -/// -internal static class ListenerStreams -{ - /// Yields one item, then waits indefinitely; completes cleanly on token cancellation. - public static async IAsyncEnumerable SingleThenWait( - T item, [EnumeratorCancellation] CancellationToken ct = default) - { - yield return item; - - try - { - await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - } - -} diff --git a/PaperlessREST.Tests/Unit/ReportProcessorTests.cs b/PaperlessREST.Tests/Unit/ReportProcessorTests.cs index e561e4b..8918579 100644 --- a/PaperlessREST.Tests/Unit/ReportProcessorTests.cs +++ b/PaperlessREST.Tests/Unit/ReportProcessorTests.cs @@ -551,13 +551,13 @@ 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 = """ + const string XmlContent = """ """; - string filePath = CreateTestFile("date-with-tz.xml", xmlContent); + string filePath = CreateTestFile("date-with-tz.xml", XmlContent); ReportProcessor sut = CreateSut(); // Act diff --git a/PaperlessServices.Tests/Integration/WorkerTestBase.cs b/PaperlessServices.Tests/Integration/WorkerTestBase.cs index 4de2ea7..2efc966 100644 --- a/PaperlessServices.Tests/Integration/WorkerTestBase.cs +++ b/PaperlessServices.Tests/Integration/WorkerTestBase.cs @@ -22,14 +22,14 @@ public class SharedContainerFixture : IAsyncLifetime private const int MinioPort = 9000; // Default image versions - override via environment variables for CI flexibility - private const string DefaultElasticsearchImage = "docker.elastic.co/elasticsearch/elasticsearch:9.1.3"; - private const string DefaultMinioImage = "minio/minio:RELEASE.2024-11-07T00-52-20Z"; - private const string DefaultRabbitmqImage = "rabbitmq:4.0-management-alpine"; + private const string DefaultElasticsearchImage = "docker.elastic.co/elasticsearch/elasticsearch:9.4.1"; + private const string DefaultMinioImage = "minio/minio:RELEASE.2025-09-07T16-13-09Z"; + private const string DefaultRabbitmqImage = "rabbitmq:4.3.0-management"; private readonly string _bucketName = $"test-{Guid.NewGuid():N}"; - private readonly ElasticsearchContainer _elastic = new ElasticsearchBuilder() - .WithImage(Environment.GetEnvironmentVariable("ELASTIC_IMAGE") ?? DefaultElasticsearchImage) + private readonly ElasticsearchContainer _elastic = new ElasticsearchBuilder( + Environment.GetEnvironmentVariable("ELASTIC_IMAGE") ?? DefaultElasticsearchImage) .WithEnvironment("discovery.type", "single-node") .WithEnvironment("xpack.security.enabled", "false") .WithEnvironment("ES_JAVA_OPTS", "-Xms512m -Xmx512m") @@ -37,12 +37,12 @@ public class SharedContainerFixture : IAsyncLifetime private readonly string _indexName = $"test_{Guid.NewGuid():N}"; - private readonly MinioContainer _minio = new MinioBuilder() - .WithImage(Environment.GetEnvironmentVariable("MINIO_IMAGE") ?? DefaultMinioImage) + private readonly MinioContainer _minio = new MinioBuilder( + Environment.GetEnvironmentVariable("MINIO_IMAGE") ?? DefaultMinioImage) .Build(); - private readonly RabbitMqContainer _rabbit = new RabbitMqBuilder() - .WithImage(Environment.GetEnvironmentVariable("RABBITMQ_IMAGE") ?? DefaultRabbitmqImage) + private readonly RabbitMqContainer _rabbit = new RabbitMqBuilder( + Environment.GetEnvironmentVariable("RABBITMQ_IMAGE") ?? DefaultRabbitmqImage) .Build(); private IHost _host = null!; diff --git a/Version.props b/Version.props index 436ef9c..7780573 100644 --- a/Version.props +++ b/Version.props @@ -2,15 +2,18 @@ - 10.0.0 - 10.0.0 - 10.0.7 - 10.0.0 - 10.0.0 + 10.0.8 + 10.0.8 + 10.0.8 + 10.0.8 + + 10.6.0 + 10.0.2 + 10.0.1 - 8.1.0 - 8.1.1 + 10.0.0 + 10.0.0 9.0.0 @@ -20,32 +23,34 @@ 9.0.8 0.7.1 4.20.72 - 3.2.1 - 18.1.0 + 3.2.2 + 18.6.2 + 2.0.2 - 10.0.0 - 5.0.1 + 10.2.0 + 6.3.0 - 4.9.0 + 4.11.0 - 3.0.3 - 3.1.1 - 9.2.2 - 2.0.1 + 3.0.4 + 3.2.0 + 9.4.0 + 2.1.1 2.0.1 1.8.23 1.21.1 1.8.1.2 2025.2.4 + 1.0.1 3.1.0 7.0.0 13.0.4 - 2.11.0 + 2.14.14 2.3.1 diff --git a/global.json b/global.json index cdf0aab..e2e2c2c 100644 --- a/global.json +++ b/global.json @@ -4,9 +4,9 @@ "rollForward": "latestFeature" }, "msbuild-sdks": { - "ANcpLua.NET.Sdk": "3.4.29", - "ANcpLua.NET.Sdk.Web": "3.4.29", - "ANcpLua.NET.Sdk.Test": "3.4.29" + "ANcpLua.NET.Sdk": "3.4.32", + "ANcpLua.NET.Sdk.Web": "3.4.32", + "ANcpLua.NET.Sdk.Test": "3.4.32" }, "test": { "runner": "Microsoft.Testing.Platform"