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
40 changes: 29 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
with:
name: test-results
path: |
./test-results/**/*.trx
./test-results/*.trx
./test-results/**/coverage.xml
retention-days: 14

Expand All @@ -78,7 +78,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
name: Test Results
path: ./test-results/**/*.trx
path: ./test-results/*.trx
reporter: dotnet-trx

- name: Pack
Expand Down Expand Up @@ -146,26 +146,44 @@ jobs:
-p:TestDatabase=${{ matrix.database }}

- name: Test
run: >-
dotnet test --no-build -c Release
--project tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests
-p:TestDatabase=${{ matrix.database }}
--
--report-trx --report-trx-filename results.trx
--results-directory ./test-results
run: |
if [ "${{ matrix.database }}" = "Cosmos" ]; then
# Cosmos vNext emulator binds host port 8081 (the .NET SDK
# connects to the gateway-advertised endpoint, which must
# match the host-side port), so only one emulator can run
# at a time. Run TFMs sequentially.
for tfm in net8.0 net9.0 net10.0; do
echo "::group::Cosmos tests ($tfm)"
dotnet test --no-build -c Release \
--project tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests \
-p:TestDatabase=${{ matrix.database }} \
-f "$tfm" \
--results-directory ./test-results \
-- \
--report-trx --report-trx-filename "results-$tfm.trx"
echo "::endgroup::"
done
else
dotnet test --no-build -c Release \
--project tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests \
-p:TestDatabase=${{ matrix.database }} \
--results-directory ./test-results \
-- \
--report-trx --report-trx-filename results.trx
fi

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: container-test-results-${{ matrix.database }}
path: ./test-results/**/*.trx
path: ./test-results/*.trx
retention-days: 14

- name: Test report
if: always()
uses: dorny/test-reporter@v1
with:
name: Container Test Results (${{ matrix.database }})
path: ./test-results/**/*.trx
path: ./test-results/*.trx
reporter: dotnet-trx
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="8.23.60" />
<PackageVersion Include="Basic.Reference.Assemblies.Net90" Version="1.8.4" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Testcontainers" Version="4.3.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.3.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.3.0" />
<PackageVersion Include="Testcontainers.CosmosDb" Version="4.3.0" />
<PackageVersion Include="Testcontainers.MySql" Version="4.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Cosmos" Version="8.0.25" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@
</ItemGroup>

<!-- Cosmos DB (conditional) -->
<!-- Uses the Linux vNext preview emulator via the generic Testcontainers
package; Testcontainers.CosmosDb only wraps the classic emulator. -->
<ItemGroup Condition="'$(TestDatabase)' == 'Cosmos' or '$(TestDatabase)' == 'All'">
<PackageReference Include="Testcontainers.CosmosDb" />
<PackageReference Include="Testcontainers" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Cosmos"
Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Cosmos"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task Select_Total_ReturnsCorrectValues()
}

[TestMethod]
public async Task Where_TotalGreaterThan100_FiltersCorrectly()
public virtual async Task Where_TotalGreaterThan100_FiltersCorrectly()
{
Expression<Func<Order, double>> totalExpr = o => o.Total;
var expanded = (Expression<Func<Order, double>>)totalExpr.ExpandExpressives();
Expand All @@ -47,7 +47,7 @@ public async Task Where_TotalGreaterThan100_FiltersCorrectly()
}

[TestMethod]
public async Task Where_NoMatch_ReturnsEmpty()
public virtual async Task Where_NoMatch_ReturnsEmpty()
{
Expression<Func<Order, double>> totalExpr = o => o.Total;
var expanded = (Expression<Func<Order, double>>)totalExpr.ExpandExpressives();
Expand All @@ -62,7 +62,7 @@ public async Task Where_NoMatch_ReturnsEmpty()
}

[TestMethod]
public async Task OrderByDescending_Total_ReturnsSortedDescending()
public virtual async Task OrderByDescending_Total_ReturnsSortedDescending()
{
Expression<Func<Order, double>> totalExpr = o => o.Total;
var expanded = (Expression<Func<Order, double>>)totalExpr.ExpandExpressives();
Expand Down Expand Up @@ -280,7 +280,7 @@ public async Task Select_InstanceProperty_ViaExpressiveFor_ReturnsConstant()
// ── Loop Tests ──────────────────────────────────────────────────────────

[TestMethod]
public async Task Select_ItemCount_ReturnsCorrectCounts()
public virtual async Task Select_ItemCount_ReturnsCorrectCounts()
{
Expression<Func<Order, int>> expr = o => o.ItemCount();
var expanded = (Expression<Func<Order, int>>)expr.ExpandExpressives();
Expand All @@ -291,7 +291,7 @@ public async Task Select_ItemCount_ReturnsCorrectCounts()
}

[TestMethod]
public async Task Select_ItemTotal_ReturnsCorrectTotals()
public virtual async Task Select_ItemTotal_ReturnsCorrectTotals()
{
Expression<Func<Order, double>> expr = o => o.ItemTotal();
var expanded = (Expression<Func<Order, double>>)expr.ExpandExpressives();
Expand All @@ -302,7 +302,7 @@ public async Task Select_ItemTotal_ReturnsCorrectTotals()
}

[TestMethod]
public async Task Select_HasExpensiveItems_ReturnsCorrectFlags()
public virtual async Task Select_HasExpensiveItems_ReturnsCorrectFlags()
{
Expression<Func<Order, bool>> expr = o => o.HasExpensiveItems();
var expanded = (Expression<Func<Order, bool>>)expr.ExpandExpressives();
Expand All @@ -313,7 +313,7 @@ public async Task Select_HasExpensiveItems_ReturnsCorrectFlags()
}

[TestMethod]
public async Task Select_AllItemsAffordable_ReturnsCorrectFlags()
public virtual async Task Select_AllItemsAffordable_ReturnsCorrectFlags()
{
Expression<Func<Order, bool>> expr = o => o.AllItemsAffordable();
var expanded = (Expression<Func<Order, bool>>)expr.ExpandExpressives();
Expand All @@ -324,7 +324,7 @@ public async Task Select_AllItemsAffordable_ReturnsCorrectFlags()
}

[TestMethod]
public async Task Select_ItemTotalForExpensive_ReturnsCorrectTotals()
public virtual async Task Select_ItemTotalForExpensive_ReturnsCorrectTotals()
{
Expression<Func<Order, double>> expr = o => o.ItemTotalForExpensive();
var expanded = (Expression<Func<Order, double>>)expr.ExpandExpressives();
Expand All @@ -337,7 +337,7 @@ public async Task Select_ItemTotalForExpensive_ReturnsCorrectTotals()
// ── Null Conditional ────────────────────────────────────────────────────

[TestMethod]
public async Task Select_CustomerName_ReturnsCorrectNullableValues()
public virtual async Task Select_CustomerName_ReturnsCorrectNullableValues()
{
Expression<Func<Order, string?>> expr = o => o.CustomerName;
var expanded = (Expression<Func<Order, string?>>)expr.ExpandExpressives();
Expand All @@ -348,7 +348,7 @@ public async Task Select_CustomerName_ReturnsCorrectNullableValues()
}

[TestMethod]
public async Task Select_TagLength_ReturnsCorrectNullableValues()
public virtual async Task Select_TagLength_ReturnsCorrectNullableValues()
{
Expression<Func<Order, int?>> expr = o => o.TagLength;
var expanded = (Expression<Func<Order, int?>>)expr.ExpandExpressives();
Expand All @@ -375,7 +375,7 @@ public async Task Where_CustomerNameEquals_FiltersCorrectly()
}

[TestMethod]
public async Task Where_CustomerNameIsNull_FiltersCorrectly()
public virtual async Task Where_CustomerNameIsNull_FiltersCorrectly()
{
Expression<Func<Order, string?>> nameExpr = o => o.CustomerName;
var expanded = (Expression<Func<Order, string?>>)nameExpr.ExpandExpressives();
Expand Down Expand Up @@ -411,7 +411,7 @@ public virtual async Task OrderBy_TagLength_NullsAppearFirst()
// ── Nullable Chain ──────────────────────────────────────────────────────

[TestMethod]
public async Task Select_CustomerCountry_TwoLevelChain()
public virtual async Task Select_CustomerCountry_TwoLevelChain()
{
Expression<Func<Order, string?>> expr = o => o.CustomerCountry;
var expanded = (Expression<Func<Order, string?>>)expr.ExpandExpressives();
Expand Down Expand Up @@ -526,7 +526,7 @@ public async Task Polyfill_Arithmetic_ProjectsCorrectly()
}

[TestMethod]
public async Task Polyfill_NullConditional_ProjectsCorrectly()
public virtual async Task Polyfill_NullConditional_ProjectsCorrectly()
{
var expr = ExpressionPolyfill.Create((Order o) => o.Customer != null ? o.Customer.Name : null);

Expand Down Expand Up @@ -607,7 +607,7 @@ public async Task Select_FormattedPrice_UsesToStringWithFormat()
}

[TestMethod]
public async Task Where_Summary_TranslatesToSql()
public virtual async Task Where_Summary_TranslatesToSql()
{
// Summary uses string.Concat(string, string, string, string).
// This verifies the 4-arg overload translates to SQL (Where throws if not).
Expand All @@ -621,7 +621,7 @@ public async Task Where_Summary_TranslatesToSql()
}

[TestMethod]
public async Task Where_DetailedSummary_ConcatArrayTranslatesToSql()
public virtual async Task Where_DetailedSummary_ConcatArrayTranslatesToSql()
{
// DetailedSummary has 7 string parts, so the emitter produces string.Concat(string[]).
// FlattenConcatArrayCalls rewrites it to chained Concat calls for EF Core.
Expand Down Expand Up @@ -649,7 +649,7 @@ public async Task Select_GetGrade_ReturnsCorrectValues()
}

[TestMethod]
public async Task OrderBy_GetGrade_ReturnsSorted()
public virtual async Task OrderBy_GetGrade_ReturnsSorted()
{
Expression<Func<Order, string>> gradeExpr = o => o.GetGrade();
var expandedGrade = (Expression<Func<Order, string>>)gradeExpr.ExpandExpressives();
Expand All @@ -664,7 +664,7 @@ public async Task OrderBy_GetGrade_ReturnsSorted()
}

[TestMethod]
public async Task OrderByDescending_GetGrade_ReturnsSortedDescending()
public virtual async Task OrderByDescending_GetGrade_ReturnsSortedDescending()
{
Expression<Func<Order, string>> gradeExpr = o => o.GetGrade();
var expandedGrade = (Expression<Func<Order, string>>)gradeExpr.ExpandExpressives();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
using Testcontainers.PostgreSql;
#endif
#if TEST_COSMOS
using Testcontainers.CosmosDb;
using System.Net.Http;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
#endif
#if TEST_POMELO_MYSQL && !NET10_0_OR_GREATER
using Testcontainers.MySql;
Expand All @@ -32,7 +34,18 @@ public static class ContainerFixture
#endif

#if TEST_COSMOS
private static CosmosDbContainer? _cosmos;
// Cosmos DB Linux emulator vNext (preview). Microsoft's officially
// recommended image for CI/CD use; the classic emulator is documented
// as not running reliably on hosted CI agents.
// https://learn.microsoft.com/en-us/azure/cosmos-db/emulator-linux
private const string CosmosImage = "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview";
private const int CosmosGatewayPort = 8081;

// Well-known emulator key (same value used by classic and vNext).
private const string CosmosEmulatorKey =
"C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";

private static IContainer? _cosmos;
public static string? CosmosConnectionString { get; private set; }
#endif

Expand Down Expand Up @@ -63,7 +76,20 @@ public static async Task InitializeAsync(TestContext _)
tasks.Add(StartPostgresAsync());
#endif
#if TEST_COSMOS
_cosmos = new CosmosDbBuilder().Build();
// The .NET Cosmos SDK reads the gateway's account metadata, which
// returns the emulator's *internal* endpoint (localhost:8081). The
// SDK then connects to that endpoint from the host, so we must bind
// the host port to the same number 8081 as the container port. That
// means only one emulator can run per host — Cosmos tests for the
// multi-TFM matrix must be serialized in CI.
_cosmos = new ContainerBuilder()
.WithImage(CosmosImage)
// .NET SDK does not support HTTP mode against the emulator.
.WithEnvironment("PROTOCOL", "https")
.WithPortBinding(CosmosGatewayPort, CosmosGatewayPort)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilPortIsAvailable(CosmosGatewayPort))
.Build();
tasks.Add(StartCosmosAsync());
#endif
#if TEST_POMELO_MYSQL && !NET10_0_OR_GREATER
Expand Down Expand Up @@ -132,7 +158,40 @@ private static async Task StartPostgresAsync()
private static async Task StartCosmosAsync()
{
await _cosmos!.StartAsync();
CosmosConnectionString = _cosmos.GetConnectionString();
CosmosConnectionString =
$"AccountEndpoint=https://localhost:{CosmosGatewayPort}/;AccountKey={CosmosEmulatorKey}";

// The vNext emulator binds port 8081 long before its backing
// PostgreSQL + Rust gateway can serve requests. Poll the gateway
// root endpoint until it returns a 200 — bypass the self-signed
// cert manually since the built-in HTTP wait strategy does not.
using var httpClient = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true,
})
{
Timeout = TimeSpan.FromSeconds(5),
};

var url = $"https://localhost:{CosmosGatewayPort}/";
var deadline = DateTime.UtcNow.AddMinutes(5);
while (DateTime.UtcNow < deadline)
{
try
{
var resp = await httpClient.GetAsync(url);
if (resp.IsSuccessStatusCode)
return;
}
catch
{
// Gateway not yet ready — keep polling.
}
await Task.Delay(TimeSpan.FromSeconds(1));
}

throw new TimeoutException(
$"Cosmos vNext emulator at {url} did not become ready within 5 minutes.");
}
#endif

Expand Down
Loading
Loading