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("Ban enforcement — handleModActionCreate", () => {
1291 it("calls applyBan when a ban mod action is created", async () => {
1292 const mockBanEnforcer = (indexer as any).banEnforcer;
1293
1294 // Set up select to return a forum (getForumIdByDid) and then ensureUser
1295 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1296 from: vi.fn().mockReturnValue({
1297 where: vi.fn().mockReturnValue({
1298 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1299 }),
1300 }),
1301 });
1302
1303 const event = {
1304 did: "did:plc:forum",
1305 time_us: 1234567890,
1306 kind: "commit",
1307 commit: {
1308 rev: "abc",
1309 operation: "create",
1310 collection: "space.atbb.modAction",
1311 rkey: "action1",
1312 cid: "cid123",
1313 record: {
1314 $type: "space.atbb.modAction",
1315 action: "space.atbb.modAction.ban",
1316 subject: { did: "did:plc:target123" },
1317 createdBy: "did:plc:mod",
1318 createdAt: "2024-01-01T00:00:00Z",
1319 },
1320 },
1321 } as any;
1322
1323 await indexer.handleModActionCreate(event);
1324
1325 expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object));
1326 });
1327
1328 it("does NOT call applyBan for non-ban actions (e.g. pin)", async () => {
1329 const mockBanEnforcer = (indexer as any).banEnforcer;
1330
1331 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1332 from: vi.fn().mockReturnValue({
1333 where: vi.fn().mockReturnValue({
1334 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1335 }),
1336 }),
1337 });
1338
1339 const event = {
1340 did: "did:plc:forum",
1341 time_us: 1234567890,
1342 kind: "commit",
1343 commit: {
1344 rev: "abc",
1345 operation: "create",
1346 collection: "space.atbb.modAction",
1347 rkey: "action2",
1348 cid: "cid124",
1349 record: {
1350 $type: "space.atbb.modAction",
1351 action: "space.atbb.modAction.pin",
1352 subject: { post: { uri: "at://did:plc:user/space.atbb.post/abc", cid: "cid" } },
1353 createdBy: "did:plc:mod",
1354 createdAt: "2024-01-01T00:00:00Z",
1355 },
1356 },
1357 } as any;
1358
1359 await indexer.handleModActionCreate(event);
1360
1361 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1362 });
1363
1364 it("does NOT call applyBan when the ban record insert was skipped (unknown forum DID)", async () => {
1365 const mockBanEnforcer = (indexer as any).banEnforcer;
1366
1367 // Select returns empty — forum DID not found, toInsertValues returns null → insert skipped
1368 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1369 from: vi.fn().mockReturnValue({
1370 where: vi.fn().mockReturnValue({
1371 limit: vi.fn().mockResolvedValue([]),
1372 }),
1373 }),
1374 });
1375
1376 const event = {
1377 did: "did:plc:unknown-forum",
1378 time_us: 1234567890,
1379 kind: "commit",
1380 commit: {
1381 rev: "abc",
1382 operation: "create",
1383 collection: "space.atbb.modAction",
1384 rkey: "action1",
1385 cid: "cid123",
1386 record: {
1387 $type: "space.atbb.modAction",
1388 action: "space.atbb.modAction.ban",
1389 subject: { did: "did:plc:target123" },
1390 createdBy: "did:plc:mod",
1391 createdAt: "2024-01-01T00:00:00Z",
1392 },
1393 },
1394 } as any;
1395
1396 await indexer.handleModActionCreate(event);
1397
1398 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1399 });
1400
1401 it("logs warning and skips applyBan when ban action has no subject.did", async () => {
1402 const mockBanEnforcer = (indexer as any).banEnforcer;
1403
1404 // Mock select to return forum found
1405 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1406 from: vi.fn().mockReturnValue({
1407 where: vi.fn().mockReturnValue({
1408 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1409 }),
1410 }),
1411 });
1412
1413 const event = {
1414 did: "did:plc:forum",
1415 time_us: 1234567890,
1416 kind: "commit",
1417 commit: {
1418 rev: "abc",
1419 operation: "create",
1420 collection: "space.atbb.modAction",
1421 rkey: "action1",
1422 cid: "cid123",
1423 record: {
1424 $type: "space.atbb.modAction",
1425 action: "space.atbb.modAction.ban",
1426 subject: {}, // no did field
1427 createdBy: "did:plc:mod",
1428 createdAt: "2024-01-01T00:00:00Z",
1429 },
1430 },
1431 } as any;
1432
1433 await indexer.handleModActionCreate(event);
1434
1435 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1436 expect(mockLogger.warn).toHaveBeenCalledWith(
1437 expect.stringContaining("missing subject.did"),
1438 expect.any(Object)
1439 );
1440 });
1441
1442 it("calls liftBan when an unban mod action is indexed", async () => {
1443 const mockBanEnforcer = (indexer as any).banEnforcer;
1444
1445 // Mock select to return forum found
1446 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1447 from: vi.fn().mockReturnValue({
1448 where: vi.fn().mockReturnValue({
1449 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1450 }),
1451 }),
1452 });
1453
1454 const event = {
1455 did: "did:plc:forum",
1456 time_us: 1234567891,
1457 kind: "commit",
1458 commit: {
1459 rev: "def",
1460 operation: "create",
1461 collection: "space.atbb.modAction",
1462 rkey: "action2",
1463 cid: "cid124",
1464 record: {
1465 $type: "space.atbb.modAction",
1466 action: "space.atbb.modAction.unban",
1467 subject: { did: "did:plc:target123" },
1468 createdBy: "did:plc:mod",
1469 createdAt: "2024-01-01T00:00:01Z",
1470 },
1471 },
1472 } as any;
1473
1474 await indexer.handleModActionCreate(event);
1475
1476 expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith(
1477 "did:plc:target123",
1478 expect.any(Object) // transaction context
1479 );
1480 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
1481 });
1482 it("race condition: post indexed before ban — ban retroactively hides it", async () => {
1483 const mockBanEnforcer = (indexer as any).banEnforcer;
1484
1485 // Step 1: Post arrives before ban — isBanned returns false at this moment
1486 mockBanEnforcer.isBanned.mockResolvedValueOnce(false);
1487
1488 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1489 from: vi.fn().mockReturnValue({
1490 where: vi.fn().mockReturnValue({
1491 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1492 }),
1493 }),
1494 });
1495
1496 const postEvent = {
1497 did: "did:plc:target123",
1498 time_us: 1234567890,
1499 kind: "commit",
1500 commit: {
1501 rev: "abc",
1502 operation: "create",
1503 collection: "space.atbb.post",
1504 rkey: "post1",
1505 cid: "cid123",
1506 record: {
1507 $type: "space.atbb.post",
1508 text: "Hello world",
1509 createdAt: "2024-01-01T00:00:00Z",
1510 },
1511 },
1512 } as any;
1513
1514 await indexer.handlePostCreate(postEvent);
1515 expect(mockDb.insert).toHaveBeenCalled(); // post was actually inserted into DB before ban arrived
1516
1517 // Step 2: Ban arrives — applyBan retroactively hides the post
1518 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
1519 from: vi.fn().mockReturnValue({
1520 where: vi.fn().mockReturnValue({
1521 limit: vi.fn().mockResolvedValue([{ id: 1n }]),
1522 }),
1523 }),
1524 });
1525
1526 const banEvent = {
1527 did: "did:plc:forum",
1528 time_us: 1234567891,
1529 kind: "commit",
1530 commit: {
1531 rev: "def",
1532 operation: "create",
1533 collection: "space.atbb.modAction",
1534 rkey: "action1",
1535 cid: "cid124",
1536 record: {
1537 $type: "space.atbb.modAction",
1538 action: "space.atbb.modAction.ban",
1539 subject: { did: "did:plc:target123" },
1540 createdBy: "did:plc:mod",
1541 createdAt: "2024-01-01T00:00:01Z",
1542 },
1543 },
1544 } as any;
1545
1546 await indexer.handleModActionCreate(banEvent);
1547 expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object));
1548 });
1549 });
1550
1551 describe("Ban enforcement — handleModActionDelete", () => {
1552 it("calls liftBan when a ban record is deleted", async () => {
1553 const mockBanEnforcer = (indexer as any).banEnforcer;
1554
1555 // Transaction mock: select returns a ban record, delete succeeds
1556 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
1557 async (callback: (tx: any) => Promise<any>) => {
1558 const tx = {
1559 select: vi.fn().mockReturnValue({
1560 from: vi.fn().mockReturnValue({
1561 where: vi.fn().mockReturnValue({
1562 limit: vi.fn().mockResolvedValue([
1563 {
1564 action: "space.atbb.modAction.ban",
1565 subjectDid: "did:plc:target123",
1566 },
1567 ]),
1568 }),
1569 }),
1570 }),
1571 delete: vi.fn().mockReturnValue({
1572 where: vi.fn().mockResolvedValue(undefined),
1573 }),
1574 };
1575 return callback(tx);
1576 }
1577 );
1578
1579 const event = {
1580 did: "did:plc:forum",
1581 time_us: 1234567890,
1582 kind: "commit",
1583 commit: {
1584 rev: "abc",
1585 operation: "delete",
1586 collection: "space.atbb.modAction",
1587 rkey: "action1",
1588 },
1589 } as any;
1590
1591 await indexer.handleModActionDelete(event);
1592
1593 expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith(
1594 "did:plc:target123",
1595 expect.anything() // the transaction
1596 );
1597 });
1598
1599 it("does NOT call liftBan when a non-ban record is deleted", async () => {
1600 const mockBanEnforcer = (indexer as any).banEnforcer;
1601
1602 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
1603 async (callback: (tx: any) => Promise<any>) => {
1604 const tx = {
1605 select: vi.fn().mockReturnValue({
1606 from: vi.fn().mockReturnValue({
1607 where: vi.fn().mockReturnValue({
1608 limit: vi.fn().mockResolvedValue([
1609 {
1610 action: "space.atbb.modAction.pin",
1611 subjectDid: null,
1612 },
1613 ]),
1614 }),
1615 }),
1616 }),
1617 delete: vi.fn().mockReturnValue({
1618 where: vi.fn().mockResolvedValue(undefined),
1619 }),
1620 };
1621 return callback(tx);
1622 }
1623 );
1624
1625 const event = {
1626 did: "did:plc:forum",
1627 time_us: 1234567890,
1628 kind: "commit",
1629 commit: {
1630 rev: "abc",
1631 operation: "delete",
1632 collection: "space.atbb.modAction",
1633 rkey: "action2",
1634 },
1635 } as any;
1636
1637 await indexer.handleModActionDelete(event);
1638
1639 expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled();
1640 });
1641
1642 it("does NOT call liftBan when the record is not found (already deleted)", async () => {
1643 const mockBanEnforcer = (indexer as any).banEnforcer;
1644
1645 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
1646 async (callback: (tx: any) => Promise<any>) => {
1647 const tx = {
1648 select: vi.fn().mockReturnValue({
1649 from: vi.fn().mockReturnValue({
1650 where: vi.fn().mockReturnValue({
1651 limit: vi.fn().mockResolvedValue([]),
1652 }),
1653 }),
1654 }),
1655 delete: vi.fn().mockReturnValue({
1656 where: vi.fn().mockResolvedValue(undefined),
1657 }),
1658 };
1659 return callback(tx);
1660 }
1661 );
1662
1663 const event = {
1664 did: "did:plc:forum",
1665 time_us: 1234567890,
1666 kind: "commit",
1667 commit: {
1668 rev: "abc",
1669 operation: "delete",
1670 collection: "space.atbb.modAction",
1671 rkey: "action3",
1672 },
1673 } as any;
1674
1675 await indexer.handleModActionDelete(event);
1676
1677 expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled();
1678 });
1679 });
1680});