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
at atb-52-css-token-extraction 1680 lines 52 kB view raw
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});