Skip to content

Commit 897f3e7

Browse files
JerrettDavisclaude
andcommitted
fix(tests): skip container integration tests on transient Docker/registry failures
CI failed when Docker Hub returned a transient InternalServerError pulling mysql:8.0 ("Head https://registry-1.docker.io/.../manifests/8.0: unknown"), hard-failing the run. Only one of eight MySQL scenarios failed in the same run, confirming an infrastructure flake rather than a product defect. Add ContainerStartup.StartOrSkipAsync which starts the container and converts transient Docker daemon/registry failures into a SkipException (following the existing Xunit.SkippableFact pattern used by the Snowflake tests). The skip is thrown eagerly in the test method body (before the TinyBDD pipeline, which would otherwise wrap it in BddStepException), so SkippableFact reports a skip instead of a failure. Genuine assertion/product failures still fail the build. Convert all Testcontainers-backed integration tests (MySQL, PostgreSQL, SQL Server, Oracle, Firebird, end-to-end, query-schema) to [SkippableFact] and eager container start. Add unit tests for the failure-detection logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e855fd4 commit 897f3e7

9 files changed

Lines changed: 325 additions & 145 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using DotNet.Testcontainers.Containers;
2+
using Xunit;
3+
4+
namespace JD.Efcpt.Build.Tests.Integration;
5+
6+
/// <summary>
7+
/// Helpers for starting Testcontainers-backed integration tests in a way that is
8+
/// resilient to transient Docker/registry infrastructure failures.
9+
/// </summary>
10+
/// <remarks>
11+
/// These integration tests require a working Docker daemon and the ability to pull
12+
/// images from a registry (e.g. Docker Hub). CI runners periodically hit transient
13+
/// registry errors such as
14+
/// <c>Docker API responded with status code='InternalServerError', response='{"message":"Head ... manifests/8.0: unknown: "}'</c>.
15+
/// Such failures are environmental, not product defects, so they should mark the test
16+
/// as <b>skipped</b> (with a clear reason) rather than failing the build. Genuine
17+
/// product failures (assertion failures, schema-reader bugs) still fail as normal.
18+
/// </remarks>
19+
public static class ContainerStartup
20+
{
21+
/// <summary>
22+
/// Starts the supplied container, converting transient Docker/registry
23+
/// infrastructure failures into a test skip via <see cref="SkipException"/>.
24+
/// </summary>
25+
public static async Task StartOrSkipAsync(IContainer container)
26+
{
27+
try
28+
{
29+
await container.StartAsync();
30+
}
31+
catch (Exception ex) when (IsDockerInfrastructureFailure(ex))
32+
{
33+
throw new SkipException(
34+
"Skipping container-based integration test: Docker/registry is unavailable " +
35+
$"or returned a transient error ({ex.GetType().Name}: {Flatten(ex)}).");
36+
}
37+
}
38+
39+
/// <summary>
40+
/// Determines whether an exception (or any inner exception) represents a transient
41+
/// Docker daemon / registry infrastructure failure rather than a product defect.
42+
/// </summary>
43+
public static bool IsDockerInfrastructureFailure(Exception? ex)
44+
{
45+
for (var current = ex; current is not null; current = current.InnerException)
46+
{
47+
var typeName = current.GetType().Name;
48+
if (typeName is "DockerApiException" or "DockerContainerNotFoundException")
49+
{
50+
return true;
51+
}
52+
53+
var message = current.Message ?? string.Empty;
54+
if (message.Contains("registry-1.docker.io", StringComparison.OrdinalIgnoreCase)
55+
|| message.Contains("manifests", StringComparison.OrdinalIgnoreCase)
56+
|| message.Contains("InternalServerError", StringComparison.OrdinalIgnoreCase)
57+
|| message.Contains("Docker API responded", StringComparison.OrdinalIgnoreCase)
58+
|| message.Contains("toomanyrequests", StringComparison.OrdinalIgnoreCase)
59+
|| message.Contains("Cannot connect to the Docker daemon", StringComparison.OrdinalIgnoreCase)
60+
|| message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase)
61+
|| message.Contains("error pulling image", StringComparison.OrdinalIgnoreCase)
62+
|| message.Contains("failed to resolve reference", StringComparison.OrdinalIgnoreCase))
63+
{
64+
return true;
65+
}
66+
}
67+
68+
return false;
69+
}
70+
71+
private static string Flatten(Exception ex)
72+
{
73+
var inner = ex;
74+
while (inner.InnerException is not null)
75+
{
76+
inner = inner.InnerException;
77+
}
78+
79+
return inner.Message;
80+
}
81+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Xunit;
2+
3+
namespace JD.Efcpt.Build.Tests.Integration;
4+
5+
public sealed class ContainerStartupTests
6+
{
7+
[Fact]
8+
public void Detects_transient_docker_registry_error_from_ci()
9+
{
10+
// The exact transient failure observed on CI when pulling mysql:8.0 from Docker Hub.
11+
var ex = new Exception(
12+
"Docker API responded with status code='InternalServerError', " +
13+
"response='{\"message\":\"Head \\\"https://registry-1.docker.io/v2/library/mysql/manifests/8.0\\\": unknown: \"}'");
14+
15+
Assert.True(ContainerStartup.IsDockerInfrastructureFailure(ex));
16+
}
17+
18+
[Fact]
19+
public void Detects_docker_daemon_unavailable()
20+
{
21+
var ex = new Exception("Cannot connect to the Docker daemon at unix:///var/run/docker.sock.");
22+
Assert.True(ContainerStartup.IsDockerInfrastructureFailure(ex));
23+
}
24+
25+
[Fact]
26+
public void Detects_registry_rate_limit()
27+
{
28+
var ex = new InvalidOperationException(
29+
"wrapper", new Exception("toomanyrequests: You have reached your pull rate limit."));
30+
Assert.True(ContainerStartup.IsDockerInfrastructureFailure(ex));
31+
}
32+
33+
[Fact]
34+
public void Does_not_treat_product_assertion_failure_as_infrastructure()
35+
{
36+
var ex = new Exception("Expected 3 tables but found 2.");
37+
Assert.False(ContainerStartup.IsDockerInfrastructureFailure(ex));
38+
}
39+
40+
[Fact]
41+
public void Handles_null()
42+
{
43+
Assert.False(ContainerStartup.IsDockerInfrastructureFailure(null));
44+
}
45+
}

