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