fix/issue-3-splitquery #5

Merged
notazof merged 8 commits from fix/issue-3-splitquery into main 2026-07-12 02:47:16 +01:00
10 changed files with 169 additions and 18 deletions
Showing only changes of commit fa7794b7a0 - Show all commits

repoduces the bug

Lum 2026-07-11 21:56:25 +01:00

View file

@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />

View file

@ -30,10 +30,11 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
} }
/// <summary>Reset the in-memory database to a known empty state. /// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the /// The fixture now uses SQLite in-memory (see forgejo#3), which
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance, /// shares its store across the lifetime of the
/// so without a per-test reset the test order would leak /// <see cref="BlogsWebServerFixture"/> instance, so without a
/// state between tests.</summary> /// per-test reset the test order would leak state between
/// tests.</summary>
private void ResetDatabase() private void ResetDatabase()
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();

View file

@ -48,6 +48,19 @@ namespace Yavsc.Blogs.Tests;
/// </summary> /// </summary>
public sealed class BlogsWebServerFixture : WebHostFixture public sealed class BlogsWebServerFixture : WebHostFixture
{ {
// SQLite in-memory database is created once and shared across all
// DbContext instances for the test lifetime. The connection must
// stay open: closing it destroys the in-memory database. The
// Microsoft.Data.Sqlite pool will then open additional connections
// to the same in-memory store, as long as the original connection
// is alive. This is the SQLite equivalent of the EF Core
// InMemoryDatabaseRoot we used to use.
private Microsoft.Data.Sqlite.SqliteConnection? _sharedSqliteConnection;
// Legacy field kept to make the migration diff readable. The
// InMemory provider path is no longer used by this fixture, but
// removing it is out of scope for the SQLite-in-memory migration
// (forgejo#3 follow-up).
[System.Obsolete("Replaced by SQLite in-memory (forgejo#3).")]
private InMemoryDatabaseRoot? _inMemoryRoot; private InMemoryDatabaseRoot? _inMemoryRoot;
protected override WebApplication BuildApp(WebApplicationBuilder builder) protected override WebApplication BuildApp(WebApplicationBuilder builder)
@ -58,15 +71,18 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// against an empty table returns an empty list, which is // against an empty table returns an empty list, which is
// exactly what the first test wants to assert. // exactly what the first test wants to assert.
// //
// Share a single InMemoryDatabaseRoot across the test // We use SQLite in-memory (not the EF Core InMemory provider)
// lifetime so POST + GET on the same fixture see the same // because the InMemory provider cannot materialise navigation
// store. Without the root, EF Core's In-Memory provider // properties from IdentityServer8 entity types (see forgejo#3).
// creates independent stores per DbContext in some // SQLite in-memory is a transient, file-less store that
// configurations, and the second request would see an // executes real SQL, so navigation properties work as
// empty list even after the first wrote a row. // expected. The shared SqliteConnection keeps the database
_inMemoryRoot = new InMemoryDatabaseRoot(); // alive for the test lifetime, mirroring the
// InMemoryDatabaseRoot pattern we used previously.
_sharedSqliteConnection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:");
_sharedSqliteConnection.Open();
builder.Services.AddDbContext<ApplicationDbContext>(opt => builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)); opt.UseSqlite(_sharedSqliteConnection));
// Trivial file-system auth: the GET index path never calls // Trivial file-system auth: the GET index path never calls
// into it, but the DI container needs an instance. // into it, but the DI container needs an instance.

View file

@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" /> <PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" /> <PackageReference Include="xunit.v3.extensibility.core" />

View file

