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);
}
}