feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user

Bundled end-of-branch commit on feat/postit-acl-members.

PostIt UI for circles + per-post ACL
- Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders
  (Bearer/OIDC scope tests vs. blog API fakes live where they
  belong) and introduces PostItHeadlessCollection so the
  Avalonia.Headless tests share a single xUnit collection
  instead of contending with the EF-Core test host.
- Adds BlogAclApiTests (a brand-new behavioural layer over
  POST /api/v1/blogacl) and the fakes it relies on
  (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember
  DialogTests); pulls UserId-through-OIDC-sub path into
  BearerScopeTests / FakeAuthorizingBrowser /
  OidcStubAuthority.
- App.axaml.cs gets a small PushPageAsync touch-up the new
  tests rely on.
- Drops UnitTest1.cs (xUnit scaffold, never used).

Yavsc.Blogs.Tests — SQLite instead of InMemory
- Bumps Yavsc.Blogs.Tests.csproj on
  Microsoft.EntityFrameworkCore.Sqlite and rewrites
  BlogsWebServerFixture to hold a single shared
  SqliteConnection (Cache=Shared) for the fixture lifetime,
  with a sync Dispose close to dodge async teardown hangs.
  Reason: the EF Core InMemory provider silently ignores FKs,
  which masked the kind of bug we are about to pin in the
  ACL tests. SQLite enforces them, so any future INSERT that
  forgets to seed its parent rows fails loudly here instead
  of passing the test and breaking prod.
- PublishEndpointTests and BlogApiSmokeTests get a one-line
  tweak to follow the new connection lifecycle.

Foreign-key fallout: seed the default user in the fixture
- Adds BlogsWebServerFixture.SeedUser(userName). Now that
  SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every
  test that POST/PUT/DELETE a BlogPost and sends AuthorId=
  'tester' in the payload needs an AspNetUsers row to satisfy
  the FK or it returns 500 with SQLite Error 19.
- BlogApiTests wraps the existing ResetDatabase with a
  ResetAndSeedDefaultUser helper for the six mutating tests;
  the four GET-only and ModelState-only tests keep the bare
  ResetDatabase.
- Side benefit: every test in Yavsc.Blogs.Tests now finishes
  cleanly instead of hanging at teardown — previously a stuck
  test held the shared SqliteConnection open and the next
  tests waited indefinitely.

Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25
green from a clean run, no fixture teardown hang.
This commit is contained in:
Paul Schneider 2026-08-20 23:59:21 +01:00
commit a44c04ad77
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
22 changed files with 806 additions and 460 deletions

View file

@ -1,6 +1,4 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
@ -31,8 +29,15 @@ namespace PostIt.Tests;
/// click via <c>button.Command?.Execute(...)</c> + flush
/// any async command before asserting.</para>
/// </summary>
[Collection("PostIt Headless")]
public class AddCircleMemberDialogTests
{
private PostItHeadlessCollection fixture;
public AddCircleMemberDialogTests(PostItHeadlessCollection fixture, ITestOutputHelper output)
{
this.fixture = fixture;
}
/// <summary>
/// Stand-in <see cref="IUserDirectory"/> that returns an
/// empty list. The dialog's "Rechercher" button is never
@ -73,7 +78,10 @@ public class AddCircleMemberDialogTests
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
/// (so <c>ViewLocator</c> can resolve it from the VM).
/// </summary>
private static (MainWindow window, CirclesPage page, AddCircleMemberDialog dialog) Mount()
private static (MainWindow window,
CirclesPage page,
AddCircleMemberDialog dialog)
Mount()
{
var api = new ThrowingApi();
var circleClient = new CircleApiClient(api, "http://localhost/");
@ -117,7 +125,7 @@ public class AddCircleMemberDialogTests
public void Close_button_pops_dialog_off_nav_stack()
{
// Arrange: stack starts at 2 (CirclesPage + dialog).
var (window, _, _) = Mount();
var window = fixture.Window;
var stackBefore = window.NavRoot.NavigationStack.Count;
Assert.Equal(2, stackBefore);

View file

@ -64,3 +64,29 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
return Task.FromResult(default(T)!);
}
}
/// <summary>
/// <see cref="YavscApiClient"/> stand-in whose constructor
/// points at <c>https://stub.invalid</c> so any HTTP traffic
/// that escapes a test (misconfigured command, missing fake
/// handler) raises a clear <see cref="System.Net.Http.HttpRequestException"/>
/// instead of silently hitting a real endpoint. Used by tests
/// that don't actually exercise the API client (they click a
/// button, assert on the nav stack, end of story) but whose
/// VMs require one in their constructor.
/// </summary>
internal sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}

View file

