Skip to content
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
POSTGRES_IMAGE=postgres:17-alpine
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
ELASTIC_IMAGE=docker.elastic.co/elasticsearch/elasticsearch:9.1.3

# Container credentials (placeholders; Testcontainers regenerates)
POSTGRES_DB=paperless
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public SharedRestContainerFixture()
string minioImage = Environment.GetEnvironmentVariable("MINIO_IMAGE") ??
"minio/minio:RELEASE.2025-09-07T16-13-09Z";
string elasticImage = Environment.GetEnvironmentVariable("ELASTIC_IMAGE") ??
"docker.elastic.co/elasticsearch/elasticsearch:9.4.1";
"docker.elastic.co/elasticsearch/elasticsearch:9.1.3";

_postgres = new PostgreSqlBuilder(postgresImage)
.WithWaitStrategy(Wait.ForUnixContainer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,13 @@ await sut.IndexDocumentAsync(documentId, "exists.pdf", "Already there",
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<JsonElement> response = await fixture.WaitForDocumentAsync<JsonElement>(
documentId.ToString(), TestContext.Current.CancellationToken);
// Document was still indexed successfully into the SUT's pre-created index.
// We query that index directly — fixture.WaitForDocumentAsync uses the client's
// DefaultIndex, which is the shared fixture index, not this test's per-test index.
GetResponse<JsonElement> response = await ElasticClient.GetAsync<JsonElement>(
documentId.ToString(),
g => g.Index(indexName),
TestContext.Current.CancellationToken);
response.Found.Should().BeTrue("document should be indexed in the pre-created index");
}

Expand Down
83 changes: 65 additions & 18 deletions PaperlessServices.Tests/Integration/WorkerTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ 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.4.1";
private const string DefaultElasticsearchImage = "docker.elastic.co/elasticsearch/elasticsearch:9.1.3";
private const string DefaultMinioImage = "minio/minio:RELEASE.2025-09-07T16-13-09Z";
private const string DefaultRabbitmqImage = "rabbitmq:4.3.0-management";

Expand All @@ -32,6 +32,10 @@ public class SharedContainerFixture : IAsyncLifetime
Environment.GetEnvironmentVariable("ELASTIC_IMAGE") ?? DefaultElasticsearchImage)
.WithEnvironment("discovery.type", "single-node")
.WithEnvironment("xpack.security.enabled", "false")
// Required so Testcontainers' ElasticsearchConfiguration.TlsEnabled evaluates to false
// (it AND-s xpack.security.enabled with xpack.security.http.ssl.enabled). Without this,
// the built-in wait strategy probes HTTPS while ES listens on plain HTTP, and hangs.
.WithEnvironment("xpack.security.http.ssl.enabled", "false")
.WithEnvironment("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
.Build();

Expand Down Expand Up @@ -110,14 +114,21 @@ await Task.WhenAll(

public async ValueTask DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
// _host is assigned in InitializeAsync. If init throws before that line
// (e.g. a container wait-strategy times out), _host is still null and a
// naive `_host.StopAsync()` here NREs — which masks the real init error
// in xUnit's collection-fixture cleanup report. Guard the host, and
// swallow per-container Dispose failures for the same reason.
if (_host is not null)
{
try { await _host.StopAsync(); }
catch { /* best-effort: don't mask the InitializeAsync exception */ }
_host.Dispose();
}
Comment on lines +122 to +127
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

_host.Dispose() not wrapped in best-effort handler.

The comment on lines 117-121 states the intent to "swallow per-container Dispose failures" to avoid masking init errors. StopAsync is wrapped but Dispose() is not. If Dispose() throws (e.g., a hosted service's disposal fails), it masks the original init failure exactly as described.

Proposed fix
 if (_host is not null)
 {
     try { await _host.StopAsync(); }
     catch { /* best-effort: don't mask the InitializeAsync exception */ }
-    _host.Dispose();
+    try { _host.Dispose(); }
+    catch { /* best-effort */ }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PaperlessServices.Tests/Integration/WorkerTestBase.cs` around lines 122 -
127, The cleanup currently calls _host.Dispose() without a best-effort guard,
which can throw and mask earlier InitializeAsync failures; wrap the Dispose call
in a try/catch (similar to the existing try for await _host.StopAsync()) so any
exception thrown by _host.Dispose() is swallowed and does not overwrite the
original exception — locate the block referencing _host, StopAsync and Dispose
and surround the Dispose invocation with a try { _host.Dispose(); } catch { /*
swallow to avoid masking init error */ }.


await Task.WhenAll(
_rabbit.DisposeAsync().AsTask(),
_minio.DisposeAsync().AsTask(),
_elastic.DisposeAsync().AsTask()
);
try { await _rabbit.DisposeAsync(); } catch { /* best-effort */ }
try { await _minio.DisposeAsync(); } catch { /* best-effort */ }
try { await _elastic.DisposeAsync(); } catch { /* best-effort */ }
}

public async Task<string> UploadPdfAsync(string content)
Expand Down Expand Up @@ -188,27 +199,63 @@ public async Task<SearchResponse<T>> WaitForSearchResultsAsync<T>(
TimeSpan? timeout = null,
TimeSpan? pollInterval = null)
{
timeout ??= TimeSpan.FromSeconds(10);
// 30s overall budget: GitHub-hosted runners are markedly slower than local
// dev machines and the first SearchAsync after index creation can spend
// several seconds priming query caches even after Refresh.True returns.
timeout ??= TimeSpan.FromSeconds(30);
pollInterval ??= TimeSpan.FromMilliseconds(100);

ElasticsearchClient client = Services.GetRequiredService<ElasticsearchClient>();
using CancellationTokenSource cts = new(timeout.Value);
using CancellationTokenSource linked =
CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
using CancellationTokenSource overallCts = new(timeout.Value);
using CancellationTokenSource overallLinked =
CancellationTokenSource.CreateLinkedTokenSource(overallCts.Token, cancellationToken);

// Force an index-level refresh up front. SearchIndexService writes documents
// with Refresh.True (`?refresh=true`), which is supposed to guarantee
// immediate searchability — but on slow CI disks the per-document refresh
// is observed to not always propagate before the first SearchAsync. The
// explicit Indices.RefreshAsync here is defensive and idempotent: locally
// it's a no-op (everything's already refreshed), on CI it converts an
// invisible flake into a passing search.
try
{
await client.Indices.RefreshAsync(
r => r.Indices(client.ElasticsearchClientSettings.DefaultIndex),
overallLinked.Token);
}
catch (OperationCanceledException) when (overallLinked.Token.IsCancellationRequested)
{
// Fall through to the final attempt below.
}

while (!linked.Token.IsCancellationRequested)
while (!overallLinked.Token.IsCancellationRequested)
{
SearchResponse<T> response = await client.SearchAsync<T>(configureSearch, linked.Token);
try
{
SearchResponse<T> response = await client.SearchAsync<T>(configureSearch, overallLinked.Token);

if (response.Documents.Count > 0)
if (response.Documents.Count > 0)
{
return response;
}
}
catch (OperationCanceledException) when (overallLinked.Token.IsCancellationRequested)
{
return response;
break;
}

await Task.Delay(pollInterval.Value, linked.Token);
try
{
await Task.Delay(pollInterval.Value, overallLinked.Token);
}
catch (OperationCanceledException)
{
break;
}
}

// Final attempt
// Final attempt with the caller's token only so the assertion sees real
// "found nothing" data rather than a TaskCanceledException at the wait boundary.
return await client.SearchAsync<T>(configureSearch, cancellationToken);
}

Expand Down
2 changes: 1 addition & 1 deletion PaperlessServices.Tests/xunit.runner.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"methodDisplay": "classAndMethod",
"methodDisplayOptions": "replaceUnderscoreWithSpace,useOperatorMonikers",
"parallelizeTestCollections": true,
"parallelAlgorithm": "aggressive",
"parallelAlgorithm": "conservative",
"maxParallelThreads": "4x",
"stopOnFail": false,
"failSkips": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public async Task IndexDocumentAsync(Guid id, string fileName, string content, D
processedAt = now
// storagePath deliberately excluded - internal detail not exposed in search results
// summary will be added later by GenAI service
}, i => i.Id(id.ToString()).Refresh(Refresh.True), cancellationToken);
}, i => i.Index(options.Value.DefaultIndex).Id(id.ToString()).Refresh(Refresh.True), cancellationToken);

LogIndexResult(id, response.IsValidResponse);
}
Expand Down
Loading