From fa7794b7a01c41588e54fc4395777b118940deab Mon Sep 17 00:00:00 2001 From: Lum Date: Sat, 11 Jul 2026 21:56:25 +0100 Subject: [PATCH] repoduces the bug --- Directory.Packages.props | 1 + src/Yavsc.Blogs.Tests/BlogApiTests.cs | 9 +-- .../BlogsWebServerFixture.cs | 32 ++++++--- .../Yavsc.Blogs.Tests.csproj | 1 + .../TestWebApplicationFactory.cs | 69 +++++++++++++++++++ src/Yavsc.Org.Tests/WebServerFixture.cs | 15 +++- src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj | 1 + src/Yavsc.Org/Extensions/HostingExtensions.cs | 56 +++++++++++++-- src/Yavsc.Org/Yavsc.Org.csproj | 1 + src/Yavsc.Org/appsettings-org.Testing.json | 2 +- 10 files changed, 169 insertions(+), 18 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e4b09159..84380e44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index fd64555a..4ac14c50 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -30,10 +30,11 @@ public sealed class BlogApiTests : IClassFixture } /// Reset the in-memory database to a known empty state. - /// UseInMemoryDatabase shares its store across the - /// lifetime of the instance, - /// so without a per-test reset the test order would leak - /// state between tests. + /// The fixture now uses SQLite in-memory (see forgejo#3), which + /// shares its store across the lifetime of the + /// instance, so without a + /// per-test reset the test order would leak state between + /// tests. private void ResetDatabase() { using var scope = _fixture.Services.CreateScope(); diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index 6e39fd00..a76a37a7 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -48,6 +48,19 @@ namespace Yavsc.Blogs.Tests; /// 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; 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 // exactly what the first test wants to assert. // - // Share a single InMemoryDatabaseRoot across the test - // lifetime so POST + GET on the same fixture see the same - // store. Without the root, EF Core's In-Memory provider - // creates independent stores per DbContext in some - // configurations, and the second request would see an - // empty list even after the first wrote a row. - _inMemoryRoot = new InMemoryDatabaseRoot(); + // We use SQLite in-memory (not the EF Core InMemory provider) + // because the InMemory provider cannot materialise navigation + // properties from IdentityServer8 entity types (see forgejo#3). + // SQLite in-memory is a transient, file-less store that + // executes real SQL, so navigation properties work as + // expected. The shared SqliteConnection keeps the database + // 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(opt => - opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)); + opt.UseSqlite(_sharedSqliteConnection)); // Trivial file-system auth: the GET index path never calls // into it, but the DI container needs an instance. diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index ec1f7f0a..831097b0 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs index dfd6edea..af40bb78 100644 --- a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs +++ b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs @@ -2,7 +2,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Yavsc.Models; using Yavsc.Tests.Shared; namespace Yavsc.Org.Tests; @@ -24,6 +28,21 @@ namespace Yavsc.Org.Tests; /// public class TestWebApplicationFactory : WebApplicationFactory { + // 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) { // UseEnvironment("Testing") puts the host in a dedicated @@ -38,6 +57,25 @@ public class TestWebApplicationFactory : WebApplicationFactory 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>(); + services.AddDbContext(opt => + opt.UseSqlite(_sharedSqliteConnection)); + // Replace the production IAuthorizationPolicyProvider with // the test one. The default registered by AddAuthorization // becomes irrelevant: any GetPolicyAsync call is routed here. @@ -48,6 +86,37 @@ public class TestWebApplicationFactory : WebApplicationFactory // TestUserMiddleware runs after UseAuthentication/Authorization. services.AddTransient(); services.AddTransient(); + + // 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(); }); } + + 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(); + 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); + } } diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index ed0bd69d..ebb50fc7 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -75,7 +75,20 @@ public sealed class WebServerFixture : WebHostFixture // that plus the in-memory overrides below. builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary { - [$"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 // exercises the Authenticate branch — the // RecordingSmtpClient captures it. diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index 79a6bae1..5d049626 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -53,6 +53,7 @@ +