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 @@
+