familynido/tests/FamilyNido.Tests/Integration/WallTests.cs
Pablo Fernández a308041d59 Initial commit
FamilyNido — a self-hosted PWA for a single household: shared calendar,
chores, meals, school agenda, health records and a family wall. One
instance per family, deployable with `docker compose` on any home
server.

Stack: .NET 10 (ASP.NET Core Minimal APIs) + EF Core 10 + PostgreSQL 16
on the backend, Angular 21 (standalone, signals, zoneless) + Tailwind
CSS v4 on the frontend, SignalR for realtime, optional OIDC alongside
local credentials, integration via a versioned `/api/v1/**` public API.

See README.md for the module overview and how to deploy.
2026-05-13 00:23:14 +02:00

191 lines
7.3 KiB
C#

using System.Net;
using System.Net.Http.Json;
using FamilyNido.Domain.Families;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Tests.Integration;
/// <summary>
/// Coverage for /api/wall: create, update, delete, comment, reaction toggle,
/// pin/unpin, markdown preview. Permission rules around author-or-admin are
/// also exercised.
/// </summary>
public sealed class WallTests : IntegrationTestBase
{
public WallTests(IntegrationFixture fixture) : base(fixture) { }
private async Task<TestSeed.FamilyHandle> 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<MessageRow>(resp);
dto!.Text.Should().Contain("Hola");
dto.TextHtml.Should().Contain("<strong>");
}
[Fact]
public async Task Update_changes_text_when_caller_is_author()
{
await SeedAdminAsync();
var created = await ReadAsync<MessageRow>(
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<MessageRow>(
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<MessageRow>(
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<MessageRow>(
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<MessageRow>(
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<CommentRow>(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<MessageRow>(
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<PreviewDto>(resp);
dto!.Html.Should().Contain("<strong>mundo</strong>");
}
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);
}