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.
191 lines
7.3 KiB
C#
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);
|
|
}
|