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 });
90
91 // ── Single permission → only that card ──────────────────────────────────
92
93 it("shows only Members card for user with only manageMembers", async () => {
94 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
95 const routes = await loadAdminRoutes();
96 const res = await routes.request("/admin", {
97 headers: { cookie: "atbb_session=token" },
98 });
99 expect(res.status).toBe(200);
100 const html = await res.text();
101 expect(html).toContain('href="/admin/members"');
102 expect(html).not.toContain('href="/admin/structure"');
103 expect(html).not.toContain('href="/admin/modlog"');
104 });
105
106 it("shows only Structure card for user with only manageCategories", async () => {
107 setupAuthenticatedSession(["space.atbb.permission.manageCategories"]);
108 const routes = await loadAdminRoutes();
109 const res = await routes.request("/admin", {
110 headers: { cookie: "atbb_session=token" },
111 });
112 expect(res.status).toBe(200);
113 const html = await res.text();
114 expect(html).not.toContain('href="/admin/members"');
115 expect(html).toContain('href="/admin/structure"');
116 expect(html).not.toContain('href="/admin/modlog"');
117 });
118
119 it("shows only Mod Log card for user with only moderatePosts", async () => {
120 setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]);
121 const routes = await loadAdminRoutes();
122 const res = await routes.request("/admin", {
123 headers: { cookie: "atbb_session=token" },
124 });
125 expect(res.status).toBe(200);
126 const html = await res.text();
127 expect(html).not.toContain('href="/admin/members"');
128 expect(html).not.toContain('href="/admin/structure"');
129 expect(html).toContain('href="/admin/modlog"');
130 });
131
132 it("shows only Mod Log card for user with only banUsers", async () => {
133 setupAuthenticatedSession(["space.atbb.permission.banUsers"]);
134 const routes = await loadAdminRoutes();
135 const res = await routes.request("/admin", {
136 headers: { cookie: "atbb_session=token" },
137 });
138 expect(res.status).toBe(200);
139 const html = await res.text();
140 expect(html).not.toContain('href="/admin/members"');
141 expect(html).not.toContain('href="/admin/structure"');
142 expect(html).toContain('href="/admin/modlog"');
143 });
144
145 it("shows only Mod Log card for user with only lockTopics", async () => {
146 setupAuthenticatedSession(["space.atbb.permission.lockTopics"]);
147 const routes = await loadAdminRoutes();
148 const res = await routes.request("/admin", {
149 headers: { cookie: "atbb_session=token" },
150 });
151 expect(res.status).toBe(200);
152 const html = await res.text();
153 expect(html).not.toContain('href="/admin/members"');
154 expect(html).not.toContain('href="/admin/structure"');
155 expect(html).toContain('href="/admin/modlog"');
156 });
157
158 // ── Multi-permission combos ──────────────────────────────────────────────
159
160 it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => {
161 setupAuthenticatedSession([
162 "space.atbb.permission.manageMembers",
163 "space.atbb.permission.moderatePosts",
164 ]);
165 const routes = await loadAdminRoutes();
166 const res = await routes.request("/admin", {
167 headers: { cookie: "atbb_session=token" },
168 });
169 expect(res.status).toBe(200);
170 const html = await res.text();
171 expect(html).toContain('href="/admin/members"');
172 expect(html).not.toContain('href="/admin/structure"');
173 expect(html).toContain('href="/admin/modlog"');
174 });
175
176 // ── Page structure ───────────────────────────────────────────────────────
177
178 it("renders 'Admin Panel' page title", async () => {
179 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
180 const routes = await loadAdminRoutes();
181 const res = await routes.request("/admin", {
182 headers: { cookie: "atbb_session=token" },
183 });
184 const html = await res.text();
185 expect(html).toContain("Admin Panel");
186 });
187
188 it("renders admin-nav-grid container", async () => {
189 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
190 const routes = await loadAdminRoutes();
191 const res = await routes.request("/admin", {
192 headers: { cookie: "atbb_session=token" },
193 });
194 const html = await res.text();
195 expect(html).toContain("admin-nav-grid");
196 });
197});
198
199describe("createAdminRoutes — GET /admin/members", () => {
200 beforeEach(() => {
201 vi.stubGlobal("fetch", mockFetch);
202 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
203 vi.resetModules();
204 });
205
206 afterEach(() => {
207 vi.unstubAllGlobals();
208 vi.unstubAllEnvs();
209 mockFetch.mockReset();
210 });
211
212 function mockResponse(body: unknown, ok = true, status = 200) {
213 return {
214 ok,
215 status,
216 statusText: ok ? "OK" : "Error",
217 json: () => Promise.resolve(body),
218 };
219 }
220
221 function setupSession(permissions: string[]) {
222 mockFetch.mockResolvedValueOnce(
223 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
224 );
225 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
226 }
227
228 const SAMPLE_MEMBERS = [
229 {
230 did: "did:plc:alice",
231 handle: "alice.bsky.social",
232 role: "Owner",
233 roleUri: "at://did:plc:forum/space.atbb.forum.role/owner",
234 joinedAt: "2026-01-01T00:00:00.000Z",
235 },
236 {
237 did: "did:plc:bob",
238 handle: "bob.bsky.social",
239 role: "Member",
240 roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
241 joinedAt: "2026-01-05T00:00:00.000Z",
242 },
243 ];
244
245 const SAMPLE_ROLES = [
246 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
247 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
248 ];
249
250 async function loadAdminRoutes() {
251 const { createAdminRoutes } = await import("../admin.js");
252 return createAdminRoutes("http://localhost:3000");
253 }
254
255 it("redirects unauthenticated users to /login", async () => {
256 const routes = await loadAdminRoutes();
257 const res = await routes.request("/admin/members");
258 expect(res.status).toBe(302);
259 expect(res.headers.get("location")).toBe("/login");
260 });
261
262 it("returns 403 for authenticated user without manageMembers", async () => {
263 setupSession(["space.atbb.permission.manageCategories"]);
264 const routes = await loadAdminRoutes();
265 const res = await routes.request("/admin/members", {
266 headers: { cookie: "atbb_session=token" },
267 });
268 expect(res.status).toBe(403);
269 });
270
271 it("renders member table with handles and role badges", async () => {
272 setupSession(["space.atbb.permission.manageMembers"]);
273 mockFetch.mockResolvedValueOnce(
274 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
275 );
276
277 const routes = await loadAdminRoutes();
278 const res = await routes.request("/admin/members", {
279 headers: { cookie: "atbb_session=token" },
280 });
281
282 expect(res.status).toBe(200);
283 const html = await res.text();
284 expect(html).toContain("alice.bsky.social");
285 expect(html).toContain("bob.bsky.social");
286 expect(html).toContain("role-badge");
287 expect(html).toContain("Owner");
288 });
289
290 it("renders joined date for members", async () => {
291 setupSession(["space.atbb.permission.manageMembers"]);
292 mockFetch.mockResolvedValueOnce(
293 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
294 );
295
296 const routes = await loadAdminRoutes();
297 const res = await routes.request("/admin/members", {
298 headers: { cookie: "atbb_session=token" },
299 });
300
301 const html = await res.text();
302 expect(html).toContain("Jan");
303 expect(html).toContain("2026");
304 });
305
306 it("hides role assignment form when user lacks manageRoles", async () => {
307 setupSession(["space.atbb.permission.manageMembers"]);
308 mockFetch.mockResolvedValueOnce(
309 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
310 );
311
312 const routes = await loadAdminRoutes();
313 const res = await routes.request("/admin/members", {
314 headers: { cookie: "atbb_session=token" },
315 });
316
317 const html = await res.text();
318 expect(html).not.toContain("hx-post");
319 expect(html).not.toContain("Assign");
320 });
321
322 it("shows role assignment form when user has manageRoles", async () => {
323 setupSession([
324 "space.atbb.permission.manageMembers",
325 "space.atbb.permission.manageRoles",
326 ]);
327 mockFetch.mockResolvedValueOnce(
328 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
329 );
330 mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES }));
331
332 const routes = await loadAdminRoutes();
333 const res = await routes.request("/admin/members", {
334 headers: { cookie: "atbb_session=token" },
335 });
336
337 const html = await res.text();
338 expect(html).toContain("hx-post");
339 expect(html).toContain("/admin/members/did:plc:bob/role");
340 expect(html).toContain("Assign");
341 });
342
343 it("shows empty state when no members", async () => {
344 setupSession(["space.atbb.permission.manageMembers"]);
345 mockFetch.mockResolvedValueOnce(
346 mockResponse({ members: [], isTruncated: false })
347 );
348
349 const routes = await loadAdminRoutes();
350 const res = await routes.request("/admin/members", {
351 headers: { cookie: "atbb_session=token" },
352 });
353
354 const html = await res.text();
355 expect(html).toContain("No members");
356 });
357
358 it("shows truncated indicator when isTruncated is true", async () => {
359 setupSession(["space.atbb.permission.manageMembers"]);
360 mockFetch.mockResolvedValueOnce(
361 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true })
362 );
363
364 const routes = await loadAdminRoutes();
365 const res = await routes.request("/admin/members", {
366 headers: { cookie: "atbb_session=token" },
367 });
368
369 const html = await res.text();
370 expect(html).toContain("+");
371 });
372
373 it("returns 503 on AppView network error fetching members", async () => {
374 setupSession(["space.atbb.permission.manageMembers"]);
375 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
376
377 const routes = await loadAdminRoutes();
378 const res = await routes.request("/admin/members", {
379 headers: { cookie: "atbb_session=token" },
380 });
381
382 expect(res.status).toBe(503);
383 const html = await res.text();
384 expect(html).toContain("error-display");
385 });
386
387 it("returns 500 on AppView server error fetching members", async () => {
388 setupSession(["space.atbb.permission.manageMembers"]);
389 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
390
391 const routes = await loadAdminRoutes();
392 const res = await routes.request("/admin/members", {
393 headers: { cookie: "atbb_session=token" },
394 });
395
396 expect(res.status).toBe(500);
397 const html = await res.text();
398 expect(html).toContain("error-display");
399 });
400
401 it("redirects to /login when AppView members returns 401 (session expired)", async () => {
402 setupSession(["space.atbb.permission.manageMembers"]);
403 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
404
405 const routes = await loadAdminRoutes();
406 const res = await routes.request("/admin/members", {
407 headers: { cookie: "atbb_session=token" },
408 });
409
410 expect(res.status).toBe(302);
411 expect(res.headers.get("location")).toBe("/login");
412 });
413
414 it("renders page with empty role dropdown when roles fetch fails", async () => {
415 setupSession([
416 "space.atbb.permission.manageMembers",
417 "space.atbb.permission.manageRoles",
418 ]);
419 // members fetch succeeds
420 mockFetch.mockResolvedValueOnce(
421 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
422 );
423 // roles fetch fails
424 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
425
426 const routes = await loadAdminRoutes();
427 const res = await routes.request("/admin/members", {
428 headers: { cookie: "atbb_session=token" },
429 });
430
431 expect(res.status).toBe(200);
432 const html = await res.text();
433 // Page still renders with member data
434 expect(html).toContain("alice.bsky.social");
435 // Assign Role column still present (permission says yes, just no options)
436 expect(html).toContain("hx-post");
437 });
438});
439
440describe("createAdminRoutes — POST /admin/members/:did/role", () => {
441 beforeEach(() => {
442 vi.stubGlobal("fetch", mockFetch);
443 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
444 vi.resetModules();
445 });
446
447 afterEach(() => {
448 vi.unstubAllGlobals();
449 vi.unstubAllEnvs();
450 mockFetch.mockReset();
451 });
452
453 function mockResponse(body: unknown, ok = true, status = 200) {
454 return {
455 ok,
456 status,
457 statusText: ok ? "OK" : "Error",
458 json: () => Promise.resolve(body),
459 };
460 }
461
462 const SAMPLE_ROLES = [
463 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
464 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
465 ];
466
467 function makeFormBody(overrides: Partial<Record<string, string>> = {}): string {
468 return new URLSearchParams({
469 roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
470 handle: "bob.bsky.social",
471 joinedAt: "2026-01-05T00:00:00.000Z",
472 currentRole: "Owner",
473 currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner",
474 canManageRoles: "1",
475 rolesJson: JSON.stringify(SAMPLE_ROLES),
476 ...overrides,
477 }).toString();
478 }
479
480 async function loadAdminRoutes() {
481 const { createAdminRoutes } = await import("../admin.js");
482 return createAdminRoutes("http://localhost:3000");
483 }
484
485 function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) {
486 mockFetch.mockResolvedValueOnce(
487 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
488 );
489 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
490 }
491
492 it("returns updated <tr> with new role name on success", async () => {
493 setupPostSession();
494 mockFetch.mockResolvedValueOnce(
495 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
496 );
497
498 const routes = await loadAdminRoutes();
499 const res = await routes.request("/admin/members/did:plc:bob/role", {
500 method: "POST",
501 headers: {
502 "Content-Type": "application/x-www-form-urlencoded",
503 cookie: "atbb_session=token",
504 },
505 body: makeFormBody(),
506 });
507
508 expect(res.status).toBe(200);
509 const html = await res.text();
510 expect(html).toContain("<tr");
511 expect(html).toContain("Member");
512 expect(html).toContain("bob.bsky.social");
513 });
514
515 it("returns row with friendly error on AppView 403", async () => {
516 setupPostSession();
517 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403));
518
519 const routes = await loadAdminRoutes();
520 const res = await routes.request("/admin/members/did:plc:bob/role", {
521 method: "POST",
522 headers: {
523 "Content-Type": "application/x-www-form-urlencoded",
524 cookie: "atbb_session=token",
525 },
526 body: makeFormBody(),
527 });
528
529 expect(res.status).toBe(200);
530 const html = await res.text();
531 expect(html).toContain("member-row__error");
532 expect(html).toContain("equal or higher authority");
533 expect(html).toContain("Owner"); // preserves current role
534 });
535
536 it("returns row with friendly error on AppView 404", async () => {
537 setupPostSession();
538 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404));
539
540 const routes = await loadAdminRoutes();
541 const res = await routes.request("/admin/members/did:plc:bob/role", {
542 method: "POST",
543 headers: {
544 "Content-Type": "application/x-www-form-urlencoded",
545 cookie: "atbb_session=token",
546 },
547 body: makeFormBody(),
548 });
549
550 expect(res.status).toBe(200);
551 const html = await res.text();
552 expect(html).toContain("member-row__error");
553 expect(html).toContain("not found");
554 });
555
556 it("returns row with friendly error on AppView 500", async () => {
557 setupPostSession();
558 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
559
560 const routes = await loadAdminRoutes();
561 const res = await routes.request("/admin/members/did:plc:bob/role", {
562 method: "POST",
563 headers: {
564 "Content-Type": "application/x-www-form-urlencoded",
565 cookie: "atbb_session=token",
566 },
567 body: makeFormBody(),
568 });
569
570 expect(res.status).toBe(200);
571 const html = await res.text();
572 expect(html).toContain("member-row__error");
573 expect(html).toContain("Something went wrong");
574 });
575
576 it("returns row with unavailable message on network error", async () => {
577 setupPostSession();
578 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
579
580 const routes = await loadAdminRoutes();
581 const res = await routes.request("/admin/members/did:plc:bob/role", {
582 method: "POST",
583 headers: {
584 "Content-Type": "application/x-www-form-urlencoded",
585 cookie: "atbb_session=token",
586 },
587 body: makeFormBody(),
588 });
589
590 expect(res.status).toBe(200);
591 const html = await res.text();
592 expect(html).toContain("member-row__error");
593 expect(html).toContain("temporarily unavailable");
594 });
595
596 it("returns row with error and makes no AppView call when roleUri is missing", async () => {
597 setupPostSession();
598 const routes = await loadAdminRoutes();
599 const res = await routes.request("/admin/members/did:plc:bob/role", {
600 method: "POST",
601 headers: {
602 "Content-Type": "application/x-www-form-urlencoded",
603 cookie: "atbb_session=token",
604 },
605 body: makeFormBody({ roleUri: "" }),
606 });
607
608 expect(res.status).toBe(200);
609 const html = await res.text();
610 expect(html).toContain("member-row__error");
611 expect(mockFetch).not.toHaveBeenCalledWith(
612 expect.stringContaining("/api/admin/members/did:plc:bob/role"),
613 expect.anything()
614 );
615 });
616
617 it("re-renders form with new role pre-selected in dropdown on success", async () => {
618 setupPostSession();
619 mockFetch.mockResolvedValueOnce(
620 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
621 );
622
623 const routes = await loadAdminRoutes();
624 const res = await routes.request("/admin/members/did:plc:bob/role", {
625 method: "POST",
626 headers: {
627 "Content-Type": "application/x-www-form-urlencoded",
628 cookie: "atbb_session=token",
629 },
630 body: makeFormBody({
631 roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
632 }),
633 });
634
635 const html = await res.text();
636 // The newly assigned role URI should appear as the selected option value in the form
637 expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member");
638 });
639
640 it("returns 401 error row for unauthenticated POST", async () => {
641 // No session mock — no cookie
642 const routes = await loadAdminRoutes();
643 const res = await routes.request("/admin/members/did:plc:bob/role", {
644 method: "POST",
645 headers: { "Content-Type": "application/x-www-form-urlencoded" },
646 body: makeFormBody(),
647 });
648
649 expect(res.status).toBe(401);
650 const html = await res.text();
651 expect(html).toContain("member-row__error");
652 expect(mockFetch).not.toHaveBeenCalledWith(
653 expect.stringContaining("/api/admin/members/did:plc:bob/role"),
654 expect.anything()
655 );
656 });
657
658 it("returns 403 error row when user lacks manageRoles", async () => {
659 setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles
660 const routes = await loadAdminRoutes();
661 const res = await routes.request("/admin/members/did:plc:bob/role", {
662 method: "POST",
663 headers: {
664 "Content-Type": "application/x-www-form-urlencoded",
665 cookie: "atbb_session=token",
666 },
667 body: makeFormBody(),
668 });
669
670 expect(res.status).toBe(403);
671 const html = await res.text();
672 expect(html).toContain("member-row__error");
673 // No AppView role assignment call should have been made
674 expect(mockFetch).not.toHaveBeenCalledWith(
675 expect.stringContaining("/api/admin/members/did:plc:bob/role"),
676 expect.anything()
677 );
678 });
679
680 it("returns row with session-expired error when AppView returns 401", async () => {
681 setupPostSession();
682 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
683
684 const routes = await loadAdminRoutes();
685 const res = await routes.request("/admin/members/did:plc:bob/role", {
686 method: "POST",
687 headers: {
688 "Content-Type": "application/x-www-form-urlencoded",
689 cookie: "atbb_session=token",
690 },
691 body: makeFormBody(),
692 });
693
694 expect(res.status).toBe(200);
695 const html = await res.text();
696 expect(html).toContain("member-row__error");
697 expect(html).toContain("session has expired");
698 });
699
700 it("returns error row with reload message when rolesJson is malformed", async () => {
701 setupPostSession();
702
703 const routes = await loadAdminRoutes();
704 const res = await routes.request("/admin/members/did:plc:bob/role", {
705 method: "POST",
706 headers: {
707 "Content-Type": "application/x-www-form-urlencoded",
708 cookie: "atbb_session=token",
709 },
710 body: makeFormBody({ rolesJson: "not-valid-json{{" }),
711 });
712
713 expect(res.status).toBe(200);
714 const html = await res.text();
715 expect(html).toContain("member-row__error");
716 expect(html).toContain("reload");
717 // No AppView call should have been made
718 // (setupPostSession consumed 2 calls, then we check no more were made)
719 expect(mockFetch).toHaveBeenCalledTimes(2);
720 });
721
722 it("returns error row and makes no AppView call when targetDid lacks did: prefix", async () => {
723 setupPostSession();
724
725 const routes = await loadAdminRoutes();
726 const res = await routes.request("/admin/members/notadid/role", {
727 method: "POST",
728 headers: {
729 "Content-Type": "application/x-www-form-urlencoded",
730 cookie: "atbb_session=token",
731 },
732 body: makeFormBody({ handle: "bob.bsky.social" }),
733 });
734
735 expect(res.status).toBe(200);
736 const html = await res.text();
737 expect(html).toContain("member-row__error");
738 expect(html).toContain("Invalid member identifier");
739 // Session fetch calls consumed (2), but no AppView role call made
740 expect(mockFetch).not.toHaveBeenCalledWith(
741 expect.stringContaining("/api/admin/members/notadid/role"),
742 expect.anything()
743 );
744 });
745});
746
747describe("createAdminRoutes — GET /admin/structure", () => {
748 beforeEach(() => {
749 vi.stubGlobal("fetch", mockFetch);
750 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
751 vi.resetModules();
752 });
753
754 afterEach(() => {
755 vi.unstubAllGlobals();
756 vi.unstubAllEnvs();
757 mockFetch.mockReset();
758 });
759
760 function mockResponse(body: unknown, ok = true, status = 200) {
761 return {
762 ok,
763 status,
764 statusText: ok ? "OK" : "Error",
765 json: () => Promise.resolve(body),
766 };
767 }
768
769 function setupSession(permissions: string[]) {
770 mockFetch.mockResolvedValueOnce(
771 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
772 );
773 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
774 }
775
776 /**
777 * Sets up mock responses for the structure page data fetches.
778 * After the 2 session calls:
779 * Call 3: GET /api/categories
780 * Call 4+: GET /api/categories/:id/boards (one per category, parallel)
781 */
782 function setupStructureFetch(
783 cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>,
784 boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {}
785 ) {
786 mockFetch.mockResolvedValueOnce(
787 mockResponse({
788 categories: cats.map((c) => ({
789 id: c.id,
790 did: "did:plc:forum",
791 uri: c.uri,
792 name: c.name,
793 description: null,
794 slug: null,
795 sortOrder: c.sortOrder ?? 1,
796 forumId: "1",
797 createdAt: "2025-01-01T00:00:00.000Z",
798 indexedAt: "2025-01-01T00:00:00.000Z",
799 })),
800 })
801 );
802 for (const cat of cats) {
803 const boards = boardsByCategory[cat.id] ?? [];
804 mockFetch.mockResolvedValueOnce(
805 mockResponse({
806 boards: boards.map((b) => ({
807 id: b.id,
808 did: "did:plc:forum",
809 uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`,
810 name: b.name,
811 description: null,
812 slug: null,
813 sortOrder: 1,
814 categoryId: cat.id,
815 categoryUri: cat.uri,
816 createdAt: "2025-01-01T00:00:00.000Z",
817 indexedAt: "2025-01-01T00:00:00.000Z",
818 })),
819 })
820 );
821 }
822 }
823
824 async function loadAdminRoutes() {
825 const { createAdminRoutes } = await import("../admin.js");
826 return createAdminRoutes("http://localhost:3000");
827 }
828
829 it("redirects unauthenticated users to /login", async () => {
830 mockFetch.mockResolvedValueOnce(
831 mockResponse({ authenticated: false })
832 );
833 const routes = await loadAdminRoutes();
834 const res = await routes.request("/admin/structure");
835 expect(res.status).toBe(302);
836 expect(res.headers.get("location")).toBe("/login");
837 });
838
839 it("returns 403 for authenticated user without manageCategories", async () => {
840 setupSession(["space.atbb.permission.manageMembers"]);
841 const routes = await loadAdminRoutes();
842 const res = await routes.request("/admin/structure", {
843 headers: { cookie: "atbb_session=token" },
844 });
845 expect(res.status).toBe(403);
846 });
847
848 it("renders structure page with category and board names", async () => {
849 setupSession(["space.atbb.permission.manageCategories"]);
850 setupStructureFetch(
851 [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
852 { "1": [{ id: "10", name: "General Chat" }] }
853 );
854
855 const routes = await loadAdminRoutes();
856 const res = await routes.request("/admin/structure", {
857 headers: { cookie: "atbb_session=token" },
858 });
859
860 expect(res.status).toBe(200);
861 const html = await res.text();
862 expect(html).toContain("General Discussion");
863 expect(html).toContain("General Chat");
864 });
865
866 it("renders empty state when no categories exist", async () => {
867 setupSession(["space.atbb.permission.manageCategories"]);
868 setupStructureFetch([]);
869
870 const routes = await loadAdminRoutes();
871 const res = await routes.request("/admin/structure", {
872 headers: { cookie: "atbb_session=token" },
873 });
874
875 expect(res.status).toBe(200);
876 const html = await res.text();
877 expect(html).toContain("No categories");
878 });
879
880 it("renders the add-category form", async () => {
881 setupSession(["space.atbb.permission.manageCategories"]);
882 setupStructureFetch([]);
883
884 const routes = await loadAdminRoutes();
885 const res = await routes.request("/admin/structure", {
886 headers: { cookie: "atbb_session=token" },
887 });
888
889 const html = await res.text();
890 expect(html).toContain('action="/admin/structure/categories"');
891 });
892
893 it("renders edit and delete actions for a category", async () => {
894 setupSession(["space.atbb.permission.manageCategories"]);
895 setupStructureFetch(
896 [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }],
897 );
898
899 const routes = await loadAdminRoutes();
900 const res = await routes.request("/admin/structure", {
901 headers: { cookie: "atbb_session=token" },
902 });
903
904 const html = await res.text();
905 expect(html).toContain('action="/admin/structure/categories/5/edit"');
906 expect(html).toContain('action="/admin/structure/categories/5/delete"');
907 });
908
909 it("renders edit and delete actions for a board", async () => {
910 setupSession(["space.atbb.permission.manageCategories"]);
911 setupStructureFetch(
912 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
913 { "1": [{ id: "20", name: "Showcase" }] }
914 );
915
916 const routes = await loadAdminRoutes();
917 const res = await routes.request("/admin/structure", {
918 headers: { cookie: "atbb_session=token" },
919 });
920
921 const html = await res.text();
922 expect(html).toContain("Showcase");
923 expect(html).toContain('action="/admin/structure/boards/20/edit"');
924 expect(html).toContain('action="/admin/structure/boards/20/delete"');
925 });
926
927 it("renders add-board form with categoryUri hidden input", async () => {
928 setupSession(["space.atbb.permission.manageCategories"]);
929 setupStructureFetch(
930 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
931 );
932
933 const routes = await loadAdminRoutes();
934 const res = await routes.request("/admin/structure", {
935 headers: { cookie: "atbb_session=token" },
936 });
937
938 const html = await res.text();
939 expect(html).toContain('name="categoryUri"');
940 expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"');
941 expect(html).toContain('action="/admin/structure/boards"');
942 });
943
944 it("renders error banner when ?error= query param is present", async () => {
945 setupSession(["space.atbb.permission.manageCategories"]);
946 setupStructureFetch([]);
947
948 const routes = await loadAdminRoutes();
949 const res = await routes.request(
950 `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`,
951 { headers: { cookie: "atbb_session=token" } }
952 );
953
954 const html = await res.text();
955 expect(html).toContain("Cannot delete category with boards");
956 });
957
958 it("returns 503 on AppView network error fetching categories", async () => {
959 setupSession(["space.atbb.permission.manageCategories"]);
960 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
961
962 const routes = await loadAdminRoutes();
963 const res = await routes.request("/admin/structure", {
964 headers: { cookie: "atbb_session=token" },
965 });
966
967 expect(res.status).toBe(503);
968 const html = await res.text();
969 expect(html).toContain("error-display");
970 });
971
972 it("returns 500 on AppView server error fetching categories", async () => {
973 setupSession(["space.atbb.permission.manageCategories"]);
974 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
975
976 const routes = await loadAdminRoutes();
977 const res = await routes.request("/admin/structure", {
978 headers: { cookie: "atbb_session=token" },
979 });
980
981 expect(res.status).toBe(500);
982 const html = await res.text();
983 expect(html).toContain("error-display");
984 });
985
986 it("redirects to /login when AppView categories returns 401", async () => {
987 setupSession(["space.atbb.permission.manageCategories"]);
988 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
989
990 const routes = await loadAdminRoutes();
991 const res = await routes.request("/admin/structure", {
992 headers: { cookie: "atbb_session=token" },
993 });
994
995 expect(res.status).toBe(302);
996 expect(res.headers.get("location")).toBe("/login");
997 });
998});
999
1000describe("createAdminRoutes — POST /admin/structure/categories", () => {
1001 beforeEach(() => {
1002 vi.stubGlobal("fetch", mockFetch);
1003 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1004 vi.resetModules();
1005 });
1006
1007 afterEach(() => {
1008 vi.unstubAllGlobals();
1009 vi.unstubAllEnvs();
1010 mockFetch.mockReset();
1011 });
1012
1013 function mockResponse(body: unknown, ok = true, status = 200) {
1014 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1015 }
1016
1017 function setupSession(permissions: string[]) {
1018 mockFetch.mockResolvedValueOnce(
1019 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1020 );
1021 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1022 }
1023
1024 async function loadAdminRoutes() {
1025 const { createAdminRoutes } = await import("../admin.js");
1026 return createAdminRoutes("http://localhost:3000");
1027 }
1028
1029 function postForm(body: Record<string, string>) {
1030 const params = new URLSearchParams(body);
1031 return {
1032 method: "POST",
1033 headers: {
1034 cookie: "atbb_session=token",
1035 "content-type": "application/x-www-form-urlencoded",
1036 },
1037 body: params.toString(),
1038 };
1039 }
1040
1041 it("redirects to /login when unauthenticated", async () => {
1042 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1043 const routes = await loadAdminRoutes();
1044 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" }));
1045 expect(res.status).toBe(302);
1046 expect(res.headers.get("location")).toBe("/login");
1047 });
1048
1049 it("returns 403 without manageCategories permission", async () => {
1050 setupSession(["space.atbb.permission.manageMembers"]);
1051 const routes = await loadAdminRoutes();
1052 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" }));
1053 expect(res.status).toBe(403);
1054 });
1055
1056 it("redirects to /admin/structure on success", async () => {
1057 setupSession(["space.atbb.permission.manageCategories"]);
1058 mockFetch.mockResolvedValueOnce(
1059 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201)
1060 );
1061
1062 const routes = await loadAdminRoutes();
1063 const res = await routes.request(
1064 "/admin/structure/categories",
1065 postForm({ name: "General", description: "Talk about anything", sortOrder: "1" })
1066 );
1067
1068 expect(res.status).toBe(302);
1069 expect(res.headers.get("location")).toBe("/admin/structure");
1070 });
1071
1072 it("redirects with ?error= when name is missing", async () => {
1073 setupSession(["space.atbb.permission.manageCategories"]);
1074
1075 const routes = await loadAdminRoutes();
1076 const res = await routes.request(
1077 "/admin/structure/categories",
1078 postForm({ name: "" })
1079 );
1080
1081 expect(res.status).toBe(302);
1082 const location = res.headers.get("location") ?? "";
1083 expect(location).toContain("/admin/structure");
1084 expect(location).toContain("error=");
1085 });
1086
1087 it("redirects with ?error= on AppView error", async () => {
1088 setupSession(["space.atbb.permission.manageCategories"]);
1089 mockFetch.mockResolvedValueOnce(
1090 mockResponse({ error: "Unexpected error" }, false, 500)
1091 );
1092
1093 const routes = await loadAdminRoutes();
1094 const res = await routes.request(
1095 "/admin/structure/categories",
1096 postForm({ name: "General" })
1097 );
1098
1099 expect(res.status).toBe(302);
1100 const location = res.headers.get("location") ?? "";
1101 expect(location).toContain("/admin/structure");
1102 expect(location).toContain("error=");
1103 });
1104
1105 it("redirects with ?error= on network error", async () => {
1106 setupSession(["space.atbb.permission.manageCategories"]);
1107 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1108
1109 const routes = await loadAdminRoutes();
1110 const res = await routes.request(
1111 "/admin/structure/categories",
1112 postForm({ name: "General" })
1113 );
1114
1115 expect(res.status).toBe(302);
1116 const location = res.headers.get("location") ?? "";
1117 expect(location).toContain("/admin/structure");
1118 expect(location).toContain("error=");
1119 });
1120
1121 it("redirects with ?error= for negative sort order", async () => {
1122 setupSession(["space.atbb.permission.manageCategories"]);
1123
1124 const routes = await loadAdminRoutes();
1125 const res = await routes.request(
1126 "/admin/structure/categories",
1127 postForm({ name: "General", sortOrder: "-1" })
1128 );
1129
1130 expect(res.status).toBe(302);
1131 const location = res.headers.get("location") ?? "";
1132 expect(location).toContain("error=");
1133 });
1134});
1135
1136describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => {
1137 beforeEach(() => {
1138 vi.stubGlobal("fetch", mockFetch);
1139 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1140 vi.resetModules();
1141 });
1142
1143 afterEach(() => {
1144 vi.unstubAllGlobals();
1145 vi.unstubAllEnvs();
1146 mockFetch.mockReset();
1147 });
1148
1149 function mockResponse(body: unknown, ok = true, status = 200) {
1150 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1151 }
1152
1153 function setupSession(permissions: string[]) {
1154 mockFetch.mockResolvedValueOnce(
1155 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1156 );
1157 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1158 }
1159
1160 async function loadAdminRoutes() {
1161 const { createAdminRoutes } = await import("../admin.js");
1162 return createAdminRoutes("http://localhost:3000");
1163 }
1164
1165 function postForm(body: Record<string, string>) {
1166 const params = new URLSearchParams(body);
1167 return {
1168 method: "POST",
1169 headers: {
1170 cookie: "atbb_session=token",
1171 "content-type": "application/x-www-form-urlencoded",
1172 },
1173 body: params.toString(),
1174 };
1175 }
1176
1177 it("redirects to /login when unauthenticated", async () => {
1178 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1179 const routes = await loadAdminRoutes();
1180 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1181 expect(res.status).toBe(302);
1182 expect(res.headers.get("location")).toBe("/login");
1183 });
1184
1185 it("returns 403 without manageCategories", async () => {
1186 setupSession(["space.atbb.permission.manageMembers"]);
1187 const routes = await loadAdminRoutes();
1188 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1189 expect(res.status).toBe(403);
1190 });
1191
1192 it("redirects to /admin/structure on success", async () => {
1193 setupSession(["space.atbb.permission.manageCategories"]);
1194 mockFetch.mockResolvedValueOnce(
1195 mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200)
1196 );
1197
1198 const routes = await loadAdminRoutes();
1199 const res = await routes.request(
1200 "/admin/structure/categories/5/edit",
1201 postForm({ name: "Updated Name", description: "", sortOrder: "2" })
1202 );
1203
1204 expect(res.status).toBe(302);
1205 expect(res.headers.get("location")).toBe("/admin/structure");
1206 });
1207
1208 it("redirects with ?error= when name is missing", async () => {
1209 setupSession(["space.atbb.permission.manageCategories"]);
1210 const routes = await loadAdminRoutes();
1211 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "" }));
1212 expect(res.status).toBe(302);
1213 const location = res.headers.get("location") ?? "";
1214 expect(location).toContain("error=");
1215 });
1216
1217 it("redirects with ?error= on AppView error", async () => {
1218 setupSession(["space.atbb.permission.manageCategories"]);
1219 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Not found" }, false, 404));
1220 const routes = await loadAdminRoutes();
1221 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1222 expect(res.status).toBe(302);
1223 const location = res.headers.get("location") ?? "";
1224 expect(location).toContain("error=");
1225 });
1226
1227 it("redirects with ?error= on network error", async () => {
1228 setupSession(["space.atbb.permission.manageCategories"]);
1229 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1230 const routes = await loadAdminRoutes();
1231 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
1232 expect(res.status).toBe(302);
1233 const location = res.headers.get("location") ?? "";
1234 expect(location).toContain("error=");
1235 });
1236
1237 it("redirects with ?error= for negative sort order", async () => {
1238 setupSession(["space.atbb.permission.manageCategories"]);
1239 const routes = await loadAdminRoutes();
1240 const res = await routes.request(
1241 "/admin/structure/categories/5/edit",
1242 postForm({ name: "Updated", sortOrder: "-5" })
1243 );
1244 expect(res.status).toBe(302);
1245 const location = res.headers.get("location") ?? "";
1246 expect(location).toContain("error=");
1247 });
1248});
1249
1250describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => {
1251 beforeEach(() => {
1252 vi.stubGlobal("fetch", mockFetch);
1253 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1254 vi.resetModules();
1255 });
1256
1257 afterEach(() => {
1258 vi.unstubAllGlobals();
1259 vi.unstubAllEnvs();
1260 mockFetch.mockReset();
1261 });
1262
1263 function mockResponse(body: unknown, ok = true, status = 200) {
1264 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1265 }
1266
1267 function setupSession(permissions: string[]) {
1268 mockFetch.mockResolvedValueOnce(
1269 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1270 );
1271 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1272 }
1273
1274 async function loadAdminRoutes() {
1275 const { createAdminRoutes } = await import("../admin.js");
1276 return createAdminRoutes("http://localhost:3000");
1277 }
1278
1279 function postForm(body: Record<string, string> = {}) {
1280 const params = new URLSearchParams(body);
1281 return {
1282 method: "POST",
1283 headers: {
1284 cookie: "atbb_session=token",
1285 "content-type": "application/x-www-form-urlencoded",
1286 },
1287 body: params.toString(),
1288 };
1289 }
1290
1291 it("redirects to /login when unauthenticated", async () => {
1292 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1293 const routes = await loadAdminRoutes();
1294 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1295 expect(res.status).toBe(302);
1296 expect(res.headers.get("location")).toBe("/login");
1297 });
1298
1299 it("returns 403 without manageCategories", async () => {
1300 setupSession(["space.atbb.permission.manageMembers"]);
1301 const routes = await loadAdminRoutes();
1302 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1303 expect(res.status).toBe(403);
1304 });
1305
1306 it("redirects to /admin/structure on success", async () => {
1307 setupSession(["space.atbb.permission.manageCategories"]);
1308 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200));
1309
1310 const routes = await loadAdminRoutes();
1311 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1312
1313 expect(res.status).toBe(302);
1314 expect(res.headers.get("location")).toBe("/admin/structure");
1315 });
1316
1317 it("redirects with ?error= on AppView error (e.g. 409 has boards)", async () => {
1318 setupSession(["space.atbb.permission.manageCategories"]);
1319 mockFetch.mockResolvedValueOnce(
1320 mockResponse({ error: "Cannot delete category with boards. Remove all boards first." }, false, 409)
1321 );
1322
1323 const routes = await loadAdminRoutes();
1324 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1325
1326 expect(res.status).toBe(302);
1327 const location = res.headers.get("location") ?? "";
1328 expect(location).toContain("/admin/structure");
1329 expect(location).toContain("error=");
1330 expect(decodeURIComponent(location)).toContain("Cannot delete category with boards");
1331 });
1332
1333 it("redirects with ?error= on network error", async () => {
1334 setupSession(["space.atbb.permission.manageCategories"]);
1335 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1336
1337 const routes = await loadAdminRoutes();
1338 const res = await routes.request("/admin/structure/categories/5/delete", postForm());
1339
1340 expect(res.status).toBe(302);
1341 const location = res.headers.get("location") ?? "";
1342 expect(location).toContain("error=");
1343 });
1344});
1345
1346describe("createAdminRoutes — POST /admin/structure/boards", () => {
1347 beforeEach(() => {
1348 vi.stubGlobal("fetch", mockFetch);
1349 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1350 vi.resetModules();
1351 });
1352
1353 afterEach(() => {
1354 vi.unstubAllGlobals();
1355 vi.unstubAllEnvs();
1356 mockFetch.mockReset();
1357 });
1358
1359 function mockResponse(body: unknown, ok = true, status = 200) {
1360 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1361 }
1362
1363 function setupSession(permissions: string[]) {
1364 mockFetch.mockResolvedValueOnce(
1365 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1366 );
1367 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1368 }
1369
1370 async function loadAdminRoutes() {
1371 const { createAdminRoutes } = await import("../admin.js");
1372 return createAdminRoutes("http://localhost:3000");
1373 }
1374
1375 function postForm(body: Record<string, string>) {
1376 const params = new URLSearchParams(body);
1377 return {
1378 method: "POST",
1379 headers: {
1380 cookie: "atbb_session=token",
1381 "content-type": "application/x-www-form-urlencoded",
1382 },
1383 body: params.toString(),
1384 };
1385 }
1386
1387 it("redirects to /login when unauthenticated", async () => {
1388 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1389 const routes = await loadAdminRoutes();
1390 const res = await routes.request(
1391 "/admin/structure/boards",
1392 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1393 );
1394 expect(res.status).toBe(302);
1395 expect(res.headers.get("location")).toBe("/login");
1396 });
1397
1398 it("returns 403 without manageCategories permission", async () => {
1399 setupSession(["space.atbb.permission.manageMembers"]);
1400 const routes = await loadAdminRoutes();
1401 const res = await routes.request(
1402 "/admin/structure/boards",
1403 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1404 );
1405 expect(res.status).toBe(403);
1406 });
1407
1408 it("redirects to /admin/structure on success", async () => {
1409 setupSession(["space.atbb.permission.manageCategories"]);
1410 mockFetch.mockResolvedValueOnce(
1411 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.board/xyz", cid: "bafyrei..." }, true, 201)
1412 );
1413
1414 const routes = await loadAdminRoutes();
1415 const res = await routes.request(
1416 "/admin/structure/boards",
1417 postForm({
1418 name: "General Chat",
1419 description: "Chat about anything",
1420 sortOrder: "1",
1421 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
1422 })
1423 );
1424
1425 expect(res.status).toBe(302);
1426 expect(res.headers.get("location")).toBe("/admin/structure");
1427 });
1428
1429 it("redirects with ?error= when name is missing", async () => {
1430 setupSession(["space.atbb.permission.manageCategories"]);
1431 const routes = await loadAdminRoutes();
1432 const res = await routes.request(
1433 "/admin/structure/boards",
1434 postForm({ name: "", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1435 );
1436 expect(res.status).toBe(302);
1437 const location = res.headers.get("location") ?? "";
1438 expect(location).toContain("/admin/structure");
1439 expect(location).toContain("error=");
1440 });
1441
1442 it("redirects with ?error= when categoryUri is missing", async () => {
1443 setupSession(["space.atbb.permission.manageCategories"]);
1444 const routes = await loadAdminRoutes();
1445 const res = await routes.request(
1446 "/admin/structure/boards",
1447 postForm({ name: "General Chat", categoryUri: "" })
1448 );
1449 expect(res.status).toBe(302);
1450 const location = res.headers.get("location") ?? "";
1451 expect(location).toContain("error=");
1452 });
1453
1454 it("redirects with ?error= on AppView error", async () => {
1455 setupSession(["space.atbb.permission.manageCategories"]);
1456 mockFetch.mockResolvedValueOnce(
1457 mockResponse({ error: "Category not found" }, false, 404)
1458 );
1459
1460 const routes = await loadAdminRoutes();
1461 const res = await routes.request(
1462 "/admin/structure/boards",
1463 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1464 );
1465
1466 expect(res.status).toBe(302);
1467 const location = res.headers.get("location") ?? "";
1468 expect(location).toContain("error=");
1469 });
1470
1471 it("redirects with ?error= on network error", async () => {
1472 setupSession(["space.atbb.permission.manageCategories"]);
1473 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1474
1475 const routes = await loadAdminRoutes();
1476 const res = await routes.request(
1477 "/admin/structure/boards",
1478 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
1479 );
1480
1481 expect(res.status).toBe(302);
1482 const location = res.headers.get("location") ?? "";
1483 expect(location).toContain("error=");
1484 });
1485
1486 it("redirects with ?error= for negative sort order", async () => {
1487 setupSession(["space.atbb.permission.manageCategories"]);
1488 const routes = await loadAdminRoutes();
1489 const res = await routes.request(
1490 "/admin/structure/boards",
1491 postForm({
1492 name: "General Chat",
1493 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
1494 sortOrder: "-2",
1495 })
1496 );
1497 expect(res.status).toBe(302);
1498 const location = res.headers.get("location") ?? "";
1499 expect(location).toContain("error=");
1500 });
1501});
1502
1503describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => {
1504 beforeEach(() => {
1505 vi.stubGlobal("fetch", mockFetch);
1506 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1507 vi.resetModules();
1508 });
1509
1510 afterEach(() => {
1511 vi.unstubAllGlobals();
1512 vi.unstubAllEnvs();
1513 mockFetch.mockReset();
1514 });
1515
1516 function mockResponse(body: unknown, ok = true, status = 200) {
1517 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1518 }
1519
1520 function setupSession(permissions: string[]) {
1521 mockFetch.mockResolvedValueOnce(
1522 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1523 );
1524 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1525 }
1526
1527 async function loadAdminRoutes() {
1528 const { createAdminRoutes } = await import("../admin.js");
1529 return createAdminRoutes("http://localhost:3000");
1530 }
1531
1532 function postForm(body: Record<string, string>) {
1533 const params = new URLSearchParams(body);
1534 return {
1535 method: "POST",
1536 headers: {
1537 cookie: "atbb_session=token",
1538 "content-type": "application/x-www-form-urlencoded",
1539 },
1540 body: params.toString(),
1541 };
1542 }
1543
1544 it("redirects to /login when unauthenticated", async () => {
1545 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1546 const routes = await loadAdminRoutes();
1547 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1548 expect(res.status).toBe(302);
1549 expect(res.headers.get("location")).toBe("/login");
1550 });
1551
1552 it("returns 403 without manageCategories", async () => {
1553 setupSession(["space.atbb.permission.manageMembers"]);
1554 const routes = await loadAdminRoutes();
1555 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1556 expect(res.status).toBe(403);
1557 });
1558
1559 it("redirects to /admin/structure on success", async () => {
1560 setupSession(["space.atbb.permission.manageCategories"]);
1561 mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200));
1562
1563 const routes = await loadAdminRoutes();
1564 const res = await routes.request(
1565 "/admin/structure/boards/10/edit",
1566 postForm({ name: "Updated Board", description: "", sortOrder: "3" })
1567 );
1568
1569 expect(res.status).toBe(302);
1570 expect(res.headers.get("location")).toBe("/admin/structure");
1571 });
1572
1573 it("redirects with ?error= when name is missing", async () => {
1574 setupSession(["space.atbb.permission.manageCategories"]);
1575 const routes = await loadAdminRoutes();
1576 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "" }));
1577 expect(res.status).toBe(302);
1578 const location = res.headers.get("location") ?? "";
1579 expect(location).toContain("error=");
1580 });
1581
1582 it("redirects with ?error= on AppView error", async () => {
1583 setupSession(["space.atbb.permission.manageCategories"]);
1584 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Board not found" }, false, 404));
1585 const routes = await loadAdminRoutes();
1586 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1587 expect(res.status).toBe(302);
1588 const location = res.headers.get("location") ?? "";
1589 expect(location).toContain("error=");
1590 });
1591
1592 it("redirects with ?error= on network error", async () => {
1593 setupSession(["space.atbb.permission.manageCategories"]);
1594 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1595 const routes = await loadAdminRoutes();
1596 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
1597 expect(res.status).toBe(302);
1598 const location = res.headers.get("location") ?? "";
1599 expect(location).toContain("error=");
1600 });
1601
1602 it("redirects with ?error= for negative sort order", async () => {
1603 setupSession(["space.atbb.permission.manageCategories"]);
1604 const routes = await loadAdminRoutes();
1605 const res = await routes.request(
1606 "/admin/structure/boards/10/edit",
1607 postForm({ name: "Updated Board", sortOrder: "-3" })
1608 );
1609 expect(res.status).toBe(302);
1610 const location = res.headers.get("location") ?? "";
1611 expect(location).toContain("error=");
1612 });
1613});
1614
1615describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => {
1616 beforeEach(() => {
1617 vi.stubGlobal("fetch", mockFetch);
1618 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1619 vi.resetModules();
1620 });
1621
1622 afterEach(() => {
1623 vi.unstubAllGlobals();
1624 vi.unstubAllEnvs();
1625 mockFetch.mockReset();
1626 });
1627
1628 function mockResponse(body: unknown, ok = true, status = 200) {
1629 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
1630 }
1631
1632 function setupSession(permissions: string[]) {
1633 mockFetch.mockResolvedValueOnce(
1634 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1635 );
1636 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1637 }
1638
1639 async function loadAdminRoutes() {
1640 const { createAdminRoutes } = await import("../admin.js");
1641 return createAdminRoutes("http://localhost:3000");
1642 }
1643
1644 function postForm(body: Record<string, string> = {}) {
1645 const params = new URLSearchParams(body);
1646 return {
1647 method: "POST",
1648 headers: {
1649 cookie: "atbb_session=token",
1650 "content-type": "application/x-www-form-urlencoded",
1651 },
1652 body: params.toString(),
1653 };
1654 }
1655
1656 it("redirects to /login when unauthenticated", async () => {
1657 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
1658 const routes = await loadAdminRoutes();
1659 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1660 expect(res.status).toBe(302);
1661 expect(res.headers.get("location")).toBe("/login");
1662 });
1663
1664 it("returns 403 without manageCategories", async () => {
1665 setupSession(["space.atbb.permission.manageMembers"]);
1666 const routes = await loadAdminRoutes();
1667 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1668 expect(res.status).toBe(403);
1669 });
1670
1671 it("redirects to /admin/structure on success", async () => {
1672 setupSession(["space.atbb.permission.manageCategories"]);
1673 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200));
1674
1675 const routes = await loadAdminRoutes();
1676 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1677
1678 expect(res.status).toBe(302);
1679 expect(res.headers.get("location")).toBe("/admin/structure");
1680 });
1681
1682 it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => {
1683 setupSession(["space.atbb.permission.manageCategories"]);
1684 mockFetch.mockResolvedValueOnce(
1685 mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409)
1686 );
1687
1688 const routes = await loadAdminRoutes();
1689 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1690
1691 expect(res.status).toBe(302);
1692 const location = res.headers.get("location") ?? "";
1693 expect(decodeURIComponent(location)).toContain("Cannot delete board with posts");
1694 });
1695
1696 it("redirects with ?error= on network error", async () => {
1697 setupSession(["space.atbb.permission.manageCategories"]);
1698 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1699
1700 const routes = await loadAdminRoutes();
1701 const res = await routes.request("/admin/structure/boards/10/delete", postForm());
1702
1703 expect(res.status).toBe(302);
1704 const location = res.headers.get("location") ?? "";
1705 expect(location).toContain("error=");
1706 });
1707});
1708
1709describe("createAdminRoutes — GET /admin/modlog", () => {
1710 beforeEach(() => {
1711 vi.stubGlobal("fetch", mockFetch);
1712 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
1713 vi.resetModules();
1714 });
1715
1716 afterEach(() => {
1717 vi.unstubAllGlobals();
1718 vi.unstubAllEnvs();
1719 mockFetch.mockReset();
1720 });
1721
1722 function mockResponse(body: unknown, ok = true, status = 200) {
1723 return {
1724 ok,
1725 status,
1726 statusText: ok ? "OK" : "Error",
1727 json: () => Promise.resolve(body),
1728 };
1729 }
1730
1731 function setupSession(permissions: string[]) {
1732 mockFetch.mockResolvedValueOnce(
1733 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
1734 );
1735 mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
1736 }
1737
1738 async function loadAdminRoutes() {
1739 const { createAdminRoutes } = await import("../admin.js");
1740 return createAdminRoutes("http://localhost:3000");
1741 }
1742
1743 const SAMPLE_ACTIONS = [
1744 {
1745 id: "1",
1746 action: "space.atbb.modAction.ban",
1747 moderatorDid: "did:plc:alice",
1748 moderatorHandle: "alice.bsky.social",
1749 subjectDid: "did:plc:bob",
1750 subjectHandle: "bob.bsky.social",
1751 subjectPostUri: null,
1752 reason: "Spam",
1753 createdAt: "2026-02-26T12:01:00.000Z",
1754 },
1755 {
1756 id: "2",
1757 action: "space.atbb.modAction.delete",
1758 moderatorDid: "did:plc:alice",
1759 moderatorHandle: "alice.bsky.social",
1760 subjectDid: null,
1761 subjectHandle: null,
1762 subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123",
1763 reason: "Inappropriate",
1764 createdAt: "2026-02-26T11:30:00.000Z",
1765 },
1766 ];
1767
1768 // ── Auth & permission gates ──────────────────────────────────────────────
1769
1770 it("redirects unauthenticated users to /login", async () => {
1771 const routes = await loadAdminRoutes();
1772 const res = await routes.request("/admin/modlog");
1773 expect(res.status).toBe(302);
1774 expect(res.headers.get("location")).toBe("/login");
1775 });
1776
1777 it("returns 403 for user without any mod permission", async () => {
1778 setupSession(["space.atbb.permission.manageCategories"]);
1779 const routes = await loadAdminRoutes();
1780 const res = await routes.request("/admin/modlog", {
1781 headers: { cookie: "atbb_session=token" },
1782 });
1783 expect(res.status).toBe(403);
1784 const html = await res.text();
1785 expect(html).toContain("permission");
1786 });
1787
1788 it("allows access for moderatePosts permission", async () => {
1789 setupSession(["space.atbb.permission.moderatePosts"]);
1790 mockFetch.mockResolvedValueOnce(
1791 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1792 );
1793 const routes = await loadAdminRoutes();
1794 const res = await routes.request("/admin/modlog", {
1795 headers: { cookie: "atbb_session=token" },
1796 });
1797 expect(res.status).toBe(200);
1798 });
1799
1800 it("allows access for banUsers permission", async () => {
1801 setupSession(["space.atbb.permission.banUsers"]);
1802 mockFetch.mockResolvedValueOnce(
1803 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1804 );
1805 const routes = await loadAdminRoutes();
1806 const res = await routes.request("/admin/modlog", {
1807 headers: { cookie: "atbb_session=token" },
1808 });
1809 expect(res.status).toBe(200);
1810 });
1811
1812 it("allows access for lockTopics permission", async () => {
1813 setupSession(["space.atbb.permission.lockTopics"]);
1814 mockFetch.mockResolvedValueOnce(
1815 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1816 );
1817 const routes = await loadAdminRoutes();
1818 const res = await routes.request("/admin/modlog", {
1819 headers: { cookie: "atbb_session=token" },
1820 });
1821 expect(res.status).toBe(200);
1822 });
1823
1824 // ── Table rendering ──────────────────────────────────────────────────────
1825
1826 it("renders table with moderator handle and action label", async () => {
1827 setupSession(["space.atbb.permission.banUsers"]);
1828 mockFetch.mockResolvedValueOnce(
1829 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1830 );
1831 const routes = await loadAdminRoutes();
1832 const res = await routes.request("/admin/modlog", {
1833 headers: { cookie: "atbb_session=token" },
1834 });
1835 const html = await res.text();
1836 expect(html).toContain("alice.bsky.social");
1837 expect(html).toContain("Ban");
1838 expect(html).toContain("bob.bsky.social");
1839 expect(html).toContain("Spam");
1840 });
1841
1842 it("maps space.atbb.modAction.delete to 'Hide' label", async () => {
1843 setupSession(["space.atbb.permission.moderatePosts"]);
1844 mockFetch.mockResolvedValueOnce(
1845 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, 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 const html = await res.text();
1852 expect(html).toContain("Hide");
1853 });
1854
1855 it("shows post URI in subject column for post-targeting actions", async () => {
1856 setupSession(["space.atbb.permission.moderatePosts"]);
1857 mockFetch.mockResolvedValueOnce(
1858 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1859 );
1860 const routes = await loadAdminRoutes();
1861 const res = await routes.request("/admin/modlog", {
1862 headers: { cookie: "atbb_session=token" },
1863 });
1864 const html = await res.text();
1865 expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123");
1866 });
1867
1868 it("shows handle in subject column for user-targeting actions", async () => {
1869 setupSession(["space.atbb.permission.banUsers"]);
1870 mockFetch.mockResolvedValueOnce(
1871 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1872 );
1873 const routes = await loadAdminRoutes();
1874 const res = await routes.request("/admin/modlog", {
1875 headers: { cookie: "atbb_session=token" },
1876 });
1877 const html = await res.text();
1878 expect(html).toContain("bob.bsky.social");
1879 });
1880
1881 it("shows empty state when no actions", async () => {
1882 setupSession(["space.atbb.permission.banUsers"]);
1883 mockFetch.mockResolvedValueOnce(
1884 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
1885 );
1886 const routes = await loadAdminRoutes();
1887 const res = await routes.request("/admin/modlog", {
1888 headers: { cookie: "atbb_session=token" },
1889 });
1890 const html = await res.text();
1891 expect(html).toContain("No moderation actions");
1892 });
1893
1894 // ── Pagination ───────────────────────────────────────────────────────────
1895
1896 it("renders 'Page 1 of 2' indicator for 51 total actions", async () => {
1897 setupSession(["space.atbb.permission.banUsers"]);
1898 mockFetch.mockResolvedValueOnce(
1899 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, 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("Page 1 of 2");
1907 });
1908
1909 it("shows Next link when more pages exist", async () => {
1910 setupSession(["space.atbb.permission.banUsers"]);
1911 mockFetch.mockResolvedValueOnce(
1912 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, 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('href="/admin/modlog?offset=50"');
1920 expect(html).toContain("Next");
1921 });
1922
1923 it("hides Next link on last page", async () => {
1924 setupSession(["space.atbb.permission.banUsers"]);
1925 mockFetch.mockResolvedValueOnce(
1926 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
1927 );
1928 const routes = await loadAdminRoutes();
1929 const res = await routes.request("/admin/modlog?offset=50", {
1930 headers: { cookie: "atbb_session=token" },
1931 });
1932 const html = await res.text();
1933 expect(html).not.toContain('href="/admin/modlog?offset=100"');
1934 });
1935
1936 it("shows Previous link when not on first page", async () => {
1937 setupSession(["space.atbb.permission.banUsers"]);
1938 mockFetch.mockResolvedValueOnce(
1939 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
1940 );
1941 const routes = await loadAdminRoutes();
1942 const res = await routes.request("/admin/modlog?offset=50", {
1943 headers: { cookie: "atbb_session=token" },
1944 });
1945 const html = await res.text();
1946 expect(html).toContain('href="/admin/modlog?offset=0"');
1947 expect(html).toContain("Previous");
1948 });
1949
1950 it("hides Previous link on first page", 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).not.toContain('href="/admin/modlog?offset=-50"');
1961 expect(html).not.toContain("Previous");
1962 });
1963
1964 it("passes offset query param to AppView", async () => {
1965 setupSession(["space.atbb.permission.banUsers"]);
1966 mockFetch.mockResolvedValueOnce(
1967 mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 })
1968 );
1969 const routes = await loadAdminRoutes();
1970 await routes.request("/admin/modlog?offset=50", {
1971 headers: { cookie: "atbb_session=token" },
1972 });
1973 // Third fetch call (index 2) is the modlog API call
1974 const modlogCall = mockFetch.mock.calls[2];
1975 expect(modlogCall[0]).toContain("offset=50");
1976 expect(modlogCall[0]).toContain("limit=50");
1977 });
1978
1979 it("ignores invalid offset and defaults to 0", async () => {
1980 setupSession(["space.atbb.permission.banUsers"]);
1981 mockFetch.mockResolvedValueOnce(
1982 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
1983 );
1984 const routes = await loadAdminRoutes();
1985 const res = await routes.request("/admin/modlog?offset=notanumber", {
1986 headers: { cookie: "atbb_session=token" },
1987 });
1988 expect(res.status).toBe(200);
1989 const modlogCall = mockFetch.mock.calls[2];
1990 expect(modlogCall[0]).toContain("offset=0");
1991 });
1992
1993 // ── Error handling ───────────────────────────────────────────────────────
1994
1995 it("returns 503 on AppView network error", async () => {
1996 setupSession(["space.atbb.permission.banUsers"]);
1997 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
1998 const routes = await loadAdminRoutes();
1999 const res = await routes.request("/admin/modlog", {
2000 headers: { cookie: "atbb_session=token" },
2001 });
2002 expect(res.status).toBe(503);
2003 const html = await res.text();
2004 expect(html).toContain("error-display");
2005 });
2006
2007 it("returns 500 on AppView server error", async () => {
2008 setupSession(["space.atbb.permission.banUsers"]);
2009 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
2010 const routes = await loadAdminRoutes();
2011 const res = await routes.request("/admin/modlog", {
2012 headers: { cookie: "atbb_session=token" },
2013 });
2014 expect(res.status).toBe(500);
2015 const html = await res.text();
2016 expect(html).toContain("error-display");
2017 });
2018
2019 it("returns 500 when AppView returns non-JSON response body", async () => {
2020 setupSession(["space.atbb.permission.banUsers"]);
2021 mockFetch.mockResolvedValueOnce({
2022 ok: true,
2023 status: 200,
2024 statusText: "OK",
2025 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")),
2026 });
2027 const routes = await loadAdminRoutes();
2028 const res = await routes.request("/admin/modlog", {
2029 headers: { cookie: "atbb_session=token" },
2030 });
2031 expect(res.status).toBe(500);
2032 const html = await res.text();
2033 expect(html).toContain("error-display");
2034 });
2035
2036 it("redirects to /login when AppView returns 401", async () => {
2037 setupSession(["space.atbb.permission.banUsers"]);
2038 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
2039 const routes = await loadAdminRoutes();
2040 const res = await routes.request("/admin/modlog", {
2041 headers: { cookie: "atbb_session=token" },
2042 });
2043 expect(res.status).toBe(302);
2044 expect(res.headers.get("location")).toBe("/login");
2045 });
2046});