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(, { 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(, { 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); }); });