diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs new file mode 100644 index 00000000..9ec10f89 --- /dev/null +++ b/src/PostIt.Tests/BlogApiTestFakes.cs @@ -0,0 +1,65 @@ +using PostIt.Models; +using PostIt.Services; +using PostIt.ViewModels; + +namespace PostIt.Tests; + +/// 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. +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(); +} + +/// Test fake that records every CallAsync invocation +/// and answers them with a canned sequence: the first call gets +/// a server-issued BlogPost (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. +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 CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + _recorder.Calls.Add((method, path, body)); + // BlogPost? boxes to BlogPost at runtime, so we test the + // non-nullable type — typeof(BlogPost?) is a C# error + // (CS8639: "typeof cannot be used on a nullable reference + // type"). + if (typeof(T) == typeof(BlogPost)) + return Task.FromResult((T)(object)new BlogPost + { + Id = 42, + Title = "Mon premier billet", + AuthorId = "tester", + Article = "Contenu du billet de test.", + }); + if (typeof(T) == typeof(List)) + return Task.FromResult((T)(object)new List + { + new() { Id = 42, Title = "Mon premier billet" } + }); + return Task.FromResult(default(T)!); + } +} diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs new file mode 100644 index 00000000..c76115d7 --- /dev/null +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -0,0 +1,89 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using PostIt.Models; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +namespace PostIt.Tests; + +/// +/// Headless UI tests for the "Save" flow in . +/// The pattern is the one SessionStatusBannerTests +/// established: [AvaloniaFact], a +/// hosting the page (via a because +/// MainPage is a ContentPage), then drive the +/// controls through their public surface and assert on what +/// saw go on the wire. +/// +/// The bug we are pinning: the title TextBox is +/// currently {Binding SelectedPost.Title, Mode=TwoWay}. +/// When SelectedPost is null (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 +/// if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } } +/// 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 Title/Article +/// buffer that the XAML binds to and the Save command consumes. +/// +public 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); + 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(); + + // 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() + .OfType() + .First(t => t.PlaceholderText == "Title"); + const string typed = "Mon premier billet"; + titleBox.Text = typed; + + var saveButton = window.GetVisualDescendants() + .OfType