@ -2,7 +2,11 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Yavsc.Models;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
namespace Yavsc.Org.Tests; namespace Yavsc.Org.Tests;
@ -24,6 +28,21 @@ namespace Yavsc.Org.Tests;
/// </summary> /// </summary>
public class TestWebApplicationFactory : WebApplicationFactory<Program> public class TestWebApplicationFactory : WebApplicationFactory<Program>
{ {
// SQLite in-memory: the connection must stay open for the lifetime
// of the host, otherwise the in-memory database is destroyed and
// every new DbContext sees an empty store. We hold the connection
// here so it is disposed only when the factory is disposed. The
// Microsoft.Data.Sqlite pool reuses the underlying in-memory store
// across additional connections opened against the same connection
// string, as long as the original connection is alive. This is the
// SQLite equivalent of the EF Core InMemoryDatabaseRoot pattern.
private readonly SqliteConnection _sharedSqliteConnection = new("Data Source=:memory:");
public TestWebApplicationFactory()
{
_sharedSqliteConnection.Open();
}
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
// UseEnvironment("Testing") puts the host in a dedicated // UseEnvironment("Testing") puts the host in a dedicated
@ -38,6 +57,25 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
// The production Program.Main calls AddConfiguration("org")
// and then AddIdentityDBAndStores which calls
// GetConnectionString("YavscConnection"). The result is
// "Data Source=:memory:" (from appsettings-org.Testing.json),
// and the production code path in HostingExtensions routes
// that to UseSqlite. However, the EF Core in-memory test
// pattern needs all DbContext instances to see the same
// store; with a raw "Data Source=:memory:" connection string,
// each connection opens its own private database. We
// therefore drop the production DbContext registration and
// re-register ApplicationDbContext with the shared
// SqliteConnection held by this factory. Tests that need
// the schema to exist call EnsureCreated on the resulting
// DbContext (e.g. ClientControllerCollectionTests seeds a
// Client row in its constructor).
services.RemoveAll<DbContextOptions<ApplicationDbContext>>();
services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlite(_sharedSqliteConnection));
// Replace the production IAuthorizationPolicyProvider with // Replace the production IAuthorizationPolicyProvider with
// the test one. The default registered by AddAuthorization // the test one. The default registered by AddAuthorization
// becomes irrelevant: any GetPolicyAsync call is routed here. // becomes irrelevant: any GetPolicyAsync call is routed here.
@ -48,6 +86,37 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
// TestUserMiddleware runs after UseAuthentication/Authorization. // TestUserMiddleware runs after UseAuthentication/Authorization.
services.AddTransient<TestUserMiddleware>(); services.AddTransient<TestUserMiddleware>();
services.AddTransient<IStartupFilter, TestUserStartupFilter>(); services.AddTransient<IStartupFilter, TestUserStartupFilter>();
// Run EnsureCreated once at host start. With SQLite in-memory
// and a shared connection, this creates the schema once
// and the schema persists for the host lifetime. The
// test code (e.g. ClientControllerCollectionTests seed) can
// then write rows without having to call EnsureCreated
// itself. EnsureCreated is idempotent: re-running it on
// an existing schema is a no-op.
services.AddHostedService<SqliteEnsureCreatedHostedService>();
}); });
} }
private sealed class SqliteEnsureCreatedHostedService : IHostedService
{
private readonly IServiceProvider _services;
public SqliteEnsureCreatedHostedService(IServiceProvider services)
{
_services = services;
}
public Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.Database.EnsureCreatedAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
protected override void Dispose(bool disposing)
{
if (disposing) _sharedSqliteConnection.Dispose();
base.Dispose(disposing);
}
} }

View file

