diff --git a/.gitignore b/.gitignore index a94475e3..b7813f60 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ DataDir/ *.tests.trx *.tests.html + +*.log + diff --git a/Makefile b/Makefile index fa9d4ecf..e8d7f3cf 100644 --- a/Makefile +++ b/Makefile @@ -121,4 +121,161 @@ release: git push -u origin "$$BRANCH"; \ echo "==> Terminé. Branche $$BRANCH live sur origin." -.PHONY: test release +# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu. +# +# Usage typique : +# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe +# make qemu-install # (re)build l'APK et l'installe (AVD doit tourner) +# make qemu-build # build l'APK seul (sans install) +# make qemu-run # démarre l'AVD en background +# make qemu-stop # arrête l'émulateur +# make qemu-wait-boot # attend que l'AVD ait fini de booter +# +# Variables surchargeables (make VAR=valeur) : +# AVD_NAME default: postit_test_avd +# (l'AVD doit être listé par `avdmanager list avd`) +# ADB_SERIAL default: emulator-5554 +# (port standard du premier émulateur lancé) +# ANDROID_HOME default: /opt/android-sdk +# (le SDK Android local; doit contenir +# emulator/emulator et platform-tools/adb) +# POSTIT_RID default: android-x64 +# (doit matcher l'ABI de l'AVD; `avdmanager list avd` +# affiche la ligne Tag/ABI) +# EMU_HEADLESS default: 0 +# (1 = lancer l'émulateur sans fenêtre, pour scripter) +# CONFIG surcharge la variable CONFIG globale (Debug par +# défaut dans ce Makefile). Passer à Release pour +# un APK optimisé et signé release. +# LOGCAT_LINES default: 200 +# (nombre de lignes dumpées par `make qemu-logcat`) +# LOGCAT_FOLLOW default: 0 +# (1 = stream live via `make qemu-logcat`, +# sinon dump one-shot des N dernières lignes) +# LOGCAT_BOOT_WAIT default: 5 +# (secondes d'attente entre le clear du buffer, +# le `am start`, et le dump final dans +# `make qemu-logcat-boot`) +AVD_NAME ?= postit_test_avd +ADB_SERIAL ?= emulator-5554 +ANDROID_HOME ?= /opt/android-sdk +POSTIT_RID ?= android-x64 +EMU_HEADLESS ?= 0 +LOGCAT_LINES ?= 200 +LOGCAT_FOLLOW ?= 0 +LOGCAT_BOOT_WAIT ?= 5 + +POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj +POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) +POSTIT_APK := $(POSTIT_APK_DIR)/com.CompanyName.PostIt-Signed.apk + +qemu-run: + @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." + @mkdir -p /tmp/yavsc-emu + @EMU_ARGS=""; \ + if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \ + $(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \ + >/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \ + echo " emulator PID: $$!" + +qemu-stop: + adb -s $(ADB_SERIAL) emu kill + +qemu-wait-boot: + @echo " Waiting for $(ADB_SERIAL) to finish booting..." + adb -s $(ADB_SERIAL) wait-for-device + @for i in $$(seq 1 180); do \ + BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \ + if [ "$$BOOTED" = "1" ]; then \ + echo " ✓ booted in $${i}s"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo " ERROR: device did not boot within 180s." >&2; \ + echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \ + exit 1 + +qemu-build: + # EmbedAssembliesIntoApk=true: without this, the Debug APK ships + # without the managed assemblies in it (they are pushed at runtime + # via `adb push`, "Fast Deployment"). On the qemu emulator, the + # runtime cannot find them in `files/.__override__//` and + # aborts at startup with "No assemblies found in '.__override__'" + # (monodroid-glue.cc:757, SIGABRT). Forcing this property on + # packages the .dlls into the APK as `assemblies//` so the + # runtime reads them directly. + # + # The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`, + # not `AndroidEnableFastDeployment` (which exists in older + # templates but is a no-op in the .NET 10 SDK). + dotnet build $(POSTIT_ANDROID_CSPROJ) \ + -c $(CONFIG) \ + -p:RuntimeIdentifier=$(POSTIT_RID) \ + -p:EmbedAssembliesIntoApk=true \ + --nologo + +qemu-install: qemu-build + @if [ ! -f "$(POSTIT_APK)" ]; then \ + echo " APK not found at $(POSTIT_APK)." >&2; \ + echo " Files in $(POSTIT_APK_DIR):" >&2; \ + ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \ + exit 1; \ + fi + @echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..." + adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" + +# Dump recent logcat output for the running PostIt.Android process. +# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with +# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. +# Filtering is by PID (pidof com.CompanyName.PostIt), not by tag, +# because Mono/Xamarin can emit logs under several tags +# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering +# would miss the ones not matching. PID-based filtering is exact. +# If the app is not running, pidof returns empty and logcat exits +# silently with no output; that is the expected behaviour for +# "no logs yet". +qemu-logcat: + @PID=$$(adb -s $(ADB_SERIAL) shell pidof com.CompanyName.PostIt 2>/dev/null | tr -d '\r\n'); \ + if [ -z "$$PID" ]; then \ + echo " com.CompanyName.PostIt is not running on $(ADB_SERIAL)."; \ + echo " Start the app first (am start -n com.CompanyName.PostIt/PostIt.Android.PostItMainActivity)"; \ + exit 1; \ + fi; \ + echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ + if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ + adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID; \ + else \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID; \ + fi + +# Clear logcat, launch PostIt.Android, then dump everything that was +# emitted during the startup window. Targets the "démarrage KO" case +# where the process starts but Avalonia never renders a frame — the +# logcat trace from process start to first frame is what diagnoses it. +# +# Override LOGCAT_BOOT_WAIT to extend the post-launch wait +# (default 15s; raise to 30+ if the device is slow to boot Avalonia). +LOGCAT_BOOT_WAIT ?= 15 +qemu-logcat-boot: + @echo " Clearing logcat buffer..." + adb -s $(ADB_SERIAL) logcat -c + @echo " Launching com.CompanyName.PostIt..." + adb -s $(ADB_SERIAL) shell am start \ + -n com.CompanyName.PostIt/PostIt.Android.PostItMainActivity + @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." + @sleep $(LOGCAT_BOOT_WAIT) + @echo " Dumping logcat (PostIt PID + system buffer):" + @PID=$$(adb -s $(ADB_SERIAL) shell pidof com.CompanyName.PostIt 2>/dev/null | tr -d '\r\n'); \ + if [ -n "$$PID" ]; then \ + echo " (PID $$PID at dump time)"; \ + adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \ + else \ + echo " (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ + fi + +qemu: qemu-run qemu-wait-boot qemu-install + @echo " ✓ PostIt.Android installed on $(ADB_SERIAL)" + +.PHONY: test release qemu qemu-run qemu-stop qemu-wait-boot qemu-build qemu-install qemu-logcat qemu-logcat-boot diff --git a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs index f02a1b99..f4878860 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs @@ -79,10 +79,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture(); + var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); Assert.Equal("mapped-user", created!.AuthorId); } @@ -101,10 +101,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture(); + var created = await createdResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost @@ -115,7 +115,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture(); + var created = await createdResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); using var otherHttp = NewClient(subject: "mapped-other"); @@ -149,7 +149,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("alice"); - var response = await http.GetAsync(MembersUrl(circleId)); + var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(0, doc.RootElement.GetArrayLength()); } @@ -130,14 +130,14 @@ public sealed class CircleMembersApiTests : IClassFixture var postResponse = await http.PostAsJsonAsync( MembersUrl(circleId), - new { userId = "bob" }); + new { userId = "bob" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); - var getResponse = await http.GetAsync(MembersUrl(circleId)); + var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); - using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync()); + using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(1, doc.RootElement.GetArrayLength()); var member = doc.RootElement[0]; @@ -155,12 +155,12 @@ public sealed class CircleMembersApiTests : IClassFixture var first = await http.PostAsJsonAsync( MembersUrl(circleId), - new { userId = "bob" }); + new { userId = "bob" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, first.StatusCode); var second = await http.PostAsJsonAsync( MembersUrl(circleId), - new { userId = "bob" }); + new { userId = "bob" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } @@ -171,14 +171,14 @@ public sealed class CircleMembersApiTests : IClassFixture var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("alice"); - await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" }); + await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken); var deleteResponse = await http.DeleteAsync( - $"{MembersUrl(circleId)}/bob"); + $"{MembersUrl(circleId)}/bob", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - var getResponse = await http.GetAsync(MembersUrl(circleId)); - using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync()); + var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); + using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal(0, doc.RootElement.GetArrayLength()); } @@ -190,7 +190,7 @@ public sealed class CircleMembersApiTests : IClassFixture var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("bob"); - var response = await http.GetAsync(MembersUrl(circleId)); + var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); // 404, not 403 — the controller deliberately avoids leaking // the existence of someone else's circle. diff --git a/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs b/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs index 8a564262..7d5a02a9 100644 --- a/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs +++ b/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs @@ -100,12 +100,12 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("alice"); - var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }); + var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); - var get = await http.GetAsync($"{BlogsUrl}/{postId}"); + var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, get.StatusCode); - using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync()); + using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean()); } @@ -116,12 +116,12 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("alice"); - await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }); - var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false }); + await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); - var get = await http.GetAsync($"{BlogsUrl}/{postId}"); - using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync()); + var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken); + using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean()); } @@ -130,7 +130,7 @@ public sealed class PublishEndpointTests : IClassFixture { ResetDatabase(); using var http = NewClient("alice"); - var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true }); + var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, put.StatusCode); } @@ -141,7 +141,7 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("bob"); - var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }); + var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); // 401 Challenge (the controller returns Challenge() // for AuthorizationFailureException). The exact code // is framework-dependent; what matters is "not 204". diff --git a/src/Yavsc.Org.Tests/Controllers/TestWebApplicationFactoryIsolationTests.cs b/src/Yavsc.Org.Tests/Controllers/TestWebApplicationFactoryIsolationTests.cs new file mode 100644 index 00000000..ec86ffe8 --- /dev/null +++ b/src/Yavsc.Org.Tests/Controllers/TestWebApplicationFactoryIsolationTests.cs @@ -0,0 +1,68 @@ +using IdentityServer8.EntityFramework.DbContexts; +using IdentityServer8.EntityFramework.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Yavsc.Org.Tests.Controllers; + +/// +/// Regression sentinel: two +/// instances must not see each other's clients. +/// +/// EF Core's UseInMemoryDatabase(name) returns the same +/// backing store to every DbContext that asks for it under +/// the same name, in the same process. Before the per-fixture GUID +/// fix, both and +/// used the bare "InMemory" +/// connection string, so every fixture shared one store and tests +/// were silently order-dependent. +/// +/// We assert against directly +/// rather than via IClientStore: the validating wrapper around +/// IClientStore raises events through IEventService, +/// which is not registered in the test host and crashes with a +/// NullReferenceException before it can return a result. Going +/// straight to the DbContext is the same code path the production +/// code uses, so it is the right surface to assert against. +/// +public class TestWebApplicationFactoryIsolationTests +{ + [Fact] + public async Task Second_factory_does_not_see_clients_seeded_into_first() + { + var marker = $"marker-A-{Guid.NewGuid():N}"; + + // First factory: seed a distinctive client. + using (var first = new TestWebApplicationFactory()) + { + await using var scope = first.Services.CreateAsyncScope(); + var configDb = scope.ServiceProvider.GetRequiredService(); + var firstCs = scope.ServiceProvider.GetRequiredService() + .GetConnectionString("YavscConnection"); + Assert.StartsWith("InMemory-", firstCs); + configDb.Clients.Add(new Client { ClientId = marker, ClientName = "marker-A" }); + await configDb.SaveChangesAsync(TestContext.Current.CancellationToken); + + // Sanity: the first factory can see its own seed. + var seenByFirst = await configDb.Clients + .AsNoTracking() + .AnyAsync(c => c.ClientId == marker, TestContext.Current.CancellationToken); + Assert.True(seenByFirst); + } + + // Second factory: must start from a clean slate. If the + // in-memory store leaked from the first factory, this + // assertion fails. + using var second = new TestWebApplicationFactory(); + await using var secondScope = second.Services.CreateAsyncScope(); + var secondCs = secondScope.ServiceProvider.GetRequiredService() + .GetConnectionString("YavscConnection"); + Assert.StartsWith("InMemory-", secondCs); + var secondDb = secondScope.ServiceProvider.GetRequiredService(); + var seenBySecond = await secondDb.Clients + .AsNoTracking() + .AnyAsync(c => c.ClientId == marker, TestContext.Current.CancellationToken); + Assert.False(seenBySecond); + } +} diff --git a/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs index d4e52cc4..69009432 100644 --- a/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs +++ b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs @@ -91,9 +91,13 @@ public class EstimateSignatureFileHelperTests : IDisposable public async Task ReceiveEstimateSignatureAsync_rejects_null_payload() { var user = MakeUser("bob"); + // Capture TestContext.Current.CancellationToken outside the + // lambda so xUnit1051 sees a real CancellationToken argument + // (the lambda body runs on a different stack frame). + var ct = TestContext.Current.CancellationToken; await Assert.ThrowsAsync(() => EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync( - user, 1L, SignatureType.Pro, payload: null!)); + user, 1L, SignatureType.Pro, payload: null!, token: ct)); } [Fact] @@ -101,9 +105,10 @@ public class EstimateSignatureFileHelperTests : IDisposable { var user = MakeUser("bob"); var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } }; + var ct = TestContext.Current.CancellationToken; await Assert.ThrowsAsync(() => EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync( - user, 0L, SignatureType.Pro, payload)); + user, 0L, SignatureType.Pro, payload, token: ct)); } // --- helpers ---------------------------------------------------- diff --git a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs index dfd6edea..b85cba11 100644 --- a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs +++ b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs @@ -21,9 +21,54 @@ namespace Yavsc.Org.Tests; /// so that User.GetUserId() /// in user code sees a logged-in identity derived from the same /// header. +/// +/// Each instance gets its own in-memory database, identified by a +/// GUID generated in the constructor. The connection string +/// (ConnectionStrings:YavscConnection) is set as an +/// environment variable (ConnectionStrings__YavscConnection) +/// in the constructor and unset in , so the +/// production AddIdentityDBAndStores registers DbContext +/// instances against this fixture's own store. Without this, the +/// "InMemory" connection string from +/// appsettings-org.Testing.json would route every +/// instance — and any +/// running in the same process — to +/// the same backing store, leaking state between fixtures. +/// +/// Env vars are used (rather than ConfigureAppConfiguration or +/// UseSetting) because WebApplicationFactory applies +/// those too late: Program.Main has already captured the +/// connection string in AddIdentityDBAndStores by the time +/// the test host's overrides take effect. Env vars are the last +/// provider added in AddConfiguration (see +/// Yavsc.Server/Helpers/ConfigHelpers.cs), so they win. /// public class TestWebApplicationFactory : WebApplicationFactory { + private readonly string _fixtureId = Guid.NewGuid().ToString("N"); + + // ASP.NET Core's environment-variable configuration provider uses + // the key ConnectionStrings__YavscConnection (double underscore + // for the section separator). Set it before the host starts so + // the per-fixture connection string wins over + // appsettings-org.Testing.json. We do NOT touch the appsettings + // file; env vars take precedence in the configuration pipeline + // (see AddConfiguration in Yavsc.Server/Helpers/ConfigHelpers.cs, + // which adds AddEnvironmentVariables last). + private static readonly object _envLock = new(); + private bool _envSet; + + public TestWebApplicationFactory() + { + lock (_envLock) + { + Environment.SetEnvironmentVariable( + "ConnectionStrings__YavscConnection", + InMemoryDatabaseName.For(_fixtureId)); + _envSet = true; + } + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { // UseEnvironment("Testing") puts the host in a dedicated @@ -50,4 +95,18 @@ public class TestWebApplicationFactory : WebApplicationFactory services.AddTransient(); }); } + + protected override void Dispose(bool disposing) + { + if (disposing && _envSet) + { + lock (_envLock) + { + Environment.SetEnvironmentVariable( + "ConnectionStrings__YavscConnection", null); + _envSet = false; + } + } + base.Dispose(disposing); + } } diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index e580438b..4f736ecf 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -42,6 +42,17 @@ public sealed class WebServerFixture : WebHostFixture { private static readonly int _httpsPort = GetAvailableLoopbackPort(); + // One in-memory database name for the whole process: WebHostFixture + // is a per-process singleton (see _app, _isInitialized, _sharedServices + // in the base class), so every WebServerFixture instance shares the + // same backing store. That is intentional — the "Yavsc Server" test + // collection groups tests that should see the same seeded state, and + // re-initialising the store per fixture would just regress the + // order-dependence we are trying to eliminate. The GUID still matters + // because TestWebApplicationFactory and WebServerFixture must not + // collide in the in-memory store; see InMemoryDatabaseName. + private static readonly string _fixtureId = Guid.NewGuid().ToString("N"); + protected override int HttpsPort => _httpsPort; private static IConfiguration? _sharedConfiguration; @@ -80,7 +91,7 @@ public sealed class WebServerFixture : WebHostFixture // that plus the in-memory overrides below. builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary { - [$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = "InMemory", + [$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = InMemoryDatabaseName.For(_fixtureId), // SMTP test config: UserName non-null so MailSender // exercises the Authenticate branch — the // RecordingSmtpClient captures it. diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index f92dc3c9..6528b9c6 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -169,10 +169,20 @@ public static class HostingExtensions public static IdentityBuilder AddIdentityDBAndStores(this WebApplicationBuilder builder) { IServiceCollection services = builder.Services; - var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName); - services.AddDbContext(options => + services.AddDbContext((sp, options) => { + // Read the connection string at DbContext construction time + // rather than at AddDbContext registration time, so test + // fixtures (e.g. WebApplicationFactory) can + // override the value via the host's IConfiguration before + // any DbContext is built. Reading it eagerly at the top of + // this method would freeze whatever was in configuration + // when Program.Main ran — too early for the test host's + // ConfigureAppConfiguration / UseSetting hooks to apply. + var connectionString = sp.GetRequiredService() + .GetConnectionString(Constants.YavscConnectionStringName); + if (UsesInMemoryProvider(connectionString)) { options.UseInMemoryDatabase(connectionString); @@ -317,9 +327,20 @@ public static class HostingExtensions options.ClaimsIdentity.RoleClaimType = Constants.RoleClaimType; }); var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name; - var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName); - string sqliteConnectionString = $"Data Source={Path.Combine(Path.GetTempPath(), "yavsc_test.db")}"; + // The IdentityServer8.EntityFramework ConfigurationStoreOptions + // and OperationalStoreOptions expose ConfigureDbContext as an + // Action with no service-provider + // access, so the connection string has to be captured here at + // registration time. For the production runtime this is fine: + // the connection string does not change after startup. For + // tests, this is the one knob we cannot push into the per-fixture + // config pipeline; the TestWebApplicationFactory bridge instead + // sets ConnectionStrings__YavscConnection as an environment + // variable, which AddEnvironmentVariables picks up as the last + // configuration provider in AddConfiguration. See + // Yavsc.Server/Helpers/ConfigHelpers.cs. + var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName); var identityServerBuilder = builder.Services.AddIdentityServer(options => { @@ -600,7 +621,13 @@ public static class HostingExtensions private static bool UsesInMemoryProvider(string connectionString) { - return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase); + // Test fixtures may suffix the connection string with a + // per-fixture GUID (see InMemoryDatabaseName in + // Yavsc.Tests.Shared) to keep their in-memory stores + // isolated. The base name "InMemory" is still what + // identifies an in-memory provider — anything starting + // with it is one. + return connectionString.StartsWith(InMemoryProviderName, StringComparison.OrdinalIgnoreCase); } private static Action EnsureDefaultApplicationScopes() diff --git a/src/Yavsc.Tests.Shared/InMemoryDatabaseName.cs b/src/Yavsc.Tests.Shared/InMemoryDatabaseName.cs new file mode 100644 index 00000000..1c78c6dd --- /dev/null +++ b/src/Yavsc.Tests.Shared/InMemoryDatabaseName.cs @@ -0,0 +1,30 @@ +namespace Yavsc.Tests.Shared; + +/// +/// Helpers for the in-memory connection string used by test fixtures. +/// +/// EF Core's UseInMemoryDatabase(name) returns the same backing +/// store to every DbContext that asks for it under the same +/// , in the same process. That means every +/// fixture that uses the bare "InMemory" connection string +/// shares the same in-memory database — which leaks state between +/// fixtures that are supposed to be independent, and silently makes +/// tests order-dependent. +/// +/// The fix is to give each fixture its own suffix. +/// returns a stable, fixture-scoped connection string. The fixture +/// stores the suffix in an instance field so successive calls within +/// the same fixture always resolve to the same database. +/// +public static class InMemoryDatabaseName +{ + /// Base connection string for the in-memory provider, + /// as it appears in appsettings-org.Testing.json. + public const string Base = "InMemory"; + + /// Builds a per-fixture connection string. Two calls + /// with the same return the same + /// string; two calls with different ids return different + /// strings, isolating the underlying in-memory stores. + public static string For(string fixtureId) => $"{Base}-{fixtureId}"; +}