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 } 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});
2449