using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Yavsc.Models; using Yavsc.Models.Relationship; using Yavsc.Tests.Shared; using static Yavsc.Constants; namespace Yavsc.Blogs.Tests; /// /// Behavioural tests for the circle-members endpoints on /// CircleApiController: /// GET /api/circle/{id}/members, /// POST /api/circle/{id}/members, /// DELETE /api/circle/{id}/members/{userId}. /// /// Same fixture as : /// provides an in-memory /// ApplicationDbContext, JWT bearer auth with HS256, /// and the production BlogScope policy. Tests use /// TestTokenIssuer to mint tokens whose sub /// claim identifies the caller. /// /// Test users (alice, bob) are seeded /// directly via : /// the Blogs fixture doesn't stand up /// UserManager<ApplicationUser>, so we go /// through the DbContext the same way the production code /// would. /// [Collection("JwtClaimMapping")] public sealed class CircleMembersApiTests : IClassFixture { private readonly BlogsWebServerFixture _fixture; public CircleMembersApiTests(BlogsWebServerFixture fixture) { _fixture = fixture; } /// Reset the in-memory database and seed /// alice + bob. UseInMemoryDatabase /// shares its store across the fixture lifetime, so each /// test starts from a clean slate. private void ResetDatabaseWithUsers() { 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.Users.Add(new ApplicationUser { Id = "bob", UserName = "bob", Email = "bob@example.com", EmailConfirmed = true, FullName = "Bob Martin", Avatar = "/avatars/bob.png", }); db.SaveChanges(); } /// Create a circle owned by /// directly in the in-memory store and return its server-assigned /// id. The tests below use this to bypass the controller's POST /// (which is already covered by other tests on the branch); /// the focus here is the members endpoints. private long SeedCircle(string ownerId, string name) { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var circle = new Circle { OwnerId = ownerId, Name = name }; db.Circle.Add(circle); db.SaveChanges(); return circle.Id; } private string MembersUrl(long circleId) => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/circle/{circleId}/members"; 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://"))) }; http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", TestTokenIssuer.Issue(subject)); return http; } [Fact] public async Task GetMembers_returns_200_with_empty_list_when_no_members() { ResetDatabaseWithUsers(); var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("alice"); var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(0, doc.RootElement.GetArrayLength()); } [Fact] public async Task PostMember_returns_201_then_Get_returns_the_member() { ResetDatabaseWithUsers(); var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("alice"); var postResponse = await http.PostAsJsonAsync( MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(1, doc.RootElement.GetArrayLength()); var member = doc.RootElement[0]; Assert.Equal("bob", member.GetProperty("id").GetString()); Assert.Equal("bob", member.GetProperty("userName").GetString()); Assert.Equal("Bob Martin", member.GetProperty("fullName").GetString()); } [Fact] public async Task PostMember_returns_409_when_user_already_in_circle() { ResetDatabaseWithUsers(); var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("alice"); var first = await http.PostAsJsonAsync( MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, first.StatusCode); var second = await http.PostAsJsonAsync( MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } [Fact] public async Task DeleteMember_returns_200_then_Get_does_not_include_member() { ResetDatabaseWithUsers(); var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("alice"); await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" }, TestContext.Current.CancellationToken); var deleteResponse = await http.DeleteAsync( $"{MembersUrl(circleId)}/bob", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); var getResponse = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal(0, doc.RootElement.GetArrayLength()); } [Fact] public async Task GetMembers_returns_404_when_circle_not_owned_by_caller() { ResetDatabaseWithUsers(); // Alice's circle, Bob tries to read its members. var circleId = SeedCircle("alice", "Famille"); using var http = NewClient("bob"); var response = await http.GetAsync(MembersUrl(circleId), TestContext.Current.CancellationToken); // 404, not 403 — the controller deliberately avoids leaking // the existence of someone else's circle. Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } }