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 root/atb-56-theme-caching-layer 1814 lines 58 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("ThemePolicy Handler", () => { 1291 /** 1292 * Creates a tracking DB for themePolicy tests. 1293 * ThemePolicy's genericCreate path uses afterUpsert, which requires: 1294 * 1st insert (themePolicies): .values().returning([{id}]) 1295 * delete (themePolicyAvailableThemes): .where() 1296 * 2nd insert (themePolicyAvailableThemes): .values() 1297 */ 1298 function createThemePolicyTrackingDb() { 1299 let insertCallCount = 0; 1300 let policyInsertValues: any = null; 1301 let availableThemesInsertValues: any = null; 1302 1303 const db = { 1304 transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<any>) => { 1305 const tx = { 1306 insert: vi.fn().mockImplementation(() => ({ 1307 values: vi.fn().mockImplementation((vals: any) => { 1308 insertCallCount++; 1309 if (insertCallCount === 1) { 1310 policyInsertValues = vals; 1311 return { returning: vi.fn().mockResolvedValue([{ id: 1n }]) }; 1312 } 1313 availableThemesInsertValues = vals; 1314 return Promise.resolve(undefined); 1315 }), 1316 })), 1317 delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), 1318 update: vi.fn(), 1319 select: vi.fn().mockReturnValue({ 1320 from: vi.fn().mockReturnValue({ 1321 where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), 1322 }), 1323 }), 1324 }; 1325 return await callback(tx); 1326 }), 1327 } as unknown as Database; 1328 1329 return { 1330 db, 1331 getPolicyInsertValues: () => policyInsertValues, 1332 getAvailableThemesInsertValues: () => availableThemesInsertValues, 1333 }; 1334 } 1335 1336 it("indexes themePolicy with flat themeRef URIs — live refs (no CID)", async () => { 1337 // This test verifies the field access uses .uri directly (not .theme.uri from old strongRef). 1338 // If the old .defaultLightTheme.theme.uri path were used, this would throw TypeError. 1339 const { db: trackingDb, getPolicyInsertValues, getAvailableThemesInsertValues } = 1340 createThemePolicyTrackingDb(); 1341 const themePolicyIndexer = new Indexer(trackingDb, mockLogger); 1342 1343 const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { 1344 did: "did:plc:forum", 1345 time_us: 1234567890, 1346 kind: "commit", 1347 commit: { 1348 rev: "abc", 1349 operation: "create", 1350 collection: "space.atbb.forum.themePolicy", 1351 rkey: "self", 1352 cid: "cidPolicy", 1353 record: { 1354 $type: "space.atbb.forum.themePolicy", 1355 defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 1356 defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 1357 availableThemes: [ 1358 { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 1359 { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 1360 ], 1361 allowUserChoice: true, 1362 updatedAt: "2026-01-01T00:00:00Z", 1363 } as any, 1364 }, 1365 }; 1366 1367 await expect(themePolicyIndexer.handleThemePolicyCreate(event)).resolves.not.toThrow(); 1368 1369 const policyVals = getPolicyInsertValues(); 1370 expect(policyVals).toBeDefined(); 1371 expect(policyVals.defaultLightThemeUri).toBe( 1372 "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" 1373 ); 1374 expect(policyVals.defaultDarkThemeUri).toBe( 1375 "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" 1376 ); 1377 1378 const availableVals = getAvailableThemesInsertValues(); 1379 expect(availableVals).toBeDefined(); 1380 expect(availableVals[0].themeUri).toBe( 1381 "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" 1382 ); 1383 // Live refs: no CID in record → themeCid must be null in DB row 1384 expect(availableVals[0].themeCid).toBeNull(); 1385 }); 1386 1387 it("indexes themePolicy with pinned themeRefs (CID present)", async () => { 1388 const { db: trackingDb, getAvailableThemesInsertValues } = 1389 createThemePolicyTrackingDb(); 1390 const themePolicyIndexer = new Indexer(trackingDb, mockLogger); 1391 1392 const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { 1393 did: "did:plc:forum", 1394 time_us: 1234567890, 1395 kind: "commit", 1396 commit: { 1397 rev: "abc", 1398 operation: "create", 1399 collection: "space.atbb.forum.themePolicy", 1400 rkey: "self", 1401 cid: "cidPolicy2", 1402 record: { 1403 $type: "space.atbb.forum.themePolicy", 1404 defaultLightTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, 1405 defaultDarkTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, 1406 availableThemes: [ 1407 { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, 1408 { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, 1409 ], 1410 allowUserChoice: false, 1411 updatedAt: "2026-01-01T00:00:00Z", 1412 } as any, 1413 }, 1414 }; 1415 1416 await themePolicyIndexer.handleThemePolicyCreate(event); 1417 1418 const availableVals = getAvailableThemesInsertValues(); 1419 expect(availableVals[0].themeCid).toBe("bafylight"); 1420 expect(availableVals[1].themeCid).toBe("bafydark"); 1421 }); 1422 }); 1423 1424 describe("Ban enforcement — handleModActionCreate", () => { 1425 it("calls applyBan when a ban mod action is created", async () => { 1426 const mockBanEnforcer = (indexer as any).banEnforcer; 1427 1428 // Set up select to return a forum (getForumIdByDid) and then ensureUser 1429 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 1430 from: vi.fn().mockReturnValue({ 1431 where: vi.fn().mockReturnValue({ 1432 limit: vi.fn().mockResolvedValue([{ id: 1n }]), 1433 }), 1434 }), 1435 }); 1436 1437 const event = { 1438 did: "did:plc:forum", 1439 time_us: 1234567890, 1440 kind: "commit", 1441 commit: { 1442 rev: "abc", 1443 operation: "create", 1444 collection: "space.atbb.modAction", 1445 rkey: "action1", 1446 cid: "cid123", 1447 record: { 1448 $type: "space.atbb.modAction", 1449 action: "space.atbb.modAction.ban", 1450 subject: { did: "did:plc:target123" }, 1451 createdBy: "did:plc:mod", 1452 createdAt: "2024-01-01T00:00:00Z", 1453 }, 1454 }, 1455 } as any; 1456 1457 await indexer.handleModActionCreate(event); 1458 1459 expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object)); 1460 }); 1461 1462 it("does NOT call applyBan for non-ban actions (e.g. pin)", async () => { 1463 const mockBanEnforcer = (indexer as any).banEnforcer; 1464 1465 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 1466 from: vi.fn().mockReturnValue({ 1467 where: vi.fn().mockReturnValue({ 1468 limit: vi.fn().mockResolvedValue([{ id: 1n }]), 1469 }), 1470 }), 1471 }); 1472 1473 const event = { 1474 did: "did:plc:forum", 1475 time_us: 1234567890, 1476 kind: "commit", 1477 commit: { 1478 rev: "abc", 1479 operation: "create", 1480 collection: "space.atbb.modAction", 1481 rkey: "action2", 1482 cid: "cid124", 1483 record: { 1484 $type: "space.atbb.modAction", 1485 action: "space.atbb.modAction.pin", 1486 subject: { post: { uri: "at://did:plc:user/space.atbb.post/abc", cid: "cid" } }, 1487 createdBy: "did:plc:mod", 1488 createdAt: "2024-01-01T00:00:00Z", 1489 }, 1490 }, 1491 } as any; 1492 1493 await indexer.handleModActionCreate(event); 1494 1495 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); 1496 }); 1497 1498 it("does NOT call applyBan when the ban record insert was skipped (unknown forum DID)", async () => { 1499 const mockBanEnforcer = (indexer as any).banEnforcer; 1500 1501 // Select returns empty — forum DID not found, toInsertValues returns null → insert skipped 1502 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 1503 from: vi.fn().mockReturnValue({ 1504 where: vi.fn().mockReturnValue({ 1505 limit: vi.fn().mockResolvedValue([]), 1506 }), 1507 }), 1508 }); 1509 1510 const event = { 1511 did: "did:plc:unknown-forum", 1512 time_us: 1234567890, 1513 kind: "commit", 1514 commit: { 1515 rev: "abc", 1516 operation: "create", 1517 collection: "space.atbb.modAction", 1518 rkey: "action1", 1519 cid: "cid123", 1520 record: { 1521 $type: "space.atbb.modAction", 1522 action: "space.atbb.modAction.ban", 1523 subject: { did: "did:plc:target123" }, 1524 createdBy: "did:plc:mod", 1525 createdAt: "2024-01-01T00:00:00Z", 1526 }, 1527 }, 1528 } as any; 1529 1530 await indexer.handleModActionCreate(event); 1531 1532 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); 1533 }); 1534 1535 it("logs warning and skips applyBan when ban action has no subject.did", async () => { 1536 const mockBanEnforcer = (indexer as any).banEnforcer; 1537 1538 // Mock select to return forum found 1539 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 1540 from: vi.fn().mockReturnValue({ 1541 where: vi.fn().mockReturnValue({ 1542 limit: vi.fn().mockResolvedValue([{ id: 1n }]), 1543 }), 1544 }), 1545 }); 1546 1547 const event = { 1548 did: "did:plc:forum", 1549 time_us: 1234567890, 1550 kind: "commit", 1551 commit: { 1552 rev: "abc", 1553 operation: "create", 1554 collection: "space.atbb.modAction", 1555 rkey: "action1", 1556 cid: "cid123", 1557 record: { 1558 $type: "space.atbb.modAction", 1559 action: "space.atbb.modAction.ban", 1560 subject: {}, // no did field 1561 createdBy: "did:plc:mod", 1562 createdAt: "2024-01-01T00:00:00Z", 1563 }, 1564 }, 1565 } as any; 1566 1567 await indexer.handleModActionCreate(event); 1568 1569 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); 1570 expect(mockLogger.warn).toHaveBeenCalledWith( 1571 expect.stringContaining("missing subject.did"), 1572 expect.any(Object) 1573 ); 1574 }); 1575 1576 it("calls liftBan when an unban mod action is indexed", async () => { 1577 const mockBanEnforcer = (indexer as any).banEnforcer; 1578 1579 // Mock select to return forum found 1580 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 1581 from: vi.fn().mockReturnValue({ 1582 where: vi.fn().mockReturnValue({ 1583 limit: vi.fn().mockResolvedValue([{ id: 1n }]), 1584 }), 1585 }), 1586 }); 1587 1588 const event = { 1589 did: "did:plc:forum", 1590 time_us: 1234567891, 1591 kind: "commit", 1592 commit: { 1593 rev: "def", 1594 operation: "create", 1595 collection: "space.atbb.modAction", 1596 rkey: "action2", 1597 cid: "cid124", 1598 record: { 1599 $type: "space.atbb.modAction", 1600 action: "space.atbb.modAction.unban", 1601 subject: { did: "did:plc:target123" }, 1602 createdBy: "did:plc:mod", 1603 createdAt: "2024-01-01T00:00:01Z", 1604 }, 1605 }, 1606 } as any; 1607 1608 await indexer.handleModActionCreate(event); 1609 1610 expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith( 1611 "did:plc:target123", 1612 expect.any(Object) // transaction context 1613 ); 1614 expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); 1615 }); 1616 it("race condition: post indexed before ban — ban retroactively hides it", async () => { 1617 const mockBanEnforcer = (indexer as any).banEnforcer; 1618 1619 // Step 1: Post arrives before ban — isBanned returns false at this moment 1620 mockBanEnforcer.isBanned.mockResolvedValueOnce(false); 1621 1622 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 1623 from: vi.fn().mockReturnValue({ 1624 where: vi.fn().mockReturnValue({ 1625 limit: vi.fn().mockResolvedValue([{ id: 1n }]), 1626 }), 1627 }), 1628 }); 1629 1630 const postEvent = { 1631 did: "did:plc:target123", 1632 time_us: 1234567890, 1633 kind: "commit", 1634 commit: { 1635 rev: "abc", 1636 operation: "create", 1637 collection: "space.atbb.post", 1638 rkey: "post1", 1639 cid: "cid123", 1640 record: { 1641 $type: "space.atbb.post", 1642 text: "Hello world", 1643 createdAt: "2024-01-01T00:00:00Z", 1644 }, 1645 }, 1646 } as any; 1647 1648 await indexer.handlePostCreate(postEvent); 1649 expect(mockDb.insert).toHaveBeenCalled(); // post was actually inserted into DB before ban arrived 1650 1651 // Step 2: Ban arrives — applyBan retroactively hides the post 1652 (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 1653 from: vi.fn().mockReturnValue({ 1654 where: vi.fn().mockReturnValue({ 1655 limit: vi.fn().mockResolvedValue([{ id: 1n }]), 1656 }), 1657 }), 1658 }); 1659 1660 const banEvent = { 1661 did: "did:plc:forum", 1662 time_us: 1234567891, 1663 kind: "commit", 1664 commit: { 1665 rev: "def", 1666 operation: "create", 1667 collection: "space.atbb.modAction", 1668 rkey: "action1", 1669 cid: "cid124", 1670 record: { 1671 $type: "space.atbb.modAction", 1672 action: "space.atbb.modAction.ban", 1673 subject: { did: "did:plc:target123" }, 1674 createdBy: "did:plc:mod", 1675 createdAt: "2024-01-01T00:00:01Z", 1676 }, 1677 }, 1678 } as any; 1679 1680 await indexer.handleModActionCreate(banEvent); 1681 expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object)); 1682 }); 1683 }); 1684 1685 describe("Ban enforcement — handleModActionDelete", () => { 1686 it("calls liftBan when a ban record is deleted", async () => { 1687 const mockBanEnforcer = (indexer as any).banEnforcer; 1688 1689 // Transaction mock: select returns a ban record, delete succeeds 1690 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation( 1691 async (callback: (tx: any) => Promise<any>) => { 1692 const tx = { 1693 select: vi.fn().mockReturnValue({ 1694 from: vi.fn().mockReturnValue({ 1695 where: vi.fn().mockReturnValue({ 1696 limit: vi.fn().mockResolvedValue([ 1697 { 1698 action: "space.atbb.modAction.ban", 1699 subjectDid: "did:plc:target123", 1700 }, 1701 ]), 1702 }), 1703 }), 1704 }), 1705 delete: vi.fn().mockReturnValue({ 1706 where: vi.fn().mockResolvedValue(undefined), 1707 }), 1708 }; 1709 return callback(tx); 1710 } 1711 ); 1712 1713 const event = { 1714 did: "did:plc:forum", 1715 time_us: 1234567890, 1716 kind: "commit", 1717 commit: { 1718 rev: "abc", 1719 operation: "delete", 1720 collection: "space.atbb.modAction", 1721 rkey: "action1", 1722 }, 1723 } as any; 1724 1725 await indexer.handleModActionDelete(event); 1726 1727 expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith( 1728 "did:plc:target123", 1729 expect.anything() // the transaction 1730 ); 1731 }); 1732 1733 it("does NOT call liftBan when a non-ban record is deleted", async () => { 1734 const mockBanEnforcer = (indexer as any).banEnforcer; 1735 1736 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation( 1737 async (callback: (tx: any) => Promise<any>) => { 1738 const tx = { 1739 select: vi.fn().mockReturnValue({ 1740 from: vi.fn().mockReturnValue({ 1741 where: vi.fn().mockReturnValue({ 1742 limit: vi.fn().mockResolvedValue([ 1743 { 1744 action: "space.atbb.modAction.pin", 1745 subjectDid: null, 1746 }, 1747 ]), 1748 }), 1749 }), 1750 }), 1751 delete: vi.fn().mockReturnValue({ 1752 where: vi.fn().mockResolvedValue(undefined), 1753 }), 1754 }; 1755 return callback(tx); 1756 } 1757 ); 1758 1759 const event = { 1760 did: "did:plc:forum", 1761 time_us: 1234567890, 1762 kind: "commit", 1763 commit: { 1764 rev: "abc", 1765 operation: "delete", 1766 collection: "space.atbb.modAction", 1767 rkey: "action2", 1768 }, 1769 } as any; 1770 1771 await indexer.handleModActionDelete(event); 1772 1773 expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled(); 1774 }); 1775 1776 it("does NOT call liftBan when the record is not found (already deleted)", async () => { 1777 const mockBanEnforcer = (indexer as any).banEnforcer; 1778 1779 (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation( 1780 async (callback: (tx: any) => Promise<any>) => { 1781 const tx = { 1782 select: vi.fn().mockReturnValue({ 1783 from: vi.fn().mockReturnValue({ 1784 where: vi.fn().mockReturnValue({ 1785 limit: vi.fn().mockResolvedValue([]), 1786 }), 1787 }), 1788 }), 1789 delete: vi.fn().mockReturnValue({ 1790 where: vi.fn().mockResolvedValue(undefined), 1791 }), 1792 }; 1793 return callback(tx); 1794 } 1795 ); 1796 1797 const event = { 1798 did: "did:plc:forum", 1799 time_us: 1234567890, 1800 kind: "commit", 1801 commit: { 1802 rev: "abc", 1803 operation: "delete", 1804 collection: "space.atbb.modAction", 1805 rkey: "action3", 1806 }, 1807 } as any; 1808 1809 await indexer.handleModActionDelete(event); 1810 1811 expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled(); 1812 }); 1813 }); 1814});