Compare commits
5 commits
7f03dd7272
...
15c95ad3d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
15c95ad3d5 |
|||
|
4b35625cb4 |
|||
|
61b41f0c55 |
|||
|
d05ac52829 |
|||
|
e35786a205 |
11 changed files with 398 additions and 38 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -39,3 +39,6 @@ DataDir/
|
|||
|
||||
*.tests.trx
|
||||
*.tests.html
|
||||
|
||||
*.log
|
||||
|
||||
|
|
|
|||
159
Makefile
159
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__/<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
|
||||
|
|
|
|||
|
|
@ -79,10 +79,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
|
|||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||
var response = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<BlogPost>();
|
||||
var created = await response.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
|
||||
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>();
|
||||
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(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<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>();
|
||||
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
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<BlogsWebServerFixture>
|
|||
|
||||
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<BlogsWebServerFixture>
|
|||
|
||||
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<BlogsWebServerFixture>
|
|||
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<BlogsWebServerFixture>
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
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<BlogsWebServerFixture>
|
|||
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<BlogsWebServerFixture>
|
|||
{
|
||||
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<BlogsWebServerFixture>
|
|||
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".
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ArgumentNullException>(() =>
|
||||
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<ArgumentOutOfRangeException>(() =>
|
||||
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
||||
user, 0L, SignatureType.Pro, payload));
|
||||
user, 0L, SignatureType.Pro, payload, token: ct));
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -21,9 +21,54 @@ 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
|
||||
|
|
@ -50,4 +95,18 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string?>
|
||||
{
|
||||
[$"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.
|
||||
|
|
|
|||
|
|
@ -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<ApplicationDbContext>(options =>
|
||||
services.AddDbContext<ApplicationDbContext>((sp, 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);
|
||||
|
|
@ -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<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);
|
||||
|
||||
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<DbContext, bool> EnsureDefaultApplicationScopes()
|
||||
|
|
|
|||
30
src/Yavsc.Tests.Shared/InMemoryDatabaseName.cs
Normal file
30
src/Yavsc.Tests.Shared/InMemoryDatabaseName.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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}";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue