Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions tests/JD.Efcpt.Build.Tests/Integration/ContainerStartup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using DotNet.Testcontainers.Containers;
using Xunit;

namespace JD.Efcpt.Build.Tests.Integration;

/// <summary>
/// Helpers for starting Testcontainers-backed integration tests in a way that is
/// resilient to transient Docker/registry infrastructure failures.
/// </summary>
/// <remarks>
/// These integration tests require a working Docker daemon and the ability to pull
/// images from a registry (e.g. Docker Hub). CI runners periodically hit transient
/// registry errors such as
/// <c>Docker API responded with status code='InternalServerError', response='{"message":"Head ... manifests/8.0: unknown: "}'</c>.
/// Such failures are environmental, not product defects, so they should mark the test
/// as <b>skipped</b> (with a clear reason) rather than failing the build. Genuine
/// product failures (assertion failures, schema-reader bugs) still fail as normal.
/// </remarks>
public static class ContainerStartup
{
/// <summary>
/// Starts the supplied container, converting transient Docker/registry
/// infrastructure failures into a test skip via <see cref="SkipException"/>.
/// </summary>
public static async Task StartOrSkipAsync(IContainer container)
{
try
{
await container.StartAsync();
}
catch (Exception ex) when (IsDockerInfrastructureFailure(ex))
{
throw new SkipException(
"Skipping container-based integration test: Docker/registry is unavailable " +
$"or returned a transient error ({ex.GetType().Name}: {Flatten(ex)}).");
}
}

/// <summary>
/// Determines whether an exception (or any inner exception) represents a transient
/// Docker daemon / registry infrastructure failure rather than a product defect.
/// </summary>
public static bool IsDockerInfrastructureFailure(Exception? ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
var typeName = current.GetType().Name;
if (typeName is "DockerApiException" or "DockerContainerNotFoundException")
{
return true;
}

var message = current.Message ?? string.Empty;
if (message.Contains("registry-1.docker.io", StringComparison.OrdinalIgnoreCase)
|| message.Contains("manifests", StringComparison.OrdinalIgnoreCase)
|| message.Contains("InternalServerError", StringComparison.OrdinalIgnoreCase)
|| message.Contains("Docker API responded", StringComparison.OrdinalIgnoreCase)
|| message.Contains("toomanyrequests", StringComparison.OrdinalIgnoreCase)
|| message.Contains("Cannot connect to the Docker daemon", StringComparison.OrdinalIgnoreCase)
|| message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase)
|| message.Contains("error pulling image", StringComparison.OrdinalIgnoreCase)
|| message.Contains("failed to resolve reference", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}

private static string Flatten(Exception ex)
{
var inner = ex;
while (inner.InnerException is not null)
{
inner = inner.InnerException;
}

return inner.Message;
}
}
45 changes: 45 additions & 0 deletions tests/JD.Efcpt.Build.Tests/Integration/ContainerStartupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Xunit;

namespace JD.Efcpt.Build.Tests.Integration;

public sealed class ContainerStartupTests
{
[Fact]
public void Detects_transient_docker_registry_error_from_ci()
{
// The exact transient failure observed on CI when pulling mysql:8.0 from Docker Hub.
var ex = new Exception(
"Docker API responded with status code='InternalServerError', " +
"response='{\"message\":\"Head \\\"https://registry-1.docker.io/v2/library/mysql/manifests/8.0\\\": unknown: \"}'");

Assert.True(ContainerStartup.IsDockerInfrastructureFailure(ex));
}

[Fact]
public void Detects_docker_daemon_unavailable()
{
var ex = new Exception("Cannot connect to the Docker daemon at unix:///var/run/docker.sock.");
Assert.True(ContainerStartup.IsDockerInfrastructureFailure(ex));
}

[Fact]
public void Detects_registry_rate_limit()
{
var ex = new InvalidOperationException(
"wrapper", new Exception("toomanyrequests: You have reached your pull rate limit."));
Assert.True(ContainerStartup.IsDockerInfrastructureFailure(ex));
}

[Fact]
public void Does_not_treat_product_assertion_failure_as_infrastructure()
{
var ex = new Exception("Expected 3 tables but found 2.");
Assert.False(ContainerStartup.IsDockerInfrastructureFailure(ex));
}

[Fact]
public void Handles_null()
{
Assert.False(ContainerStartup.IsDockerInfrastructureFailure(null));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ private static async Task<TestContext> SetupSqlServerWithSampleSchema()
var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest")
.Build();

await container.StartAsync();
await ContainerStartup.StartOrSkipAsync(container);
var connectionString = container.GetConnectionString();

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

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

[Scenario("Generated models contain expected files")]
[Fact]
[SkippableFact]
public async Task Generated_models_contain_expected_files()
=> await Given("SQL Server with sample schema", SetupSqlServerWithSampleSchema)
.When("execute reverse engineering", ExecuteReverseEngineering)
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
.And("sample model file is generated", r => File.Exists(Path.Combine(r.OutputDir, "SampleModel.cs")))
.And("sample model has content", r =>
{
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
return File.Exists(sampleFile) && new FileInfo(sampleFile).Length > 0;
})
.Finally(r => r.Context.Dispose())
.AssertPassed();
{
var ctx = await SetupSqlServerWithSampleSchema();
await Given("SQL Server with sample schema", () => Task.FromResult(ctx))
.When("execute reverse engineering", ExecuteReverseEngineering)
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
.And("sample model file is generated", r => File.Exists(Path.Combine(r.OutputDir, "SampleModel.cs")))
.And("sample model has content", r =>
{
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
return File.Exists(sampleFile) && new FileInfo(sampleFile).Length > 0;
})
.Finally(r => r.Context.Dispose())
.AssertPassed();
}

[Scenario("Generated models are valid C# code")]
[Fact]
[SkippableFact]
public async Task Generated_models_are_valid_csharp_code()
=> await Given("SQL Server with sample schema", SetupSqlServerWithSampleSchema)
.When("execute reverse engineering", ExecuteReverseEngineering)
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
.And("generated .cs file exists", r =>
{
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
return csFiles.Length > 0;
})
.And("generated file has content", r =>
{
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
return csFiles.All(f => new FileInfo(f).Length > 0);
})
.And("generated file contains expected comment", r =>
{
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
if (!File.Exists(sampleFile)) return false;
var content = File.ReadAllText(sampleFile);
return content.Contains("// generated from");
})
.Finally(r => r.Context.Dispose())
.AssertPassed();
{
var ctx = await SetupSqlServerWithSampleSchema();
await Given("SQL Server with sample schema", () => Task.FromResult(ctx))
.When("execute reverse engineering", ExecuteReverseEngineering)
.Then("tasks succeed", r => r.QuerySuccess && r.RunSuccess)
.And("generated .cs file exists", r =>
{
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
return csFiles.Length > 0;
})
.And("generated file has content", r =>
{
var csFiles = GetGeneratedFiles(r.OutputDir, "*.cs");
return csFiles.All(f => new FileInfo(f).Length > 0);
})
.And("generated file contains expected comment", r =>
{
var sampleFile = Path.Combine(r.OutputDir, "SampleModel.cs");
if (!File.Exists(sampleFile)) return false;
var content = File.ReadAllText(sampleFile);
return content.Contains("// generated from");
})
.Finally(r => r.Context.Dispose())
.AssertPassed();
}

[Scenario("Schema fingerprint changes when database schema changes")]
[Fact]
[SkippableFact]
public async Task Schema_fingerprint_changes_when_database_schema_changes()
=> await Given("SQL Server with sample schema", SetupSqlServerWithSampleSchema)
.When("execute reverse engineering, modify schema, execute again", ExecuteModifyAndRegenerate)
.Then("initial generation succeeds", r => r.InitialQuerySuccess && r.InitialRunSuccess)
.And("modified generation succeeds", r => r.ModifiedQuerySuccess && r.ModifiedRunSuccess)
.And("fingerprints are different", r => r.InitialFingerprint != r.ModifiedFingerprint)
.Finally(r => r.Context.Dispose())
.AssertPassed();
{
var ctx = await SetupSqlServerWithSampleSchema();
await Given("SQL Server with sample schema", () => Task.FromResult(ctx))
.When("execute reverse engineering, modify schema, execute again", ExecuteModifyAndRegenerate)
.Then("initial generation succeeds", r => r.InitialQuerySuccess && r.InitialRunSuccess)
.And("modified generation succeeds", r => r.ModifiedQuerySuccess && r.ModifiedRunSuccess)
.And("fingerprints are different", r => r.InitialFingerprint != r.ModifiedFingerprint)
.Finally(r => r.Context.Dispose())
.AssertPassed();
}

private static async Task<ModifiedSchemaResult> ExecuteModifyAndRegenerate(TestContext context)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ private static async Task<TestContext> SetupEmptyDatabase()
var container = new FirebirdSqlBuilder("jacobalberty/firebird:v4.0")
.Build();

await container.StartAsync();
await ContainerStartup.StartOrSkipAsync(container);
return new TestContext(container, container.GetConnectionString());
}

Expand Down Expand Up @@ -156,10 +156,11 @@ private static async Task<FingerprintResult> ExecuteComputeFingerprintWithChange
// ========== Tests ==========

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

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

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

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

[Scenario("Fingerprint changes when schema changes")]
[Fact]
[SkippableFact]
public async Task Fingerprint_changes_when_schema_changes()
{
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
var ctx = await SetupDatabaseWithSchema();
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
.When("schema is modified", ExecuteComputeFingerprintWithChange)
.Then("fingerprints are different", r => !string.Equals(r.Fingerprint1, r.Fingerprint2, StringComparison.Ordinal))
.Finally(r => r.Context.Dispose())
.AssertPassed();
}

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

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

[Scenario("Excludes system tables")]
[Fact]
[SkippableFact]
public async Task Excludes_system_tables()
{
await Given("a Firebird container with test schema", SetupDatabaseWithSchema)
var ctx = await SetupDatabaseWithSchema();
await Given("a Firebird container with test schema", () => Task.FromResult(ctx))
.When("schema is read", ExecuteReadSchema)
.Then("no RDB$ tables included", r =>
!r.Schema.Tables.Any(t => t.Name.StartsWith("RDB$", StringComparison.OrdinalIgnoreCase)))
Expand Down
Loading
Loading