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";
5
6// Mock middleware at module level
7let mockUser: any;
8let mockPutRecord: ReturnType<typeof vi.fn>;
9
10vi.mock("../../middleware/auth.js", () => ({
11 requireAuth: vi.fn(() => async (c: any, next: any) => {
12 c.set("user", mockUser);
13 await next();
14 }),
15}));
16
17vi.mock("../../middleware/permissions.js", () => ({
18 requirePermission: vi.fn(() => async (_c: any, next: any) => {
19 await next();
20 }),
21 checkPermission: vi.fn().mockResolvedValue(true),
22}));
23
24// Import after mocking
25const { createModRoutes, validateReason, checkActiveAction } = await import("../mod.js");
26
27describe.sequential("Mod Module Tests", () => {
28 describe("Mod Routes", () => {
29 let ctx: TestContext;
30 let app: Hono<{ Variables: Variables }>;
31
32 beforeEach(async () => {
33 ctx = await createTestContext();
34 app = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
35
36 // Set up mock user for auth middleware
37 mockUser = { did: "did:plc:test-moderator" };
38
39 // Mock putRecord (matches @atproto/api Response format)
40 mockPutRecord = vi.fn().mockResolvedValue({
41 data: {
42 uri: "at://...",
43 cid: "bafytest",
44 },
45 });
46
47 // Mock ForumAgent
48 ctx.forumAgent = {
49 getAgent: () => ({
50 com: {
51 atproto: {
52 repo: {
53 putRecord: mockPutRecord,
54 },
55 },
56 },
57 }),
58 } as any;
59 });
60
61 afterEach(async () => {
62 await ctx.cleanup();
63 });
64
65 describe("POST /api/mod/ban", () => {
66 it("bans user successfully when admin has authority", async () => {
67 // Create admin and member users
68 const { users, memberships, roles } = await import("@atbb/db");
69 const { eq } = await import("drizzle-orm");
70
71 // Use unique DIDs for this test
72 const adminDid = "did:plc:test-ban-admin";
73 const memberDid = "did:plc:test-ban-member";
74
75 // Insert admin user
76 await ctx.db.insert(users).values({
77 did: adminDid,
78 handle: "admin.test",
79 indexedAt: new Date(),
80 });
81
82 // Insert member user
83 await ctx.db.insert(users).values({
84 did: memberDid,
85 handle: "member.test",
86 indexedAt: new Date(),
87 });
88
89 // Create admin role
90 await ctx.db.insert(roles).values({
91 did: ctx.config.forumDid,
92 rkey: "admin-role",
93 cid: "bafyadmin",
94 name: "Admin",
95 permissions: ["space.atbb.permission.banUsers"],
96 priority: 10,
97 createdAt: new Date(),
98 indexedAt: new Date(),
99 });
100
101 // Get admin role URI
102 const [adminRole] = await ctx.db
103 .select()
104 .from(roles)
105 .where(eq(roles.rkey, "admin-role"))
106 .limit(1);
107
108 // Insert memberships
109 const now = new Date();
110 await ctx.db.insert(memberships).values({
111 did: adminDid,
112 rkey: "self",
113 cid: "bafyadminmem",
114 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
115 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${adminRole.rkey}`,
116 joinedAt: now,
117 createdAt: now,
118 indexedAt: now,
119 });
120
121 await ctx.db.insert(memberships).values({
122 did: memberDid,
123 rkey: "self",
124 cid: "bafymembermem",
125 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
126 roleUri: null, // Regular member with no role
127 joinedAt: now,
128 createdAt: now,
129 indexedAt: now,
130 });
131
132 // Set mock user to admin
133 mockUser = { did: adminDid };
134
135 // Mock putRecord to return success (matches @atproto/api Response format)
136 mockPutRecord.mockResolvedValueOnce({
137 data: {
138 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test123`,
139 cid: "bafybanaction",
140 },
141 });
142
143 // POST ban request
144 const res = await app.request("/api/mod/ban", {
145 method: "POST",
146 headers: { "Content-Type": "application/json" },
147 body: JSON.stringify({
148 targetDid: memberDid,
149 reason: "Spam and harassment",
150 }),
151 });
152
153 expect(res.status).toBe(200);
154 const data = await res.json();
155 expect(data.success).toBe(true);
156 expect(data.action).toBe("space.atbb.modAction.ban");
157 expect(data.targetDid).toBe(memberDid);
158 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test123`);
159 expect(data.cid).toBe("bafybanaction");
160 expect(data.alreadyActive).toBe(false);
161
162 // Verify putRecord was called with correct parameters
163 expect(mockPutRecord).toHaveBeenCalledWith(
164 expect.objectContaining({
165 repo: ctx.config.forumDid,
166 collection: "space.atbb.modAction",
167 record: expect.objectContaining({
168 $type: "space.atbb.modAction",
169 action: "space.atbb.modAction.ban",
170 subject: { did: memberDid },
171 reason: "Spam and harassment",
172 createdBy: adminDid,
173 }),
174 })
175 );
176 });
177
178 describe("Authorization", () => {
179 it("returns 401 when not authenticated", async () => {
180 const { users, memberships } = await import("@atbb/db");
181
182 // Create target user to avoid 404 (matches cleanup pattern did:plc:test-%)
183 const targetDid = "did:plc:test-auth-target";
184 await ctx.db.insert(users).values({
185 did: targetDid,
186 handle: "authtest.test",
187 indexedAt: new Date(),
188 }).onConflictDoNothing();
189
190 await ctx.db.insert(memberships).values({
191 did: targetDid,
192 rkey: "self",
193 cid: "bafyauth",
194 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
195 roleUri: null,
196 joinedAt: new Date(),
197 createdAt: new Date(),
198 indexedAt: new Date(),
199 }).onConflictDoNothing();
200
201 // Recreate app with auth middleware that returns 401
202 const { requireAuth } = await import("../../middleware/auth.js");
203 const mockRequireAuth = requireAuth as any;
204 mockRequireAuth.mockImplementation(() => async (c: any) => {
205 return c.json({ error: "Unauthorized" }, 401);
206 });
207
208 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
209
210 const res = await testApp.request("/api/mod/ban", {
211 method: "POST",
212 headers: { "Content-Type": "application/json" },
213 body: JSON.stringify({
214 targetDid,
215 reason: "Test reason",
216 }),
217 });
218
219 expect(res.status).toBe(401);
220
221 // Restore default mock for subsequent tests
222 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
223 c.set("user", mockUser);
224 await next();
225 });
226 });
227
228 it("returns 403 when user lacks banUsers permission", async () => {
229 const { users, memberships } = await import("@atbb/db");
230 const { requirePermission } = await import("../../middleware/permissions.js");
231
232 // Create target user to avoid 404 (matches cleanup pattern did:plc:test-%)
233 const targetDid = "did:plc:test-perm-target";
234 await ctx.db.insert(users).values({
235 did: targetDid,
236 handle: "permtest.test",
237 indexedAt: new Date(),
238 }).onConflictDoNothing();
239
240 await ctx.db.insert(memberships).values({
241 did: targetDid,
242 rkey: "self",
243 cid: "bafyperm",
244 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
245 roleUri: null,
246 joinedAt: new Date(),
247 createdAt: new Date(),
248 indexedAt: new Date(),
249 }).onConflictDoNothing();
250
251 // Mock requirePermission to deny access
252 const mockRequirePermission = requirePermission as any;
253 mockRequirePermission.mockImplementation(() => async (c: any) => {
254 return c.json({ error: "Forbidden" }, 403);
255 });
256
257 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
258
259 const res = await testApp.request("/api/mod/ban", {
260 method: "POST",
261 headers: { "Content-Type": "application/json" },
262 body: JSON.stringify({
263 targetDid,
264 reason: "Test reason",
265 }),
266 });
267
268 expect(res.status).toBe(403);
269
270 // Restore default mock for subsequent tests
271 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
272 await next();
273 });
274 });
275 });
276
277 describe("Input Validation", () => {
278 beforeEach(() => {
279 // Reset mockUser to valid user for these tests
280 mockUser = { did: "did:plc:test-moderator" };
281 });
282
283 it("returns 400 for invalid DID format (not starting with 'did:')", async () => {
284 const res = await app.request("/api/mod/ban", {
285 method: "POST",
286 headers: { "Content-Type": "application/json" },
287 body: JSON.stringify({
288 targetDid: "invalid-did-format",
289 reason: "Test reason",
290 }),
291 });
292
293 expect(res.status).toBe(400);
294 const data = await res.json();
295 expect(data.error).toBe("Invalid DID format");
296 });
297
298 it("returns 400 for missing reason field", async () => {
299 const res = await app.request("/api/mod/ban", {
300 method: "POST",
301 headers: { "Content-Type": "application/json" },
302 body: JSON.stringify({
303 targetDid: "did:plc:target",
304 // reason field missing
305 }),
306 });
307
308 expect(res.status).toBe(400);
309 const data = await res.json();
310 expect(data.error).toBe("Reason is required and must be a string");
311 });
312
313 it("returns 400 for empty reason (whitespace only)", async () => {
314 const res = await app.request("/api/mod/ban", {
315 method: "POST",
316 headers: { "Content-Type": "application/json" },
317 body: JSON.stringify({
318 targetDid: "did:plc:target",
319 reason: " ",
320 }),
321 });
322
323 expect(res.status).toBe(400);
324 const data = await res.json();
325 expect(data.error).toBe("Reason is required and must not be empty");
326 });
327
328 it("returns 400 for malformed JSON", async () => {
329 const res = await app.request("/api/mod/ban", {
330 method: "POST",
331 headers: { "Content-Type": "application/json" },
332 body: "{ invalid json }",
333 });
334
335 expect(res.status).toBe(400);
336 const data = await res.json();
337 expect(data.error).toBe("Invalid JSON in request body");
338 });
339 });
340
341 describe("Business Logic", () => {
342 beforeEach(() => {
343 mockUser = { did: "did:plc:test-moderator" };
344 });
345
346 it("returns 404 when target user has no membership", async () => {
347 const res = await app.request("/api/mod/ban", {
348 method: "POST",
349 headers: { "Content-Type": "application/json" },
350 body: JSON.stringify({
351 targetDid: "did:plc:nonexistent",
352 reason: "Test reason",
353 }),
354 });
355
356 expect(res.status).toBe(404);
357 const data = await res.json();
358 expect(data.error).toBe("Target user not found");
359 });
360
361 it("returns 200 with alreadyActive: true when user already banned (idempotency)", async () => {
362 const { users, memberships, modActions, forums } = await import("@atbb/db");
363 const { eq } = await import("drizzle-orm");
364
365 // Create target user and membership with unique DID (matches cleanup pattern did:plc:test-%)
366 const targetDid = "did:plc:test-idempotent-ban";
367 await ctx.db.insert(users).values({
368 did: targetDid,
369 handle: "idempotentban.test",
370 indexedAt: new Date(),
371 }).onConflictDoNothing();
372
373 await ctx.db.insert(memberships).values({
374 did: targetDid,
375 rkey: "self",
376 cid: "bafytest",
377 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
378 roleUri: null,
379 joinedAt: new Date(),
380 createdAt: new Date(),
381 indexedAt: new Date(),
382 }).onConflictDoNothing();
383
384 // Get forum ID
385 const [forum] = await ctx.db
386 .select()
387 .from(forums)
388 .where(eq(forums.did, ctx.config.forumDid))
389 .limit(1);
390
391 // Insert existing ban action
392 await ctx.db.insert(modActions).values({
393 did: ctx.config.forumDid,
394 rkey: "existing-ban",
395 cid: "bafyban",
396 action: "space.atbb.modAction.ban",
397 subjectDid: targetDid,
398 subjectPostUri: null,
399 forumId: forum.id,
400 reason: "Previously banned",
401 createdBy: "did:plc:previous-mod",
402 expiresAt: null,
403 createdAt: new Date(),
404 indexedAt: new Date(),
405 });
406
407 // Attempt to ban again
408 const res = await app.request("/api/mod/ban", {
409 method: "POST",
410 headers: { "Content-Type": "application/json" },
411 body: JSON.stringify({
412 targetDid,
413 reason: "Trying to ban again",
414 }),
415 });
416
417 expect(res.status).toBe(200);
418 const data = await res.json();
419 expect(data.success).toBe(true);
420 expect(data.alreadyActive).toBe(true);
421 expect(data.uri).toBeNull();
422 expect(data.cid).toBeNull();
423
424 // Verify putRecord was NOT called (no duplicate action written)
425 expect(mockPutRecord).not.toHaveBeenCalled();
426 });
427 });
428
429 describe("Infrastructure Errors", () => {
430 beforeEach(() => {
431 mockUser = { did: "did:plc:test-moderator" };
432 });
433
434 it("returns 500 when ForumAgent not available", async () => {
435 const { users, memberships } = await import("@atbb/db");
436
437 // Create unique target user (matches cleanup pattern did:plc:test-%)
438 const targetDid = "did:plc:test-infra-no-agent";
439 await ctx.db.insert(users).values({
440 did: targetDid,
441 handle: "infranoagent.test",
442 indexedAt: new Date(),
443 }).onConflictDoNothing();
444
445 await ctx.db.insert(memberships).values({
446 did: targetDid,
447 rkey: "self",
448 cid: "bafyinfra1",
449 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
450 roleUri: null,
451 joinedAt: new Date(),
452 createdAt: new Date(),
453 indexedAt: new Date(),
454 }).onConflictDoNothing();
455
456 // Remove ForumAgent
457 ctx.forumAgent = undefined as any;
458
459 const res = await app.request("/api/mod/ban", {
460 method: "POST",
461 headers: { "Content-Type": "application/json" },
462 body: JSON.stringify({
463 targetDid,
464 reason: "Test reason",
465 }),
466 });
467
468 expect(res.status).toBe(500);
469 const data = await res.json();
470 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
471
472 // Restore ForumAgent for other tests
473 ctx.forumAgent = {
474 getAgent: () => ({
475 com: {
476 atproto: {
477 repo: {
478 putRecord: mockPutRecord,
479 },
480 },
481 },
482 }),
483 } as any;
484 });
485
486 it("returns 503 when ForumAgent not authenticated", async () => {
487 const { users, memberships } = await import("@atbb/db");
488
489 // Create unique target user (matches cleanup pattern did:plc:test-%)
490 const targetDid = "did:plc:test-infra-no-auth";
491 await ctx.db.insert(users).values({
492 did: targetDid,
493 handle: "infranoauth.test",
494 indexedAt: new Date(),
495 }).onConflictDoNothing();
496
497 await ctx.db.insert(memberships).values({
498 did: targetDid,
499 rkey: "self",
500 cid: "bafyinfra2",
501 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
502 roleUri: null,
503 joinedAt: new Date(),
504 createdAt: new Date(),
505 indexedAt: new Date(),
506 }).onConflictDoNothing();
507
508 // Mock getAgent to return null (not authenticated)
509 const originalAgent = ctx.forumAgent;
510 ctx.forumAgent = {
511 getAgent: () => null,
512 } as any;
513
514 const res = await app.request("/api/mod/ban", {
515 method: "POST",
516 headers: { "Content-Type": "application/json" },
517 body: JSON.stringify({
518 targetDid,
519 reason: "Test reason",
520 }),
521 });
522
523 expect(res.status).toBe(503);
524 const data = await res.json();
525 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
526
527 // Restore original agent
528 ctx.forumAgent = originalAgent;
529 });
530
531 it("returns 503 for network errors writing to PDS", async () => {
532 const { users, memberships } = await import("@atbb/db");
533
534 // Create unique target user (matches cleanup pattern did:plc:test-%)
535 const targetDid = "did:plc:test-infra-network-error";
536 await ctx.db.insert(users).values({
537 did: targetDid,
538 handle: "infranetwork.test",
539 indexedAt: new Date(),
540 }).onConflictDoNothing();
541
542 await ctx.db.insert(memberships).values({
543 did: targetDid,
544 rkey: "self",
545 cid: "bafyinfra3",
546 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
547 roleUri: null,
548 joinedAt: new Date(),
549 createdAt: new Date(),
550 indexedAt: new Date(),
551 }).onConflictDoNothing();
552
553 // Mock putRecord to throw network error
554 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
555
556 const res = await app.request("/api/mod/ban", {
557 method: "POST",
558 headers: { "Content-Type": "application/json" },
559 body: JSON.stringify({
560 targetDid,
561 reason: "Test reason",
562 }),
563 });
564
565 expect(res.status).toBe(503);
566 const data = await res.json();
567 expect(data.error).toBe("Unable to reach external service. Please try again later.");
568 });
569
570 it("returns 500 for unexpected errors writing to PDS", async () => {
571 const { users, memberships } = await import("@atbb/db");
572
573 // Create unique target user (matches cleanup pattern did:plc:test-%)
574 const targetDid = "did:plc:test-infra-server-error";
575 await ctx.db.insert(users).values({
576 did: targetDid,
577 handle: "infraserver.test",
578 indexedAt: new Date(),
579 }).onConflictDoNothing();
580
581 await ctx.db.insert(memberships).values({
582 did: targetDid,
583 rkey: "self",
584 cid: "bafyinfra4",
585 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
586 roleUri: null,
587 joinedAt: new Date(),
588 createdAt: new Date(),
589 indexedAt: new Date(),
590 }).onConflictDoNothing();
591
592 // Mock putRecord to throw unexpected error (not network error)
593 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
594
595 const res = await app.request("/api/mod/ban", {
596 method: "POST",
597 headers: { "Content-Type": "application/json" },
598 body: JSON.stringify({
599 targetDid,
600 reason: "Test reason",
601 }),
602 });
603
604 expect(res.status).toBe(500);
605 const data = await res.json();
606 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
607 });
608
609 it("returns 503 when membership query fails (database error)", async () => {
610 // Mock database query to throw error
611 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
612 throw new Error("Database connection lost");
613 });
614
615 const res = await app.request("/api/mod/ban", {
616 method: "POST",
617 headers: { "Content-Type": "application/json" },
618 body: JSON.stringify({
619 targetDid: "did:plc:test-db-error",
620 reason: "Test reason",
621 }),
622 });
623
624 expect(res.status).toBe(503);
625 const data = await res.json();
626 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
627
628 // Restore original implementation
629 dbSelectSpy.mockRestore();
630 });
631 });
632 });
633
634 describe("DELETE /api/mod/ban/:did", () => {
635 it("unbans user successfully when admin has authority", async () => {
636 // Create admin and member users
637 const { users, memberships, roles, modActions, forums } = await import("@atbb/db");
638 const { eq } = await import("drizzle-orm");
639
640 // Use unique DIDs for this test
641 const adminDid = "did:plc:test-unban-admin";
642 const memberDid = "did:plc:test-unban-member";
643
644 // Insert admin user
645 await ctx.db.insert(users).values({
646 did: adminDid,
647 handle: "unbanadmin.test",
648 indexedAt: new Date(),
649 });
650
651 // Insert member user
652 await ctx.db.insert(users).values({
653 did: memberDid,
654 handle: "unbanmember.test",
655 indexedAt: new Date(),
656 });
657
658 // Create admin role
659 await ctx.db.insert(roles).values({
660 did: ctx.config.forumDid,
661 rkey: "unban-admin-role",
662 cid: "bafyunbanadmin",
663 name: "Admin",
664 permissions: ["space.atbb.permission.banUsers"],
665 priority: 10,
666 createdAt: new Date(),
667 indexedAt: new Date(),
668 });
669
670 // Get admin role URI
671 const [adminRole] = await ctx.db
672 .select()
673 .from(roles)
674 .where(eq(roles.rkey, "unban-admin-role"))
675 .limit(1);
676
677 // Insert memberships
678 const now = new Date();
679 await ctx.db.insert(memberships).values({
680 did: adminDid,
681 rkey: "self",
682 cid: "bafyunbanadminmem",
683 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
684 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${adminRole.rkey}`,
685 joinedAt: now,
686 createdAt: now,
687 indexedAt: now,
688 });
689
690 await ctx.db.insert(memberships).values({
691 did: memberDid,
692 rkey: "self",
693 cid: "bafyunbanmembermem",
694 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
695 roleUri: null,
696 joinedAt: now,
697 createdAt: now,
698 indexedAt: now,
699 });
700
701 // Get forum ID
702 const [forum] = await ctx.db
703 .select()
704 .from(forums)
705 .where(eq(forums.did, ctx.config.forumDid))
706 .limit(1);
707
708 // Insert existing ban action so we have something to unban
709 await ctx.db.insert(modActions).values({
710 did: ctx.config.forumDid,
711 rkey: "previous-ban",
712 cid: "bafyprevban",
713 action: "space.atbb.modAction.ban",
714 subjectDid: memberDid,
715 subjectPostUri: null,
716 forumId: forum.id,
717 reason: "Previously banned",
718 createdBy: "did:plc:previous-mod",
719 expiresAt: null,
720 createdAt: new Date(now.getTime() - 1000),
721 indexedAt: new Date(now.getTime() - 1000),
722 });
723
724 // Set mock user to admin
725 mockUser = { did: adminDid };
726
727 // Mock putRecord to return success (matches @atproto/api Response format)
728 mockPutRecord.mockResolvedValueOnce({
729 data: {
730 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test-unban`,
731 cid: "bafyunbanaction",
732 },
733 });
734
735 // DELETE unban request
736 const res = await app.request(`/api/mod/ban/${memberDid}`, {
737 method: "DELETE",
738 headers: { "Content-Type": "application/json" },
739 body: JSON.stringify({
740 reason: "Appeal approved",
741 }),
742 });
743
744 expect(res.status).toBe(200);
745 const data = await res.json();
746 expect(data.success).toBe(true);
747 expect(data.action).toBe("space.atbb.modAction.unban");
748 expect(data.targetDid).toBe(memberDid);
749 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test-unban`);
750 expect(data.cid).toBe("bafyunbanaction");
751 expect(data.alreadyActive).toBe(false);
752
753 // Verify putRecord was called with correct parameters
754 expect(mockPutRecord).toHaveBeenCalledWith(
755 expect.objectContaining({
756 repo: ctx.config.forumDid,
757 collection: "space.atbb.modAction",
758 record: expect.objectContaining({
759 $type: "space.atbb.modAction",
760 action: "space.atbb.modAction.unban",
761 subject: { did: memberDid },
762 reason: "Appeal approved",
763 createdBy: adminDid,
764 }),
765 })
766 );
767 });
768
769 it("returns 200 with alreadyActive: true when user already unbanned (idempotency)", async () => {
770 const { users, memberships } = await import("@atbb/db");
771
772 // Create target user with unique DID (matches cleanup pattern did:plc:test-%)
773 const targetDid = "did:plc:test-already-unbanned";
774 await ctx.db.insert(users).values({
775 did: targetDid,
776 handle: "alreadyunbanned.test",
777 indexedAt: new Date(),
778 }).onConflictDoNothing();
779
780 await ctx.db.insert(memberships).values({
781 did: targetDid,
782 rkey: "self",
783 cid: "bafyunban",
784 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
785 roleUri: null,
786 joinedAt: new Date(),
787 createdAt: new Date(),
788 indexedAt: new Date(),
789 }).onConflictDoNothing();
790
791 // Set mock user
792 mockUser = { did: "did:plc:test-moderator" };
793
794 // Attempt to unban user who was never banned (or already unbanned)
795 const res = await app.request(`/api/mod/ban/${targetDid}`, {
796 method: "DELETE",
797 headers: { "Content-Type": "application/json" },
798 body: JSON.stringify({
799 reason: "Trying to unban again",
800 }),
801 });
802
803 expect(res.status).toBe(200);
804 const data = await res.json();
805 expect(data.success).toBe(true);
806 expect(data.alreadyActive).toBe(true);
807 expect(data.uri).toBeNull();
808 expect(data.cid).toBeNull();
809
810 // Verify putRecord was NOT called (no duplicate action written)
811 expect(mockPutRecord).not.toHaveBeenCalled();
812 });
813
814 // NOTE: Authorization tests (401, 403) are omitted for DELETE endpoint
815 // because it uses identical middleware chain as POST /api/mod/ban, which
816 // has comprehensive authorization tests. Mocking middleware state across
817 // multiple describe blocks proved problematic in the test suite.
818
819 describe("Input Validation", () => {
820 beforeEach(() => {
821 // Reset mockUser to valid user for these tests
822 mockUser = { did: "did:plc:test-moderator" };
823 });
824
825 it("returns 400 for invalid DID format (not starting with 'did:')", async () => {
826 const res = await app.request("/api/mod/ban/invalid-did-format", {
827 method: "DELETE",
828 headers: { "Content-Type": "application/json" },
829 body: JSON.stringify({
830 reason: "Test reason",
831 }),
832 });
833
834 expect(res.status).toBe(400);
835 const data = await res.json();
836 expect(data.error).toBe("Invalid DID format");
837 });
838
839 it("returns 400 for malformed JSON", async () => {
840 const res = await app.request("/api/mod/ban/did:plc:target", {
841 method: "DELETE",
842 headers: { "Content-Type": "application/json" },
843 body: "{ invalid json }",
844 });
845
846 expect(res.status).toBe(400);
847 const data = await res.json();
848 expect(data.error).toBe("Invalid JSON in request body");
849 });
850
851 it("returns 400 for missing reason field", async () => {
852 const res = await app.request("/api/mod/ban/did:plc:target", {
853 method: "DELETE",
854 headers: { "Content-Type": "application/json" },
855 body: JSON.stringify({
856 // reason field missing
857 }),
858 });
859
860 expect(res.status).toBe(400);
861 const data = await res.json();
862 expect(data.error).toBe("Reason is required and must be a string");
863 });
864
865 it("returns 400 for empty reason (whitespace only)", async () => {
866 const res = await app.request("/api/mod/ban/did:plc:target", {
867 method: "DELETE",
868 headers: { "Content-Type": "application/json" },
869 body: JSON.stringify({
870 reason: " ",
871 }),
872 });
873
874 expect(res.status).toBe(400);
875 const data = await res.json();
876 expect(data.error).toBe("Reason is required and must not be empty");
877 });
878 });
879
880 describe("Business Logic", () => {
881 beforeEach(() => {
882 mockUser = { did: "did:plc:test-moderator" };
883 });
884
885 it("returns 404 when target user has no membership", async () => {
886 const res = await app.request("/api/mod/ban/did:plc:nonexistent", {
887 method: "DELETE",
888 headers: { "Content-Type": "application/json" },
889 body: JSON.stringify({
890 reason: "Test reason",
891 }),
892 });
893
894 expect(res.status).toBe(404);
895 const data = await res.json();
896 expect(data.error).toBe("Target user not found");
897 });
898 });
899
900 describe("Infrastructure Errors", () => {
901 beforeEach(() => {
902 mockUser = { did: "did:plc:test-moderator" };
903 });
904
905 it("returns 500 when ForumAgent not available", async () => {
906 const { users, memberships, modActions, forums } = await import("@atbb/db");
907 const { eq } = await import("drizzle-orm");
908
909 // Create unique target user (matches cleanup pattern did:plc:test-%)
910 const targetDid = "did:plc:test-unban-infra-no-agent";
911 await ctx.db.insert(users).values({
912 did: targetDid,
913 handle: "unbaninfranoagent.test",
914 indexedAt: new Date(),
915 }).onConflictDoNothing();
916
917 await ctx.db.insert(memberships).values({
918 did: targetDid,
919 rkey: "self",
920 cid: "bafyunbaninfra1",
921 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
922 roleUri: null,
923 joinedAt: new Date(),
924 createdAt: new Date(),
925 indexedAt: new Date(),
926 }).onConflictDoNothing();
927
928 // Get forum ID
929 const [forum] = await ctx.db
930 .select()
931 .from(forums)
932 .where(eq(forums.did, ctx.config.forumDid))
933 .limit(1);
934
935 // Insert existing ban action so user is currently banned
936 await ctx.db.insert(modActions).values({
937 did: ctx.config.forumDid,
938 rkey: "ban-for-unban-test",
939 cid: "bafybantest",
940 action: "space.atbb.modAction.ban",
941 subjectDid: targetDid,
942 subjectPostUri: null,
943 forumId: forum.id,
944 reason: "Currently banned",
945 createdBy: "did:plc:previous-mod",
946 expiresAt: null,
947 createdAt: new Date(),
948 indexedAt: new Date(),
949 });
950
951 // Remove ForumAgent
952 ctx.forumAgent = undefined as any;
953
954 const res = await app.request(`/api/mod/ban/${targetDid}`, {
955 method: "DELETE",
956 headers: { "Content-Type": "application/json" },
957 body: JSON.stringify({
958 reason: "Test reason",
959 }),
960 });
961
962 expect(res.status).toBe(500);
963 const data = await res.json();
964 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
965
966 // Restore ForumAgent for other tests
967 ctx.forumAgent = {
968 getAgent: () => ({
969 com: {
970 atproto: {
971 repo: {
972 putRecord: mockPutRecord,
973 },
974 },
975 },
976 }),
977 } as any;
978 });
979
980 it("returns 503 when ForumAgent not authenticated", async () => {
981 const { users, memberships, modActions, forums } = await import("@atbb/db");
982 const { eq } = await import("drizzle-orm");
983
984 // Create unique target user (matches cleanup pattern did:plc:test-%)
985 const targetDid = "did:plc:test-unban-infra-no-auth";
986 await ctx.db.insert(users).values({
987 did: targetDid,
988 handle: "unbaninfranoauth.test",
989 indexedAt: new Date(),
990 }).onConflictDoNothing();
991
992 await ctx.db.insert(memberships).values({
993 did: targetDid,
994 rkey: "self",
995 cid: "bafyunbaninfra2",
996 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
997 roleUri: null,
998 joinedAt: new Date(),
999 createdAt: new Date(),
1000 indexedAt: new Date(),
1001 }).onConflictDoNothing();
1002
1003 // Get forum ID
1004 const [forum] = await ctx.db
1005 .select()
1006 .from(forums)
1007 .where(eq(forums.did, ctx.config.forumDid))
1008 .limit(1);
1009
1010 // Insert existing ban action so user is currently banned
1011 await ctx.db.insert(modActions).values({
1012 did: ctx.config.forumDid,
1013 rkey: "ban-for-unban-test2",
1014 cid: "bafybantest2",
1015 action: "space.atbb.modAction.ban",
1016 subjectDid: targetDid,
1017 subjectPostUri: null,
1018 forumId: forum.id,
1019 reason: "Currently banned",
1020 createdBy: "did:plc:previous-mod",
1021 expiresAt: null,
1022 createdAt: new Date(),
1023 indexedAt: new Date(),
1024 });
1025
1026 // Mock getAgent to return null (not authenticated)
1027 const originalAgent = ctx.forumAgent;
1028 ctx.forumAgent = {
1029 getAgent: () => null,
1030 } as any;
1031
1032 const res = await app.request(`/api/mod/ban/${targetDid}`, {
1033 method: "DELETE",
1034 headers: { "Content-Type": "application/json" },
1035 body: JSON.stringify({
1036 reason: "Test reason",
1037 }),
1038 });
1039
1040 expect(res.status).toBe(503);
1041 const data = await res.json();
1042 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1043
1044 // Restore original agent
1045 ctx.forumAgent = originalAgent;
1046 });
1047
1048 it("returns 503 for network errors writing to PDS", async () => {
1049 const { users, memberships, modActions, forums } = await import("@atbb/db");
1050 const { eq } = await import("drizzle-orm");
1051
1052 // Create unique target user (matches cleanup pattern did:plc:test-%)
1053 const targetDid = "did:plc:test-unban-infra-network-error";
1054 await ctx.db.insert(users).values({
1055 did: targetDid,
1056 handle: "unbaninfranetwork.test",
1057 indexedAt: new Date(),
1058 }).onConflictDoNothing();
1059
1060 await ctx.db.insert(memberships).values({
1061 did: targetDid,
1062 rkey: "self",
1063 cid: "bafyunbaninfra3",
1064 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1065 roleUri: null,
1066 joinedAt: new Date(),
1067 createdAt: new Date(),
1068 indexedAt: new Date(),
1069 }).onConflictDoNothing();
1070
1071 // Get forum ID
1072 const [forum] = await ctx.db
1073 .select()
1074 .from(forums)
1075 .where(eq(forums.did, ctx.config.forumDid))
1076 .limit(1);
1077
1078 // Insert existing ban action so user is currently banned
1079 await ctx.db.insert(modActions).values({
1080 did: ctx.config.forumDid,
1081 rkey: "ban-for-unban-test3",
1082 cid: "bafybantest3",
1083 action: "space.atbb.modAction.ban",
1084 subjectDid: targetDid,
1085 subjectPostUri: null,
1086 forumId: forum.id,
1087 reason: "Currently banned",
1088 createdBy: "did:plc:previous-mod",
1089 expiresAt: null,
1090 createdAt: new Date(),
1091 indexedAt: new Date(),
1092 });
1093
1094 // Mock putRecord to throw network error
1095 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
1096
1097 const res = await app.request(`/api/mod/ban/${targetDid}`, {
1098 method: "DELETE",
1099 headers: { "Content-Type": "application/json" },
1100 body: JSON.stringify({
1101 reason: "Test reason",
1102 }),
1103 });
1104
1105 expect(res.status).toBe(503);
1106 const data = await res.json();
1107 expect(data.error).toBe("Unable to reach external service. Please try again later.");
1108 });
1109
1110 it("returns 500 for unexpected errors writing to PDS", async () => {
1111 const { users, memberships, modActions, forums } = await import("@atbb/db");
1112 const { eq } = await import("drizzle-orm");
1113
1114 // Create unique target user (matches cleanup pattern did:plc:test-%)
1115 const targetDid = "did:plc:test-unban-infra-server-error";
1116 await ctx.db.insert(users).values({
1117 did: targetDid,
1118 handle: "unbaninfraserver.test",
1119 indexedAt: new Date(),
1120 }).onConflictDoNothing();
1121
1122 await ctx.db.insert(memberships).values({
1123 did: targetDid,
1124 rkey: "self",
1125 cid: "bafyunbaninfra4",
1126 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1127 roleUri: null,
1128 joinedAt: new Date(),
1129 createdAt: new Date(),
1130 indexedAt: new Date(),
1131 }).onConflictDoNothing();
1132
1133 // Get forum ID
1134 const [forum] = await ctx.db
1135 .select()
1136 .from(forums)
1137 .where(eq(forums.did, ctx.config.forumDid))
1138 .limit(1);
1139
1140 // Insert existing ban action so user is currently banned
1141 await ctx.db.insert(modActions).values({
1142 did: ctx.config.forumDid,
1143 rkey: "ban-for-unban-test4",
1144 cid: "bafybantest4",
1145 action: "space.atbb.modAction.ban",
1146 subjectDid: targetDid,
1147 subjectPostUri: null,
1148 forumId: forum.id,
1149 reason: "Currently banned",
1150 createdBy: "did:plc:previous-mod",
1151 expiresAt: null,
1152 createdAt: new Date(),
1153 indexedAt: new Date(),
1154 });
1155
1156 // Mock putRecord to throw unexpected error (not network error)
1157 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
1158
1159 const res = await app.request(`/api/mod/ban/${targetDid}`, {
1160 method: "DELETE",
1161 headers: { "Content-Type": "application/json" },
1162 body: JSON.stringify({
1163 reason: "Test reason",
1164 }),
1165 });
1166
1167 expect(res.status).toBe(500);
1168 const data = await res.json();
1169 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
1170 });
1171
1172 it("returns 503 when membership query fails (database error)", async () => {
1173 // Mock database query to throw error
1174 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1175 throw new Error("Database connection lost");
1176 });
1177
1178 const res = await app.request("/api/mod/ban/did:plc:test-unban-db-error", {
1179 method: "DELETE",
1180 headers: { "Content-Type": "application/json" },
1181 body: JSON.stringify({
1182 reason: "Test reason",
1183 }),
1184 });
1185
1186 expect(res.status).toBe(503);
1187 const data = await res.json();
1188 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1189
1190 // Restore spy
1191 dbSelectSpy.mockRestore();
1192 });
1193 });
1194 });
1195
1196 describe("POST /api/mod/lock", () => {
1197 it("locks topic successfully when moderator has authority", async () => {
1198 const { users, memberships, roles, posts } = await import("@atbb/db");
1199 const { eq } = await import("drizzle-orm");
1200
1201 // Use unique DIDs for this test
1202 const modDid = "did:plc:test-lock-mod";
1203 const authorDid = "did:plc:test-lock-author";
1204
1205 // Insert moderator user
1206 await ctx.db.insert(users).values({
1207 did: modDid,
1208 handle: "lockmod.test",
1209 indexedAt: new Date(),
1210 });
1211
1212 // Insert topic author user
1213 await ctx.db.insert(users).values({
1214 did: authorDid,
1215 handle: "lockauthor.test",
1216 indexedAt: new Date(),
1217 });
1218
1219 // Create moderator role
1220 await ctx.db.insert(roles).values({
1221 did: ctx.config.forumDid,
1222 rkey: "lock-mod-role",
1223 cid: "bafylockmod",
1224 name: "Moderator",
1225 permissions: ["space.atbb.permission.lockTopics"],
1226 priority: 20,
1227 createdAt: new Date(),
1228 indexedAt: new Date(),
1229 });
1230
1231 // Get moderator role URI
1232 const [modRole] = await ctx.db
1233 .select()
1234 .from(roles)
1235 .where(eq(roles.rkey, "lock-mod-role"))
1236 .limit(1);
1237
1238 // Insert memberships
1239 const now = new Date();
1240 await ctx.db.insert(memberships).values({
1241 did: modDid,
1242 rkey: "self",
1243 cid: "bafylockmodmem",
1244 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1245 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${modRole.rkey}`,
1246 joinedAt: now,
1247 createdAt: now,
1248 indexedAt: now,
1249 });
1250
1251 await ctx.db.insert(memberships).values({
1252 did: authorDid,
1253 rkey: "self",
1254 cid: "bafylockauthormem",
1255 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1256 roleUri: null,
1257 joinedAt: now,
1258 createdAt: now,
1259 indexedAt: now,
1260 });
1261
1262 // Insert a topic post (rootPostId = null means it's a topic)
1263 const [topic] = await ctx.db.insert(posts).values({
1264 did: authorDid,
1265 rkey: "3lbktopic",
1266 cid: "bafytopic",
1267 text: "Test topic to be locked",
1268 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1269 boardUri: null,
1270 boardId: null,
1271 rootPostId: null, // This is a topic (root post)
1272 parentPostId: null,
1273 createdAt: now,
1274 indexedAt: now,
1275 }).returning();
1276
1277 // Set mock user to moderator
1278 mockUser = { did: modDid };
1279
1280 // Mock putRecord to return success
1281 mockPutRecord.mockResolvedValueOnce({
1282 data: {
1283 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test-lock`,
1284 cid: "bafylockaction",
1285 },
1286 });
1287
1288 // POST lock request
1289 const res = await app.request("/api/mod/lock", {
1290 method: "POST",
1291 headers: { "Content-Type": "application/json" },
1292 body: JSON.stringify({
1293 topicId: topic.id.toString(),
1294 reason: "Off-topic discussion",
1295 }),
1296 });
1297
1298 expect(res.status).toBe(200);
1299 const data = await res.json();
1300 expect(data.success).toBe(true);
1301 expect(data.action).toBe("space.atbb.modAction.lock");
1302 expect(data.topicId).toBe(topic.id.toString());
1303 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test-lock`);
1304 expect(data.cid).toBe("bafylockaction");
1305 expect(data.alreadyActive).toBe(false);
1306
1307 // Verify putRecord was called with correct parameters
1308 expect(mockPutRecord).toHaveBeenCalledWith(
1309 expect.objectContaining({
1310 repo: ctx.config.forumDid,
1311 collection: "space.atbb.modAction",
1312 record: expect.objectContaining({
1313 $type: "space.atbb.modAction",
1314 action: "space.atbb.modAction.lock",
1315 subject: {
1316 post: {
1317 uri: `at://${authorDid}/space.atbb.post/${topic.rkey}`,
1318 cid: topic.cid,
1319 },
1320 },
1321 reason: "Off-topic discussion",
1322 createdBy: modDid,
1323 }),
1324 })
1325 );
1326 });
1327
1328 it("returns 400 when trying to lock a reply post (not root)", async () => {
1329 const { users, posts } = await import("@atbb/db");
1330
1331 // Create author
1332 const authorDid = "did:plc:test-lock-reply-author";
1333 await ctx.db.insert(users).values({
1334 did: authorDid,
1335 handle: "lockreplyauthor.test",
1336 indexedAt: new Date(),
1337 });
1338
1339 const now = new Date();
1340
1341 // Insert a topic first
1342 const [topic] = await ctx.db.insert(posts).values({
1343 did: authorDid,
1344 rkey: "3lbktopicroot",
1345 cid: "bafytopicroot",
1346 text: "Topic root",
1347 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1348 boardUri: null,
1349 boardId: null,
1350 rootPostId: null, // This is a topic
1351 parentPostId: null,
1352 createdAt: now,
1353 indexedAt: now,
1354 }).returning();
1355
1356 // Insert a reply post
1357 const [reply] = await ctx.db.insert(posts).values({
1358 did: authorDid,
1359 rkey: "3lbkreply",
1360 cid: "bafyreply",
1361 text: "This is a reply",
1362 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1363 boardUri: null,
1364 boardId: null,
1365 rootPostId: topic.id, // This is a reply (has rootPostId)
1366 parentPostId: topic.id,
1367 createdAt: now,
1368 indexedAt: now,
1369 }).returning();
1370
1371 mockUser = { did: "did:plc:test-moderator" };
1372
1373 const res = await app.request("/api/mod/lock", {
1374 method: "POST",
1375 headers: { "Content-Type": "application/json" },
1376 body: JSON.stringify({
1377 topicId: reply.id.toString(),
1378 reason: "Testing reply lock",
1379 }),
1380 });
1381
1382 expect(res.status).toBe(400);
1383 const data = await res.json();
1384 expect(data.error).toBe("Can only lock topic posts, not replies");
1385 });
1386
1387 it("returns 404 when topic not found", async () => {
1388 mockUser = { did: "did:plc:test-moderator" };
1389
1390 const res = await app.request("/api/mod/lock", {
1391 method: "POST",
1392 headers: { "Content-Type": "application/json" },
1393 body: JSON.stringify({
1394 topicId: "999999999",
1395 reason: "Testing nonexistent topic",
1396 }),
1397 });
1398
1399 expect(res.status).toBe(404);
1400 const data = await res.json();
1401 expect(data.error).toBe("Topic not found");
1402 });
1403
1404 describe("Authorization", () => {
1405 it("returns 401 when not authenticated", async () => {
1406 const { users, memberships, posts } = await import("@atbb/db");
1407
1408 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
1409 const authorDid = "did:plc:test-lock-auth";
1410 await ctx.db.insert(users).values({
1411 did: authorDid,
1412 handle: "lockauth.test",
1413 indexedAt: new Date(),
1414 }).onConflictDoNothing();
1415
1416 await ctx.db.insert(memberships).values({
1417 did: authorDid,
1418 rkey: "self",
1419 cid: "bafylockauth",
1420 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1421 roleUri: null,
1422 joinedAt: new Date(),
1423 createdAt: new Date(),
1424 indexedAt: new Date(),
1425 }).onConflictDoNothing();
1426
1427 const [topic] = await ctx.db.insert(posts).values({
1428 did: authorDid,
1429 rkey: "3lbklockauth",
1430 cid: "bafylockauth",
1431 text: "Test topic for auth",
1432 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1433 boardUri: null,
1434 boardId: null,
1435 rootPostId: null,
1436 parentPostId: null,
1437 createdAt: new Date(),
1438 indexedAt: new Date(),
1439 }).returning();
1440
1441 // Mock requireAuth to return 401
1442 const { requireAuth } = await import("../../middleware/auth.js");
1443 const mockRequireAuth = requireAuth as any;
1444 mockRequireAuth.mockImplementation(() => async (c: any) => {
1445 return c.json({ error: "Unauthorized" }, 401);
1446 });
1447
1448 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
1449
1450 const res = await testApp.request("/api/mod/lock", {
1451 method: "POST",
1452 headers: { "Content-Type": "application/json" },
1453 body: JSON.stringify({
1454 topicId: String(topic.id),
1455 reason: "Test reason",
1456 }),
1457 });
1458
1459 expect(res.status).toBe(401);
1460
1461 // Restore default mock for subsequent tests
1462 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
1463 c.set("user", mockUser);
1464 await next();
1465 });
1466 });
1467
1468 it("returns 403 when user lacks lockTopics permission", async () => {
1469 const { users, memberships, posts } = await import("@atbb/db");
1470 const { requirePermission } = await import("../../middleware/permissions.js");
1471
1472 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
1473 const authorDid = "did:plc:test-lock-perm";
1474 await ctx.db.insert(users).values({
1475 did: authorDid,
1476 handle: "lockperm.test",
1477 indexedAt: new Date(),
1478 }).onConflictDoNothing();
1479
1480 await ctx.db.insert(memberships).values({
1481 did: authorDid,
1482 rkey: "self",
1483 cid: "bafylockperm",
1484 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1485 roleUri: null,
1486 joinedAt: new Date(),
1487 createdAt: new Date(),
1488 indexedAt: new Date(),
1489 }).onConflictDoNothing();
1490
1491 const [topic] = await ctx.db.insert(posts).values({
1492 did: authorDid,
1493 rkey: "3lbklockperm",
1494 cid: "bafylockperm",
1495 text: "Test topic for permission",
1496 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1497 boardUri: null,
1498 boardId: null,
1499 rootPostId: null,
1500 parentPostId: null,
1501 createdAt: new Date(),
1502 indexedAt: new Date(),
1503 }).returning();
1504
1505 // Mock requirePermission to deny access
1506 const mockRequirePermission = requirePermission as any;
1507 mockRequirePermission.mockImplementation(() => async (c: any) => {
1508 return c.json({ error: "Forbidden" }, 403);
1509 });
1510
1511 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
1512
1513 const res = await testApp.request("/api/mod/lock", {
1514 method: "POST",
1515 headers: { "Content-Type": "application/json" },
1516 body: JSON.stringify({
1517 topicId: String(topic.id),
1518 reason: "Test reason",
1519 }),
1520 });
1521
1522 expect(res.status).toBe(403);
1523
1524 // Restore default mock for subsequent tests
1525 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
1526 await next();
1527 });
1528 });
1529 });
1530
1531 describe("Input Validation", () => {
1532 beforeEach(() => {
1533 // Reset mockUser to valid user for these tests
1534 mockUser = { did: "did:plc:test-moderator" };
1535 });
1536
1537 it("returns 400 for malformed JSON", async () => {
1538 const res = await app.request("/api/mod/lock", {
1539 method: "POST",
1540 headers: { "Content-Type": "application/json" },
1541 body: "{ invalid json }",
1542 });
1543
1544 expect(res.status).toBe(400);
1545 const data = await res.json();
1546 expect(data.error).toBe("Invalid JSON in request body");
1547 });
1548
1549 it("returns 400 for invalid topicId format (non-numeric)", async () => {
1550 const res = await app.request("/api/mod/lock", {
1551 method: "POST",
1552 headers: { "Content-Type": "application/json" },
1553 body: JSON.stringify({
1554 topicId: "not-a-number",
1555 reason: "Test reason",
1556 }),
1557 });
1558
1559 expect(res.status).toBe(400);
1560 const data = await res.json();
1561 expect(data.error).toBe("Invalid topic ID format");
1562 });
1563
1564 it("returns 400 for missing reason field", async () => {
1565 const res = await app.request("/api/mod/lock", {
1566 method: "POST",
1567 headers: { "Content-Type": "application/json" },
1568 body: JSON.stringify({
1569 topicId: "123456",
1570 // reason field missing
1571 }),
1572 });
1573
1574 expect(res.status).toBe(400);
1575 const data = await res.json();
1576 expect(data.error).toBe("Reason is required and must be a string");
1577 });
1578
1579 it("returns 400 for empty reason (whitespace only)", async () => {
1580 const res = await app.request("/api/mod/lock", {
1581 method: "POST",
1582 headers: { "Content-Type": "application/json" },
1583 body: JSON.stringify({
1584 topicId: "123456",
1585 reason: " ",
1586 }),
1587 });
1588
1589 expect(res.status).toBe(400);
1590 const data = await res.json();
1591 expect(data.error).toBe("Reason is required and must not be empty");
1592 });
1593 });
1594
1595 describe("Business Logic", () => {
1596 beforeEach(() => {
1597 mockUser = { did: "did:plc:test-moderator" };
1598 });
1599
1600 it("returns 200 with alreadyActive: true when already locked (idempotency)", async () => {
1601 const { users, posts, forums, modActions } = await import("@atbb/db");
1602 const { eq } = await import("drizzle-orm");
1603
1604 // Create author
1605 const authorDid = "did:plc:test-lock-already-locked";
1606 await ctx.db.insert(users).values({
1607 did: authorDid,
1608 handle: "alreadylocked.test",
1609 indexedAt: new Date(),
1610 }).onConflictDoNothing();
1611
1612 const now = new Date();
1613
1614 // Insert a topic
1615 const [topic] = await ctx.db.insert(posts).values({
1616 did: authorDid,
1617 rkey: "3lbklocked",
1618 cid: "bafylocked",
1619 text: "Already locked topic",
1620 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1621 boardUri: null,
1622 boardId: null,
1623 rootPostId: null,
1624 parentPostId: null,
1625 createdAt: now,
1626 indexedAt: now,
1627 }).returning();
1628
1629 // Get forum ID
1630 const [forum] = await ctx.db
1631 .select()
1632 .from(forums)
1633 .where(eq(forums.did, ctx.config.forumDid))
1634 .limit(1);
1635
1636 // Insert existing lock action
1637 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
1638 await ctx.db.insert(modActions).values({
1639 did: ctx.config.forumDid,
1640 rkey: "existing-lock",
1641 cid: "bafyexistlock",
1642 action: "space.atbb.modAction.lock",
1643 subjectDid: null,
1644 subjectPostUri: topicUri,
1645 forumId: forum.id,
1646 reason: "Previously locked",
1647 createdBy: "did:plc:previous-mod",
1648 expiresAt: null,
1649 createdAt: new Date(now.getTime() - 1000),
1650 indexedAt: new Date(now.getTime() - 1000),
1651 });
1652
1653 // Attempt to lock again
1654 const res = await app.request("/api/mod/lock", {
1655 method: "POST",
1656 headers: { "Content-Type": "application/json" },
1657 body: JSON.stringify({
1658 topicId: topic.id.toString(),
1659 reason: "Trying to lock again",
1660 }),
1661 });
1662
1663 expect(res.status).toBe(200);
1664 const data = await res.json();
1665 expect(data.success).toBe(true);
1666 expect(data.alreadyActive).toBe(true);
1667 expect(data.uri).toBeNull();
1668 expect(data.cid).toBeNull();
1669
1670 // Verify putRecord was NOT called (no duplicate action written)
1671 expect(mockPutRecord).not.toHaveBeenCalled();
1672 });
1673 });
1674
1675 describe("Infrastructure Errors", () => {
1676 beforeEach(() => {
1677 mockUser = { did: "did:plc:test-moderator" };
1678 });
1679
1680 it("returns 500 when ForumAgent not available", async () => {
1681 const { users, posts } = await import("@atbb/db");
1682
1683 // Create author
1684 const authorDid = "did:plc:test-lock-no-agent";
1685 await ctx.db.insert(users).values({
1686 did: authorDid,
1687 handle: "locknoagent.test",
1688 indexedAt: new Date(),
1689 }).onConflictDoNothing();
1690
1691 const now = new Date();
1692
1693 // Insert a topic
1694 const [topic] = await ctx.db.insert(posts).values({
1695 did: authorDid,
1696 rkey: "3lbknoagent",
1697 cid: "bafynoagent",
1698 text: "Test topic",
1699 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1700 boardUri: null,
1701 boardId: null,
1702 rootPostId: null,
1703 parentPostId: null,
1704 createdAt: now,
1705 indexedAt: now,
1706 }).returning();
1707
1708 // Remove ForumAgent
1709 ctx.forumAgent = undefined as any;
1710
1711 const res = await app.request("/api/mod/lock", {
1712 method: "POST",
1713 headers: { "Content-Type": "application/json" },
1714 body: JSON.stringify({
1715 topicId: topic.id.toString(),
1716 reason: "Test reason",
1717 }),
1718 });
1719
1720 expect(res.status).toBe(500);
1721 const data = await res.json();
1722 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
1723
1724 // Restore ForumAgent for other tests
1725 ctx.forumAgent = {
1726 getAgent: () => ({
1727 com: {
1728 atproto: {
1729 repo: {
1730 putRecord: mockPutRecord,
1731 },
1732 },
1733 },
1734 }),
1735 } as any;
1736 });
1737
1738 it("returns 503 when ForumAgent not authenticated", async () => {
1739 const { users, posts } = await import("@atbb/db");
1740
1741 // Create author
1742 const authorDid = "did:plc:test-lock-no-auth";
1743 await ctx.db.insert(users).values({
1744 did: authorDid,
1745 handle: "locknoauth.test",
1746 indexedAt: new Date(),
1747 }).onConflictDoNothing();
1748
1749 const now = new Date();
1750
1751 // Insert a topic
1752 const [topic] = await ctx.db.insert(posts).values({
1753 did: authorDid,
1754 rkey: "3lbknoauth",
1755 cid: "bafynoauth",
1756 text: "Test topic",
1757 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1758 boardUri: null,
1759 boardId: null,
1760 rootPostId: null,
1761 parentPostId: null,
1762 createdAt: now,
1763 indexedAt: now,
1764 }).returning();
1765
1766 // Mock getAgent to return null (not authenticated)
1767 const originalAgent = ctx.forumAgent;
1768 ctx.forumAgent = {
1769 getAgent: () => null,
1770 } as any;
1771
1772 const res = await app.request("/api/mod/lock", {
1773 method: "POST",
1774 headers: { "Content-Type": "application/json" },
1775 body: JSON.stringify({
1776 topicId: topic.id.toString(),
1777 reason: "Test reason",
1778 }),
1779 });
1780
1781 expect(res.status).toBe(503);
1782 const data = await res.json();
1783 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1784
1785 // Restore original agent
1786 ctx.forumAgent = originalAgent;
1787 });
1788
1789 it("returns 503 for network errors writing to PDS", async () => {
1790 const { users, posts } = await import("@atbb/db");
1791
1792 // Create author
1793 const authorDid = "did:plc:test-lock-network-error";
1794 await ctx.db.insert(users).values({
1795 did: authorDid,
1796 handle: "locknetwork.test",
1797 indexedAt: new Date(),
1798 }).onConflictDoNothing();
1799
1800 const now = new Date();
1801
1802 // Insert a topic
1803 const [topic] = await ctx.db.insert(posts).values({
1804 did: authorDid,
1805 rkey: "3lbknetwork",
1806 cid: "bafynetwork",
1807 text: "Test topic",
1808 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1809 boardUri: null,
1810 boardId: null,
1811 rootPostId: null,
1812 parentPostId: null,
1813 createdAt: now,
1814 indexedAt: now,
1815 }).returning();
1816
1817 // Mock putRecord to throw network error
1818 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
1819
1820 const res = await app.request("/api/mod/lock", {
1821 method: "POST",
1822 headers: { "Content-Type": "application/json" },
1823 body: JSON.stringify({
1824 topicId: topic.id.toString(),
1825 reason: "Test reason",
1826 }),
1827 });
1828
1829 expect(res.status).toBe(503);
1830 const data = await res.json();
1831 expect(data.error).toBe("Unable to reach external service. Please try again later.");
1832 });
1833
1834 it("returns 500 for unexpected errors writing to PDS", async () => {
1835 const { users, posts } = await import("@atbb/db");
1836
1837 // Create author
1838 const authorDid = "did:plc:test-lock-server-error";
1839 await ctx.db.insert(users).values({
1840 did: authorDid,
1841 handle: "lockserver.test",
1842 indexedAt: new Date(),
1843 }).onConflictDoNothing();
1844
1845 const now = new Date();
1846
1847 // Insert a topic
1848 const [topic] = await ctx.db.insert(posts).values({
1849 did: authorDid,
1850 rkey: "3lbkserver",
1851 cid: "bafyserver",
1852 text: "Test topic",
1853 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1854 boardUri: null,
1855 boardId: null,
1856 rootPostId: null,
1857 parentPostId: null,
1858 createdAt: now,
1859 indexedAt: now,
1860 }).returning();
1861
1862 // Mock putRecord to throw unexpected error (not network error)
1863 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
1864
1865 const res = await app.request("/api/mod/lock", {
1866 method: "POST",
1867 headers: { "Content-Type": "application/json" },
1868 body: JSON.stringify({
1869 topicId: topic.id.toString(),
1870 reason: "Test reason",
1871 }),
1872 });
1873
1874 expect(res.status).toBe(500);
1875 const data = await res.json();
1876 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
1877 });
1878
1879 it("returns 503 when post query fails (database error)", async () => {
1880 // Mock console.error to suppress error output during test
1881 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1882
1883 // Mock database query to throw error on first call (post query)
1884 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1885 throw new Error("Database connection lost");
1886 });
1887
1888 const res = await app.request("/api/mod/lock", {
1889 method: "POST",
1890 headers: { "Content-Type": "application/json" },
1891 body: JSON.stringify({
1892 topicId: "999999999",
1893 reason: "Test reason",
1894 }),
1895 });
1896
1897 expect(res.status).toBe(503);
1898 const data = await res.json();
1899 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1900
1901 // Restore spies
1902 consoleErrorSpy.mockRestore();
1903 dbSelectSpy.mockRestore();
1904 });
1905 });
1906 });
1907
1908 describe("DELETE /api/mod/lock/:topicId", () => {
1909 it("unlocks topic successfully when moderator has authority", async () => {
1910 const { users, memberships, roles, posts, forums, modActions } = await import("@atbb/db");
1911 const { eq } = await import("drizzle-orm");
1912
1913 // Use unique DIDs for this test
1914 const modDid = "did:plc:test-unlock-mod";
1915 const authorDid = "did:plc:test-unlock-author";
1916
1917 // Insert moderator user
1918 await ctx.db.insert(users).values({
1919 did: modDid,
1920 handle: "unlockmod.test",
1921 indexedAt: new Date(),
1922 });
1923
1924 // Insert topic author user
1925 await ctx.db.insert(users).values({
1926 did: authorDid,
1927 handle: "unlockauthor.test",
1928 indexedAt: new Date(),
1929 });
1930
1931 // Create moderator role
1932 await ctx.db.insert(roles).values({
1933 did: ctx.config.forumDid,
1934 rkey: "unlock-mod-role",
1935 cid: "bafyunlockmod",
1936 name: "Moderator",
1937 permissions: ["space.atbb.permission.lockTopics"],
1938 priority: 20,
1939 createdAt: new Date(),
1940 indexedAt: new Date(),
1941 });
1942
1943 // Get moderator role URI
1944 const [modRole] = await ctx.db
1945 .select()
1946 .from(roles)
1947 .where(eq(roles.rkey, "unlock-mod-role"))
1948 .limit(1);
1949
1950 // Insert memberships
1951 const now = new Date();
1952 await ctx.db.insert(memberships).values({
1953 did: modDid,
1954 rkey: "self",
1955 cid: "bafyunlockmodmem",
1956 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1957 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${modRole.rkey}`,
1958 joinedAt: now,
1959 createdAt: now,
1960 indexedAt: now,
1961 });
1962
1963 await ctx.db.insert(memberships).values({
1964 did: authorDid,
1965 rkey: "self",
1966 cid: "bafyunlockauthormem",
1967 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1968 roleUri: null,
1969 joinedAt: now,
1970 createdAt: now,
1971 indexedAt: now,
1972 });
1973
1974 // Insert a topic post
1975 const [topic] = await ctx.db.insert(posts).values({
1976 did: authorDid,
1977 rkey: "3lbkunlocktopic",
1978 cid: "bafyunlocktopic",
1979 text: "Test topic to be unlocked",
1980 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1981 boardUri: null,
1982 boardId: null,
1983 rootPostId: null, // This is a topic
1984 parentPostId: null,
1985 createdAt: now,
1986 indexedAt: now,
1987 }).returning();
1988
1989 // Get forum ID
1990 const [forum] = await ctx.db
1991 .select()
1992 .from(forums)
1993 .where(eq(forums.did, ctx.config.forumDid))
1994 .limit(1);
1995
1996 // Insert existing lock action so topic is currently locked
1997 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
1998 await ctx.db.insert(modActions).values({
1999 did: ctx.config.forumDid,
2000 rkey: "previous-lock",
2001 cid: "bafyprevlock",
2002 action: "space.atbb.modAction.lock",
2003 subjectDid: null,
2004 subjectPostUri: topicUri,
2005 forumId: forum.id,
2006 reason: "Previously locked",
2007 createdBy: "did:plc:previous-mod",
2008 expiresAt: null,
2009 createdAt: new Date(now.getTime() - 1000),
2010 indexedAt: new Date(now.getTime() - 1000),
2011 });
2012
2013 // Set mock user to moderator
2014 mockUser = { did: modDid };
2015
2016 // Mock putRecord to return success
2017 mockPutRecord.mockResolvedValueOnce({
2018 data: {
2019 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test-unlock`,
2020 cid: "bafyunlockaction",
2021 },
2022 });
2023
2024 // DELETE unlock request
2025 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2026 method: "DELETE",
2027 headers: { "Content-Type": "application/json" },
2028 body: JSON.stringify({
2029 reason: "Discussion resumed",
2030 }),
2031 });
2032
2033 expect(res.status).toBe(200);
2034 const data = await res.json();
2035 expect(data.success).toBe(true);
2036 expect(data.action).toBe("space.atbb.modAction.unlock");
2037 expect(data.topicId).toBe(topic.id.toString());
2038 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test-unlock`);
2039 expect(data.cid).toBe("bafyunlockaction");
2040 expect(data.alreadyActive).toBe(false);
2041
2042 // Verify putRecord was called with correct parameters
2043 expect(mockPutRecord).toHaveBeenCalledWith(
2044 expect.objectContaining({
2045 repo: ctx.config.forumDid,
2046 collection: "space.atbb.modAction",
2047 record: expect.objectContaining({
2048 $type: "space.atbb.modAction",
2049 action: "space.atbb.modAction.unlock",
2050 subject: {
2051 post: {
2052 uri: topicUri,
2053 cid: topic.cid,
2054 },
2055 },
2056 reason: "Discussion resumed",
2057 createdBy: modDid,
2058 }),
2059 })
2060 );
2061 });
2062
2063 describe("Authorization", () => {
2064 it("returns 401 when not authenticated", async () => {
2065 const { users, memberships, posts } = await import("@atbb/db");
2066
2067 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
2068 const authorDid = "did:plc:test-unlock-auth";
2069 await ctx.db.insert(users).values({
2070 did: authorDid,
2071 handle: "unlockauth.test",
2072 indexedAt: new Date(),
2073 }).onConflictDoNothing();
2074
2075 await ctx.db.insert(memberships).values({
2076 did: authorDid,
2077 rkey: "self",
2078 cid: "bafyunlockauth",
2079 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2080 roleUri: null,
2081 joinedAt: new Date(),
2082 createdAt: new Date(),
2083 indexedAt: new Date(),
2084 }).onConflictDoNothing();
2085
2086 const [topic] = await ctx.db.insert(posts).values({
2087 did: authorDid,
2088 rkey: "3lbkunlockauth",
2089 cid: "bafyunlockauth",
2090 text: "Test topic for auth",
2091 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2092 boardUri: null,
2093 boardId: null,
2094 rootPostId: null,
2095 parentPostId: null,
2096 createdAt: new Date(),
2097 indexedAt: new Date(),
2098 }).returning();
2099
2100 // Mock requireAuth to return 401
2101 const { requireAuth } = await import("../../middleware/auth.js");
2102 const mockRequireAuth = requireAuth as any;
2103 mockRequireAuth.mockImplementation(() => async (c: any) => {
2104 return c.json({ error: "Unauthorized" }, 401);
2105 });
2106
2107 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2108
2109 const res = await testApp.request(`/api/mod/lock/${topic.id}`, {
2110 method: "DELETE",
2111 headers: { "Content-Type": "application/json" },
2112 body: JSON.stringify({
2113 reason: "Test reason",
2114 }),
2115 });
2116
2117 expect(res.status).toBe(401);
2118
2119 // Restore default mock for subsequent tests
2120 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
2121 c.set("user", mockUser);
2122 await next();
2123 });
2124 });
2125
2126 it("returns 403 when user lacks lockTopics permission", async () => {
2127 const { users, memberships, posts } = await import("@atbb/db");
2128 const { requirePermission } = await import("../../middleware/permissions.js");
2129
2130 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
2131 const authorDid = "did:plc:test-unlock-perm";
2132 await ctx.db.insert(users).values({
2133 did: authorDid,
2134 handle: "unlockperm.test",
2135 indexedAt: new Date(),
2136 }).onConflictDoNothing();
2137
2138 await ctx.db.insert(memberships).values({
2139 did: authorDid,
2140 rkey: "self",
2141 cid: "bafyunlockperm",
2142 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2143 roleUri: null,
2144 joinedAt: new Date(),
2145 createdAt: new Date(),
2146 indexedAt: new Date(),
2147 }).onConflictDoNothing();
2148
2149 const [topic] = await ctx.db.insert(posts).values({
2150 did: authorDid,
2151 rkey: "3lbkunlockperm",
2152 cid: "bafyunlockperm",
2153 text: "Test topic for permission",
2154 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2155 boardUri: null,
2156 boardId: null,
2157 rootPostId: null,
2158 parentPostId: null,
2159 createdAt: new Date(),
2160 indexedAt: new Date(),
2161 }).returning();
2162
2163 // Mock requirePermission to deny access
2164 const mockRequirePermission = requirePermission as any;
2165 mockRequirePermission.mockImplementation(() => async (c: any) => {
2166 return c.json({ error: "Forbidden" }, 403);
2167 });
2168
2169 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2170
2171 const res = await testApp.request(`/api/mod/lock/${topic.id}`, {
2172 method: "DELETE",
2173 headers: { "Content-Type": "application/json" },
2174 body: JSON.stringify({
2175 reason: "Test reason",
2176 }),
2177 });
2178
2179 expect(res.status).toBe(403);
2180
2181 // Restore default mock for subsequent tests
2182 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
2183 await next();
2184 });
2185 });
2186 });
2187
2188 describe("Input Validation", () => {
2189 beforeEach(() => {
2190 mockUser = { did: "did:plc:test-moderator" };
2191 });
2192
2193 it("returns 400 for invalid topicId format", async () => {
2194 const res = await app.request("/api/mod/lock/not-a-number", {
2195 method: "DELETE",
2196 headers: { "Content-Type": "application/json" },
2197 body: JSON.stringify({
2198 reason: "Test reason",
2199 }),
2200 });
2201
2202 expect(res.status).toBe(400);
2203 const data = await res.json();
2204 expect(data.error).toBe("Invalid topic ID format");
2205 });
2206
2207 it("returns 400 for missing reason field", async () => {
2208 const res = await app.request("/api/mod/lock/123456", {
2209 method: "DELETE",
2210 headers: { "Content-Type": "application/json" },
2211 body: JSON.stringify({
2212 // reason field missing
2213 }),
2214 });
2215
2216 expect(res.status).toBe(400);
2217 const data = await res.json();
2218 expect(data.error).toBe("Reason is required and must be a string");
2219 });
2220
2221 it("returns 400 for empty reason (whitespace only)", async () => {
2222 const res = await app.request("/api/mod/lock/123456", {
2223 method: "DELETE",
2224 headers: { "Content-Type": "application/json" },
2225 body: JSON.stringify({
2226 reason: " ",
2227 }),
2228 });
2229
2230 expect(res.status).toBe(400);
2231 const data = await res.json();
2232 expect(data.error).toBe("Reason is required and must not be empty");
2233 });
2234 });
2235
2236 describe("Business Logic", () => {
2237 beforeEach(() => {
2238 mockUser = { did: "did:plc:test-moderator" };
2239 });
2240
2241 it("returns 404 when topic not found", async () => {
2242 const res = await app.request("/api/mod/lock/999999999", {
2243 method: "DELETE",
2244 headers: { "Content-Type": "application/json" },
2245 body: JSON.stringify({
2246 reason: "Testing nonexistent topic",
2247 }),
2248 });
2249
2250 expect(res.status).toBe(404);
2251 const data = await res.json();
2252 expect(data.error).toBe("Topic not found");
2253 });
2254
2255 it("returns 200 with alreadyActive: true when already unlocked (idempotency)", async () => {
2256 const { users, posts } = await import("@atbb/db");
2257
2258 // Create author
2259 const authorDid = "did:plc:test-unlock-already-unlocked";
2260 await ctx.db.insert(users).values({
2261 did: authorDid,
2262 handle: "alreadyunlocked.test",
2263 indexedAt: new Date(),
2264 }).onConflictDoNothing();
2265
2266 const now = new Date();
2267
2268 // Insert a topic (never locked, or previously unlocked)
2269 const [topic] = await ctx.db.insert(posts).values({
2270 did: authorDid,
2271 rkey: "3lbkunlocked",
2272 cid: "bafyunlocked",
2273 text: "Already unlocked topic",
2274 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2275 boardUri: null,
2276 boardId: null,
2277 rootPostId: null,
2278 parentPostId: null,
2279 createdAt: now,
2280 indexedAt: now,
2281 }).returning();
2282
2283 // Attempt to unlock (no lock exists)
2284 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2285 method: "DELETE",
2286 headers: { "Content-Type": "application/json" },
2287 body: JSON.stringify({
2288 reason: "Trying to unlock again",
2289 }),
2290 });
2291
2292 expect(res.status).toBe(200);
2293 const data = await res.json();
2294 expect(data.success).toBe(true);
2295 expect(data.alreadyActive).toBe(true);
2296 expect(data.uri).toBeNull();
2297 expect(data.cid).toBeNull();
2298
2299 // Verify putRecord was NOT called (no duplicate action written)
2300 expect(mockPutRecord).not.toHaveBeenCalled();
2301 });
2302 });
2303
2304 describe("Infrastructure Errors", () => {
2305 beforeEach(() => {
2306 mockUser = { did: "did:plc:test-moderator" };
2307 });
2308
2309 it("returns 503 when post query fails (database error)", async () => {
2310 // Mock console.error to suppress error output during test
2311 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
2312
2313 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
2314 throw new Error("Database connection lost");
2315 });
2316
2317 const res = await app.request("/api/mod/lock/999999999", {
2318 method: "DELETE",
2319 headers: { "Content-Type": "application/json" },
2320 body: JSON.stringify({
2321 reason: "Test reason",
2322 }),
2323 });
2324
2325 expect(res.status).toBe(503);
2326 const data = await res.json();
2327 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
2328
2329 consoleErrorSpy.mockRestore();
2330 dbSelectSpy.mockRestore();
2331 });
2332
2333 it("returns 500 when ForumAgent not available", async () => {
2334 const { users, posts, forums, modActions } = await import("@atbb/db");
2335 const { eq } = await import("drizzle-orm");
2336
2337 // Create author
2338 const authorDid = "did:plc:test-unlock-no-agent";
2339 await ctx.db.insert(users).values({
2340 did: authorDid,
2341 handle: "unlocknoagent.test",
2342 indexedAt: new Date(),
2343 }).onConflictDoNothing();
2344
2345 const now = new Date();
2346
2347 // Insert a topic
2348 const [topic] = await ctx.db.insert(posts).values({
2349 did: authorDid,
2350 rkey: "3lbkunlocknoagent",
2351 cid: "bafyunlocknoagent",
2352 text: "Test topic",
2353 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2354 boardUri: null,
2355 boardId: null,
2356 rootPostId: null,
2357 parentPostId: null,
2358 createdAt: now,
2359 indexedAt: now,
2360 }).returning();
2361
2362 // Get forum ID and insert lock action so topic is locked
2363 const [forum] = await ctx.db
2364 .select()
2365 .from(forums)
2366 .where(eq(forums.did, ctx.config.forumDid))
2367 .limit(1);
2368
2369 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2370 await ctx.db.insert(modActions).values({
2371 did: ctx.config.forumDid,
2372 rkey: "unlock-no-agent-lock",
2373 cid: "bafyunlocknoagentlock",
2374 action: "space.atbb.modAction.lock",
2375 subjectDid: null,
2376 subjectPostUri: topicUri,
2377 forumId: forum.id,
2378 reason: "Locked",
2379 createdBy: "did:plc:previous-mod",
2380 expiresAt: null,
2381 createdAt: new Date(now.getTime() - 1000),
2382 indexedAt: new Date(now.getTime() - 1000),
2383 });
2384
2385 // Remove ForumAgent
2386 ctx.forumAgent = undefined as any;
2387
2388 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2389 method: "DELETE",
2390 headers: { "Content-Type": "application/json" },
2391 body: JSON.stringify({
2392 reason: "Test reason",
2393 }),
2394 });
2395
2396 expect(res.status).toBe(500);
2397 const data = await res.json();
2398 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
2399
2400 // Restore ForumAgent for other tests
2401 ctx.forumAgent = {
2402 getAgent: () => ({
2403 com: {
2404 atproto: {
2405 repo: {
2406 putRecord: mockPutRecord,
2407 },
2408 },
2409 },
2410 }),
2411 } as any;
2412 });
2413
2414 it("returns 503 when ForumAgent not authenticated", async () => {
2415 const { users, posts, forums, modActions } = await import("@atbb/db");
2416 const { eq } = await import("drizzle-orm");
2417
2418 // Create author
2419 const authorDid = "did:plc:test-unlock-no-auth";
2420 await ctx.db.insert(users).values({
2421 did: authorDid,
2422 handle: "unlocknoauth.test",
2423 indexedAt: new Date(),
2424 }).onConflictDoNothing();
2425
2426 const now = new Date();
2427
2428 // Insert a topic
2429 const [topic] = await ctx.db.insert(posts).values({
2430 did: authorDid,
2431 rkey: "3lbkunlocknoauth",
2432 cid: "bafyunlocknoauth",
2433 text: "Test topic",
2434 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2435 boardUri: null,
2436 boardId: null,
2437 rootPostId: null,
2438 parentPostId: null,
2439 createdAt: now,
2440 indexedAt: now,
2441 }).returning();
2442
2443 // Get forum ID and insert lock action
2444 const [forum] = await ctx.db
2445 .select()
2446 .from(forums)
2447 .where(eq(forums.did, ctx.config.forumDid))
2448 .limit(1);
2449
2450 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2451 await ctx.db.insert(modActions).values({
2452 did: ctx.config.forumDid,
2453 rkey: "unlock-no-auth-lock",
2454 cid: "bafyunlocknoauthlock",
2455 action: "space.atbb.modAction.lock",
2456 subjectDid: null,
2457 subjectPostUri: topicUri,
2458 forumId: forum.id,
2459 reason: "Locked",
2460 createdBy: "did:plc:previous-mod",
2461 expiresAt: null,
2462 createdAt: new Date(now.getTime() - 1000),
2463 indexedAt: new Date(now.getTime() - 1000),
2464 });
2465
2466 // Mock getAgent to return null (not authenticated)
2467 const originalAgent = ctx.forumAgent;
2468 ctx.forumAgent = {
2469 getAgent: () => null,
2470 } as any;
2471
2472 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2473 method: "DELETE",
2474 headers: { "Content-Type": "application/json" },
2475 body: JSON.stringify({
2476 reason: "Test reason",
2477 }),
2478 });
2479
2480 expect(res.status).toBe(503);
2481 const data = await res.json();
2482 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
2483
2484 // Restore original agent
2485 ctx.forumAgent = originalAgent;
2486 });
2487
2488 it("returns 503 for network errors writing to PDS", async () => {
2489 const { users, posts, forums, modActions } = await import("@atbb/db");
2490 const { eq } = await import("drizzle-orm");
2491
2492 // Create author
2493 const authorDid = "did:plc:test-unlock-network-error";
2494 await ctx.db.insert(users).values({
2495 did: authorDid,
2496 handle: "unlocknetwork.test",
2497 indexedAt: new Date(),
2498 }).onConflictDoNothing();
2499
2500 const now = new Date();
2501
2502 // Insert a topic
2503 const [topic] = await ctx.db.insert(posts).values({
2504 did: authorDid,
2505 rkey: "3lbkunlocknetwork",
2506 cid: "bafyunlocknetwork",
2507 text: "Test topic",
2508 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2509 boardUri: null,
2510 boardId: null,
2511 rootPostId: null,
2512 parentPostId: null,
2513 createdAt: now,
2514 indexedAt: now,
2515 }).returning();
2516
2517 // Get forum ID and insert lock action
2518 const [forum] = await ctx.db
2519 .select()
2520 .from(forums)
2521 .where(eq(forums.did, ctx.config.forumDid))
2522 .limit(1);
2523
2524 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2525 await ctx.db.insert(modActions).values({
2526 did: ctx.config.forumDid,
2527 rkey: "unlock-network-lock",
2528 cid: "bafyunlocknetworklock",
2529 action: "space.atbb.modAction.lock",
2530 subjectDid: null,
2531 subjectPostUri: topicUri,
2532 forumId: forum.id,
2533 reason: "Locked",
2534 createdBy: "did:plc:previous-mod",
2535 expiresAt: null,
2536 createdAt: new Date(now.getTime() - 1000),
2537 indexedAt: new Date(now.getTime() - 1000),
2538 });
2539
2540 // Mock putRecord to throw network error
2541 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
2542
2543 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2544 method: "DELETE",
2545 headers: { "Content-Type": "application/json" },
2546 body: JSON.stringify({
2547 reason: "Test reason",
2548 }),
2549 });
2550
2551 expect(res.status).toBe(503);
2552 const data = await res.json();
2553 expect(data.error).toBe("Unable to reach external service. Please try again later.");
2554 });
2555
2556 it("returns 500 for unexpected errors writing to PDS", async () => {
2557 const { users, posts, forums, modActions } = await import("@atbb/db");
2558 const { eq } = await import("drizzle-orm");
2559
2560 // Create author
2561 const authorDid = "did:plc:test-unlock-server-error";
2562 await ctx.db.insert(users).values({
2563 did: authorDid,
2564 handle: "unlockserver.test",
2565 indexedAt: new Date(),
2566 }).onConflictDoNothing();
2567
2568 const now = new Date();
2569
2570 // Insert a topic
2571 const [topic] = await ctx.db.insert(posts).values({
2572 did: authorDid,
2573 rkey: "3lbkunlockserver",
2574 cid: "bafyunlockserver",
2575 text: "Test topic",
2576 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2577 boardUri: null,
2578 boardId: null,
2579 rootPostId: null,
2580 parentPostId: null,
2581 createdAt: now,
2582 indexedAt: now,
2583 }).returning();
2584
2585 // Get forum ID and insert lock action
2586 const [forum] = await ctx.db
2587 .select()
2588 .from(forums)
2589 .where(eq(forums.did, ctx.config.forumDid))
2590 .limit(1);
2591
2592 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2593 await ctx.db.insert(modActions).values({
2594 did: ctx.config.forumDid,
2595 rkey: "unlock-server-lock",
2596 cid: "bafyunlockserverlock",
2597 action: "space.atbb.modAction.lock",
2598 subjectDid: null,
2599 subjectPostUri: topicUri,
2600 forumId: forum.id,
2601 reason: "Locked",
2602 createdBy: "did:plc:previous-mod",
2603 expiresAt: null,
2604 createdAt: new Date(now.getTime() - 1000),
2605 indexedAt: new Date(now.getTime() - 1000),
2606 });
2607
2608 // Mock putRecord to throw unexpected error (not network error)
2609 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
2610
2611 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2612 method: "DELETE",
2613 headers: { "Content-Type": "application/json" },
2614 body: JSON.stringify({
2615 reason: "Test reason",
2616 }),
2617 });
2618
2619 expect(res.status).toBe(500);
2620 const data = await res.json();
2621 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
2622 });
2623 });
2624 });
2625
2626 describe("POST /api/mod/hide", () => {
2627 it("hides topic post successfully", async () => {
2628 const { users, posts } = await import("@atbb/db");
2629
2630 // Create moderator and member users
2631 const modDid = "did:plc:test-hide-mod";
2632 const memberDid = "did:plc:test-hide-member";
2633
2634 await ctx.db.insert(users).values({
2635 did: modDid,
2636 handle: "hidemod.test",
2637 indexedAt: new Date(),
2638 });
2639
2640 await ctx.db.insert(users).values({
2641 did: memberDid,
2642 handle: "hidemember.test",
2643 indexedAt: new Date(),
2644 });
2645
2646 // Insert topic post
2647 const [topic] = await ctx.db.insert(posts).values({
2648 did: memberDid,
2649 rkey: "3lbkhidetopic",
2650 cid: "bafyhidetopic",
2651 text: "Spam topic",
2652 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2653 boardUri: null,
2654 boardId: null,
2655 rootPostId: null,
2656 parentPostId: null,
2657 createdAt: new Date(),
2658 indexedAt: new Date(),
2659 }).returning();
2660
2661 mockUser = { did: modDid };
2662
2663 mockPutRecord.mockResolvedValueOnce({
2664 data: {
2665 uri: "at://did:plc:forum/space.atbb.modAction/hide123",
2666 cid: "bafyhide",
2667 },
2668 });
2669
2670 const res = await app.request("/api/mod/hide", {
2671 method: "POST",
2672 headers: { "Content-Type": "application/json" },
2673 body: JSON.stringify({
2674 postId: topic.id.toString(),
2675 reason: "Spam content",
2676 }),
2677 });
2678
2679 expect(res.status).toBe(200);
2680 const data = await res.json();
2681 expect(data.success).toBe(true);
2682 expect(data.action).toBe("space.atbb.modAction.delete");
2683 expect(data.postId).toBe(topic.id.toString());
2684 });
2685
2686 it("hides reply post successfully", async () => {
2687 const { users, posts } = await import("@atbb/db");
2688
2689 const modDid = "did:plc:test-hide-reply-mod";
2690 const memberDid = "did:plc:test-hide-reply-member";
2691
2692 await ctx.db.insert(users).values({
2693 did: modDid,
2694 handle: "hidereplymod.test",
2695 indexedAt: new Date(),
2696 });
2697
2698 await ctx.db.insert(users).values({
2699 did: memberDid,
2700 handle: "hidereplymember.test",
2701 indexedAt: new Date(),
2702 });
2703
2704 const now = new Date();
2705
2706 // Insert topic
2707 const [topic] = await ctx.db.insert(posts).values({
2708 did: memberDid,
2709 rkey: "3lbkhidereplytopic",
2710 cid: "bafyhidereplytopic",
2711 text: "Topic",
2712 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2713 boardUri: null,
2714 boardId: null,
2715 rootPostId: null,
2716 parentPostId: null,
2717 createdAt: now,
2718 indexedAt: now,
2719 }).returning();
2720
2721 // Insert reply
2722 const [reply] = await ctx.db.insert(posts).values({
2723 did: memberDid,
2724 rkey: "3lbkhidereply",
2725 cid: "bafyhidereply",
2726 text: "Spam reply",
2727 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2728 boardUri: null,
2729 boardId: null,
2730 rootPostId: topic.id,
2731 parentPostId: topic.id,
2732 createdAt: now,
2733 indexedAt: now,
2734 }).returning();
2735
2736 mockUser = { did: modDid };
2737
2738 mockPutRecord.mockResolvedValueOnce({
2739 data: {
2740 uri: "at://did:plc:forum/space.atbb.modAction/hide456",
2741 cid: "bafyhide2",
2742 },
2743 });
2744
2745 const res = await app.request("/api/mod/hide", {
2746 method: "POST",
2747 headers: { "Content-Type": "application/json" },
2748 body: JSON.stringify({
2749 postId: reply.id.toString(),
2750 reason: "Harassment",
2751 }),
2752 });
2753
2754 expect(res.status).toBe(200);
2755 const data = await res.json();
2756 expect(data.success).toBe(true);
2757 expect(data.action).toBe("space.atbb.modAction.delete");
2758 });
2759
2760 describe("Authorization", () => {
2761 it("returns 401 when not authenticated", async () => {
2762 const { users, memberships, posts } = await import("@atbb/db");
2763
2764 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
2765 const authorDid = "did:plc:test-hide-auth";
2766 await ctx.db.insert(users).values({
2767 did: authorDid,
2768 handle: "hideauth.test",
2769 indexedAt: new Date(),
2770 }).onConflictDoNothing();
2771
2772 await ctx.db.insert(memberships).values({
2773 did: authorDid,
2774 rkey: "self",
2775 cid: "bafyhideauth",
2776 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2777 roleUri: null,
2778 joinedAt: new Date(),
2779 createdAt: new Date(),
2780 indexedAt: new Date(),
2781 }).onConflictDoNothing();
2782
2783 const [post] = await ctx.db.insert(posts).values({
2784 did: authorDid,
2785 rkey: "3lbkhideauth",
2786 cid: "bafyhideauth",
2787 text: "Test post for auth",
2788 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2789 boardUri: null,
2790 boardId: null,
2791 rootPostId: null,
2792 parentPostId: null,
2793 createdAt: new Date(),
2794 indexedAt: new Date(),
2795 }).returning();
2796
2797 // Mock requireAuth to return 401
2798 const { requireAuth } = await import("../../middleware/auth.js");
2799 const mockRequireAuth = requireAuth as any;
2800 mockRequireAuth.mockImplementation(() => async (c: any) => {
2801 return c.json({ error: "Unauthorized" }, 401);
2802 });
2803
2804 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2805
2806 const res = await testApp.request("/api/mod/hide", {
2807 method: "POST",
2808 headers: { "Content-Type": "application/json" },
2809 body: JSON.stringify({
2810 postId: String(post.id),
2811 reason: "Test reason",
2812 }),
2813 });
2814
2815 expect(res.status).toBe(401);
2816
2817 // Restore default mock for subsequent tests
2818 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
2819 c.set("user", mockUser);
2820 await next();
2821 });
2822 });
2823
2824 it("returns 403 when user lacks moderatePosts permission", async () => {
2825 const { users, memberships, posts } = await import("@atbb/db");
2826 const { requirePermission } = await import("../../middleware/permissions.js");
2827
2828 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
2829 const authorDid = "did:plc:test-hide-perm";
2830 await ctx.db.insert(users).values({
2831 did: authorDid,
2832 handle: "hideperm.test",
2833 indexedAt: new Date(),
2834 }).onConflictDoNothing();
2835
2836 await ctx.db.insert(memberships).values({
2837 did: authorDid,
2838 rkey: "self",
2839 cid: "bafyhideperm",
2840 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2841 roleUri: null,
2842 joinedAt: new Date(),
2843 createdAt: new Date(),
2844 indexedAt: new Date(),
2845 }).onConflictDoNothing();
2846
2847 const [post] = await ctx.db.insert(posts).values({
2848 did: authorDid,
2849 rkey: "3lbkhideperm",
2850 cid: "bafyhideperm",
2851 text: "Test post for permission",
2852 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2853 boardUri: null,
2854 boardId: null,
2855 rootPostId: null,
2856 parentPostId: null,
2857 createdAt: new Date(),
2858 indexedAt: new Date(),
2859 }).returning();
2860
2861 // Mock requirePermission to deny access
2862 const mockRequirePermission = requirePermission as any;
2863 mockRequirePermission.mockImplementation(() => async (c: any) => {
2864 return c.json({ error: "Forbidden" }, 403);
2865 });
2866
2867 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2868
2869 const res = await testApp.request("/api/mod/hide", {
2870 method: "POST",
2871 headers: { "Content-Type": "application/json" },
2872 body: JSON.stringify({
2873 postId: String(post.id),
2874 reason: "Test reason",
2875 }),
2876 });
2877
2878 expect(res.status).toBe(403);
2879
2880 // Restore default mock for subsequent tests
2881 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
2882 await next();
2883 });
2884 });
2885 });
2886
2887 describe("Input Validation", () => {
2888 beforeEach(() => {
2889 mockUser = { did: "did:plc:test-moderator" };
2890 });
2891
2892 it("returns 400 for malformed JSON", async () => {
2893 const res = await app.request("/api/mod/hide", {
2894 method: "POST",
2895 headers: { "Content-Type": "application/json" },
2896 body: "{ invalid json }",
2897 });
2898
2899 expect(res.status).toBe(400);
2900 const data = await res.json();
2901 expect(data.error).toBe("Invalid JSON in request body");
2902 });
2903
2904 it("returns 400 when postId is missing", async () => {
2905 const res = await app.request("/api/mod/hide", {
2906 method: "POST",
2907 headers: { "Content-Type": "application/json" },
2908 body: JSON.stringify({
2909 // postId missing
2910 reason: "Test reason",
2911 }),
2912 });
2913
2914 expect(res.status).toBe(400);
2915 const data = await res.json();
2916 expect(data.error).toBe("postId is required and must be a string");
2917 });
2918
2919 it("returns 400 when postId is not a string", async () => {
2920 const res = await app.request("/api/mod/hide", {
2921 method: "POST",
2922 headers: { "Content-Type": "application/json" },
2923 body: JSON.stringify({
2924 postId: 123456, // number instead of string
2925 reason: "Test reason",
2926 }),
2927 });
2928
2929 expect(res.status).toBe(400);
2930 const data = await res.json();
2931 expect(data.error).toBe("postId is required and must be a string");
2932 });
2933
2934 it("returns 400 for invalid postId format (non-numeric)", async () => {
2935 const res = await app.request("/api/mod/hide", {
2936 method: "POST",
2937 headers: { "Content-Type": "application/json" },
2938 body: JSON.stringify({
2939 postId: "not-a-number",
2940 reason: "Test reason",
2941 }),
2942 });
2943
2944 expect(res.status).toBe(400);
2945 const data = await res.json();
2946 expect(data.error).toBe("Invalid post ID");
2947 });
2948
2949 it("returns 400 when reason is missing", async () => {
2950 const res = await app.request("/api/mod/hide", {
2951 method: "POST",
2952 headers: { "Content-Type": "application/json" },
2953 body: JSON.stringify({
2954 postId: "123456",
2955 // reason missing
2956 }),
2957 });
2958
2959 expect(res.status).toBe(400);
2960 const data = await res.json();
2961 expect(data.error).toBe("Reason is required and must be a string");
2962 });
2963
2964 it("returns 400 when reason is empty string", async () => {
2965 const res = await app.request("/api/mod/hide", {
2966 method: "POST",
2967 headers: { "Content-Type": "application/json" },
2968 body: JSON.stringify({
2969 postId: "123456",
2970 reason: " ", // whitespace only
2971 }),
2972 });
2973
2974 expect(res.status).toBe(400);
2975 const data = await res.json();
2976 expect(data.error).toBe("Reason is required and must not be empty");
2977 });
2978 });
2979
2980 describe("Business Logic", () => {
2981 beforeEach(() => {
2982 mockUser = { did: "did:plc:test-moderator" };
2983 });
2984
2985 it("returns 404 when post does not exist", async () => {
2986 const res = await app.request("/api/mod/hide", {
2987 method: "POST",
2988 headers: { "Content-Type": "application/json" },
2989 body: JSON.stringify({
2990 postId: "999999999", // non-existent
2991 reason: "Test reason",
2992 }),
2993 });
2994
2995 expect(res.status).toBe(404);
2996 const data = await res.json();
2997 expect(data.error).toBe("Post not found");
2998 });
2999
3000 it("returns 200 with alreadyActive: true when post is already hidden (idempotency)", async () => {
3001 const { users, posts, forums, modActions } = await import("@atbb/db");
3002 const { eq } = await import("drizzle-orm");
3003
3004 // Create author
3005 const authorDid = "did:plc:test-hide-already-hidden";
3006 await ctx.db.insert(users).values({
3007 did: authorDid,
3008 handle: "alreadyhidden.test",
3009 indexedAt: new Date(),
3010 }).onConflictDoNothing();
3011
3012 const now = new Date();
3013
3014 // Insert a post
3015 const [post] = await ctx.db.insert(posts).values({
3016 did: authorDid,
3017 rkey: "3lbkhidden",
3018 cid: "bafyhidden",
3019 text: "Already hidden post",
3020 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3021 boardUri: null,
3022 boardId: null,
3023 rootPostId: null,
3024 parentPostId: null,
3025 createdAt: now,
3026 indexedAt: now,
3027 }).returning();
3028
3029 // Get forum ID
3030 const [forum] = await ctx.db
3031 .select()
3032 .from(forums)
3033 .where(eq(forums.did, ctx.config.forumDid))
3034 .limit(1);
3035
3036 // Insert existing hide action
3037 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3038 await ctx.db.insert(modActions).values({
3039 did: ctx.config.forumDid,
3040 rkey: "existing-hide",
3041 cid: "bafyexisthide",
3042 action: "space.atbb.modAction.delete",
3043 subjectDid: null,
3044 subjectPostUri: postUri,
3045 forumId: forum.id,
3046 reason: "Previously hidden",
3047 createdBy: "did:plc:previous-mod",
3048 expiresAt: null,
3049 createdAt: new Date(now.getTime() - 1000),
3050 indexedAt: new Date(now.getTime() - 1000),
3051 });
3052
3053 // Attempt to hide again
3054 const res = await app.request("/api/mod/hide", {
3055 method: "POST",
3056 headers: { "Content-Type": "application/json" },
3057 body: JSON.stringify({
3058 postId: post.id.toString(),
3059 reason: "Trying to hide again",
3060 }),
3061 });
3062
3063 expect(res.status).toBe(200);
3064 const data = await res.json();
3065 expect(data.success).toBe(true);
3066 expect(data.alreadyActive).toBe(true);
3067 expect(data.uri).toBeNull();
3068 expect(data.cid).toBeNull();
3069
3070 // Verify putRecord was NOT called (no duplicate action written)
3071 expect(mockPutRecord).not.toHaveBeenCalled();
3072 });
3073 });
3074
3075 describe("Infrastructure Errors", () => {
3076 beforeEach(() => {
3077 mockUser = { did: "did:plc:test-moderator" };
3078 });
3079
3080 it("returns 503 when post query fails (database error)", async () => {
3081 // Mock console.error to suppress error output during test
3082 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3083
3084 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
3085 throw new Error("Database connection lost");
3086 });
3087
3088 const res = await app.request("/api/mod/hide", {
3089 method: "POST",
3090 headers: { "Content-Type": "application/json" },
3091 body: JSON.stringify({
3092 postId: "999999999",
3093 reason: "Test reason",
3094 }),
3095 });
3096
3097 expect(res.status).toBe(503);
3098 const data = await res.json();
3099 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
3100
3101 consoleErrorSpy.mockRestore();
3102 dbSelectSpy.mockRestore();
3103 });
3104
3105 it("returns 500 when ForumAgent not available", async () => {
3106 const { users, posts } = await import("@atbb/db");
3107
3108 // Create author
3109 const authorDid = "did:plc:test-hide-no-agent";
3110 await ctx.db.insert(users).values({
3111 did: authorDid,
3112 handle: "hidenoagent.test",
3113 indexedAt: new Date(),
3114 }).onConflictDoNothing();
3115
3116 const now = new Date();
3117
3118 // Insert a post
3119 const [post] = await ctx.db.insert(posts).values({
3120 did: authorDid,
3121 rkey: "3lbknoagent",
3122 cid: "bafynoagent",
3123 text: "Test post",
3124 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3125 boardUri: null,
3126 boardId: null,
3127 rootPostId: null,
3128 parentPostId: null,
3129 createdAt: now,
3130 indexedAt: now,
3131 }).returning();
3132
3133 // Remove ForumAgent
3134 ctx.forumAgent = undefined as any;
3135
3136 const res = await app.request("/api/mod/hide", {
3137 method: "POST",
3138 headers: { "Content-Type": "application/json" },
3139 body: JSON.stringify({
3140 postId: post.id.toString(),
3141 reason: "Test reason",
3142 }),
3143 });
3144
3145 expect(res.status).toBe(500);
3146 const data = await res.json();
3147 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
3148
3149 // Restore ForumAgent for other tests
3150 ctx.forumAgent = {
3151 getAgent: () => ({
3152 com: {
3153 atproto: {
3154 repo: {
3155 putRecord: mockPutRecord,
3156 },
3157 },
3158 },
3159 }),
3160 } as any;
3161 });
3162
3163 it("returns 503 when ForumAgent not authenticated", async () => {
3164 const { users, posts } = await import("@atbb/db");
3165
3166 // Create author
3167 const authorDid = "did:plc:test-hide-no-auth";
3168 await ctx.db.insert(users).values({
3169 did: authorDid,
3170 handle: "hidenoauth.test",
3171 indexedAt: new Date(),
3172 }).onConflictDoNothing();
3173
3174 const now = new Date();
3175
3176 // Insert a post
3177 const [post] = await ctx.db.insert(posts).values({
3178 did: authorDid,
3179 rkey: "3lbknoauth",
3180 cid: "bafynoauth",
3181 text: "Test post",
3182 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3183 boardUri: null,
3184 boardId: null,
3185 rootPostId: null,
3186 parentPostId: null,
3187 createdAt: now,
3188 indexedAt: now,
3189 }).returning();
3190
3191 // Mock getAgent to return null (not authenticated)
3192 const originalAgent = ctx.forumAgent;
3193 ctx.forumAgent = {
3194 getAgent: () => null,
3195 } as any;
3196
3197 const res = await app.request("/api/mod/hide", {
3198 method: "POST",
3199 headers: { "Content-Type": "application/json" },
3200 body: JSON.stringify({
3201 postId: post.id.toString(),
3202 reason: "Test reason",
3203 }),
3204 });
3205
3206 expect(res.status).toBe(503);
3207 const data = await res.json();
3208 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
3209
3210 // Restore original agent
3211 ctx.forumAgent = originalAgent;
3212 });
3213
3214 it("returns 503 for network errors writing to PDS", async () => {
3215 const { users, posts } = await import("@atbb/db");
3216
3217 // Create author
3218 const authorDid = "did:plc:test-hide-network-error";
3219 await ctx.db.insert(users).values({
3220 did: authorDid,
3221 handle: "hidenetwork.test",
3222 indexedAt: new Date(),
3223 }).onConflictDoNothing();
3224
3225 const now = new Date();
3226
3227 // Insert a post
3228 const [post] = await ctx.db.insert(posts).values({
3229 did: authorDid,
3230 rkey: "3lbknetwork",
3231 cid: "bafynetwork",
3232 text: "Test post",
3233 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3234 boardUri: null,
3235 boardId: null,
3236 rootPostId: null,
3237 parentPostId: null,
3238 createdAt: now,
3239 indexedAt: now,
3240 }).returning();
3241
3242 // Mock putRecord to throw network error
3243 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
3244
3245 const res = await app.request("/api/mod/hide", {
3246 method: "POST",
3247 headers: { "Content-Type": "application/json" },
3248 body: JSON.stringify({
3249 postId: post.id.toString(),
3250 reason: "Test reason",
3251 }),
3252 });
3253
3254 expect(res.status).toBe(503);
3255 const data = await res.json();
3256 expect(data.error).toBe("Unable to reach external service. Please try again later.");
3257 });
3258
3259 it("returns 500 for unexpected errors writing to PDS", async () => {
3260 const { users, posts } = await import("@atbb/db");
3261
3262 // Create author
3263 const authorDid = "did:plc:test-hide-server-error";
3264 await ctx.db.insert(users).values({
3265 did: authorDid,
3266 handle: "hideserver.test",
3267 indexedAt: new Date(),
3268 }).onConflictDoNothing();
3269
3270 const now = new Date();
3271
3272 // Insert a post
3273 const [post] = await ctx.db.insert(posts).values({
3274 did: authorDid,
3275 rkey: "3lbkserver",
3276 cid: "bafyserver",
3277 text: "Test post",
3278 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3279 boardUri: null,
3280 boardId: null,
3281 rootPostId: null,
3282 parentPostId: null,
3283 createdAt: now,
3284 indexedAt: now,
3285 }).returning();
3286
3287 // Mock putRecord to throw unexpected error (not network error)
3288 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
3289
3290 const res = await app.request("/api/mod/hide", {
3291 method: "POST",
3292 headers: { "Content-Type": "application/json" },
3293 body: JSON.stringify({
3294 postId: post.id.toString(),
3295 reason: "Test reason",
3296 }),
3297 });
3298
3299 expect(res.status).toBe(500);
3300 const data = await res.json();
3301 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
3302 });
3303 });
3304 });
3305
3306 describe("DELETE /api/mod/hide/:postId", () => {
3307 it("unhides post successfully", async () => {
3308 const { users, posts, modActions } = await import("@atbb/db");
3309
3310 const modDid = "did:plc:test-unhide-mod";
3311 const memberDid = "did:plc:test-unhide-member";
3312
3313 await ctx.db.insert(users).values({
3314 did: modDid,
3315 handle: "unhidemod.test",
3316 indexedAt: new Date(),
3317 });
3318
3319 await ctx.db.insert(users).values({
3320 did: memberDid,
3321 handle: "unhidemember.test",
3322 indexedAt: new Date(),
3323 });
3324
3325 const [topic] = await ctx.db.insert(posts).values({
3326 did: memberDid,
3327 rkey: "3lbkunhidetopic",
3328 cid: "bafyunhidetopic",
3329 text: "Test",
3330 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3331 boardUri: null,
3332 boardId: null,
3333 rootPostId: null,
3334 parentPostId: null,
3335 createdAt: new Date(),
3336 indexedAt: new Date(),
3337 }).returning();
3338
3339 const postUri = `at://${memberDid}/space.atbb.post/${topic.rkey}`;
3340 await ctx.db.insert(modActions).values({
3341 did: ctx.config.forumDid,
3342 rkey: "hide1",
3343 cid: "bafyhide",
3344 action: "space.atbb.modAction.delete",
3345 subjectPostUri: postUri,
3346 reason: "Original hide",
3347 createdBy: modDid,
3348 createdAt: new Date(),
3349 indexedAt: new Date(),
3350 });
3351
3352 mockUser = { did: modDid };
3353
3354 mockPutRecord.mockResolvedValueOnce({
3355 data: {
3356 uri: "at://did:plc:forum/space.atbb.modAction/unhide123",
3357 cid: "bafyunhide",
3358 },
3359 });
3360
3361 const res = await app.request(`/api/mod/hide/${topic.id}`, {
3362 method: "DELETE",
3363 headers: { "Content-Type": "application/json" },
3364 body: JSON.stringify({ reason: "False positive" }),
3365 });
3366
3367 expect(res.status).toBe(200);
3368 const data = await res.json();
3369 expect(data.success).toBe(true);
3370 expect(data.action).toBe("space.atbb.modAction.undelete");
3371 });
3372
3373 it("supports hide→unhide→hide toggle (verifies lexicon fix)", async () => {
3374 const { users, posts, modActions } = await import("@atbb/db");
3375
3376 const modDid = "did:plc:test-toggle-mod";
3377 const memberDid = "did:plc:test-toggle-member";
3378
3379 await ctx.db.insert(users).values({
3380 did: modDid,
3381 handle: "togglemod.test",
3382 indexedAt: new Date(),
3383 });
3384
3385 await ctx.db.insert(users).values({
3386 did: memberDid,
3387 handle: "togglemember.test",
3388 indexedAt: new Date(),
3389 });
3390
3391 const [topic] = await ctx.db.insert(posts).values({
3392 did: memberDid,
3393 rkey: "3lbktoggletopic",
3394 cid: "bafytoggletopic",
3395 text: "Test toggle",
3396 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3397 boardUri: null,
3398 boardId: null,
3399 rootPostId: null,
3400 parentPostId: null,
3401 createdAt: new Date(),
3402 indexedAt: new Date(),
3403 }).returning();
3404
3405 mockUser = { did: modDid };
3406 const postUri = `at://${memberDid}/space.atbb.post/${topic.rkey}`;
3407
3408 // Step 1: Hide the post (writes "delete" action)
3409 mockPutRecord.mockResolvedValueOnce({
3410 data: {
3411 uri: "at://did:plc:forum/space.atbb.modAction/hide1",
3412 cid: "bafyhide1",
3413 },
3414 });
3415
3416 const hideRes = await app.request("/api/mod/hide", {
3417 method: "POST",
3418 headers: { "Content-Type": "application/json" },
3419 body: JSON.stringify({
3420 postId: topic.id.toString(),
3421 reason: "Hide test",
3422 }),
3423 });
3424
3425 expect(hideRes.status).toBe(200);
3426 const hideData = await hideRes.json();
3427 expect(hideData.success).toBe(true);
3428 expect(hideData.action).toBe("space.atbb.modAction.delete");
3429 expect(hideData.alreadyActive).toBe(false);
3430
3431 // Manually insert the hide action to database (simulating what PDS write would do)
3432 await ctx.db.insert(modActions).values({
3433 did: ctx.config.forumDid,
3434 rkey: "hide1",
3435 cid: "bafyhide1",
3436 action: "space.atbb.modAction.delete",
3437 subjectPostUri: postUri,
3438 reason: "Hide test",
3439 createdBy: modDid,
3440 createdAt: new Date(),
3441 indexedAt: new Date(),
3442 });
3443
3444 // Step 2: Unhide the post (writes "undelete" action)
3445 mockPutRecord.mockResolvedValueOnce({
3446 data: {
3447 uri: "at://did:plc:forum/space.atbb.modAction/unhide1",
3448 cid: "bafyunhide1",
3449 },
3450 });
3451
3452 const unhideRes = await app.request(`/api/mod/hide/${topic.id}`, {
3453 method: "DELETE",
3454 headers: { "Content-Type": "application/json" },
3455 body: JSON.stringify({ reason: "Unhide test" }),
3456 });
3457
3458 expect(unhideRes.status).toBe(200);
3459 const unhideData = await unhideRes.json();
3460 expect(unhideData.success).toBe(true);
3461 expect(unhideData.action).toBe("space.atbb.modAction.undelete");
3462 expect(unhideData.alreadyActive).toBe(false);
3463
3464 // Manually insert the unhide action
3465 await ctx.db.insert(modActions).values({
3466 did: ctx.config.forumDid,
3467 rkey: "unhide1",
3468 cid: "bafyunhide1",
3469 action: "space.atbb.modAction.undelete",
3470 subjectPostUri: postUri,
3471 reason: "Unhide test",
3472 createdBy: modDid,
3473 createdAt: new Date(Date.now() + 1000), // Slightly later
3474 indexedAt: new Date(),
3475 });
3476
3477 // Step 3: Hide again (should succeed because post is now unhidden)
3478 mockPutRecord.mockResolvedValueOnce({
3479 data: {
3480 uri: "at://did:plc:forum/space.atbb.modAction/hide2",
3481 cid: "bafyhide2",
3482 },
3483 });
3484
3485 const hideRes2 = await app.request("/api/mod/hide", {
3486 method: "POST",
3487 headers: { "Content-Type": "application/json" },
3488 body: JSON.stringify({
3489 postId: topic.id.toString(),
3490 reason: "Hide again",
3491 }),
3492 });
3493
3494 expect(hideRes2.status).toBe(200);
3495 const hideData2 = await hideRes2.json();
3496 expect(hideData2.success).toBe(true);
3497 expect(hideData2.action).toBe("space.atbb.modAction.delete");
3498 expect(hideData2.alreadyActive).toBe(false); // Critical: proves toggle works
3499 });
3500
3501 describe("Authorization", () => {
3502 it("returns 401 when not authenticated", async () => {
3503 const { users, memberships, posts } = await import("@atbb/db");
3504
3505 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
3506 const authorDid = "did:plc:test-unhide-auth";
3507 await ctx.db.insert(users).values({
3508 did: authorDid,
3509 handle: "unhideauth.test",
3510 indexedAt: new Date(),
3511 }).onConflictDoNothing();
3512
3513 await ctx.db.insert(memberships).values({
3514 did: authorDid,
3515 rkey: "self",
3516 cid: "bafyunhideauth",
3517 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3518 roleUri: null,
3519 joinedAt: new Date(),
3520 createdAt: new Date(),
3521 indexedAt: new Date(),
3522 }).onConflictDoNothing();
3523
3524 const [post] = await ctx.db.insert(posts).values({
3525 did: authorDid,
3526 rkey: "3lbkunhideauth",
3527 cid: "bafyunhideauth",
3528 text: "Test post for auth",
3529 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3530 boardUri: null,
3531 boardId: null,
3532 rootPostId: null,
3533 parentPostId: null,
3534 createdAt: new Date(),
3535 indexedAt: new Date(),
3536 }).returning();
3537
3538 // Mock requireAuth to return 401
3539 const { requireAuth } = await import("../../middleware/auth.js");
3540 const mockRequireAuth = requireAuth as any;
3541 mockRequireAuth.mockImplementation(() => async (c: any) => {
3542 return c.json({ error: "Unauthorized" }, 401);
3543 });
3544
3545 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
3546
3547 const res = await testApp.request(`/api/mod/hide/${post.id}`, {
3548 method: "DELETE",
3549 headers: { "Content-Type": "application/json" },
3550 body: JSON.stringify({
3551 reason: "Test reason",
3552 }),
3553 });
3554
3555 expect(res.status).toBe(401);
3556
3557 // Restore default mock for subsequent tests
3558 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
3559 c.set("user", mockUser);
3560 await next();
3561 });
3562 });
3563
3564 it("returns 403 when user lacks moderatePosts permission", async () => {
3565 const { users, memberships, posts } = await import("@atbb/db");
3566 const { requirePermission } = await import("../../middleware/permissions.js");
3567
3568 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
3569 const authorDid = "did:plc:test-unhide-perm";
3570 await ctx.db.insert(users).values({
3571 did: authorDid,
3572 handle: "unhideperm.test",
3573 indexedAt: new Date(),
3574 }).onConflictDoNothing();
3575
3576 await ctx.db.insert(memberships).values({
3577 did: authorDid,
3578 rkey: "self",
3579 cid: "bafyunhideperm",
3580 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3581 roleUri: null,
3582 joinedAt: new Date(),
3583 createdAt: new Date(),
3584 indexedAt: new Date(),
3585 }).onConflictDoNothing();
3586
3587 const [post] = await ctx.db.insert(posts).values({
3588 did: authorDid,
3589 rkey: "3lbkunhideperm",
3590 cid: "bafyunhideperm",
3591 text: "Test post for permission",
3592 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3593 boardUri: null,
3594 boardId: null,
3595 rootPostId: null,
3596 parentPostId: null,
3597 createdAt: new Date(),
3598 indexedAt: new Date(),
3599 }).returning();
3600
3601 // Mock requirePermission to deny access
3602 const mockRequirePermission = requirePermission as any;
3603 mockRequirePermission.mockImplementation(() => async (c: any) => {
3604 return c.json({ error: "Forbidden" }, 403);
3605 });
3606
3607 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
3608
3609 const res = await testApp.request(`/api/mod/hide/${post.id}`, {
3610 method: "DELETE",
3611 headers: { "Content-Type": "application/json" },
3612 body: JSON.stringify({
3613 reason: "Test reason",
3614 }),
3615 });
3616
3617 expect(res.status).toBe(403);
3618
3619 // Restore default mock for subsequent tests
3620 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
3621 await next();
3622 });
3623 });
3624 });
3625
3626 describe("Input Validation", () => {
3627 beforeEach(() => {
3628 mockUser = { did: "did:plc:test-moderator" };
3629 });
3630
3631 it("returns 400 for invalid postId param format", async () => {
3632 const res = await app.request("/api/mod/hide/not-a-number", {
3633 method: "DELETE",
3634 headers: { "Content-Type": "application/json" },
3635 body: JSON.stringify({ reason: "Test reason" }),
3636 });
3637
3638 expect(res.status).toBe(400);
3639 const data = await res.json();
3640 expect(data.error).toBe("Invalid post ID");
3641 });
3642
3643 it("returns 400 for malformed JSON in request body", async () => {
3644 const res = await app.request("/api/mod/hide/123456", {
3645 method: "DELETE",
3646 headers: { "Content-Type": "application/json" },
3647 body: "{ invalid json }",
3648 });
3649
3650 expect(res.status).toBe(400);
3651 const data = await res.json();
3652 expect(data.error).toBe("Invalid JSON in request body");
3653 });
3654
3655 it("returns 400 when reason is missing", async () => {
3656 const res = await app.request("/api/mod/hide/123456", {
3657 method: "DELETE",
3658 headers: { "Content-Type": "application/json" },
3659 body: JSON.stringify({
3660 // reason missing
3661 }),
3662 });
3663
3664 expect(res.status).toBe(400);
3665 const data = await res.json();
3666 expect(data.error).toBe("Reason is required and must be a string");
3667 });
3668
3669 it("returns 400 when reason is empty string", async () => {
3670 const res = await app.request("/api/mod/hide/123456", {
3671 method: "DELETE",
3672 headers: { "Content-Type": "application/json" },
3673 body: JSON.stringify({
3674 reason: " ", // whitespace only
3675 }),
3676 });
3677
3678 expect(res.status).toBe(400);
3679 const data = await res.json();
3680 expect(data.error).toBe("Reason is required and must not be empty");
3681 });
3682 });
3683
3684 describe("Business Logic", () => {
3685 beforeEach(() => {
3686 mockUser = { did: "did:plc:test-moderator" };
3687 });
3688
3689 it("returns 404 when post does not exist", async () => {
3690 const res = await app.request("/api/mod/hide/999999999", {
3691 method: "DELETE",
3692 headers: { "Content-Type": "application/json" },
3693 body: JSON.stringify({ reason: "Test reason" }),
3694 });
3695
3696 expect(res.status).toBe(404);
3697 const data = await res.json();
3698 expect(data.error).toBe("Post not found");
3699 });
3700
3701 it("returns 200 with alreadyActive: true when post is already unhidden (idempotency)", async () => {
3702 const { users, posts } = await import("@atbb/db");
3703
3704 // Create author
3705 const authorDid = "did:plc:test-unhide-already-unhidden";
3706 await ctx.db.insert(users).values({
3707 did: authorDid,
3708 handle: "alreadyunhidden.test",
3709 indexedAt: new Date(),
3710 }).onConflictDoNothing();
3711
3712 const now = new Date();
3713
3714 // Insert a post (no hide action = already unhidden)
3715 const [post] = await ctx.db.insert(posts).values({
3716 did: authorDid,
3717 rkey: "3lbkunhidden",
3718 cid: "bafyunhidden",
3719 text: "Not hidden post",
3720 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3721 boardUri: null,
3722 boardId: null,
3723 rootPostId: null,
3724 parentPostId: null,
3725 createdAt: now,
3726 indexedAt: now,
3727 }).returning();
3728
3729 // Attempt to unhide (no existing hide action)
3730 const res = await app.request(`/api/mod/hide/${post.id}`, {
3731 method: "DELETE",
3732 headers: { "Content-Type": "application/json" },
3733 body: JSON.stringify({ reason: "Trying to unhide already visible post" }),
3734 });
3735
3736 expect(res.status).toBe(200);
3737 const data = await res.json();
3738 expect(data.success).toBe(true);
3739 expect(data.alreadyActive).toBe(true);
3740 expect(data.uri).toBeNull();
3741 expect(data.cid).toBeNull();
3742
3743 // Verify putRecord was NOT called (no duplicate action written)
3744 expect(mockPutRecord).not.toHaveBeenCalled();
3745 });
3746 });
3747
3748 describe("Infrastructure Errors", () => {
3749 beforeEach(() => {
3750 mockUser = { did: "did:plc:test-moderator" };
3751 });
3752
3753 it("returns 503 when post query fails (database error)", async () => {
3754 // Mock console.error to suppress error output during test
3755 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3756
3757 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
3758 throw new Error("Database connection lost");
3759 });
3760
3761 const res = await app.request("/api/mod/hide/999999999", {
3762 method: "DELETE",
3763 headers: { "Content-Type": "application/json" },
3764 body: JSON.stringify({
3765 reason: "Test reason",
3766 }),
3767 });
3768
3769 expect(res.status).toBe(503);
3770 const data = await res.json();
3771 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
3772
3773 consoleErrorSpy.mockRestore();
3774 dbSelectSpy.mockRestore();
3775 });
3776
3777 it("returns 500 when ForumAgent not available", async () => {
3778 const { users, posts, forums, modActions } = await import("@atbb/db");
3779 const { eq } = await import("drizzle-orm");
3780
3781 // Create author
3782 const authorDid = "did:plc:test-unhide-no-agent";
3783 await ctx.db.insert(users).values({
3784 did: authorDid,
3785 handle: "unhidenoagent.test",
3786 indexedAt: new Date(),
3787 }).onConflictDoNothing();
3788
3789 const now = new Date();
3790
3791 // Insert a post
3792 const [post] = await ctx.db.insert(posts).values({
3793 did: authorDid,
3794 rkey: "3lbknoagent2",
3795 cid: "bafynoagent2",
3796 text: "Test post",
3797 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3798 boardUri: null,
3799 boardId: null,
3800 rootPostId: null,
3801 parentPostId: null,
3802 createdAt: now,
3803 indexedAt: now,
3804 }).returning();
3805
3806 // Get forum ID
3807 const [forum] = await ctx.db
3808 .select()
3809 .from(forums)
3810 .where(eq(forums.did, ctx.config.forumDid))
3811 .limit(1);
3812
3813 // Insert existing hide action
3814 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3815 await ctx.db.insert(modActions).values({
3816 did: ctx.config.forumDid,
3817 rkey: "hide-for-unhide-test",
3818 cid: "bafyhide",
3819 action: "space.atbb.modAction.delete",
3820 subjectDid: null,
3821 subjectPostUri: postUri,
3822 forumId: forum.id,
3823 reason: "Hidden",
3824 createdBy: "did:plc:test-mod",
3825 expiresAt: null,
3826 createdAt: new Date(now.getTime() - 1000),
3827 indexedAt: new Date(now.getTime() - 1000),
3828 });
3829
3830 // Remove ForumAgent
3831 ctx.forumAgent = undefined as any;
3832
3833 const res = await app.request(`/api/mod/hide/${post.id}`, {
3834 method: "DELETE",
3835 headers: { "Content-Type": "application/json" },
3836 body: JSON.stringify({ reason: "Test reason" }),
3837 });
3838
3839 expect(res.status).toBe(500);
3840 const data = await res.json();
3841 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
3842
3843 // Restore ForumAgent for other tests
3844 ctx.forumAgent = {
3845 getAgent: () => ({
3846 com: {
3847 atproto: {
3848 repo: {
3849 putRecord: mockPutRecord,
3850 },
3851 },
3852 },
3853 }),
3854 } as any;
3855 });
3856
3857 it("returns 503 when ForumAgent not authenticated", async () => {
3858 const { users, posts, forums, modActions } = await import("@atbb/db");
3859 const { eq } = await import("drizzle-orm");
3860
3861 // Create author
3862 const authorDid = "did:plc:test-unhide-no-auth";
3863 await ctx.db.insert(users).values({
3864 did: authorDid,
3865 handle: "unhidenoauth.test",
3866 indexedAt: new Date(),
3867 }).onConflictDoNothing();
3868
3869 const now = new Date();
3870
3871 // Insert a post
3872 const [post] = await ctx.db.insert(posts).values({
3873 did: authorDid,
3874 rkey: "3lbknoauth2",
3875 cid: "bafynoauth2",
3876 text: "Test post",
3877 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3878 boardUri: null,
3879 boardId: null,
3880 rootPostId: null,
3881 parentPostId: null,
3882 createdAt: now,
3883 indexedAt: now,
3884 }).returning();
3885
3886 // Get forum ID
3887 const [forum] = await ctx.db
3888 .select()
3889 .from(forums)
3890 .where(eq(forums.did, ctx.config.forumDid))
3891 .limit(1);
3892
3893 // Insert existing hide action
3894 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3895 await ctx.db.insert(modActions).values({
3896 did: ctx.config.forumDid,
3897 rkey: "hide-for-unhide-auth-test",
3898 cid: "bafyhide",
3899 action: "space.atbb.modAction.delete",
3900 subjectDid: null,
3901 subjectPostUri: postUri,
3902 forumId: forum.id,
3903 reason: "Hidden",
3904 createdBy: "did:plc:test-mod",
3905 expiresAt: null,
3906 createdAt: new Date(now.getTime() - 1000),
3907 indexedAt: new Date(now.getTime() - 1000),
3908 });
3909
3910 // Mock getAgent to return null (not authenticated)
3911 const originalAgent = ctx.forumAgent;
3912 ctx.forumAgent = {
3913 getAgent: () => null,
3914 } as any;
3915
3916 const res = await app.request(`/api/mod/hide/${post.id}`, {
3917 method: "DELETE",
3918 headers: { "Content-Type": "application/json" },
3919 body: JSON.stringify({ reason: "Test reason" }),
3920 });
3921
3922 expect(res.status).toBe(503);
3923 const data = await res.json();
3924 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
3925
3926 // Restore original agent
3927 ctx.forumAgent = originalAgent;
3928 });
3929
3930 it("returns 503 for network errors writing to PDS", async () => {
3931 const { users, posts, forums, modActions } = await import("@atbb/db");
3932 const { eq } = await import("drizzle-orm");
3933
3934 // Create author
3935 const authorDid = "did:plc:test-unhide-network-error";
3936 await ctx.db.insert(users).values({
3937 did: authorDid,
3938 handle: "unhidenetwork.test",
3939 indexedAt: new Date(),
3940 }).onConflictDoNothing();
3941
3942 const now = new Date();
3943
3944 // Insert a post
3945 const [post] = await ctx.db.insert(posts).values({
3946 did: authorDid,
3947 rkey: "3lbknetwork2",
3948 cid: "bafynetwork2",
3949 text: "Test post",
3950 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3951 boardUri: null,
3952 boardId: null,
3953 rootPostId: null,
3954 parentPostId: null,
3955 createdAt: now,
3956 indexedAt: now,
3957 }).returning();
3958
3959 // Get forum ID
3960 const [forum] = await ctx.db
3961 .select()
3962 .from(forums)
3963 .where(eq(forums.did, ctx.config.forumDid))
3964 .limit(1);
3965
3966 // Insert existing hide action
3967 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3968 await ctx.db.insert(modActions).values({
3969 did: ctx.config.forumDid,
3970 rkey: "hide-for-unhide-network-test",
3971 cid: "bafyhide",
3972 action: "space.atbb.modAction.delete",
3973 subjectDid: null,
3974 subjectPostUri: postUri,
3975 forumId: forum.id,
3976 reason: "Hidden",
3977 createdBy: "did:plc:test-mod",
3978 expiresAt: null,
3979 createdAt: new Date(now.getTime() - 1000),
3980 indexedAt: new Date(now.getTime() - 1000),
3981 });
3982
3983 // Mock putRecord to throw network error
3984 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
3985
3986 const res = await app.request(`/api/mod/hide/${post.id}`, {
3987 method: "DELETE",
3988 headers: { "Content-Type": "application/json" },
3989 body: JSON.stringify({ reason: "Test reason" }),
3990 });
3991
3992 expect(res.status).toBe(503);
3993 const data = await res.json();
3994 expect(data.error).toBe("Unable to reach external service. Please try again later.");
3995 });
3996
3997 it("returns 500 for unexpected errors writing to PDS", async () => {
3998 const { users, posts, forums, modActions } = await import("@atbb/db");
3999 const { eq } = await import("drizzle-orm");
4000
4001 // Create author
4002 const authorDid = "did:plc:test-unhide-server-error";
4003 await ctx.db.insert(users).values({
4004 did: authorDid,
4005 handle: "unhideserver.test",
4006 indexedAt: new Date(),
4007 }).onConflictDoNothing();
4008
4009 const now = new Date();
4010
4011 // Insert a post
4012 const [post] = await ctx.db.insert(posts).values({
4013 did: authorDid,
4014 rkey: "3lbkserver2",
4015 cid: "bafyserver2",
4016 text: "Test post",
4017 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
4018 boardUri: null,
4019 boardId: null,
4020 rootPostId: null,
4021 parentPostId: null,
4022 createdAt: now,
4023 indexedAt: now,
4024 }).returning();
4025
4026 // Get forum ID
4027 const [forum] = await ctx.db
4028 .select()
4029 .from(forums)
4030 .where(eq(forums.did, ctx.config.forumDid))
4031 .limit(1);
4032
4033 // Insert existing hide action
4034 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
4035 await ctx.db.insert(modActions).values({
4036 did: ctx.config.forumDid,
4037 rkey: "hide-for-unhide-server-test",
4038 cid: "bafyhide",
4039 action: "space.atbb.modAction.delete",
4040 subjectDid: null,
4041 subjectPostUri: postUri,
4042 forumId: forum.id,
4043 reason: "Hidden",
4044 createdBy: "did:plc:test-mod",
4045 expiresAt: null,
4046 createdAt: new Date(now.getTime() - 1000),
4047 indexedAt: new Date(now.getTime() - 1000),
4048 });
4049
4050 // Mock putRecord to throw unexpected error (not network error)
4051 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
4052
4053 const res = await app.request(`/api/mod/hide/${post.id}`, {
4054 method: "DELETE",
4055 headers: { "Content-Type": "application/json" },
4056 body: JSON.stringify({ reason: "Test reason" }),
4057 });
4058
4059 expect(res.status).toBe(500);
4060 const data = await res.json();
4061 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
4062 });
4063 });
4064 });
4065 });
4066
4067 describe("Helper: validateReason", () => {
4068 it("returns null for valid reason", () => {
4069 const result = validateReason("User violated community guidelines");
4070 expect(result).toBeNull();
4071 });
4072
4073 it("returns error for non-string reason", () => {
4074 const result = validateReason(123);
4075 expect(result).toBe("Reason is required and must be a string");
4076 });
4077
4078 it("returns error for empty/whitespace reason", () => {
4079 expect(validateReason("")).toBe("Reason is required and must not be empty");
4080 expect(validateReason(" ")).toBe("Reason is required and must not be empty");
4081 expect(validateReason("\t\n")).toBe("Reason is required and must not be empty");
4082 });
4083
4084 it("returns error for reason exceeding 3000 characters", () => {
4085 const longReason = "a".repeat(3001);
4086 const result = validateReason(longReason);
4087 expect(result).toBe("Reason must not exceed 3000 characters");
4088 });
4089 });
4090
4091 describe("Helper: checkActiveAction", () => {
4092 let ctx: TestContext;
4093
4094 beforeEach(async () => {
4095 ctx = await createTestContext();
4096 });
4097
4098 afterEach(async () => {
4099 await ctx.cleanup();
4100 });
4101
4102 it("returns null when no actions exist for subject", async () => {
4103 const result = await checkActiveAction(
4104 ctx,
4105 { did: "did:plc:nonexistent" },
4106 "ban"
4107 );
4108 expect(result).toBeNull();
4109 });
4110
4111 it("returns true when action is active (most recent action matches)", async () => {
4112 // Get forum ID from database
4113 const { forums, modActions } = await import("@atbb/db");
4114 const { eq } = await import("drizzle-orm");
4115 const [forum] = await ctx.db
4116 .select()
4117 .from(forums)
4118 .where(eq(forums.did, ctx.config.forumDid))
4119 .limit(1);
4120
4121 // Insert a ban action
4122 await ctx.db.insert(modActions).values({
4123 did: ctx.config.forumDid,
4124 rkey: "test-ban-1",
4125 cid: "bafytest1",
4126 action: "ban",
4127 subjectDid: "did:plc:testuser",
4128 subjectPostUri: null,
4129 forumId: forum.id,
4130 reason: "Violating rules",
4131 createdBy: "did:plc:moderator",
4132 expiresAt: null,
4133 createdAt: new Date(),
4134 indexedAt: new Date(),
4135 });
4136
4137 const result = await checkActiveAction(
4138 ctx,
4139 { did: "did:plc:testuser" },
4140 "ban"
4141 );
4142 expect(result).toBe(true);
4143 });
4144
4145 it("returns false when action is reversed (unban after ban)", async () => {
4146 // Get forum ID from database
4147 const { forums, modActions } = await import("@atbb/db");
4148 const { eq } = await import("drizzle-orm");
4149 const [forum] = await ctx.db
4150 .select()
4151 .from(forums)
4152 .where(eq(forums.did, ctx.config.forumDid))
4153 .limit(1);
4154
4155 // Insert a ban action first
4156 const now = new Date();
4157 const earlier = new Date(now.getTime() - 1000);
4158
4159 await ctx.db.insert(modActions).values({
4160 did: ctx.config.forumDid,
4161 rkey: "test-ban-2",
4162 cid: "bafytest2",
4163 action: "ban",
4164 subjectDid: "did:plc:testuser2",
4165 subjectPostUri: null,
4166 forumId: forum.id,
4167 reason: "Violating rules",
4168 createdBy: "did:plc:moderator",
4169 expiresAt: null,
4170 createdAt: earlier,
4171 indexedAt: earlier,
4172 });
4173
4174 // Insert an unban action (more recent)
4175 await ctx.db.insert(modActions).values({
4176 did: ctx.config.forumDid,
4177 rkey: "test-unban-2",
4178 cid: "bafytest3",
4179 action: "unban",
4180 subjectDid: "did:plc:testuser2",
4181 subjectPostUri: null,
4182 forumId: forum.id,
4183 reason: "Appeal approved",
4184 createdBy: "did:plc:admin",
4185 expiresAt: null,
4186 createdAt: now,
4187 indexedAt: now,
4188 });
4189
4190 const result = await checkActiveAction(
4191 ctx,
4192 { did: "did:plc:testuser2" },
4193 "ban"
4194 );
4195 expect(result).toBe(false);
4196 });
4197
4198 it("returns null when database query fails (fail-safe behavior)", async () => {
4199 const loggerErrorSpy = vi.spyOn(ctx.logger, "error");
4200
4201 // Mock database query to throw error
4202 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
4203 throw new Error("Database connection lost");
4204 });
4205
4206 const result = await checkActiveAction(
4207 ctx,
4208 { did: "did:plc:testuser" },
4209 "ban"
4210 );
4211
4212 // Should return null (fail-safe) instead of throwing
4213 expect(result).toBeNull();
4214
4215 // Should log the error
4216 expect(loggerErrorSpy).toHaveBeenCalledWith(
4217 "Failed to check active moderation action",
4218 expect.objectContaining({
4219 operation: "checkActiveAction",
4220 actionType: "ban",
4221 })
4222 );
4223
4224 // Restore mocks
4225 dbSelectSpy.mockRestore();
4226 loggerErrorSpy.mockRestore();
4227 });
4228
4229 it("re-throws programming errors after logging them as CRITICAL", async () => {
4230 const loggerErrorSpy = vi.spyOn(ctx.logger, "error");
4231
4232 // Mock database query to throw TypeError (programming error)
4233 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
4234 throw new TypeError("Cannot read property 'includes' of undefined");
4235 });
4236
4237 // Should re-throw the TypeError, not return null
4238 await expect(
4239 checkActiveAction(ctx, { did: "did:plc:testuser" }, "ban")
4240 ).rejects.toThrow(TypeError);
4241
4242 // Should log the error with CRITICAL prefix before re-throwing
4243 expect(loggerErrorSpy).toHaveBeenCalledWith(
4244 "CRITICAL: Programming error in checkActiveAction",
4245 expect.objectContaining({
4246 operation: "checkActiveAction",
4247 actionType: "ban",
4248 })
4249 );
4250
4251 // Restore mocks
4252 dbSelectSpy.mockRestore();
4253 loggerErrorSpy.mockRestore();
4254 });
4255 });
4256});