WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
3const mockFetch = vi.fn();
4
5describe("createAdminRoutes — GET /admin", () => {
6 beforeEach(() => {
7 vi.stubGlobal("fetch", mockFetch);
8 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
9 vi.resetModules();
10 });
11
12 afterEach(() => {
13 vi.unstubAllGlobals();
14 vi.unstubAllEnvs();
15 mockFetch.mockReset();
16 });
17
18 function mockResponse(body: unknown, ok = true, status = 200) {
19 return {
20 ok,
21 status,
22 statusText: ok ? "OK" : "Error",
23 json: () => Promise.resolve(body),
24 };
25 }
26
27 /**
28 * Sets up the two-fetch mock sequence for an authenticated session.
29 * Call 1: GET /api/auth/session
30 * Call 2: GET /api/admin/members/me
31 */
32 function setupAuthenticatedSession(permissions: string[]) {
33 mockFetch.mockResolvedValueOnce(
34 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
35 );
36 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
37 }
38
39 async function loadAdminRoutes() {
40 const { createAdminRoutes } = await import("../admin.js");
41 return createAdminRoutes("http://localhost:3000");
42 }
43
44 // ── Unauthenticated ─────────────────────────────────────────────────────
45
46 it("redirects unauthenticated users to /login", async () => {
47 // No atbb_session cookie → zero fetch calls
48 const routes = await loadAdminRoutes();
49 const res = await routes.request("/admin");
50 expect(res.status).toBe(302);
51 expect(res.headers.get("location")).toBe("/login");
52 });
53
54 // ── No admin permissions → 403 ──────────────────────────────────────────
55
56 it("returns 403 for authenticated user with no permissions", async () => {
57 setupAuthenticatedSession([]);
58 const routes = await loadAdminRoutes();
59 const res = await routes.request("/admin", {
60 headers: { cookie: "atbb_session=token" },
61 });
62 expect(res.status).toBe(403);
63 const html = await res.text();
64 expect(html).toContain("Access Denied");
65 });
66
67 it("returns 403 for authenticated user with only an unrelated permission", async () => {
68 setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]);
69 const routes = await loadAdminRoutes();
70 const res = await routes.request("/admin", {
71 headers: { cookie: "atbb_session=token" },
72 });
73 expect(res.status).toBe(403);
74 });
75
76 // ── Wildcard → all cards ─────────────────────────────────────────────────
77
78 it("grants access and shows all cards for wildcard (*) permission", async () => {
79 setupAuthenticatedSession(["*"]);
80 const routes = await loadAdminRoutes();
81 const res = await routes.request("/admin", {
82 headers: { cookie: "atbb_session=token" },
83 });
84 expect(res.status).toBe(200);
85 const html = await res.text();
86 expect(html).toContain('href="/admin/members"');
87 expect(html).toContain('href="/admin/structure"');
88 expect(html).toContain('href="/admin/modlog"');
89 expect(html).toContain('href="/admin/themes"');
90 });
91
92 // ── Single permission → only that card ──────────────────────────────────
93
94 it("shows only Members card for user with only manageMembers", async () => {
95 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
96 const routes = await loadAdminRoutes();
97 const res = await routes.request("/admin", {
98 headers: { cookie: "atbb_session=token" },
99 });
100 expect(res.status).toBe(200);
101 const html = await res.text();
102 expect(html).toContain('href="/admin/members"');
103 expect(html).not.toContain('href="/admin/structure"');
104 expect(html).not.toContain('href="/admin/modlog"');
105 expect(html).not.toContain('href="/admin/themes"');
106 });
107
108 it("shows only Structure card for user with only manageCategories", async () => {
109 setupAuthenticatedSession(["space.atbb.permission.manageCategories"]);
110 const routes = await loadAdminRoutes();
111 const res = await routes.request("/admin", {
112 headers: { cookie: "atbb_session=token" },
113 });
114 expect(res.status).toBe(200);
115 const html = await res.text();
116 expect(html).not.toContain('href="/admin/members"');
117 expect(html).toContain('href="/admin/structure"');
118 expect(html).not.toContain('href="/admin/modlog"');
119 expect(html).not.toContain('href="/admin/themes"');
120 });
121
122 it("shows only Mod Log card for user with only moderatePosts", async () => {
123 setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]);
124 const routes = await loadAdminRoutes();
125 const res = await routes.request("/admin", {
126 headers: { cookie: "atbb_session=token" },
127 });
128 expect(res.status).toBe(200);
129 const html = await res.text();
130 expect(html).not.toContain('href="/admin/members"');
131 expect(html).not.toContain('href="/admin/structure"');
132 expect(html).toContain('href="/admin/modlog"');
133 expect(html).not.toContain('href="/admin/themes"');
134 });
135
136 it("shows only Mod Log card for user with only banUsers", async () => {
137 setupAuthenticatedSession(["space.atbb.permission.banUsers"]);
138 const routes = await loadAdminRoutes();
139 const res = await routes.request("/admin", {
140 headers: { cookie: "atbb_session=token" },
141 });
142 expect(res.status).toBe(200);
143 const html = await res.text();
144 expect(html).not.toContain('href="/admin/members"');
145 expect(html).not.toContain('href="/admin/structure"');
146 expect(html).toContain('href="/admin/modlog"');
147 expect(html).not.toContain('href="/admin/themes"');
148 });
149
150 it("shows only Mod Log card for user with only lockTopics", async () => {
151 setupAuthenticatedSession(["space.atbb.permission.lockTopics"]);
152 const routes = await loadAdminRoutes();
153 const res = await routes.request("/admin", {
154 headers: { cookie: "atbb_session=token" },
155 });
156 expect(res.status).toBe(200);
157 const html = await res.text();
158 expect(html).not.toContain('href="/admin/members"');
159 expect(html).not.toContain('href="/admin/structure"');
160 expect(html).toContain('href="/admin/modlog"');
161 expect(html).not.toContain('href="/admin/themes"');
162 });
163
164 it("shows Themes card for user with manageThemes permission", async () => {
165 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
166 const routes = await loadAdminRoutes();
167 const res = await routes.request("/admin", {
168 headers: { cookie: "atbb_session=token" },
169 });
170 expect(res.status).toBe(200);
171 const html = await res.text();
172 expect(html).toContain('href="/admin/themes"');
173 expect(html).toContain("🎨");
174 expect(html).not.toContain('href="/admin/members"');
175 expect(html).not.toContain('href="/admin/structure"');
176 expect(html).not.toContain('href="/admin/modlog"');
177 });
178
179 it("does not show Themes card for user with only manageMembers permission", async () => {
180 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
181 const routes = await loadAdminRoutes();
182 const res = await routes.request("/admin", {
183 headers: { cookie: "atbb_session=token" },
184 });
185 expect(res.status).toBe(200);
186 const html = await res.text();
187 expect(html).not.toContain('href="/admin/themes"');
188 });
189
190 it("shows Themes card for wildcard (*) permission user", async () => {
191 setupAuthenticatedSession(["*"]);
192 const routes = await loadAdminRoutes();
193 const res = await routes.request("/admin", {
194 headers: { cookie: "atbb_session=token" },
195 });
196 expect(res.status).toBe(200);
197 const html = await res.text();
198 expect(html).toContain('href="/admin/themes"');
199 });
200
201 it("grants access to /admin landing page for user with only manageThemes", async () => {
202 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
203 const routes = await loadAdminRoutes();
204 const res = await routes.request("/admin", {
205 headers: { cookie: "atbb_session=token" },
206 });
207 // manageThemes should be in ADMIN_PERMISSIONS so the landing page is accessible
208 expect(res.status).toBe(200);
209 });
210
211 // ── Multi-permission combos ──────────────────────────────────────────────
212
213 it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => {
214 setupAuthenticatedSession([
215 "space.atbb.permission.manageMembers",
216 "space.atbb.permission.moderatePosts",
217 ]);
218 const routes = await loadAdminRoutes();
219 const res = await routes.request("/admin", {
220 headers: { cookie: "atbb_session=token" },
221 });
222 expect(res.status).toBe(200);
223 const html = await res.text();
224 expect(html).toContain('href="/admin/members"');
225 expect(html).not.toContain('href="/admin/structure"');
226 expect(html).toContain('href="/admin/modlog"');
227 expect(html).not.toContain('href="/admin/themes"');
228 });
229
230 // ── Page structure ───────────────────────────────────────────────────────
231
232 it("renders 'Admin Panel' page title", async () => {
233 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
234 const routes = await loadAdminRoutes();
235 const res = await routes.request("/admin", {
236 headers: { cookie: "atbb_session=token" },
237 });
238 const html = await res.text();
239 expect(html).toContain("Admin Panel");
240 });
241
242 it("renders admin-nav-grid container", async () => {
243 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
244 const routes = await loadAdminRoutes();
245 const res = await routes.request("/admin", {
246 headers: { cookie: "atbb_session=token" },
247 });
248 const html = await res.text();
249 expect(html).toContain("admin-nav-grid");
250 });
251});
252
253describe("createAdminRoutes — GET /admin/members", () => {
254 beforeEach(() => {
255 vi.stubGlobal("fetch", mockFetch);
256 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
257 vi.resetModules();
258 });
259
260 afterEach(() => {
261 vi.unstubAllGlobals();
262 vi.unstubAllEnvs();
263 mockFetch.mockReset();
264 });
265
266 function mockResponse(body: unknown, ok = true, status = 200) {
267 return {
268 ok,
269 status,
270 statusText: ok ? "OK" : "Error",
271 json: () => Promise.resolve(body),
272 };
273 }
274
275 function setupSession(permissions: string[]) {
276 mockFetch.mockResolvedValueOnce(
277 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
278 );
279 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
280 }
281
282 const SAMPLE_MEMBERS = [
283 {
284 did: "did:plc:alice",
285 handle: "alice.bsky.social",
286 role: "Owner",
287 roleUri: "at://did:plc:forum/space.atbb.forum.role/owner",
288 joinedAt: "2026-01-01T00:00:00.000Z",
289 },
290 {
291 did: "did:plc:bob",
292 handle: "bob.bsky.social",
293 role: "Member",
294 roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
295 joinedAt: "2026-01-05T00:00:00.000Z",
296 },
297 ];
298
299 const SAMPLE_ROLES = [
300 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
301 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
302 ];
303
304 async function loadAdminRoutes() {
305 const { createAdminRoutes } = await import("../admin.js");
306 return createAdminRoutes("http://localhost:3000");
307 }
308
309 it("redirects unauthenticated users to /login", async () => {
310 const routes = await loadAdminRoutes();
311 const res = await routes.request("/admin/members");
312 expect(res.status).toBe(302);
313 expect(res.headers.get("location")).toBe("/login");
314 });
315
316 it("returns 403 for authenticated user without manageMembers", async () => {
317 setupSession(["space.atbb.permission.manageCategories"]);
318 const routes = await loadAdminRoutes();
319 const res = await routes.request("/admin/members", {
320 headers: { cookie: "atbb_session=token" },
321 });
322 expect(res.status).toBe(403);
323 });
324
325 it("renders member table with handles and role badges", async () => {
326 setupSession(["space.atbb.permission.manageMembers"]);
327 mockFetch.mockResolvedValueOnce(
328 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
329 );
330
331 const routes = await loadAdminRoutes();
332 const res = await routes.request("/admin/members", {
333 headers: { cookie: "atbb_session=token" },
334 });
335
336 expect(res.status).toBe(200);
337 const html = await res.text();
338 expect(html).toContain("alice.bsky.social");
339 expect(html).toContain("bob.bsky.social");
340 expect(html).toContain("role-badge");
341 expect(html).toContain("Owner");
342 });
343
344 it("renders joined date for members", async () => {
345 setupSession(["space.atbb.permission.manageMembers"]);
346 mockFetch.mockResolvedValueOnce(
347 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
348 );
349
350 const routes = await loadAdminRoutes();
351 const res = await routes.request("/admin/members", {
352 headers: { cookie: "atbb_session=token" },
353 });
354
355 const html = await res.text();
356 expect(html).toContain("Jan");
357 expect(html).toContain("2026");
358 });
359
360 it("hides role assignment form when user lacks manageRoles", async () => {
361 setupSession(["space.atbb.permission.manageMembers"]);
362 mockFetch.mockResolvedValueOnce(
363 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
364 );
365
366 const routes = await loadAdminRoutes();
367 const res = await routes.request("/admin/members", {
368 headers: { cookie: "atbb_session=token" },
369 });
370
371 const html = await res.text();
372 expect(html).not.toContain("hx-post");
373 expect(html).not.toContain("Assign");
374 });
375
376 it("shows role assignment form when user has manageRoles", async () => {
377 setupSession([
378 "space.atbb.permission.manageMembers",
379 "space.atbb.permission.manageRoles",
380 ]);
381 mockFetch.mockResolvedValueOnce(
382 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
383 );
384 mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES }));
385
386 const routes = await loadAdminRoutes();
387 const res = await routes.request("/admin/members", {
388 headers: { cookie: "atbb_session=token" },
389 });
390
391 const html = await res.text();
392 expect(html).toContain("hx-post");
393 expect(html).toContain("/admin/members/did:plc:bob/role");
394 expect(html).toContain("Assign");
395 });
396
397 it("shows empty state when no members", async () => {
398 setupSession(["space.atbb.permission.manageMembers"]);
399 mockFetch.mockResolvedValueOnce(
400 mockResponse({ members: [], isTruncated: false })
401 );
402
403 const routes = await loadAdminRoutes();
404 const res = await routes.request("/admin/members", {
405 headers: { cookie: "atbb_session=token" },
406 });
407
408 const html = await res.text();
409 expect(html).toContain("No members");
410 });
411
412 it("shows truncated indicator when isTruncated is true", async () => {
413 setupSession(["space.atbb.permission.manageMembers"]);
414 mockFetch.mockResolvedValueOnce(
415 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true })
416 );
417
418 const routes = await loadAdminRoutes();
419 const res = await routes.request("/admin/members", {
420 headers: { cookie: "atbb_session=token" },
421 });
422
423 const html = await res.text();
424 expect(html).toContain("+");
425 });
426
427 it("returns 503 on AppView network error fetching members", async () => {
428 setupSession(["space.atbb.permission.manageMembers"]);
429 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
430
431 const routes = await loadAdminRoutes();
432 const res = await routes.request("/admin/members", {
433 headers: { cookie: "atbb_session=token" },
434 });
435
436 expect(res.status).toBe(503);
437 const html = await res.text();
438 expect(html).toContain("error-display");
439 });
440
441 it("returns 500 on AppView server error fetching members", async () => {
442 setupSession(["space.atbb.permission.manageMembers"]);
443 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
444
445 const routes = await loadAdminRoutes();
446 const res = await routes.request("/admin/members", {
447 headers: { cookie: "atbb_session=token" },
448 });
449
450 expect(res.status).toBe(500);
451 const html = await res.text();
452 expect(html).toContain("error-display");
453 });
454
455 it("redirects to /login when AppView members returns 401 (session expired)", async () => {
456 setupSession(["space.atbb.permission.manageMembers"]);
457 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
458
459 const routes = await loadAdminRoutes();
460 const res = await routes.request("/admin/members", {
461 headers: { cookie: "atbb_session=token" },
462 });
463
464 expect(res.status).toBe(302);
465 expect(res.headers.get("location")).toBe("/login");
466 });
467
468 it("renders page with empty role dropdown when roles fetch fails", async () => {
469 setupSession([
470 "space.atbb.permission.manageMembers",
471 "space.atbb.permission.manageRoles",
472 ]);
473 // members fetch succeeds
474 mockFetch.mockResolvedValueOnce(
475 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
476 );
477 // roles fetch fails
478 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
479
480 const routes = await loadAdminRoutes();
481 const res = await routes.request("/admin/members", {
482 headers: { cookie: "atbb_session=token" },
483 });
484
485 expect(res.status).toBe(200);
486 const html = await res.text();
487 // Page still renders with member data
488 expect(html).toContain("alice.bsky.social");
489 // Assign Role column still present (permission says yes, just no options)
490 expect(html).toContain("hx-post");
491 });
492});
493
494describe("createAdminRoutes — POST /admin/members/:did/role", () => {
495 beforeEach(() => {
496 vi.stubGlobal("fetch", mockFetch);
497 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
498 vi.resetModules();
499 });
500
501 afterEach(() => {
502 vi.unstubAllGlobals();
503 vi.unstubAllEnvs();
504 mockFetch.mockReset();
505 });
506
507 function mockResponse(body: unknown, ok = true, status = 200) {
508 return {
509 ok,
510 status,
511 statusText: ok ? "OK" : "Error",
512 json: () => Promise.resolve(body),
513 };
514 }
515
516 const SAMPLE_ROLES = [
517 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
518 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
519 ];
520
521 function makeFormBody(overrides: Partial<Record<string, string>> = {}): string {
522 return new URLSearchParams({
523 roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
524 handle: "bob.bsky.social",
525 joinedAt: "2026-01-05T00:00:00.000Z",
526 currentRole: "Owner",
527 currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner",
528 canManageRoles: "1",
529 rolesJson: JSON.stringify(SAMPLE_ROLES),
530 ...overrides,
531 }).toString();
532 }
533
534 async function loadAdminRoutes() {
535 const { createAdminRoutes } = await import("../admin.js");
536 return createAdminRoutes("http://localhost:3000");
537 }
538
539 function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) {
540 mockFetch.mockResolvedValueOnce(
541 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
542 );
543 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
544 }
545
546 it("returns updated <tr> with new role name on success", async () => {
547 setupPostSession();
548 mockFetch.mockResolvedValueOnce(
549 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
550 );
551
552 const routes = await loadAdminRoutes();
553 const res = await routes.request("/admin/members/did:plc:bob/role", {
554 method: "POST",
555 headers: {
556 "Content-Type": "application/x-www-form-urlencoded",
557 cookie: "atbb_session=token",
558 },
559 body: makeFormBody(),
560 });
561
562 expect(res.status).toBe(200);
563 const html = await res.text();
564 expect(html).toContain("<tr");
565 expect(html).toContain("Member");
566 expect(html).toContain("bob.bsky.social");
567 });
568
569 it("returns row with friendly error on AppView 403", async () => {
570 setupPostSession();
571 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403));
572
573 const routes = await loadAdminRoutes();
574 const res = await routes.request("/admin/members/did:plc:bob/role", {
575 method: "POST",
576 headers: {
577 "Content-Type": "application/x-www-form-urlencoded",
578 cookie: "atbb_session=token",
579 },
580 body: makeFormBody(),
581 });
582
583 expect(res.status).toBe(200);
584 const html = await res.text();
585 expect(html).toContain("member-row__error");
586 expect(html).toContain("equal or higher authority");
587 expect(html).toContain("Owner"); // preserves current role
588 });
589
590 it("returns row with friendly error on AppView 404", async () => {
591 setupPostSession();
592 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404));
593
594 const routes = await loadAdminRoutes();
595 const res = await routes.request("/admin/members/did:plc:bob/role", {
596 method: "POST",
597 headers: {
598 "Content-Type": "application/x-www-form-urlencoded",
599 cookie: "atbb_session=token",
600 },
601 body: makeFormBody(),
602 });
603
604 expect(res.status).toBe(200);
605 const html = await res.text();
606 expect(html).toContain("member-row__error");
607 expect(html).toContain("not found");
608 });
609
610 it("returns row with friendly error on AppView 500", async () => {
611 setupPostSession();
612 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
613
614 const routes = await loadAdminRoutes();
615 const res = await routes.request("/admin/members/did:plc:bob/role", {
616 method: "POST",
617 headers: {
618 "Content-Type": "application/x-www-form-urlencoded",
619 cookie: "atbb_session=token",
620 },
621 body: makeFormBody(),
622 });
623
624 expect(res.status).toBe(200);
625 const html = await res.text();
626 expect(html).toContain("member-row__error");
627 expect(html).toContain("Something went wrong");
628 });
629
630 it("returns row with unavailable message on network error", async () => {
631 setupPostSession();
632 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
633
634 const routes = await loadAdminRoutes();
635 const res = await routes.request("/admin/members/did:plc:bob/role", {
636 method: "POST",
637 headers: {
638 "Content-Type": "application/x-www-form-urlencoded",
639 cookie: "atbb_session=token",
640 },
641 body: makeFormBody(),
642 });
643
644 expect(res.status).toBe(200);
645 const html = await res.text();
646 expect(html).toContain("member-row__error");
647 expect(html).toContain("temporarily unavailable");
648 });
649
650 it("returns row with error and makes no AppView call when roleUri is missing", async () => {
651 setupPostSession();
652 const routes = await loadAdminRoutes();
653 const res = await routes.request("/admin/members/did:plc:bob/role", {
654 method: "POST",
655 headers: {
656 "Content-Type": "application/x-www-form-urlencoded",
657 cookie: "atbb_session=token",
658 },
659 body: makeFormBody({ roleUri: "" }),
660 });
661
662 expect(res.status).toBe(200);
663 const html = await res.text();
664 expect(html).toContain("member-row__error");
665 expect(mockFetch).not.toHaveBeenCalledWith(
666 expect.stringContaining("/api/admin/members/did:plc:bob/role"),
667 expect.anything()
668 );
669 });
670
671 it("re-renders form with new role pre-selected in dropdown on success", async () => {
672 setupPostSession();
673 mockFetch.mockResolvedValueOnce(
674 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
675 );
676
677 const routes = await loadAdminRoutes();
678 const res = await routes.request("/admin/members/did:plc:bob/role", {
679 method: "POST",
680 headers: {
681 "Content-Type": "application/x-www-form-urlencoded",
682 cookie: "atbb_session=token",
683 },
684 body: makeFormBody({
685 roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
686 }),
687 });
688
689 const html = await res.text();
690 // The newly assigned role URI should appear as the selected option value in the form
691 expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member");
692 });
693
694 it("returns 401 error row for unauthenticated POST", async () => {
695 // No session mock — no cookie
696 const routes = await loadAdminRoutes();
697 const res = await routes.request("/admin/members/did:plc:bob/role", {
698 method: "POST",
699 headers: { "Content-Type": "application/x-www-form-urlencoded" },
700 body: makeFormBody(),
701 });
702
703 expect(res.status).toBe(401);
704 const html = await res.text();
705 expect(html).toContain("member-row__error");
706 expect(mockFetch).not.toHaveBeenCalledWith(
707 expect.stringContaining("/api/admin/members/did:plc:bob/role"),
708 expect.anything()
709 );
710 });
711
712 it("returns 403 error row when user lacks manageRoles", async () => {
713 setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles
714 const routes = await loadAdminRoutes();
715 const res = await routes.request("/admin/members/did:plc:bob/role", {
716 method: "POST",
717 headers: {
718 "Content-Type": "application/x-www-form-urlencoded",
719 cookie: "atbb_session=token",
720 },
721 body: makeFormBody(),
722 });
723
724 expect(res.status).toBe(403);
725 const html = await res.text();
726 expect(html).toContain("member-row__error");
727 // No AppView role assignment call should have been made
728 expect(mockFetch).not.toHaveBeenCalledWith(
729 expect.stringContaining("/api/admin/members/did:plc:bob/role"),
730 expect.anything()
731 );
732 });
733
734 it("returns row with session-expired error when AppView returns 401", async () => {
735 setupPostSession();
736 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
737
738 const routes = await loadAdminRoutes();
739 const res = await routes.request("/admin/members/did:plc:bob/role", {
740 method: "POST",
741 headers: {
742 "Content-Type": "application/x-www-form-urlencoded",
743 cookie: "atbb_session=token",
744 },
745 body: makeFormBody(),
746 });
747
748 expect(res.status).toBe(200);
749 const html = await res.text();
750 expect(html).toContain("member-row__error");
751 expect(html).toContain("session has expired");
752 });
753
754 it("returns error row with reload message when rolesJson is malformed", async () => {
755 setupPostSession();
756
757 const routes = await loadAdminRoutes();
758 const res = await routes.request("/admin/members/did:plc:bob/role", {
759 method: "POST",
760 headers: {
761 "Content-Type": "application/x-www-form-urlencoded",
762 cookie: "atbb_session=token",
763 },
764 body: makeFormBody({ rolesJson: "not-valid-json{{" }),
765 });
766
767 expect(res.status).toBe(200);
768 const html = await res.text();
769 expect(html).toContain("member-row__error");
770 expect(html).toContain("reload");
771 // No AppView call should have been made
772 // (setupPostSession consumed 2 calls, then we check no more were made)
773 expect(mockFetch).toHaveBeenCalledTimes(2);
774 });
775
776 it("returns error row and makes no AppView call when targetDid lacks did: prefix", async () => {
777 setupPostSession();
778
779 const routes = await loadAdminRoutes();
780 const res = await routes.request("/admin/members/notadid/role", {
781 method: "POST",
782 headers: {
783 "Content-Type": "application/x-www-form-urlencoded",
784 cookie: "atbb_session=token",
785 },
786 body: makeFormBody({ handle: "bob.bsky.social" }),
787 });
788
789 expect(res.status).toBe(200);
790 const html = await res.text();
791 expect(html).toContain("member-row__error");
792 expect(html).toContain("Invalid member identifier");
793 // Session fetch calls consumed (2), but no AppView role call made
794 expect(mockFetch).not.toHaveBeenCalledWith(
795 expect.stringContaining("/api/admin/members/notadid/role"),
796 expect.anything()
797 );
798 });
799});
800
801describe("createAdminRoutes — GET /admin/structure", () => {
802 beforeEach(() => {
803 vi.stubGlobal("fetch", mockFetch);
804 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
805 vi.resetModules();
806 });
807
808 afterEach(() => {
809 vi.unstubAllGlobals();
810 vi.unstubAllEnvs();
811 mockFetch.mockReset();
812 });
813
814 function mockResponse(body: unknown, ok = true, status = 200) {
815 return {
816 ok,
817 status,
818 statusText: ok ? "OK" : "Error",
819 json: () => Promise.resolve(body),
820 };
821 }
822
823 function setupSession(permissions: string[]) {
824 mockFetch.mockResolvedValueOnce(
825 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
826 );
827 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
828 }
829
830 /**
831 * Sets up mock responses for the structure page data fetches.
832 * After the 2 session calls:
833 * Call 3: GET /api/categories
834 * Call 4+: GET /api/categories/:id/boards (one per category, parallel)
835 */
836 function setupStructureFetch(
837 cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>,
838 boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {}
839 ) {
840 mockFetch.mockResolvedValueOnce(
841 mockResponse({
842 categories: cats.map((c) => ({
843 id: c.id,
844 did: "did:plc:forum",
845 uri: c.uri,
846 name: c.name,
847 description: null,
848 slug: null,
849 sortOrder: c.sortOrder ?? 1,
850 forumId: "1",
851 createdAt: "2025-01-01T00:00:00.000Z",
852 indexedAt: "2025-01-01T00:00:00.000Z",
853 })),
854 })
855 );
856 for (const cat of cats) {
857 const boards = boardsByCategory[cat.id] ?? [];
858 mockFetch.mockResolvedValueOnce(
859 mockResponse({
860 boards: boards.map((b) => ({
861 id: b.id,
862 did: "did:plc:forum",
863 uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`,
864 name: b.name,
865 description: null,
866 slug: null,
867 sortOrder: 1,
868 categoryId: cat.id,
869 categoryUri: cat.uri,
870 createdAt: "2025-01-01T00:00:00.000Z",
871 indexedAt: "2025-01-01T00:00:00.000Z",
872 })),
873 })
874 );
875 }
876 }
877
878 async function loadAdminRoutes() {
879 const { createAdminRoutes } = await import("../admin.js");
880 return createAdminRoutes("http://localhost:3000");
881 }
882
883 it("redirects unauthenticated users to /login", async () => {
884 mockFetch.mockResolvedValueOnce(
885 mockResponse({ authenticated: false })
886 );
887 const routes = await loadAdminRoutes();
888 const res = await routes.request("/admin/structure");
889 expect(res.status).toBe(302);
890 expect(res.headers.get("location")).toBe("/login");
891 });
892
893 it("returns 403 for authenticated user without manageCategories", async () => {
894 setupSession(["space.atbb.permission.manageMembers"]);
895 const routes = await loadAdminRoutes();
896 const res = await routes.request("/admin/structure", {
897 headers: { cookie: "atbb_session=token" },
898 });
899 expect(res.status).toBe(403);
900 });
901
902 it("renders structure page with category and board names", async () => {
903 setupSession(["space.atbb.permission.manageCategories"]);
904 setupStructureFetch(
905 [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
906 { "1": [{ id: "10", name: "General Chat" }] }
907 );
908
909 const routes = await loadAdminRoutes();
910 const res = await routes.request("/admin/structure", {
911 headers: { cookie: "atbb_session=token" },
912 });
913
914 expect(res.status).toBe(200);
915 const html = await res.text();
916 expect(html).toContain("General Discussion");
917 expect(html).toContain("General Chat");
918 });
919
920 it("renders empty state when no categories exist", async () => {
921 setupSession(["space.atbb.permission.manageCategories"]);
922 setupStructureFetch([]);
923
924 const routes = await loadAdminRoutes();
925 const res = await routes.request("/admin/structure", {
926 headers: { cookie: "atbb_session=token" },
927 });
928
929 expect(res.status).toBe(200);
930 const html = await res.text();
931 expect(html).toContain("No categories");
932 });
933
934 it("renders the add-category form", async () => {
935 setupSession(["space.atbb.permission.manageCategories"]);
936 setupStructureFetch([]);
937
938 const routes = await loadAdminRoutes();
939 const res = await routes.request("/admin/structure", {
940 headers: { cookie: "atbb_session=token" },
941 });
942
943 const html = await res.text();
944 expect(html).toContain('action="/admin/structure/categories"');
945 });
946
947 it("renders edit and delete actions for a category", async () => {
948 setupSession(["space.atbb.permission.manageCategories"]);
949 setupStructureFetch(
950 [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }],
951 );
952
953 const routes = await loadAdminRoutes();
954 const res = await routes.request("/admin/structure", {
955 headers: { cookie: "atbb_session=token" },
956 });
957
958 const html = await res.text();
959 expect(html).toContain('action="/admin/structure/categories/5/edit"');
960 expect(html).toContain('action="/admin/structure/categories/5/delete"');
961 });
962
963 it("renders edit and delete actions for a board", async () => {
964 setupSession(["space.atbb.permission.manageCategories"]);
965 setupStructureFetch(
966 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
967 { "1": [{ id: "20", name: "Showcase" }] }
968 );
969
970 const routes = await loadAdminRoutes();
971 const res = await routes.request("/admin/structure", {
972 headers: { cookie: "atbb_session=token" },
973 });
974
975 const html = await res.text();
976 expect(html).toContain("Showcase");
977 expect(html).toContain('action="/admin/structure/boards/20/edit"');
978 expect(html).toContain('action="/admin/structure/boards/20/delete"');
979 });
980
981 it("renders add-board form with categoryUri hidden input", async () => {
982 setupSession(["space.atbb.permission.manageCategories"]);
983 setupStructureFetch(
984 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
985 );
986
987 const routes = await loadAdminRoutes();
988 const res = await routes.request("/admin/structure", {
989 headers: { cookie: "atbb_session=token" },
990 });
991
992 const html = await res.text();
993 expect(html).toContain('name="categoryUri"');
994 expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"');
995 expect(html).toContain('action="/admin/structure/boards"');
996 });
997
998 it("renders error banner when ?error= query param is present", async () => {
999 setupSession(["space.atbb.permission.manageCategories"]);
1000 setupStructureFetch([]);
1001
1002 const routes = await loadAdminRoutes();
1003 const res = await routes.request(
1004 `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`,
1005 { headers: { cookie: "atbb_session=token" } }
1006 );
1007
1008 const html = await res.text();
1009 expect(html).toContain("Cannot delete category with boards");
1010 });
1011
1012 it("returns 503 on AppView network error fetching categories", async () => {
1013 setupSession(["space.atbb.permission.manageCategories"]);
1014 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1015
1016 const routes = await loadAdminRoutes();
1017 const res = await routes.request("/admin/structure", {
1018 headers: { cookie: "atbb_session=token" },
1019 });
1020
1021 expect(res.status).toBe(503);
1022 const html = await res.text();
1023 expect(html).toContain("error-display");
1024 });
1025
1026 it("returns 500 on AppView server error fetching categories", async () => {
1027 setupSession(["space.atbb.permission.manageCategories"]);
1028 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
1029
1030 const routes = await loadAdminRoutes();
1031 const res = await routes.request("/admin/structure", {
1032 headers: { cookie: "atbb_session=token" },
1033 });
1034
1035 expect(res.status).toBe(500);
1036 const html = await res.text();
1037 expect(html).toContain("error-display");
1038 });
1039
1040 it("redirects to /login when AppView categories returns 401", async () => {
1041 setupSession(["space.atbb.permission.manageCategories"]);
1042 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
1043
1044 const routes = await loadAdminRoutes();
1045 const res = await routes.request("/admin/structure", {
1046 headers: { cookie: "atbb_session=token" },
1047 });
1048
1049 expect(res.status).toBe(302);
1050 expect(res.headers.get("location")).toBe("/login");
1051 });
1052});
1053
1054describe("createAdminRoutes — POST /admin/structure/categories", () => {
1055 beforeEach(() => {
1056 vi.stubGlobal("fetch", mockFetch);
1057 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1058 vi.resetModules();
1059 });
1060
1061 afterEach(() => {
1062 vi.unstubAllGlobals();
1063 vi.unstubAllEnvs();
1064 mockFetch.mockReset();
1065 });
1066
1067 function mockResponse(body: unknown, ok = true, status = 200) {
1068 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1069 }
1070
1071 function setupSession(permissions: string[]) {
1072 mockFetch.mockResolvedValueOnce(
1073 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1074 );
1075 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1076 }
1077
1078 async function loadAdminRoutes() {
1079 const { createAdminRoutes } = await import("../admin.js");
1080 return createAdminRoutes("http://localhost:3000");
1081 }
1082
1083 function postForm(body: Record<string, string>) {
1084 const params = new URLSearchParams(body);
1085 return {
1086 method: "POST",
1087 headers: {
1088 cookie: "atbb_session=token",
1089 "content-type": "application/x-www-form-urlencoded",
1090 },
1091 body: params.toString(),
1092 };
1093 }
1094
1095 it("redirects to /login when unauthenticated", async () => {
1096 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1097 const routes = await loadAdminRoutes();
1098 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" }));
1099 expect(res.status).toBe(302);
1100 expect(res.headers.get("location")).toBe("/login");
1101 });
1102
1103 it("returns 403 without manageCategories permission", async () => {
1104 setupSession(["space.atbb.permission.manageMembers"]);
1105 const routes = await loadAdminRoutes();
1106 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" }));
1107 expect(res.status).toBe(403);
1108 });
1109
1110 it("redirects to /admin/structure on success", async () => {
1111 setupSession(["space.atbb.permission.manageCategories"]);
1112 mockFetch.mockResolvedValueOnce(
1113 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201)
1114 );
1115
1116 const routes = await loadAdminRoutes();
1117 const res = await routes.request(
1118 "/admin/structure/categories",
1119 postForm({ name: "General", description: "Talk about anything", sortOrder: "1" })
1120 );
1121
1122 expect(res.status).toBe(302);
1123 expect(res.headers.get("location")).toBe("/admin/structure");
1124 });
1125
1126 it("redirects with ?error= when name is missing", async () => {
1127 setupSession(["space.atbb.permission.manageCategories"]);
1128
1129 const routes = await loadAdminRoutes();
1130 const res = await routes.request(
1131 "/admin/structure/categories",
1132 postForm({ name: "" })
1133 );
1134
1135 expect(res.status).toBe(302);
1136 const location = res.headers.get("location") ?? "";
1137 expect(location).toContain("/admin/structure");
1138 expect(location).toContain("error=");
1139 });
1140
1141 it("redirects with ?error= on AppView error", async () => {
1142 setupSession(["space.atbb.permission.manageCategories"]);
1143 mockFetch.mockResolvedValueOnce(
1144 mockResponse({ error: "Unexpected error" }, false, 500)
1145 );
1146
1147 const routes = await loadAdminRoutes();
1148 const res = await routes.request(
1149 "/admin/structure/categories",
1150 postForm({ name: "General" })
1151 );
1152
1153 expect(res.status).toBe(302);
1154 const location = res.headers.get("location") ?? "";
1155 expect(location).toContain("/admin/structure");
1156 expect(location).toContain("error=");
1157 });
1158
1159 it("redirects with ?error= on network error", async () => {
1160 setupSession(["space.atbb.permission.manageCategories"]);
1161 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1162
1163 const routes = await loadAdminRoutes();
1164 const res = await routes.request(
1165 "/admin/structure/categories",
1166 postForm({ name: "General" })
1167 );
1168
1169 expect(res.status).toBe(302);
1170 const location = res.headers.get("location") ?? "";
1171 expect(location).toContain("/admin/structure");
1172 expect(location).toContain("error=");
1173 });
1174
1175 it("redirects with ?error= for negative sort order", async () => {
1176 setupSession(["space.atbb.permission.manageCategories"]);
1177
1178 const routes = await loadAdminRoutes();
1179 const res = await routes.request(
1180 "/admin/structure/categories",
1181 postForm({ name: "General", sortOrder: "-1" })
1182 );
1183
1184 expect(res.status).toBe(302);
1185 const location = res.headers.get("location") ?? "";
1186 expect(location).toContain("error=");
1187 });
1188});
1189
1190describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => {
1191 beforeEach(() => {
1192 vi.stubGlobal("fetch", mockFetch);
1193 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1194 vi.resetModules();
1195 });
1196
1197 afterEach(() => {
1198 vi.unstubAllGlobals();
1199 vi.unstubAllEnvs();
1200 mockFetch.mockReset();
1201 });
1202
1203 function mockResponse(body: unknown, ok = true, status = 200) {
1204 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1205 }
1206
1207 function setupSession(permissions: string[]) {
1208 mockFetch.mockResolvedValueOnce(
1209 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1210 );
1211 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1212 }
1213
1214 async function loadAdminRoutes() {
1215 const { createAdminRoutes } = await import("../admin.js");
1216 return createAdminRoutes("http://localhost:3000");
1217 }
1218
1219 function postForm(body: Record<string, string>) {
1220 const params = new URLSearchParams(body);
1221 return {
1222 method: "POST",
1223 headers: {
1224 cookie: "atbb_session=token",
1225 "content-type": "application/x-www-form-urlencoded",
1226 },
1227 body: params.toString(),
1228 };
1229 }
1230
1231 it("redirects to /login when unauthenticated", async () => {
1232 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1233 const routes = await loadAdminRoutes();
1234 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1235 expect(res.status).toBe(302);
1236 expect(res.headers.get("location")).toBe("/login");
1237 });
1238
1239 it("returns 403 without manageCategories", async () => {
1240 setupSession(["space.atbb.permission.manageMembers"]);
1241 const routes = await loadAdminRoutes();
1242 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1243 expect(res.status).toBe(403);
1244 });
1245
1246 it("redirects to /admin/structure on success", async () => {
1247 setupSession(["space.atbb.permission.manageCategories"]);
1248 mockFetch.mockResolvedValueOnce(
1249 mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200)
1250 );
1251
1252 const routes = await loadAdminRoutes();
1253 const res = await routes.request(
1254 "/admin/structure/categories/5/edit",
1255 postForm({ name: "Updated Name", description: "", sortOrder: "2" })
1256 );
1257
1258 expect(res.status).toBe(302);
1259 expect(res.headers.get("location")).toBe("/admin/structure");
1260 });
1261
1262 it("redirects with ?error= when name is missing", async () => {
1263 setupSession(["space.atbb.permission.manageCategories"]);
1264 const routes = await loadAdminRoutes();
1265 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "" }));
1266 expect(res.status).toBe(302);
1267 const location = res.headers.get("location") ?? "";
1268 expect(location).toContain("error=");
1269 });
1270
1271 it("redirects with ?error= on AppView error", async () => {
1272 setupSession(["space.atbb.permission.manageCategories"]);
1273 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Not found" }, false, 404));
1274 const routes = await loadAdminRoutes();
1275 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1276 expect(res.status).toBe(302);
1277 const location = res.headers.get("location") ?? "";
1278 expect(location).toContain("error=");
1279 });
1280
1281 it("redirects with ?error= on network error", async () => {
1282 setupSession(["space.atbb.permission.manageCategories"]);
1283 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1284 const routes = await loadAdminRoutes();
1285 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1286 expect(res.status).toBe(302);
1287 const location = res.headers.get("location") ?? "";
1288 expect(location).toContain("error=");
1289 });
1290
1291 it("redirects with ?error= for negative sort order", async () => {
1292 setupSession(["space.atbb.permission.manageCategories"]);
1293 const routes = await loadAdminRoutes();
1294 const res = await routes.request(
1295 "/admin/structure/categories/5/edit",
1296 postForm({ name: "Updated", sortOrder: "-5" })
1297 );
1298 expect(res.status).toBe(302);
1299 const location = res.headers.get("location") ?? "";
1300 expect(location).toContain("error=");
1301 });
1302});
1303
1304describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => {
1305 beforeEach(() => {
1306 vi.stubGlobal("fetch", mockFetch);
1307 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1308 vi.resetModules();
1309 });
1310
1311 afterEach(() => {
1312 vi.unstubAllGlobals();
1313 vi.unstubAllEnvs();
1314 mockFetch.mockReset();
1315 });
1316
1317 function mockResponse(body: unknown, ok = true, status = 200) {
1318 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1319 }
1320
1321 function setupSession(permissions: string[]) {
1322 mockFetch.mockResolvedValueOnce(
1323 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1324 );
1325 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1326 }
1327
1328 async function loadAdminRoutes() {
1329 const { createAdminRoutes } = await import("../admin.js");
1330 return createAdminRoutes("http://localhost:3000");
1331 }
1332
1333 function postForm(body: Record<string, string> = {}) {
1334 const params = new URLSearchParams(body);
1335 return {
1336 method: "POST",
1337 headers: {
1338 cookie: "atbb_session=token",
1339 "content-type": "application/x-www-form-urlencoded",
1340 },
1341 body: params.toString(),
1342 };
1343 }
1344
1345 it("redirects to /login when unauthenticated", async () => {
1346 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1347 const routes = await loadAdminRoutes();
1348 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1349 expect(res.status).toBe(302);
1350 expect(res.headers.get("location")).toBe("/login");
1351 });
1352
1353 it("returns 403 without manageCategories", async () => {
1354 setupSession(["space.atbb.permission.manageMembers"]);
1355 const routes = await loadAdminRoutes();
1356 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1357 expect(res.status).toBe(403);
1358 });
1359
1360 it("redirects to /admin/structure on success", async () => {
1361 setupSession(["space.atbb.permission.manageCategories"]);
1362 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200));
1363
1364 const routes = await loadAdminRoutes();
1365 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1366
1367 expect(res.status).toBe(302);
1368 expect(res.headers.get("location")).toBe("/admin/structure");
1369 });
1370
1371 it("redirects with ?error= on AppView error (e.g. 409 has boards)", async () => {
1372 setupSession(["space.atbb.permission.manageCategories"]);
1373 mockFetch.mockResolvedValueOnce(
1374 mockResponse({ error: "Cannot delete category with boards. Remove all boards first." }, false, 409)
1375 );
1376
1377 const routes = await loadAdminRoutes();
1378 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1379
1380 expect(res.status).toBe(302);
1381 const location = res.headers.get("location") ?? "";
1382 expect(location).toContain("/admin/structure");
1383 expect(location).toContain("error=");
1384 expect(decodeURIComponent(location)).toContain("Cannot delete category with boards");
1385 });
1386
1387 it("redirects with ?error= on network error", async () => {
1388 setupSession(["space.atbb.permission.manageCategories"]);
1389 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1390
1391 const routes = await loadAdminRoutes();
1392 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1393
1394 expect(res.status).toBe(302);
1395 const location = res.headers.get("location") ?? "";
1396 expect(location).toContain("error=");
1397 });
1398});
1399
1400describe("createAdminRoutes — POST /admin/structure/boards", () => {
1401 beforeEach(() => {
1402 vi.stubGlobal("fetch", mockFetch);
1403 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1404 vi.resetModules();
1405 });
1406
1407 afterEach(() => {
1408 vi.unstubAllGlobals();
1409 vi.unstubAllEnvs();
1410 mockFetch.mockReset();
1411 });
1412
1413 function mockResponse(body: unknown, ok = true, status = 200) {
1414 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1415 }
1416
1417 function setupSession(permissions: string[]) {
1418 mockFetch.mockResolvedValueOnce(
1419 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1420 );
1421 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1422 }
1423
1424 async function loadAdminRoutes() {
1425 const { createAdminRoutes } = await import("../admin.js");
1426 return createAdminRoutes("http://localhost:3000");
1427 }
1428
1429 function postForm(body: Record<string, string>) {
1430 const params = new URLSearchParams(body);
1431 return {
1432 method: "POST",
1433 headers: {
1434 cookie: "atbb_session=token",
1435 "content-type": "application/x-www-form-urlencoded",
1436 },
1437 body: params.toString(),
1438 };
1439 }
1440
1441 it("redirects to /login when unauthenticated", async () => {
1442 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1443 const routes = await loadAdminRoutes();
1444 const res = await routes.request(
1445 "/admin/structure/boards",
1446 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1447 );
1448 expect(res.status).toBe(302);
1449 expect(res.headers.get("location")).toBe("/login");
1450 });
1451
1452 it("returns 403 without manageCategories permission", async () => {
1453 setupSession(["space.atbb.permission.manageMembers"]);
1454 const routes = await loadAdminRoutes();
1455 const res = await routes.request(
1456 "/admin/structure/boards",
1457 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1458 );
1459 expect(res.status).toBe(403);
1460 });
1461
1462 it("redirects to /admin/structure on success", async () => {
1463 setupSession(["space.atbb.permission.manageCategories"]);
1464 mockFetch.mockResolvedValueOnce(
1465 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.board/xyz", cid: "bafyrei..." }, true, 201)
1466 );
1467
1468 const routes = await loadAdminRoutes();
1469 const res = await routes.request(
1470 "/admin/structure/boards",
1471 postForm({
1472 name: "General Chat",
1473 description: "Chat about anything",
1474 sortOrder: "1",
1475 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
1476 })
1477 );
1478
1479 expect(res.status).toBe(302);
1480 expect(res.headers.get("location")).toBe("/admin/structure");
1481 });
1482
1483 it("redirects with ?error= when name is missing", async () => {
1484 setupSession(["space.atbb.permission.manageCategories"]);
1485 const routes = await loadAdminRoutes();
1486 const res = await routes.request(
1487 "/admin/structure/boards",
1488 postForm({ name: "", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1489 );
1490 expect(res.status).toBe(302);
1491 const location = res.headers.get("location") ?? "";
1492 expect(location).toContain("/admin/structure");
1493 expect(location).toContain("error=");
1494 });
1495
1496 it("redirects with ?error= when categoryUri is missing", async () => {
1497 setupSession(["space.atbb.permission.manageCategories"]);
1498 const routes = await loadAdminRoutes();
1499 const res = await routes.request(
1500 "/admin/structure/boards",
1501 postForm({ name: "General Chat", categoryUri: "" })
1502 );
1503 expect(res.status).toBe(302);
1504 const location = res.headers.get("location") ?? "";
1505 expect(location).toContain("error=");
1506 });
1507
1508 it("redirects with ?error= on AppView error", async () => {
1509 setupSession(["space.atbb.permission.manageCategories"]);
1510 mockFetch.mockResolvedValueOnce(
1511 mockResponse({ error: "Category not found" }, false, 404)
1512 );
1513
1514 const routes = await loadAdminRoutes();
1515 const res = await routes.request(
1516 "/admin/structure/boards",
1517 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1518 );
1519
1520 expect(res.status).toBe(302);
1521 const location = res.headers.get("location") ?? "";
1522 expect(location).toContain("error=");
1523 });
1524
1525 it("redirects with ?error= on network error", async () => {
1526 setupSession(["space.atbb.permission.manageCategories"]);
1527 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1528
1529 const routes = await loadAdminRoutes();
1530 const res = await routes.request(
1531 "/admin/structure/boards",
1532 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1533 );
1534
1535 expect(res.status).toBe(302);
1536 const location = res.headers.get("location") ?? "";
1537 expect(location).toContain("error=");
1538 });
1539
1540 it("redirects with ?error= for negative sort order", async () => {
1541 setupSession(["space.atbb.permission.manageCategories"]);
1542 const routes = await loadAdminRoutes();
1543 const res = await routes.request(
1544 "/admin/structure/boards",
1545 postForm({
1546 name: "General Chat",
1547 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
1548 sortOrder: "-2",
1549 })
1550 );
1551 expect(res.status).toBe(302);
1552 const location = res.headers.get("location") ?? "";
1553 expect(location).toContain("error=");
1554 });
1555});
1556
1557describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => {
1558 beforeEach(() => {
1559 vi.stubGlobal("fetch", mockFetch);
1560 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1561 vi.resetModules();
1562 });
1563
1564 afterEach(() => {
1565 vi.unstubAllGlobals();
1566 vi.unstubAllEnvs();
1567 mockFetch.mockReset();
1568 });
1569
1570 function mockResponse(body: unknown, ok = true, status = 200) {
1571 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1572 }
1573
1574 function setupSession(permissions: string[]) {
1575 mockFetch.mockResolvedValueOnce(
1576 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1577 );
1578 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1579 }
1580
1581 async function loadAdminRoutes() {
1582 const { createAdminRoutes } = await import("../admin.js");
1583 return createAdminRoutes("http://localhost:3000");
1584 }
1585
1586 function postForm(body: Record<string, string>) {
1587 const params = new URLSearchParams(body);
1588 return {
1589 method: "POST",
1590 headers: {
1591 cookie: "atbb_session=token",
1592 "content-type": "application/x-www-form-urlencoded",
1593 },
1594 body: params.toString(),
1595 };
1596 }
1597
1598 it("redirects to /login when unauthenticated", async () => {
1599 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1600 const routes = await loadAdminRoutes();
1601 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1602 expect(res.status).toBe(302);
1603 expect(res.headers.get("location")).toBe("/login");
1604 });
1605
1606 it("returns 403 without manageCategories", async () => {
1607 setupSession(["space.atbb.permission.manageMembers"]);
1608 const routes = await loadAdminRoutes();
1609 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1610 expect(res.status).toBe(403);
1611 });
1612
1613 it("redirects to /admin/structure on success", async () => {
1614 setupSession(["space.atbb.permission.manageCategories"]);
1615 mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200));
1616
1617 const routes = await loadAdminRoutes();
1618 const res = await routes.request(
1619 "/admin/structure/boards/10/edit",
1620 postForm({ name: "Updated Board", description: "", sortOrder: "3" })
1621 );
1622
1623 expect(res.status).toBe(302);
1624 expect(res.headers.get("location")).toBe("/admin/structure");
1625 });
1626
1627 it("redirects with ?error= when name is missing", async () => {
1628 setupSession(["space.atbb.permission.manageCategories"]);
1629 const routes = await loadAdminRoutes();
1630 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "" }));
1631 expect(res.status).toBe(302);
1632 const location = res.headers.get("location") ?? "";
1633 expect(location).toContain("error=");
1634 });
1635
1636 it("redirects with ?error= on AppView error", async () => {
1637 setupSession(["space.atbb.permission.manageCategories"]);
1638 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Board not found" }, false, 404));
1639 const routes = await loadAdminRoutes();
1640 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1641 expect(res.status).toBe(302);
1642 const location = res.headers.get("location") ?? "";
1643 expect(location).toContain("error=");
1644 });
1645
1646 it("redirects with ?error= on network error", async () => {
1647 setupSession(["space.atbb.permission.manageCategories"]);
1648 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1649 const routes = await loadAdminRoutes();
1650 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1651 expect(res.status).toBe(302);
1652 const location = res.headers.get("location") ?? "";
1653 expect(location).toContain("error=");
1654 });
1655
1656 it("redirects with ?error= for negative sort order", async () => {
1657 setupSession(["space.atbb.permission.manageCategories"]);
1658 const routes = await loadAdminRoutes();
1659 const res = await routes.request(
1660 "/admin/structure/boards/10/edit",
1661 postForm({ name: "Updated Board", sortOrder: "-3" })
1662 );
1663 expect(res.status).toBe(302);
1664 const location = res.headers.get("location") ?? "";
1665 expect(location).toContain("error=");
1666 });
1667});
1668
1669describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => {
1670 beforeEach(() => {
1671 vi.stubGlobal("fetch", mockFetch);
1672 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1673 vi.resetModules();
1674 });
1675
1676 afterEach(() => {
1677 vi.unstubAllGlobals();
1678 vi.unstubAllEnvs();
1679 mockFetch.mockReset();
1680 });
1681
1682 function mockResponse(body: unknown, ok = true, status = 200) {
1683 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1684 }
1685
1686 function setupSession(permissions: string[]) {
1687 mockFetch.mockResolvedValueOnce(
1688 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1689 );
1690 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1691 }
1692
1693 async function loadAdminRoutes() {
1694 const { createAdminRoutes } = await import("../admin.js");
1695 return createAdminRoutes("http://localhost:3000");
1696 }
1697
1698 function postForm(body: Record<string, string> = {}) {
1699 const params = new URLSearchParams(body);
1700 return {
1701 method: "POST",
1702 headers: {
1703 cookie: "atbb_session=token",
1704 "content-type": "application/x-www-form-urlencoded",
1705 },
1706 body: params.toString(),
1707 };
1708 }
1709
1710 it("redirects to /login when unauthenticated", async () => {
1711 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1712 const routes = await loadAdminRoutes();
1713 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1714 expect(res.status).toBe(302);
1715 expect(res.headers.get("location")).toBe("/login");
1716 });
1717
1718 it("returns 403 without manageCategories", async () => {
1719 setupSession(["space.atbb.permission.manageMembers"]);
1720 const routes = await loadAdminRoutes();
1721 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1722 expect(res.status).toBe(403);
1723 });
1724
1725 it("redirects to /admin/structure on success", async () => {
1726 setupSession(["space.atbb.permission.manageCategories"]);
1727 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200));
1728
1729 const routes = await loadAdminRoutes();
1730 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1731
1732 expect(res.status).toBe(302);
1733 expect(res.headers.get("location")).toBe("/admin/structure");
1734 });
1735
1736 it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => {
1737 setupSession(["space.atbb.permission.manageCategories"]);
1738 mockFetch.mockResolvedValueOnce(
1739 mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409)
1740 );
1741
1742 const routes = await loadAdminRoutes();
1743 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1744
1745 expect(res.status).toBe(302);
1746 const location = res.headers.get("location") ?? "";
1747 expect(decodeURIComponent(location)).toContain("Cannot delete board with posts");
1748 });
1749
1750 it("redirects with ?error= on network error", async () => {
1751 setupSession(["space.atbb.permission.manageCategories"]);
1752 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1753
1754 const routes = await loadAdminRoutes();
1755 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1756
1757 expect(res.status).toBe(302);
1758 const location = res.headers.get("location") ?? "";
1759 expect(location).toContain("error=");
1760 });
1761});
1762
1763describe("createAdminRoutes — GET /admin/modlog", () => {
1764 beforeEach(() => {
1765 vi.stubGlobal("fetch", mockFetch);
1766 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1767 vi.resetModules();
1768 });
1769
1770 afterEach(() => {
1771 vi.unstubAllGlobals();
1772 vi.unstubAllEnvs();
1773 mockFetch.mockReset();
1774 });
1775
1776 function mockResponse(body: unknown, ok = true, status = 200) {
1777 return {
1778 ok,
1779 status,
1780 statusText: ok ? "OK" : "Error",
1781 json: () => Promise.resolve(body),
1782 };
1783 }
1784
1785 function setupSession(permissions: string[]) {
1786 mockFetch.mockResolvedValueOnce(
1787 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1788 );
1789 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1790 }
1791
1792 async function loadAdminRoutes() {
1793 const { createAdminRoutes } = await import("../admin.js");
1794 return createAdminRoutes("http://localhost:3000");
1795 }
1796
1797 const SAMPLE_ACTIONS = [
1798 {
1799 id: "1",
1800 action: "space.atbb.modAction.ban",
1801 moderatorDid: "did:plc:alice",
1802 moderatorHandle: "alice.bsky.social",
1803 subjectDid: "did:plc:bob",
1804 subjectHandle: "bob.bsky.social",
1805 subjectPostUri: null,
1806 reason: "Spam",
1807 createdAt: "2026-02-26T12:01:00.000Z",
1808 },
1809 {
1810 id: "2",
1811 action: "space.atbb.modAction.delete",
1812 moderatorDid: "did:plc:alice",
1813 moderatorHandle: "alice.bsky.social",
1814 subjectDid: null,
1815 subjectHandle: null,
1816 subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123",
1817 reason: "Inappropriate",
1818 createdAt: "2026-02-26T11:30:00.000Z",
1819 },
1820 ];
1821
1822 // ── Auth & permission gates ──────────────────────────────────────────────
1823
1824 it("redirects unauthenticated users to /login", async () => {
1825 const routes = await loadAdminRoutes();
1826 const res = await routes.request("/admin/modlog");
1827 expect(res.status).toBe(302);
1828 expect(res.headers.get("location")).toBe("/login");
1829 });
1830
1831 it("returns 403 for user without any mod permission", async () => {
1832 setupSession(["space.atbb.permission.manageCategories"]);
1833 const routes = await loadAdminRoutes();
1834 const res = await routes.request("/admin/modlog", {
1835 headers: { cookie: "atbb_session=token" },
1836 });
1837 expect(res.status).toBe(403);
1838 const html = await res.text();
1839 expect(html).toContain("permission");
1840 });
1841
1842 it("allows access for moderatePosts permission", async () => {
1843 setupSession(["space.atbb.permission.moderatePosts"]);
1844 mockFetch.mockResolvedValueOnce(
1845 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1846 );
1847 const routes = await loadAdminRoutes();
1848 const res = await routes.request("/admin/modlog", {
1849 headers: { cookie: "atbb_session=token" },
1850 });
1851 expect(res.status).toBe(200);
1852 });
1853
1854 it("allows access for banUsers permission", async () => {
1855 setupSession(["space.atbb.permission.banUsers"]);
1856 mockFetch.mockResolvedValueOnce(
1857 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1858 );
1859 const routes = await loadAdminRoutes();
1860 const res = await routes.request("/admin/modlog", {
1861 headers: { cookie: "atbb_session=token" },
1862 });
1863 expect(res.status).toBe(200);
1864 });
1865
1866 it("allows access for lockTopics permission", async () => {
1867 setupSession(["space.atbb.permission.lockTopics"]);
1868 mockFetch.mockResolvedValueOnce(
1869 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1870 );
1871 const routes = await loadAdminRoutes();
1872 const res = await routes.request("/admin/modlog", {
1873 headers: { cookie: "atbb_session=token" },
1874 });
1875 expect(res.status).toBe(200);
1876 });
1877
1878 // ── Table rendering ──────────────────────────────────────────────────────
1879
1880 it("renders table with moderator handle and action label", async () => {
1881 setupSession(["space.atbb.permission.banUsers"]);
1882 mockFetch.mockResolvedValueOnce(
1883 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1884 );
1885 const routes = await loadAdminRoutes();
1886 const res = await routes.request("/admin/modlog", {
1887 headers: { cookie: "atbb_session=token" },
1888 });
1889 const html = await res.text();
1890 expect(html).toContain("alice.bsky.social");
1891 expect(html).toContain("Ban");
1892 expect(html).toContain("bob.bsky.social");
1893 expect(html).toContain("Spam");
1894 });
1895
1896 it("maps space.atbb.modAction.delete to 'Hide' label", async () => {
1897 setupSession(["space.atbb.permission.moderatePosts"]);
1898 mockFetch.mockResolvedValueOnce(
1899 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1900 );
1901 const routes = await loadAdminRoutes();
1902 const res = await routes.request("/admin/modlog", {
1903 headers: { cookie: "atbb_session=token" },
1904 });
1905 const html = await res.text();
1906 expect(html).toContain("Hide");
1907 });
1908
1909 it("shows post URI in subject column for post-targeting actions", async () => {
1910 setupSession(["space.atbb.permission.moderatePosts"]);
1911 mockFetch.mockResolvedValueOnce(
1912 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1913 );
1914 const routes = await loadAdminRoutes();
1915 const res = await routes.request("/admin/modlog", {
1916 headers: { cookie: "atbb_session=token" },
1917 });
1918 const html = await res.text();
1919 expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123");
1920 });
1921
1922 it("shows handle in subject column for user-targeting actions", async () => {
1923 setupSession(["space.atbb.permission.banUsers"]);
1924 mockFetch.mockResolvedValueOnce(
1925 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1926 );
1927 const routes = await loadAdminRoutes();
1928 const res = await routes.request("/admin/modlog", {
1929 headers: { cookie: "atbb_session=token" },
1930 });
1931 const html = await res.text();
1932 expect(html).toContain("bob.bsky.social");
1933 });
1934
1935 it("shows empty state when no actions", async () => {
1936 setupSession(["space.atbb.permission.banUsers"]);
1937 mockFetch.mockResolvedValueOnce(
1938 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1939 );
1940 const routes = await loadAdminRoutes();
1941 const res = await routes.request("/admin/modlog", {
1942 headers: { cookie: "atbb_session=token" },
1943 });
1944 const html = await res.text();
1945 expect(html).toContain("No moderation actions");
1946 });
1947
1948 // ── Pagination ───────────────────────────────────────────────────────────
1949
1950 it("renders 'Page 1 of 2' indicator for 51 total actions", async () => {
1951 setupSession(["space.atbb.permission.banUsers"]);
1952 mockFetch.mockResolvedValueOnce(
1953 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
1954 );
1955 const routes = await loadAdminRoutes();
1956 const res = await routes.request("/admin/modlog", {
1957 headers: { cookie: "atbb_session=token" },
1958 });
1959 const html = await res.text();
1960 expect(html).toContain("Page 1 of 2");
1961 });
1962
1963 it("shows Next link when more pages exist", async () => {
1964 setupSession(["space.atbb.permission.banUsers"]);
1965 mockFetch.mockResolvedValueOnce(
1966 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
1967 );
1968 const routes = await loadAdminRoutes();
1969 const res = await routes.request("/admin/modlog", {
1970 headers: { cookie: "atbb_session=token" },
1971 });
1972 const html = await res.text();
1973 expect(html).toContain('href="/admin/modlog?offset=50"');
1974 expect(html).toContain("Next");
1975 });
1976
1977 it("hides Next link on last page", async () => {
1978 setupSession(["space.atbb.permission.banUsers"]);
1979 mockFetch.mockResolvedValueOnce(
1980 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
1981 );
1982 const routes = await loadAdminRoutes();
1983 const res = await routes.request("/admin/modlog?offset=50", {
1984 headers: { cookie: "atbb_session=token" },
1985 });
1986 const html = await res.text();
1987 expect(html).not.toContain('href="/admin/modlog?offset=100"');
1988 });
1989
1990 it("shows Previous link when not on first page", async () => {
1991 setupSession(["space.atbb.permission.banUsers"]);
1992 mockFetch.mockResolvedValueOnce(
1993 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
1994 );
1995 const routes = await loadAdminRoutes();
1996 const res = await routes.request("/admin/modlog?offset=50", {
1997 headers: { cookie: "atbb_session=token" },
1998 });
1999 const html = await res.text();
2000 expect(html).toContain('href="/admin/modlog?offset=0"');
2001 expect(html).toContain("Previous");
2002 });
2003
2004 it("hides Previous link on first page", async () => {
2005 setupSession(["space.atbb.permission.banUsers"]);
2006 mockFetch.mockResolvedValueOnce(
2007 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
2008 );
2009 const routes = await loadAdminRoutes();
2010 const res = await routes.request("/admin/modlog", {
2011 headers: { cookie: "atbb_session=token" },
2012 });
2013 const html = await res.text();
2014 expect(html).not.toContain('href="/admin/modlog?offset=-50"');
2015 expect(html).not.toContain("Previous");
2016 });
2017
2018 it("passes offset query param to AppView", async () => {
2019 setupSession(["space.atbb.permission.banUsers"]);
2020 mockFetch.mockResolvedValueOnce(
2021 mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 })
2022 );
2023 const routes = await loadAdminRoutes();
2024 await routes.request("/admin/modlog?offset=50", {
2025 headers: { cookie: "atbb_session=token" },
2026 });
2027 // Third fetch call (index 2) is the modlog API call
2028 const modlogCall = mockFetch.mock.calls[2];
2029 expect(modlogCall[0]).toContain("offset=50");
2030 expect(modlogCall[0]).toContain("limit=50");
2031 });
2032
2033 it("ignores invalid offset and defaults to 0", async () => {
2034 setupSession(["space.atbb.permission.banUsers"]);
2035 mockFetch.mockResolvedValueOnce(
2036 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
2037 );
2038 const routes = await loadAdminRoutes();
2039 const res = await routes.request("/admin/modlog?offset=notanumber", {
2040 headers: { cookie: "atbb_session=token" },
2041 });
2042 expect(res.status).toBe(200);
2043 const modlogCall = mockFetch.mock.calls[2];
2044 expect(modlogCall[0]).toContain("offset=0");
2045 });
2046
2047 // ── Error handling ───────────────────────────────────────────────────────
2048
2049 it("returns 503 on AppView network error", async () => {
2050 setupSession(["space.atbb.permission.banUsers"]);
2051 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
2052 const routes = await loadAdminRoutes();
2053 const res = await routes.request("/admin/modlog", {
2054 headers: { cookie: "atbb_session=token" },
2055 });
2056 expect(res.status).toBe(503);
2057 const html = await res.text();
2058 expect(html).toContain("error-display");
2059 });
2060
2061 it("returns 500 on AppView server error", async () => {
2062 setupSession(["space.atbb.permission.banUsers"]);
2063 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
2064 const routes = await loadAdminRoutes();
2065 const res = await routes.request("/admin/modlog", {
2066 headers: { cookie: "atbb_session=token" },
2067 });
2068 expect(res.status).toBe(500);
2069 const html = await res.text();
2070 expect(html).toContain("error-display");
2071 });
2072
2073 it("returns 500 when AppView returns non-JSON response body", async () => {
2074 setupSession(["space.atbb.permission.banUsers"]);
2075 mockFetch.mockResolvedValueOnce({
2076 ok: true,
2077 status: 200,
2078 statusText: "OK",
2079 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")),
2080 });
2081 const routes = await loadAdminRoutes();
2082 const res = await routes.request("/admin/modlog", {
2083 headers: { cookie: "atbb_session=token" },
2084 });
2085 expect(res.status).toBe(500);
2086 const html = await res.text();
2087 expect(html).toContain("error-display");
2088 });
2089
2090 it("redirects to /login when AppView returns 401", async () => {
2091 setupSession(["space.atbb.permission.banUsers"]);
2092 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
2093 const routes = await loadAdminRoutes();
2094 const res = await routes.request("/admin/modlog", {
2095 headers: { cookie: "atbb_session=token" },
2096 });
2097 expect(res.status).toBe(302);
2098 expect(res.headers.get("location")).toBe("/login");
2099 });
2100});
2101
2102describe("createAdminRoutes — GET /admin/themes", () => {
2103 beforeEach(() => {
2104 vi.stubGlobal("fetch", mockFetch);
2105 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
2106 vi.resetModules();
2107 });
2108
2109 afterEach(() => {
2110 vi.unstubAllGlobals();
2111 vi.unstubAllEnvs();
2112 mockFetch.mockReset();
2113 });
2114
2115 function mockResponse(body: unknown, ok = true, status = 200) {
2116 return {
2117 ok,
2118 status,
2119 statusText: ok ? "OK" : "Error",
2120 json: () => Promise.resolve(body),
2121 };
2122 }
2123
2124 function setupAuthenticatedSession(permissions: string[]) {
2125 mockFetch.mockResolvedValueOnce(
2126 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
2127 );
2128 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
2129 }
2130
2131 async function loadAdminRoutes() {
2132 const { createAdminRoutes } = await import("../admin.js");
2133 return createAdminRoutes("http://localhost:3000");
2134 }
2135
2136 it("redirects unauthenticated users to /login", async () => {
2137 const routes = await loadAdminRoutes();
2138 const res = await routes.request("/admin/themes");
2139 expect(res.status).toBe(302);
2140 expect(res.headers.get("location")).toBe("/login");
2141 });
2142
2143 it("returns 403 for users without manageThemes permission", async () => {
2144 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
2145 const routes = await loadAdminRoutes();
2146 const res = await routes.request("/admin/themes", {
2147 headers: { cookie: "atbb_session=token" },
2148 });
2149 expect(res.status).toBe(403);
2150 });
2151
2152 it("renders theme cards with name, colorScheme badge, and swatches", async () => {
2153 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2154 // GET /api/admin/themes
2155 mockFetch.mockResolvedValueOnce(
2156 mockResponse({
2157 themes: [
2158 {
2159 id: "1",
2160 uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa",
2161 name: "Neobrutal Light",
2162 colorScheme: "light",
2163 tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00", "color-surface": "#ffffff", "color-secondary": "#3a86ff", "color-border": "#1a1a1a" },
2164 cssOverrides: null,
2165 fontUrls: null,
2166 createdAt: "2026-01-01T00:00:00.000Z",
2167 indexedAt: "2026-01-01T00:00:00.000Z",
2168 },
2169 ],
2170 })
2171 );
2172 // GET /api/theme-policy
2173 mockFetch.mockResolvedValueOnce(
2174 mockResponse({
2175 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa",
2176 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa",
2177 allowUserChoice: true,
2178 availableThemes: [
2179 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", cid: "bafytheme1" },
2180 ],
2181 })
2182 );
2183
2184 const routes = await loadAdminRoutes();
2185 const res = await routes.request("/admin/themes", {
2186 headers: { cookie: "atbb_session=token" },
2187 });
2188 expect(res.status).toBe(200);
2189 const html = await res.text();
2190 expect(html).toContain("Neobrutal Light");
2191 expect(html).toContain("light"); // colorScheme badge
2192 expect(html).toContain("#f5f0e8"); // color-bg swatch
2193 expect(html).toContain("#ff5c00"); // color-primary swatch
2194 expect(html).toContain("policy-form"); // policy form id
2195 expect(html).toContain("availableThemes"); // checkbox name
2196 });
2197
2198 it("shows error banner when ?error= query param is present", async () => {
2199 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2200 mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] }));
2201 mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); // no policy yet
2202
2203 const routes = await loadAdminRoutes();
2204 const res = await routes.request(
2205 "/admin/themes?error=" + encodeURIComponent("Cannot delete a default theme"),
2206 { headers: { cookie: "atbb_session=token" } }
2207 );
2208 expect(res.status).toBe(200);
2209 const html = await res.text();
2210 expect(html).toContain("Cannot delete a default theme");
2211 expect(html).toContain("structure-error-banner");
2212 });
2213
2214 it("renders create form with preset options", async () => {
2215 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2216 mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] }));
2217 mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404));
2218
2219 const routes = await loadAdminRoutes();
2220 const res = await routes.request("/admin/themes", {
2221 headers: { cookie: "atbb_session=token" },
2222 });
2223 expect(res.status).toBe(200);
2224 const html = await res.text();
2225 expect(html).toContain("neobrutal-light");
2226 expect(html).toContain("neobrutal-dark");
2227 expect(html).toContain("blank");
2228 });
2229
2230 it("renders page gracefully when AppView returns non-JSON response", async () => {
2231 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2232 // AppView returns an HTML error page — .json() throws SyntaxError
2233 mockFetch.mockResolvedValueOnce({
2234 ok: true,
2235 status: 200,
2236 statusText: "OK",
2237 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")),
2238 });
2239 mockFetch.mockResolvedValueOnce({
2240 ok: true,
2241 status: 200,
2242 statusText: "OK",
2243 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")),
2244 });
2245
2246 const routes = await loadAdminRoutes();
2247 const res = await routes.request("/admin/themes", {
2248 headers: { cookie: "atbb_session=token" },
2249 });
2250 // Should render the page with empty data rather than crashing with 500
2251 expect(res.status).toBe(200);
2252 const html = await res.text();
2253 expect(html).toContain("No themes yet");
2254 });
2255});
2256
2257describe("createAdminRoutes — POST /admin/themes", () => {
2258 beforeEach(() => {
2259 vi.stubGlobal("fetch", mockFetch);
2260 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
2261 vi.resetModules();
2262 });
2263
2264 afterEach(() => {
2265 vi.unstubAllGlobals();
2266 vi.unstubAllEnvs();
2267 mockFetch.mockReset();
2268 });
2269
2270 function mockResponse(body: unknown, ok = true, status = 200) {
2271 return { ok, status, json: () => Promise.resolve(body) };
2272 }
2273
2274 function setupAuthenticatedSession(permissions: string[]) {
2275 mockFetch.mockResolvedValueOnce(
2276 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
2277 );
2278 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
2279 }
2280
2281 async function loadAdminRoutes() {
2282 const { createAdminRoutes } = await import("../admin.js");
2283 return createAdminRoutes("http://localhost:3000");
2284 }
2285
2286 it("redirects to /login when unauthenticated", async () => {
2287 mockFetch.mockResolvedValueOnce(
2288 mockResponse({ authenticated: false, did: null, handle: null })
2289 );
2290 const routes = await loadAdminRoutes();
2291 const res = await routes.request("/admin/themes", {
2292 method: "POST",
2293 headers: { "content-type": "application/x-www-form-urlencoded" },
2294 body: "name=Test&colorScheme=light&preset=blank",
2295 });
2296 expect(res.status).toBe(302);
2297 expect(res.headers.get("location")).toBe("/login");
2298 });
2299
2300 it("returns 403 without manageThemes permission", async () => {
2301 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
2302 const routes = await loadAdminRoutes();
2303 const res = await routes.request("/admin/themes", {
2304 method: "POST",
2305 headers: {
2306 cookie: "atbb_session=token",
2307 "content-type": "application/x-www-form-urlencoded",
2308 },
2309 body: "name=Test&colorScheme=light&preset=blank",
2310 });
2311 expect(res.status).toBe(403);
2312 });
2313
2314 it("redirects with error on network failure", async () => {
2315 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2316 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
2317
2318 const routes = await loadAdminRoutes();
2319 const res = await routes.request("/admin/themes", {
2320 method: "POST",
2321 headers: {
2322 cookie: "atbb_session=token",
2323 "content-type": "application/x-www-form-urlencoded",
2324 },
2325 body: "name=My+Theme&colorScheme=light&preset=blank",
2326 });
2327
2328 expect(res.status).toBe(302);
2329 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2330 });
2331
2332 it("creates theme and redirects to /admin/themes on success", async () => {
2333 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2334 mockFetch.mockResolvedValueOnce(
2335 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.theme/newrkey", cid: "bafy" }, true, 201)
2336 );
2337
2338 const routes = await loadAdminRoutes();
2339 const res = await routes.request("/admin/themes", {
2340 method: "POST",
2341 headers: {
2342 cookie: "atbb_session=token",
2343 "content-type": "application/x-www-form-urlencoded",
2344 },
2345 body: "name=My+Theme&colorScheme=light&preset=neobrutal-light",
2346 });
2347
2348 expect(res.status).toBe(302);
2349 expect(res.headers.get("location")).toBe("/admin/themes");
2350 });
2351
2352 it("sends preset tokens to API when preset is neobrutal-light", async () => {
2353 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2354 mockFetch.mockResolvedValueOnce(
2355 mockResponse({ uri: "at://...", cid: "bafy" }, true, 201)
2356 );
2357
2358 const routes = await loadAdminRoutes();
2359 await routes.request("/admin/themes", {
2360 method: "POST",
2361 headers: {
2362 cookie: "atbb_session=token",
2363 "content-type": "application/x-www-form-urlencoded",
2364 },
2365 body: "name=Neo&colorScheme=light&preset=neobrutal-light",
2366 });
2367
2368 // The API call should contain the preset tokens
2369 const apiCall = mockFetch.mock.calls[2]; // calls 0+1 = auth, call 2 = POST /api/admin/themes
2370 const body = JSON.parse(apiCall[1].body);
2371 expect(body.tokens).toHaveProperty("color-bg");
2372 expect(body.tokens["color-bg"]).toBe("#f5f0e8");
2373 expect(body.name).toBe("Neo");
2374 expect(body.colorScheme).toBe("light");
2375 });
2376
2377 it("sends empty tokens for blank preset", async () => {
2378 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2379 mockFetch.mockResolvedValueOnce(
2380 mockResponse({ uri: "at://...", cid: "bafy" }, true, 201)
2381 );
2382
2383 const routes = await loadAdminRoutes();
2384 await routes.request("/admin/themes", {
2385 method: "POST",
2386 headers: {
2387 cookie: "atbb_session=token",
2388 "content-type": "application/x-www-form-urlencoded",
2389 },
2390 body: "name=Blank+Theme&colorScheme=light&preset=blank",
2391 });
2392
2393 const apiCall = mockFetch.mock.calls[2];
2394 const body = JSON.parse(apiCall[1].body);
2395 expect(body.tokens).toEqual({});
2396 });
2397
2398 it("redirects with error when name is missing", async () => {
2399 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2400
2401 const routes = await loadAdminRoutes();
2402 const res = await routes.request("/admin/themes", {
2403 method: "POST",
2404 headers: {
2405 cookie: "atbb_session=token",
2406 "content-type": "application/x-www-form-urlencoded",
2407 },
2408 body: "colorScheme=light&preset=blank",
2409 });
2410
2411 expect(res.status).toBe(302);
2412 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2413 expect(res.headers.get("location")).toContain("required");
2414 });
2415
2416 it("redirects with error on AppView API failure", async () => {
2417 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2418 mockFetch.mockResolvedValueOnce(
2419 mockResponse({ error: "Theme creation failed" }, false, 500)
2420 );
2421
2422 const routes = await loadAdminRoutes();
2423 const res = await routes.request("/admin/themes", {
2424 method: "POST",
2425 headers: {
2426 cookie: "atbb_session=token",
2427 "content-type": "application/x-www-form-urlencoded",
2428 },
2429 body: "name=My+Theme&colorScheme=light&preset=blank",
2430 });
2431
2432 expect(res.status).toBe(302);
2433 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2434 });
2435});
2436
2437describe("createAdminRoutes — POST /admin/themes/:rkey/duplicate", () => {
2438 beforeEach(() => {
2439 vi.stubGlobal("fetch", mockFetch);
2440 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
2441 vi.resetModules();
2442 });
2443
2444 afterEach(() => {
2445 vi.unstubAllGlobals();
2446 vi.unstubAllEnvs();
2447 mockFetch.mockReset();
2448 });
2449
2450 function mockResponse(body: unknown, ok = true, status = 200) {
2451 return { ok, status, json: () => Promise.resolve(body) };
2452 }
2453
2454 function setupAuthenticatedSession(permissions: string[]) {
2455 mockFetch.mockResolvedValueOnce(
2456 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
2457 );
2458 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
2459 }
2460
2461 async function loadAdminRoutes() {
2462 const { createAdminRoutes } = await import("../admin.js");
2463 return createAdminRoutes("http://localhost:3000");
2464 }
2465
2466 it("redirects to /login when unauthenticated", async () => {
2467 mockFetch.mockResolvedValueOnce(
2468 mockResponse({ authenticated: false, did: null, handle: null })
2469 );
2470 const routes = await loadAdminRoutes();
2471 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", {
2472 method: "POST",
2473 headers: { "content-type": "application/x-www-form-urlencoded" },
2474 });
2475 expect(res.status).toBe(302);
2476 expect(res.headers.get("location")).toBe("/login");
2477 });
2478
2479 it("returns 403 without manageThemes permission", async () => {
2480 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
2481 const routes = await loadAdminRoutes();
2482 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", {
2483 method: "POST",
2484 headers: {
2485 cookie: "atbb_session=token",
2486 "content-type": "application/x-www-form-urlencoded",
2487 },
2488 });
2489 expect(res.status).toBe(403);
2490 });
2491
2492 it("redirects with error on network failure", async () => {
2493 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2494 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
2495
2496 const routes = await loadAdminRoutes();
2497 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", {
2498 method: "POST",
2499 headers: { cookie: "atbb_session=token" },
2500 });
2501
2502 expect(res.status).toBe(302);
2503 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2504 });
2505
2506 it("duplicates theme and redirects to /admin/themes on success", async () => {
2507 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2508 mockFetch.mockResolvedValueOnce(
2509 mockResponse(
2510 { uri: "at://...", rkey: "newrkey", name: "Neobrutal Light (Copy)" },
2511 true,
2512 201
2513 )
2514 );
2515
2516 const routes = await loadAdminRoutes();
2517 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", {
2518 method: "POST",
2519 headers: { cookie: "atbb_session=token" },
2520 });
2521
2522 expect(res.status).toBe(302);
2523 expect(res.headers.get("location")).toBe("/admin/themes");
2524 });
2525
2526 it("redirects with error on AppView failure", async () => {
2527 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2528 mockFetch.mockResolvedValueOnce(
2529 mockResponse({ error: "Theme not found" }, false, 404)
2530 );
2531
2532 const routes = await loadAdminRoutes();
2533 const res = await routes.request("/admin/themes/nonexistent/duplicate", {
2534 method: "POST",
2535 headers: { cookie: "atbb_session=token" },
2536 });
2537
2538 expect(res.status).toBe(302);
2539 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2540 });
2541});
2542
2543describe("createAdminRoutes — POST /admin/themes/:rkey/delete", () => {
2544 beforeEach(() => {
2545 vi.stubGlobal("fetch", mockFetch);
2546 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
2547 vi.resetModules();
2548 });
2549
2550 afterEach(() => {
2551 vi.unstubAllGlobals();
2552 vi.unstubAllEnvs();
2553 mockFetch.mockReset();
2554 });
2555
2556 function mockResponse(body: unknown, ok = true, status = 200) {
2557 return { ok, status, json: () => Promise.resolve(body) };
2558 }
2559
2560 function setupAuthenticatedSession(permissions: string[]) {
2561 mockFetch.mockResolvedValueOnce(
2562 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
2563 );
2564 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
2565 }
2566
2567 async function loadAdminRoutes() {
2568 const { createAdminRoutes } = await import("../admin.js");
2569 return createAdminRoutes("http://localhost:3000");
2570 }
2571
2572 it("redirects to /login when unauthenticated", async () => {
2573 mockFetch.mockResolvedValueOnce(
2574 mockResponse({ authenticated: false, did: null, handle: null })
2575 );
2576 const routes = await loadAdminRoutes();
2577 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
2578 method: "POST",
2579 headers: { "content-type": "application/x-www-form-urlencoded" },
2580 });
2581 expect(res.status).toBe(302);
2582 expect(res.headers.get("location")).toBe("/login");
2583 });
2584
2585 it("returns 403 without manageThemes permission", async () => {
2586 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
2587 const routes = await loadAdminRoutes();
2588 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
2589 method: "POST",
2590 headers: {
2591 cookie: "atbb_session=token",
2592 "content-type": "application/x-www-form-urlencoded",
2593 },
2594 });
2595 expect(res.status).toBe(403);
2596 });
2597
2598 it("redirects with error on network failure", async () => {
2599 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2600 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
2601
2602 const routes = await loadAdminRoutes();
2603 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
2604 method: "POST",
2605 headers: { cookie: "atbb_session=token" },
2606 });
2607
2608 expect(res.status).toBe(302);
2609 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2610 });
2611
2612 it("deletes theme and redirects to /admin/themes on success", async () => {
2613 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2614 mockFetch.mockResolvedValueOnce(mockResponse({ deleted: true }, true, 200));
2615
2616 const routes = await loadAdminRoutes();
2617 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
2618 method: "POST",
2619 headers: { cookie: "atbb_session=token" },
2620 });
2621
2622 expect(res.status).toBe(302);
2623 expect(res.headers.get("location")).toBe("/admin/themes");
2624 });
2625
2626 it("redirects with human-friendly error message on 409 conflict (theme is a default)", async () => {
2627 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2628 mockFetch.mockResolvedValueOnce(
2629 mockResponse(
2630 { error: "Cannot delete a theme that is currently set as a default" },
2631 false,
2632 409
2633 )
2634 );
2635
2636 const routes = await loadAdminRoutes();
2637 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
2638 method: "POST",
2639 headers: { cookie: "atbb_session=token" },
2640 });
2641
2642 expect(res.status).toBe(302);
2643 const location = res.headers.get("location") ?? "";
2644 expect(location).toContain("/admin/themes?error=");
2645 expect(decodeURIComponent(location)).toContain("Cannot delete");
2646 });
2647
2648 it("redirects with error on generic AppView failure", async () => {
2649 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2650 mockFetch.mockResolvedValueOnce(
2651 mockResponse({ error: "Internal server error" }, false, 500)
2652 );
2653
2654 const routes = await loadAdminRoutes();
2655 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
2656 method: "POST",
2657 headers: { cookie: "atbb_session=token" },
2658 });
2659
2660 expect(res.status).toBe(302);
2661 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2662 });
2663});
2664
2665describe("createAdminRoutes — POST /admin/theme-policy", () => {
2666 beforeEach(() => {
2667 vi.stubGlobal("fetch", mockFetch);
2668 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
2669 vi.resetModules();
2670 });
2671
2672 afterEach(() => {
2673 vi.unstubAllGlobals();
2674 vi.unstubAllEnvs();
2675 mockFetch.mockReset();
2676 });
2677
2678 function mockResponse(body: unknown, ok = true, status = 200) {
2679 return { ok, status, json: () => Promise.resolve(body) };
2680 }
2681
2682 function setupAuthenticatedSession(permissions: string[]) {
2683 mockFetch.mockResolvedValueOnce(
2684 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
2685 );
2686 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
2687 }
2688
2689 async function loadAdminRoutes() {
2690 const { createAdminRoutes } = await import("../admin.js");
2691 return createAdminRoutes("http://localhost:3000");
2692 }
2693
2694 it("redirects to /login when unauthenticated", async () => {
2695 mockFetch.mockResolvedValueOnce(
2696 mockResponse({ authenticated: false, did: null, handle: null })
2697 );
2698 const routes = await loadAdminRoutes();
2699 const res = await routes.request("/admin/theme-policy", {
2700 method: "POST",
2701 headers: { "content-type": "application/x-www-form-urlencoded" },
2702 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test",
2703 });
2704 expect(res.status).toBe(302);
2705 expect(res.headers.get("location")).toBe("/login");
2706 });
2707
2708 it("returns 403 without manageThemes permission", async () => {
2709 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
2710 const routes = await loadAdminRoutes();
2711 const res = await routes.request("/admin/theme-policy", {
2712 method: "POST",
2713 headers: {
2714 cookie: "atbb_session=token",
2715 "content-type": "application/x-www-form-urlencoded",
2716 },
2717 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test",
2718 });
2719 expect(res.status).toBe(403);
2720 });
2721
2722 it("redirects with error on network failure", async () => {
2723 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2724 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
2725
2726 const routes = await loadAdminRoutes();
2727 const res = await routes.request("/admin/theme-policy", {
2728 method: "POST",
2729 headers: {
2730 cookie: "atbb_session=token",
2731 "content-type": "application/x-www-form-urlencoded",
2732 },
2733 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test",
2734 });
2735
2736 expect(res.status).toBe(302);
2737 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2738 });
2739
2740 it("saves policy and redirects to /admin/themes on success", async () => {
2741 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2742 mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200));
2743
2744 const routes = await loadAdminRoutes();
2745 const body = new URLSearchParams({
2746 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1",
2747 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2",
2748 allowUserChoice: "on",
2749 });
2750 body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1");
2751 body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2");
2752
2753 const res = await routes.request("/admin/theme-policy", {
2754 method: "POST",
2755 headers: {
2756 cookie: "atbb_session=token",
2757 "content-type": "application/x-www-form-urlencoded",
2758 },
2759 body: body.toString(),
2760 });
2761
2762 expect(res.status).toBe(302);
2763 expect(res.headers.get("location")).toBe("/admin/themes");
2764
2765 const apiCall = mockFetch.mock.calls[2];
2766 const sentBody = JSON.parse(apiCall[1].body);
2767 expect(sentBody.allowUserChoice).toBe(true);
2768 expect(sentBody.availableThemes).toEqual([
2769 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1" },
2770 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2" },
2771 ]);
2772 });
2773
2774 it("treats absent allowUserChoice checkbox as false", async () => {
2775 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2776 mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200));
2777
2778 const routes = await loadAdminRoutes();
2779 // No allowUserChoice field — checkbox was unchecked
2780 const res = await routes.request("/admin/theme-policy", {
2781 method: "POST",
2782 headers: {
2783 cookie: "atbb_session=token",
2784 "content-type": "application/x-www-form-urlencoded",
2785 },
2786 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test",
2787 });
2788
2789 expect(res.status).toBe(302);
2790 const apiCall = mockFetch.mock.calls[2];
2791 const sentBody = JSON.parse(apiCall[1].body);
2792 expect(sentBody.allowUserChoice).toBe(false);
2793 });
2794
2795 it("sends empty availableThemes when no checkboxes are checked", async () => {
2796 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2797 mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200));
2798
2799 const routes = await loadAdminRoutes();
2800 const res = await routes.request("/admin/theme-policy", {
2801 method: "POST",
2802 headers: {
2803 cookie: "atbb_session=token",
2804 "content-type": "application/x-www-form-urlencoded",
2805 },
2806 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test&allowUserChoice=on",
2807 });
2808
2809 expect(res.status).toBe(302);
2810 const apiCall = mockFetch.mock.calls[2];
2811 const sentBody = JSON.parse(apiCall[1].body);
2812 expect(sentBody.availableThemes).toEqual([]);
2813 });
2814
2815 it("redirects with error on AppView failure", async () => {
2816 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
2817 mockFetch.mockResolvedValueOnce(
2818 mockResponse({ error: "Invalid theme URIs" }, false, 400)
2819 );
2820
2821 const routes = await loadAdminRoutes();
2822 const res = await routes.request("/admin/theme-policy", {
2823 method: "POST",
2824 headers: {
2825 cookie: "atbb_session=token",
2826 "content-type": "application/x-www-form-urlencoded",
2827 },
2828 body: "defaultLightThemeUri=bad&defaultDarkThemeUri=bad",
2829 });
2830
2831 expect(res.status).toBe(302);
2832 expect(res.headers.get("location")).toContain("/admin/themes?error=");
2833 });
2834});