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, vi } from "vitest";
2import { Indexer } from "../indexer.js";
3import { createMockLogger } from "./mock-logger.js";
4import type { Database } from "@atbb/db";
5import { memberships } from "@atbb/db";
6import type { CommitCreateEvent, CommitUpdateEvent, CommitDeleteEvent } from "@skyware/jetstream";
7
8vi.mock("../ban-enforcer.js", () => ({
9 BanEnforcer: vi.fn().mockImplementation(() => ({
10 isBanned: vi.fn().mockResolvedValue(false),
11 applyBan: vi.fn().mockResolvedValue(undefined),
12 liftBan: vi.fn().mockResolvedValue(undefined),
13 })),
14}));
15
16
17// Mock database
18const createMockDb = () => {
19 const mockInsert = vi.fn().mockReturnValue({
20 values: vi.fn().mockResolvedValue(undefined),
21 });
22
23 const mockUpdate = vi.fn().mockReturnValue({
24 set: vi.fn().mockReturnValue({
25 where: vi.fn().mockResolvedValue(undefined),
26 }),
27 });
28
29 const mockDelete = vi.fn().mockReturnValue({
30 where: vi.fn().mockResolvedValue(undefined),
31 });
32
33 const mockSelect = vi.fn().mockReturnValue({
34 from: vi.fn().mockReturnValue({
35 where: vi.fn().mockReturnValue({
36 limit: vi.fn().mockResolvedValue([]),
37 }),
38 }),
39 });
40
41 const mockTransaction = vi.fn().mockImplementation(async (callback) => {
42 // Create a transaction context that has the same methods as the db
43 const txContext = {
44 insert: mockInsert,
45 update: mockUpdate,
46 delete: mockDelete,
47 select: mockSelect,
48 };
49 // Execute the callback with the transaction context
50 return await callback(txContext);
51 });
52
53 return {
54 insert: mockInsert,
55 update: mockUpdate,
56 delete: mockDelete,
57 select: mockSelect,
58 transaction: mockTransaction,
59 } as unknown as Database;
60};
61
62/**
63 * Builds a mock DB whose transaction captures values passed to insert/update.
64 * selectResults controls what FK lookup queries return (e.g. board/forum IDs).
65 */
66function createTrackingDb(selectResults: any[] = []) {
67 let insertedValues: any = null;
68 let updatedValues: any = null;
69
70 const db = createMockDb();
71 db.transaction = vi.fn().mockImplementation(async (callback) => {
72 const txContext = {
73 insert: vi.fn().mockImplementation((_table: any) => ({
74 values: vi.fn().mockImplementation((vals: any) => {
75 insertedValues = vals;
76 return Promise.resolve(undefined);
77 }),
78 })),
79 select: vi.fn().mockReturnValue({
80 from: vi.fn().mockReturnValue({
81 where: vi.fn().mockReturnValue({
82 limit: vi.fn().mockResolvedValue(selectResults),
83 }),
84 }),
85 }),
86 update: vi.fn().mockImplementation((_table: any) => ({
87 set: vi.fn().mockImplementation((vals: any) => {
88 updatedValues = vals;
89 return { where: vi.fn().mockResolvedValue(undefined) };
90 }),
91 })),
92 delete: vi.fn(),
93 };
94 return await callback(txContext);
95 });
96
97 return {
98 db,
99 getInsertedValues: () => insertedValues,
100 getUpdatedValues: () => updatedValues,
101 };
102}
103
104describe("Indexer", () => {
105 let mockDb: Database;
106 let indexer: Indexer;
107 let mockLogger: ReturnType<typeof createMockLogger>;
108
109 beforeEach(() => {
110 vi.clearAllMocks();
111 mockDb = createMockDb();
112 mockLogger = createMockLogger();
113 indexer = new Indexer(mockDb, mockLogger);
114 });
115
116 describe("Post Handler", () => {
117 it("should handle post creation with minimal fields", async () => {
118
119 const event: CommitCreateEvent<"space.atbb.post"> = {
120 did: "did:plc:test123",
121 time_us: 1234567890,
122 kind: "commit",
123 commit: {
124 rev: "abc",
125 operation: "create",
126 collection: "space.atbb.post",
127 rkey: "post1",
128 cid: "cid123",
129 record: {
130 $type: "space.atbb.post",
131 text: "Hello world",
132 createdAt: "2024-01-01T00:00:00Z",
133 } as any,
134 },
135 };
136
137 await indexer.handlePostCreate(event);
138
139 expect(mockDb.insert).toHaveBeenCalled();
140 });
141
142 it("should handle post creation with forum reference", async () => {
143
144 const event: CommitCreateEvent<"space.atbb.post"> = {
145 did: "did:plc:test123",
146 time_us: 1234567890,
147 kind: "commit",
148 commit: {
149 rev: "abc",
150 operation: "create",
151 collection: "space.atbb.post",
152 rkey: "post1",
153 cid: "cid123",
154 record: {
155 $type: "space.atbb.post",
156 text: "Hello world",
157 forum: {
158 forum: {
159 uri: "at://did:plc:forum/space.atbb.forum/self",
160 cid: "cidForum",
161 },
162 },
163 createdAt: "2024-01-01T00:00:00Z",
164 } as any,
165 },
166 };
167
168 await indexer.handlePostCreate(event);
169
170 expect(mockDb.insert).toHaveBeenCalled();
171 });
172
173 it("should handle post creation with board reference", async () => {
174 const mockBoardId = BigInt(123);
175 const { db: trackingDb, getInsertedValues } = createTrackingDb([{ id: mockBoardId }]);
176 const boardIndexer = new Indexer(trackingDb, mockLogger);
177 const boardUri = "at://did:plc:forum/space.atbb.forum.board/board1";
178
179 const event: CommitCreateEvent<"space.atbb.post"> = {
180 did: "did:plc:test123",
181 time_us: 1234567890,
182 kind: "commit",
183 commit: {
184 rev: "abc",
185 operation: "create",
186 collection: "space.atbb.post",
187 rkey: "post1",
188 cid: "cid123",
189 record: {
190 $type: "space.atbb.post",
191 text: "Hello world in a board",
192 forum: {
193 forum: {
194 uri: "at://did:plc:forum/space.atbb.forum.forum/self",
195 cid: "cidForum",
196 },
197 },
198 board: {
199 board: {
200 uri: boardUri,
201 cid: "cidBoard",
202 },
203 },
204 createdAt: "2024-01-01T00:00:00Z",
205 } as any,
206 },
207 };
208
209 await boardIndexer.handlePostCreate(event);
210
211 // Verify the insert values include boardUri and boardId
212 const insertedValues = getInsertedValues();
213 expect(insertedValues).toBeDefined();
214 expect(insertedValues.boardUri).toBe(boardUri);
215 expect(insertedValues.boardId).toBe(mockBoardId);
216 });
217
218 it("should handle post creation with reply references", async () => {
219
220
221 const event: CommitCreateEvent<"space.atbb.post"> = {
222 did: "did:plc:test123",
223 time_us: 1234567890,
224 kind: "commit",
225 commit: {
226 rev: "abc",
227 operation: "create",
228 collection: "space.atbb.post",
229 rkey: "post2",
230 cid: "cid456",
231 record: {
232 $type: "space.atbb.post",
233 text: "Reply text",
234 reply: {
235 root: {
236 uri: "at://did:plc:user1/space.atbb.post/post1",
237 cid: "cidRoot",
238 },
239 parent: {
240 uri: "at://did:plc:user1/space.atbb.post/post1",
241 cid: "cidParent",
242 },
243 },
244 createdAt: "2024-01-01T01:00:00Z",
245 } as any,
246 },
247 };
248
249 await indexer.handlePostCreate(event);
250
251 expect(mockDb.insert).toHaveBeenCalled();
252 });
253
254 it("resolves rootPostId and parentPostId when reply ref has correct $type", async () => {
255 const mockPostId = 99n;
256 const { db: trackingDb, getInsertedValues } = createTrackingDb([{ id: mockPostId }]);
257 const replyIndexer = new Indexer(trackingDb, mockLogger);
258 const rootUri = "at://did:plc:user1/space.atbb.post/topic1";
259 const parentUri = "at://did:plc:user1/space.atbb.post/reply1";
260
261 const event: CommitCreateEvent<"space.atbb.post"> = {
262 did: "did:plc:test123",
263 time_us: 1234567890,
264 kind: "commit",
265 commit: {
266 rev: "abc",
267 operation: "create",
268 collection: "space.atbb.post",
269 rkey: "reply2",
270 cid: "cid789",
271 record: {
272 $type: "space.atbb.post",
273 text: "A properly-typed reply",
274 reply: {
275 $type: "space.atbb.post#replyRef",
276 root: { uri: rootUri, cid: "cidRoot" },
277 parent: { uri: parentUri, cid: "cidParent" },
278 },
279 createdAt: "2024-01-01T02:00:00Z",
280 } as any,
281 },
282 };
283
284 await replyIndexer.handlePostCreate(event);
285
286 const vals = getInsertedValues();
287 expect(vals).toBeDefined();
288 // Correctly-typed reply ref: IDs must be resolved to non-null values
289 expect(vals.rootPostId).toBe(mockPostId);
290 expect(vals.parentPostId).toBe(mockPostId);
291 expect(vals.rootUri).toBe(rootUri);
292 expect(vals.parentUri).toBe(parentUri);
293 // No error should be logged for a well-formed reply
294 expect(mockLogger.error).not.toHaveBeenCalledWith(
295 expect.stringContaining("reply ref missing $type"),
296 expect.any(Object)
297 );
298 });
299
300 it("strips title when indexing a reply on create (title: null regardless of record)", async () => {
301 const { db: trackingDb, getInsertedValues } = createTrackingDb();
302 const replyIndexer = new Indexer(trackingDb, mockLogger);
303 const rootParentUri = "at://did:plc:user1/space.atbb.post/topic1";
304
305 const event: CommitCreateEvent<"space.atbb.post"> = {
306 did: "did:plc:test123",
307 time_us: 1234567890,
308 kind: "commit",
309 commit: {
310 rev: "abc",
311 operation: "create",
312 collection: "space.atbb.post",
313 rkey: "reply1",
314 cid: "cid456",
315 record: {
316 $type: "space.atbb.post",
317 text: "Reply with a title (should be stripped)",
318 title: "This should not be stored",
319 // No $type on the reply ref — isReplyRef() returns false;
320 // rootPostId/parentPostId are null while rootUri/parentUri are populated via optional chaining.
321 reply: {
322 root: { uri: rootParentUri, cid: "cidRoot" },
323 parent: { uri: rootParentUri, cid: "cidParent" },
324 },
325 createdAt: "2024-01-01T01:00:00Z",
326 } as any,
327 },
328 };
329
330 await replyIndexer.handlePostCreate(event);
331
332 const vals = getInsertedValues();
333 expect(vals).toBeDefined();
334 expect(vals.title).toBeNull();
335 // $type-less reply ref: IDs not resolved, URIs populated via optional chaining
336 expect(vals.rootPostId).toBeNull();
337 expect(vals.parentPostId).toBeNull();
338 expect(vals.rootUri).toBe(rootParentUri);
339 expect(vals.parentUri).toBe(rootParentUri);
340 // Operators must be alerted — a post with null thread IDs is silently unreachable
341 expect(mockLogger.error).toHaveBeenCalledWith(
342 expect.stringContaining("reply ref missing $type"),
343 expect.objectContaining({ errorId: "POST_REPLY_REF_MISSING_TYPE" })
344 );
345 });
346
347 it("strips title when indexing a reply on update (title: null regardless of record)", async () => {
348 const { db: trackingDb, getUpdatedValues } = createTrackingDb();
349 const replyIndexer = new Indexer(trackingDb, mockLogger);
350
351 const updateEvent: CommitUpdateEvent<"space.atbb.post"> = {
352 did: "did:plc:test123",
353 time_us: 1234567890,
354 kind: "commit",
355 commit: {
356 rev: "abc",
357 operation: "update",
358 collection: "space.atbb.post",
359 rkey: "reply1",
360 cid: "cid789",
361 record: {
362 $type: "space.atbb.post",
363 text: "Updated reply text",
364 title: "Title that should be stripped on update",
365 reply: {
366 root: { uri: "at://did:plc:user1/space.atbb.post/topic1", cid: "cidRoot" },
367 parent: { uri: "at://did:plc:user1/space.atbb.post/topic1", cid: "cidParent" },
368 },
369 createdAt: "2024-01-01T01:00:00Z",
370 } as any,
371 },
372 };
373
374 await replyIndexer.handlePostUpdate(updateEvent);
375
376 const vals = getUpdatedValues();
377 expect(vals).toBeDefined();
378 expect(vals.title).toBeNull();
379 });
380
381 it("preserves title when indexing a topic starter on create", async () => {
382 const { db: trackingDb, getInsertedValues } = createTrackingDb();
383 const topicIndexer = new Indexer(trackingDb, mockLogger);
384
385 const event: CommitCreateEvent<"space.atbb.post"> = {
386 did: "did:plc:test123",
387 time_us: 1234567890,
388 kind: "commit",
389 commit: {
390 rev: "abc",
391 operation: "create",
392 collection: "space.atbb.post",
393 rkey: "topic1",
394 cid: "cid111",
395 record: {
396 $type: "space.atbb.post",
397 text: "Topic body text",
398 title: "My topic title",
399 createdAt: "2024-01-01T00:00:00Z",
400 } as any,
401 },
402 };
403
404 await topicIndexer.handlePostCreate(event);
405
406 const vals = getInsertedValues();
407 expect(vals).toBeDefined();
408 expect(vals.title).toBe("My topic title");
409 expect(vals.rootPostId).toBeNull();
410 expect(vals.parentPostId).toBeNull();
411 expect(vals.rootUri).toBeNull();
412 expect(vals.parentUri).toBeNull();
413 });
414
415 it("preserves title when indexing a topic starter on update", async () => {
416 const { db: trackingDb, getUpdatedValues } = createTrackingDb();
417 const topicIndexer = new Indexer(trackingDb, mockLogger);
418
419 const event: CommitUpdateEvent<"space.atbb.post"> = {
420 did: "did:plc:test123",
421 time_us: 1234567890,
422 kind: "commit",
423 commit: {
424 rev: "abc",
425 operation: "update",
426 collection: "space.atbb.post",
427 rkey: "topic1",
428 cid: "cid222",
429 record: {
430 $type: "space.atbb.post",
431 text: "Updated topic body",
432 title: "Updated topic title",
433 createdAt: "2024-01-01T00:00:00Z",
434 } as any,
435 },
436 };
437
438 await topicIndexer.handlePostUpdate(event);
439
440 const vals = getUpdatedValues();
441 expect(vals).toBeDefined();
442 expect(vals.title).toBe("Updated topic title");
443 });
444
445 it("should handle post update", async () => {
446
447
448 const event: CommitUpdateEvent<"space.atbb.post"> = {
449 did: "did:plc:test123",
450 time_us: 1234567890,
451 kind: "commit",
452 commit: {
453 rev: "abc",
454 operation: "update",
455 collection: "space.atbb.post",
456 rkey: "post1",
457 cid: "cid789",
458 record: {
459 $type: "space.atbb.post",
460 text: "Updated text",
461 createdAt: "2024-01-01T00:00:00Z",
462 } as any,
463 },
464 };
465
466 await indexer.handlePostUpdate(event);
467
468 expect(mockDb.update).toHaveBeenCalled();
469 });
470
471 it("should handle post deletion with soft delete", async () => {
472
473
474 const event: CommitDeleteEvent<"space.atbb.post"> = {
475 did: "did:plc:test123",
476 time_us: 1234567890,
477 kind: "commit",
478 commit: {
479 rev: "abc",
480 operation: "delete",
481 collection: "space.atbb.post",
482 rkey: "post1",
483 },
484 };
485
486 await indexer.handlePostDelete(event);
487
488 expect(mockDb.update).toHaveBeenCalled();
489 });
490 });
491
492 describe("Forum Handler", () => {
493 it("should handle forum creation", async () => {
494
495
496 const event: CommitCreateEvent<"space.atbb.forum.forum"> = {
497 did: "did:plc:forum",
498 time_us: 1234567890,
499 kind: "commit",
500 commit: {
501 rev: "abc",
502 operation: "create",
503 collection: "space.atbb.forum.forum",
504 rkey: "self",
505 cid: "cidForum",
506 record: {
507 $type: "space.atbb.forum.forum",
508 name: "Test Forum",
509 description: "A test forum",
510 } as any,
511 },
512 };
513
514 await indexer.handleForumCreate(event);
515
516 expect(mockDb.insert).toHaveBeenCalled();
517 });
518
519 it("should handle forum update", async () => {
520
521
522 const event: CommitUpdateEvent<"space.atbb.forum.forum"> = {
523 did: "did:plc:forum",
524 time_us: 1234567890,
525 kind: "commit",
526 commit: {
527 rev: "abc",
528 operation: "update",
529 collection: "space.atbb.forum.forum",
530 rkey: "self",
531 cid: "cidForumNew",
532 record: {
533 $type: "space.atbb.forum.forum",
534 name: "Updated Forum Name",
535 description: "Updated description",
536 } as any,
537 },
538 };
539
540 await indexer.handleForumUpdate(event);
541
542 expect(mockDb.update).toHaveBeenCalled();
543 });
544
545 it("should handle forum deletion", async () => {
546
547
548 const event: CommitDeleteEvent<"space.atbb.forum.forum"> = {
549 did: "did:plc:forum",
550 time_us: 1234567890,
551 kind: "commit",
552 commit: {
553 rev: "abc",
554 operation: "delete",
555 collection: "space.atbb.forum.forum",
556 rkey: "self",
557 },
558 };
559
560 await indexer.handleForumDelete(event);
561
562 expect(mockDb.delete).toHaveBeenCalled();
563 });
564 });
565
566 describe("Category Handler", () => {
567 it("should handle category creation without errors", async () => {
568
569
570 const event: CommitCreateEvent<"space.atbb.forum.category"> = {
571 did: "did:plc:forum",
572 time_us: 1234567890,
573 kind: "commit",
574 commit: {
575 rev: "abc",
576 operation: "create",
577 collection: "space.atbb.forum.category",
578 rkey: "cat1",
579 cid: "cidCat",
580 record: {
581 $type: "space.atbb.forum.category",
582 name: "General Discussion",
583 forum: {
584 forum: {
585 uri: "at://did:plc:forum/space.atbb.forum/self",
586 cid: "cidForum",
587 },
588 },
589 slug: "general-discussion",
590 sortOrder: 0,
591 createdAt: "2024-01-01T00:00:00Z",
592 } as any,
593 },
594 };
595
596 // Test that function executes without throwing
597 // Note: Since forum doesn't exist in mock, it will skip insertion
598 await expect(indexer.handleCategoryCreate(event)).resolves.not.toThrow();
599 });
600
601 it("should skip category creation if forum not found", async () => {
602
603
604 // Mock failed forum lookup
605 vi.spyOn(mockDb, "select").mockReturnValue({
606 from: vi.fn().mockReturnValue({
607 where: vi.fn().mockReturnValue({
608 limit: vi.fn().mockResolvedValue([]),
609 }),
610 }),
611 } as any);
612
613 const event: CommitCreateEvent<"space.atbb.forum.category"> = {
614 did: "did:plc:forum",
615 time_us: 1234567890,
616 kind: "commit",
617 commit: {
618 rev: "abc",
619 operation: "create",
620 collection: "space.atbb.forum.category",
621 rkey: "cat1",
622 cid: "cidCat",
623 record: {
624 $type: "space.atbb.forum.category",
625 name: "General Discussion",
626 forum: {
627 forum: {
628 uri: "at://did:plc:forum/space.atbb.forum/self",
629 cid: "cidForum",
630 },
631 },
632 createdAt: "2024-01-01T00:00:00Z",
633 } as any,
634 },
635 };
636
637 await indexer.handleCategoryCreate(event);
638
639 expect(mockDb.insert).not.toHaveBeenCalled();
640 });
641 });
642
643 // ── Critical Test Coverage for Refactored Generic Methods ──
644 // These tests verify behavioral equivalence after consolidating
645 // 15 handler methods into data-driven collection configs
646
647 describe("Transaction Rollback Behavior", () => {
648 it("should rollback when ensureUser throws", async () => {
649 const mockDbWithError = createMockDb();
650 mockDbWithError.transaction = vi.fn().mockImplementation(async (callback) => {
651 const txContext = {
652 insert: vi.fn().mockRejectedValue(new Error("User creation failed")),
653 update: vi.fn(),
654 delete: vi.fn(),
655 select: vi.fn(),
656 };
657 await callback(txContext);
658 });
659
660 const indexer = new Indexer(mockDbWithError, mockLogger);
661 const event: CommitCreateEvent<"space.atbb.post"> = {
662 did: "did:plc:test",
663 time_us: 1234567890,
664 kind: "commit",
665 commit: {
666 rev: "abc",
667 operation: "create",
668 collection: "space.atbb.post",
669 rkey: "post1",
670 cid: "cid123",
671 record: {
672 $type: "space.atbb.post",
673 text: "Test",
674 createdAt: "2024-01-01T00:00:00Z",
675 } as any,
676 },
677 };
678
679 await expect(indexer.handlePostCreate(event)).rejects.toThrow();
680 });
681
682 it("should rollback when insert fails after FK lookup", async () => {
683 const mockDbWithError = createMockDb();
684 mockDbWithError.transaction = vi.fn().mockImplementation(async (callback) => {
685 const txContext = {
686 insert: vi.fn().mockReturnValue({
687 values: vi.fn().mockRejectedValue(new Error("Foreign key constraint failed")),
688 }),
689 update: vi.fn(),
690 delete: vi.fn(),
691 select: vi.fn().mockReturnValue({
692 from: vi.fn().mockReturnValue({
693 where: vi.fn().mockReturnValue({
694 limit: vi.fn().mockResolvedValue([{ id: BigInt(1) }]),
695 }),
696 }),
697 }),
698 };
699 await callback(txContext);
700 });
701
702 const indexer = new Indexer(mockDbWithError, mockLogger);
703 const event: CommitCreateEvent<"space.atbb.forum.category"> = {
704 did: "did:plc:forum",
705 time_us: 1234567890,
706 kind: "commit",
707 commit: {
708 rev: "abc",
709 operation: "create",
710 collection: "space.atbb.forum.category",
711 rkey: "cat1",
712 cid: "cidCat",
713 record: {
714 $type: "space.atbb.forum.category",
715 name: "General",
716 createdAt: "2024-01-01T00:00:00Z",
717 } as any,
718 },
719 };
720
721 await expect(indexer.handleCategoryCreate(event)).rejects.toThrow("Foreign key constraint failed");
722 });
723
724 it("should log error and re-throw when database operation fails", async () => {
725 const mockDbWithError = createMockDb();
726 mockDbWithError.transaction = vi.fn().mockRejectedValue(new Error("Database connection lost"));
727
728 const indexer = new Indexer(mockDbWithError, mockLogger);
729 const event: CommitCreateEvent<"space.atbb.forum.forum"> = {
730 did: "did:plc:forum",
731 time_us: 1234567890,
732 kind: "commit",
733 commit: {
734 rev: "abc",
735 operation: "create",
736 collection: "space.atbb.forum.forum",
737 rkey: "self",
738 cid: "cidForum",
739 record: {
740 $type: "space.atbb.forum.forum",
741 name: "Test Forum",
742 createdAt: "2024-01-01T00:00:00Z",
743 } as any,
744 },
745 };
746
747 await expect(indexer.handleForumCreate(event)).rejects.toThrow("Database connection lost");
748 expect(mockLogger.error).toHaveBeenCalledWith(
749 expect.stringContaining("Failed to index forum create"),
750 expect.objectContaining({ error: "Database connection lost" })
751 );
752 });
753 });
754
755 describe("Null Return Path Verification", () => {
756 it("should not insert category when getForumIdByDid returns null", async () => {
757 vi.spyOn(mockDb, "select").mockReturnValue({
758 from: vi.fn().mockReturnValue({
759 where: vi.fn().mockReturnValue({
760 limit: vi.fn().mockResolvedValue([]),
761 }),
762 }),
763 } as any);
764
765 const event: CommitCreateEvent<"space.atbb.forum.category"> = {
766 did: "did:plc:forum",
767 time_us: 1234567890,
768 kind: "commit",
769 commit: {
770 rev: "abc",
771 operation: "create",
772 collection: "space.atbb.forum.category",
773 rkey: "cat1",
774 cid: "cidCat",
775 record: {
776 $type: "space.atbb.forum.category",
777 name: "General",
778 createdAt: "2024-01-01T00:00:00Z",
779 } as any,
780 },
781 };
782
783 await indexer.handleCategoryCreate(event);
784 expect(mockDb.insert).not.toHaveBeenCalled();
785 });
786
787 it("should not update category when getForumIdByDid returns null", async () => {
788 vi.spyOn(mockDb, "select").mockReturnValue({
789 from: vi.fn().mockReturnValue({
790 where: vi.fn().mockReturnValue({
791 limit: vi.fn().mockResolvedValue([]),
792 }),
793 }),
794 } as any);
795
796 const event: CommitUpdateEvent<"space.atbb.forum.category"> = {
797 did: "did:plc:forum",
798 time_us: 1234567890,
799 kind: "commit",
800 commit: {
801 rev: "abc",
802 operation: "update",
803 collection: "space.atbb.forum.category",
804 rkey: "cat1",
805 cid: "cidCat2",
806 record: {
807 $type: "space.atbb.forum.category",
808 name: "General Updated",
809 createdAt: "2024-01-01T00:00:00Z",
810 } as any,
811 },
812 };
813
814 await indexer.handleCategoryUpdate(event);
815 expect(mockDb.update).not.toHaveBeenCalled();
816 });
817
818 it("should not insert membership when getForumIdByUri returns null", async () => {
819 // Note: ensureUser() will still insert a user record before forum lookup
820 // This test verifies the membership insert is skipped, not that zero inserts happen
821 let membershipInsertCalled = false;
822
823 const mockDbWithTracking = createMockDb();
824 mockDbWithTracking.transaction = vi.fn().mockImplementation(async (callback) => {
825 const txContext = {
826 insert: vi.fn().mockImplementation((table: any) => {
827 // Track if membership table insert is attempted
828 if (table === memberships) {
829 membershipInsertCalled = true;
830 }
831 return {
832 values: vi.fn().mockResolvedValue(undefined),
833 };
834 }),
835 update: vi.fn(),
836 delete: vi.fn(),
837 select: vi.fn().mockReturnValue({
838 from: vi.fn().mockReturnValue({
839 where: vi.fn().mockReturnValue({
840 limit: vi.fn().mockResolvedValue([]), // Forum not found
841 }),
842 }),
843 }),
844 };
845 return await callback(txContext);
846 });
847
848 const indexer = new Indexer(mockDbWithTracking, mockLogger);
849 const event: CommitCreateEvent<"space.atbb.membership"> = {
850 did: "did:plc:user",
851 time_us: 1234567890,
852 kind: "commit",
853 commit: {
854 rev: "abc",
855 operation: "create",
856 collection: "space.atbb.membership",
857 rkey: "membership1",
858 cid: "cidMembership",
859 record: {
860 $type: "space.atbb.membership",
861 forum: {
862 forum: {
863 uri: "at://did:plc:forum/space.atbb.forum.forum/self",
864 cid: "cidForum",
865 },
866 },
867 createdAt: "2024-01-01T00:00:00Z",
868 } as any,
869 },
870 };
871
872 await indexer.handleMembershipCreate(event);
873 expect(membershipInsertCalled).toBe(false);
874 });
875
876 it("should not update membership when getForumIdByUri returns null", async () => {
877 vi.spyOn(mockDb, "select").mockReturnValue({
878 from: vi.fn().mockReturnValue({
879 where: vi.fn().mockReturnValue({
880 limit: vi.fn().mockResolvedValue([]),
881 }),
882 }),
883 } as any);
884
885 const event: CommitUpdateEvent<"space.atbb.membership"> = {
886 did: "did:plc:user",
887 time_us: 1234567890,
888 kind: "commit",
889 commit: {
890 rev: "abc",
891 operation: "update",
892 collection: "space.atbb.membership",
893 rkey: "membership1",
894 cid: "cidMembership2",
895 record: {
896 $type: "space.atbb.membership",
897 forum: {
898 forum: {
899 uri: "at://did:plc:forum/space.atbb.forum.forum/self",
900 cid: "cidForum",
901 },
902 },
903 createdAt: "2024-01-01T00:00:00Z",
904 } as any,
905 },
906 };
907
908 await indexer.handleMembershipUpdate(event);
909 expect(mockDb.update).not.toHaveBeenCalled();
910 });
911
912 it("should not insert modAction when getForumIdByDid returns null", async () => {
913 vi.spyOn(mockDb, "select").mockReturnValue({
914 from: vi.fn().mockReturnValue({
915 where: vi.fn().mockReturnValue({
916 limit: vi.fn().mockResolvedValue([]),
917 }),
918 }),
919 } as any);
920
921 const event: CommitCreateEvent<"space.atbb.modAction"> = {
922 did: "did:plc:forum",
923 time_us: 1234567890,
924 kind: "commit",
925 commit: {
926 rev: "abc",
927 operation: "create",
928 collection: "space.atbb.modAction",
929 rkey: "action1",
930 cid: "cidAction",
931 record: {
932 $type: "space.atbb.modAction",
933 action: "ban",
934 createdBy: "did:plc:moderator",
935 subject: {
936 did: "did:plc:baduser",
937 },
938 createdAt: "2024-01-01T00:00:00Z",
939 } as any,
940 },
941 };
942
943 await indexer.handleModActionCreate(event);
944 expect(mockDb.insert).not.toHaveBeenCalled();
945 });
946
947 it("should not update modAction when getForumIdByDid returns null", async () => {
948 vi.spyOn(mockDb, "select").mockReturnValue({
949 from: vi.fn().mockReturnValue({
950 where: vi.fn().mockReturnValue({
951 limit: vi.fn().mockResolvedValue([]),
952 }),
953 }),
954 } as any);
955
956 const event: CommitUpdateEvent<"space.atbb.modAction"> = {
957 did: "did:plc:forum",
958 time_us: 1234567890,
959 kind: "commit",
960 commit: {
961 rev: "abc",
962 operation: "update",
963 collection: "space.atbb.modAction",
964 rkey: "action1",
965 cid: "cidAction2",
966 record: {
967 $type: "space.atbb.modAction",
968 action: "unban",
969 createdBy: "did:plc:moderator",
970 subject: {
971 did: "did:plc:baduser",
972 },
973 createdAt: "2024-01-01T00:00:00Z",
974 } as any,
975 },
976 };
977
978 await indexer.handleModActionUpdate(event);
979 expect(mockDb.update).not.toHaveBeenCalled();
980 });
981 });
982
983 describe("Error Re-throwing Behavior", () => {
984 it("should re-throw errors from genericCreate", async () => {
985 const mockDbWithError = createMockDb();
986 mockDbWithError.transaction = vi.fn().mockRejectedValue(new Error("Database error"));
987
988 const indexer = new Indexer(mockDbWithError, mockLogger);
989 const event: CommitCreateEvent<"space.atbb.post"> = {
990 did: "did:plc:test",
991 time_us: 1234567890,
992 kind: "commit",
993 commit: {
994 rev: "abc",
995 operation: "create",
996 collection: "space.atbb.post",
997 rkey: "post1",
998 cid: "cid123",
999 record: {
1000 $type: "space.atbb.post",
1001 text: "Test",
1002 createdAt: "2024-01-01T00:00:00Z",
1003 } as any,
1004 },
1005 };
1006
1007 await expect(indexer.handlePostCreate(event)).rejects.toThrow("Database error");
1008 });
1009
1010 it("should re-throw errors from genericUpdate", async () => {
1011 const mockDbWithError = createMockDb();
1012 mockDbWithError.transaction = vi.fn().mockRejectedValue(new Error("Update failed"));
1013
1014 const indexer = new Indexer(mockDbWithError, mockLogger);
1015 const event: CommitUpdateEvent<"space.atbb.forum.forum"> = {
1016 did: "did:plc:forum",
1017 time_us: 1234567890,
1018 kind: "commit",
1019 commit: {
1020 rev: "abc",
1021 operation: "update",
1022 collection: "space.atbb.forum.forum",
1023 rkey: "self",
1024 cid: "cidForum2",
1025 record: {
1026 $type: "space.atbb.forum.forum",
1027 name: "Updated Forum",
1028 createdAt: "2024-01-01T00:00:00Z",
1029 } as any,
1030 },
1031 };
1032
1033 await expect(indexer.handleForumUpdate(event)).rejects.toThrow("Update failed");
1034 });
1035
1036 it("should re-throw errors from genericDelete (soft)", async () => {
1037 const mockDbWithError = createMockDb();
1038 mockDbWithError.update = vi.fn().mockReturnValue({
1039 set: vi.fn().mockReturnValue({
1040 where: vi.fn().mockRejectedValue(new Error("Soft delete failed")),
1041 }),
1042 });
1043
1044 const indexer = new Indexer(mockDbWithError, mockLogger);
1045 const event: CommitDeleteEvent<"space.atbb.post"> = {
1046 did: "did:plc:test",
1047 time_us: 1234567890,
1048 kind: "commit",
1049 commit: {
1050 rev: "abc",
1051 operation: "delete",
1052 collection: "space.atbb.post",
1053 rkey: "post1",
1054 },
1055 };
1056
1057 await expect(indexer.handlePostDelete(event)).rejects.toThrow("Soft delete failed");
1058 });
1059
1060 it("should re-throw errors from genericDelete (hard)", async () => {
1061 const mockDbWithError = createMockDb();
1062 mockDbWithError.delete = vi.fn().mockReturnValue({
1063 where: vi.fn().mockRejectedValue(new Error("Hard delete failed")),
1064 });
1065
1066 const indexer = new Indexer(mockDbWithError, mockLogger);
1067 const event: CommitDeleteEvent<"space.atbb.forum.forum"> = {
1068 did: "did:plc:forum",
1069 time_us: 1234567890,
1070 kind: "commit",
1071 commit: {
1072 rev: "abc",
1073 operation: "delete",
1074 collection: "space.atbb.forum.forum",
1075 rkey: "self",
1076 },
1077 };
1078
1079 await expect(indexer.handleForumDelete(event)).rejects.toThrow("Hard delete failed");
1080 });
1081
1082 it("re-throws errors from handleModActionDelete and logs with context", async () => {
1083 (mockDb.transaction as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
1084 new Error("Transaction aborted")
1085 );
1086
1087 const event = {
1088 did: "did:plc:forum",
1089 time_us: 1234567890,
1090 kind: "commit",
1091 commit: {
1092 rev: "abc",
1093 operation: "delete",
1094 collection: "space.atbb.modAction",
1095 rkey: "action1",
1096 },
1097 } as any;
1098
1099 await expect(indexer.handleModActionDelete(event)).rejects.toThrow("Transaction aborted");
1100 expect(mockLogger.error).toHaveBeenCalledWith(
1101 expect.stringContaining("Failed to delete modAction"),
1102 expect.objectContaining({ error: "Transaction aborted" })
1103 );
1104 });
1105 });
1106
1107 describe("Delete Strategy Verification", () => {
1108 it("should tombstone posts using db.update (preserves row for FK stability)", async () => {
1109 // Capture mockSet to verify the exact tombstone payload
1110 const mockWhere = vi.fn().mockResolvedValue(undefined);
1111 const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
1112 (mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce({ set: mockSet });
1113
1114 const event: CommitDeleteEvent<"space.atbb.post"> = {
1115 did: "did:plc:test",
1116 time_us: 1234567890,
1117 kind: "commit",
1118 commit: {
1119 rev: "abc",
1120 operation: "delete",
1121 collection: "space.atbb.post",
1122 rkey: "post1",
1123 },
1124 };
1125
1126 await indexer.handlePostDelete(event);
1127
1128 // Tombstone: row is updated (content replaced), not hard-deleted
1129 expect(mockDb.update).toHaveBeenCalled();
1130 expect(mockDb.delete).not.toHaveBeenCalled();
1131 // Must set the exact tombstone payload — not bannedByMod, not deleted
1132 expect(mockSet).toHaveBeenCalledWith({
1133 text: "[user deleted this post]",
1134 deletedByUser: true,
1135 });
1136 expect(mockSet).not.toHaveBeenCalledWith(
1137 expect.objectContaining({ bannedByMod: expect.anything() })
1138 );
1139 expect(mockSet).not.toHaveBeenCalledWith(
1140 expect.objectContaining({ deleted: expect.anything() })
1141 );
1142 });
1143
1144 it("should hard delete forums using db.delete", async () => {
1145 const event: CommitDeleteEvent<"space.atbb.forum.forum"> = {
1146 did: "did:plc:forum",
1147 time_us: 1234567890,
1148 kind: "commit",
1149 commit: {
1150 rev: "abc",
1151 operation: "delete",
1152 collection: "space.atbb.forum.forum",
1153 rkey: "self",
1154 },
1155 };
1156
1157 await indexer.handleForumDelete(event);
1158
1159 expect(mockDb.delete).toHaveBeenCalled();
1160 expect(mockDb.update).not.toHaveBeenCalled();
1161 });
1162
1163 it("should hard delete categories using db.delete", async () => {
1164 const event: CommitDeleteEvent<"space.atbb.forum.category"> = {
1165 did: "did:plc:forum",
1166 time_us: 1234567890,
1167 kind: "commit",
1168 commit: {
1169 rev: "abc",
1170 operation: "delete",
1171 collection: "space.atbb.forum.category",
1172 rkey: "cat1",
1173 },
1174 };
1175
1176 await indexer.handleCategoryDelete(event);
1177
1178 expect(mockDb.delete).toHaveBeenCalled();
1179 expect(mockDb.update).not.toHaveBeenCalled();
1180 });
1181
1182 it("should hard delete memberships using db.delete", async () => {
1183 const event: CommitDeleteEvent<"space.atbb.membership"> = {
1184 did: "did:plc:user",
1185 time_us: 1234567890,
1186 kind: "commit",
1187 commit: {
1188 rev: "abc",
1189 operation: "delete",
1190 collection: "space.atbb.membership",
1191 rkey: "membership1",
1192 },
1193 };
1194
1195 await indexer.handleMembershipDelete(event);
1196
1197 expect(mockDb.delete).toHaveBeenCalled();
1198 expect(mockDb.update).not.toHaveBeenCalled();
1199 });
1200
1201 it("should hard delete modActions using db.delete", async () => {
1202 const event: CommitDeleteEvent<"space.atbb.modAction"> = {
1203 did: "did:plc:forum",
1204 time_us: 1234567890,
1205 kind: "commit",
1206 commit: {
1207 rev: "abc",
1208 operation: "delete",
1209 collection: "space.atbb.modAction",
1210 rkey: "action1",
1211 },
1212 };
1213
1214 await indexer.handleModActionDelete(event);
1215
1216 expect(mockDb.delete).toHaveBeenCalled();
1217 expect(mockDb.update).not.toHaveBeenCalled();
1218 });
1219 });
1220
1221 describe("Ban enforcement — handlePostCreate", () => {
1222 it("skips indexing when the user is banned", async () => {
1223 const { BanEnforcer } = await import("../ban-enforcer.js");
1224 const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results.at(-1)!.value;
1225 mockBanEnforcer.isBanned.mockResolvedValue(true);
1226
1227 const event = {
1228 did: "did:plc:banned123",
1229 time_us: 1234567890,
1230 kind: "commit",
1231 commit: {
1232 rev: "abc",
1233 operation: "create",
1234 collection: "space.atbb.post",
1235 rkey: "post1",
1236 cid: "cid123",
1237 record: {
1238 $type: "space.atbb.post",
1239 text: "Hello world",
1240 createdAt: "2024-01-01T00:00:00Z",
1241 },
1242 },
1243 } as any;
1244
1245 await indexer.handlePostCreate(event);
1246
1247 // The DB insert should NOT have been called
1248 expect(mockDb.insert).not.toHaveBeenCalled();
1249 });
1250
1251 it("indexes the post normally when the user is not banned", async () => {
1252 const { BanEnforcer } = await import("../ban-enforcer.js");
1253 const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results.at(-1)!.value;
1254 mockBanEnforcer.isBanned.mockResolvedValue(false);
1255
1256 // Set up select to return a user (ensureUser) and no parent/root posts
1257 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1258 from: vi.fn().mockReturnValue({
1259 where: vi.fn().mockReturnValue({
1260 limit: vi.fn().mockResolvedValue([{ did: "did:plc:user123" }]),
1261 }),
1262 }),
1263 });
1264
1265 const event = {
1266 did: "did:plc:user123",
1267 time_us: 1234567890,
1268 kind: "commit",
1269 commit: {
1270 rev: "abc",
1271 operation: "create",
1272 collection: "space.atbb.post",
1273 rkey: "post1",
1274 cid: "cid123",
1275 record: {
1276 $type: "space.atbb.post",
1277 text: "Hello world",
1278 createdAt: "2024-01-01T00:00:00Z",
1279 },
1280 },
1281 } as any;
1282
1283 await indexer.handlePostCreate(event);
1284
1285 expect(mockDb.insert).toHaveBeenCalled();
1286 });
1287 });
1288
1289
1290 describe("ThemePolicy Handler", () => {
1291 /**
1292 * Creates a tracking DB for themePolicy tests.
1293 * ThemePolicy's genericCreate path uses afterUpsert, which requires:
1294 * 1st insert (themePolicies): .values().returning([{id}])
1295 * delete (themePolicyAvailableThemes): .where()
1296 * 2nd insert (themePolicyAvailableThemes): .values()
1297 */
1298 function createThemePolicyTrackingDb() {
1299 let insertCallCount = 0;
1300 let policyInsertValues: any = null;
1301 let availableThemesInsertValues: any = null;
1302
1303 const db = {
1304 transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<any>) => {
1305 const tx = {
1306 insert: vi.fn().mockImplementation(() => ({
1307 values: vi.fn().mockImplementation((vals: any) => {
1308 insertCallCount++;
1309 if (insertCallCount === 1) {
1310 policyInsertValues = vals;
1311 return { returning: vi.fn().mockResolvedValue([{ id: 1n }]) };
1312 }
1313 availableThemesInsertValues = vals;
1314 return Promise.resolve(undefined);
1315 }),
1316 })),
1317 delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
1318 update: vi.fn(),
1319 select: vi.fn().mockReturnValue({
1320 from: vi.fn().mockReturnValue({
1321 where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
1322 }),
1323 }),
1324 };
1325 return await callback(tx);
1326 }),
1327 } as unknown as Database;
1328
1329 return {
1330 db,
1331 getPolicyInsertValues: () => policyInsertValues,
1332 getAvailableThemesInsertValues: () => availableThemesInsertValues,
1333 };
1334 }
1335
1336 it("indexes themePolicy with flat themeRef URIs — live refs (no CID)", async () => {
1337 // This test verifies the field access uses .uri directly (not .theme.uri from old strongRef).
1338 // If the old .defaultLightTheme.theme.uri path were used, this would throw TypeError.
1339 const { db: trackingDb, getPolicyInsertValues, getAvailableThemesInsertValues } =
1340 createThemePolicyTrackingDb();
1341 const themePolicyIndexer = new Indexer(trackingDb, mockLogger);
1342
1343 const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = {
1344 did: "did:plc:forum",
1345 time_us: 1234567890,
1346 kind: "commit",
1347 commit: {
1348 rev: "abc",
1349 operation: "create",
1350 collection: "space.atbb.forum.themePolicy",
1351 rkey: "self",
1352 cid: "cidPolicy",
1353 record: {
1354 $type: "space.atbb.forum.themePolicy",
1355 defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" },
1356 defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" },
1357 availableThemes: [
1358 { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" },
1359 { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" },
1360 ],
1361 allowUserChoice: true,
1362 updatedAt: "2026-01-01T00:00:00Z",
1363 } as any,
1364 },
1365 };
1366
1367 await expect(themePolicyIndexer.handleThemePolicyCreate(event)).resolves.not.toThrow();
1368
1369 const policyVals = getPolicyInsertValues();
1370 expect(policyVals).toBeDefined();
1371 expect(policyVals.defaultLightThemeUri).toBe(
1372 "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light"
1373 );
1374 expect(policyVals.defaultDarkThemeUri).toBe(
1375 "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark"
1376 );
1377
1378 const availableVals = getAvailableThemesInsertValues();
1379 expect(availableVals).toBeDefined();
1380 expect(availableVals[0].themeUri).toBe(
1381 "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light"
1382 );
1383 // Live refs: no CID in record → themeCid must be null in DB row
1384 expect(availableVals[0].themeCid).toBeNull();
1385 });
1386
1387 it("indexes themePolicy with pinned themeRefs (CID present)", async () => {
1388 const { db: trackingDb, getAvailableThemesInsertValues } =
1389 createThemePolicyTrackingDb();
1390 const themePolicyIndexer = new Indexer(trackingDb, mockLogger);
1391
1392 const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = {
1393 did: "did:plc:forum",
1394 time_us: 1234567890,
1395 kind: "commit",
1396 commit: {
1397 rev: "abc",
1398 operation: "create",
1399 collection: "space.atbb.forum.themePolicy",
1400 rkey: "self",
1401 cid: "cidPolicy2",
1402 record: {
1403 $type: "space.atbb.forum.themePolicy",
1404 defaultLightTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" },
1405 defaultDarkTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" },
1406 availableThemes: [
1407 { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" },
1408 { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" },
1409 ],
1410 allowUserChoice: false,
1411 updatedAt: "2026-01-01T00:00:00Z",
1412 } as any,
1413 },
1414 };
1415
1416 await themePolicyIndexer.handleThemePolicyCreate(event);
1417
1418 const availableVals = getAvailableThemesInsertValues();
1419 expect(availableVals[0].themeCid).toBe("bafylight");
1420 expect(availableVals[1].themeCid).toBe("bafydark");
1421 });
1422 });
1423
1424 describe("Ban enforcement — handleModActionCreate", () => {
1425 it("calls applyBan when a ban mod action is created", async () => {
1426 const mockBanEnforcer = (indexer as any).banEnforcer;
1427
1428 // Set up select to return a forum (getForumIdByDid) and then ensureUser
1429 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1430 from: vi.fn().mockReturnValue({
1431 where: vi.fn().mockReturnValue({
1432 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1433 }),
1434 }),
1435 });
1436
1437 const event = {
1438 did: "did:plc:forum",
1439 time_us: 1234567890,
1440 kind: "commit",
1441 commit: {
1442 rev: "abc",
1443 operation: "create",
1444 collection: "space.atbb.modAction",
1445 rkey: "action1",
1446 cid: "cid123",
1447 record: {
1448 $type: "space.atbb.modAction",
1449 action: "space.atbb.modAction.ban",
1450 subject: { did: "did:plc:target123" },
1451 createdBy: "did:plc:mod",
1452 createdAt: "2024-01-01T00:00:00Z",
1453 },
1454 },
1455 } as any;
1456
1457 await indexer.handleModActionCreate(event);
1458
1459 expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object));
1460 });
1461
1462 it("does NOT call applyBan for non-ban actions (e.g. pin)", async () => {
1463 const mockBanEnforcer = (indexer as any).banEnforcer;
1464
1465 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1466 from: vi.fn().mockReturnValue({
1467 where: vi.fn().mockReturnValue({
1468 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1469 }),
1470 }),
1471 });
1472
1473 const event = {
1474 did: "did:plc:forum",
1475 time_us: 1234567890,
1476 kind: "commit",
1477 commit: {
1478 rev: "abc",
1479 operation: "create",
1480 collection: "space.atbb.modAction",
1481 rkey: "action2",
1482 cid: "cid124",
1483 record: {
1484 $type: "space.atbb.modAction",
1485 action: "space.atbb.modAction.pin",
1486 subject: { post: { uri: "at://did:plc:user/space.atbb.post/abc", cid: "cid" } },
1487 createdBy: "did:plc:mod",
1488 createdAt: "2024-01-01T00:00:00Z",
1489 },
1490 },
1491 } as any;
1492
1493 await indexer.handleModActionCreate(event);
1494
1495 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1496 });
1497
1498 it("does NOT call applyBan when the ban record insert was skipped (unknown forum DID)", async () => {
1499 const mockBanEnforcer = (indexer as any).banEnforcer;
1500
1501 // Select returns empty — forum DID not found, toInsertValues returns null → insert skipped
1502 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1503 from: vi.fn().mockReturnValue({
1504 where: vi.fn().mockReturnValue({
1505 limit: vi.fn().mockResolvedValue([]),
1506 }),
1507 }),
1508 });
1509
1510 const event = {
1511 did: "did:plc:unknown-forum",
1512 time_us: 1234567890,
1513 kind: "commit",
1514 commit: {
1515 rev: "abc",
1516 operation: "create",
1517 collection: "space.atbb.modAction",
1518 rkey: "action1",
1519 cid: "cid123",
1520 record: {
1521 $type: "space.atbb.modAction",
1522 action: "space.atbb.modAction.ban",
1523 subject: { did: "did:plc:target123" },
1524 createdBy: "did:plc:mod",
1525 createdAt: "2024-01-01T00:00:00Z",
1526 },
1527 },
1528 } as any;
1529
1530 await indexer.handleModActionCreate(event);
1531
1532 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1533 });
1534
1535 it("logs warning and skips applyBan when ban action has no subject.did", async () => {
1536 const mockBanEnforcer = (indexer as any).banEnforcer;
1537
1538 // Mock select to return forum found
1539 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1540 from: vi.fn().mockReturnValue({
1541 where: vi.fn().mockReturnValue({
1542 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1543 }),
1544 }),
1545 });
1546
1547 const event = {
1548 did: "did:plc:forum",
1549 time_us: 1234567890,
1550 kind: "commit",
1551 commit: {
1552 rev: "abc",
1553 operation: "create",
1554 collection: "space.atbb.modAction",
1555 rkey: "action1",
1556 cid: "cid123",
1557 record: {
1558 $type: "space.atbb.modAction",
1559 action: "space.atbb.modAction.ban",
1560 subject: {}, // no did field
1561 createdBy: "did:plc:mod",
1562 createdAt: "2024-01-01T00:00:00Z",
1563 },
1564 },
1565 } as any;
1566
1567 await indexer.handleModActionCreate(event);
1568
1569 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1570 expect(mockLogger.warn).toHaveBeenCalledWith(
1571 expect.stringContaining("missing subject.did"),
1572 expect.any(Object)
1573 );
1574 });
1575
1576 it("calls liftBan when an unban mod action is indexed", async () => {
1577 const mockBanEnforcer = (indexer as any).banEnforcer;
1578
1579 // Mock select to return forum found
1580 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1581 from: vi.fn().mockReturnValue({
1582 where: vi.fn().mockReturnValue({
1583 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1584 }),
1585 }),
1586 });
1587
1588 const event = {
1589 did: "did:plc:forum",
1590 time_us: 1234567891,
1591 kind: "commit",
1592 commit: {
1593 rev: "def",
1594 operation: "create",
1595 collection: "space.atbb.modAction",
1596 rkey: "action2",
1597 cid: "cid124",
1598 record: {
1599 $type: "space.atbb.modAction",
1600 action: "space.atbb.modAction.unban",
1601 subject: { did: "did:plc:target123" },
1602 createdBy: "did:plc:mod",
1603 createdAt: "2024-01-01T00:00:01Z",
1604 },
1605 },
1606 } as any;
1607
1608 await indexer.handleModActionCreate(event);
1609
1610 expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith(
1611 "did:plc:target123",
1612 expect.any(Object) // transaction context
1613 );
1614 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1615 });
1616 it("race condition: post indexed before ban — ban retroactively hides it", async () => {
1617 const mockBanEnforcer = (indexer as any).banEnforcer;
1618
1619 // Step 1: Post arrives before ban — isBanned returns false at this moment
1620 mockBanEnforcer.isBanned.mockResolvedValueOnce(false);
1621
1622 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1623 from: vi.fn().mockReturnValue({
1624 where: vi.fn().mockReturnValue({
1625 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1626 }),
1627 }),
1628 });
1629
1630 const postEvent = {
1631 did: "did:plc:target123",
1632 time_us: 1234567890,
1633 kind: "commit",
1634 commit: {
1635 rev: "abc",
1636 operation: "create",
1637 collection: "space.atbb.post",
1638 rkey: "post1",
1639 cid: "cid123",
1640 record: {
1641 $type: "space.atbb.post",
1642 text: "Hello world",
1643 createdAt: "2024-01-01T00:00:00Z",
1644 },
1645 },
1646 } as any;
1647
1648 await indexer.handlePostCreate(postEvent);
1649 expect(mockDb.insert).toHaveBeenCalled(); // post was actually inserted into DB before ban arrived
1650
1651 // Step 2: Ban arrives — applyBan retroactively hides the post
1652 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1653 from: vi.fn().mockReturnValue({
1654 where: vi.fn().mockReturnValue({
1655 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1656 }),
1657 }),
1658 });
1659
1660 const banEvent = {
1661 did: "did:plc:forum",
1662 time_us: 1234567891,
1663 kind: "commit",
1664 commit: {
1665 rev: "def",
1666 operation: "create",
1667 collection: "space.atbb.modAction",
1668 rkey: "action1",
1669 cid: "cid124",
1670 record: {
1671 $type: "space.atbb.modAction",
1672 action: "space.atbb.modAction.ban",
1673 subject: { did: "did:plc:target123" },
1674 createdBy: "did:plc:mod",
1675 createdAt: "2024-01-01T00:00:01Z",
1676 },
1677 },
1678 } as any;
1679
1680 await indexer.handleModActionCreate(banEvent);
1681 expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object));
1682 });
1683 });
1684
1685 describe("Ban enforcement — handleModActionDelete", () => {
1686 it("calls liftBan when a ban record is deleted", async () => {
1687 const mockBanEnforcer = (indexer as any).banEnforcer;
1688
1689 // Transaction mock: select returns a ban record, delete succeeds
1690 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
1691 async (callback: (tx: any) => Promise<any>) => {
1692 const tx = {
1693 select: vi.fn().mockReturnValue({
1694 from: vi.fn().mockReturnValue({
1695 where: vi.fn().mockReturnValue({
1696 limit: vi.fn().mockResolvedValue([
1697 {
1698 action: "space.atbb.modAction.ban",
1699 subjectDid: "did:plc:target123",
1700 },
1701 ]),
1702 }),
1703 }),
1704 }),
1705 delete: vi.fn().mockReturnValue({
1706 where: vi.fn().mockResolvedValue(undefined),
1707 }),
1708 };
1709 return callback(tx);
1710 }
1711 );
1712
1713 const event = {
1714 did: "did:plc:forum",
1715 time_us: 1234567890,
1716 kind: "commit",
1717 commit: {
1718 rev: "abc",
1719 operation: "delete",
1720 collection: "space.atbb.modAction",
1721 rkey: "action1",
1722 },
1723 } as any;
1724
1725 await indexer.handleModActionDelete(event);
1726
1727 expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith(
1728 "did:plc:target123",
1729 expect.anything() // the transaction
1730 );
1731 });
1732
1733 it("does NOT call liftBan when a non-ban record is deleted", async () => {
1734 const mockBanEnforcer = (indexer as any).banEnforcer;
1735
1736 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
1737 async (callback: (tx: any) => Promise<any>) => {
1738 const tx = {
1739 select: vi.fn().mockReturnValue({
1740 from: vi.fn().mockReturnValue({
1741 where: vi.fn().mockReturnValue({
1742 limit: vi.fn().mockResolvedValue([
1743 {
1744 action: "space.atbb.modAction.pin",
1745 subjectDid: null,
1746 },
1747 ]),
1748 }),
1749 }),
1750 }),
1751 delete: vi.fn().mockReturnValue({
1752 where: vi.fn().mockResolvedValue(undefined),
1753 }),
1754 };
1755 return callback(tx);
1756 }
1757 );
1758
1759 const event = {
1760 did: "did:plc:forum",
1761 time_us: 1234567890,
1762 kind: "commit",
1763 commit: {
1764 rev: "abc",
1765 operation: "delete",
1766 collection: "space.atbb.modAction",
1767 rkey: "action2",
1768 },
1769 } as any;
1770
1771 await indexer.handleModActionDelete(event);
1772
1773 expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled();
1774 });
1775
1776 it("does NOT call liftBan when the record is not found (already deleted)", async () => {
1777 const mockBanEnforcer = (indexer as any).banEnforcer;
1778
1779 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
1780 async (callback: (tx: any) => Promise<any>) => {
1781 const tx = {
1782 select: vi.fn().mockReturnValue({
1783 from: vi.fn().mockReturnValue({
1784 where: vi.fn().mockReturnValue({
1785 limit: vi.fn().mockResolvedValue([]),
1786 }),
1787 }),
1788 }),
1789 delete: vi.fn().mockReturnValue({
1790 where: vi.fn().mockResolvedValue(undefined),
1791 }),
1792 };
1793 return callback(tx);
1794 }
1795 );
1796
1797 const event = {
1798 did: "did:plc:forum",
1799 time_us: 1234567890,
1800 kind: "commit",
1801 commit: {
1802 rev: "abc",
1803 operation: "delete",
1804 collection: "space.atbb.modAction",
1805 rkey: "action3",
1806 },
1807 } as any;
1808
1809 await indexer.handleModActionDelete(event);
1810
1811 expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled();
1812 });
1813 });
1814});