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