Files
YesChef/src/backend/YesChef.Api.IntegrationTests/Features/FamilyEndpointsTests.cs
T
Josh Rogers de5c18f3e6 Add per-family invite codes and admin roles
First member into a family becomes Admin; subsequent members default to
Member. JWTs carry a family_role claim that is refreshed from the DB on
each request so promotions and demotions take effect immediately.

New /api/family endpoints let admins view the roster, regenerate the
invite code, change roles, and remove members. Last-admin and
self-removal guards prevent locking the family out of management.

The /family page exposes the same actions in the UI; the bottom nav now
links there. Members see the roster but not the invite code.

Existing deployments get a one-time backfill at startup: any family with
members but no admin gets its earliest-joined member promoted.
2026-05-08 21:29:15 -05:00

186 lines
7.9 KiB
C#

using System.Net;
using System.Net.Http.Json;
using YesChef.Api.Entities;
using YesChef.Api.Features.Families;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
public class FamilyEndpointsTests : AuthenticatedIntegrationTest
{
// The default registered user is the first member of the bootstrap family
// and therefore the admin. Tests that need a non-admin register a second
// user via the same invite code to get a Member.
[Test]
public async Task First_registrant_is_admin()
{
var family = await Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
await Assert.That(family!.MyRole).IsEqualTo("Admin");
await Assert.That(family.Members.Count).IsEqualTo(1);
await Assert.That(family.Members[0].Role).IsEqualTo("Admin");
}
[Test]
public async Task Second_registrant_is_member()
{
using var second = await Data.RegisterAsync("second-user");
var family = await second.Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
await Assert.That(family!.MyRole).IsEqualTo("Member");
await Assert.That(family.Members.Count).IsEqualTo(2);
}
[Test]
public async Task Get_family_hides_invite_code_from_members()
{
using var member = await Data.RegisterAsync("member");
var asAdmin = await Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
var asMember = await member.Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
await Assert.That(asAdmin!.InviteCode).IsNotNull();
await Assert.That(asMember!.InviteCode).IsNull();
}
[Test]
public async Task Regenerate_invite_code_replaces_code_and_returns_new_one()
{
var beforeCode = await UseDbAsync(db => db.Families.Select(f => f.InviteCode).FirstAsync());
var response = await Client.PostAsync("/api/family/invite-code/regenerate", content: null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<FamilyEndpoints.InviteCodeResponse>();
await Assert.That(body!.InviteCode).IsNotEqualTo(beforeCode);
await Assert.That(body.InviteCode.Length).IsEqualTo(10);
var afterCode = await UseDbAsync(db => db.Families.Select(f => f.InviteCode).FirstAsync());
await Assert.That(afterCode).IsEqualTo(body.InviteCode);
}
[Test]
public async Task Regenerate_invite_code_forbidden_for_member()
{
using var member = await Data.RegisterAsync("member");
var response = await member.Client.PostAsync("/api/family/invite-code/regenerate", content: null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
}
[Test]
public async Task Update_role_promotes_member_to_admin()
{
using var member = await Data.RegisterAsync("member");
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
var response = await Client.PutAsJsonAsync($"/api/family/members/{memberId}/role",
new FamilyEndpoints.UpdateRoleRequest("Admin"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
var role = await UseDbAsync(db => db.FamilyMemberships
.Where(m => m.UserId == memberId).Select(m => m.Role).SingleAsync());
await Assert.That(role).IsEqualTo(FamilyRole.Admin);
}
[Test]
public async Task Update_role_blocks_demoting_last_admin()
{
var response = await Client.PutAsJsonAsync($"/api/family/members/{User.Id}/role",
new FamilyEndpoints.UpdateRoleRequest("Member"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
[Test]
public async Task Update_role_allows_demoting_admin_when_other_admin_exists()
{
using var member = await Data.RegisterAsync("member");
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
// promote so we have two admins
await Client.PutAsJsonAsync($"/api/family/members/{memberId}/role",
new FamilyEndpoints.UpdateRoleRequest("Admin"));
// demote self — should succeed because the other admin remains
var response = await Client.PutAsJsonAsync($"/api/family/members/{User.Id}/role",
new FamilyEndpoints.UpdateRoleRequest("Member"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
}
[Test]
public async Task Update_role_forbidden_for_member()
{
using var member = await Data.RegisterAsync("member");
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
var response = await member.Client.PutAsJsonAsync($"/api/family/members/{memberId}/role",
new FamilyEndpoints.UpdateRoleRequest("Admin"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
}
[Test]
public async Task Remove_member_deletes_membership()
{
using var member = await Data.RegisterAsync("member");
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
var response = await Client.DeleteAsync($"/api/family/members/{memberId}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
var stillMember = await UseDbAsync(db => db.FamilyMemberships.AnyAsync(m => m.UserId == memberId));
await Assert.That(stillMember).IsFalse();
}
[Test]
public async Task Remove_member_revokes_their_token()
{
using var member = await Data.RegisterAsync("member");
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
var removal = await Client.DeleteAsync($"/api/family/members/{memberId}");
await Assert.That(removal.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
// The kicked member's existing JWT must now be rejected.
var meResponse = await member.Client.GetAsync("/api/auth/me");
await Assert.That(meResponse.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
[Test]
public async Task Remove_self_returns_409()
{
var response = await Client.DeleteAsync($"/api/family/members/{User.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
[Test]
public async Task Member_demoted_loses_admin_access_on_next_request()
{
// Promote a second user to admin, then have the original admin demote
// them. The original admin's already-issued token should still work
// (still admin); the demoted user's token should no longer pass the
// admin policy on the next request.
using var second = await Data.RegisterAsync("second");
var secondId = await UseDbAsync(db => db.Users.Where(u => u.Name == "second").Select(u => u.Id).SingleAsync());
await Client.PutAsJsonAsync($"/api/family/members/{secondId}/role",
new FamilyEndpoints.UpdateRoleRequest("Admin"));
// confirm second can now regenerate (admin-only)
var asAdmin = await second.Client.PostAsync("/api/family/invite-code/regenerate", content: null);
await Assert.That(asAdmin.StatusCode).IsEqualTo(HttpStatusCode.OK);
// demote second
var demote = await Client.PutAsJsonAsync($"/api/family/members/{secondId}/role",
new FamilyEndpoints.UpdateRoleRequest("Member"));
await Assert.That(demote.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
// second's same token now fails the admin policy
var afterDemote = await second.Client.PostAsync("/api/family/invite-code/regenerate", content: null);
await Assert.That(afterDemote.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
}
}