fix(blog): replace IApplicationUser Author with concrete BlogPostAuthorDto
System.Text.Json cannot materialise an interface without a
polymorphic converter. Until this commit, BlogPostDto.Author
was typed as the abstract interface IApplicationUser, which
crashed the "load posts" call in PostIt whenever the server
returned a post with a populated Author object (the common
case — GET /api/BlogApi).
Fix:
* Introduce a minimum-viable wire DTO BlogPostAuthorDto in
Yavsc.Abstract.Blogspot (record: Id, UserName, Avatar).
These are the only fields the client UI actually needs;
the server-side ApplicationUser navigation is preserved
for permission checks and authorisation.
* Change IBlogPost.Author and BlogPostDto.Author from
IApplicationUser to BlogPostAuthorDto? (interface change,
breaking). The EF entity BlogPost keeps its full
ApplicationUser navigation property and exposes
IBlogPost.Author via an explicit interface implementation
that projects to BlogPostAuthorDto on demand (so EF can
still lazy-load the navigation without forcing an eager
join on every read).
* Restore the using directive that was accidentally removed
when the BlogPostDto property was rewritten (needed for
ICircleAuthorization in GetACL()).
Regression coverage (the missing test Paul flagged):
* Add BlogPostAuthorDtoTests in PostIt.Tests with four
scenarios that exercise the wire shape on the client side:
- A BlogPostDto JSON with a populated Author round-trips
through JsonSerializer without throwing and the three
fields (Id, UserName, Avatar) survive intact.
- A BlogPostDto JSON with explicit "author": null
deserialises with Author == null.
- A BlogPostDto JSON without any Author field at all
deserialises with Author == null (forward compat).
- The serialised shape of BlogPostAuthorDto uses camelCase
property names (matching the server's Web defaults), so
the field names on the wire don't drift without a test
catching it.
Tests: 55/55 PostIt.Tests (+4 new), 24/24 Yavsc.Blogs.Tests,
44/44 Yavsc.Org.Tests. No regressions.
Side note: yavsc.sln picks up Yavsc.Api.Client (added by
'feat/postit-acl' in 1.0.7 but never registered in the
solution file until now — probably auto-added by a recent
'dotnet build' that discovered the .csproj).
This commit is contained in:
parent
40d992ee18
commit
f36679aa65
6 changed files with 251 additions and 5 deletions
169
src/PostIt.Tests/BlogPostAuthorDtoTests.cs
Normal file
169
src/PostIt.Tests/BlogPostAuthorDtoTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip tests for the wire shape of a blog post as
|
||||||
|
/// serialised by Yavsc.Blogs and consumed by PostIt.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Background: in 1.0.7, <c>BlogPostDto.Author</c> was typed as
|
||||||
|
/// the abstract interface <c>IApplicationUser</c>. 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 <c>Author</c>
|
||||||
|
/// object. The fix replaced <c>IApplicationUser</c> with a thin
|
||||||
|
/// concrete DTO, <c>BlogPostAuthorDto</c>, embedded directly in
|
||||||
|
/// <c>BlogPostDto.Author</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// These tests pin the wire shape: a JSON document with an
|
||||||
|
/// <c>Author</c> 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
|
||||||
|
/// <c>PostIt.Tests</c> — the client-side assembly — so the
|
||||||
|
/// regression is caught at the deserialisation boundary, where
|
||||||
|
/// it actually manifested in production.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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<BlogPostDto>(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<BlogPostDto>(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<BlogPostDto>(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 _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using Yavsc.Abstract.Identity;
|
|
||||||
using Yavsc.Abstract.Identity.Security;
|
using Yavsc.Abstract.Identity.Security;
|
||||||
|
|
||||||
namespace Yavsc.Blogspot;
|
namespace Yavsc.Blogspot;
|
||||||
|
|
@ -8,7 +7,7 @@ public class BlogPostDto : IBlogPost
|
||||||
{
|
{
|
||||||
public string AuthorId { get; set; }
|
public string AuthorId { get; set; }
|
||||||
|
|
||||||
public IApplicationUser Author { get; set; }
|
public BlogPostAuthorDto? Author { get; set; }
|
||||||
|
|
||||||
public string Article { get; set ; }
|
public string Article { get; set ; }
|
||||||
public string Photo { get; set ; }
|
public string Photo { get; set ; }
|
||||||
|
|
|
||||||
33
src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs
Normal file
33
src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace Yavsc.Blogspot;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum-viable author payload embedded in <see cref="BlogPostDto"/>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Before this record existed, <c>BlogPostDto.Author</c> was typed
|
||||||
|
/// as the abstract interface <c>IApplicationUser</c>. 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 <c>Author</c> object that the
|
||||||
|
/// client could not deserialise.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This record is the wire shape: <c>Id</c> for "go to author
|
||||||
|
/// profile", <c>UserName</c> for "by @username", <c>Avatar</c>
|
||||||
|
/// for the round badge next to the title. The server-side
|
||||||
|
/// <c>BlogPost</c> entity (<c>Yavsc.Server.Models.Blog</c>) keeps
|
||||||
|
/// its full <c>ApplicationUser</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BlogPostAuthorDto
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string? UserName { get; init; }
|
||||||
|
public string? Avatar { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
using Yavsc.Abstract.Identity;
|
|
||||||
using Yavsc.Abstract.Identity.Security;
|
using Yavsc.Abstract.Identity.Security;
|
||||||
using Yavsc.Interfaces;
|
using Yavsc.Interfaces;
|
||||||
|
|
||||||
|
|
@ -9,6 +8,11 @@ namespace Yavsc.Blogspot
|
||||||
{
|
{
|
||||||
public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle
|
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,32 @@ namespace Yavsc.Models.Blog
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public bool IsPublished { get; set; }
|
public bool IsPublished { get; set; }
|
||||||
|
|
||||||
IApplicationUser IBlogPost.Author => Author;
|
/// <summary>
|
||||||
|
/// Explicit interface implementation of
|
||||||
|
/// <see cref="IBlogPost.Author"/>. The underlying
|
||||||
|
/// navigation property is <see cref="Author"/>
|
||||||
|
/// (an <c>ApplicationUser</c> entity), but the wire
|
||||||
|
/// DTO is a thin <see cref="BlogPostAuthorDto"/> 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 <c>null</c> when the navigation hasn't been
|
||||||
|
/// loaded (caller should pre-Include <c>Author</c> if
|
||||||
|
/// they need it).
|
||||||
|
/// </summary>
|
||||||
|
BlogPostAuthorDto? IBlogPost.Author
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var a = Author;
|
||||||
|
if (a == null) return null;
|
||||||
|
return new BlogPostAuthorDto
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
UserName = a.UserName,
|
||||||
|
Avatar = a.Avatar
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
yavsc.sln
15
yavsc.sln
|
|
@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Ya
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
@ -235,5 +249,6 @@ Global
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
{4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
{0E471075-DABF-40E9-98B7-1630BEF19145} = {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}
|
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
|
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue