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:
320
web/src/pages/BandSettingsPage.test.tsx
Normal file
320
web/src/pages/BandSettingsPage.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user