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, beforeEach, afterEach, vi } from "vitest";
2import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
3import { Hono } from "hono";
4import type { Variables } from "../../types.js";
5import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db";
6import { eq } from "drizzle-orm";
7
8// Mock middleware at module level
9let mockUser: any;
10let mockGetUserRole: ReturnType<typeof vi.fn>;
11let mockPutRecord: ReturnType<typeof vi.fn>;
12let mockDeleteRecord: ReturnType<typeof vi.fn>;
13let mockRequireAnyPermissionPass = true;
14
15// Create the mock function at module level
16mockGetUserRole = vi.fn();
17
18vi.mock("../../middleware/auth.js", () => ({
19 requireAuth: vi.fn(() => async (c: any, next: any) => {
20 if (!mockUser) {
21 return c.json({ error: "Unauthorized" }, 401);
22 }
23 c.set("user", mockUser);
24 await next();
25 }),
26}));
27
28vi.mock("../../middleware/permissions.js", () => ({
29 requirePermission: vi.fn(() => async (_c: any, next: any) => {
30 await next();
31 }),
32 requireAnyPermission: vi.fn(() => async (c: any, next: any) => {
33 if (!mockRequireAnyPermissionPass) {
34 return c.json({ error: "Insufficient permissions" }, 403);
35 }
36 await next();
37 }),
38 getUserRole: (...args: any[]) => mockGetUserRole(...args),
39 checkPermission: vi.fn().mockResolvedValue(true),
40}));
41
42// Import after mocking
43const { createAdminRoutes } = await import("../admin.js");
44
45describe.sequential("Admin Routes", () => {
46 let ctx: TestContext;
47 let app: Hono<{ Variables: Variables }>;
48
49 beforeEach(async () => {
50 ctx = await createTestContext();
51 app = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
52
53 // Set up mock user for auth middleware
54 mockUser = { did: "did:plc:test-admin" };
55 mockGetUserRole.mockClear();
56 mockRequireAnyPermissionPass = true;
57
58 // Mock putRecord
59 mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } });
60 mockDeleteRecord = vi.fn().mockResolvedValue({});
61
62 // Mock ForumAgent
63 ctx.forumAgent = {
64 getAgent: () => ({
65 com: {
66 atproto: {
67 repo: {
68 putRecord: mockPutRecord,
69 deleteRecord: mockDeleteRecord,
70 },
71 },
72 },
73 }),
74 } as any;
75 });
76
77 afterEach(async () => {
78 await ctx.cleanup();
79 });
80
81 describe("POST /api/admin/members/:did/role", () => {
82 beforeEach(async () => {
83 // Create test roles: Owner (priority 0), Admin (priority 10), Moderator (priority 20)
84 const [ownerRole] = await ctx.db.insert(roles).values({
85 did: ctx.config.forumDid,
86 rkey: "owner",
87 cid: "bafyowner",
88 name: "Owner",
89 description: "Forum owner",
90 priority: 0,
91 createdAt: new Date(),
92 indexedAt: new Date(),
93 }).returning({ id: roles.id });
94 await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]);
95
96 const [adminRole] = await ctx.db.insert(roles).values({
97 did: ctx.config.forumDid,
98 rkey: "admin",
99 cid: "bafyadmin",
100 name: "Admin",
101 description: "Administrator",
102 priority: 10,
103 createdAt: new Date(),
104 indexedAt: new Date(),
105 }).returning({ id: roles.id });
106 await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]);
107
108 const [moderatorRole] = await ctx.db.insert(roles).values({
109 did: ctx.config.forumDid,
110 rkey: "moderator",
111 cid: "bafymoderator",
112 name: "Moderator",
113 description: "Moderator",
114 priority: 20,
115 createdAt: new Date(),
116 indexedAt: new Date(),
117 }).returning({ id: roles.id });
118 await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]);
119
120 // Create target user and membership (use onConflictDoNothing to handle test re-runs)
121 await ctx.db.insert(users).values({
122 did: "did:plc:test-target",
123 handle: "target.test",
124 indexedAt: new Date(),
125 }).onConflictDoNothing();
126
127 await ctx.db.insert(memberships).values({
128 did: "did:plc:test-target",
129 rkey: "self",
130 cid: "bafymember",
131 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
132 joinedAt: new Date(),
133 createdAt: new Date(),
134 indexedAt: new Date(),
135 }).onConflictDoNothing();
136 });
137
138 it("assigns role successfully when admin has authority", async () => {
139 // Admin (priority 10) assigns Moderator (priority 20) - allowed
140 mockGetUserRole.mockResolvedValue({
141 id: 2n,
142 name: "Admin",
143 priority: 10,
144 permissions: ["space.atbb.permission.manageRoles"],
145 });
146
147 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
148 method: "POST",
149 headers: { "Content-Type": "application/json" },
150 body: JSON.stringify({
151 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
152 }),
153 });
154
155 expect(res.status).toBe(200);
156 const data = await res.json();
157 expect(data).toMatchObject({
158 success: true,
159 roleAssigned: "Moderator",
160 targetDid: "did:plc:test-target",
161 });
162 expect(mockPutRecord).toHaveBeenCalledWith(
163 expect.objectContaining({
164 repo: "did:plc:test-target",
165 collection: "space.atbb.membership",
166 record: expect.objectContaining({
167 role: expect.objectContaining({
168 role: expect.objectContaining({
169 uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
170 cid: "bafymoderator",
171 }),
172 }),
173 }),
174 })
175 );
176 });
177
178 it("returns 403 when assigning role with equal authority", async () => {
179 // Admin (priority 10) tries to assign Admin (priority 10) - blocked
180 mockGetUserRole.mockResolvedValue({
181 id: 2n,
182 name: "Admin",
183 priority: 10,
184 permissions: ["space.atbb.permission.manageRoles"],
185 });
186
187 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
188 method: "POST",
189 headers: { "Content-Type": "application/json" },
190 body: JSON.stringify({
191 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`,
192 }),
193 });
194
195 expect(res.status).toBe(403);
196 const data = await res.json();
197 expect(data.error).toContain("equal or higher authority");
198 // Priority values must not be leaked in responses (security: CLAUDE.md)
199 expect(data.yourPriority).toBeUndefined();
200 expect(data.targetRolePriority).toBeUndefined();
201 expect(mockPutRecord).not.toHaveBeenCalled();
202 });
203
204 it("returns 403 when assigning role with higher authority", async () => {
205 // Admin (priority 10) tries to assign Owner (priority 0) - blocked
206 mockGetUserRole.mockResolvedValue({
207 id: 2n,
208 name: "Admin",
209 priority: 10,
210 permissions: ["space.atbb.permission.manageRoles"],
211 });
212
213 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
214 method: "POST",
215 headers: { "Content-Type": "application/json" },
216 body: JSON.stringify({
217 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner`,
218 }),
219 });
220
221 expect(res.status).toBe(403);
222 const data = await res.json();
223 expect(data.error).toContain("equal or higher authority");
224 // Priority values must not be leaked in responses (security: CLAUDE.md)
225 expect(data.yourPriority).toBeUndefined();
226 expect(data.targetRolePriority).toBeUndefined();
227 expect(mockPutRecord).not.toHaveBeenCalled();
228 });
229
230 it("returns 404 when role not found", async () => {
231 mockGetUserRole.mockResolvedValue({
232 id: 2n,
233 name: "Admin",
234 priority: 10,
235 permissions: ["space.atbb.permission.manageRoles"],
236 });
237
238 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
239 method: "POST",
240 headers: { "Content-Type": "application/json" },
241 body: JSON.stringify({
242 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`,
243 }),
244 });
245
246 expect(res.status).toBe(404);
247 const data = await res.json();
248 expect(data.error).toBe("Role not found");
249 expect(mockPutRecord).not.toHaveBeenCalled();
250 });
251
252 it("returns 404 when target user not a member", async () => {
253 mockGetUserRole.mockResolvedValue({
254 id: 2n,
255 name: "Admin",
256 priority: 10,
257 permissions: ["space.atbb.permission.manageRoles"],
258 });
259
260 const res = await app.request("/api/admin/members/did:plc:nonmember/role", {
261 method: "POST",
262 headers: { "Content-Type": "application/json" },
263 body: JSON.stringify({
264 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
265 }),
266 });
267
268 expect(res.status).toBe(404);
269 const data = await res.json();
270 expect(data.error).toBe("User is not a member of this forum");
271 expect(mockPutRecord).not.toHaveBeenCalled();
272 });
273
274 it("returns 403 when user has no role assigned", async () => {
275 // getUserRole returns null (user has no role)
276 mockGetUserRole.mockResolvedValue(null);
277
278 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
279 method: "POST",
280 headers: { "Content-Type": "application/json" },
281 body: JSON.stringify({
282 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
283 }),
284 });
285
286 expect(res.status).toBe(403);
287 const data = await res.json();
288 expect(data.error).toBe("You do not have a role assigned");
289 expect(mockPutRecord).not.toHaveBeenCalled();
290 });
291
292 it("returns 400 for missing roleUri field", async () => {
293 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
294 method: "POST",
295 headers: { "Content-Type": "application/json" },
296 body: JSON.stringify({}),
297 });
298
299 expect(res.status).toBe(400);
300 const data = await res.json();
301 expect(data.error).toContain("roleUri is required");
302 });
303
304 it("returns 400 for invalid roleUri format", async () => {
305 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
306 method: "POST",
307 headers: { "Content-Type": "application/json" },
308 body: JSON.stringify({ roleUri: "invalid-uri" }),
309 });
310
311 expect(res.status).toBe(400);
312 const data = await res.json();
313 expect(data.error).toBe("Invalid roleUri format");
314 });
315
316 it("returns 400 for malformed JSON", async () => {
317 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
318 method: "POST",
319 headers: { "Content-Type": "application/json" },
320 body: "{ invalid json }",
321 });
322
323 expect(res.status).toBe(400);
324 const data = await res.json();
325 expect(data.error).toContain("Invalid JSON");
326 });
327
328 it("returns 503 when PDS connection fails (network error)", async () => {
329 mockGetUserRole.mockResolvedValue({
330 id: 2n,
331 name: "Admin",
332 priority: 10,
333 permissions: ["space.atbb.permission.manageRoles"],
334 });
335
336 mockPutRecord.mockRejectedValue(new Error("fetch failed"));
337
338 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
339 method: "POST",
340 headers: { "Content-Type": "application/json" },
341 body: JSON.stringify({
342 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
343 }),
344 });
345
346 expect(res.status).toBe(503);
347 const data = await res.json();
348 expect(data.error).toContain("Unable to reach external service");
349 });
350
351 it("returns 500 when ForumAgent unavailable", async () => {
352 mockGetUserRole.mockResolvedValue({
353 id: 2n,
354 name: "Admin",
355 priority: 10,
356 permissions: ["space.atbb.permission.manageRoles"],
357 });
358
359 ctx.forumAgent = null;
360
361 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
362 method: "POST",
363 headers: { "Content-Type": "application/json" },
364 body: JSON.stringify({
365 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
366 }),
367 });
368
369 expect(res.status).toBe(500);
370 const data = await res.json();
371 expect(data.error).toContain("Forum agent not available");
372 });
373
374 it("returns 500 for unexpected server errors", async () => {
375 mockGetUserRole.mockResolvedValue({
376 id: 2n,
377 name: "Admin",
378 priority: 10,
379 permissions: ["space.atbb.permission.manageRoles"],
380 });
381
382 mockPutRecord.mockRejectedValue(new Error("Unexpected write error"));
383
384 const res = await app.request("/api/admin/members/did:plc:test-target/role", {
385 method: "POST",
386 headers: { "Content-Type": "application/json" },
387 body: JSON.stringify({
388 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
389 }),
390 });
391
392 expect(res.status).toBe(500);
393 const data = await res.json();
394 expect(data.error).toContain("Failed to assign role");
395 expect(data.error).not.toContain("PDS");
396 });
397 });
398
399 describe("GET /api/admin/roles", () => {
400 it("lists all roles sorted by priority", async () => {
401 // Create test roles
402 const [ownerRole] = await ctx.db.insert(roles).values({
403 did: ctx.config.forumDid,
404 rkey: "owner",
405 cid: "bafyowner",
406 name: "Owner",
407 description: "Forum owner",
408 priority: 0,
409 createdAt: new Date(),
410 indexedAt: new Date(),
411 }).returning({ id: roles.id });
412 await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]);
413
414 const [moderatorRole] = await ctx.db.insert(roles).values({
415 did: ctx.config.forumDid,
416 rkey: "moderator",
417 cid: "bafymoderator",
418 name: "Moderator",
419 description: "Moderator",
420 priority: 20,
421 createdAt: new Date(),
422 indexedAt: new Date(),
423 }).returning({ id: roles.id });
424 await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]);
425
426 const [adminRole] = await ctx.db.insert(roles).values({
427 did: ctx.config.forumDid,
428 rkey: "admin",
429 cid: "bafyadmin",
430 name: "Admin",
431 description: "Administrator",
432 priority: 10,
433 createdAt: new Date(),
434 indexedAt: new Date(),
435 }).returning({ id: roles.id });
436 await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]);
437
438 const res = await app.request("/api/admin/roles");
439
440 expect(res.status).toBe(200);
441 const data = await res.json();
442 expect(data.roles).toHaveLength(3);
443
444 // Verify sorted by priority (Owner first, Moderator last)
445 expect(data.roles[0].name).toBe("Owner");
446 expect(data.roles[0].priority).toBe(0);
447 expect(data.roles[0].permissions).toEqual(["*"]);
448
449 expect(data.roles[1].name).toBe("Admin");
450 expect(data.roles[1].priority).toBe(10);
451
452 expect(data.roles[2].name).toBe("Moderator");
453 expect(data.roles[2].priority).toBe(20);
454
455 // Verify BigInt serialization
456 expect(typeof data.roles[0].id).toBe("string");
457 });
458
459 it("returns empty array when no roles exist", async () => {
460 const res = await app.request("/api/admin/roles");
461
462 expect(res.status).toBe(200);
463 const data = await res.json();
464 expect(data.roles).toEqual([]);
465 });
466
467 it("includes uri field constructed from did and rkey", async () => {
468 // Seed a role matching the pattern used in this describe block
469 await ctx.db.insert(roles).values({
470 did: ctx.config.forumDid,
471 rkey: "moderator",
472 cid: "bafymoderator",
473 name: "Moderator",
474 description: "Moderator",
475 priority: 20,
476 createdAt: new Date(),
477 indexedAt: new Date(),
478 });
479
480 const res = await app.request("/api/admin/roles");
481
482 expect(res.status).toBe(200);
483 const data = await res.json() as { roles: Array<{ name: string; uri: string; id: string }> };
484 expect(data.roles).toHaveLength(1);
485 expect(data.roles[0].uri).toBe(`at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`);
486 });
487 });
488
489 describe.sequential("GET /api/admin/members", () => {
490 beforeEach(async () => {
491 // Clean database to ensure no data pollution from other tests
492 await ctx.cleanDatabase();
493
494 // Re-insert forum (deleted by cleanDatabase)
495 await ctx.db.insert(forums).values({
496 did: ctx.config.forumDid,
497 rkey: "self",
498 cid: "bafytest",
499 name: "Test Forum",
500 description: "A test forum",
501 indexedAt: new Date(),
502 });
503
504 // Create test role
505 const [moderatorRole] = await ctx.db.insert(roles).values({
506 did: ctx.config.forumDid,
507 rkey: "moderator",
508 cid: "bafymoderator",
509 name: "Moderator",
510 description: "Moderator",
511 priority: 20,
512 createdAt: new Date(),
513 indexedAt: new Date(),
514 }).returning({ id: roles.id });
515 await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]);
516 });
517
518 it("lists members with assigned roles", async () => {
519 // Create user and membership with role
520 await ctx.db.insert(users).values({
521 did: "did:plc:test-member-role",
522 handle: "member.test",
523 indexedAt: new Date(),
524 }).onConflictDoNothing();
525
526 await ctx.db.insert(memberships).values({
527 did: "did:plc:test-member-role",
528 rkey: "self",
529 cid: "bafymember",
530 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
531 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
532 joinedAt: new Date("2026-01-15T00:00:00.000Z"),
533 createdAt: new Date(),
534 indexedAt: new Date(),
535 }).onConflictDoNothing();
536
537 const res = await app.request("/api/admin/members");
538
539 expect(res.status).toBe(200);
540 const data = await res.json();
541 expect(data.members).toHaveLength(1);
542 expect(data.members[0]).toMatchObject({
543 did: "did:plc:test-member-role",
544 handle: "member.test",
545 role: "Moderator",
546 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
547 joinedAt: "2026-01-15T00:00:00.000Z",
548 });
549 });
550
551 it("shows Guest for members with no role", async () => {
552 // Create user and membership without role
553 await ctx.db.insert(users).values({
554 did: "did:plc:test-guest",
555 handle: "guest.test",
556 indexedAt: new Date(),
557 }).onConflictDoNothing();
558
559 await ctx.db.insert(memberships).values({
560 did: "did:plc:test-guest",
561 rkey: "self",
562 cid: "bafymember",
563 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
564 roleUri: null,
565 joinedAt: new Date("2026-01-15T00:00:00.000Z"),
566 createdAt: new Date(),
567 indexedAt: new Date(),
568 }).onConflictDoNothing();
569
570 const res = await app.request("/api/admin/members");
571
572 expect(res.status).toBe(200);
573 const data = await res.json();
574 expect(data.members).toHaveLength(1);
575 expect(data.members[0]).toMatchObject({
576 did: "did:plc:test-guest",
577 handle: "guest.test",
578 role: "Guest",
579 roleUri: null,
580 });
581 });
582
583 it("shows DID as handle fallback when handle not found", async () => {
584 // Create user without handle (to test DID fallback)
585 await ctx.db.insert(users).values({
586 did: "did:plc:test-unknown",
587 handle: null, // No handle to test fallback
588 indexedAt: new Date(),
589 }).onConflictDoNothing();
590
591 // Create membership for this user
592 await ctx.db.insert(memberships).values({
593 did: "did:plc:test-unknown",
594 rkey: "self",
595 cid: "bafymember",
596 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
597 roleUri: null,
598 joinedAt: new Date(),
599 createdAt: new Date(),
600 indexedAt: new Date(),
601 }).onConflictDoNothing();
602
603 const res = await app.request("/api/admin/members");
604
605 expect(res.status).toBe(200);
606 const data = await res.json();
607 expect(data.members).toHaveLength(1);
608 expect(data.members[0]).toMatchObject({
609 did: "did:plc:test-unknown",
610 handle: "did:plc:test-unknown", // DID used as fallback
611 role: "Guest",
612 });
613 });
614 });
615 describe.sequential("GET /api/admin/members/me", () => {
616 beforeEach(async () => {
617 // Clean database to ensure no data pollution from other tests
618 await ctx.cleanDatabase();
619
620 // Re-insert forum (deleted by cleanDatabase)
621 await ctx.db.insert(forums).values({
622 did: ctx.config.forumDid,
623 rkey: "self",
624 cid: "bafytest",
625 name: "Test Forum",
626 description: "A test forum",
627 indexedAt: new Date(),
628 });
629
630 // Set mock user
631 mockUser = { did: "did:plc:test-me" };
632 });
633
634 it("returns 401 when not authenticated", async () => {
635 mockUser = null; // signals the requireAuth mock to return 401
636 const res = await app.request("/api/admin/members/me");
637 expect(res.status).toBe(401);
638 });
639
640 it("returns 404 when authenticated user has no membership record", async () => {
641 // mockUser is set to did:plc:test-me but no membership record exists
642 const res = await app.request("/api/admin/members/me");
643
644 expect(res.status).toBe(404);
645 const data = await res.json();
646 expect(data.error).toBe("Membership not found");
647 });
648
649 it("returns 200 with membership, role, and permissions for a user with a linked role", async () => {
650 // Insert role
651 const [moderatorRole] = await ctx.db.insert(roles).values({
652 did: ctx.config.forumDid,
653 rkey: "moderator",
654 cid: "bafymoderator",
655 name: "Moderator",
656 description: "Moderator role",
657 priority: 20,
658 createdAt: new Date(),
659 indexedAt: new Date(),
660 }).returning({ id: roles.id });
661 await ctx.db.insert(rolePermissions).values([
662 { roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" },
663 { roleId: moderatorRole.id, permission: "space.atbb.permission.lockTopics" },
664 ]);
665
666 // Insert user
667 await ctx.db.insert(users).values({
668 did: "did:plc:test-me",
669 handle: "me.test",
670 indexedAt: new Date(),
671 }).onConflictDoNothing();
672
673 // Insert membership linked to role
674 await ctx.db.insert(memberships).values({
675 did: "did:plc:test-me",
676 rkey: "self",
677 cid: "bafymembership",
678 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
679 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
680 joinedAt: new Date("2026-01-15T00:00:00.000Z"),
681 createdAt: new Date(),
682 indexedAt: new Date(),
683 }).onConflictDoNothing();
684
685 const res = await app.request("/api/admin/members/me");
686
687 expect(res.status).toBe(200);
688 const data = await res.json();
689 expect(data).toMatchObject({
690 did: "did:plc:test-me",
691 handle: "me.test",
692 role: "Moderator",
693 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`,
694 permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.lockTopics"],
695 });
696 });
697
698 it("returns 200 with empty permissions array when membership exists but role has no permissions", async () => {
699 // Insert role with empty permissions
700 await ctx.db.insert(roles).values({
701 did: ctx.config.forumDid,
702 rkey: "guest-role",
703 cid: "bafyguestrole",
704 name: "Guest Role",
705 description: "Role with no permissions",
706 priority: 100,
707 createdAt: new Date(),
708 indexedAt: new Date(),
709 });
710 // No rolePermissions inserted — role has no permissions
711
712 // Insert user
713 await ctx.db.insert(users).values({
714 did: "did:plc:test-me",
715 handle: "me.test",
716 indexedAt: new Date(),
717 }).onConflictDoNothing();
718
719 // Insert membership linked to role
720 await ctx.db.insert(memberships).values({
721 did: "did:plc:test-me",
722 rkey: "self",
723 cid: "bafymembership",
724 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
725 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/guest-role`,
726 joinedAt: new Date(),
727 createdAt: new Date(),
728 indexedAt: new Date(),
729 }).onConflictDoNothing();
730
731 const res = await app.request("/api/admin/members/me");
732
733 expect(res.status).toBe(200);
734 const data = await res.json();
735 expect(data.permissions).toEqual([]);
736 expect(data.role).toBe("Guest Role");
737 });
738
739 it("only returns the current user's membership, not other users'", async () => {
740 // Insert role
741 const [adminRole] = await ctx.db.insert(roles).values({
742 did: ctx.config.forumDid,
743 rkey: "admin",
744 cid: "bafyadmin",
745 name: "Admin",
746 description: "Admin role",
747 priority: 10,
748 createdAt: new Date(),
749 indexedAt: new Date(),
750 }).returning({ id: roles.id });
751 await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "*" }]);
752
753 // Insert current user with membership
754 await ctx.db.insert(users).values({
755 did: "did:plc:test-me",
756 handle: "me.test",
757 indexedAt: new Date(),
758 }).onConflictDoNothing();
759
760 await ctx.db.insert(memberships).values({
761 did: "did:plc:test-me",
762 rkey: "self",
763 cid: "bafymymembership",
764 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
765 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`,
766 joinedAt: new Date(),
767 createdAt: new Date(),
768 indexedAt: new Date(),
769 }).onConflictDoNothing();
770
771 // Insert another user with a different role
772 await ctx.db.insert(users).values({
773 did: "did:plc:test-other",
774 handle: "other.test",
775 indexedAt: new Date(),
776 }).onConflictDoNothing();
777
778 await ctx.db.insert(memberships).values({
779 did: "did:plc:test-other",
780 rkey: "self",
781 cid: "bafyothermembership",
782 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
783 roleUri: null,
784 joinedAt: new Date(),
785 createdAt: new Date(),
786 indexedAt: new Date(),
787 }).onConflictDoNothing();
788
789 const res = await app.request("/api/admin/members/me");
790
791 expect(res.status).toBe(200);
792 const data = await res.json();
793 // Should return only our user's data
794 expect(data.did).toBe("did:plc:test-me");
795 expect(data.handle).toBe("me.test");
796 expect(data.role).toBe("Admin");
797 });
798
799 it("returns 'Guest' as role when membership has no roleUri", async () => {
800 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
801 await ctx.db.insert(users).values({
802 did: "did:plc:test-guest",
803 handle: "guest.bsky.social",
804 indexedAt: new Date(),
805 });
806 await ctx.db.insert(memberships).values({
807 did: "did:plc:test-guest",
808 rkey: "guestrkey",
809 cid: "bafymembership-guest",
810 forumUri,
811 roleUri: null,
812 joinedAt: new Date(),
813 createdAt: new Date(),
814 indexedAt: new Date(),
815 });
816
817 mockUser = { did: "did:plc:test-guest" };
818 const res = await app.request("/api/admin/members/me");
819 expect(res.status).toBe(200);
820 const data = await res.json();
821 expect(data.did).toBe("did:plc:test-guest");
822 expect(data.role).toBe("Guest");
823 expect(data.roleUri).toBeNull();
824 expect(data.permissions).toEqual([]);
825 });
826 });
827
828 describe.sequential("POST /api/admin/categories", () => {
829 beforeEach(async () => {
830 await ctx.cleanDatabase();
831
832 mockUser = { did: "did:plc:test-admin" };
833 mockPutRecord.mockClear();
834 mockDeleteRecord.mockClear();
835 mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid123`, cid: "bafycategory" } });
836 });
837
838 it("creates category with valid body → 201 and putRecord called", async () => {
839 const res = await app.request("/api/admin/categories", {
840 method: "POST",
841 headers: { "Content-Type": "application/json" },
842 body: JSON.stringify({ name: "General Discussion", description: "Talk about anything.", sortOrder: 1 }),
843 });
844
845 expect(res.status).toBe(201);
846 const data = await res.json();
847 expect(data.uri).toContain("/space.atbb.forum.category/");
848 expect(data.cid).toBe("bafycategory");
849 expect(mockPutRecord).toHaveBeenCalledWith(
850 expect.objectContaining({
851 repo: ctx.config.forumDid,
852 collection: "space.atbb.forum.category",
853 rkey: expect.any(String),
854 record: expect.objectContaining({
855 $type: "space.atbb.forum.category",
856 name: "General Discussion",
857 description: "Talk about anything.",
858 sortOrder: 1,
859 createdAt: expect.any(String),
860 }),
861 })
862 );
863 });
864
865 it("creates category without optional fields → 201", async () => {
866 const res = await app.request("/api/admin/categories", {
867 method: "POST",
868 headers: { "Content-Type": "application/json" },
869 body: JSON.stringify({ name: "Minimal" }),
870 });
871
872 expect(res.status).toBe(201);
873 expect(mockPutRecord).toHaveBeenCalledWith(
874 expect.objectContaining({
875 record: expect.objectContaining({ name: "Minimal" }),
876 })
877 );
878 });
879
880 it("returns 400 when name is missing → no PDS write", async () => {
881 const res = await app.request("/api/admin/categories", {
882 method: "POST",
883 headers: { "Content-Type": "application/json" },
884 body: JSON.stringify({ description: "No name field" }),
885 });
886
887 expect(res.status).toBe(400);
888 const data = await res.json();
889 expect(data.error).toContain("name");
890 expect(mockPutRecord).not.toHaveBeenCalled();
891 });
892
893 it("returns 400 when name is empty string → no PDS write", async () => {
894 const res = await app.request("/api/admin/categories", {
895 method: "POST",
896 headers: { "Content-Type": "application/json" },
897 body: JSON.stringify({ name: " " }),
898 });
899
900 expect(res.status).toBe(400);
901 expect(mockPutRecord).not.toHaveBeenCalled();
902 });
903
904 it("returns 400 for malformed JSON", async () => {
905 const res = await app.request("/api/admin/categories", {
906 method: "POST",
907 headers: { "Content-Type": "application/json" },
908 body: "{ bad json }",
909 });
910
911 expect(res.status).toBe(400);
912 const data = await res.json();
913 expect(data.error).toContain("Invalid JSON");
914 expect(mockPutRecord).not.toHaveBeenCalled();
915 });
916
917 it("returns 401 when unauthenticated → no PDS write", async () => {
918 mockUser = null;
919
920 const res = await app.request("/api/admin/categories", {
921 method: "POST",
922 headers: { "Content-Type": "application/json" },
923 body: JSON.stringify({ name: "Test" }),
924 });
925
926 expect(res.status).toBe(401);
927 expect(mockPutRecord).not.toHaveBeenCalled();
928 });
929
930 it("returns 503 when PDS network error", async () => {
931 mockPutRecord.mockRejectedValue(new Error("fetch failed"));
932
933 const res = await app.request("/api/admin/categories", {
934 method: "POST",
935 headers: { "Content-Type": "application/json" },
936 body: JSON.stringify({ name: "Test" }),
937 });
938
939 expect(res.status).toBe(503);
940 const data = await res.json();
941 expect(data.error).toContain("Unable to reach external service");
942 expect(mockPutRecord).toHaveBeenCalled();
943 });
944
945 it("returns 500 when ForumAgent unavailable", async () => {
946 ctx.forumAgent = null;
947
948 const res = await app.request("/api/admin/categories", {
949 method: "POST",
950 headers: { "Content-Type": "application/json" },
951 body: JSON.stringify({ name: "Test" }),
952 });
953
954 expect(res.status).toBe(500);
955 const data = await res.json();
956 expect(data.error).toContain("Forum agent not available");
957 });
958
959 it("returns 503 when ForumAgent not authenticated", async () => {
960 const originalAgent = ctx.forumAgent;
961 ctx.forumAgent = { getAgent: () => null } as any;
962
963 const res = await app.request("/api/admin/categories", {
964 method: "POST",
965 headers: { "Content-Type": "application/json" },
966 body: JSON.stringify({ name: "Test" }),
967 });
968
969 expect(res.status).toBe(503);
970 const data = await res.json();
971 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
972 expect(mockPutRecord).not.toHaveBeenCalled();
973
974 ctx.forumAgent = originalAgent;
975 });
976
977 it("returns 403 when user lacks manageCategories permission", async () => {
978 const { requirePermission } = await import("../../middleware/permissions.js");
979 const mockRequirePermission = requirePermission as any;
980 mockRequirePermission.mockImplementation(() => async (c: any) => {
981 return c.json({ error: "Forbidden" }, 403);
982 });
983
984 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
985 const res = await testApp.request("/api/admin/categories", {
986 method: "POST",
987 headers: { "Content-Type": "application/json" },
988 body: JSON.stringify({ name: "Test" }),
989 });
990
991 expect(res.status).toBe(403);
992 expect(mockPutRecord).not.toHaveBeenCalled();
993
994 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
995 await next();
996 });
997 });
998 });
999
1000 describe.sequential("PUT /api/admin/categories/:id", () => {
1001 let categoryId: string;
1002
1003 beforeEach(async () => {
1004 await ctx.cleanDatabase();
1005
1006 await ctx.db.insert(forums).values({
1007 did: ctx.config.forumDid,
1008 rkey: "self",
1009 cid: "bafytest",
1010 name: "Test Forum",
1011 description: "A test forum",
1012 indexedAt: new Date(),
1013 });
1014
1015 const [cat] = await ctx.db.insert(categories).values({
1016 did: ctx.config.forumDid,
1017 rkey: "tid-test-cat",
1018 cid: "bafycat",
1019 name: "Original Name",
1020 description: "Original description",
1021 sortOrder: 1,
1022 createdAt: new Date("2026-01-01T00:00:00.000Z"),
1023 indexedAt: new Date(),
1024 }).returning({ id: categories.id });
1025
1026 categoryId = cat.id.toString();
1027
1028 mockUser = { did: "did:plc:test-admin" };
1029 mockPutRecord.mockClear();
1030 mockDeleteRecord.mockClear();
1031 mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`, cid: "bafynewcid" } });
1032 });
1033
1034 it("updates category name → 200 and putRecord called with same rkey", async () => {
1035 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1036 method: "PUT",
1037 headers: { "Content-Type": "application/json" },
1038 body: JSON.stringify({ name: "Updated Name", description: "New desc", sortOrder: 2 }),
1039 });
1040
1041 expect(res.status).toBe(200);
1042 const data = await res.json();
1043 expect(data.uri).toContain("/space.atbb.forum.category/");
1044 expect(data.cid).toBe("bafynewcid");
1045 expect(mockPutRecord).toHaveBeenCalledWith(
1046 expect.objectContaining({
1047 repo: ctx.config.forumDid,
1048 collection: "space.atbb.forum.category",
1049 rkey: "tid-test-cat",
1050 record: expect.objectContaining({
1051 $type: "space.atbb.forum.category",
1052 name: "Updated Name",
1053 description: "New desc",
1054 sortOrder: 2,
1055 }),
1056 })
1057 );
1058 });
1059
1060 it("preserves original createdAt, description, and sortOrder when not provided", async () => {
1061 // category was created with description: "Original description", sortOrder: 1
1062 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1063 method: "PUT",
1064 headers: { "Content-Type": "application/json" },
1065 body: JSON.stringify({ name: "Updated Name" }),
1066 });
1067
1068 expect(res.status).toBe(200);
1069 expect(mockPutRecord).toHaveBeenCalledWith(
1070 expect.objectContaining({
1071 record: expect.objectContaining({
1072 createdAt: "2026-01-01T00:00:00.000Z",
1073 description: "Original description",
1074 sortOrder: 1,
1075 }),
1076 })
1077 );
1078 });
1079
1080 it("returns 400 when name is missing", async () => {
1081 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1082 method: "PUT",
1083 headers: { "Content-Type": "application/json" },
1084 body: JSON.stringify({ description: "No name" }),
1085 });
1086
1087 expect(res.status).toBe(400);
1088 const data = await res.json();
1089 expect(data.error).toContain("name");
1090 expect(mockPutRecord).not.toHaveBeenCalled();
1091 });
1092
1093 it("returns 400 when name is whitespace-only", async () => {
1094 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1095 method: "PUT",
1096 headers: { "Content-Type": "application/json" },
1097 body: JSON.stringify({ name: " " }),
1098 });
1099
1100 expect(res.status).toBe(400);
1101 expect(mockPutRecord).not.toHaveBeenCalled();
1102 });
1103
1104 it("returns 400 for malformed JSON", async () => {
1105 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1106 method: "PUT",
1107 headers: { "Content-Type": "application/json" },
1108 body: "{ bad json }",
1109 });
1110
1111 expect(res.status).toBe(400);
1112 const data = await res.json();
1113 expect(data.error).toContain("Invalid JSON");
1114 expect(mockPutRecord).not.toHaveBeenCalled();
1115 });
1116
1117 it("returns 400 for invalid category ID format", async () => {
1118 const res = await app.request("/api/admin/categories/not-a-number", {
1119 method: "PUT",
1120 headers: { "Content-Type": "application/json" },
1121 body: JSON.stringify({ name: "Test" }),
1122 });
1123
1124 expect(res.status).toBe(400);
1125 const data = await res.json();
1126 expect(data.error).toContain("Invalid category ID");
1127 expect(mockPutRecord).not.toHaveBeenCalled();
1128 });
1129
1130 it("returns 404 when category not found", async () => {
1131 const res = await app.request("/api/admin/categories/99999", {
1132 method: "PUT",
1133 headers: { "Content-Type": "application/json" },
1134 body: JSON.stringify({ name: "Test" }),
1135 });
1136
1137 expect(res.status).toBe(404);
1138 const data = await res.json();
1139 expect(data.error).toContain("Category not found");
1140 expect(mockPutRecord).not.toHaveBeenCalled();
1141 });
1142
1143 it("returns 401 when unauthenticated", async () => {
1144 mockUser = null;
1145
1146 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1147 method: "PUT",
1148 headers: { "Content-Type": "application/json" },
1149 body: JSON.stringify({ name: "Test" }),
1150 });
1151
1152 expect(res.status).toBe(401);
1153 expect(mockPutRecord).not.toHaveBeenCalled();
1154 });
1155
1156 it("returns 503 when PDS network error", async () => {
1157 mockPutRecord.mockRejectedValue(new Error("fetch failed"));
1158
1159 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1160 method: "PUT",
1161 headers: { "Content-Type": "application/json" },
1162 body: JSON.stringify({ name: "Test" }),
1163 });
1164
1165 expect(res.status).toBe(503);
1166 const data = await res.json();
1167 expect(data.error).toContain("Unable to reach external service");
1168 expect(mockPutRecord).toHaveBeenCalled();
1169 });
1170
1171 it("returns 500 when ForumAgent unavailable", async () => {
1172 ctx.forumAgent = null;
1173
1174 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1175 method: "PUT",
1176 headers: { "Content-Type": "application/json" },
1177 body: JSON.stringify({ name: "Test" }),
1178 });
1179
1180 expect(res.status).toBe(500);
1181 const data = await res.json();
1182 expect(data.error).toContain("Forum agent not available");
1183 });
1184
1185 it("returns 503 when ForumAgent not authenticated", async () => {
1186 const originalAgent = ctx.forumAgent;
1187 ctx.forumAgent = { getAgent: () => null } as any;
1188
1189 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1190 method: "PUT",
1191 headers: { "Content-Type": "application/json" },
1192 body: JSON.stringify({ name: "Test" }),
1193 });
1194
1195 expect(res.status).toBe(503);
1196 const data = await res.json();
1197 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1198 expect(mockPutRecord).not.toHaveBeenCalled();
1199
1200 ctx.forumAgent = originalAgent;
1201 });
1202
1203 it("returns 503 when category lookup query fails", async () => {
1204 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1205 throw new Error("Database connection lost");
1206 });
1207
1208 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1209 method: "PUT",
1210 headers: { "Content-Type": "application/json" },
1211 body: JSON.stringify({ name: "Test" }),
1212 });
1213
1214 expect(res.status).toBe(503);
1215 const data = await res.json();
1216 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1217 expect(mockPutRecord).not.toHaveBeenCalled();
1218
1219 dbSelectSpy.mockRestore();
1220 });
1221
1222 it("returns 403 when user lacks manageCategories permission", async () => {
1223 const { requirePermission } = await import("../../middleware/permissions.js");
1224 const mockRequirePermission = requirePermission as any;
1225 mockRequirePermission.mockImplementation(() => async (c: any) => {
1226 return c.json({ error: "Forbidden" }, 403);
1227 });
1228
1229 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
1230 const res = await testApp.request(`/api/admin/categories/${categoryId}`, {
1231 method: "PUT",
1232 headers: { "Content-Type": "application/json" },
1233 body: JSON.stringify({ name: "Test" }),
1234 });
1235
1236 expect(res.status).toBe(403);
1237 expect(mockPutRecord).not.toHaveBeenCalled();
1238
1239 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
1240 await next();
1241 });
1242 });
1243 });
1244
1245 describe.sequential("DELETE /api/admin/categories/:id", () => {
1246 let categoryId: string;
1247
1248 beforeEach(async () => {
1249 await ctx.cleanDatabase();
1250
1251 await ctx.db.insert(forums).values({
1252 did: ctx.config.forumDid,
1253 rkey: "self",
1254 cid: "bafytest",
1255 name: "Test Forum",
1256 description: "A test forum",
1257 indexedAt: new Date(),
1258 });
1259
1260 const [cat] = await ctx.db.insert(categories).values({
1261 did: ctx.config.forumDid,
1262 rkey: "tid-test-del",
1263 cid: "bafycat",
1264 name: "Delete Me",
1265 description: null,
1266 sortOrder: 1,
1267 createdAt: new Date(),
1268 indexedAt: new Date(),
1269 }).returning({ id: categories.id });
1270
1271 categoryId = cat.id.toString();
1272
1273 mockUser = { did: "did:plc:test-admin" };
1274 mockDeleteRecord.mockClear();
1275 mockDeleteRecord.mockResolvedValue({});
1276 });
1277
1278 it("deletes empty category → 200 and deleteRecord called", async () => {
1279 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1280 method: "DELETE",
1281 });
1282
1283 expect(res.status).toBe(200);
1284 const data = await res.json();
1285 expect(data.success).toBe(true);
1286 expect(mockDeleteRecord).toHaveBeenCalledWith({
1287 repo: ctx.config.forumDid,
1288 collection: "space.atbb.forum.category",
1289 rkey: "tid-test-del",
1290 });
1291 });
1292
1293 it("returns 409 when category has boards → deleteRecord NOT called", async () => {
1294 await ctx.db.insert(boards).values({
1295 did: ctx.config.forumDid,
1296 rkey: "tid-board-1",
1297 cid: "bafyboard",
1298 name: "Blocked Board",
1299 categoryId: BigInt(categoryId),
1300 categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-del`,
1301 createdAt: new Date(),
1302 indexedAt: new Date(),
1303 });
1304
1305 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1306 method: "DELETE",
1307 });
1308
1309 expect(res.status).toBe(409);
1310 const data = await res.json();
1311 expect(data.error).toContain("boards");
1312 expect(mockDeleteRecord).not.toHaveBeenCalled();
1313 });
1314
1315 it("returns 400 for invalid category ID", async () => {
1316 const res = await app.request("/api/admin/categories/not-a-number", {
1317 method: "DELETE",
1318 });
1319
1320 expect(res.status).toBe(400);
1321 const data = await res.json();
1322 expect(data.error).toContain("Invalid category ID");
1323 expect(mockDeleteRecord).not.toHaveBeenCalled();
1324 });
1325
1326 it("returns 404 when category not found", async () => {
1327 const res = await app.request("/api/admin/categories/99999", {
1328 method: "DELETE",
1329 });
1330
1331 expect(res.status).toBe(404);
1332 const data = await res.json();
1333 expect(data.error).toContain("Category not found");
1334 expect(mockDeleteRecord).not.toHaveBeenCalled();
1335 });
1336
1337 it("returns 401 when unauthenticated", async () => {
1338 mockUser = null;
1339
1340 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1341 method: "DELETE",
1342 });
1343
1344 expect(res.status).toBe(401);
1345 expect(mockDeleteRecord).not.toHaveBeenCalled();
1346 });
1347
1348 it("returns 503 when PDS network error on delete", async () => {
1349 mockDeleteRecord.mockRejectedValue(new Error("fetch failed"));
1350
1351 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1352 method: "DELETE",
1353 });
1354
1355 expect(res.status).toBe(503);
1356 const data = await res.json();
1357 expect(data.error).toContain("Unable to reach external service");
1358 expect(mockDeleteRecord).toHaveBeenCalled();
1359 });
1360
1361 it("returns 500 when ForumAgent unavailable", async () => {
1362 ctx.forumAgent = null;
1363
1364 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1365 method: "DELETE",
1366 });
1367
1368 expect(res.status).toBe(500);
1369 const data = await res.json();
1370 expect(data.error).toContain("Forum agent not available");
1371 });
1372
1373 it("returns 503 when ForumAgent not authenticated", async () => {
1374 const originalAgent = ctx.forumAgent;
1375 ctx.forumAgent = { getAgent: () => null } as any;
1376
1377 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1378 method: "DELETE",
1379 });
1380
1381 expect(res.status).toBe(503);
1382 const data = await res.json();
1383 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1384 expect(mockDeleteRecord).not.toHaveBeenCalled();
1385
1386 ctx.forumAgent = originalAgent;
1387 });
1388
1389 it("returns 503 when category lookup query fails", async () => {
1390 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1391 throw new Error("Database connection lost");
1392 });
1393
1394 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1395 method: "DELETE",
1396 });
1397
1398 expect(res.status).toBe(503);
1399 const data = await res.json();
1400 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1401 expect(mockDeleteRecord).not.toHaveBeenCalled();
1402
1403 dbSelectSpy.mockRestore();
1404 });
1405
1406 it("returns 503 when board count query fails", async () => {
1407 const originalSelect = ctx.db.select.bind(ctx.db);
1408 let callCount = 0;
1409 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => {
1410 callCount++;
1411 if (callCount === 1) {
1412 // First call: category lookup — pass through to real DB
1413 return (originalSelect as any)(...args);
1414 }
1415 // Second call: board count preflight — throw DB error
1416 throw new Error("Database connection lost");
1417 });
1418
1419 const res = await app.request(`/api/admin/categories/${categoryId}`, {
1420 method: "DELETE",
1421 });
1422
1423 expect(res.status).toBe(503);
1424 const data = await res.json();
1425 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1426 expect(mockDeleteRecord).not.toHaveBeenCalled();
1427
1428 dbSelectSpy.mockRestore();
1429 });
1430
1431 it("returns 403 when user lacks manageCategories permission", async () => {
1432 const { requirePermission } = await import("../../middleware/permissions.js");
1433 const mockRequirePermission = requirePermission as any;
1434 mockRequirePermission.mockImplementation(() => async (c: any) => {
1435 return c.json({ error: "Forbidden" }, 403);
1436 });
1437
1438 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
1439 const res = await testApp.request(`/api/admin/categories/${categoryId}`, {
1440 method: "DELETE",
1441 });
1442
1443 expect(res.status).toBe(403);
1444 expect(mockDeleteRecord).not.toHaveBeenCalled();
1445
1446 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
1447 await next();
1448 });
1449 });
1450 });
1451
1452 describe.sequential("POST /api/admin/boards", () => {
1453 let categoryUri: string;
1454
1455 beforeEach(async () => {
1456 await ctx.cleanDatabase();
1457
1458 mockUser = { did: "did:plc:test-admin" };
1459 mockPutRecord.mockClear();
1460 mockDeleteRecord.mockClear();
1461 mockPutRecord.mockResolvedValue({
1462 data: {
1463 uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`,
1464 cid: "bafyboard",
1465 },
1466 });
1467
1468 // Insert a category the tests can reference
1469 await ctx.db.insert(categories).values({
1470 did: ctx.config.forumDid,
1471 rkey: "tid-test-cat",
1472 cid: "bafycat",
1473 name: "Test Category",
1474 createdAt: new Date("2026-01-01T00:00:00.000Z"),
1475 indexedAt: new Date(),
1476 });
1477 categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
1478 });
1479
1480 it("creates board with valid body → 201 and putRecord called with categoryRef", async () => {
1481 const res = await app.request("/api/admin/boards", {
1482 method: "POST",
1483 headers: { "Content-Type": "application/json" },
1484 body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }),
1485 });
1486
1487 expect(res.status).toBe(201);
1488 const data = await res.json();
1489 expect(data.uri).toContain("/space.atbb.forum.board/");
1490 expect(data.cid).toBe("bafyboard");
1491 expect(mockPutRecord).toHaveBeenCalledWith(
1492 expect.objectContaining({
1493 repo: ctx.config.forumDid,
1494 collection: "space.atbb.forum.board",
1495 rkey: expect.any(String),
1496 record: expect.objectContaining({
1497 $type: "space.atbb.forum.board",
1498 name: "General Chat",
1499 description: "Talk here.",
1500 sortOrder: 1,
1501 category: { category: { uri: categoryUri, cid: "bafycat" } },
1502 createdAt: expect.any(String),
1503 }),
1504 })
1505 );
1506 });
1507
1508 it("creates board without optional fields → 201", async () => {
1509 const res = await app.request("/api/admin/boards", {
1510 method: "POST",
1511 headers: { "Content-Type": "application/json" },
1512 body: JSON.stringify({ name: "Minimal", categoryUri }),
1513 });
1514
1515 expect(res.status).toBe(201);
1516 expect(mockPutRecord).toHaveBeenCalledWith(
1517 expect.objectContaining({
1518 record: expect.objectContaining({ name: "Minimal" }),
1519 })
1520 );
1521 });
1522
1523 it("returns 400 when name is missing → no PDS write", async () => {
1524 const res = await app.request("/api/admin/boards", {
1525 method: "POST",
1526 headers: { "Content-Type": "application/json" },
1527 body: JSON.stringify({ categoryUri }),
1528 });
1529
1530 expect(res.status).toBe(400);
1531 const data = await res.json();
1532 expect(data.error).toContain("name");
1533 expect(mockPutRecord).not.toHaveBeenCalled();
1534 });
1535
1536 it("returns 400 when name is empty string → no PDS write", async () => {
1537 const res = await app.request("/api/admin/boards", {
1538 method: "POST",
1539 headers: { "Content-Type": "application/json" },
1540 body: JSON.stringify({ name: " ", categoryUri }),
1541 });
1542
1543 expect(res.status).toBe(400);
1544 expect(mockPutRecord).not.toHaveBeenCalled();
1545 });
1546
1547 it("returns 400 when categoryUri is missing → no PDS write", async () => {
1548 const res = await app.request("/api/admin/boards", {
1549 method: "POST",
1550 headers: { "Content-Type": "application/json" },
1551 body: JSON.stringify({ name: "Test Board" }),
1552 });
1553
1554 expect(res.status).toBe(400);
1555 const data = await res.json();
1556 expect(data.error).toContain("categoryUri");
1557 expect(mockPutRecord).not.toHaveBeenCalled();
1558 });
1559
1560 it("returns 404 when categoryUri references unknown category → no PDS write", async () => {
1561 const res = await app.request("/api/admin/boards", {
1562 method: "POST",
1563 headers: { "Content-Type": "application/json" },
1564 body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }),
1565 });
1566
1567 expect(res.status).toBe(404);
1568 const data = await res.json();
1569 expect(data.error).toContain("Category not found");
1570 expect(mockPutRecord).not.toHaveBeenCalled();
1571 });
1572
1573 it("returns 400 for malformed JSON", async () => {
1574 const res = await app.request("/api/admin/boards", {
1575 method: "POST",
1576 headers: { "Content-Type": "application/json" },
1577 body: "{ bad json }",
1578 });
1579
1580 expect(res.status).toBe(400);
1581 const data = await res.json();
1582 expect(data.error).toContain("Invalid JSON");
1583 expect(mockPutRecord).not.toHaveBeenCalled();
1584 });
1585
1586 it("returns 401 when unauthenticated → no PDS write", async () => {
1587 mockUser = null;
1588
1589 const res = await app.request("/api/admin/boards", {
1590 method: "POST",
1591 headers: { "Content-Type": "application/json" },
1592 body: JSON.stringify({ name: "Test", categoryUri }),
1593 });
1594
1595 expect(res.status).toBe(401);
1596 expect(mockPutRecord).not.toHaveBeenCalled();
1597 });
1598
1599 it("returns 503 when PDS network error", async () => {
1600 mockPutRecord.mockRejectedValue(new Error("fetch failed"));
1601
1602 const res = await app.request("/api/admin/boards", {
1603 method: "POST",
1604 headers: { "Content-Type": "application/json" },
1605 body: JSON.stringify({ name: "Test", categoryUri }),
1606 });
1607
1608 expect(res.status).toBe(503);
1609 const data = await res.json();
1610 expect(data.error).toContain("Unable to reach external service");
1611 expect(mockPutRecord).toHaveBeenCalled();
1612 });
1613
1614 it("returns 500 when ForumAgent unavailable", async () => {
1615 ctx.forumAgent = null;
1616
1617 const res = await app.request("/api/admin/boards", {
1618 method: "POST",
1619 headers: { "Content-Type": "application/json" },
1620 body: JSON.stringify({ name: "Test", categoryUri }),
1621 });
1622
1623 expect(res.status).toBe(500);
1624 const data = await res.json();
1625 expect(data.error).toContain("Forum agent not available");
1626 });
1627
1628 it("returns 503 when ForumAgent not authenticated", async () => {
1629 const originalAgent = ctx.forumAgent;
1630 ctx.forumAgent = { getAgent: () => null } as any;
1631
1632 const res = await app.request("/api/admin/boards", {
1633 method: "POST",
1634 headers: { "Content-Type": "application/json" },
1635 body: JSON.stringify({ name: "Test", categoryUri }),
1636 });
1637
1638 expect(res.status).toBe(503);
1639 const data = await res.json();
1640 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1641 expect(mockPutRecord).not.toHaveBeenCalled();
1642
1643 ctx.forumAgent = originalAgent;
1644 });
1645
1646 it("returns 503 when category lookup query fails", async () => {
1647 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1648 throw new Error("Database connection lost");
1649 });
1650
1651 const res = await app.request("/api/admin/boards", {
1652 method: "POST",
1653 headers: { "Content-Type": "application/json" },
1654 body: JSON.stringify({ name: "Test Board", categoryUri }),
1655 });
1656
1657 expect(res.status).toBe(503);
1658 const data = await res.json();
1659 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1660 expect(mockPutRecord).not.toHaveBeenCalled();
1661
1662 dbSelectSpy.mockRestore();
1663 });
1664
1665 it("returns 403 when user lacks manageCategories permission", async () => {
1666 const { requirePermission } = await import("../../middleware/permissions.js");
1667 const mockRequirePermission = requirePermission as any;
1668 mockRequirePermission.mockImplementation(() => async (c: any) => {
1669 return c.json({ error: "Forbidden" }, 403);
1670 });
1671
1672 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
1673 const res = await testApp.request("/api/admin/boards", {
1674 method: "POST",
1675 headers: { "Content-Type": "application/json" },
1676 body: JSON.stringify({ name: "Test", categoryUri }),
1677 });
1678
1679 expect(res.status).toBe(403);
1680 expect(mockPutRecord).not.toHaveBeenCalled();
1681
1682 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
1683 await next();
1684 });
1685 });
1686 });
1687
1688 describe.sequential("PUT /api/admin/boards/:id", () => {
1689 let boardId: string;
1690 let categoryUri: string;
1691
1692 beforeEach(async () => {
1693 await ctx.cleanDatabase();
1694
1695 mockUser = { did: "did:plc:test-admin" };
1696 mockPutRecord.mockClear();
1697 mockDeleteRecord.mockClear();
1698 mockPutRecord.mockResolvedValue({
1699 data: {
1700 uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`,
1701 cid: "bafyboardupdated",
1702 },
1703 });
1704
1705 // Insert a category and a board
1706 const [cat] = await ctx.db.insert(categories).values({
1707 did: ctx.config.forumDid,
1708 rkey: "tid-test-cat",
1709 cid: "bafycat",
1710 name: "Test Category",
1711 createdAt: new Date("2026-01-01T00:00:00.000Z"),
1712 indexedAt: new Date(),
1713 }).returning({ id: categories.id });
1714
1715 categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
1716
1717 const [brd] = await ctx.db.insert(boards).values({
1718 did: ctx.config.forumDid,
1719 rkey: "tid-test-board",
1720 cid: "bafyboard",
1721 name: "Original Name",
1722 description: "Original description",
1723 sortOrder: 1,
1724 categoryId: cat.id,
1725 categoryUri,
1726 createdAt: new Date("2026-01-01T00:00:00.000Z"),
1727 indexedAt: new Date(),
1728 }).returning({ id: boards.id });
1729
1730 boardId = brd.id.toString();
1731 });
1732
1733 it("updates board with all fields → 200 and putRecord called with same rkey", async () => {
1734 const res = await app.request(`/api/admin/boards/${boardId}`, {
1735 method: "PUT",
1736 headers: { "Content-Type": "application/json" },
1737 body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }),
1738 });
1739
1740 expect(res.status).toBe(200);
1741 const data = await res.json();
1742 expect(data.uri).toContain("/space.atbb.forum.board/");
1743 expect(data.cid).toBe("bafyboardupdated");
1744 expect(mockPutRecord).toHaveBeenCalledWith(
1745 expect.objectContaining({
1746 repo: ctx.config.forumDid,
1747 collection: "space.atbb.forum.board",
1748 rkey: "tid-test-board",
1749 record: expect.objectContaining({
1750 $type: "space.atbb.forum.board",
1751 name: "Renamed Board",
1752 description: "New description",
1753 sortOrder: 2,
1754 category: { category: { uri: categoryUri, cid: "bafycat" } },
1755 }),
1756 })
1757 );
1758 });
1759
1760 it("updates board without optional fields → falls back to existing values", async () => {
1761 const res = await app.request(`/api/admin/boards/${boardId}`, {
1762 method: "PUT",
1763 headers: { "Content-Type": "application/json" },
1764 body: JSON.stringify({ name: "Renamed Only" }),
1765 });
1766
1767 expect(res.status).toBe(200);
1768 expect(mockPutRecord).toHaveBeenCalledWith(
1769 expect.objectContaining({
1770 record: expect.objectContaining({
1771 name: "Renamed Only",
1772 description: "Original description",
1773 sortOrder: 1,
1774 }),
1775 })
1776 );
1777 });
1778
1779 it("returns 400 when name is missing", async () => {
1780 const res = await app.request(`/api/admin/boards/${boardId}`, {
1781 method: "PUT",
1782 headers: { "Content-Type": "application/json" },
1783 body: JSON.stringify({ description: "No name" }),
1784 });
1785
1786 expect(res.status).toBe(400);
1787 const data = await res.json();
1788 expect(data.error).toContain("name");
1789 expect(mockPutRecord).not.toHaveBeenCalled();
1790 });
1791
1792 it("returns 400 when name is empty string", async () => {
1793 const res = await app.request(`/api/admin/boards/${boardId}`, {
1794 method: "PUT",
1795 headers: { "Content-Type": "application/json" },
1796 body: JSON.stringify({ name: " " }),
1797 });
1798
1799 expect(res.status).toBe(400);
1800 expect(mockPutRecord).not.toHaveBeenCalled();
1801 });
1802
1803 it("returns 400 for non-numeric ID", async () => {
1804 const res = await app.request("/api/admin/boards/not-a-number", {
1805 method: "PUT",
1806 headers: { "Content-Type": "application/json" },
1807 body: JSON.stringify({ name: "Test" }),
1808 });
1809
1810 expect(res.status).toBe(400);
1811 expect(mockPutRecord).not.toHaveBeenCalled();
1812 });
1813
1814 it("returns 404 when board not found", async () => {
1815 const res = await app.request("/api/admin/boards/99999", {
1816 method: "PUT",
1817 headers: { "Content-Type": "application/json" },
1818 body: JSON.stringify({ name: "Test" }),
1819 });
1820
1821 expect(res.status).toBe(404);
1822 const data = await res.json();
1823 expect(data.error).toContain("Board not found");
1824 expect(mockPutRecord).not.toHaveBeenCalled();
1825 });
1826
1827 it("returns 400 for malformed JSON", async () => {
1828 const res = await app.request(`/api/admin/boards/${boardId}`, {
1829 method: "PUT",
1830 headers: { "Content-Type": "application/json" },
1831 body: "{ bad json }",
1832 });
1833
1834 expect(res.status).toBe(400);
1835 const data = await res.json();
1836 expect(data.error).toContain("Invalid JSON");
1837 expect(mockPutRecord).not.toHaveBeenCalled();
1838 });
1839
1840 it("returns 401 when unauthenticated", async () => {
1841 mockUser = null;
1842
1843 const res = await app.request(`/api/admin/boards/${boardId}`, {
1844 method: "PUT",
1845 headers: { "Content-Type": "application/json" },
1846 body: JSON.stringify({ name: "Test" }),
1847 });
1848
1849 expect(res.status).toBe(401);
1850 expect(mockPutRecord).not.toHaveBeenCalled();
1851 });
1852
1853 it("returns 503 when PDS network error", async () => {
1854 mockPutRecord.mockRejectedValue(new Error("fetch failed"));
1855
1856 const res = await app.request(`/api/admin/boards/${boardId}`, {
1857 method: "PUT",
1858 headers: { "Content-Type": "application/json" },
1859 body: JSON.stringify({ name: "Test" }),
1860 });
1861
1862 expect(res.status).toBe(503);
1863 const data = await res.json();
1864 expect(data.error).toContain("Unable to reach external service");
1865 });
1866
1867 it("returns 500 when ForumAgent unavailable", async () => {
1868 ctx.forumAgent = null;
1869
1870 const res = await app.request(`/api/admin/boards/${boardId}`, {
1871 method: "PUT",
1872 headers: { "Content-Type": "application/json" },
1873 body: JSON.stringify({ name: "Test" }),
1874 });
1875
1876 expect(res.status).toBe(500);
1877 const data = await res.json();
1878 expect(data.error).toContain("Forum agent not available");
1879 });
1880
1881 it("returns 503 when ForumAgent not authenticated", async () => {
1882 const originalAgent = ctx.forumAgent;
1883 ctx.forumAgent = { getAgent: () => null } as any;
1884
1885 const res = await app.request(`/api/admin/boards/${boardId}`, {
1886 method: "PUT",
1887 headers: { "Content-Type": "application/json" },
1888 body: JSON.stringify({ name: "Test" }),
1889 });
1890
1891 expect(res.status).toBe(503);
1892 const data = await res.json();
1893 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1894 expect(mockPutRecord).not.toHaveBeenCalled();
1895
1896 ctx.forumAgent = originalAgent;
1897 });
1898
1899 it("returns 403 when user lacks manageCategories permission", async () => {
1900 const { requirePermission } = await import("../../middleware/permissions.js");
1901 const mockRequirePermission = requirePermission as any;
1902 mockRequirePermission.mockImplementation(() => async (c: any) => {
1903 return c.json({ error: "Forbidden" }, 403);
1904 });
1905
1906 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
1907 const res = await testApp.request(`/api/admin/boards/${boardId}`, {
1908 method: "PUT",
1909 headers: { "Content-Type": "application/json" },
1910 body: JSON.stringify({ name: "Test" }),
1911 });
1912
1913 expect(res.status).toBe(403);
1914 expect(mockPutRecord).not.toHaveBeenCalled();
1915
1916 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
1917 await next();
1918 });
1919 });
1920
1921 it("returns 503 when board lookup query fails", async () => {
1922 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1923 throw new Error("Database connection lost");
1924 });
1925
1926 const res = await app.request(`/api/admin/boards/${boardId}`, {
1927 method: "PUT",
1928 headers: { "Content-Type": "application/json" },
1929 body: JSON.stringify({ name: "Updated Name" }),
1930 });
1931
1932 expect(res.status).toBe(503);
1933 const data = await res.json();
1934 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1935 expect(mockPutRecord).not.toHaveBeenCalled();
1936
1937 dbSelectSpy.mockRestore();
1938 });
1939
1940 it("returns 503 when category CID lookup query fails", async () => {
1941 const originalSelect = ctx.db.select.bind(ctx.db);
1942 let callCount = 0;
1943 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => {
1944 callCount++;
1945 if (callCount === 1) {
1946 // First call: board lookup — pass through to real DB
1947 return (originalSelect as any)(...args);
1948 }
1949 // Second call: category CID fetch — throw DB error
1950 throw new Error("Database connection lost");
1951 });
1952
1953 const res = await app.request(`/api/admin/boards/${boardId}`, {
1954 method: "PUT",
1955 headers: { "Content-Type": "application/json" },
1956 body: JSON.stringify({ name: "Updated Name" }),
1957 });
1958
1959 expect(res.status).toBe(503);
1960 const data = await res.json();
1961 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1962 expect(mockPutRecord).not.toHaveBeenCalled();
1963
1964 dbSelectSpy.mockRestore();
1965 });
1966 });
1967
1968 describe.sequential("DELETE /api/admin/boards/:id", () => {
1969 let boardId: string;
1970 let categoryUri: string;
1971
1972 beforeEach(async () => {
1973 await ctx.cleanDatabase();
1974
1975 mockUser = { did: "did:plc:test-admin" };
1976 mockPutRecord.mockClear();
1977 mockDeleteRecord.mockClear();
1978 mockDeleteRecord.mockResolvedValue({});
1979
1980 // Insert a category and a board
1981 const [cat] = await ctx.db.insert(categories).values({
1982 did: ctx.config.forumDid,
1983 rkey: "tid-test-cat",
1984 cid: "bafycat",
1985 name: "Test Category",
1986 createdAt: new Date("2026-01-01T00:00:00.000Z"),
1987 indexedAt: new Date(),
1988 }).returning({ id: categories.id });
1989
1990 categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
1991
1992 const [brd] = await ctx.db.insert(boards).values({
1993 did: ctx.config.forumDid,
1994 rkey: "tid-test-board",
1995 cid: "bafyboard",
1996 name: "Test Board",
1997 categoryId: cat.id,
1998 categoryUri,
1999 createdAt: new Date("2026-01-01T00:00:00.000Z"),
2000 indexedAt: new Date(),
2001 }).returning({ id: boards.id });
2002
2003 boardId = brd.id.toString();
2004 });
2005
2006 it("deletes empty board → 200 and deleteRecord called", async () => {
2007 const res = await app.request(`/api/admin/boards/${boardId}`, {
2008 method: "DELETE",
2009 });
2010
2011 expect(res.status).toBe(200);
2012 const data = await res.json();
2013 expect(data.success).toBe(true);
2014 expect(mockDeleteRecord).toHaveBeenCalledWith({
2015 repo: ctx.config.forumDid,
2016 collection: "space.atbb.forum.board",
2017 rkey: "tid-test-board",
2018 });
2019 });
2020
2021 it("returns 409 when board has posts → deleteRecord NOT called", async () => {
2022 // Insert a user and a post referencing this board
2023 await ctx.db.insert(users).values({
2024 did: "did:plc:test-user",
2025 handle: "testuser.bsky.social",
2026 indexedAt: new Date(),
2027 });
2028
2029 const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1);
2030
2031 await ctx.db.insert(posts).values({
2032 did: "did:plc:test-user",
2033 rkey: "tid-test-post",
2034 cid: "bafypost",
2035 text: "Hello world",
2036 boardId: brd.id,
2037 boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`,
2038 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2039 createdAt: new Date(),
2040 indexedAt: new Date(),
2041 });
2042
2043 const res = await app.request(`/api/admin/boards/${boardId}`, {
2044 method: "DELETE",
2045 });
2046
2047 expect(res.status).toBe(409);
2048 const data = await res.json();
2049 expect(data.error).toContain("posts");
2050 expect(mockDeleteRecord).not.toHaveBeenCalled();
2051 });
2052
2053 it("returns 400 for non-numeric ID", async () => {
2054 const res = await app.request("/api/admin/boards/not-a-number", {
2055 method: "DELETE",
2056 });
2057
2058 expect(res.status).toBe(400);
2059 const data = await res.json();
2060 expect(data.error).toContain("Invalid board ID");
2061 expect(mockDeleteRecord).not.toHaveBeenCalled();
2062 });
2063
2064 it("returns 404 when board not found", async () => {
2065 const res = await app.request("/api/admin/boards/99999", {
2066 method: "DELETE",
2067 });
2068
2069 expect(res.status).toBe(404);
2070 const data = await res.json();
2071 expect(data.error).toContain("Board not found");
2072 expect(mockDeleteRecord).not.toHaveBeenCalled();
2073 });
2074
2075 it("returns 503 when board lookup query fails", async () => {
2076 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
2077 throw new Error("Database connection lost");
2078 });
2079
2080 const res = await app.request(`/api/admin/boards/${boardId}`, {
2081 method: "DELETE",
2082 });
2083
2084 expect(res.status).toBe(503);
2085 const data = await res.json();
2086 expect(data.error).toContain("Please try again later");
2087 expect(mockDeleteRecord).not.toHaveBeenCalled();
2088
2089 dbSelectSpy.mockRestore();
2090 });
2091
2092 it("returns 503 when post count query fails", async () => {
2093 const originalSelect = ctx.db.select.bind(ctx.db);
2094 let callCount = 0;
2095 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => {
2096 callCount++;
2097 if (callCount === 1) {
2098 // First call: board lookup — pass through to real DB
2099 return (originalSelect as any)(...args);
2100 }
2101 // Second call: post count preflight — throw DB error
2102 throw new Error("Database connection lost");
2103 });
2104
2105 const res = await app.request(`/api/admin/boards/${boardId}`, {
2106 method: "DELETE",
2107 });
2108
2109 expect(res.status).toBe(503);
2110 const data = await res.json();
2111 expect(data.error).toContain("Please try again later");
2112 expect(mockDeleteRecord).not.toHaveBeenCalled();
2113
2114 dbSelectSpy.mockRestore();
2115 });
2116
2117 it("returns 401 when unauthenticated", async () => {
2118 mockUser = null;
2119
2120 const res = await app.request(`/api/admin/boards/${boardId}`, {
2121 method: "DELETE",
2122 });
2123
2124 expect(res.status).toBe(401);
2125 expect(mockDeleteRecord).not.toHaveBeenCalled();
2126 });
2127
2128 it("returns 503 when PDS network error", async () => {
2129 mockDeleteRecord.mockRejectedValue(new Error("fetch failed"));
2130
2131 const res = await app.request(`/api/admin/boards/${boardId}`, {
2132 method: "DELETE",
2133 });
2134
2135 expect(res.status).toBe(503);
2136 const data = await res.json();
2137 expect(data.error).toContain("Unable to reach external service");
2138 });
2139
2140 it("returns 500 when ForumAgent unavailable", async () => {
2141 ctx.forumAgent = null;
2142
2143 const res = await app.request(`/api/admin/boards/${boardId}`, {
2144 method: "DELETE",
2145 });
2146
2147 expect(res.status).toBe(500);
2148 const data = await res.json();
2149 expect(data.error).toContain("Forum agent not available");
2150 });
2151
2152 it("returns 503 when ForumAgent not authenticated", async () => {
2153 const originalAgent = ctx.forumAgent;
2154 ctx.forumAgent = { getAgent: () => null } as any;
2155
2156 const res = await app.request(`/api/admin/boards/${boardId}`, {
2157 method: "DELETE",
2158 });
2159
2160 expect(res.status).toBe(503);
2161 const data = await res.json();
2162 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
2163 expect(mockDeleteRecord).not.toHaveBeenCalled();
2164
2165 ctx.forumAgent = originalAgent;
2166 });
2167
2168 it("returns 403 when user lacks manageCategories permission", async () => {
2169 const { requirePermission } = await import("../../middleware/permissions.js");
2170 const mockRequirePermission = requirePermission as any;
2171 mockRequirePermission.mockImplementation(() => async (c: any) => {
2172 return c.json({ error: "Forbidden" }, 403);
2173 });
2174
2175 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
2176 const res = await testApp.request(`/api/admin/boards/${boardId}`, {
2177 method: "DELETE",
2178 });
2179
2180 expect(res.status).toBe(403);
2181 expect(mockDeleteRecord).not.toHaveBeenCalled();
2182
2183 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
2184 await next();
2185 });
2186 });
2187 });
2188
2189 describe("GET /api/admin/modlog", () => {
2190 beforeEach(async () => {
2191 await ctx.cleanDatabase();
2192 });
2193
2194 it("returns 401 when not authenticated", async () => {
2195 mockUser = null;
2196 const res = await app.request("/api/admin/modlog");
2197 expect(res.status).toBe(401);
2198 });
2199
2200 it("returns 403 when user lacks all mod permissions", async () => {
2201 mockRequireAnyPermissionPass = false;
2202 const res = await app.request("/api/admin/modlog");
2203 expect(res.status).toBe(403);
2204 });
2205
2206 it("returns empty list when no mod actions exist", async () => {
2207 const res = await app.request("/api/admin/modlog");
2208 expect(res.status).toBe(200);
2209 const data = await res.json() as any;
2210 expect(data.actions).toEqual([]);
2211 expect(data.total).toBe(0);
2212 expect(data.offset).toBe(0);
2213 expect(data.limit).toBe(50);
2214 });
2215
2216 it("returns paginated mod actions with moderator and subject handles", async () => {
2217 await ctx.db.insert(users).values([
2218 { did: "did:plc:mod-alice", handle: "alice.bsky.social", indexedAt: new Date() },
2219 { did: "did:plc:subject-bob", handle: "bob.bsky.social", indexedAt: new Date() },
2220 ]);
2221
2222 await ctx.db.insert(modActions).values({
2223 did: ctx.config.forumDid,
2224 rkey: "modaction-ban-1",
2225 cid: "cid-ban-1",
2226 action: "space.atbb.modAction.ban",
2227 subjectDid: "did:plc:subject-bob",
2228 subjectPostUri: null,
2229 createdBy: "did:plc:mod-alice",
2230 reason: "Spam",
2231 createdAt: new Date("2026-02-26T12:01:00Z"),
2232 indexedAt: new Date(),
2233 });
2234
2235 const res = await app.request("/api/admin/modlog");
2236 expect(res.status).toBe(200);
2237
2238 const data = await res.json() as any;
2239 expect(data.total).toBe(1);
2240 expect(data.actions).toHaveLength(1);
2241
2242 const action = data.actions[0];
2243 expect(typeof action.id).toBe("string");
2244 expect(action.action).toBe("space.atbb.modAction.ban");
2245 expect(action.moderatorDid).toBe("did:plc:mod-alice");
2246 expect(action.moderatorHandle).toBe("alice.bsky.social");
2247 expect(action.subjectDid).toBe("did:plc:subject-bob");
2248 expect(action.subjectHandle).toBe("bob.bsky.social");
2249 expect(action.subjectPostUri).toBeNull();
2250 expect(action.reason).toBe("Spam");
2251 expect(action.createdAt).toBe("2026-02-26T12:01:00.000Z");
2252 });
2253
2254 it("returns null subjectHandle and populated subjectPostUri for post-targeting actions", async () => {
2255 await ctx.db.insert(users).values({
2256 did: "did:plc:mod-carol",
2257 handle: "carol.bsky.social",
2258 indexedAt: new Date(),
2259 });
2260
2261 await ctx.db.insert(modActions).values({
2262 did: ctx.config.forumDid,
2263 rkey: "modaction-hide-1",
2264 cid: "cid-hide-1",
2265 action: "space.atbb.modAction.hide",
2266 subjectDid: null,
2267 subjectPostUri: "at://did:plc:user/space.atbb.post/abc123",
2268 createdBy: "did:plc:mod-carol",
2269 reason: "Inappropriate",
2270 createdAt: new Date("2026-02-26T11:30:00Z"),
2271 indexedAt: new Date(),
2272 });
2273
2274 const res = await app.request("/api/admin/modlog");
2275 expect(res.status).toBe(200);
2276
2277 const data = await res.json() as any;
2278 const action = data.actions.find((a: any) => a.action === "space.atbb.modAction.hide");
2279 expect(action).toBeDefined();
2280 expect(action.subjectDid).toBeNull();
2281 expect(action.subjectHandle).toBeNull();
2282 expect(action.subjectPostUri).toBe("at://did:plc:user/space.atbb.post/abc123");
2283 });
2284
2285 it("falls back to moderatorDid when moderator has no handle indexed", async () => {
2286 await ctx.db.insert(users).values({
2287 did: "did:plc:mod-nohandle",
2288 handle: null,
2289 indexedAt: new Date(),
2290 });
2291
2292 await ctx.db.insert(modActions).values({
2293 did: ctx.config.forumDid,
2294 rkey: "modaction-nohandle-1",
2295 cid: "cid-nohandle-1",
2296 action: "space.atbb.modAction.ban",
2297 subjectDid: null,
2298 subjectPostUri: null,
2299 createdBy: "did:plc:mod-nohandle",
2300 reason: "Test",
2301 createdAt: new Date(),
2302 indexedAt: new Date(),
2303 });
2304
2305 const res = await app.request("/api/admin/modlog");
2306 expect(res.status).toBe(200);
2307
2308 const data = await res.json() as any;
2309 const action = data.actions.find((a: any) => a.moderatorDid === "did:plc:mod-nohandle");
2310 expect(action).toBeDefined();
2311 expect(action.moderatorHandle).toBe("did:plc:mod-nohandle");
2312 });
2313
2314 it("falls back to moderatorDid when moderator has no users row at all", async () => {
2315 // Insert a mod action whose createdBy DID has NO entry in the users table
2316 await ctx.db.insert(modActions).values({
2317 did: ctx.config.forumDid,
2318 rkey: "modaction-nouser-1",
2319 cid: "cid-nouser-1",
2320 action: "space.atbb.modAction.ban",
2321 subjectDid: null,
2322 subjectPostUri: null,
2323 createdBy: "did:plc:mod-completely-unknown",
2324 reason: "No users row",
2325 createdAt: new Date(),
2326 indexedAt: new Date(),
2327 });
2328
2329 const res = await app.request("/api/admin/modlog");
2330 expect(res.status).toBe(200);
2331
2332 const data = await res.json() as any;
2333 // The action must appear in the results (not silently dropped by an inner join)
2334 const action = data.actions.find(
2335 (a: any) => a.moderatorDid === "did:plc:mod-completely-unknown"
2336 );
2337 expect(action).toBeDefined();
2338 expect(action.moderatorHandle).toBe("did:plc:mod-completely-unknown");
2339 });
2340
2341 it("returns actions in createdAt DESC order", async () => {
2342 await ctx.db.insert(users).values({
2343 did: "did:plc:mod-order",
2344 handle: "order.bsky.social",
2345 indexedAt: new Date(),
2346 });
2347
2348 const now = Date.now();
2349 await ctx.db.insert(modActions).values([
2350 {
2351 did: ctx.config.forumDid,
2352 rkey: "modaction-old",
2353 cid: "cid-old",
2354 action: "space.atbb.modAction.ban",
2355 subjectDid: null,
2356 subjectPostUri: null,
2357 createdBy: "did:plc:mod-order",
2358 reason: "Old action",
2359 createdAt: new Date(now - 10000),
2360 indexedAt: new Date(),
2361 },
2362 {
2363 did: ctx.config.forumDid,
2364 rkey: "modaction-new",
2365 cid: "cid-new",
2366 action: "space.atbb.modAction.hide",
2367 subjectDid: null,
2368 subjectPostUri: null,
2369 createdBy: "did:plc:mod-order",
2370 reason: "New action",
2371 createdAt: new Date(now),
2372 indexedAt: new Date(),
2373 },
2374 ]);
2375
2376 const res = await app.request("/api/admin/modlog");
2377 const data = await res.json() as any;
2378
2379 const orderActions = data.actions.filter((a: any) =>
2380 a.moderatorDid === "did:plc:mod-order"
2381 );
2382 expect(orderActions).toHaveLength(2);
2383 expect(orderActions[0].reason).toBe("New action");
2384 expect(orderActions[1].reason).toBe("Old action");
2385 });
2386
2387 it("respects limit and offset query params", async () => {
2388 await ctx.db.insert(users).values({
2389 did: "did:plc:mod-pagination",
2390 handle: "pagination.bsky.social",
2391 indexedAt: new Date(),
2392 });
2393
2394 await ctx.db.insert(modActions).values([
2395 { did: ctx.config.forumDid, rkey: "pag-1", cid: "c1", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "A", createdAt: new Date(3000), indexedAt: new Date() },
2396 { did: ctx.config.forumDid, rkey: "pag-2", cid: "c2", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "B", createdAt: new Date(2000), indexedAt: new Date() },
2397 { did: ctx.config.forumDid, rkey: "pag-3", cid: "c3", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "C", createdAt: new Date(1000), indexedAt: new Date() },
2398 ]);
2399
2400 const page1 = await app.request("/api/admin/modlog?limit=2&offset=0");
2401 const data1 = await page1.json() as any;
2402 expect(data1.actions).toHaveLength(2);
2403 expect(data1.limit).toBe(2);
2404 expect(data1.offset).toBe(0);
2405 expect(data1.total).toBe(3);
2406 expect(data1.actions[0].reason).toBe("A");
2407
2408 const page2 = await app.request("/api/admin/modlog?limit=2&offset=2");
2409 const data2 = await page2.json() as any;
2410 expect(data2.actions).toHaveLength(1);
2411 expect(data2.total).toBe(3);
2412 expect(data2.actions[0].reason).toBe("C");
2413 });
2414
2415 it("returns 400 for non-numeric limit", async () => {
2416 const res = await app.request("/api/admin/modlog?limit=abc");
2417 expect(res.status).toBe(400);
2418 const data = await res.json() as any;
2419 expect(data.error).toMatch(/limit/i);
2420 });
2421
2422 it("returns 400 for negative limit", async () => {
2423 const res = await app.request("/api/admin/modlog?limit=-1");
2424 expect(res.status).toBe(400);
2425 });
2426
2427 it("returns 400 for negative offset", async () => {
2428 const res = await app.request("/api/admin/modlog?offset=-5");
2429 expect(res.status).toBe(400);
2430 });
2431
2432 it("caps limit at 100", async () => {
2433 const res = await app.request("/api/admin/modlog?limit=999");
2434 expect(res.status).toBe(200);
2435 const data = await res.json() as any;
2436 expect(data.limit).toBe(100);
2437 });
2438
2439 it("uses default limit=50 and offset=0 when not provided", async () => {
2440 const res = await app.request("/api/admin/modlog");
2441 expect(res.status).toBe(200);
2442 const data = await res.json() as any;
2443 expect(data.limit).toBe(50);
2444 expect(data.offset).toBe(0);
2445 });
2446 });
2447
2448 describe("GET /api/admin/themes", () => {
2449 beforeEach(async () => {
2450 await ctx.cleanDatabase();
2451 });
2452
2453 it("returns empty array when no themes exist", async () => {
2454 const res = await app.request("/api/admin/themes");
2455 expect(res.status).toBe(200);
2456 const body = await res.json();
2457 expect(body).toHaveProperty("themes");
2458 expect(body.themes).toEqual([]);
2459 });
2460
2461 it("returns all themes regardless of policy availability", async () => {
2462 // Insert two themes but only add one to policy
2463 await ctx.db.insert(themes).values([
2464 {
2465 did: ctx.config.forumDid,
2466 rkey: "3lbltheme1aa",
2467 cid: "bafytheme1",
2468 name: "Neobrutal Light",
2469 colorScheme: "light",
2470 tokens: { "color-bg": "#f5f0e8" },
2471 createdAt: new Date(),
2472 indexedAt: new Date(),
2473 },
2474 {
2475 did: ctx.config.forumDid,
2476 rkey: "3lbltheme2bb",
2477 cid: "bafytheme2",
2478 name: "Neobrutal Dark",
2479 colorScheme: "dark",
2480 tokens: { "color-bg": "#1a1a1a" },
2481 createdAt: new Date(),
2482 indexedAt: new Date(),
2483 },
2484 ]);
2485
2486 const res = await app.request("/api/admin/themes");
2487 expect(res.status).toBe(200);
2488 const body = await res.json();
2489
2490 // Returns BOTH themes — not filtered by policy
2491 expect(body.themes).toHaveLength(2);
2492 expect(body.themes[0]).toMatchObject({
2493 name: "Neobrutal Light",
2494 colorScheme: "light",
2495 });
2496 expect(body.themes[0]).toHaveProperty("tokens");
2497 expect(body.themes[0]).toHaveProperty("uri");
2498 expect(body.themes[0].uri).toContain("space.atbb.forum.theme");
2499 });
2500
2501 it("returns 401 when not authenticated", async () => {
2502 mockUser = null;
2503 const res = await app.request("/api/admin/themes");
2504 expect(res.status).toBe(401);
2505 });
2506 });
2507
2508 describe("POST /api/admin/themes", () => {
2509 it("creates theme and returns 201 with uri and cid", async () => {
2510 const res = await app.request("/api/admin/themes", {
2511 method: "POST",
2512 headers: { "Content-Type": "application/json" },
2513 body: JSON.stringify({
2514 name: "Neobrutal Light",
2515 colorScheme: "light",
2516 tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" },
2517 }),
2518 });
2519 expect(res.status).toBe(201);
2520 const body = await res.json();
2521 expect(body.uri).toBeDefined();
2522 expect(body.cid).toBeDefined();
2523 expect(mockPutRecord).toHaveBeenCalledOnce();
2524 });
2525
2526 it("includes cssOverrides and fontUrls when provided", async () => {
2527 const res = await app.request("/api/admin/themes", {
2528 method: "POST",
2529 headers: { "Content-Type": "application/json" },
2530 body: JSON.stringify({
2531 name: "Custom Theme",
2532 colorScheme: "dark",
2533 tokens: { "color-bg": "#1a1a1a" },
2534 cssOverrides: ".card { border-radius: 4px; }",
2535 fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"],
2536 }),
2537 });
2538 expect(res.status).toBe(201);
2539 const call = mockPutRecord.mock.calls[0][0];
2540 // Sanitizer reformats CSS to compact form (no extra spaces)
2541 expect(call.record.cssOverrides).toBe(".card{border-radius:4px}");
2542 expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]);
2543 });
2544
2545 it("strips dangerous CSS constructs from cssOverrides before PDS write", async () => {
2546 const res = await app.request("/api/admin/themes", {
2547 method: "POST",
2548 headers: { "Content-Type": "application/json" },
2549 body: JSON.stringify({
2550 name: "Dangerous Theme",
2551 colorScheme: "light",
2552 tokens: { "color-bg": "#ffffff" },
2553 cssOverrides: '@import "https://evil.com/steal.css"; .ok { color: red; }',
2554 }),
2555 });
2556 expect(res.status).toBe(201);
2557 const call = mockPutRecord.mock.calls[0][0];
2558 expect(call.record.cssOverrides).not.toContain("@import");
2559 expect(call.record.cssOverrides).not.toContain("evil.com");
2560 expect(call.record.cssOverrides).toContain("color:red");
2561 });
2562
2563 it("returns 400 when name is missing", async () => {
2564 const res = await app.request("/api/admin/themes", {
2565 method: "POST",
2566 headers: { "Content-Type": "application/json" },
2567 body: JSON.stringify({ colorScheme: "light", tokens: {} }),
2568 });
2569 expect(res.status).toBe(400);
2570 const body = await res.json();
2571 expect(body.error).toMatch(/name/i);
2572 });
2573
2574 it("returns 400 when name is empty string", async () => {
2575 const res = await app.request("/api/admin/themes", {
2576 method: "POST",
2577 headers: { "Content-Type": "application/json" },
2578 body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }),
2579 });
2580 expect(res.status).toBe(400);
2581 });
2582
2583 it("returns 400 when colorScheme is invalid", async () => {
2584 const res = await app.request("/api/admin/themes", {
2585 method: "POST",
2586 headers: { "Content-Type": "application/json" },
2587 body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }),
2588 });
2589 expect(res.status).toBe(400);
2590 const body = await res.json();
2591 expect(body.error).toMatch(/colorScheme/i);
2592 });
2593
2594 it("returns 400 when colorScheme is missing", async () => {
2595 const res = await app.request("/api/admin/themes", {
2596 method: "POST",
2597 headers: { "Content-Type": "application/json" },
2598 body: JSON.stringify({ name: "Test", tokens: {} }),
2599 });
2600 expect(res.status).toBe(400);
2601 });
2602
2603 it("returns 400 when tokens is missing", async () => {
2604 const res = await app.request("/api/admin/themes", {
2605 method: "POST",
2606 headers: { "Content-Type": "application/json" },
2607 body: JSON.stringify({ name: "Test", colorScheme: "light" }),
2608 });
2609 expect(res.status).toBe(400);
2610 const body = await res.json();
2611 expect(body.error).toMatch(/tokens/i);
2612 });
2613
2614 it("returns 400 when tokens is an array (not an object)", async () => {
2615 const res = await app.request("/api/admin/themes", {
2616 method: "POST",
2617 headers: { "Content-Type": "application/json" },
2618 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }),
2619 });
2620 expect(res.status).toBe(400);
2621 });
2622
2623 it("returns 400 when a token value is not a string", async () => {
2624 const res = await app.request("/api/admin/themes", {
2625 method: "POST",
2626 headers: { "Content-Type": "application/json" },
2627 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }),
2628 });
2629 expect(res.status).toBe(400);
2630 const body = await res.json();
2631 expect(body.error).toMatch(/tokens/i);
2632 });
2633
2634 it("returns 400 when a fontUrl is not HTTPS", async () => {
2635 const res = await app.request("/api/admin/themes", {
2636 method: "POST",
2637 headers: { "Content-Type": "application/json" },
2638 body: JSON.stringify({
2639 name: "Test",
2640 colorScheme: "light",
2641 tokens: {},
2642 fontUrls: ["http://example.com/font.css"],
2643 }),
2644 });
2645 expect(res.status).toBe(400);
2646 const body = await res.json();
2647 expect(body.error).toMatch(/https/i);
2648 });
2649
2650 it("returns 500 when ForumAgent is not configured", async () => {
2651 ctx.forumAgent = null;
2652 const res = await app.request("/api/admin/themes", {
2653 method: "POST",
2654 headers: { "Content-Type": "application/json" },
2655 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }),
2656 });
2657 expect(res.status).toBe(500);
2658 const body = await res.json();
2659 expect(body.error).toContain("Forum agent not available");
2660 });
2661
2662 it("returns 503 when ForumAgent not authenticated", async () => {
2663 const originalAgent = ctx.forumAgent;
2664 ctx.forumAgent = { getAgent: () => null } as any;
2665 const res = await app.request("/api/admin/themes", {
2666 method: "POST",
2667 headers: { "Content-Type": "application/json" },
2668 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }),
2669 });
2670 expect(res.status).toBe(503);
2671 const body = await res.json();
2672 expect(body.error).toBe("Forum agent not authenticated. Please try again later.");
2673 expect(mockPutRecord).not.toHaveBeenCalled();
2674 ctx.forumAgent = originalAgent;
2675 });
2676
2677 it("returns 401 when not authenticated", async () => {
2678 mockUser = null;
2679 const res = await app.request("/api/admin/themes", {
2680 method: "POST",
2681 headers: { "Content-Type": "application/json" },
2682 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }),
2683 });
2684 expect(res.status).toBe(401);
2685 expect(mockPutRecord).not.toHaveBeenCalled();
2686 });
2687
2688 it("returns 403 when user lacks manageThemes permission", async () => {
2689 const { requirePermission } = await import("../../middleware/permissions.js");
2690 const mockRequirePermission = requirePermission as any;
2691 mockRequirePermission.mockImplementation(() => async (c: any) => {
2692 return c.json({ error: "Forbidden" }, 403);
2693 });
2694
2695 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
2696 const res = await testApp.request("/api/admin/themes", {
2697 method: "POST",
2698 headers: { "Content-Type": "application/json" },
2699 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }),
2700 });
2701
2702 expect(res.status).toBe(403);
2703 expect(mockPutRecord).not.toHaveBeenCalled();
2704
2705 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
2706 await next();
2707 });
2708 });
2709
2710 it("returns 503 when PDS write fails with a network error", async () => {
2711 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
2712 const res = await app.request("/api/admin/themes", {
2713 method: "POST",
2714 headers: { "Content-Type": "application/json" },
2715 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }),
2716 });
2717 expect(res.status).toBe(503);
2718 });
2719 });
2720
2721 describe("PUT /api/admin/themes/:rkey", () => {
2722 const TEST_RKEY = "3lblputtest1";
2723 const TEST_CREATED_AT = new Date("2026-01-01T00:00:00Z");
2724
2725 beforeEach(async () => {
2726 await ctx.db.insert(themes).values({
2727 did: ctx.config.forumDid,
2728 rkey: TEST_RKEY,
2729 cid: "bafythemeput",
2730 name: "Original Theme",
2731 colorScheme: "light",
2732 tokens: { "color-bg": "#ffffff", "color-text": "#000000" },
2733 cssOverrides: ".existing { color: red; }",
2734 fontUrls: ["https://fonts.example.com/existing.css"],
2735 createdAt: TEST_CREATED_AT,
2736 indexedAt: new Date(),
2737 });
2738 });
2739
2740 it("updates theme and returns 200 with uri and cid", async () => {
2741 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2742 method: "PUT",
2743 headers: { "Content-Type": "application/json" },
2744 body: JSON.stringify({
2745 name: "Updated Theme",
2746 colorScheme: "dark",
2747 tokens: { "color-bg": "#1a1a1a", "color-text": "#ffffff" },
2748 }),
2749 });
2750 expect(res.status).toBe(200);
2751 const body = await res.json();
2752 expect(body.uri).toBeDefined();
2753 expect(body.cid).toBeDefined();
2754 expect(mockPutRecord).toHaveBeenCalledOnce();
2755 const call = mockPutRecord.mock.calls[0][0];
2756 expect(call.record.name).toBe("Updated Theme");
2757 expect(call.record.colorScheme).toBe("dark");
2758 expect(call.rkey).toBe(TEST_RKEY);
2759 });
2760
2761 it("preserves existing cssOverrides when not provided in request body", async () => {
2762 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2763 method: "PUT",
2764 headers: { "Content-Type": "application/json" },
2765 body: JSON.stringify({
2766 name: "Updated Theme",
2767 colorScheme: "light",
2768 tokens: { "color-bg": "#f0f0f0" },
2769 }),
2770 });
2771 expect(res.status).toBe(200);
2772 const call = mockPutRecord.mock.calls[0][0];
2773 // Sanitizer reformats CSS to compact form (no extra spaces)
2774 expect(call.record.cssOverrides).toBe(".existing{color:red}");
2775 });
2776
2777 it("strips dangerous CSS constructs from cssOverrides before PDS write on update", async () => {
2778 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2779 method: "PUT",
2780 headers: { "Content-Type": "application/json" },
2781 body: JSON.stringify({
2782 name: "Updated Theme",
2783 colorScheme: "light",
2784 tokens: { "color-bg": "#f0f0f0" },
2785 cssOverrides: 'body { background: url("https://evil.com/track.gif"); color: blue; }',
2786 }),
2787 });
2788 expect(res.status).toBe(200);
2789 const call = mockPutRecord.mock.calls[0][0];
2790 expect(call.record.cssOverrides).not.toContain("evil.com");
2791 expect(call.record.cssOverrides).toContain("color:blue");
2792 });
2793
2794 it("preserves existing fontUrls when not provided in request body", async () => {
2795 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2796 method: "PUT",
2797 headers: { "Content-Type": "application/json" },
2798 body: JSON.stringify({
2799 name: "Updated Theme",
2800 colorScheme: "light",
2801 tokens: { "color-bg": "#f0f0f0" },
2802 }),
2803 });
2804 expect(res.status).toBe(200);
2805 const call = mockPutRecord.mock.calls[0][0];
2806 expect(call.record.fontUrls).toEqual(["https://fonts.example.com/existing.css"]);
2807 });
2808
2809 it("preserves original createdAt in the PDS record", async () => {
2810 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2811 method: "PUT",
2812 headers: { "Content-Type": "application/json" },
2813 body: JSON.stringify({
2814 name: "Updated Theme",
2815 colorScheme: "light",
2816 tokens: { "color-bg": "#f0f0f0" },
2817 }),
2818 });
2819 expect(res.status).toBe(200);
2820 const call = mockPutRecord.mock.calls[0][0];
2821 expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z");
2822 });
2823
2824 it("returns 404 for unknown rkey", async () => {
2825 const res = await app.request("/api/admin/themes/nonexistentkey", {
2826 method: "PUT",
2827 headers: { "Content-Type": "application/json" },
2828 body: JSON.stringify({
2829 name: "Updated Theme",
2830 colorScheme: "light",
2831 tokens: { "color-bg": "#f0f0f0" },
2832 }),
2833 });
2834 expect(res.status).toBe(404);
2835 const body = await res.json();
2836 expect(body.error).toMatch(/not found/i);
2837 });
2838
2839 it("returns 400 when name is missing", async () => {
2840 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2841 method: "PUT",
2842 headers: { "Content-Type": "application/json" },
2843 body: JSON.stringify({ colorScheme: "light", tokens: {} }),
2844 });
2845 expect(res.status).toBe(400);
2846 const body = await res.json();
2847 expect(body.error).toMatch(/name/i);
2848 });
2849
2850 it("returns 400 when colorScheme is invalid", async () => {
2851 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2852 method: "PUT",
2853 headers: { "Content-Type": "application/json" },
2854 body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }),
2855 });
2856 expect(res.status).toBe(400);
2857 const body = await res.json();
2858 expect(body.error).toMatch(/colorScheme/i);
2859 });
2860
2861 it("returns 400 when tokens is an array", async () => {
2862 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2863 method: "PUT",
2864 headers: { "Content-Type": "application/json" },
2865 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }),
2866 });
2867 expect(res.status).toBe(400);
2868 const body = await res.json();
2869 expect(body.error).toMatch(/tokens/i);
2870 });
2871
2872 it("returns 401 when not authenticated", async () => {
2873 mockUser = null;
2874 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2875 method: "PUT",
2876 headers: { "Content-Type": "application/json" },
2877 body: JSON.stringify({
2878 name: "Updated Theme",
2879 colorScheme: "light",
2880 tokens: { "color-bg": "#f0f0f0" },
2881 }),
2882 });
2883 expect(res.status).toBe(401);
2884 expect(mockPutRecord).not.toHaveBeenCalled();
2885 });
2886
2887 it("returns 403 when user lacks manageThemes permission", async () => {
2888 const { requirePermission } = await import("../../middleware/permissions.js");
2889 const mockRequirePermission = requirePermission as any;
2890 mockRequirePermission.mockImplementation(() => async (c: any) => {
2891 return c.json({ error: "Forbidden" }, 403);
2892 });
2893
2894 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
2895 const res = await testApp.request(`/api/admin/themes/${TEST_RKEY}`, {
2896 method: "PUT",
2897 headers: { "Content-Type": "application/json" },
2898 body: JSON.stringify({
2899 name: "Updated Theme",
2900 colorScheme: "light",
2901 tokens: { "color-bg": "#f0f0f0" },
2902 }),
2903 });
2904
2905 expect(res.status).toBe(403);
2906 expect(mockPutRecord).not.toHaveBeenCalled();
2907
2908 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
2909 await next();
2910 });
2911 });
2912
2913 it("returns 503 when ForumAgent not authenticated", async () => {
2914 const originalAgent = ctx.forumAgent;
2915 ctx.forumAgent = { getAgent: () => null } as any;
2916 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2917 method: "PUT",
2918 headers: { "Content-Type": "application/json" },
2919 body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }),
2920 });
2921 expect(res.status).toBe(503);
2922 const body = await res.json();
2923 expect(body.error).toBe("Forum agent not authenticated. Please try again later.");
2924 expect(mockPutRecord).not.toHaveBeenCalled();
2925 ctx.forumAgent = originalAgent;
2926 });
2927
2928 it("returns 503 when PDS write fails with a network error", async () => {
2929 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
2930 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, {
2931 method: "PUT",
2932 headers: { "Content-Type": "application/json" },
2933 body: JSON.stringify({
2934 name: "Updated Theme",
2935 colorScheme: "light",
2936 tokens: { "color-bg": "#f0f0f0" },
2937 }),
2938 });
2939 expect(res.status).toBe(503);
2940 });
2941 });
2942
2943 describe("DELETE /api/admin/themes/:rkey", () => {
2944 const themeRkey = "3lbldeltest1";
2945
2946 beforeEach(async () => {
2947 await ctx.db.insert(themes).values({
2948 did: ctx.config.forumDid,
2949 rkey: themeRkey,
2950 cid: "bafydeltest",
2951 name: "Theme To Delete",
2952 colorScheme: "light",
2953 tokens: { "color-bg": "#ffffff" },
2954 createdAt: new Date(),
2955 indexedAt: new Date(),
2956 });
2957 });
2958
2959 it("deletes theme and returns 200 with success: true", async () => {
2960 const res = await app.request(`/api/admin/themes/${themeRkey}`, {
2961 method: "DELETE",
2962 });
2963 expect(res.status).toBe(200);
2964 const body = await res.json();
2965 expect(body.success).toBe(true);
2966 expect(mockDeleteRecord).toHaveBeenCalledWith({
2967 repo: ctx.config.forumDid,
2968 collection: "space.atbb.forum.theme",
2969 rkey: themeRkey,
2970 });
2971 });
2972
2973 it("returns 404 for unknown rkey", async () => {
2974 const res = await app.request("/api/admin/themes/doesnotexist", {
2975 method: "DELETE",
2976 });
2977 expect(res.status).toBe(404);
2978 });
2979
2980 it("returns 409 when theme is the defaultLightTheme in policy", async () => {
2981 await ctx.db.insert(themePolicies).values({
2982 did: ctx.config.forumDid,
2983 rkey: "self",
2984 cid: "bafypolicydel",
2985 defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`,
2986 defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
2987 allowUserChoice: true,
2988 indexedAt: new Date(),
2989 });
2990
2991 const res = await app.request(`/api/admin/themes/${themeRkey}`, {
2992 method: "DELETE",
2993 });
2994 expect(res.status).toBe(409);
2995 const body = await res.json();
2996 expect(body.error).toMatch(/default/i);
2997 });
2998
2999 it("returns 409 when theme is the defaultDarkTheme in policy", async () => {
3000 await ctx.db.insert(themePolicies).values({
3001 did: ctx.config.forumDid,
3002 rkey: "self",
3003 cid: "bafypolicydel2",
3004 defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
3005 defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`,
3006 allowUserChoice: true,
3007 indexedAt: new Date(),
3008 });
3009
3010 const res = await app.request(`/api/admin/themes/${themeRkey}`, {
3011 method: "DELETE",
3012 });
3013 expect(res.status).toBe(409);
3014 const body = await res.json();
3015 expect(body.error).toMatch(/default/i);
3016 });
3017
3018 it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => {
3019 const [policy] = await ctx.db.insert(themePolicies).values({
3020 did: ctx.config.forumDid,
3021 rkey: "self",
3022 cid: "bafypolicyavail",
3023 defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
3024 defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
3025 allowUserChoice: true,
3026 indexedAt: new Date(),
3027 }).returning();
3028 await ctx.db.insert(themePolicyAvailableThemes).values({
3029 policyId: policy.id,
3030 themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`,
3031 themeCid: "bafydeltest",
3032 });
3033
3034 const res = await app.request(`/api/admin/themes/${themeRkey}`, {
3035 method: "DELETE",
3036 });
3037 expect(res.status).toBe(200);
3038 });
3039
3040 it("returns 401 when not authenticated", async () => {
3041 mockUser = null;
3042 const res = await app.request(`/api/admin/themes/${themeRkey}`, {
3043 method: "DELETE",
3044 });
3045 expect(res.status).toBe(401);
3046 expect(mockDeleteRecord).not.toHaveBeenCalled();
3047 });
3048
3049 it("returns 403 when user lacks manageThemes permission", async () => {
3050 const { requirePermission } = await import("../../middleware/permissions.js");
3051 const mockRequirePermission = requirePermission as any;
3052 mockRequirePermission.mockImplementation(() => async (c: any) => {
3053 return c.json({ error: "Forbidden" }, 403);
3054 });
3055
3056 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
3057 const res = await testApp.request(`/api/admin/themes/${themeRkey}`, {
3058 method: "DELETE",
3059 });
3060
3061 expect(res.status).toBe(403);
3062 expect(mockDeleteRecord).not.toHaveBeenCalled();
3063
3064 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
3065 await next();
3066 });
3067 });
3068
3069 it("returns 503 when ForumAgent not authenticated", async () => {
3070 const originalAgent = ctx.forumAgent;
3071 ctx.forumAgent = { getAgent: () => null } as any;
3072 const res = await app.request(`/api/admin/themes/${themeRkey}`, {
3073 method: "DELETE",
3074 });
3075 expect(res.status).toBe(503);
3076 const body = await res.json();
3077 expect(body.error).toBe("Forum agent not authenticated. Please try again later.");
3078 expect(mockDeleteRecord).not.toHaveBeenCalled();
3079 ctx.forumAgent = originalAgent;
3080 });
3081
3082 it("returns 503 when PDS delete fails with a network error", async () => {
3083 mockDeleteRecord.mockRejectedValueOnce(new Error("fetch failed"));
3084 const res = await app.request(`/api/admin/themes/${themeRkey}`, {
3085 method: "DELETE",
3086 });
3087 expect(res.status).toBe(503);
3088 });
3089 });
3090
3091 describe("POST /api/admin/themes/:rkey/duplicate", () => {
3092 beforeEach(async () => {
3093 await ctx.cleanDatabase();
3094 await ctx.db.insert(themes).values({
3095 did: ctx.config.forumDid,
3096 rkey: "3lblsource1aa",
3097 cid: "bafysource1",
3098 name: "Neobrutal Light",
3099 colorScheme: "light",
3100 tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" },
3101 createdAt: new Date(),
3102 indexedAt: new Date(),
3103 });
3104 });
3105
3106 it("calls putRecord with a new rkey and '(Copy)' name", async () => {
3107 mockPutRecord.mockResolvedValueOnce({
3108 data: { uri: "at://did:plc:test-forum/space.atbb.forum.theme/3lblcopy001a", cid: "bafycopy1" },
3109 });
3110
3111 const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", {
3112 method: "POST",
3113 });
3114
3115 expect(res.status).toBe(201);
3116 const body = await res.json();
3117 expect(body.name).toBe("Neobrutal Light (Copy)");
3118 expect(body.rkey).toBeDefined();
3119 expect(body.rkey).not.toBe("3lblsource1aa");
3120 expect(body.uri).toContain("space.atbb.forum.theme");
3121
3122 expect(mockPutRecord).toHaveBeenCalledOnce();
3123 const putCall = mockPutRecord.mock.calls[0][0];
3124 expect(putCall.record.name).toBe("Neobrutal Light (Copy)");
3125 expect(putCall.record.colorScheme).toBe("light");
3126 expect(putCall.record.tokens).toEqual({ "color-bg": "#f5f0e8", "color-primary": "#ff5c00" });
3127 expect(putCall.collection).toBe("space.atbb.forum.theme");
3128 });
3129
3130 it("returns 404 when source rkey does not exist", async () => {
3131 const res = await app.request("/api/admin/themes/nonexistent/duplicate", {
3132 method: "POST",
3133 });
3134 expect(res.status).toBe(404);
3135 });
3136
3137 it("returns 401 when not authenticated", async () => {
3138 mockUser = null;
3139 const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", {
3140 method: "POST",
3141 });
3142 expect(res.status).toBe(401);
3143 });
3144
3145 it("copies cssOverrides and fontUrls when they are set on the source", async () => {
3146 // Insert a theme with optional fields populated
3147 await ctx.db.insert(themes).values({
3148 did: ctx.config.forumDid,
3149 rkey: "3lblsource2bb",
3150 cid: "bafysource2",
3151 name: "Custom Theme",
3152 colorScheme: "dark",
3153 tokens: { "color-bg": "#1a1a1a" },
3154 cssOverrides: "body { font-size: 18px; }",
3155 fontUrls: ["https://fonts.googleapis.com/css2?family=Roboto"],
3156 createdAt: new Date(),
3157 indexedAt: new Date(),
3158 });
3159
3160 const res = await app.request("/api/admin/themes/3lblsource2bb/duplicate", {
3161 method: "POST",
3162 });
3163
3164 expect(res.status).toBe(201);
3165 expect(mockPutRecord).toHaveBeenCalledOnce();
3166 const putCall = mockPutRecord.mock.calls[0][0];
3167 // Sanitizer reformats CSS to compact form on duplication
3168 expect(putCall.record.cssOverrides).toBe("body{font-size:18px}");
3169 expect(putCall.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Roboto"]);
3170 expect(putCall.record.name).toBe("Custom Theme (Copy)");
3171 });
3172 });
3173
3174 describe("PUT /api/admin/theme-policy", () => {
3175 const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`;
3176 const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`;
3177
3178 const validBody = {
3179 availableThemes: [
3180 { uri: lightUri, cid: "bafylight" },
3181 { uri: darkUri, cid: "bafydark" },
3182 ],
3183 defaultLightThemeUri: lightUri,
3184 defaultDarkThemeUri: darkUri,
3185 allowUserChoice: true,
3186 };
3187
3188 it("creates policy (upsert) and returns 200 with uri and cid", async () => {
3189 const res = await app.request("/api/admin/theme-policy", {
3190 method: "PUT",
3191 headers: { "Content-Type": "application/json" },
3192 body: JSON.stringify(validBody),
3193 });
3194 expect(res.status).toBe(200);
3195 const body = await res.json();
3196 expect(body.uri).toBeDefined();
3197 expect(body.cid).toBeDefined();
3198 expect(mockPutRecord).toHaveBeenCalledOnce();
3199 });
3200
3201 it("writes PDS record with flat themeRef structure (no theme: wrapper)", async () => {
3202 await app.request("/api/admin/theme-policy", {
3203 method: "PUT",
3204 headers: { "Content-Type": "application/json" },
3205 body: JSON.stringify(validBody),
3206 });
3207 const call = mockPutRecord.mock.calls[0][0];
3208 expect(call.record.$type).toBe("space.atbb.forum.themePolicy");
3209 expect(call.rkey).toBe("self");
3210 // Flat themeRef: { uri, cid } — no nested theme: {} wrapper
3211 expect(call.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" });
3212 expect(call.record.defaultLightTheme).toEqual({ uri: lightUri, cid: "bafylight" });
3213 expect(call.record.defaultDarkTheme).toEqual({ uri: darkUri, cid: "bafydark" });
3214 expect(call.record.allowUserChoice).toBe(true);
3215 expect(typeof call.record.updatedAt).toBe("string");
3216 expect(call.collection).toBe("space.atbb.forum.themePolicy");
3217 expect(call.repo).toBe(ctx.config.forumDid);
3218 });
3219
3220 it("overwrites existing policy (upsert) and returns 200 with uri and cid", async () => {
3221 await ctx.db.insert(themePolicies).values({
3222 did: ctx.config.forumDid,
3223 rkey: "self",
3224 cid: "bafyexisting",
3225 defaultLightThemeUri: lightUri,
3226 defaultDarkThemeUri: darkUri,
3227 allowUserChoice: false,
3228 indexedAt: new Date(),
3229 });
3230
3231 const res = await app.request("/api/admin/theme-policy", {
3232 method: "PUT",
3233 headers: { "Content-Type": "application/json" },
3234 body: JSON.stringify(validBody),
3235 });
3236 expect(res.status).toBe(200);
3237 const body = await res.json();
3238 expect(body.uri).toBeDefined();
3239 expect(body.cid).toBeDefined();
3240 expect(mockPutRecord).toHaveBeenCalledOnce();
3241 });
3242
3243 it("defaults allowUserChoice to true when not provided", async () => {
3244 const { allowUserChoice: _, ...bodyWithout } = validBody;
3245 await app.request("/api/admin/theme-policy", {
3246 method: "PUT",
3247 headers: { "Content-Type": "application/json" },
3248 body: JSON.stringify(bodyWithout),
3249 });
3250 const call = mockPutRecord.mock.calls[0][0];
3251 expect(call.record.allowUserChoice).toBe(true);
3252 });
3253
3254 it("returns 400 when availableThemes is missing", async () => {
3255 const { availableThemes: _, ...bodyWithout } = validBody;
3256 const res = await app.request("/api/admin/theme-policy", {
3257 method: "PUT",
3258 headers: { "Content-Type": "application/json" },
3259 body: JSON.stringify(bodyWithout),
3260 });
3261 expect(res.status).toBe(400);
3262 const body = await res.json();
3263 expect(body.error).toMatch(/availableThemes/i);
3264 });
3265
3266 it("returns 400 when availableThemes is empty array", async () => {
3267 const res = await app.request("/api/admin/theme-policy", {
3268 method: "PUT",
3269 headers: { "Content-Type": "application/json" },
3270 body: JSON.stringify({ ...validBody, availableThemes: [] }),
3271 });
3272 expect(res.status).toBe(400);
3273 const body = await res.json();
3274 expect(body.error).toMatch(/availableThemes/i);
3275 });
3276
3277 it("accepts uri-only entries as live refs — writes themeRef without cid", async () => {
3278 // URI without cid is a valid live ref (e.g. canonical atbb.space preset)
3279 // No DB lookup or insertion required — CID is simply absent in the PDS record
3280 const liveUri = `at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light`;
3281
3282 const res = await app.request("/api/admin/theme-policy", {
3283 method: "PUT",
3284 headers: { "Content-Type": "application/json" },
3285 body: JSON.stringify({
3286 defaultLightThemeUri: liveUri,
3287 defaultDarkThemeUri: liveUri,
3288 allowUserChoice: true,
3289 availableThemes: [{ uri: liveUri }], // no cid — live ref
3290 }),
3291 });
3292
3293 expect(res.status).toBe(200);
3294 expect(mockPutRecord).toHaveBeenCalledOnce();
3295 const putCall = mockPutRecord.mock.calls[0][0];
3296 // Live ref has uri but no cid field
3297 expect(putCall.record.availableThemes[0]).toEqual({ uri: liveUri });
3298 expect(putCall.record.defaultLightTheme).toEqual({ uri: liveUri });
3299 expect(putCall.record.defaultDarkTheme).toEqual({ uri: liveUri });
3300 });
3301
3302 it("accepts live refs to external URIs not in local DB (e.g. atbb.space canonical presets)", async () => {
3303 // Previously the route rejected URIs not found in the local DB.
3304 // Now URI-only entries are valid live refs regardless of whether the theme is local.
3305 const externalUri = `at://did:web:atbb.space/space.atbb.forum.theme/clean-light`;
3306
3307 const res = await app.request("/api/admin/theme-policy", {
3308 method: "PUT",
3309 headers: { "Content-Type": "application/json" },
3310 body: JSON.stringify({
3311 defaultLightThemeUri: externalUri,
3312 defaultDarkThemeUri: externalUri,
3313 allowUserChoice: true,
3314 availableThemes: [{ uri: externalUri }],
3315 }),
3316 });
3317
3318 expect(res.status).toBe(200);
3319 expect(mockPutRecord).toHaveBeenCalledOnce();
3320 });
3321
3322 it("treats cid: \"\" as absent and writes a live ref (no cid in PDS record)", async () => {
3323 const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2aa`;
3324
3325 const res = await app.request("/api/admin/theme-policy", {
3326 method: "PUT",
3327 headers: { "Content-Type": "application/json" },
3328 body: JSON.stringify({
3329 defaultLightThemeUri: themeUri,
3330 defaultDarkThemeUri: themeUri,
3331 allowUserChoice: true,
3332 availableThemes: [{ uri: themeUri, cid: "" }], // empty string → treated as absent
3333 }),
3334 });
3335
3336 expect(res.status).toBe(200);
3337 expect(mockPutRecord).toHaveBeenCalledOnce();
3338 const putCall = mockPutRecord.mock.calls[0][0];
3339 // Empty string cid is dropped — written as live ref
3340 expect(putCall.record.availableThemes[0]).toEqual({ uri: themeUri });
3341 });
3342
3343 it("uses provided cid as-is when entry includes one (pinned ref)", async () => {
3344 await app.request("/api/admin/theme-policy", {
3345 method: "PUT",
3346 headers: { "Content-Type": "application/json" },
3347 body: JSON.stringify(validBody),
3348 });
3349 const putCall = mockPutRecord.mock.calls[0][0];
3350 // Pinned refs include both uri and cid
3351 expect(putCall.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" });
3352 expect(putCall.record.availableThemes[1]).toEqual({ uri: darkUri, cid: "bafydark" });
3353 });
3354
3355 it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => {
3356 const res = await app.request("/api/admin/theme-policy", {
3357 method: "PUT",
3358 headers: { "Content-Type": "application/json" },
3359 body: JSON.stringify({
3360 ...validBody,
3361 defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist",
3362 }),
3363 });
3364 expect(res.status).toBe(400);
3365 const body = await res.json();
3366 expect(body.error).toMatch(/defaultLightThemeUri/i);
3367 });
3368
3369 it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => {
3370 const res = await app.request("/api/admin/theme-policy", {
3371 method: "PUT",
3372 headers: { "Content-Type": "application/json" },
3373 body: JSON.stringify({
3374 ...validBody,
3375 defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist",
3376 }),
3377 });
3378 expect(res.status).toBe(400);
3379 const body = await res.json();
3380 expect(body.error).toMatch(/defaultDarkThemeUri/i);
3381 });
3382
3383 it("returns 400 when defaultLightThemeUri is missing", async () => {
3384 const { defaultLightThemeUri: _, ...bodyWithout } = validBody;
3385 const res = await app.request("/api/admin/theme-policy", {
3386 method: "PUT",
3387 headers: { "Content-Type": "application/json" },
3388 body: JSON.stringify(bodyWithout),
3389 });
3390 expect(res.status).toBe(400);
3391 const body = await res.json();
3392 expect(body.error).toMatch(/defaultLightThemeUri/i);
3393 });
3394
3395 it("returns 400 when defaultDarkThemeUri is missing", async () => {
3396 const { defaultDarkThemeUri: _, ...bodyWithout } = validBody;
3397 const res = await app.request("/api/admin/theme-policy", {
3398 method: "PUT",
3399 headers: { "Content-Type": "application/json" },
3400 body: JSON.stringify(bodyWithout),
3401 });
3402 expect(res.status).toBe(400);
3403 const body = await res.json();
3404 expect(body.error).toMatch(/defaultDarkThemeUri/i);
3405 });
3406
3407 it("returns 401 when not authenticated", async () => {
3408 mockUser = null;
3409 const res = await app.request("/api/admin/theme-policy", {
3410 method: "PUT",
3411 headers: { "Content-Type": "application/json" },
3412 body: JSON.stringify(validBody),
3413 });
3414 expect(res.status).toBe(401);
3415 expect(mockPutRecord).not.toHaveBeenCalled();
3416 });
3417
3418 it("returns 403 when user lacks manageThemes permission", async () => {
3419 const { requirePermission } = await import("../../middleware/permissions.js");
3420 const mockRequirePermission = requirePermission as any;
3421 mockRequirePermission.mockImplementation(() => async (c: any) => {
3422 return c.json({ error: "Forbidden" }, 403);
3423 });
3424
3425 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
3426 const res = await testApp.request("/api/admin/theme-policy", {
3427 method: "PUT",
3428 headers: { "Content-Type": "application/json" },
3429 body: JSON.stringify(validBody),
3430 });
3431
3432 expect(res.status).toBe(403);
3433 expect(mockPutRecord).not.toHaveBeenCalled();
3434
3435 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
3436 await next();
3437 });
3438 });
3439
3440 it("returns 500 when ForumAgent is not configured", async () => {
3441 ctx.forumAgent = null;
3442 const res = await app.request("/api/admin/theme-policy", {
3443 method: "PUT",
3444 headers: { "Content-Type": "application/json" },
3445 body: JSON.stringify(validBody),
3446 });
3447 expect(res.status).toBe(500);
3448 const body = await res.json();
3449 expect(body.error).toContain("Forum agent not available");
3450 });
3451
3452 it("returns 503 when ForumAgent not authenticated", async () => {
3453 const originalAgent = ctx.forumAgent;
3454 ctx.forumAgent = { getAgent: () => null } as any;
3455 const res = await app.request("/api/admin/theme-policy", {
3456 method: "PUT",
3457 headers: { "Content-Type": "application/json" },
3458 body: JSON.stringify(validBody),
3459 });
3460 expect(res.status).toBe(503);
3461 const body = await res.json();
3462 expect(body.error).toBe("Forum agent not authenticated. Please try again later.");
3463 expect(mockPutRecord).not.toHaveBeenCalled();
3464 ctx.forumAgent = originalAgent;
3465 });
3466 });
3467
3468});
3469