postIt #2
4 changed files with 283 additions and 2 deletions
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.
commit
03cd9843d3
65
src/PostIt.Tests/BlogApiTestFakes.cs
Normal file
65
src/PostIt.Tests/BlogApiTestFakes.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
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 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.</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));
|
||||
// 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<BlogPost>))
|
||||
return Task.FromResult((T)(object)new List<BlogPost>
|
||||
{
|
||||
new() { Id = 42, Title = "Mon premier billet" }
|
||||
});
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
}
|
||||
89
src/PostIt.Tests/MainPageSaveTests.cs
Normal file
89
src/PostIt.Tests/MainPageSaveTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <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);
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -191,7 +191,7 @@ public class YavscApiClient : IAsyncDisposable
|
|||
CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
|
||||
|
|
@ -217,7 +217,7 @@ public class YavscApiClient : IAsyncDisposable
|
|||
CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -259,6 +259,48 @@ public class YavscApiClient : IAsyncDisposable
|
|||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the bare <c>response.EnsureSuccessStatusCode()</c>
|
||||
/// call site with one that surfaces the response body in the
|
||||
/// thrown exception. The default behaviour truncates the
|
||||
/// diagnostic to "Response status code does not indicate
|
||||
/// success: 400 (Bad Request)." — useless when the server is
|
||||
/// an ASP.NET Core action returning a <c>ProblemDetails</c>
|
||||
/// that names the field that failed ModelState validation.
|
||||
/// The VM's <c>catch (Exception ex)</c> in
|
||||
/// <c>MainPageViewModel.ExecuteAsync</c> shows
|
||||
/// <c>ex.Message</c> on the status bar, so embedding the body
|
||||
/// here is enough to make the next "click Save" self-explanatory
|
||||
/// (e.g. <i>"Error: 400 — The Title field is required."</i>).
|
||||
/// </summary>
|
||||
private static async Task EnsureSuccessOrThrowAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
|
||||
// Read the body before throwing; once the response is
|
||||
// disposed, the stream is gone. We bound the read to a few
|
||||
// KB so a hostile server can't make us buffer megabytes
|
||||
// just to format an error message.
|
||||
string body = string.Empty;
|
||||
try
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
body = raw.Length > 1024 ? raw[..1024] + "…" : raw;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Body unreadable: fall back to the default message.
|
||||
}
|
||||
|
||||
var msg = body.Length > 0
|
||||
? $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}"
|
||||
: $"{(int)response.StatusCode} {response.ReasonPhrase}";
|
||||
throw new HttpRequestException(msg, inner: null, statusCode: response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lock the refresh path so concurrent callers don't each rotate
|
||||
/// the refresh token (which Auth0 invalidates on first use).
|
||||
|
|
|
|||
|
|
@ -242,4 +242,89 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_from_PostIt_shape_returns_201_not_400()
|
||||
{
|
||||
// Regression test for the "Save" button in PostIt: from the
|
||||
// user's point of view, they type a Title and an Article in
|
||||
// the editor pane and tap "Save". The VM serialises the
|
||||
// SelectedPost via JsonContent.Create (camelCase, System.Text.Json
|
||||
// defaults) and POSTs it to /api/v1/blog. This test sends
|
||||
// exactly that payload — same fields, same types, same
|
||||
// serialiser (PostAsJsonAsync is wired to the same
|
||||
// System.Net.Http.Json pipeline that YavscApiClient uses on
|
||||
// the PostIt side) — and asserts that the server accepts it
|
||||
// with 201 Created, not 400 BadRequest. If the controller's
|
||||
// ModelState validation starts rejecting the PostIt payload
|
||||
// (missing field, wrong casing, etc.), this test fails
|
||||
// before the regression reaches a user.
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// Mirrors what MainPageViewModel.Save builds: a BlogPost with
|
||||
// Id=0 (so the controller treats it as a create), Title and
|
||||
// Article filled in by the user, and DateCreated/DateModified
|
||||
// stamped by the VM. AuthorId is what the OIDC sub resolves
|
||||
// to in the test fixture.
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Mon premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu du billet de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||
|
||||
// Dump the body on failure so the test name + the response
|
||||
// payload are enough to start a fix; the framework's
|
||||
// assertion message is otherwise opaque (just "Expected
|
||||
// Created, got BadRequest").
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Fail(string.Format("Expected 201 Created, got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_with_empty_title_returns_400()
|
||||
{
|
||||
// Mirrors the buggy branch in MainPageViewModel.Save: when
|
||||
// the user taps "Save" without a SelectedPost (e.g. they
|
||||
// typed into the editor without first clicking an item in
|
||||
// the list, so the {Binding SelectedPost.Title, Mode=TwoWay}
|
||||
// XAML binding had no target and the keystrokes were
|
||||
// silently dropped), the VM builds a BlogPost with
|
||||
// Title = string.Empty and POSTs it. BlogPost.Title carries
|
||||
// [Required] → ModelState.IsValid fails → 400 BadRequest.
|
||||
// This is the regression we are hunting. The 400 is
|
||||
// expected here: the test pins the *current* controller
|
||||
// behaviour so a future change that, say, makes Title
|
||||
// nullable in the model or drops [Required], triggers a
|
||||
// conscious update of the test (and probably of the VM).
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = string.Empty,
|
||||
AuthorId = "tester",
|
||||
Article = "Article non vide, mais titre vide.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Fail(string.Format("Expected 400 BadRequest (empty Title is invalid), got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue