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 BlogAclApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
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 = _fixture.CircleId,
BlogPostId = _fixture.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.
using var http = NewClient("alice");
var payload = new PostAccessControlRulePayload
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.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)
{
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
}