using System.Net; using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; using Yavsc.Abstract.BlogSpot; 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 long CircleId { get; private set; } public long PostId { get; private set; } public BlogAclApiTests(BlogsWebServerFixture fixture) { _fixture = fixture; ResetDatabaseWithAlice(); } /// 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(); CircleId = SeedCircle("alice", "test", isPublic: true); PostId = SeedBlogPost("alice", "Billet ACL test"); } /// 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() { 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, TestContext.Current.CancellationToken); // 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() { // 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"); var payload = new PostAccessControlRulePayload { CircleId = circleId, BlogPostId = postId }; var response = await http.PostAsJsonAsync(BlogAclUrl(), payload, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); } /// /// Payload templates for . /// Each row carries the shape we want to POST; -1L and /// -2L are negative sentinels that the test substitutes /// with the ids of freshly seeded Circle / BlogPost /// rows before sending, so every shape lands against a real /// principal entity and the seeded fixtures are not dead. /// public static IEnumerable BlogAclPayloadsForNever500() { // circleId only (the historical bug shape, 2026-08-21 mercure): // must be rejected, never 500. yield return new PostAccessControlRulePayload?[] { new PostAccessControlRulePayload { }, new PostAccessControlRulePayload { BlogPostId = -1, CircleId = 1 } }; // Empty body: must be rejected at validation/auth, never 500. yield return new PostAccessControlRulePayload?[] { new PostAccessControlRulePayload { BlogPostId = -1, CircleId = -1 } }; // blogPostId only: must be rejected, never 500. yield return new PostAccessControlRulePayload?[] { new PostAccessControlRulePayload { BlogPostId = -2, CircleId = -1 } }; // Explicit BlogPostId = 0 (default(long)): must be rejected, // never 500. This is the precise shape that EF Core's // shaper used to crash on. yield return new PostAccessControlRulePayload?[] { new PostAccessControlRulePayload(), null }; } /// /// Hard rule (Paul, 2026-08-21): a 500 is never acceptable /// [Theory] [MemberData(nameof(BlogAclPayloadsForNever500))] public async Task PostCircleAuthorization_never_returns_500( Dictionary payload) { ResetDatabaseWithAlice(); using var http = NewClient("alice"); var response = await http.PostAsJsonAsync( BlogAclUrl(), payload, TestContext.Current.CancellationToken); Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode); } }