diff --git a/PaperlessServices.Tests/Integration/WorkerTestBase.cs b/PaperlessServices.Tests/Integration/WorkerTestBase.cs index 2efc966..b4d2ddd 100644 --- a/PaperlessServices.Tests/Integration/WorkerTestBase.cs +++ b/PaperlessServices.Tests/Integration/WorkerTestBase.cs @@ -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(); @@ -188,27 +192,63 @@ public async Task> WaitForSearchResultsAsync( 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(); - 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 response = await client.SearchAsync(configureSearch, linked.Token); + try + { + SearchResponse response = await client.SearchAsync(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(configureSearch, cancellationToken); } diff --git a/PaperlessServices.Tests/xunit.runner.json b/PaperlessServices.Tests/xunit.runner.json index 5c0209e..0bc55b4 100644 --- a/PaperlessServices.Tests/xunit.runner.json +++ b/PaperlessServices.Tests/xunit.runner.json @@ -4,7 +4,7 @@ "methodDisplay": "classAndMethod", "methodDisplayOptions": "replaceUnderscoreWithSpace,useOperatorMonikers", "parallelizeTestCollections": true, - "parallelAlgorithm": "aggressive", + "parallelAlgorithm": "conservative", "maxParallelThreads": "4x", "stopOnFail": false, "failSkips": false,