PostIt/Yavsc.Blogs: surface 4xx body + pin controller + red UI test for Save
The 'Save' button in PostIt has been returning 400 from
/api/v1/blog ever since the editor's title and article fields
were re-bound to SelectedPost.Title / SelectedPost.Article.
The user types into the editor, taps Save, the controller
rejects with 'The Title field is required', and the PostIt
status bar shows only the generic 'Response status code does
not indicate success: 400' — no field name, no reason.
Three pieces here make the regression diagnosable and pin a
test for the fix:
1. YavscApiClient: replace EnsureSuccessStatusCode() at both
call sites with a small helper that reads the response
body and embeds it in the thrown HttpRequestException. The
VM's existing catch (Exception) in ExecuteAsync forwards
ex.Message to the status bar, so the next 'click Save'
tells the user exactly which field the server rejected.
2. Yavsc.Blogs.Tests: two integration tests on the real
controller (no HTTP mock) — one pins that a well-formed
PostIt-shaped payload (Title + Article + AuthorId + dates,
Id=0) is accepted with 201, the other pins that a payload
with Title=string.Empty is rejected with 400. Together they
pin the contract the VM has to honour.
3. PostIt.Tests: a red [AvaloniaFact] UI test that mounts
MainPage inside a headless Window, types a title into the
TextBox without first selecting a post in the list, taps
Save, and asserts the body of the first POST contains
the typed title. Today this test fails with Title='',
reproducing the production 400. The matching fix (a
Title/Article buffer on MainPageViewModel that the XAML
binds to, and that Save uses to build the outgoing
BlogPost) is the next commit; the test is the safety net.
2026-07-11 02:52:50 +01:00
|
|
|
using Avalonia;
|
|
|
|
|
using Avalonia.Controls;
|
|
|
|
|
using Avalonia.Headless.XUnit;
|
|
|
|
|
using Avalonia.VisualTree;
|
2026-08-17 23:45:45 +01:00
|
|
|
using Yavsc.Blogspot;
|
feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.
Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
the blogs base URL explicitly in its constructor so it doesn't
need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
Same conventions as CircleApiClient.
DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
navigation properties on the server-side Circle (Owner,
Members), which depend on ApplicationUser and other server
types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
reason: the server entity has Target and Allowed navigation
properties the client never needs.
The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.
Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
2026-08-17 23:50:35 +01:00
|
|
|
using Yavsc.Api.Client;
|
PostIt/Yavsc.Blogs: surface 4xx body + pin controller + red UI test for Save
The 'Save' button in PostIt has been returning 400 from
/api/v1/blog ever since the editor's title and article fields
were re-bound to SelectedPost.Title / SelectedPost.Article.
The user types into the editor, taps Save, the controller
rejects with 'The Title field is required', and the PostIt
status bar shows only the generic 'Response status code does
not indicate success: 400' — no field name, no reason.
Three pieces here make the regression diagnosable and pin a
test for the fix:
1. YavscApiClient: replace EnsureSuccessStatusCode() at both
call sites with a small helper that reads the response
body and embeds it in the thrown HttpRequestException. The
VM's existing catch (Exception) in ExecuteAsync forwards
ex.Message to the status bar, so the next 'click Save'
tells the user exactly which field the server rejected.
2. Yavsc.Blogs.Tests: two integration tests on the real
controller (no HTTP mock) — one pins that a well-formed
PostIt-shaped payload (Title + Article + AuthorId + dates,
Id=0) is accepted with 201, the other pins that a payload
with Title=string.Empty is rejected with 400. Together they
pin the contract the VM has to honour.
3. PostIt.Tests: a red [AvaloniaFact] UI test that mounts
MainPage inside a headless Window, types a title into the
TextBox without first selecting a post in the list, taps
Save, and asserts the body of the first POST contains
the typed title. Today this test fails with Title='',
reproducing the production 400. The matching fix (a
Title/Article buffer on MainPageViewModel that the XAML
binds to, and that Save uses to build the outgoing
BlogPost) is the next commit; the test is the safety net.
2026-07-11 02:52:50 +01:00
|
|
|
using PostIt.Services;
|
|
|
|
|
using PostIt.ViewModels;
|
|
|
|
|
using PostIt.Views;
|
|
|
|
|
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.
|
|
|
|
|
///
|
|
|
|
|
/// <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 BlogPost { 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>
|
|
|
|
|
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);
|
feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.
Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
the blogs base URL explicitly in its constructor so it doesn't
need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
Same conventions as CircleApiClient.
DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
navigation properties on the server-side Circle (Owner,
Members), which depend on ApplicationUser and other server
types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
reason: the server entity has Target and Allowed navigation
properties the client never needs.
The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.
Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
2026-08-17 23:50:35 +01:00
|
|
|
var blog = new BlogApiClient(api, "http://localhost/");
|
PostIt/Yavsc.Blogs: surface 4xx body + pin controller + red UI test for Save
The 'Save' button in PostIt has been returning 400 from
/api/v1/blog ever since the editor's title and article fields
were re-bound to SelectedPost.Title / SelectedPost.Article.
The user types into the editor, taps Save, the controller
rejects with 'The Title field is required', and the PostIt
status bar shows only the generic 'Response status code does
not indicate success: 400' — no field name, no reason.
Three pieces here make the regression diagnosable and pin a
test for the fix:
1. YavscApiClient: replace EnsureSuccessStatusCode() at both
call sites with a small helper that reads the response
body and embeds it in the thrown HttpRequestException. The
VM's existing catch (Exception) in ExecuteAsync forwards
ex.Message to the status bar, so the next 'click Save'
tells the user exactly which field the server rejected.
2. Yavsc.Blogs.Tests: two integration tests on the real
controller (no HTTP mock) — one pins that a well-formed
PostIt-shaped payload (Title + Article + AuthorId + dates,
Id=0) is accepted with 201, the other pins that a payload
with Title=string.Empty is rejected with 400. Together they
pin the contract the VM has to honour.
3. PostIt.Tests: a red [AvaloniaFact] UI test that mounts
MainPage inside a headless Window, types a title into the
TextBox without first selecting a post in the list, taps
Save, and asserts the body of the first POST contains
the typed title. Today this test fails with Title='',
reproducing the production 400. The matching fix (a
Title/Article buffer on MainPageViewModel that the XAML
binds to, and that Save uses to build the outgoing
BlogPost) is the next commit; the test is the safety net.
2026-07-11 02:52:50 +01:00
|
|
|
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<TextBox>()
|
|
|
|
|
.First(t => t.PlaceholderText == "Title");
|
|
|
|
|
const string typed = "Mon premier billet";
|
|
|
|
|
titleBox.Text = typed;
|
|
|
|
|
|
|
|
|
|
var saveButton = 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.
|
|
|
|
|
await Task.Delay(200);
|
|
|
|
|
|
|
|
|
|
// Assert: the first POST to "blog" carried a BlogPost
|
|
|
|
|
// 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<BlogPost>(body);
|
|
|
|
|
Assert.Equal(typed, sent.Title);
|
|
|
|
|
}
|
|
|
|
|
}
|