diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index cb63ed6d3..5c5fb2564 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -68,6 +68,7 @@ diff --git a/src/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs b/src/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs new file mode 100644 index 000000000..fa1b9c379 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs @@ -0,0 +1,164 @@ +using System.Security.Claims; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Blogspot; +using Yavsc.Models; +using Yavsc.Models.Blog; +using Yavsc.Blogs.Tests.Fixtures; + +namespace Yavsc.Blogs.Tests; + +/// +/// Direct service-level tests for . +/// +/// The HTTP tests in all drive the +/// [Authorize("BlogScope")]-protected BlogApiController, +/// so the request is always authenticated by the time it reaches the +/// service. The MVC BlogspotController.Index action, by +/// contrast, is [AllowAnonymous] — that is the one caller that +/// actually exercises the unauthenticated branch of +/// , the branch that reads from +/// blogSpotPublications and projects to p.Post. +/// +/// These tests pin that behaviour at the service layer: a +/// BlogPost that has a BlogSpotPublication row is +/// listed for an anonymous viewer, and one without is not. Seeding +/// runs against the same shared SQLite store as the rest of the +/// Yavsc Blogs collection, so the real +/// (registered by +/// ) is resolved from DI and run +/// end-to-end — no mocking. +/// +[Collection("Yavsc Blogs")] +public sealed class BlogSpotServiceIndexTests : IClassFixture +{ + private readonly BlogsWebServerFixture _fixture; + + public BlogSpotServiceIndexTests(BlogsWebServerFixture fixture) + { + _fixture = fixture; + } + + /// An unauthenticated principal: a + /// with no authentication type has + /// == false, so + /// takes its anonymous + /// branch. This mirrors what the JwtBearer middleware hands the + /// MVC [AllowAnonymous] action when no bearer token is + /// presented. + private static ClaimsPrincipal Anonymous() + => new(new ClaimsIdentity()); + + /// Resolve the real from the + /// fixture's DI container and run against it, + /// disposing the request scope (and its scoped DbContext) afterwards. + /// The service itself is not ; the scope is, + /// so the scope is what we must dispose to avoid leaking a DbContext + /// against the shared SQLite connection across tests. + private async Task WithServiceAsync( + Func> body) + { + using var scope = _fixture.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + return await body(service); + } + + private void Publish(long postId) + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // The publication flag is the existence of a row in + // blogSpotPublications — same write SetPublishAsync performs + // in production. Inserting it directly is the most faithful + // way to seed "this post is published" without routing + // through the authorize-gated PUT/publish endpoint. + db.blogSpotPublications.Add(new BlogSpotPublication { PostId = postId }); + db.SaveChanges(); + } + + [Fact] + public async Task Index_lists_a_published_post_for_anonymous_access() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + var publishedId = _fixture.SeedBlogPost("alice", "Published for all"); + + Publish(publishedId); + + var posts = await WithServiceAsync(async svc => + { + var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25); + return result.OfType().ToArray(); + }); + + var listed = Assert.Single(posts); + Assert.Equal("Published for all", listed.Title); + // The service serves IsPublished from the + // blogSpotPublications table — the anonymous branch does + // not Include the Publication navigation, so this proves + // the value is hydrated by the service, not derived from + // the EF graph. + Assert.True(listed.IsPublished); + } + + [Fact] + public async Task Index_serves_IsPublished_false_for_a_draft_for_anonymous_access() + { + // Anonymous viewers never receive a draft (the branch + // queries blogSpotPublications), so this is checked through + // the authenticated branch: the service must still serve + // IsPublished == false for a post with no publication row, + // not the default of a freshly loaded entity. + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + var draftId = _fixture.SeedBlogPost("alice", "Draft, not published"); + + var viewer = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("sub", "alice")], authenticationType: "Bearer")); + + var posts = await WithServiceAsync(async svc => + { + var result = await svc.Index(viewer, id: "", skip: 0, take: 25); + return result.OfType().ToArray(); + }); + + var draft = Assert.Single(posts); + Assert.Equal(draftId, draft.Id); + Assert.False(draft.IsPublished); + } + + [Fact] + public async Task Index_does_not_list_an_unpublished_post_for_anonymous_access() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + var publishedId = _fixture.SeedBlogPost("alice", "Published for all"); + _fixture.SeedBlogPost("alice", "Draft, not published"); + + // Only the first post is published; the draft has no + // BlogSpotPublication row. + Publish(publishedId); + + var titles = await WithServiceAsync(async svc => + { + var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25); + return result.Select(p => p.Title).ToArray(); + }); + + Assert.Contains("Published for all", titles); + Assert.DoesNotContain("Draft, not published", titles); + } + + [Fact] + public async Task Index_returns_an_empty_list_for_anonymous_access_when_nothing_is_published() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + // A draft with no publication row: invisible to anonymous. + _fixture.SeedBlogPost("alice", "Draft, not published"); + + var result = await WithServiceAsync(svc => + svc.Index(Anonymous(), id: "", skip: 0, take: 25)); + + Assert.Empty(result); + } +} \ No newline at end of file diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs index d948e8880..e05bf5600 100644 --- a/src/Yavsc.Server/Models/Blog/BlogPost.cs +++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs @@ -103,13 +103,19 @@ namespace Yavsc.Models.Blog /// Whether this post is published. Not a column: the /// existence of a row in BlogSpotPublication /// is the source of truth. EF skips this property via - /// [NotMapped] so no migration is needed. The - /// service hydrates it after each fetch (single bulk - /// lookup, not N+1) and it surfaces through the wire - /// as part of the JSON-serialized BlogPost. + /// [NotMapped] so no migration is needed. + /// + /// This is a value BlogSpotService owns and + /// sets explicitly on every post it returns — a single + /// bulk lookup against blogSpotPublications for a + /// list, an AnyAsync for a single post — so the + /// flag is correct whether or not the EF query Included + /// the navigation. Clients + /// (PostIt) read it as-is off the wire and must not + /// recompute it. /// [NotMapped] - public bool IsPublished { get => Publication != null; } + public bool IsPublished { get; set; } [JsonIgnore] /// diff --git a/src/Yavsc.Server/Services/BlogSpotService.cs b/src/Yavsc.Server/Services/BlogSpotService.cs index bd182c437..5d2ad5eaa 100644 --- a/src/Yavsc.Server/Services/BlogSpotService.cs +++ b/src/Yavsc.Server/Services/BlogSpotService.cs @@ -112,6 +112,13 @@ public class BlogSpotService post.AuthorId = userId; _context.BlogSpot.Add(post); _context.SaveChanges(userId); + // A freshly created post has no BlogSpotPublication row, + // so the server-side truth is "not published". The client + // may have sent isPublished:true in the POST body, but + // publication is toggled separately via SetPublishAsync; + // overwrite whatever was deserialized so the create + // response reflects the actual state. + post.IsPublished = false; return post; } @@ -151,6 +158,12 @@ public class BlogSpotService ScrubAclForViewer(blog, user); + // IsPublished is a value the service serves to clients; + // set it from the loaded Publication navigation (Details + // Includes it above) rather than leaving it at the + // default false a deserialized entity would have. + blog.IsPublished = blog.Publication != null; + foreach (var c in blog.Comments) c.Author = _context.Users.First(u => u.Id == c.AuthorId); @@ -247,7 +260,16 @@ public class BlogSpotService var materialized = posts.ToList(); - foreach (var post in materialized.OfType()) + // IsPublished is a value the service owns: hydrate it + // from blogSpotPublications in one bulk lookup so it is + // correct whether or not the branch above Included the + // Publication navigation (the anonymous branch does + // not). Without this, a freshly deserialized list would + // report IsPublished == false for every post. + var blogPosts = materialized.OfType().ToList(); + await HydrateIsPublishedAsync(blogPosts); + + foreach (var post in blogPosts) ScrubAclForViewer(post, user); return materialized @@ -291,10 +313,17 @@ public class BlogSpotService public async Task GetBlogPostAsync(long value) { - return await _context.BlogSpot + var blog = await _context.BlogSpot .Include(b => b.Author) .Include(b => b.ACL) .SingleOrDefaultAsync(x => x.Id == value); + if (blog != null) + // Serve the publication state from its source of truth + // so the delete response's isPublished is correct — the + // query above does not Include the Publication navigation. + blog.IsPublished = await _context.blogSpotPublications + .AnyAsync(p => p.PostId == blog.Id); + return blog; } public async Task SetPublishAsync(ClaimsPrincipal user, long postId, bool publish) @@ -328,6 +357,29 @@ public class BlogSpotService post.ACL = new List(); } + /// + /// Populate for each post in + /// from the blogSpotPublications + /// table — a single bulk lookup, not one query per post. The + /// IsPublished flag is a wire-only value the service owns: + /// clients (PostIt) read it as-is off the JSON response rather + /// than computing it themselves. Call this on every collection + /// the service returns to a caller, so the flag is correct + /// regardless of whether the EF query Included the + /// navigation. + /// + private async Task HydrateIsPublishedAsync(IReadOnlyList posts) + { + if (posts.Count == 0) return; + var ids = posts.Select(p => p.Id).ToArray(); + var publishedIds = await _context.blogSpotPublications + .Where(p => ids.Contains(p.PostId)) + .Select(p => p.PostId) + .ToHashSetAsync(); + foreach (var post in posts) + post.IsPublished = publishedIds.Contains(post.Id); + } + private static bool IsOwner(BlogPost post, ClaimsPrincipal? user) { if (user?.Identity?.IsAuthenticated != true) return false;