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);
}
}