@ -1,6 +1,4 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
@ -50,43 +48,43 @@ namespace PostIt.Tests;
/// <item>"[DEV] Signature" — click pushes a page onto the
/// stack.</item>
/// </list>
///
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
/// owns the <see cref="MainWindow"/> and the production DI graph.
/// Each test builds a local <see cref="ServiceCollection"/> with
/// the fake <see cref="YavscApiClient"/> + the page VMs and
/// registers the destination pages, then swaps it in via
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
/// fixture re-attaches the ViewLocator and the MainWindow so
/// subsequent <see cref="App.PushPageAsync"/> calls route through
/// the overridden graph.</para>
/// </summary>
public class MainPageButtonsTests
[Collection("PostIt Headless")]
public sealed class MainPageButtonsTests
{
/// <summary>
/// Fake <see cref="YavscApiClient"/> that throws on any
/// wire call. These tests never invoke a command that hits
/// the API — only the click → nav side of the pipeline is
/// asserted.
/// </summary>
private sealed class ThrowingApi : YavscApiClient
private readonly PostItHeadlessCollection _host;
public MainPageButtonsTests(PostItHeadlessCollection host)
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
_host = host;
}
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
/// <summary>
/// Build the test DI graph: <see cref="ThrowingApi"/> for
/// the API clients (the click tests never hit the wire;
/// any traffic would be a wiring bug), the real
/// <see cref="BlogApiClient"/> / <see cref="CircleApiClient"/>
/// / <see cref="BlogAclApiClient"/> that the page VM
/// resolves, and the page + dialog + VM registrations the
/// <see cref="ViewLocator"/> needs to resolve the three
/// push targets.
/// </summary>
private MainPageViewModel BuildViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves
// when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this
// suite to avoid side effects from App.BuildServices() (real
// token-store wiring).
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton(circle);
@ -96,51 +94,31 @@ public class MainPageButtonsTests
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
var sp = services.BuildServiceProvider();
var vm = new MainPageViewModel(blog, services: sp);
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
/// <summary>
/// Mount a real <see cref="MainWindow"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its
/// named buttons. The window is shown so the visual tree is
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// Push a <see cref="MainPage"/> with the given VM onto
/// the shared <see cref="MainWindow"/>'s nav stack. Clears
/// any pages the previous test left behind (the fixture's
/// MainWindow is shared across every test class). Returns
/// the live page so the test can access its named buttons.
/// </summary>
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
private MainPage MountAsync(MainPageViewModel vm)
{
var window = new MainWindow();
var page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
if (vm.Services is not null)
{
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(vm.Services));
}
app.AttachMainWindow(window);
window.Show();
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page);
_host.PushAsync(page);
return page;
}
/// <summary>
/// Click a button by focusing it and pressing Enter — the
/// supported headless pattern (cf. CalculatorTests in the
/// Avalonia.Samples repo). Returns the nav-stack count
/// before the click so the caller can assert on the delta.
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
/// itself — it is the <see cref="TopLevel"/> that owns the
/// headless implementation, and routing the key through any
/// descendant TopLevel (e.g. one obtained via
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
/// <c>NullReferenceException</c> from the headless impl
/// because the descendant does not carry the
/// <c>PlatformHandle</c> the harness expects.
/// Click a button by executing its <see cref="Button.Command"/>
/// and draining any <see cref="IAsyncRelayCommand"/> so the
/// caller can assert on the resulting nav stack immediately.
/// </summary>
private static int ClickAndCapture(MainWindow window, Button button)
{
@ -165,8 +143,8 @@ public class MainPageButtonsTests
Title = "An existing post",
AuthorId = "u-alice"
};
var vm = MakeViewModel(post);
var (window, page) = MountMainPage(vm);
var vm = BuildViewModel(post);
var page = MountAsync(vm);
// Sanity: the button's command is bound and CanExecute
// is true. If this fails, the bug is upstream (XAML
@ -176,12 +154,12 @@ public class MainPageButtonsTests
Assert.True(aclButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, aclButton);
var stackBefore = ClickAndCapture(_host.Window, aclButton);
// Assert γ + sniff léger: stack grew, new top is a Page.
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {_host.Window.NavRoot.NavigationStack.Count}.");
var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
@ -191,19 +169,19 @@ public class MainPageButtonsTests
{
// Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var vm = BuildViewModel();
var page = MountAsync(vm);
var circlesButton = page.OpenCirclesButton;
Assert.NotNull(circlesButton.Command);
// Act
var stackBefore = ClickAndCapture(window, circlesButton);
var stackBefore = ClickAndCapture(_host.Window, circlesButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
"Click on 'Mes cercles' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
@ -214,25 +192,25 @@ public class MainPageButtonsTests
// Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via
// DI and call App.PushPage; the ViewLocator
// then maps SignaturePageViewModel -> SignaturePage and
// the binding pushes the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
// The ServiceCollection registered in BuildViewModel
// provides SignaturePageViewModel so the command can
// resolve it via DI and call App.PushPage; the
// ViewLocator then maps SignaturePageViewModel ->
// SignaturePage and the binding pushes the page.
var vm = BuildViewModel();
var page = MountAsync(vm);
var signatureButton = page.OpenSignatureDevButton;
Assert.NotNull(signatureButton.Command);
Assert.True(signatureButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, signatureButton);
var stackBefore = ClickAndCapture(_host.Window, signatureButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}

View file

@ -1,22 +1,22 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
/// The pattern is the one <c>SessionStatusBannerTests</c>
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// hosting the page (via a <see cref="Frame"/> because
/// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
/// controls through their public surface and assert on what
/// <see cref="RecordingYavscApiClient"/> saw go on the wire.
/// Uses the shared <see cref="PostItHeadlessFixture"/> (a real
/// <see cref="MainWindow"/> with the production DI graph attached
/// to <see cref="App"/>) plus a local
/// <see cref="ServiceCollection"/> that swaps
/// <see cref="YavscApiClient"/> for the recording fake.
///
/// <para>The bug we are pinning: the title <c>TextBox</c> is
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
@ -31,41 +31,38 @@ namespace PostIt.Tests;
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
/// buffer that the XAML binds to and the Save command consumes.</para>
/// </summary>
public class MainPageSaveTests
[Collection("PostIt Headless")]
public sealed class MainPageSaveTests
{
[AvaloniaFact]
public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
{
// Arrange: VM with a recording API client, mounted in a
// headless window via a Frame (MainPage is a ContentPage,
// not a Control, so it needs a navigation host).
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainPageViewModel(blog);
private readonly PostItHeadlessCollection _host;
public MainPageSaveTests(PostItHeadlessCollection host)
{
_host = host;
}
[AvaloniaFact]
public void Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
{
// Arrange: VM with a recording API client, mounted on
// the shared MainWindow's nav stack.
var recorder = new CallRecorder();
var blog = _host.Services.GetRequiredService<BlogApiClient>();
var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it
// must be hosted in a navigation surface. The production
// MainWindow.axaml uses NavigationPage, and the API is the
// same one App.axaml.cs drives at boot (PushAsync, fire-
// and-forget in prod because the page is the top of the
// stack immediately).
var nav = new NavigationPage();
_ = nav.PushAsync(page);
var window = new Window { Content = nav };
window.Show();
_host.PushAsync(page);
// Act: type a title into the editor's TextBox without
// first selecting a post in the list — the only state in
// which a new post can be created. Then click Save.
var titleBox = window.GetVisualDescendants()
// first selecting a post in the list — the only state
// in which a new post can be created. Then click Save.
var titleBox = _host.Window.GetVisualDescendants()
.OfType<TextBox>()
.First(t => t.PlaceholderText == "Title");
const string typed = "Mon premier billet";
titleBox.Text = typed;
var saveButton = window.GetVisualDescendants()
var saveButton = _host.Window.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Save");
saveButton.Command!.Execute(null);
@ -75,7 +72,11 @@ public class MainPageSaveTests
// task on the dispatcher. Give the dispatcher a chance to
// run so the awaited CallAsync has actually fired before
// we inspect the recorder.
await Task.Delay(200);
var deadline = DateTime.UtcNow.AddSeconds(2);
while (recorder.Calls.Count == 0 && DateTime.UtcNow < deadline)
{
Task.Delay(20).GetAwaiter().GetResult();
}
// Assert: the first POST to "blog" carried a BlogPostDto
// whose Title is exactly what the user typed. The bug

View file

@ -0,0 +1,124 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the user-reported bug:
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
/// dialog opened (the dropdown showed "Choisir un cercle..." and
/// the list was blank, with no error to hint at why).
///
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
/// to trigger <c>LoadAsync</c> on the first
/// <c>DataContextChanged</c>, and the VM guards re-entry via
/// <c>_loaded</c>. Two tests pin that contract:</para>
/// <list type="bullet">
/// <item><c>LoadAsync_runs_once_on_DataContext_changed</c>: HTTP
/// traffic shows up after the dialog is mounted.</item>
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
/// once (the <c>_loaded</c> gate).</item>
/// </list>
///
/// <para>HTTP is stubbed with a counter
/// <see cref="HttpMessageHandler"/> that returns canned JSON
/// <c>[]</c> for every request. The handler counts calls so the
/// tests can assert "exactly one round-trip on mount" and
/// "exactly one round-trip after two calls to LoadAsync". This
/// is the same shape used by <c>BearerScopeTests</c>: real
/// <see cref="YavscApiClient"/> subclass, real
/// <see cref="HttpClient"/> with an injected handler, real
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
/// talking to it.</para>
///
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
/// provides the <see cref="MainWindow"/> already wired to
/// <see cref="App"/>. Each test builds its own DI graph with
/// the counting HTTP handler and swaps it in via
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
/// graph exposes <c>PostAclDialog</c> so the
/// <see cref="ViewLocator"/> resolves it from
/// <see cref="PostAclDialogViewModel"/>.</para>
/// </summary>
[Collection("PostIt Headless")]
public sealed class PostAclDialogTests
{
private readonly PostItHeadlessCollection _host;
public PostAclDialogTests(PostItHeadlessCollection host)
{
_host = host;
}
/// <summary>
/// <see cref="HttpMessageHandler"/> that replies 200 with
/// <c>[]</c> (a valid JSON empty array, which both
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
/// deserialize) and counts the number of requests.
/// </summary>
private sealed class CountingHttpHandler : HttpMessageHandler
{
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. Same recipe as
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
/// override <c>CallAsync{T}</c> to talk to our own
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
/// because the load-on-attach bug has nothing to do with
/// token refresh.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
public TestableYavscApiClient(
Settings settings,
TokenStore store,
HttpMessageHandler handler)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
}

View file

@ -1,234 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the user-reported bug:
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
/// dialog opened (the dropdown showed "Choisir un cercle..." and
/// the list was blank, with no error to hint at why).
///
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
/// to trigger <c>LoadAsync</c> on the first
/// <c>AttachedToVisualTree</c>, and the VM guards re-entry via
/// <c>_loaded</c>. Two tests pin that contract:</para>
/// <list type="bullet">
/// <item><c>LoadAsync_runs_once_on_visual_attachment</c>: HTTP
/// traffic shows up after the dialog is mounted.</item>
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
/// once (the <c>_loaded</c> gate).</item>
/// </list>
///
/// <para>HTTP is stubbed with a counter
/// <see cref="HttpMessageHandler"/> that returns canned JSON
/// <c>[]</c> for every request. The handler counts calls so the
/// tests can assert "exactly one round-trip on mount" and
/// "exactly one round-trip after two calls to LoadAsync". This
/// is the same shape used by <c>BearerScopeTests</c>: real
/// <see cref="YavscApiClient"/> subclass, real
/// <see cref="HttpClient"/> with an injected handler, real
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
/// talking to it.</para>
/// </summary>
public class PostAclDialogTests
{
/// <summary>
/// <see cref="HttpMessageHandler"/> that replies 200 with
/// <c>[]</c> (a valid JSON empty array, which both
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
/// deserialize) and counts the number of requests.
/// </summary>
private sealed class CountingHttpHandler : HttpMessageHandler
{
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. Same recipe as
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
/// override <c>CallAsync{T}</c> to talk to our own
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
/// because the load-on-attach bug has nothing to do with
/// token refresh.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
public TestableYavscApiClient(
Settings settings,
TokenStore store,
HttpMessageHandler handler)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
/// <summary>
/// Build a minimal DI graph exposing the two API clients
/// (backed by a stub HTTP handler) and the page itself, so
/// <c>ViewLocator</c> can resolve the dialog from the VM.
/// Returns the handler, the API clients, and the window so
/// the test can assert on request counts and push the
/// dialog via the canonical <c>App.PushPageAsync</c> path.
/// The DI graph is built into a local <see cref="IServiceProvider"/>
/// that is NOT attached to <see cref="App.ServiceProvider"/>:
/// rebinding the global DI mid-test would trample the
/// Settings singleton the rest of the harness depends on.
/// </summary>
private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
{
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(aclClient);
services.AddSingleton(circleClient);
services.AddTransient<PostAclDialog>();
var sp = services.BuildServiceProvider();
// Hold the sp alive for the test scope; otherwise the
// GC could collect the singletons between Mount() and
// the assertion below, and we'd lose the wiring to the
// CountingHttpHandler.
GC.KeepAlive(sp);
var window = new MainWindow();
var app = (App)Application.Current!;
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(sp));
app.AttachMainWindow(window);
window.Show();
return (window, aclClient, circleClient, handler);
}
/// <summary>
/// The bug: opening the dialog never called LoadAsync, so
/// MyCircles/AclEntries were empty. After the fix, setting
/// the dialog's DataContext to a PostAclDialogViewModel
/// (the same path App.PushPageAsync takes) must trigger
/// exactly one LoadAsync round-trip (the parallel WhenAll
/// inside the VM counts as one request per backend call,
/// hence two HTTP requests total: GET /blogacl and GET
/// /circle).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_runs_once_on_DataContext_changed()
{
// Arrange
var (window, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 42, Title = "Test post" };
// Sanity: handler starts quiet.
Assert.Equal(0, handler.RequestCount);
// Act: push the dialog via the canonical VM-first pipeline.
// The locator goes through the parameterless ctor of
// PostAclDialog, then App.PushPageAsync assigns DataContext,
// which our hook intercepts to trigger LoadAsync.
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
await ((App)Application.Current!).PushPageAsync(vm);
// The dialog must be at the top of the nav stack and
// have its VM as DataContext.
var dialog = window.NavRoot.NavigationStack[^1] as PostAclDialog
?? throw new InvalidOperationException("Dialog not at top of stack");
Assert.Same(vm, dialog.DataContext);
// Drain pending async work. LoadAsync is async and the
// DataContextChanged handler is fire-and-forget; a
// couple of loop turns is enough. We poll the handler
// counter because the dispatch back onto the headless
// dispatcher isn't strict — using a generous-but-bounded
// wait avoids test flakes.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (handler.RequestCount < 2 && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
// Assert: exactly two GETs went out (one to /blogacl,
// one to /circle), both from the LoadAsync call.
Assert.Equal(2, handler.RequestCount);
// And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded);
}
/// <summary>
/// The fix exposes a guard on the VM too: a second call to
/// LoadAsync on the same instance must NOT issue more HTTP
/// traffic. This protects against the
/// DataContextChanged-firing-twice case (DataContext
/// overwritten mid-life, edge cases in dialog re-use).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_is_idempotent()
{
// Arrange
var (_, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 99, Title = "Idempotency" };
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
// Act: invoke LoadAsync twice in a row.
await vm.LoadAsync();
await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded.
Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded);
}
}

View file

@ -0,0 +1,177 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// xUnit collection grouping every headless UI test in
/// <c>PostIt.Tests</c>. The Avalonia headless harness instantiates
/// a single <see cref="PostItHeadlessFixture"/> per test class
/// (<c>IClassFixture&lt;PostItHeadlessFixture&gt;</c>); the
/// collection marker here exists for two reasons:
///
/// <list type="bullet">
/// <item><description>It documents the shared lifecycle
/// contract: every test class that opts in gets the same
/// <see cref="MainWindow"/>, the same DI service provider,
/// the same <see cref="ViewLocator"/> on
/// <see cref="Application.DataTemplates"/>, and the same
/// <see cref="PostIt.App.MainWindow"/> attachment that
/// <c>App.PushPageAsync</c> relies on.</description></item>
/// <item><description>It disables parallelisation across the
/// whole collection. The Avalonia headless platform is
/// process-global (one <see cref="Application.Current"/> per
/// process, one dispatcher per thread), so two collection
/// members running in parallel would race on the same
/// static state and produce flaky failures with no useful
/// diagnostic. Same rationale as
/// <c>JwtClaimMappingCollection</c>.</description></item>
/// </list>
///
/// Mirrors the convention used by
/// <c>Yavsc.Org.Tests.WebServerFixture</c> (collection
/// <c>"Yavsc Server"</c>) and
/// <c>Yavsc.Blogs.Tests.JwtClaimMappingCollection</c>.
/// </summary>
[CollectionDefinition("PostIt Headless")]
public sealed class PostItHeadlessCollection: IDisposable
{
/// <summary>The DI service provider the fixture booted
/// (production graph from <see cref="App.BuildServices"/>).
/// Identical across every <see cref="PostItHeadlessFixture"/>
/// instance — see class remarks.</summary>
public IServiceProvider Services { get; private set; }
/// <summary>The headless <see cref="MainWindow"/> for this
/// fixture instance. Already <see cref="WindowBase.Show"/>n,
/// so its visual tree is realised and
/// <see cref="Button.Command"/> bindings have been
/// evaluated. The window is per-instance, not
/// process-shared, so each test class gets a clean nav
/// stack out of the box.</summary>
public MainWindow Window { get; private set; }
/// <summary>The <see cref="App"/> instance the Avalonia
/// headless harness set as <see cref="Application.Current"/>.
/// Convenience accessor for tests that need to call
/// <c>App.PushPageAsync</c> directly.</summary>
public App App { get; private set; }
/// <summary>The navigation surface the
/// <see cref="MainWindow"/> hosts. Tests can read
/// <c>NavigationStack</c> directly or call
/// <see cref="PushAsync"/> to push onto it.</summary>
public NavigationPage NavRoot { get; private set; }
public PostItHeadlessCollection()
{
// First fixture to construct in this process:
// build the production DI container and attach
// it to the App.
App = (App)Application.Current!;
var testingServices = new ServiceCollection();
var api = new RecordingYavscApiClient(new CallRecorder());
var blog = new BlogApiClient(api, "http://localhost/");
// The recording fake is sufficient on its own — no
// production wiring needed. Swap it in via the fixture
// so the App.PushPageAsync path resolves the same way
// it would in production (minus the token store).
testingServices.AddSingleton(api);
testingServices.AddSingleton(blog);
Services = App.BuildServices(testingServices);
App.AttachServiceProvider(Services);
// The ViewLocator is the single entry point
// App.PushPageAsync uses to map a VM to a Page.
App.DataTemplates.Clear();
App.DataTemplates.Add(new ViewLocator(Services));
// Per-instance window: each test class gets its own.
Window = new MainWindow();
App.AttachMainWindow(Window);
Window.Show();
}
/// <summary>
/// Push a view model or page onto <see cref="NavRoot"/>.
/// Awaits the push asynchronously so the headless
/// dispatcher can pump frames while the push is in flight;
/// the caller can then assert on the resulting stack
/// (<c>NavRoot.NavigationStack[^1]</c>).
/// </summary>
/// <remarks>
/// Tests that want a clean stack (most of them, since
/// the fixture's <see cref="MainWindow"/> is shared
/// across every test class) should call
/// <see cref="ClearNavigationStack"/> before pushing, or
/// use <see cref="MountAsync"/> which clears by default.
/// </remarks>
/// <returns>The page that was pushed, so the caller can
/// assert on its type or bind a <c>DataContext</c>.</returns>
public Page PushAsync(object vmOrPage)
{
if (vmOrPage is null) throw new ArgumentNullException(nameof(vmOrPage));
// Synchronous push: the Avalonia headless
// NavigationPage.PushAsync returns a Task that
// completes once the transition animation finishes,
// and in headless that animation is driven by the
// dispatcher pump. We block on the Task with
// GetAwaiter().GetResult() rather than awaiting it
// because the test body is itself running on the
// dispatcher thread (the [AvaloniaFact] attribute
// schedules the test there); an await would capture
// the dispatcher as the continuation target and
// deadlock waiting for the push to complete on a
// thread that's already busy running the test.
if (vmOrPage is Page page)
{
Window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
// Pump the dispatcher once so the pushed page
// is actually on NavigationStack (the Awaiter
// above unblocks before the stack is updated).
Dispatcher.UIThread.RunJobs();
return page;
}
// VM push: route through the production
// App.PushPageAsync pipeline.
var app = (App)Application.Current!;
var vm = (ViewModelBase)vmOrPage;
app.PushPageAsync(vm).GetAwaiter().GetResult();
Dispatcher.UIThread.RunJobs();
return Window.NavRoot.NavigationStack[^1];
}
public void Dispose()
{
// Last fixture out: tear the shared state down so
// the next test run starts clean. We don't shut
// down the Avalonia headless platform — that's
// owned by the [AvaloniaTestApplication] attribute
// on TestAppBuilder and gets torn down when the
// process exits.
try
{
var app = (App)Application.Current!;
app.DataTemplates.Clear();
}
catch { /* best effort */ }
Services = null;
}
}

View file

@ -1,46 +1,54 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// a real <see cref="MainWindow"/> via the headless Avalonia
/// platform declared in <c>TestApp.cs</c>.
/// UI tests for <see cref="SessionStatusBanner"/>. The shared
/// <see cref="PostItHeadlessFixture"/> provides the headless
/// <see cref="MainWindow"/> already attached to <see cref="App"/>
/// and shown, so each test only has to wire its
/// <see cref="SessionStatusViewModel"/> onto
/// <c>MainWindow.SessionBanner</c> and assert on the rendered
/// tree.
///
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
/// established: a test attribute <c>[AvaloniaFact]</c> (from
/// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
/// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
/// attribute schedules the test body inside a dispatcher, which
/// is the precondition for the headless Window's
/// <para>The session banner's <c>DataContext</c> is not wired
/// by <see cref="App.OnFrameworkInitializationCompleted"/> in
/// these tests: production wires it at composition time, but a
/// unit test runs against a freshly-built <see cref="App"/> so
/// we set the <c>DataContext</c> on the banner directly. The
/// production code path is exercised end-to-end by the manual
/// launch, not here.</para>
///
/// <para>Pattern: <c>[AvaloniaFact]</c> (from
/// <c>Avalonia.Headless.XUnit</c>) instead of plain
/// <c>[Fact]</c> because the AvaloniaFact attribute schedules
/// the test body inside a dispatcher, which is the precondition
/// for the headless Window's
/// <c>PlatformManager.CreateWindow()</c> to find a registered
/// service. A plain <c>[Fact]</c> test that calls
/// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para>
///
/// <para>The session banner's <c>DataContext</c> is not wired in
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// end-to-end by the manual launch, not here.</para>
/// <c>new Window().Show()</c> throws because the harness has
/// not been initialised for that thread.</para>
/// </summary>
public class SessionStatusBannerTests
[Collection("PostIt Headless")]
public sealed class SessionStatusBannerTests
{
private readonly PostItHeadlessCollection _host;
public SessionStatusBannerTests(PostItHeadlessCollection host)
{
_host = host;
}
[AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var banner = _host.Window.SessionBanner;
banner.DataContext = new SessionStatusViewModel();
var buttons = window.SessionBanner.GetVisualDescendants()
var buttons = banner.GetVisualDescendants()
.OfType<Button>()
.ToList();
@ -57,31 +65,30 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out()
{
var window = new MainWindow();
var banner = _host.Window.SessionBanner;
var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
banner.DataContext = vm;
var login = window.SessionBanner.GetVisualDescendants()
var login = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se connecter");
// The XAML binds IsVisible to IsLoggedOut. After Show,
// the binding has been evaluated.
// The XAML binds IsVisible to IsLoggedOut. After the
// banner is on the realised visual tree, the binding
// has been evaluated.
Assert.True(login.IsVisible);
}
[AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out()
{
var window = new MainWindow();
var banner = _host.Window.SessionBanner;
var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
banner.DataContext = vm;
var logout = window.SessionBanner.GetVisualDescendants()
var logout = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter");
@ -91,11 +98,10 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var banner = _host.Window.SessionBanner;
banner.DataContext = new SessionStatusViewModel();
var settings = window.SessionBanner.GetVisualDescendants()
var settings = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Paramètres");
@ -108,11 +114,10 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_session_label_reflects_DataContext()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var banner = _host.Window.SessionBanner;
banner.DataContext = new SessionStatusViewModel();
var label = window.SessionBanner.GetVisualDescendants()
var label = banner.GetVisualDescendants()
.OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");

View file

@ -1,16 +0,0 @@
using Avalonia.Headless.XUnit;
using Avalonia.Controls;
using PostIt.Views;
namespace PostIt.Tests;
public class MainPageTests
{
[AvaloniaFact]
public void MainPage_Should_Load()
{
var window = new MainWindow();
window.Show();
Assert.NotNull(window);
}
}

View file

@ -49,7 +49,7 @@ public partial class App : Application
// build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = BuildServices();
this.ServiceProvider = BuildServices(new ServiceCollection());
AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>();
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
@ -139,7 +139,7 @@ public partial class App : Application
/// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod.
/// </summary>
internal static IServiceProvider BuildServices()
internal static IServiceProvider BuildServices(ServiceCollection services)
{
var settings = new Settings();
settings.Load();
@ -156,7 +156,6 @@ public partial class App : Application
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();

View file

@ -0,0 +1,161 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
/// <c>POST /api/v1/blogacl</c> with a JSON body of
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId + Comment).
///
/// <para>Same fixture as <see cref="CircleMembersApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides a SQLite
/// <c>:memory:</c> <c>ApplicationDbContext</c> (so FKs are
/// enforced the way a real relational engine would) and JWT
/// bearer auth via <c>TestTokenIssuer</c>. No mocks — the real
/// DbContext receives the real INSERT attempt.</para>
///
/// <para>The bug being pinned by these tests: the POST endpoint
/// calls <c>_context.CircleAuthorizationToBlogPost.Add(...)</c>
/// then <c>SaveChangesAsync</c>. The entity has a composite
/// key (CircleId + BlogPostId) and two FKs; EF Core refuses
/// the INSERT with
/// <c>System.InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown when
/// attempting to save changes</c> when the principal entities
/// (the existing <c>BlogPost</c> and <c>Circle</c>) are not
/// attached to the DbContext in the same change-tracker graph.</para>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database and seed <c>alice</c>.
/// The shared SQLite <c>:memory:</c> store persists across
/// requests, so each test starts from a clean slate.</summary>
private void ResetDatabaseWithAlice()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.SaveChanges();
}
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedCircle(string ownerId, string name)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
/// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedBlogPost(string authorId, string title)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
}
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
// The Blogs fixture disables JwtSecurityTokenHandler's
// inbound claim-type remap, so the JWT's "sub" stays "sub"
// rather than being rewritten to ClaimTypes.NameIdentifier.
// The controller, however, reads the user id via
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
// an explicit nameid claim to keep the legacy lookup happy.
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
TestTokenIssuer.Issue(
subject,
extraClaims: new[]
{
new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier,
subject),
}));
return http;
}
/// <summary>PostIt sends only the FK ids (<c>CircleId</c> +
/// <c>BlogPostId</c>) plus scalar fields, never the navigation
/// properties <c>Target</c> / <c>Allowed</c>. The controller
/// must accept that shape and persist the ACL row.</summary>
[Fact]
public async Task PostCircleAuthorization_returns_201_when_adding_existing_circle_to_existing_post()
{
ResetDatabaseWithAlice();
var circleId = SeedCircle("alice", "Famille");
var postId = SeedBlogPost("alice", "Billet de test");
using var http = NewClient("alice");
// Mirror PostIt's payload: scalar FK ids only, no nav props.
var payload = new CircleAuthorizationToBlogPost
{
CircleId = circleId,
BlogPostId = postId,
Comment = true,
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload);
// Expected: 201 Created (per controller line 133: return
// CreatedAtRoute("GetCircleAuthorizationToBlogPost", ...)).
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}

View file

@ -12,6 +12,7 @@ namespace Yavsc.Blogs.Tests;
/// surface. The first behavioural test (GET /api/v1/blog returns
/// 200) lands in a follow-up commit.
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;

View file

@ -22,7 +22,7 @@ namespace Yavsc.Blogs.Tests;
/// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework.
/// </summary>
[Collection("JwtClaimMapping")]
[Collection("Yavsc Blogs")]
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
@ -45,6 +45,21 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
db.Database.EnsureCreated();
}
/// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required
/// for any test that POST/PUT/DELETE a <c>BlogPost</c>:
/// <c>BlogPost.AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>, and SQLite (unlike the EF Core
/// InMemory provider) enforces it. Without the seed, the
/// POST handler hits
/// <c>SQLite Error 19: 'FOREIGN KEY constraint failed'</c>
/// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser()
{
ResetDatabase();
_fixture.SeedUser("tester");
}
/// <summary>The fixture's <c>WebApplication</c> is bound to
/// <c>https://localhost:&lt;random&gt;</c> via
/// <see cref="WebHostFixture.Addresses"/>. We pick the first
@ -116,7 +131,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient();
// Create a minimal BlogPost. The server assigns Id, so we
@ -154,7 +169,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
@ -186,7 +201,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PostBlogComment_returns_201_for_existing_post()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
@ -249,7 +264,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update()
{
ResetDatabase();
ResetAndSeedDefaultUser();
// The JWT's sub must match the post's AuthorId:
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
// and UserHelpers.GetUserId reads "sub" off the principal.
@ -300,7 +315,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient();
// Seed a post we can delete.
@ -342,7 +357,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// ModelState validation starts rejecting the PostIt payload
// (missing field, wrong casing, etc.), this test fails
// before the regression reaches a user.
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
// Mirrors what MainPageViewModel.Save builds: a BlogPost with

View file

@ -2,8 +2,8 @@ using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Blogs.Controllers;
@ -14,14 +14,20 @@ using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Test host for the Yavsc.Blogs API surface. Specialisation of
/// <see cref="WebHostFixture"/> that wires up only the bits the
/// blog API actually depends on:
/// Shared integration-test host for the Yavsc.Blogs API surface.
/// Specialisation of <see cref="WebHostFixture"/> that wires up
/// only the bits the blog API actually depends on:
///
/// <list type="bullet">
/// <item><description>An in-memory <see cref="ApplicationDbContext"/>
/// (the real one — no mock) so <c>BlogSpotService.Index</c> can run
/// against an empty table and return an empty list.</description></item>
/// <item><description>A SQLite <c>:memory:</c> database
/// (<see cref="Microsoft.EntityFrameworkCore.Sqlite"/>) backed
/// by a single shared <see cref="SqliteConnection"/> held open
/// for the lifetime of the host. SQLite enforces real foreign
/// keys and real transactional semantics, so the tests see the
/// same INSERT-time FK validation a production Postgres host
/// would — unlike the EF Core InMemory provider, which silently
/// ignores FKs and masks bugs that surface only against a real
/// relational engine.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: the GET index path doesn't read the file system, so any
/// implementation is fine.</description></item>
@ -44,31 +50,58 @@ namespace Yavsc.Blogs.Tests;
///
/// No IdentityServer, no SMTP, no static assets — the Org fixture
/// owns all of that and we don't need any of it for blog integration
/// tests.
/// tests. Marked <see cref="CollectionDefinitionAttribute"/> so the
/// host is shared across every <c>[Collection("Yavsc Blogs")]</c>
/// test class: one host, one SQLite DB, one Kestrel port.
/// </summary>
[CollectionDefinition("Yavsc Blogs")]
public sealed class BlogsWebServerFixture : WebHostFixture
{
protected override int HttpsPort => 5103;
private InMemoryDatabaseRoot? _inMemoryRoot;
// A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
// shared configuration into static slots. Closing the
// connection destroys the in-memory database — so we close
// it only when the last fixture instance is disposed (see
// Dispose below), exactly when WebHostFixture tears down the
// host.
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
// Use the real ApplicationDbContext with an in-memory store.
// BlogSpotService reads _context.BlogSpot directly, so any
// attempt to mock it would be wasted work; the real service
// 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();
// Open the shared in-memory connection lazily on the first
// fixture construction. Subsequent constructions (xUnit
// creates one fixture instance per IClassFixture) reuse
// the same connection so all DbContexts across all tests
// see the same database.
SqliteConnection sharedConnection;
lock (_sqliteLock)
{
if (_sharedSqliteConnection is null)
{
// Mode=Memory + Cache=Shared gives us a named
// in-memory database that every connection string
// referencing "File:YavscBlogsTests?mode=memory&cache=shared"
// will resolve to the same backing store, as long
// as at least one SqliteConnection stays open
// against it.
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscBlogsTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
}
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot));
// UseSqlite(DbConnection) keeps the connection we just
// opened alive for the DbContext's lifetime, instead of
// letting EF open and close its own. Without this,
// each DbContext would get a fresh connection pointing
// at an empty :memory: store and nothing would persist
// across requests.
opt.UseSqlite(sharedConnection));
// Trivial file-system auth: the GET index path never calls
// into it, but the DI container needs an instance.
@ -168,6 +201,75 @@ public sealed class BlogsWebServerFixture : WebHostFixture
return app;
}
public override void Dispose()
{
try
{
base.Dispose();
}
finally
{
// Close the shared SQLite connection only when the
// last fixture instance goes away, matching the
// lifetime contract of WebHostFixture.Dispose. We
// rely on base.Dispose's _instanceCount decrement
// having run, so we close only if the host is gone
// (base already nulled _app when count==0).
lock (_sqliteLock)
{
if (_sharedSqliteConnection is not null)
{
// Synchronous close: SQLite's Close() is
// documented as safe to call from a sync
// context and avoids the GetAwaiter().GetResult()
// pattern that's historically caused teardown
// hangs in this repo's async pipeline.
_sharedSqliteConnection.Close();
_sharedSqliteConnection.Dispose();
_sharedSqliteConnection = null;
}
}
}
}
/// <summary>Seed an <see cref="ApplicationUser"/> in the shared
/// SQLite store, so tests that POST/PUT/DELETE a
/// <c>BlogPost</c> (whose <c>AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>) don't trip the FK constraint that
/// SQLite enforces but the EF Core InMemory provider silently
/// ignored. Idempotent on <paramref name="userName"/>: a
/// second call for the same id is a no-op (the user already
/// exists).</summary>
/// <param name="userName">Both the PK id and the login name.
/// The JWT subject in tests is this same string, so seeding
/// this id is enough to make the FK from a
/// <c>BlogPost.AuthorId</c> resolve.</param>
/// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var existing = db.Users.SingleOrDefault(u => u.Id == userName);
if (existing != null) return existing;
// Email is an alternate key on ApplicationUser; seeding
// it explicitly avoids the InMemory provider's null-claim
// tracking quirk (cf. PublishEndpointTests.ResetDatabase)
// and keeps the column shape realistic for prod.
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return user;
}
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
/// blog API endpoints exercised by the first tests don't read the
/// file system, so the implementation can be a no-op.</summary>

View file

@ -25,7 +25,7 @@ namespace Yavsc.Blogs.Tests;
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
/// via <see cref="TestTokenIssuer"/>.</para>
/// </summary>
[Collection("JwtClaimMapping")]
[Collection("Yavsc Blogs")]
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;

View file

@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />