Publish, bug fix
This commit is contained in:
parent
1773c5aaea
commit
db5c202918
4 changed files with 230 additions and 7 deletions
|
|
@ -68,6 +68,7 @@
|
|||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarToggleButton Label="Publié"
|
||||
Command="{Binding TogglePublishCommand}"
|
||||
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
|
||||
<CommandBarToggleButton.Icon>
|
||||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||
|
|
|
|||
164
src/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs
Normal file
164
src/Yavsc.Blogs.Tests/BlogSpotServiceIndexTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Direct service-level tests for <see cref="BlogSpotService.Index"/>.
|
||||
///
|
||||
/// <para>The HTTP tests in <see cref="BlogApiTests"/> all drive the
|
||||
/// <c>[Authorize("BlogScope")]</c>-protected <c>BlogApiController</c>,
|
||||
/// so the request is always authenticated by the time it reaches the
|
||||
/// service. The MVC <c>BlogspotController.Index</c> action, by
|
||||
/// contrast, is <c>[AllowAnonymous]</c> — that is the one caller that
|
||||
/// actually exercises the unauthenticated branch of
|
||||
/// <see cref="BlogSpotService.Index"/>, the branch that reads from
|
||||
/// <c>blogSpotPublications</c> and projects to <c>p.Post</c>.</para>
|
||||
///
|
||||
/// <para>These tests pin that behaviour at the service layer: a
|
||||
/// <c>BlogPost</c> that has a <c>BlogSpotPublication</c> 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
|
||||
/// <c>Yavsc Blogs</c> collection, so the real
|
||||
/// <see cref="BlogSpotService"/> (registered by
|
||||
/// <see cref="BlogsWebServerFixture"/>) is resolved from DI and run
|
||||
/// end-to-end — no mocking.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class BlogSpotServiceIndexTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public BlogSpotServiceIndexTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>An unauthenticated principal: a <see cref="ClaimsIdentity"/>
|
||||
/// with no authentication type has
|
||||
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false, so
|
||||
/// <see cref="BlogSpotService.Index"/> takes its anonymous
|
||||
/// branch. This mirrors what the JwtBearer middleware hands the
|
||||
/// MVC <c>[AllowAnonymous]</c> action when no bearer token is
|
||||
/// presented.</summary>
|
||||
private static ClaimsPrincipal Anonymous()
|
||||
=> new(new ClaimsIdentity());
|
||||
|
||||
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
|
||||
/// fixture's DI container and run <paramref name="body"/> against it,
|
||||
/// disposing the request scope (and its scoped DbContext) afterwards.
|
||||
/// The service itself is not <see cref="IDisposable"/>; the scope is,
|
||||
/// so the scope is what we must dispose to avoid leaking a DbContext
|
||||
/// against the shared SQLite connection across tests.</summary>
|
||||
private async Task<T> WithServiceAsync<T>(
|
||||
Func<BlogSpotService, Task<T>> body)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<BlogSpotService>();
|
||||
return await body(service);
|
||||
}
|
||||
|
||||
private void Publish(long postId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
// 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<BlogPost>().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<BlogPost>().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);
|
||||
}
|
||||
}
|
||||
|
|
@ -103,13 +103,19 @@ namespace Yavsc.Models.Blog
|
|||
/// Whether this post is published. Not a column: the
|
||||
/// existence of a row in <c>BlogSpotPublication</c>
|
||||
/// is the source of truth. EF skips this property via
|
||||
/// <c>[NotMapped]</c> 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 <c>BlogPost</c>.
|
||||
/// <c>[NotMapped]</c> so no migration is needed.
|
||||
///
|
||||
/// <para>This is a value <c>BlogSpotService</c> owns and
|
||||
/// sets explicitly on every post it returns — a single
|
||||
/// bulk lookup against <c>blogSpotPublications</c> for a
|
||||
/// list, an <c>AnyAsync</c> for a single post — so the
|
||||
/// flag is correct whether or not the EF query Included
|
||||
/// the <see cref="Publication"/> navigation. Clients
|
||||
/// (PostIt) read it as-is off the wire and must not
|
||||
/// recompute it.</para>
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsPublished { get => Publication != null; }
|
||||
public bool IsPublished { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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<BlogPost>())
|
||||
// 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<BlogPost>().ToList();
|
||||
await HydrateIsPublishedAsync(blogPosts);
|
||||
|
||||
foreach (var post in blogPosts)
|
||||
ScrubAclForViewer(post, user);
|
||||
|
||||
return materialized
|
||||
|
|
@ -291,10 +313,17 @@ public class BlogSpotService
|
|||
|
||||
public async Task<BlogPost?> 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<bool> SetPublishAsync(ClaimsPrincipal user, long postId, bool publish)
|
||||
|
|
@ -328,6 +357,29 @@ public class BlogSpotService
|
|||
post.ACL = new List<CircleAuthorizationToBlogPost>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populate <see cref="BlogPost.IsPublished"/> for each post in
|
||||
/// <paramref name="posts"/> from the <c>blogSpotPublications</c>
|
||||
/// table — a single bulk lookup, not one query per post. The
|
||||
/// <c>IsPublished</c> 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
|
||||
/// <see cref="BlogPost.Publication"/> navigation.
|
||||
/// </summary>
|
||||
private async Task HydrateIsPublishedAsync(IReadOnlyList<BlogPost> 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue