using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
///
/// Behavioural tests for BlogAclApiController.PostCircleAuthorizationToBlogPost:
/// POST /api/v1/blogacl with a JSON body of
/// CircleAuthorizationToBlogPost (CircleId + BlogPostId).
///
/// Same fixture as :
/// provides a SQLite
/// :memory: ApplicationDbContext (so FKs are
/// enforced the way a real relational engine would) and JWT
/// bearer auth via TestTokenIssuer. No mocks — the real
/// DbContext receives the real INSERT attempt.
///
/// The bug being pinned by these tests: the POST endpoint
/// calls _context.CircleAuthorizationToBlogPost.Add(...)
/// then SaveChangesAsync. The entity has a composite
/// key (CircleId + BlogPostId) and two FKs; EF Core refuses
/// the INSERT with
/// System.InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown when
/// attempting to save changes when the principal entities
/// (the existing BlogPost and Circle) are not
/// attached to the DbContext in the same change-tracker graph.
///
[Collection("Yavsc Blogs")]
public sealed class BlogAclApiTests : IClassFixture
{
private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// Reset the in-memory database and seed alice.
/// The shared SQLite :memory: store persists across
/// requests, so each test starts from a clean slate.
private void ResetDatabaseWithAlice()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.SaveChanges();
}
/// Create a circle owned by
/// directly in the SQLite store and return its server-assigned
/// id.
private long SeedCircle(string ownerId, string name, bool isPublic = false)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
/// Create a blog post owned by
/// directly in the SQLite store and return its server-assigned
/// id.
private long SeedBlogPost(string authorId, string title)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
}
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
// The Blogs fixture disables JwtSecurityTokenHandler's
// inbound claim-type remap, so the JWT's "sub" stays "sub"
// rather than being rewritten to ClaimTypes.NameIdentifier.
// The controller, however, reads the user id via
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
// an explicit nameid claim to keep the legacy lookup happy.
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
TestTokenIssuer.Issue(
subject,
extraClaims: new[]
{
new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier,
subject),
}));
return http;
}
/// PostIt sends only the FK ids (CircleId +
/// BlogPostId) plus scalar fields, never the navigation
/// properties Target / Allowed. The controller
/// must accept that shape and persist the ACL row.
[Fact]
public async Task PostCircleAuthorization_returns_201_when_adding_existing_circle_to_existing_post()
{
ResetDatabaseWithAlice();
var circleId = SeedCircle("alice", "Famille");
var postId = SeedBlogPost("alice", "Billet de test");
using var http = NewClient("alice");
// Mirror PostIt's payload: scalar FK ids only, no nav props.
var payload = new CircleAuthorizationToBlogPost
{
CircleId = circleId,
BlogPostId = postId,
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload);
// Expected: 201 Created (per controller line 133: return
// CreatedAtRoute("GetCircleAuthorizationToBlogPost", ...)).
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
///
/// Reproduces the prod 500 logged on 2026-08-21 on mercure:
/// InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown
/// when POSTs the
/// shape { "circleId": <id> } — the exact body the
/// PostIt client builds from
/// (which only carries CircleId). The server deserialises
/// it into , leaves
/// BlogPostId at its default(long) = 0, attaches
/// no Target navigation, and EF Core refuses to INSERT
/// during PrepareToSave(). The fix lives in PostIt
/// (enrich the payload with blogPostId + comment)
/// and on the wire DTO ( must
/// carry those fields); the server validates. Until that ships,
/// this test stays red.
///
[Fact]
public async Task PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test()
{
ResetDatabaseWithAlice();
// The prod circle already exists with Name="test", Public=true,
// owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end.
var circleId = SeedCircle("alice", "test", isPublic: true);
var postId = SeedBlogPost("alice", "Billet ACL test");
using var http = NewClient("alice");
// Exact wire shape PostIt sends today:
// { "circleId": } — no blogPostId, no comment.
var payload = new Dictionary
{
["circleId"] = circleId,
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}