tests/JD.Efcpt.Build.Tests/Integration/EndToEndReverseEngineeringTests.cs

Lines changed: 65 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ private static async Task<TestContext> SetupSqlServerWithSampleSchema()
4040
var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest")
4141
.Build();
4242

43-
await container.StartAsync();
43+
await ContainerStartup.StartOrSkipAsync(container);
4444
var connectionString = container.GetConnectionString();
4545

4646
// Create a sample schema with multiple tables
@@ -181,68 +181,80 @@ private static string[] GetGeneratedFiles(string directory, string pattern)
181181
// ========== Tests ==========
182182

183183
[Scenario("Generate models from SQL Server schema")]
184-
[Fact]
184+
[SkippableFact]
185185
public async Task Generate_models_from_sql_server_schema()
186-
=> await Given("SQL Server with Customers, Orders, Products tables", SetupSqlServerWithSampleSchema)
187-
.When("execute reverse engineering pipeline", ExecuteReverseEngineering)
188-
.Then("query schema task succeeds", r => r.QuerySuccess)
189-
.And("run efcpt task succeeds", r => r.RunSuccess)
190-
.And("fingerprint file exists", r => File.Exists(Path.Combine(r.OutputDir, "efcpt-fingerprint.txt")))
191-
.And("schema model file exists", r => File.Exists(Path.Combine(r.OutputDir, "schema-model.json")))
192-
.Finally(r => r.Context.Dispose())
193-
.AssertPassed();
186+
{
187+
var ctx = await SetupSqlServerWithSampleSchema();
188+
await Given("SQL Server with Customers, Orders, Products tables", () => Task.FromResult(ctx))
189+
.When("execute reverse engineering pipeline", ExecuteReverseEngineering)
190+
.Then("query schema task succeeds", r => r.QuerySuccess)
191+
.And("run efcpt task succeeds", r => r.RunSuccess)
192+
.And("fingerprint file exists", r => File.Exists(Path.Combine(r.OutputDir, "efcpt-fingerprint.txt")))
193+
.And("schema model file exists", r => File.Exists(Path.Combine(r.OutputDir, "schema-model.json")))
194+
.Finally(r => r.Context.Dispose())
195+
.AssertPassed();
196+
}
194197

195198
[Scenario("Generated models contain expected files")]
196-
[Fact]
199+
[SkippableFact]
197200
public async Task Generated_models_contain_expected_files()
198-
=> await Given("SQL Server with sample schema", SetupSqlServerWithSampleSchema)
199-
.When("execute reverse engineering", ExecuteReverseEngineering)
200-
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
201-
.And("sample model file is generated", r => File.Exists(Path.Combine(r.OutputDir, "SampleModel.cs")))
202-
.And("sample model has content", r =>
203-
{
204-
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
205-
return File.Exists(sampleFile) && new FileInfo(sampleFile).Length > 0;
206-
})
207-
.Finally(r => r.Context.Dispose())
208-
.AssertPassed();
201+
{
202+
var ctx = await SetupSqlServerWithSampleSchema();
203+
await Given("SQL Server with sample schema", () => Task.FromResult(ctx))
204+
.When("execute reverse engineering", ExecuteReverseEngineering)
205+
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
206+
.And("sample model file is generated", r => File.Exists(Path.Combine(r.OutputDir, "SampleModel.cs")))
207+
.And("sample model has content", r =>
208+
{
209+
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
210+
return File.Exists(sampleFile) && new FileInfo(sampleFile).Length > 0;
211+
})
212+
.Finally(r => r.Context.Dispose())
213+
.AssertPassed();
214+
}
209215

