Compare commits

..

No commits in common. "15c95ad3d597e6cbd8289e953fe4f886f6ae9806" and "7f03dd727267b63d7cd398b72dd01becf64f85fa" have entirely different histories.

11 changed files with 38 additions and 398 deletions

3
.gitignore vendored
View file

@ -39,6 +39,3 @@ DataDir/
*.tests.trx
*.tests.html
*.log

159
Makefile
View file

@ -121,161 +121,4 @@ release:
git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin."
# 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__/<rid>/` 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/<rid>/` 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
.PHONY: test release

View file

@ -79,10 +79,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken);
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
var created = await response.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(created);
Assert.Equal("mapped-user", created!.AuthorId);
}
@ -101,10 +101,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
});
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(created);
var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
@ -115,7 +115,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
Article = "Contenu mis à jour.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
});
Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
}
@ -134,10 +134,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
});
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(created);
using var otherHttp = NewClient(subject: "mapped-other");
@ -149,7 +149,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
Article = "Contenu non autorisé.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
}, TestContext.Current.CancellationToken);
});
Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode);
}

View file

@ -113,10 +113,10 @@ public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
var response = await http.GetAsync(MembersUrl(circleId));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
@ -130,14 +130,14 @@ public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
var postResponse = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" }, TestContext.Current.CancellationToken);
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
var getResponse = await http.GetAsync(MembersUrl(circleId));
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
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<BlogsWebServerFixture>
var first = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" }, TestContext.Current.CancellationToken);
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
var second = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" }, TestContext.Current.CancellationToken);
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
@ -171,14 +171,14 @@ public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken);
await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" });
var deleteResponse = await http.DeleteAsync(
$"{MembersUrl(circleId)}/bob", TestContext.Current.CancellationToken);
$"{MembersUrl(circleId)}/bob");
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var getResponse = await http.GetAsync(MembersUrl(circleId));
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
@ -190,7 +190,7 @@ public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("bob");
var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken);
var response = await http.GetAsync(MembersUrl(circleId));
// 404, not 403 — the controller deliberately avoids leaking
// the existence of someone else's circle.

View file

@ -100,12 +100,12 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice");
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken);
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
@ -116,12 +116,12 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice");
using var http = NewClient("alice");
await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken);
await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
@ -130,7 +130,7 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{
ResetDatabase();
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true });
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
}
@ -141,7 +141,7 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice");
using var http = NewClient("bob");
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
// 401 Challenge (the controller returns Challenge()
// for AuthorizationFailureException). The exact code
// is framework-dependent; what matters is "not 204".

View file

@ -1,68 +0,0 @@
using IdentityServer8.EntityFramework.DbContexts;
using IdentityServer8.EntityFramework.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Yavsc.Org.Tests.Controllers;
/// <summary>
/// Regression sentinel: two <see cref="TestWebApplicationFactory"/>
/// instances must not see each other's clients.
///
/// EF Core's <c>UseInMemoryDatabase(name)</c> returns the same
/// backing store to every <c>DbContext</c> that asks for it under
/// the same name, in the same process. Before the per-fixture GUID
/// fix, both <see cref="TestWebApplicationFactory"/> and
/// <see cref="WebServerFixture"/> used the bare <c>"InMemory"</c>
/// connection string, so every fixture shared one store and tests
/// were silently order-dependent.
///
/// We assert against <see cref="ConfigurationDbContext"/> directly
/// rather than via <c>IClientStore</c>: the validating wrapper around
/// <c>IClientStore</c> raises events through <c>IEventService</c>,
/// which is not registered in the test host and crashes with a
/// <c>NullReferenceException</c> 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.
/// </summary>
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<ConfigurationDbContext>();
var firstCs = scope.ServiceProvider.GetRequiredService<IConfiguration>()
.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<IConfiguration>()
.GetConnectionString("YavscConnection");
Assert.StartsWith("InMemory-", secondCs);
var secondDb = secondScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
var seenBySecond = await secondDb.Clients
.AsNoTracking()
.AnyAsync(c => c.ClientId == marker, TestContext.Current.CancellationToken);
Assert.False(seenBySecond);
}
}

View file

@ -91,13 +91,9 @@ 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<ArgumentNullException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 1L, SignatureType.Pro, payload: null!, token: ct));
user, 1L, SignatureType.Pro, payload: null!));
}
[Fact]
@ -105,10 +101,9 @@ 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<ArgumentOutOfRangeException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 0L, SignatureType.Pro, payload, token: ct));
user, 0L, SignatureType.Pro, payload));
}
// --- helpers ----------------------------------------------------

View file

