Move band management into dedicated settings pages

- Add BandSettingsPage (/bands/:id/settings/:panel) with Members,
  Storage, and Band Settings panels matching the mockup design
- Strip members list, invite controls, and NC folder config from
  BandPage — library view now focuses purely on recordings workflow
- Add band-scoped nav section to AppShell sidebar (Members, Storage,
  Band Settings) with correct per-panel active states
- Fix amAdmin bug: was checking if any member is admin; now correctly
  checks if the current user holds the admin role
- Add 31 vitest tests covering BandPage cleanliness, routing, access
  control (admin vs member), and per-panel mutation behaviour
- Add test:web, test:api:unit, test:feature (post-feature pipeline),
  and ci tasks to Taskfile; frontend tests run via podman node:20-alpine
- Add README with architecture overview, setup guide, and test docs
- Add @testing-library/dom and @testing-library/jest-dom to package.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-01 14:55:10 +02:00
parent 69c614cf62
commit 16bfdd2e90
12 changed files with 2428 additions and 465 deletions

View File

@@ -0,0 +1,320 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../test/helpers";
import { BandSettingsPage } from "./BandSettingsPage";
// ── Shared fixtures ───────────────────────────────────────────────────────────
const ME = { id: "m-me", email: "s@example.com", display_name: "Steffen", avatar_url: null, created_at: "" };
const BAND = {
id: "band-1",
name: "Loud Hands",
slug: "loud-hands",
genre_tags: ["post-rock", "math-rock"],
nc_folder_path: "bands/loud-hands/",
};
const MEMBERS_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "admin", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "member", joined_at: "" },
];
const MEMBERS_NON_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "member", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "admin", joined_at: "" },
];
const INVITES_RESPONSE = {
invites: [
{
id: "inv-1",
token: "abcdef1234567890abcd",
role: "member",
expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
is_used: false,
band_id: "band-1",
created_at: new Date().toISOString(),
used_at: null,
},
],
total: 1,
pending: 1,
};
// ── Mocks ─────────────────────────────────────────────────────────────────────
const {
mockGetBand,
mockApiGet,
mockApiPost,
mockApiPatch,
mockApiDelete,
mockListInvites,
mockRevokeInvite,
} = vi.hoisted(() => ({
mockGetBand: vi.fn(),
mockApiGet: vi.fn(),
mockApiPost: vi.fn(),
mockApiPatch: vi.fn(),
mockApiDelete: vi.fn(),
mockListInvites: vi.fn(),
mockRevokeInvite: vi.fn(),
}));
vi.mock("../api/bands", () => ({ getBand: mockGetBand }));
vi.mock("../api/invites", () => ({
listInvites: mockListInvites,
revokeInvite: mockRevokeInvite,
}));
vi.mock("../api/client", () => ({
api: {
get: mockApiGet,
post: mockApiPost,
patch: mockApiPatch,
delete: mockApiDelete,
},
isLoggedIn: vi.fn().mockReturnValue(true),
}));
// ── Default mock implementations ─────────────────────────────────────────────
afterEach(() => vi.clearAllMocks());
function setupApiGet(members: typeof MEMBERS_ADMIN) {
mockApiGet.mockImplementation((url: string) => {
if (url === "/auth/me") return Promise.resolve(ME);
if (url.includes("/members")) return Promise.resolve(members);
return Promise.resolve([]);
});
}
beforeEach(() => {
mockGetBand.mockResolvedValue(BAND);
setupApiGet(MEMBERS_ADMIN);
mockApiPost.mockResolvedValue({ id: "inv-new", token: "newtoken123", role: "member", expires_at: "" });
mockApiPatch.mockResolvedValue(BAND);
mockApiDelete.mockResolvedValue({});
mockListInvites.mockResolvedValue(INVITES_RESPONSE);
mockRevokeInvite.mockResolvedValue({});
});
// ── Render helpers ────────────────────────────────────────────────────────────
function renderPanel(panel: "members" | "storage" | "band", members = MEMBERS_ADMIN) {
setupApiGet(members);
return renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: `/bands/band-1/settings/${panel}`,
});
}
// ── Routing ───────────────────────────────────────────────────────────────────
describe("BandSettingsPage — routing (TC-15 to TC-17)", () => {
it("TC-15: renders Storage panel for /settings/storage", async () => {
renderPanel("storage");
const heading = await screen.findByRole("heading", { name: /storage/i });
expect(heading).toBeTruthy();
});
it("TC-16: renders Band Settings panel for /settings/band", async () => {
renderPanel("band");
const heading = await screen.findByRole("heading", { name: /band settings/i });
expect(heading).toBeTruthy();
});
it("TC-17: unknown panel falls back to Members", async () => {
mockApiGet.mockResolvedValue(MEMBERS_ADMIN);
renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: "/bands/band-1/settings/unknown-panel",
});
const heading = await screen.findByRole("heading", { name: /members/i });
expect(heading).toBeTruthy();
});
});
// ── Members panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Members panel access control (TC-18 to TC-23)", () => {
it("TC-18: admin sees + Invite button", async () => {
renderPanel("members", MEMBERS_ADMIN);
const btn = await screen.findByText(/\+ invite/i);
expect(btn).toBeTruthy();
});
it("TC-19: non-admin does not see + Invite button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex"); // wait for members to load
expect(screen.queryByText(/\+ invite/i)).toBeNull();
});
it("TC-20: admin sees Remove button on non-admin members", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
expect(removeBtn).toBeTruthy();
});
it("TC-21: non-admin does not see any Remove button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText("Remove")).toBeNull();
});
it("TC-22: admin does not see Remove on admin-role members", async () => {
renderPanel("members", MEMBERS_ADMIN);
await screen.findByText("Steffen");
// Only one Remove button — for Alex (member), not Steffen (admin)
const removeBtns = screen.queryAllByText("Remove");
expect(removeBtns).toHaveLength(1);
});
it("TC-23: Pending Invites section hidden from non-admins", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText(/pending invites/i)).toBeNull();
});
});
// ── Members panel — functionality ─────────────────────────────────────────────
describe("BandSettingsPage — Members panel functionality (TC-24 to TC-28)", () => {
it("TC-24: generate invite shows link in UI", async () => {
const token = "tok123abc456def789gh";
mockApiPost.mockResolvedValue({ id: "inv-new", token, role: "member", expires_at: "" });
renderPanel("members", MEMBERS_ADMIN);
const inviteBtn = await screen.findByText(/\+ invite/i);
fireEvent.click(inviteBtn);
const linkEl = await screen.findByText(new RegExp(token));
expect(linkEl).toBeTruthy();
});
it("TC-26: remove member calls DELETE endpoint", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
fireEvent.click(removeBtn);
await waitFor(() => {
expect(mockApiDelete).toHaveBeenCalledWith("/bands/band-1/members/m-2");
});
});
it("TC-27: revoke invite calls revokeInvite and refetches", async () => {
renderPanel("members", MEMBERS_ADMIN);
const revokeBtn = await screen.findByText("Revoke");
fireEvent.click(revokeBtn);
await waitFor(() => {
expect(mockRevokeInvite).toHaveBeenCalledWith("inv-1");
});
});
});
// ── Storage panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Storage panel access control (TC-29 to TC-33)", () => {
it("TC-29: admin sees Edit button", async () => {
renderPanel("storage", MEMBERS_ADMIN);
const edit = await screen.findByText("Edit");
expect(edit).toBeTruthy();
});
it("TC-30: non-admin does not see Edit button", async () => {
renderPanel("storage", MEMBERS_NON_ADMIN);
await screen.findByText(/scan path/i);
expect(screen.queryByText("Edit")).toBeNull();
});
it("TC-31: saving NC folder path calls PATCH and closes form", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
const input = screen.getByPlaceholderText(/bands\/loud-hands\//i);
fireEvent.change(input, { target: { value: "bands/custom-path/" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith(
"/bands/band-1",
{ nc_folder_path: "bands/custom-path/" }
);
});
});
it("TC-32: cancel edit closes form without calling PATCH", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockApiPatch).not.toHaveBeenCalled();
});
expect(screen.queryByText("Save")).toBeNull();
});
it("TC-33: shows default path when nc_folder_path is null", async () => {
mockGetBand.mockResolvedValueOnce({ ...BAND, nc_folder_path: null });
renderPanel("storage", MEMBERS_ADMIN);
const path = await screen.findByText("bands/loud-hands/");
expect(path).toBeTruthy();
});
});
// ── Band settings panel — access control ──────────────────────────────────────
describe("BandSettingsPage — Band Settings panel access control (TC-34 to TC-40)", () => {
it("TC-34: admin sees Save changes button", async () => {
renderPanel("band", MEMBERS_ADMIN);
const btn = await screen.findByText(/save changes/i);
expect(btn).toBeTruthy();
});
it("TC-35: non-admin sees info text instead of Save button", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
// Wait for the band panel heading so we know the page has fully loaded
await screen.findByRole("heading", { name: /band settings/i });
// Once queries settle, the BandPanel-level info text should appear and Save should be absent
await waitFor(() => {
expect(screen.getByText(/only admins can edit band settings/i)).toBeTruthy();
});
expect(screen.queryByText(/save changes/i)).toBeNull();
});
it("TC-36: name field is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const input = await screen.findByDisplayValue("Loud Hands");
expect((input as HTMLInputElement).disabled).toBe(true);
});
it("TC-37: saving calls PATCH with name and genre_tags", async () => {
renderPanel("band", MEMBERS_ADMIN);
await screen.findByText(/save changes/i);
fireEvent.click(screen.getByText(/save changes/i));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith("/bands/band-1", {
name: "Loud Hands",
genre_tags: ["post-rock", "math-rock"],
});
});
});
it("TC-38: adding a genre tag shows the new pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
const tagInput = await screen.findByPlaceholderText(/add genre tag/i);
fireEvent.change(tagInput, { target: { value: "punk" } });
fireEvent.keyDown(tagInput, { key: "Enter" });
expect(screen.getByText("punk")).toBeTruthy();
});
it("TC-39: removing a genre tag removes its pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
// Find the × button next to "post-rock"
await screen.findByText("post-rock");
// There are two tags; find the × buttons
const removeButtons = screen.getAllByText("×");
fireEvent.click(removeButtons[0]);
expect(screen.queryByText("post-rock")).toBeNull();
});
it("TC-40: Delete band button is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const deleteBtn = await screen.findByText(/delete band/i);
expect((deleteBtn as HTMLButtonElement).disabled).toBe(true);
});
});