210216
[Scenario("Generated models are valid C# code")]
211-
[Fact]
217+
[SkippableFact]
212218
public async Task Generated_models_are_valid_csharp_code()
213-
=> await Given("SQL Server with sample schema", SetupSqlServerWithSampleSchema)
214-
.When("execute reverse engineering", ExecuteReverseEngineering)
215-
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
216-
.And("generated .cs file exists", r =>
217-
{
218-
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
219-
return csFiles.Length > 0;
220-
})
221-
.And("generated file has content", r =>
222-
{
223-
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
224-
return csFiles.All(f => new FileInfo(f).Length > 0);
225-
})
226-
.And("generated file contains expected comment", r =>
227-
{
228-
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
229-
if (!File.Exists(sampleFile)) return false;
230-
var content = File.ReadAllText(sampleFile);
231-
return content.Contains("// generated from");
232-
})
233-
.Finally(r => r.Context.Dispose())
234-
.AssertPassed();
219+
{
220+
var ctx = await SetupSqlServerWithSampleSchema();
221+
await Given("SQL Server with sample schema", () => Task.FromResult(ctx))
222+
.When("execute reverse engineering", ExecuteReverseEngineering)
223+
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
224+
.And("generated .cs file exists", r =>
225+
{
226+
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
227+
return csFiles.Length > 0;
228+
})
229+
.And("generated file has content", r =>
230+
{
231+
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
232+
return csFiles.All(f => new FileInfo(f).Length > 0);
233+
})
234+
.And("generated file contains expected comment", r =>
235+
{
236+
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
237+
if (!File.Exists(sampleFile)) return false;
238+
var content = File.ReadAllText(sampleFile);
239+
return content.Contains("// generated from");
240+
})
241+
.Finally(r => r.Context.Dispose())
242+
.AssertPassed();
243+
}
235244

236245
[Scenario("Schema fingerprint changes when database schema changes")]
237-
[Fact]
246+
[SkippableFact]
238247
public async Task Schema_fingerprint_changes_when_database_schema_changes()
239-
=> await Given("SQL Server with sample schema", SetupSqlServerWithSampleSchema)
240-
.When("execute reverse engineering, modify schema, execute again", ExecuteModifyAndRegenerate)
241-
.Then("initial generation succeeds", r => r.InitialQuerySuccess && r.InitialRunSuccess)
242-
.And("modified generation succeeds", r => r.ModifiedQuerySuccess && r.ModifiedRunSuccess)
243-
.And("fingerprints are different", r => r.InitialFingerprint != r.ModifiedFingerprint)
244-
.Finally(r => r.Context.Dispose())
245-
.AssertPassed();
248+
{
249+
var ctx = await SetupSqlServerWithSampleSchema();
250+
await Given("SQL Server with sample schema", () => Task.FromResult(ctx))
251+
.When("execute reverse engineering, modify schema, execute again", ExecuteModifyAndRegenerate)
252+
.Then("initial generation succeeds", r => r.InitialQuerySuccess && r.InitialRunSuccess)
253+
.And("modified generation succeeds", r => r.ModifiedQuerySuccess && r.ModifiedRunSuccess)
254+
.And("fingerprints are different", r => r.InitialFingerprint != r.ModifiedFingerprint)
255+
.Finally(r => r.Context.Dispose())
256+
.AssertPassed();
257+
}
246258

247259
private static async Task<ModifiedSchemaResult> ExecuteModifyAndRegenerate(TestContext context)
248260
{

tests/JD.Efcpt.Build.Tests/Integration/FirebirdSchemaIntegrationTests.cs

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ private static async Task<TestContext> SetupEmptyDatabase()
3939
var container = new FirebirdSqlBuilder("jacobalberty/firebird:v4.0")
4040
.Build();
4141

42-
await container.StartAsync();
42+
await ContainerStartup.StartOrSkipAsync(container);
4343
return new TestContext(container, container.GetConnectionString());
4444
}
4545

@@ -156,10 +156,11 @@ private static async Task<FingerprintResult> ExecuteComputeFingerprintWithChange
156156
// ========== Tests ==========
157157

