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:
parent
6825f74308
commit
a44c04ad77
22 changed files with 806 additions and 460 deletions
145
src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs
Normal file
145
src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless coverage for the two interactive buttons of the
|
||||
/// "add a circle member" modal: "Ajouter" and "Fermer".
|
||||
///
|
||||
/// <para>The dialog is pushed on top of <see cref="CirclesPage"/>
|
||||
/// via the canonical <c>App.PushPageAsync</c> pipeline (the
|
||||
/// same path <c>CirclesPageViewModel.OpenAddMemberAsync</c>
|
||||
/// uses). The test asserts on <c>NavRoot.NavigationStack</c>
|
||||
/// size before and after each click — the user's bug was "I
|
||||
/// click and nothing happens", so the failure mode is a stack
|
||||
/// that doesn't shrink for "Fermer", and a "Confirmer" event
|
||||
/// that the host doesn't pick up for "Ajouter" (the dialog
|
||||
/// stays up = stack doesn't shrink either).</para>
|
||||
///
|
||||
/// <para>Pattern follows <c>MainPageButtonsTests</c>: name
|
||||
/// every interactive control in XAML with <c>x:Name</c>,
|
||||
/// 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
|
||||
/// exercised in these tests — the picker starts empty and
|
||||
/// the "Ajouter" button's IsEnabled is bound to a null
|
||||
/// selection, which keeps the click harmless even when
|
||||
/// its <see cref="AddCircleMemberDialogViewModel.Add"/>
|
||||
/// command does fire.
|
||||
/// </summary>
|
||||
private sealed class StubUserDirectory : IUserDirectory
|
||||
{
|
||||
public Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<UserSummary>>(new List<UserSummary>());
|
||||
}
|
||||
|
||||
private 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()))
|
||||
{ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mount a real <see cref="MainWindow"/>, build a minimal
|
||||
/// DI graph, push <see cref="CirclesPage"/> then the
|
||||
/// <see cref="AddCircleMemberDialog"/> on top of it.
|
||||
/// Returns the stack size so the test can pin the delta.
|
||||
/// The graph exposes <c>IUserDirectory</c> (so the dialog
|
||||
/// 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()
|
||||
{
|
||||
var api = new ThrowingApi();
|
||||
var circleClient = new CircleApiClient(api, "http://localhost/");
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(new Settings());
|
||||
services.AddSingleton<IUserDirectory>(new StubUserDirectory());
|
||||
services.AddSingleton(circleClient);
|
||||
services.AddTransient<CirclesPage>();
|
||||
services.AddTransient<CirclesPageViewModel>();
|
||||
services.AddTransient<AddCircleMemberDialog>();
|
||||
services.AddTransient<AddCircleMemberDialogViewModel>();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var window = new MainWindow();
|
||||
var app = (PostIt.App)Application.Current!;
|
||||
app.DataTemplates.Clear();
|
||||
app.DataTemplates.Add(new ViewLocator(sp));
|
||||
app.AttachMainWindow(window);
|
||||
window.Show();
|
||||
|
||||
var circlesPage = sp.GetRequiredService<CirclesPage>();
|
||||
window.NavRoot.PushAsync(circlesPage).GetAwaiter().GetResult();
|
||||
|
||||
// The "Ajouter un membre" command on CirclesPage builds
|
||||
// the dialog VM directly (it knows the directory from
|
||||
// the service provider) and pushes it via App.PushPage.
|
||||
var dialogVm = new AddCircleMemberDialogViewModel(sp.GetRequiredService<IUserDirectory>());
|
||||
((App)Application.Current!).PushPageAsync(dialogVm).GetAwaiter().GetResult();
|
||||
|
||||
var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog
|
||||
?? throw new System.InvalidOperationException("Dialog page not at top of stack.");
|
||||
return (window, circlesPage, dialog);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click the "Fermer" button on the dialog and assert the
|
||||
/// nav stack shrinks by exactly one.
|
||||
/// </summary>
|
||||
[AvaloniaFact]
|
||||
public void Close_button_pops_dialog_off_nav_stack()
|
||||
{
|
||||
// Arrange: stack starts at 2 (CirclesPage + dialog).
|
||||
var window = fixture.Window;
|
||||
var stackBefore = window.NavRoot.NavigationStack.Count;
|
||||
Assert.Equal(2, stackBefore);
|
||||
|
||||
// Act
|
||||
var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog ?? throw new System.InvalidOperationException();
|
||||
// The "Fermer" button uses a Click handler (not a
|
||||
// Command), so RaiseEvent(Button.ClickEvent) is the
|
||||
// right way to fire it from headless code. Executing
|
||||
// Command would no-op because no Command is bound.
|
||||
dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
|
||||
|
||||
// Assert: stack -1, the top is the CirclesPage again.
|
||||
Assert.True(window.NavRoot.NavigationStack.Count == stackBefore - 1,
|
||||
$"Click on 'Fermer' must shrink the nav stack by one. Before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
|
||||
Assert.IsType<CirclesPage>(window.NavRoot.NavigationStack[^1]);
|
||||
}
|
||||
}
|
||||
92
src/PostIt.Tests/Blogs/BlogApiTestFakes.cs
Normal file
92
src/PostIt.Tests/Blogs/BlogApiTestFakes.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using Yavsc.Blogspot;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Models;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>Per-call ledger shared between the test and the
|
||||
/// recording fake, so the assertion can inspect what the VM
|
||||
/// actually sent on the wire without coupling to the fake's
|
||||
/// internals.</summary>
|
||||
internal sealed class CallRecorder
|
||||
{
|
||||
public (HttpMethod method, string path, object? body) FirstCall =>
|
||||
Calls[0];
|
||||
public List<(HttpMethod method, string path, object? body)> Calls { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Test fake that records every CallAsync invocation
|
||||
/// and answers them with a canned sequence: the first call gets
|
||||
/// a server-issued BlogPostDto (Id=42), the second call gets a
|
||||
/// single-element list containing that post. Used by the ViewModel
|
||||
/// tests and the headless UI test to capture exactly what the
|
||||
/// Save button posts to the server.</summary>
|
||||
internal sealed class RecordingYavscApiClient : YavscApiClient
|
||||
{
|
||||
private readonly CallRecorder _recorder;
|
||||
public RecordingYavscApiClient(CallRecorder recorder)
|
||||
: base(
|
||||
new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = "https://stub.invalid",
|
||||
ClientId = "stub",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
},
|
||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||
{
|
||||
_recorder = recorder;
|
||||
}
|
||||
|
||||
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
_recorder.Calls.Add((method, path, body));
|
||||
// BlogPostDto? boxes to BlogPostDto at runtime, so we test the
|
||||
// non-nullable type — typeof(BlogPostDto?) is a C# error
|
||||
// (CS8639: "typeof cannot be used on a nullable reference
|
||||
// type").
|
||||
if (typeof(T) == typeof(BlogPostDto))
|
||||
return Task.FromResult((T)(object)new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
Title = "Mon premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu du billet de test.",
|
||||
});
|
||||
if (typeof(T) == typeof(List<BlogPostDto>))
|
||||
return Task.FromResult((T)(object)new List<BlogPostDto>
|
||||
{
|
||||
new() { Id = 42, Title = "Mon premier billet" }
|
||||
});
|
||||
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()))
|
||||
{ }
|
||||
}
|
||||
169
src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs
Normal file
169
src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using System.Text.Json;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip tests for the wire shape of a blog post as
|
||||
/// serialised by Yavsc.Blogs and consumed by PostIt.
|
||||
///
|
||||
/// <para>
|
||||
/// Background: in 1.0.7, <c>BlogPostDto.Author</c> was typed as
|
||||
/// the abstract interface <c>IApplicationUser</c>. System.Text.Json
|
||||
/// cannot materialise an interface without a polymorphic
|
||||
/// converter, so the "load posts" call from PostIt crashed when
|
||||
/// the server returned a post with a populated <c>Author</c>
|
||||
/// object. The fix replaced <c>IApplicationUser</c> with a thin
|
||||
/// concrete DTO, <c>BlogPostAuthorDto</c>, embedded directly in
|
||||
/// <c>BlogPostDto.Author</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// These tests pin the wire shape: a JSON document with an
|
||||
/// <c>Author</c> object must deserialise without throwing and
|
||||
/// must round-trip the three fields PostIt exposes in the UI
|
||||
/// (Id, UserName, Avatar). They are intentionally placed in
|
||||
/// <c>PostIt.Tests</c> — the client-side assembly — so the
|
||||
/// regression is caught at the deserialisation boundary, where
|
||||
/// it actually manifested in production.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class BlogPostAuthorDtoTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions CaseInsensitiveJson
|
||||
= new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
[Fact]
|
||||
public void BlogPostDto_deserialises_with_populated_author()
|
||||
{
|
||||
// A representative JSON shape the server would emit for
|
||||
// GET /api/BlogApi. The Author object is fully populated
|
||||
// — that's the shape that used to break deserialisation
|
||||
// when Author was typed as the abstract IApplicationUser
|
||||
// interface.
|
||||
var json = """
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Premier billet",
|
||||
"article": "Contenu",
|
||||
"photo": null,
|
||||
"dateCreated": "2026-08-01T12:00:00Z",
|
||||
"dateModified": "2026-08-02T12:00:00Z",
|
||||
"userCreated": "alice",
|
||||
"userModified": "alice",
|
||||
"authorId": "u-alice",
|
||||
"isPublished": true,
|
||||
"author": {
|
||||
"id": "u-alice",
|
||||
"userName": "alice",
|
||||
"avatar": "/avatars/alice.png"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
|
||||
|
||||
Assert.NotNull(post);
|
||||
Assert.Equal(42, post!.Id);
|
||||
Assert.Equal("Premier billet", post.Title);
|
||||
Assert.Equal("u-alice", post.AuthorId);
|
||||
Assert.True(post.IsPublished);
|
||||
|
||||
// The actual regression coverage: Author must
|
||||
// materialise as a concrete DTO, not be left null because
|
||||
// of a JsonException on IApplicationUser.
|
||||
Assert.NotNull(post.Author);
|
||||
Assert.Equal("u-alice", post.Author!.Id);
|
||||
Assert.Equal("alice", post.Author.UserName);
|
||||
Assert.Equal("/avatars/alice.png", post.Author.Avatar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlogPostDto_deserialises_when_author_is_null()
|
||||
{
|
||||
// The server is allowed to omit Author (the field is
|
||||
// nullable on the wire — it maps to a navigation
|
||||
// property that may not have been Included). The client
|
||||
// must accept that shape without throwing.
|
||||
var json = """
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Sans auteur",
|
||||
"article": null,
|
||||
"photo": null,
|
||||
"dateCreated": "2026-08-01T12:00:00Z",
|
||||
"dateModified": "2026-08-01T12:00:00Z",
|
||||
"userCreated": "system",
|
||||
"userModified": "system",
|
||||
"authorId": "system",
|
||||
"isPublished": false,
|
||||
"author": null
|
||||
}
|
||||
""";
|
||||
|
||||
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
|
||||
|
||||
Assert.NotNull(post);
|
||||
Assert.Null(post!.Author);
|
||||
Assert.Equal("system", post.AuthorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlogPostDto_deserialises_when_author_field_is_missing()
|
||||
{
|
||||
// Forward-compatibility: an older server that doesn't
|
||||
// emit the Author field at all. Should not throw.
|
||||
var json = """
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Ancien format",
|
||||
"article": "Pas d'auteur dans la charge utile",
|
||||
"photo": null,
|
||||
"dateCreated": "2026-07-01T12:00:00Z",
|
||||
"dateModified": "2026-07-01T12:00:00Z",
|
||||
"userCreated": "bob",
|
||||
"userModified": "bob",
|
||||
"authorId": "u-bob",
|
||||
"isPublished": true
|
||||
}
|
||||
""";
|
||||
|
||||
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
|
||||
|
||||
Assert.NotNull(post);
|
||||
Assert.Null(post!.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlogPostAuthorDto_serialises_back_to_expected_json_shape()
|
||||
{
|
||||
// Pin the wire shape on the way out too. The server
|
||||
// builds BlogPostAuthorDto from an ApplicationUser and
|
||||
// PostIt receives it as JSON; if the field names
|
||||
// change (e.g. case) the round-trip on the client side
|
||||
// is what would silently break.
|
||||
//
|
||||
// The server emits camelCase (ASP.NET Core's Web
|
||||
// defaults — PropertyNamingPolicy = CamelCase). We
|
||||
// mirror that here so the test reflects what the wire
|
||||
// actually looks like. PropertyNameCaseInsensitive on
|
||||
// the client deserialiser means we don't have to
|
||||
// hardcode the casing for the inbound assertions.
|
||||
var author = new BlogPostAuthorDto
|
||||
{
|
||||
Id = "u-alice",
|
||||
UserName = "alice",
|
||||
Avatar = "/avatars/alice.png"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(author,
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.True(root.TryGetProperty("id", out _));
|
||||
Assert.True(root.TryGetProperty("userName", out _));
|
||||
Assert.True(root.TryGetProperty("avatar", out _));
|
||||
}
|
||||
}
|
||||
217
src/PostIt.Tests/Blogs/MainPageButtonsTests.cs
Normal file
217
src/PostIt.Tests/Blogs/MainPageButtonsTests.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Blogspot;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the three toolbar buttons on
|
||||
/// <see cref="MainPage"/> that the user reported as inoperative:
|
||||
/// "ACL", "Mes cercles", and "[DEV] Signature".
|
||||
///
|
||||
/// <para>Pattern (per the Avalonia headless testing docs —
|
||||
/// <c>TestableApp.Headless.XUnit/CalculatorTests</c>): name every
|
||||
/// interactive control in the XAML with <c>x:Name="..."</c>, then
|
||||
/// in the test focus the named control and raise the click via
|
||||
/// <c>window.KeyPressQwerty(PhysicalKey.Enter, ...)</c>. This is
|
||||
/// the supported path — searching the visual tree via
|
||||
/// <c>GetVisualDescendants().OfType<Button>()</c> for a
|
||||
/// button by Content text is brittle and was tried first; it does
|
||||
/// not work reliably when the page is hosted inside an
|
||||
/// <see cref="Avalonia.Controls.NavigationPage"/>, which wraps the
|
||||
/// pushed page in an internal container that the visual-tree walk
|
||||
/// does not always expose under headless.</para>
|
||||
///
|
||||
/// <para>The assertion is on the post-click top of
|
||||
/// <see cref="Avalonia.Controls.INavigation.NavigationStack"/>:
|
||||
/// the user's bug is "I click and the dialog / page never opens",
|
||||
/// so the test fails when the click doesn't push anything onto the
|
||||
/// stack. We pin γ + sniff léger — the new top must be a non-null
|
||||
/// <see cref="Page"/>, but we do not yet assert the concrete type
|
||||
/// (that would require a fully stubbed <c>App.ServiceProvider</c>,
|
||||
/// which is the next iteration of this suite).</para>
|
||||
///
|
||||
/// <para>Each test exercises the bit that would silently break if
|
||||
/// the wiring was reverted:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>"ACL" — click with a selected post pushes a page onto
|
||||
/// the stack.</item>
|
||||
/// <item>"Mes cercles" — click pushes a page onto the stack.</item>
|
||||
/// <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>
|
||||
[Collection("PostIt Headless")]
|
||||
public sealed class MainPageButtonsTests
|
||||
{
|
||||
private readonly PostItHeadlessCollection _host;
|
||||
|
||||
public MainPageButtonsTests(PostItHeadlessCollection host)
|
||||
{
|
||||
_host = host;
|
||||
}
|
||||
|
||||
/// <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/");
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(new Settings());
|
||||
services.AddSingleton(circle);
|
||||
services.AddSingleton(acl);
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
services.AddTransient<CirclesPageViewModel>();
|
||||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
services.AddTransient<PostAclDialog>();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var vm = new MainPageViewModel(blog, services: sp);
|
||||
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
||||
return vm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 MainPage MountAsync(MainPageViewModel vm)
|
||||
{
|
||||
var page = new MainPage { DataContext = vm };
|
||||
_host.PushAsync(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
var stackBefore = window.NavRoot.NavigationStack.Count;
|
||||
button.Command?.Execute(button.CommandParameter);
|
||||
if (button.Command is IAsyncRelayCommand asyncCommand)
|
||||
{
|
||||
asyncCommand.ExecutionTask?.GetAwaiter().GetResult();
|
||||
}
|
||||
return stackBefore;
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Acl_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: a VM whose SelectedPost is non-null so
|
||||
// CanManageAcl evaluates to true and the button is
|
||||
// armed.
|
||||
var post = new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
Title = "An existing post",
|
||||
AuthorId = "u-alice"
|
||||
};
|
||||
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
|
||||
// binding) and the rest of the test is moot.
|
||||
var aclButton = page.ManageAclButton;
|
||||
Assert.NotNull(aclButton.Command);
|
||||
Assert.True(aclButton.Command.CanExecute(null));
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(_host.Window, aclButton);
|
||||
|
||||
// Assert γ + sniff léger: stack grew, new top is a Page.
|
||||
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);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Circles_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: OpenCircles has no CanExecute guard today —
|
||||
// any click should fire it and push the page.
|
||||
var vm = BuildViewModel();
|
||||
var page = MountAsync(vm);
|
||||
|
||||
var circlesButton = page.OpenCirclesButton;
|
||||
Assert.NotNull(circlesButton.Command);
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(_host.Window, circlesButton);
|
||||
|
||||
// Assert
|
||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
"Click on 'Mes cercles' must push a new page onto the nav stack.");
|
||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// 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 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(_host.Window, signatureButton);
|
||||
|
||||
// Assert
|
||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
|
||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
}
|
||||
91
src/PostIt.Tests/Blogs/MainPageSaveTests.cs
Normal file
91
src/PostIt.Tests/Blogs/MainPageSaveTests.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.VisualTree;
|
||||
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"/>.
|
||||
/// 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>.
|
||||
/// When <c>SelectedPost is null</c> (i.e. the user has not yet
|
||||
/// clicked an item in the posts list — which is the only state
|
||||
/// in which a brand-new post can be created), the binding has
|
||||
/// no target and the user's keystrokes are silently dropped.
|
||||
/// Clicking "Save" then routes to the VM branch
|
||||
/// <c>if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } }</c>
|
||||
/// which the controller rejects with 400 "The Title field is
|
||||
/// required." This test fails on that branch today and will
|
||||
/// 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>
|
||||
[Collection("PostIt Headless")]
|
||||
public sealed class MainPageSaveTests
|
||||
{
|
||||
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 };
|
||||
_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 = _host.Window.GetVisualDescendants()
|
||||
.OfType<TextBox>()
|
||||
.First(t => t.PlaceholderText == "Title");
|
||||
const string typed = "Mon premier billet";
|
||||
titleBox.Text = typed;
|
||||
|
||||
var saveButton = _host.Window.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Save");
|
||||
saveButton.Command!.Execute(null);
|
||||
|
||||
// The Save command is async (RelayCommand over Task) but
|
||||
// ExecuteAsync would await; the sync Execute enqueues the
|
||||
// task on the dispatcher. Give the dispatcher a chance to
|
||||
// run so the awaited CallAsync has actually fired before
|
||||
// we inspect the recorder.
|
||||
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
|
||||
// fails this assertion with Title == string.Empty.
|
||||
Assert.NotEmpty(recorder.Calls);
|
||||
var (method, path, body) = recorder.FirstCall;
|
||||
Assert.Equal(HttpMethod.Post, method);
|
||||
Assert.Equal("blog", path);
|
||||
var sent = Assert.IsType<BlogPostDto>(body);
|
||||
Assert.Equal(typed, sent.Title);
|
||||
}
|
||||
}
|
||||
124
src/PostIt.Tests/Blogs/PostAclDialogTests.cs
Normal file
124
src/PostIt.Tests/Blogs/PostAclDialogTests.cs
Normal 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!);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue