using System.Net; using System.Net.Http.Json; using FamilyNido.Domain.Families; using FluentAssertions; using Microsoft.EntityFrameworkCore; namespace FamilyNido.Tests.Integration; /// /// Coverage for /api/wall: create, update, delete, comment, reaction toggle, /// pin/unpin, markdown preview. Permission rules around author-or-admin are /// also exercised. /// public sealed class WallTests : IntegrationTestBase { public WallTests(IntegrationFixture fixture) : base(fixture) { } private async Task SeedAdminAsync() { var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); await TestSeed.LoginAsync(Client, "dan@example.com"); return handle; } [Fact] public async Task Create_persists_a_message_and_renders_html() { await SeedAdminAsync(); var resp = await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Hola **mundo**" }); resp.StatusCode.Should().Be(HttpStatusCode.Created); var dto = await ReadAsync(resp); dto!.Text.Should().Contain("Hola"); dto.TextHtml.Should().Contain(""); } [Fact] public async Task Update_changes_text_when_caller_is_author() { await SeedAdminAsync(); var created = await ReadAsync( await Client.PostAsJsonAsync("/api/wall/messages", new { text = "v1" })); var resp = await Client.PatchAsJsonAsync( $"/api/wall/messages/{created!.Id}", new { text = "v2 editado" }); resp.StatusCode.Should().Be(HttpStatusCode.OK); var saved = await WithDbAsync(db => db.WallMessages.Where(m => m.Id == created.Id).Select(m => m.Text).FirstAsync()); saved.Should().Contain("v2 editado"); } [Fact] public async Task Update_rejects_non_author_non_admin() { // Dan (admin) creates the message. var dan = await SeedAdminAsync(); var created = await ReadAsync( await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Original de Dan" })); // Create a second user (Adult) who is not the author and not admin. await WithDbAsync(db => TestSeed.SeedFamilyAsync( db, Fixture.Factory.Services, email: "alice@example.com", displayName: "Alice", role: FamilyRole.Adult)); // The second seed created a *new* family — back the test out and put // Alice in Dan's family instead so the message is visible. await WithDbAsync(async db => { var alice = await db.Users.SingleAsync(u => u.Email == "alice@example.com"); var aliceMember = await db.FamilyMembers.SingleAsync(m => m.UserId == alice.Id); aliceMember.FamilyId = dan.Family.Id; // Drop the second family so it doesn't pollute future queries. var spareFamily = await db.Families.SingleAsync(f => f.Id != dan.Family.Id); db.Families.Remove(spareFamily); await db.SaveChangesAsync(); }); // Re-login as Alice. var aliceClient = Fixture.Factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { HandleCookies = true, AllowAutoRedirect = false, }); await TestSeed.LoginAsync(aliceClient, "alice@example.com"); var resp = await aliceClient.PatchAsJsonAsync( $"/api/wall/messages/{created!.Id}", new { text = "Hijack" }); resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] public async Task Delete_removes_the_message() { await SeedAdminAsync(); var created = await ReadAsync( await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Para borrar" })); var resp = await Client.DeleteAsync($"/api/wall/messages/{created!.Id}"); resp.StatusCode.Should().Be(HttpStatusCode.NoContent); var stillThere = await WithDbAsync(db => db.WallMessages.AnyAsync(m => m.Id == created.Id)); stillThere.Should().BeFalse(); } [Fact] public async Task Pin_then_unpin_round_trips() { await SeedAdminAsync(); var created = await ReadAsync( await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Para fijar" })); var pin = await Client.PostAsync($"/api/wall/messages/{created!.Id}/pin", content: null); pin.StatusCode.Should().Be(HttpStatusCode.OK); (await WithDbAsync(db => db.WallMessages.Where(m => m.Id == created.Id).Select(m => m.IsPinned).FirstAsync())) .Should().BeTrue(); var unpin = await Client.PostAsync($"/api/wall/messages/{created.Id}/unpin", content: null); unpin.StatusCode.Should().Be(HttpStatusCode.OK); (await WithDbAsync(db => db.WallMessages.Where(m => m.Id == created.Id).Select(m => m.IsPinned).FirstAsync())) .Should().BeFalse(); } [Fact] public async Task Comment_persists_and_delete_removes_it() { await SeedAdminAsync(); var msg = await ReadAsync( await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Padre" })); var addResp = await Client.PostAsJsonAsync( $"/api/wall/messages/{msg!.Id}/comments", new { text = "Comentario" }); addResp.IsSuccessStatusCode.Should().BeTrue(); var comment = await ReadAsync(addResp); comment!.Text.Should().Contain("Comentario"); var del = await Client.DeleteAsync($"/api/wall/comments/{comment.Id}"); del.StatusCode.Should().Be(HttpStatusCode.NoContent); var stillThere = await WithDbAsync(db => db.WallComments.AnyAsync(c => c.Id == comment.Id)); stillThere.Should().BeFalse(); } [Fact] public async Task Toggle_reaction_adds_then_removes() { await SeedAdminAsync(); var msg = await ReadAsync( await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Reactionable" })); var first = await Client.PostAsJsonAsync( $"/api/wall/messages/{msg!.Id}/reactions", new { emoji = "❤️" }); first.StatusCode.Should().Be(HttpStatusCode.OK); (await WithDbAsync(db => db.WallReactions.CountAsync(r => r.MessageId == msg.Id))).Should().Be(1); var second = await Client.PostAsJsonAsync( $"/api/wall/messages/{msg.Id}/reactions", new { emoji = "❤️" }); second.StatusCode.Should().Be(HttpStatusCode.OK); (await WithDbAsync(db => db.WallReactions.CountAsync(r => r.MessageId == msg.Id))).Should().Be(0); } [Fact] public async Task Preview_renders_markdown_with_strong() { await SeedAdminAsync(); var resp = await Client.PostAsJsonAsync("/api/wall/preview", new { text = "Hola **mundo**" }); resp.StatusCode.Should().Be(HttpStatusCode.OK); var dto = await ReadAsync(resp); dto!.Html.Should().Contain("mundo"); } private sealed record MessageRow(Guid Id, string Text, string TextHtml, bool IsPinned); private sealed record CommentRow(Guid Id, Guid MessageId, string Text); private sealed record PreviewDto(string Html); }