158158
[Scenario("Reads tables from Firebird database")]
159-
[Fact]
159+
[SkippableFact]
160160
public async Task Reads_tables_from_database()
161161
{
162-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
162+
var ctx = await SetupDatabaseWithSchema();
163+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
163164
.When("schema is read", ExecuteReadSchema)
164165
.Then("returns test tables", r => r.Schema.Tables.Count >= 3)
165166
.And("contains customers table", r => r.Schema.Tables.Any(t => t.Name.Equals("CUSTOMERS", StringComparison.OrdinalIgnoreCase)))
@@ -170,10 +171,11 @@ await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
170171
}
171172

172173
[Scenario("Reads columns with correct metadata")]
173-
[Fact]
174+
[SkippableFact]
174175
public async Task Reads_columns_with_metadata()
175176
{
176-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
177+
var ctx = await SetupDatabaseWithSchema();
178+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
177179
.When("schema is read", ExecuteReadSchema)
178180
.Then("customers table has correct column count", r =>
179181
r.Schema.Tables.First(t => t.Name.Equals("CUSTOMERS", StringComparison.OrdinalIgnoreCase)).Columns.Count == 4)
@@ -184,10 +186,11 @@ await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
184186
}
185187

186188
[Scenario("Reads indexes from Firebird database")]
187-
[Fact]
189+
[SkippableFact]
188190
public async Task Reads_indexes_from_database()
189191
{
190-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
192+
var ctx = await SetupDatabaseWithSchema();
193+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
191194
.When("schema is read", ExecuteReadSchema)
192195
.Then("products table has indexes", r =>
193196
r.Schema.Tables.First(t => t.Name.Equals("PRODUCTS", StringComparison.OrdinalIgnoreCase)).Indexes.Count > 0)
@@ -196,10 +199,11 @@ await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
196199
}
197200

198201
[Scenario("Computes deterministic fingerprint")]
199-
[Fact]
202+
[SkippableFact]
200203
public async Task Computes_deterministic_fingerprint()
201204
{
202-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
205+
var ctx = await SetupDatabaseWithSchema();
206+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
203207
.When("fingerprint computed twice", ExecuteComputeFingerprint)
204208
.Then("fingerprints are equal", r => string.Equals(r.Fingerprint1, r.Fingerprint2, StringComparison.Ordinal))
205209
.And("fingerprint is not empty", r => !string.IsNullOrEmpty(r.Fingerprint1))
@@ -208,21 +212,23 @@ await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
208212
}
209213

210214
[Scenario("Fingerprint changes when schema changes")]
211-
[Fact]
215+
[SkippableFact]
212216
public async Task Fingerprint_changes_when_schema_changes()
213217
{
214-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
218+
var ctx = await SetupDatabaseWithSchema();
219+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
215220
.When("schema is modified", ExecuteComputeFingerprintWithChange)
216221
.Then("fingerprints are different", r => !string.Equals(r.Fingerprint1, r.Fingerprint2, StringComparison.Ordinal))
217222
.Finally(r => r.Context.Dispose())
218223
.AssertPassed();
219224
}
220225

221226
[Scenario("Uses factory to create reader")]
222-
[Fact]
227+
[SkippableFact]
223228
public async Task Factory_creates_correct_reader()
224229
{
225-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
230+
var ctx = await SetupDatabaseWithSchema();
231+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
226232
.When("schema read via factory", ExecuteReadSchemaViaFactory)
227233
.Then("returns valid schema", r => r.Schema.Tables.Count >= 3)
228234
.And("contains customers table", r => r.Schema.Tables.Any(t => t.Name.Equals("CUSTOMERS", StringComparison.OrdinalIgnoreCase)))
@@ -231,10 +237,11 @@ await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
231237
}
232238

233239
[Scenario("fb alias works")]
234-
[Fact]
240+
[SkippableFact]
235241
public async Task Fb_alias_works()
236242
{
237-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
243+
var ctx = await SetupDatabaseWithSchema();
244+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
238245
.When("schema read via fb alias", ExecuteReadSchemaViaFbAlias)
239246
.Then("returns valid schema", r => r.Schema.Tables.Count >= 3)
240247
.And("contains customers table", r => r.Schema.Tables.Any(t => t.Name.Equals("CUSTOMERS", StringComparison.OrdinalIgnoreCase)))
@@ -243,10 +250,11 @@ await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
243250
}
244251

245252
[Scenario("Excludes system tables")]
246-
[Fact]
253+
[SkippableFact]
247254
public async Task Excludes_system_tables()
248255
{
249-
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
256+
var ctx = await SetupDatabaseWithSchema();
257+
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
250258
.When("schema is read", ExecuteReadSchema)
251259
.Then("no RDB$ tables included", r =>
252260
!r.Schema.Tables.Any(t => t.Name.StartsWith("RDB$", StringComparison.OrdinalIgnoreCase)))

0 commit comments

Comments
 (0)