From 384f6bc3005995f49072b316024efa13b8cf1fb8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 25 Sep 2026 15:11:25 +0100 Subject: [PATCH] =?UTF-8?q?IBlogPost=20porte=20IsPublished=20:=20le=20endp?= =?UTF-8?q?oint=20liste=20ne=20le=20l=C3=A2che=20plus=20sur=20le=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le blog API (Yavsc.Blogs/Program.cs) sérialise en System.Text.Json (.AddControllers() par défaut), et BlogApiController.GetBlogspot retourne IEnumerable. STJ n'émet que les membres du type déclaré (l'interface), donc IsPublished — qui n'était que sur les classes concrètes BlogPost / BlogPostDto — était dropped du payload liste. PostIt lisait alors IsPublished=false pour tout billet, y compris publiés (cas prod /BlogSpot/Details/13). Fix : déclarer IsPublished sur IBlogPost. BlogPost et BlogPostDto l'implémentent déjà. Tests de wire-shape (IBlogPostWireShapeTests) ajoutés pour verrouiller le contrat au niveau du boundary. Co-Authored-By: Claude Code --- src/Yavsc.Abstract/Blogspot/IBlogPost.cs | 18 ++++ .../IBlogPostWireShapeTests.cs | 87 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs index 38a50b78f..c5a098e07 100644 --- a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs @@ -15,5 +15,23 @@ namespace Yavsc.Blogspot // BlogPost entity implements this getter by mapping its // ApplicationUser navigation to a BlogPostAuthorDto. BlogPostAuthorDto? Author { get; } + + /// + /// Whether this post is published. The server derives this + /// from the existence of a row in BlogSpotPublication + /// and serves it on the wire so clients (PostIt) can render + /// the current publication state without a follow-up request. + /// + /// This MUST be declared on the interface — not just on + /// the concrete BlogPost entity / BlogPostDto — + /// because the blog API's list endpoint returns + /// IEnumerable<IBlogPost>, and the blog API host + /// serialises with System.Text.Json, which emits only the + /// members of the declared (interface) type. When + /// IsPublished lived only on the concrete classes, STJ + /// dropped it from the list payload and PostIt read + /// false for every post, including published ones. + /// + bool IsPublished { get; set; } } } diff --git a/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs b/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs new file mode 100644 index 000000000..bb38725cd --- /dev/null +++ b/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Yavsc.Blogspot; +using Yavsc.Models.Blog; + +namespace Yavsc.Blogs.Tests; + +/// +/// Wire-shape tests pinning that +/// is carried on the JSON the blog API list endpoint emits. +/// +/// The blog API host (Yavsc.Blogs/Program.cs) serialises +/// with System.Text.Json (the .AddControllers() default — no +/// AddNewtonsoftJson), and BlogApiController.GetBlogspot +/// returns IEnumerable<IBlogPost>. System.Text.Json emits +/// only the members of the declared type, so a property that +/// exists solely on the concrete BlogPost entity is dropped from +/// the payload. IsPublished used to be one such property: it was +/// on BlogPost and BlogPostDto but not on +/// , so the list response omitted it and PostIt +/// read IsPublished == false for every post — including +/// published ones. +/// +/// These tests reproduce that serialisation path directly +/// (declared type IEnumerable<IBlogPost> through +/// ) and assert +/// isPublished survives the round trip. They live in +/// Yavsc.Blogs.Tests — the assembly that owns the blog API +/// contract — so a regression at the wire boundary is caught here, +/// where it manifested in production (PostIt showing a published +/// post's status as false). +/// +public sealed class IBlogPostWireShapeTests +{ + private static readonly JsonSerializerOptions WebDefaults + = new(JsonSerializerDefaults.Web); + + [Fact] + public void IsPublished_is_emitted_when_list_is_serialised_as_IEnumerable_IBlogPost() + { + var post = new BlogPost + { + Id = 13, + Title = "Published", + AuthorId = "alice", + Article = "x", + }; + post.IsPublished = true; + + // Declared type mirrors GetBlogspot's return: IEnumerable. + IEnumerable list = new List { post }; + + // Serialize with the DECLARED type, the way ASP.NET Core's + // SystemTextJsonOutputFormatter hands context.ObjectType (the + // action return type) to JsonSerializer.Serialize. + var json = JsonSerializer.Serialize(list, typeof(IEnumerable), + WebDefaults); + + using var doc = JsonDocument.Parse(json); + var element = doc.RootElement[0]; + // Web defaults use camelCase, so the property is "isPublished". + Assert.True(element.TryGetProperty("isPublished", out var isPublished), + $"isPublished was dropped from the list payload. JSON: {json}"); + Assert.True(isPublished.GetBoolean()); + } + + [Fact] + public void IsPublished_round_trips_through_BlogPostDto_on_the_client() + { + // The shape the client (PostIt) receives and deserialises with + // PropertyNameCaseInsensitive = true (cf. YavscApiClient). + var json = """ + [{ + "id": 13, + "title": "Published", + "article": "x", + "authorId": "alice", + "isPublished": true + }] + """; + + var posts = JsonSerializer.Deserialize>(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + var post = Assert.Single(posts); + Assert.True(post.IsPublished); + } +} \ No newline at end of file