@ -75,7 +75,20 @@ public sealed class WebServerFixture : WebHostFixture
// that plus the in-memory overrides below. // that plus the in-memory overrides below.
builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?> builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?>
{ {
[$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory", // The EF Core in-memory provider cannot materialise
// entity types from IdentityServer8 (see forgejo#3):
// it crashes with IndexOutOfRangeException on the
// multi-Include query in ClientController.LoadClientAsync
// and on per-collection LoadAsync. SQLite in-memory is
// a transient, file-less store that uses the same
// connection string semantics as the InMemory provider
// ("keep the connection open for the host lifetime")
// but actually executes SQL, so it handles
// navigation-property entities correctly. The
// HostingExtensions code path detects this connection
// string and routes to UseSqlite. See
// doc/testing.md for the test-driver policy.
[$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "Data Source=:memory:",
// SMTP test config: UserName non-null so MailSender // SMTP test config: UserName non-null so MailSender
// exercises the Authenticate branch — the // exercises the Authenticate branch — the
// RecordingSmtpClient captures it. // RecordingSmtpClient captures it.

View file

@ -53,6 +53,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
</ItemGroup> </ItemGroup>
<!-- <!--
MapStaticAssets() in the production pipeline resolves MapStaticAssets() in the production pipeline resolves

View file

@ -174,8 +174,22 @@ public static class HostingExtensions
services.AddDbContext<ApplicationDbContext>(options => services.AddDbContext<ApplicationDbContext>(options =>
{ {
if (UsesInMemoryProvider(connectionString)) if (UsesSqliteInMemoryProvider(connectionString))
{ {
// The Sqlite connection must be kept open for the
// lifetime of the host: closing the connection
// destroys the in-memory database. The fixture
// manages this; the provider just receives the
// connection string and opens its own pooled
// connections.
options.UseSqlite(connectionString);
}
else if (UsesInMemoryProvider(connectionString))
{
// Legacy in-memory provider (Microsoft.EntityFrameworkCore.InMemory).
// Kept for tests that do not exercise navigation
// properties from IdentityServer8 entity types. The
// general case is SQLite in-memory, above.
options.UseInMemoryDatabase(connectionString); options.UseInMemoryDatabase(connectionString);
} }
else else
@ -343,7 +357,11 @@ public static class HostingExtensions
{ {
options.ConfigureDbContext = b => options.ConfigureDbContext = b =>
{ {
if (UsesInMemoryProvider(connectionString)) if (UsesSqliteInMemoryProvider(connectionString))
{
b.UseSqlite(connectionString);
}
else if (UsesInMemoryProvider(connectionString))
{ {
b.UseInMemoryDatabase(connectionString); b.UseInMemoryDatabase(connectionString);
} }
@ -367,7 +385,11 @@ public static class HostingExtensions
{ {
options.ConfigureDbContext = b => options.ConfigureDbContext = b =>
{ {
if (UsesInMemoryProvider(connectionString)) if (UsesSqliteInMemoryProvider(connectionString))
{
b.UseSqlite(connectionString);
}
else if (UsesInMemoryProvider(connectionString))
{ {
b.UseInMemoryDatabase(connectionString); b.UseInMemoryDatabase(connectionString);
} }
@ -601,9 +623,35 @@ public static class HostingExtensions
private static bool UsesInMemoryProvider(string connectionString) private static bool UsesInMemoryProvider(string connectionString)
{ {
return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase); // The legacy in-memory marker (EF Core InMemory provider).
if (string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase))
return true;
// SQLite in-memory is a separate driver (Microsoft.EntityFrameworkCore.Sqlite)
// but is conceptually the same: a transient, file-less store
// that lives only for the duration of a test. Tests use it for
// entity types (IdentityServer8 navigation properties) that the
// InMemory provider cannot materialise. The check is intentionally
// liberal: any SQLite connection string that opens an in-memory
// database qualifies.
return IsSqliteInMemoryConnectionString(connectionString);
} }
private static bool IsSqliteInMemoryConnectionString(string connectionString)
{
if (string.IsNullOrEmpty(connectionString)) return false;
// The canonical Microsoft.Data.Sqlite form: "Data Source=:memory:"
// (with or without spaces around the colon). The keyword is
// case-insensitive.
if (connectionString.Contains("Data Source=:memory:", StringComparison.OrdinalIgnoreCase))
return true;
if (connectionString.Contains("DataSource=:memory:", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private static bool UsesSqliteInMemoryProvider(string connectionString)
=> IsSqliteInMemoryConnectionString(connectionString);
private static Action<DbContext, bool> EnsureDefaultApplicationScopes() private static Action<DbContext, bool> EnsureDefaultApplicationScopes()
{ {
return (context, _) => return (context, _) =>

View file

@ -31,6 +31,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" />
<PackageReference Include="Google.Apis.Compute.v1" /> <PackageReference Include="Google.Apis.Compute.v1" />

View file

@ -1,6 +1,6 @@
{ {
"ConnectionStrings": { "ConnectionStrings": {
"YavscConnection": "InMemory" "YavscConnection": "Data Source=:memory:"
}, },
"Smtp": { "Smtp": {
"Host": "smtp.test.local", "Host": "smtp.test.local",