@ -21,54 +21,9 @@ namespace Yavsc.Org.Tests;
/// <see cref="TestUserMiddleware"/> so that <c>User.GetUserId()</c>
/// 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
/// (<c>ConnectionStrings:YavscConnection</c>) is set as an
/// environment variable (<c>ConnectionStrings__YavscConnection</c>)
/// in the constructor and unset in <see cref="Dispose"/>, so the
/// production <c>AddIdentityDBAndStores</c> registers <c>DbContext</c>
/// instances against this fixture's own store. Without this, the
/// <c>"InMemory"</c> connection string from
/// <c>appsettings-org.Testing.json</c> would route every
/// <see cref="TestWebApplicationFactory"/> instance — and any
/// <see cref="WebServerFixture"/> running in the same process — to
/// the same backing store, leaking state between fixtures.
///
/// Env vars are used (rather than <c>ConfigureAppConfiguration</c> or
/// <c>UseSetting</c>) because <c>WebApplicationFactory</c> applies
/// those too late: <c>Program.Main</c> has already captured the
/// connection string in <c>AddIdentityDBAndStores</c> by the time
/// the test host's overrides take effect. Env vars are the last
/// provider added in <c>AddConfiguration</c> (see
/// <c>Yavsc.Server/Helpers/ConfigHelpers.cs</c>), so they win.
/// </summary>
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
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
@ -95,18 +50,4 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
services.AddTransient<IStartupFilter, TestUserStartupFilter>();
});
}
protected override void Dispose(bool disposing)
{
if (disposing && _envSet)
{
lock (_envLock)
{
Environment.SetEnvironmentVariable(
"ConnectionStrings__YavscConnection", null);
_envSet = false;
}
}
base.Dispose(disposing);
}
}

View file

@ -42,17 +42,6 @@ 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;
@ -91,7 +80,7 @@ public sealed class WebServerFixture : WebHostFixture
// that plus the in-memory overrides below.
builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = InMemoryDatabaseName.For(_fixtureId),
[$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = "InMemory",
// SMTP test config: UserName non-null so MailSender
// exercises the Authenticate branch — the
// RecordingSmtpClient captures it.

View file

@ -169,20 +169,10 @@ public static class HostingExtensions
public static IdentityBuilder AddIdentityDBAndStores(this WebApplicationBuilder builder)
{
IServiceCollection services = builder.Services;
var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName);
services.AddDbContext<ApplicationDbContext>((sp, options) =>
services.AddDbContext<ApplicationDbContext>(options =>
{
// Read the connection string at DbContext construction time
// rather than at AddDbContext registration time, so test
// fixtures (e.g. WebApplicationFactory<Program>) 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<IConfiguration>()
.GetConnectionString(Constants.YavscConnectionStringName);
if (UsesInMemoryProvider(connectionString))
{
options.UseInMemoryDatabase(connectionString);
@ -327,21 +317,10 @@ public static class HostingExtensions
options.ClaimsIdentity.RoleClaimType = Constants.RoleClaimType;
});
var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name;
// The IdentityServer8.EntityFramework ConfigurationStoreOptions
// and OperationalStoreOptions expose ConfigureDbContext as an
// Action<DbContextOptionsBuilder> 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);
string sqliteConnectionString = $"Data Source={Path.Combine(Path.GetTempPath(), "yavsc_test.db")}";
var identityServerBuilder = builder.Services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
@ -621,13 +600,7 @@ public static class HostingExtensions
private static bool UsesInMemoryProvider(string connectionString)
{
// 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);
return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase);
}
private static Action<DbContext, bool> EnsureDefaultApplicationScopes()

View file

@ -1,30 +0,0 @@
namespace Yavsc.Tests.Shared;
/// <summary>
/// Helpers for the in-memory connection string used by test fixtures.
///
/// EF Core's <c>UseInMemoryDatabase(name)</c> returns the same backing
/// store to every <c>DbContext</c> that asks for it under the same
/// <paramref name="name"/>, in the same process. That means every
/// fixture that uses the bare <c>"InMemory"</c> 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. <see cref="For"/>
/// 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.
/// </summary>
public static class InMemoryDatabaseName
{
/// <summary>Base connection string for the in-memory provider,
/// as it appears in <c>appsettings-org.Testing.json</c>.</summary>
public const string Base = "InMemory";
/// <summary>Builds a per-fixture connection string. Two calls
/// with the same <paramref name="fixtureId"/> return the same
/// string; two calls with different ids return different
/// strings, isolating the underlying in-memory stores.</summary>
public static string For(string fixtureId) => $"{Base}-{fixtureId}";
}