diff --git a/src/PostIt.Tests/BlogPostAuthorDtoTests.cs b/src/PostIt.Tests/BlogPostAuthorDtoTests.cs new file mode 100644 index 00000000..895f220e --- /dev/null +++ b/src/PostIt.Tests/BlogPostAuthorDtoTests.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using Yavsc.Blogspot; + +namespace PostIt.Tests; + +/// +/// Round-trip tests for the wire shape of a blog post as +/// serialised by Yavsc.Blogs and consumed by PostIt. +/// +/// +/// Background: in 1.0.7, BlogPostDto.Author was typed as +/// the abstract interface IApplicationUser. System.Text.Json +/// cannot materialise an interface without a polymorphic +/// converter, so the "load posts" call from PostIt crashed when +/// the server returned a post with a populated Author +/// object. The fix replaced IApplicationUser with a thin +/// concrete DTO, BlogPostAuthorDto, embedded directly in +/// BlogPostDto.Author. +/// +/// +/// +/// These tests pin the wire shape: a JSON document with an +/// Author object must deserialise without throwing and +/// must round-trip the three fields PostIt exposes in the UI +/// (Id, UserName, Avatar). They are intentionally placed in +/// PostIt.Tests — the client-side assembly — so the +/// regression is caught at the deserialisation boundary, where +/// it actually manifested in production. +/// +/// +public class BlogPostAuthorDtoTests +{ + private static readonly JsonSerializerOptions CaseInsensitiveJson + = new() { PropertyNameCaseInsensitive = true }; + + [Fact] + public void BlogPostDto_deserialises_with_populated_author() + { + // A representative JSON shape the server would emit for + // GET /api/BlogApi. The Author object is fully populated + // — that's the shape that used to break deserialisation + // when Author was typed as the abstract IApplicationUser + // interface. + var json = """ + { + "id": 42, + "title": "Premier billet", + "article": "Contenu", + "photo": null, + "dateCreated": "2026-08-01T12:00:00Z", + "dateModified": "2026-08-02T12:00:00Z", + "userCreated": "alice", + "userModified": "alice", + "authorId": "u-alice", + "isPublished": true, + "author": { + "id": "u-alice", + "userName": "alice", + "avatar": "/avatars/alice.png" + } + } + """; + + var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); + + Assert.NotNull(post); + Assert.Equal(42, post!.Id); + Assert.Equal("Premier billet", post.Title); + Assert.Equal("u-alice", post.AuthorId); + Assert.True(post.IsPublished); + + // The actual regression coverage: Author must + // materialise as a concrete DTO, not be left null because + // of a JsonException on IApplicationUser. + Assert.NotNull(post.Author); + Assert.Equal("u-alice", post.Author!.Id); + Assert.Equal("alice", post.Author.UserName); + Assert.Equal("/avatars/alice.png", post.Author.Avatar); + } + + [Fact] + public void BlogPostDto_deserialises_when_author_is_null() + { + // The server is allowed to omit Author (the field is + // nullable on the wire — it maps to a navigation + // property that may not have been Included). The client + // must accept that shape without throwing. + var json = """ + { + "id": 7, + "title": "Sans auteur", + "article": null, + "photo": null, + "dateCreated": "2026-08-01T12:00:00Z", + "dateModified": "2026-08-01T12:00:00Z", + "userCreated": "system", + "userModified": "system", + "authorId": "system", + "isPublished": false, + "author": null + } + """; + + var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); + + Assert.NotNull(post); + Assert.Null(post!.Author); + Assert.Equal("system", post.AuthorId); + } + + [Fact] + public void BlogPostDto_deserialises_when_author_field_is_missing() + { + // Forward-compatibility: an older server that doesn't + // emit the Author field at all. Should not throw. + var json = """ + { + "id": 9, + "title": "Ancien format", + "article": "Pas d'auteur dans la charge utile", + "photo": null, + "dateCreated": "2026-07-01T12:00:00Z", + "dateModified": "2026-07-01T12:00:00Z", + "userCreated": "bob", + "userModified": "bob", + "authorId": "u-bob", + "isPublished": true + } + """; + + var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); + + Assert.NotNull(post); + Assert.Null(post!.Author); + } + + [Fact] + public void BlogPostAuthorDto_serialises_back_to_expected_json_shape() + { + // Pin the wire shape on the way out too. The server + // builds BlogPostAuthorDto from an ApplicationUser and + // PostIt receives it as JSON; if the field names + // change (e.g. case) the round-trip on the client side + // is what would silently break. + // + // The server emits camelCase (ASP.NET Core's Web + // defaults — PropertyNamingPolicy = CamelCase). We + // mirror that here so the test reflects what the wire + // actually looks like. PropertyNameCaseInsensitive on + // the client deserialiser means we don't have to + // hardcode the casing for the inbound assertions. + var author = new BlogPostAuthorDto + { + Id = "u-alice", + UserName = "alice", + Avatar = "/avatars/alice.png" + }; + + var json = JsonSerializer.Serialize(author, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("id", out _)); + Assert.True(root.TryGetProperty("userName", out _)); + Assert.True(root.TryGetProperty("avatar", out _)); + } +} diff --git a/src/Yavsc.Abstract/Blogspot/BlogPost.cs b/src/Yavsc.Abstract/Blogspot/BlogPost.cs index 0f88fdf8..2406fb2a 100644 --- a/src/Yavsc.Abstract/Blogspot/BlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/BlogPost.cs @@ -1,5 +1,4 @@ using System; -using Yavsc.Abstract.Identity; using Yavsc.Abstract.Identity.Security; namespace Yavsc.Blogspot; @@ -8,7 +7,7 @@ public class BlogPostDto : IBlogPost { public string AuthorId { get; set; } - public IApplicationUser Author { get; set; } + public BlogPostAuthorDto? Author { get; set; } public string Article { get; set ; } public string Photo { get; set ; } diff --git a/src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs b/src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs new file mode 100644 index 00000000..e332822e --- /dev/null +++ b/src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs @@ -0,0 +1,33 @@ +namespace Yavsc.Blogspot; + +/// +/// Minimum-viable author payload embedded in . +/// +/// +/// Before this record existed, BlogPostDto.Author was typed +/// as the abstract interface IApplicationUser. The +/// interface is fine for server-side contract (we have a concrete +/// entity that implements it) but System.Text.Json cannot +/// materialise an interface without a polymorphic converter +/// configured on both ends. PostIt would crash on load-posts +/// because the JSON contained an Author object that the +/// client could not deserialise. +/// +/// +/// +/// This record is the wire shape: Id for "go to author +/// profile", UserName for "by @username", Avatar +/// for the round badge next to the title. The server-side +/// BlogPost entity (Yavsc.Server.Models.Blog) keeps +/// its full ApplicationUser navigation property for +/// permission checks and authorisation; the DTO is built on +/// demand by the controller / service layer when the post is +/// served to the wire. +/// +/// +public sealed record BlogPostAuthorDto +{ + public string Id { get; init; } = string.Empty; + public string? UserName { get; init; } + public string? Avatar { get; init; } +} diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs index 5287685d..6090fca2 100644 --- a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs @@ -1,7 +1,6 @@ -using Yavsc.Abstract.Identity; using Yavsc.Abstract.Identity.Security; using Yavsc.Interfaces; @@ -9,6 +8,11 @@ namespace Yavsc.Blogspot { public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle { - IApplicationUser Author { get; } + // Typed as a concrete wire DTO (not the IApplicationUser + // interface) so System.Text.Json can materialise it on the + // client without a polymorphic converter. The server-side + // BlogPost entity implements this getter by mapping its + // ApplicationUser navigation to a BlogPostAuthorDto. + BlogPostAuthorDto? Author { get; } } } diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs index 213910cd..be9fe7fa 100644 --- a/src/Yavsc.Server/Models/Blog/BlogPost.cs +++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs @@ -107,6 +107,32 @@ namespace Yavsc.Models.Blog [NotMapped] public bool IsPublished { get; set; } - IApplicationUser IBlogPost.Author => Author; + /// + /// Explicit interface implementation of + /// . The underlying + /// navigation property is + /// (an ApplicationUser entity), but the wire + /// DTO is a thin with + /// only the fields the client UI consumes. We project + /// on demand so EF can lazy-load the navigation + /// without forcing an eager join on every read. + /// Returns null when the navigation hasn't been + /// loaded (caller should pre-Include Author if + /// they need it). + /// + BlogPostAuthorDto? IBlogPost.Author + { + get + { + var a = Author; + if (a == null) return null; + return new BlogPostAuthorDto + { + Id = a.Id, + UserName = a.UserName, + Avatar = a.Avatar + }; + } + } } } diff --git a/yavsc.sln b/yavsc.sln index fafdff89..7d972ade 100644 --- a/yavsc.sln +++ b/yavsc.sln @@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Ya EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -215,6 +217,18 @@ Global {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x64.Build.0 = Release|Any CPU {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x86.ActiveCfg = Release|Any CPU {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x86.Build.0 = Release|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Debug|x64.ActiveCfg = Debug|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Debug|x64.Build.0 = Debug|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Debug|x86.ActiveCfg = Debug|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Debug|x86.Build.0 = Debug|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|Any CPU.Build.0 = Release|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x64.ActiveCfg = Release|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x64.Build.0 = Release|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.ActiveCfg = Release|Any CPU + {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -235,5 +249,6 @@ Global {4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} + {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} EndGlobalSection EndGlobal