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"