import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const mockFetch = vi.fn(); describe("createAdminRoutes — GET /admin", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body), }; } /** * Sets up the two-fetch mock sequence for an authenticated session. * Call 1: GET /api/auth/session * Call 2: GET /api/admin/members/me */ function setupAuthenticatedSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } // ── Unauthenticated ───────────────────────────────────────────────────── it("redirects unauthenticated users to /login", async () => { // No atbb_session cookie → zero fetch calls const routes = await loadAdminRoutes(); const res = await routes.request("/admin"); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); // ── No admin permissions → 403 ────────────────────────────────────────── it("returns 403 for authenticated user with no permissions", async () => { setupAuthenticatedSession([]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(403); const html = await res.text(); expect(html).toContain("Access Denied"); }); it("returns 403 for authenticated user with only an unrelated permission", async () => { setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(403); }); // ── Wildcard → all cards ───────────────────────────────────────────────── it("grants access and shows all cards for wildcard (*) permission", async () => { setupAuthenticatedSession(["*"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('href="/admin/members"'); expect(html).toContain('href="/admin/structure"'); expect(html).toContain('href="/admin/modlog"'); }); // ── Single permission → only that card ────────────────────────────────── it("shows only Members card for user with only manageMembers", async () => { setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('href="/admin/members"'); expect(html).not.toContain('href="/admin/structure"'); expect(html).not.toContain('href="/admin/modlog"'); }); it("shows only Structure card for user with only manageCategories", async () => { setupAuthenticatedSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).not.toContain('href="/admin/members"'); expect(html).toContain('href="/admin/structure"'); expect(html).not.toContain('href="/admin/modlog"'); }); it("shows only Mod Log card for user with only moderatePosts", async () => { setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).not.toContain('href="/admin/members"'); expect(html).not.toContain('href="/admin/structure"'); expect(html).toContain('href="/admin/modlog"'); }); it("shows only Mod Log card for user with only banUsers", async () => { setupAuthenticatedSession(["space.atbb.permission.banUsers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).not.toContain('href="/admin/members"'); expect(html).not.toContain('href="/admin/structure"'); expect(html).toContain('href="/admin/modlog"'); }); it("shows only Mod Log card for user with only lockTopics", async () => { setupAuthenticatedSession(["space.atbb.permission.lockTopics"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).not.toContain('href="/admin/members"'); expect(html).not.toContain('href="/admin/structure"'); expect(html).toContain('href="/admin/modlog"'); }); // ── Multi-permission combos ────────────────────────────────────────────── it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => { setupAuthenticatedSession([ "space.atbb.permission.manageMembers", "space.atbb.permission.moderatePosts", ]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('href="/admin/members"'); expect(html).not.toContain('href="/admin/structure"'); expect(html).toContain('href="/admin/modlog"'); }); // ── Page structure ─────────────────────────────────────────────────────── it("renders 'Admin Panel' page title", async () => { setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("Admin Panel"); }); it("renders admin-nav-grid container", async () => { setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("admin-nav-grid"); }); }); describe("createAdminRoutes — GET /admin/members", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body), }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } const SAMPLE_MEMBERS = [ { did: "did:plc:alice", handle: "alice.bsky.social", role: "Owner", roleUri: "at://did:plc:forum/space.atbb.forum.role/owner", joinedAt: "2026-01-01T00:00:00.000Z", }, { did: "did:plc:bob", handle: "bob.bsky.social", role: "Member", roleUri: "at://did:plc:forum/space.atbb.forum.role/member", joinedAt: "2026-01-05T00:00:00.000Z", }, ]; const SAMPLE_ROLES = [ { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, ]; async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } it("redirects unauthenticated users to /login", async () => { const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members"); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 for authenticated user without manageMembers", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(403); }); it("renders member table with handles and role badges", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockResolvedValueOnce( mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("alice.bsky.social"); expect(html).toContain("bob.bsky.social"); expect(html).toContain("role-badge"); expect(html).toContain("Owner"); }); it("renders joined date for members", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockResolvedValueOnce( mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("Jan"); expect(html).toContain("2026"); }); it("hides role assignment form when user lacks manageRoles", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockResolvedValueOnce( mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).not.toContain("hx-post"); expect(html).not.toContain("Assign"); }); it("shows role assignment form when user has manageRoles", async () => { setupSession([ "space.atbb.permission.manageMembers", "space.atbb.permission.manageRoles", ]); mockFetch.mockResolvedValueOnce( mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) ); mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES })); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("hx-post"); expect(html).toContain("/admin/members/did:plc:bob/role"); expect(html).toContain("Assign"); }); it("shows empty state when no members", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockResolvedValueOnce( mockResponse({ members: [], isTruncated: false }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("No members"); }); it("shows truncated indicator when isTruncated is true", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockResolvedValueOnce( mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("+"); }); it("returns 503 on AppView network error fetching members", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(503); const html = await res.text(); expect(html).toContain("error-display"); }); it("returns 500 on AppView server error fetching members", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(500); const html = await res.text(); expect(html).toContain("error-display"); }); it("redirects to /login when AppView members returns 401 (session expired)", async () => { setupSession(["space.atbb.permission.manageMembers"]); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("renders page with empty role dropdown when roles fetch fails", async () => { setupSession([ "space.atbb.permission.manageMembers", "space.atbb.permission.manageRoles", ]); // members fetch succeeds mockFetch.mockResolvedValueOnce( mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) ); // roles fetch fails mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); // Page still renders with member data expect(html).toContain("alice.bsky.social"); // Assign Role column still present (permission says yes, just no options) expect(html).toContain("hx-post"); }); }); describe("createAdminRoutes — POST /admin/members/:did/role", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body), }; } const SAMPLE_ROLES = [ { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, ]; function makeFormBody(overrides: Partial> = {}): string { return new URLSearchParams({ roleUri: "at://did:plc:forum/space.atbb.forum.role/member", handle: "bob.bsky.social", joinedAt: "2026-01-05T00:00:00.000Z", currentRole: "Owner", currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner", canManageRoles: "1", rolesJson: JSON.stringify(SAMPLE_ROLES), ...overrides, }).toString(); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } it("returns updated with new role name on success", async () => { setupPostSession(); mockFetch.mockResolvedValueOnce( mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody(), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain(" { setupPostSession(); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody(), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(html).toContain("equal or higher authority"); expect(html).toContain("Owner"); // preserves current role }); it("returns row with friendly error on AppView 404", async () => { setupPostSession(); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody(), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(html).toContain("not found"); }); it("returns row with friendly error on AppView 500", async () => { setupPostSession(); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody(), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(html).toContain("Something went wrong"); }); it("returns row with unavailable message on network error", async () => { setupPostSession(); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody(), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(html).toContain("temporarily unavailable"); }); it("returns row with error and makes no AppView call when roleUri is missing", async () => { setupPostSession(); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody({ roleUri: "" }), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(mockFetch).not.toHaveBeenCalledWith( expect.stringContaining("/api/admin/members/did:plc:bob/role"), expect.anything() ); }); it("re-renders form with new role pre-selected in dropdown on success", async () => { setupPostSession(); mockFetch.mockResolvedValueOnce( mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody({ roleUri: "at://did:plc:forum/space.atbb.forum.role/member", }), }); const html = await res.text(); // The newly assigned role URI should appear as the selected option value in the form expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member"); }); it("returns 401 error row for unauthenticated POST", async () => { // No session mock — no cookie const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: makeFormBody(), }); expect(res.status).toBe(401); const html = await res.text(); expect(html).toContain("member-row__error"); expect(mockFetch).not.toHaveBeenCalledWith( expect.stringContaining("/api/admin/members/did:plc:bob/role"), expect.anything() ); }); it("returns 403 error row when user lacks manageRoles", async () => { setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody(), }); expect(res.status).toBe(403); const html = await res.text(); expect(html).toContain("member-row__error"); // No AppView role assignment call should have been made expect(mockFetch).not.toHaveBeenCalledWith( expect.stringContaining("/api/admin/members/did:plc:bob/role"), expect.anything() ); }); it("returns row with session-expired error when AppView returns 401", async () => { setupPostSession(); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody(), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(html).toContain("session has expired"); }); it("returns error row with reload message when rolesJson is malformed", async () => { setupPostSession(); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/did:plc:bob/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody({ rolesJson: "not-valid-json{{" }), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(html).toContain("reload"); // No AppView call should have been made // (setupPostSession consumed 2 calls, then we check no more were made) expect(mockFetch).toHaveBeenCalledTimes(2); }); it("returns error row and makes no AppView call when targetDid lacks did: prefix", async () => { setupPostSession(); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/members/notadid/role", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token", }, body: makeFormBody({ handle: "bob.bsky.social" }), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("member-row__error"); expect(html).toContain("Invalid member identifier"); // Session fetch calls consumed (2), but no AppView role call made expect(mockFetch).not.toHaveBeenCalledWith( expect.stringContaining("/api/admin/members/notadid/role"), expect.anything() ); }); }); describe("createAdminRoutes — GET /admin/structure", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body), }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } /** * Sets up mock responses for the structure page data fetches. * After the 2 session calls: * Call 3: GET /api/categories * Call 4+: GET /api/categories/:id/boards (one per category, parallel) */ function setupStructureFetch( cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>, boardsByCategory: Record> = {} ) { mockFetch.mockResolvedValueOnce( mockResponse({ categories: cats.map((c) => ({ id: c.id, did: "did:plc:forum", uri: c.uri, name: c.name, description: null, slug: null, sortOrder: c.sortOrder ?? 1, forumId: "1", createdAt: "2025-01-01T00:00:00.000Z", indexedAt: "2025-01-01T00:00:00.000Z", })), }) ); for (const cat of cats) { const boards = boardsByCategory[cat.id] ?? []; mockFetch.mockResolvedValueOnce( mockResponse({ boards: boards.map((b) => ({ id: b.id, did: "did:plc:forum", uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`, name: b.name, description: null, slug: null, sortOrder: 1, categoryId: cat.id, categoryUri: cat.uri, createdAt: "2025-01-01T00:00:00.000Z", indexedAt: "2025-01-01T00:00:00.000Z", })), }) ); } } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } it("redirects unauthenticated users to /login", async () => { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: false }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure"); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 for authenticated user without manageCategories", async () => { setupSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(403); }); it("renders structure page with category and board names", async () => { setupSession(["space.atbb.permission.manageCategories"]); setupStructureFetch( [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], { "1": [{ id: "10", name: "General Chat" }] } ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("General Discussion"); expect(html).toContain("General Chat"); }); it("renders empty state when no categories exist", async () => { setupSession(["space.atbb.permission.manageCategories"]); setupStructureFetch([]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("No categories"); }); it("renders the add-category form", async () => { setupSession(["space.atbb.permission.manageCategories"]); setupStructureFetch([]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain('action="/admin/structure/categories"'); }); it("renders edit and delete actions for a category", async () => { setupSession(["space.atbb.permission.manageCategories"]); setupStructureFetch( [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }], ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain('action="/admin/structure/categories/5/edit"'); expect(html).toContain('action="/admin/structure/categories/5/delete"'); }); it("renders edit and delete actions for a board", async () => { setupSession(["space.atbb.permission.manageCategories"]); setupStructureFetch( [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], { "1": [{ id: "20", name: "Showcase" }] } ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("Showcase"); expect(html).toContain('action="/admin/structure/boards/20/edit"'); expect(html).toContain('action="/admin/structure/boards/20/delete"'); }); it("renders add-board form with categoryUri hidden input", async () => { setupSession(["space.atbb.permission.manageCategories"]); setupStructureFetch( [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain('name="categoryUri"'); expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"'); expect(html).toContain('action="/admin/structure/boards"'); }); it("renders error banner when ?error= query param is present", async () => { setupSession(["space.atbb.permission.manageCategories"]); setupStructureFetch([]); const routes = await loadAdminRoutes(); const res = await routes.request( `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`, { headers: { cookie: "atbb_session=token" } } ); const html = await res.text(); expect(html).toContain("Cannot delete category with boards"); }); it("returns 503 on AppView network error fetching categories", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(503); const html = await res.text(); expect(html).toContain("error-display"); }); it("returns 500 on AppView server error fetching categories", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(500); const html = await res.text(); expect(html).toContain("error-display"); }); it("redirects to /login when AppView categories returns 401", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); }); describe("createAdminRoutes — POST /admin/structure/categories", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } function postForm(body: Record) { const params = new URLSearchParams(body); return { method: "POST", headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded", }, body: params.toString(), }; } it("redirects to /login when unauthenticated", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 without manageCategories permission", async () => { setupSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); expect(res.status).toBe(403); }); it("redirects to /admin/structure on success", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce( mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201) ); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/categories", postForm({ name: "General", description: "Talk about anything", sortOrder: "1" }) ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/admin/structure"); }); it("redirects with ?error= when name is missing", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/categories", postForm({ name: "" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("/admin/structure"); expect(location).toContain("error="); }); it("redirects with ?error= on AppView error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce( mockResponse({ error: "Unexpected error" }, false, 500) ); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/categories", postForm({ name: "General" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("/admin/structure"); expect(location).toContain("error="); }); it("redirects with ?error= on network error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/categories", postForm({ name: "General" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("/admin/structure"); expect(location).toContain("error="); }); it("redirects with ?error= for negative sort order", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/categories", postForm({ name: "General", sortOrder: "-1" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); }); describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } function postForm(body: Record) { const params = new URLSearchParams(body); return { method: "POST", headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded", }, body: params.toString(), }; } it("redirects to /login when unauthenticated", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 without manageCategories", async () => { setupSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); expect(res.status).toBe(403); }); it("redirects to /admin/structure on success", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce( mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200) ); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/categories/5/edit", postForm({ name: "Updated Name", description: "", sortOrder: "2" }) ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/admin/structure"); }); it("redirects with ?error= when name is missing", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "" })); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= on AppView error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce(mockResponse({ error: "Not found" }, false, 404)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= on network error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= for negative sort order", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/categories/5/edit", postForm({ name: "Updated", sortOrder: "-5" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); }); describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } function postForm(body: Record = {}) { const params = new URLSearchParams(body); return { method: "POST", headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded", }, body: params.toString(), }; } it("redirects to /login when unauthenticated", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/delete", postForm()); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 without manageCategories", async () => { setupSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/delete", postForm()); expect(res.status).toBe(403); }); it("redirects to /admin/structure on success", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/delete", postForm()); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/admin/structure"); }); it("redirects with ?error= on AppView error (e.g. 409 has boards)", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce( mockResponse({ error: "Cannot delete category with boards. Remove all boards first." }, false, 409) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/delete", postForm()); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("/admin/structure"); expect(location).toContain("error="); expect(decodeURIComponent(location)).toContain("Cannot delete category with boards"); }); it("redirects with ?error= on network error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/categories/5/delete", postForm()); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); }); describe("createAdminRoutes — POST /admin/structure/boards", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } function postForm(body: Record) { const params = new URLSearchParams(body); return { method: "POST", headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded", }, body: params.toString(), }; } it("redirects to /login when unauthenticated", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 without manageCategories permission", async () => { setupSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) ); expect(res.status).toBe(403); }); it("redirects to /admin/structure on success", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce( mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.board/xyz", cid: "bafyrei..." }, true, 201) ); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "General Chat", description: "Chat about anything", sortOrder: "1", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", }) ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/admin/structure"); }); it("redirects with ?error= when name is missing", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("/admin/structure"); expect(location).toContain("error="); }); it("redirects with ?error= when categoryUri is missing", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "General Chat", categoryUri: "" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= on AppView error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce( mockResponse({ error: "Category not found" }, false, 404) ); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= on network error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= for negative sort order", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards", postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", sortOrder: "-2", }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); }); describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } function postForm(body: Record) { const params = new URLSearchParams(body); return { method: "POST", headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded", }, body: params.toString(), }; } it("redirects to /login when unauthenticated", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 without manageCategories", async () => { setupSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); expect(res.status).toBe(403); }); it("redirects to /admin/structure on success", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200)); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards/10/edit", postForm({ name: "Updated Board", description: "", sortOrder: "3" }) ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/admin/structure"); }); it("redirects with ?error= when name is missing", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "" })); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= on AppView error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce(mockResponse({ error: "Board not found" }, false, 404)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= on network error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); it("redirects with ?error= for negative sort order", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request( "/admin/structure/boards/10/edit", postForm({ name: "Updated Board", sortOrder: "-3" }) ); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); }); describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } function postForm(body: Record = {}) { const params = new URLSearchParams(body); return { method: "POST", headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded", }, body: params.toString(), }; } it("redirects to /login when unauthenticated", async () => { mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/delete", postForm()); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 without manageCategories", async () => { setupSession(["space.atbb.permission.manageMembers"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/delete", postForm()); expect(res.status).toBe(403); }); it("redirects to /admin/structure on success", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/delete", postForm()); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/admin/structure"); }); it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockResolvedValueOnce( mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/delete", postForm()); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(decodeURIComponent(location)).toContain("Cannot delete board with posts"); }); it("redirects with ?error= on network error", async () => { setupSession(["space.atbb.permission.manageCategories"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/structure/boards/10/delete", postForm()); expect(res.status).toBe(302); const location = res.headers.get("location") ?? ""; expect(location).toContain("error="); }); }); describe("createAdminRoutes — GET /admin/modlog", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); mockFetch.mockReset(); }); function mockResponse(body: unknown, ok = true, status = 200) { return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body), }; } function setupSession(permissions: string[]) { mockFetch.mockResolvedValueOnce( mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) ); mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); } async function loadAdminRoutes() { const { createAdminRoutes } = await import("../admin.js"); return createAdminRoutes("http://localhost:3000"); } const SAMPLE_ACTIONS = [ { id: "1", action: "space.atbb.modAction.ban", moderatorDid: "did:plc:alice", moderatorHandle: "alice.bsky.social", subjectDid: "did:plc:bob", subjectHandle: "bob.bsky.social", subjectPostUri: null, reason: "Spam", createdAt: "2026-02-26T12:01:00.000Z", }, { id: "2", action: "space.atbb.modAction.delete", moderatorDid: "did:plc:alice", moderatorHandle: "alice.bsky.social", subjectDid: null, subjectHandle: null, subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123", reason: "Inappropriate", createdAt: "2026-02-26T11:30:00.000Z", }, ]; // ── Auth & permission gates ────────────────────────────────────────────── it("redirects unauthenticated users to /login", async () => { const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog"); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); it("returns 403 for user without any mod permission", async () => { setupSession(["space.atbb.permission.manageCategories"]); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(403); const html = await res.text(); expect(html).toContain("permission"); }); it("allows access for moderatePosts permission", async () => { setupSession(["space.atbb.permission.moderatePosts"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); }); it("allows access for banUsers permission", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); }); it("allows access for lockTopics permission", async () => { setupSession(["space.atbb.permission.lockTopics"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); }); // ── Table rendering ────────────────────────────────────────────────────── it("renders table with moderator handle and action label", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("alice.bsky.social"); expect(html).toContain("Ban"); expect(html).toContain("bob.bsky.social"); expect(html).toContain("Spam"); }); it("maps space.atbb.modAction.delete to 'Hide' label", async () => { setupSession(["space.atbb.permission.moderatePosts"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("Hide"); }); it("shows post URI in subject column for post-targeting actions", async () => { setupSession(["space.atbb.permission.moderatePosts"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123"); }); it("shows handle in subject column for user-targeting actions", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("bob.bsky.social"); }); it("shows empty state when no actions", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("No moderation actions"); }); // ── Pagination ─────────────────────────────────────────────────────────── it("renders 'Page 1 of 2' indicator for 51 total actions", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain("Page 1 of 2"); }); it("shows Next link when more pages exist", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain('href="/admin/modlog?offset=50"'); expect(html).toContain("Next"); }); it("hides Next link on last page", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog?offset=50", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).not.toContain('href="/admin/modlog?offset=100"'); }); it("shows Previous link when not on first page", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog?offset=50", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).toContain('href="/admin/modlog?offset=0"'); expect(html).toContain("Previous"); }); it("hides Previous link on first page", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); const html = await res.text(); expect(html).not.toContain('href="/admin/modlog?offset=-50"'); expect(html).not.toContain("Previous"); }); it("passes offset query param to AppView", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 }) ); const routes = await loadAdminRoutes(); await routes.request("/admin/modlog?offset=50", { headers: { cookie: "atbb_session=token" }, }); // Third fetch call (index 2) is the modlog API call const modlogCall = mockFetch.mock.calls[2]; expect(modlogCall[0]).toContain("offset=50"); expect(modlogCall[0]).toContain("limit=50"); }); it("ignores invalid offset and defaults to 0", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce( mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) ); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog?offset=notanumber", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(200); const modlogCall = mockFetch.mock.calls[2]; expect(modlogCall[0]).toContain("offset=0"); }); // ── Error handling ─────────────────────────────────────────────────────── it("returns 503 on AppView network error", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(503); const html = await res.text(); expect(html).toContain("error-display"); }); it("returns 500 on AppView server error", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(500); const html = await res.text(); expect(html).toContain("error-display"); }); it("returns 500 when AppView returns non-JSON response body", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, statusText: "OK", json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), }); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(500); const html = await res.text(); expect(html).toContain("error-display"); }); it("redirects to /login when AppView returns 401", async () => { setupSession(["space.atbb.permission.banUsers"]); mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); const routes = await loadAdminRoutes(); const res = await routes.request("/admin/modlog", { headers: { cookie: "atbb_session=token" }, }); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); });