- 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>
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|