Files
rehearshalhub/web/src/pages/BandSettingsPage.test.tsx
Mistral Vibe 16bfdd2e90 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>
2026-04-01 14:55:10 +02:00

321 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});