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