import { describe, it, expect, beforeEach, vi } from "vitest"; import { Indexer } from "../indexer.js"; import { createMockLogger } from "./mock-logger.js"; import type { Database } from "@atbb/db"; import { memberships } from "@atbb/db"; import type { CommitCreateEvent, CommitUpdateEvent, CommitDeleteEvent } from "@skyware/jetstream"; vi.mock("../ban-enforcer.js", () => ({ BanEnforcer: vi.fn().mockImplementation(() => ({ isBanned: vi.fn().mockResolvedValue(false), applyBan: vi.fn().mockResolvedValue(undefined), liftBan: vi.fn().mockResolvedValue(undefined), })), })); // Mock database const createMockDb = () => { const mockInsert = vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined), }); const mockUpdate = vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), }); const mockDelete = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }); const mockSelect = vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), }); const mockTransaction = vi.fn().mockImplementation(async (callback) => { // Create a transaction context that has the same methods as the db const txContext = { insert: mockInsert, update: mockUpdate, delete: mockDelete, select: mockSelect, }; // Execute the callback with the transaction context return await callback(txContext); }); return { insert: mockInsert, update: mockUpdate, delete: mockDelete, select: mockSelect, transaction: mockTransaction, } as unknown as Database; }; /** * Builds a mock DB whose transaction captures values passed to insert/update. * selectResults controls what FK lookup queries return (e.g. board/forum IDs). */ function createTrackingDb(selectResults: any[] = []) { let insertedValues: any = null; let updatedValues: any = null; const db = createMockDb(); db.transaction = vi.fn().mockImplementation(async (callback) => { const txContext = { insert: vi.fn().mockImplementation((_table: any) => ({ values: vi.fn().mockImplementation((vals: any) => { insertedValues = vals; return Promise.resolve(undefined); }), })), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(selectResults), }), }), }), update: vi.fn().mockImplementation((_table: any) => ({ set: vi.fn().mockImplementation((vals: any) => { updatedValues = vals; return { where: vi.fn().mockResolvedValue(undefined) }; }), })), delete: vi.fn(), }; return await callback(txContext); }); return { db, getInsertedValues: () => insertedValues, getUpdatedValues: () => updatedValues, }; } describe("Indexer", () => { let mockDb: Database; let indexer: Indexer; let mockLogger: ReturnType; beforeEach(() => { vi.clearAllMocks(); mockDb = createMockDb(); mockLogger = createMockLogger(); indexer = new Indexer(mockDb, mockLogger); }); describe("Post Handler", () => { it("should handle post creation with minimal fields", async () => { const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Hello world", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handlePostCreate(event); expect(mockDb.insert).toHaveBeenCalled(); }); it("should handle post creation with forum reference", async () => { const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Hello world", forum: { forum: { uri: "at://did:plc:forum/space.atbb.forum/self", cid: "cidForum", }, }, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handlePostCreate(event); expect(mockDb.insert).toHaveBeenCalled(); }); it("should handle post creation with board reference", async () => { const mockBoardId = BigInt(123); const { db: trackingDb, getInsertedValues } = createTrackingDb([{ id: mockBoardId }]); const boardIndexer = new Indexer(trackingDb, mockLogger); const boardUri = "at://did:plc:forum/space.atbb.forum.board/board1"; const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Hello world in a board", forum: { forum: { uri: "at://did:plc:forum/space.atbb.forum.forum/self", cid: "cidForum", }, }, board: { board: { uri: boardUri, cid: "cidBoard", }, }, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await boardIndexer.handlePostCreate(event); // Verify the insert values include boardUri and boardId const insertedValues = getInsertedValues(); expect(insertedValues).toBeDefined(); expect(insertedValues.boardUri).toBe(boardUri); expect(insertedValues.boardId).toBe(mockBoardId); }); it("should handle post creation with reply references", async () => { const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post2", cid: "cid456", record: { $type: "space.atbb.post", text: "Reply text", reply: { root: { uri: "at://did:plc:user1/space.atbb.post/post1", cid: "cidRoot", }, parent: { uri: "at://did:plc:user1/space.atbb.post/post1", cid: "cidParent", }, }, createdAt: "2024-01-01T01:00:00Z", } as any, }, }; await indexer.handlePostCreate(event); expect(mockDb.insert).toHaveBeenCalled(); }); it("resolves rootPostId and parentPostId when reply ref has correct $type", async () => { const mockPostId = 99n; const { db: trackingDb, getInsertedValues } = createTrackingDb([{ id: mockPostId }]); const replyIndexer = new Indexer(trackingDb, mockLogger); const rootUri = "at://did:plc:user1/space.atbb.post/topic1"; const parentUri = "at://did:plc:user1/space.atbb.post/reply1"; const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "reply2", cid: "cid789", record: { $type: "space.atbb.post", text: "A properly-typed reply", reply: { $type: "space.atbb.post#replyRef", root: { uri: rootUri, cid: "cidRoot" }, parent: { uri: parentUri, cid: "cidParent" }, }, createdAt: "2024-01-01T02:00:00Z", } as any, }, }; await replyIndexer.handlePostCreate(event); const vals = getInsertedValues(); expect(vals).toBeDefined(); // Correctly-typed reply ref: IDs must be resolved to non-null values expect(vals.rootPostId).toBe(mockPostId); expect(vals.parentPostId).toBe(mockPostId); expect(vals.rootUri).toBe(rootUri); expect(vals.parentUri).toBe(parentUri); // No error should be logged for a well-formed reply expect(mockLogger.error).not.toHaveBeenCalledWith( expect.stringContaining("reply ref missing $type"), expect.any(Object) ); }); it("strips title when indexing a reply on create (title: null regardless of record)", async () => { const { db: trackingDb, getInsertedValues } = createTrackingDb(); const replyIndexer = new Indexer(trackingDb, mockLogger); const rootParentUri = "at://did:plc:user1/space.atbb.post/topic1"; const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "reply1", cid: "cid456", record: { $type: "space.atbb.post", text: "Reply with a title (should be stripped)", title: "This should not be stored", // No $type on the reply ref — isReplyRef() returns false; // rootPostId/parentPostId are null while rootUri/parentUri are populated via optional chaining. reply: { root: { uri: rootParentUri, cid: "cidRoot" }, parent: { uri: rootParentUri, cid: "cidParent" }, }, createdAt: "2024-01-01T01:00:00Z", } as any, }, }; await replyIndexer.handlePostCreate(event); const vals = getInsertedValues(); expect(vals).toBeDefined(); expect(vals.title).toBeNull(); // $type-less reply ref: IDs not resolved, URIs populated via optional chaining expect(vals.rootPostId).toBeNull(); expect(vals.parentPostId).toBeNull(); expect(vals.rootUri).toBe(rootParentUri); expect(vals.parentUri).toBe(rootParentUri); // Operators must be alerted — a post with null thread IDs is silently unreachable expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("reply ref missing $type"), expect.objectContaining({ errorId: "POST_REPLY_REF_MISSING_TYPE" }) ); }); it("strips title when indexing a reply on update (title: null regardless of record)", async () => { const { db: trackingDb, getUpdatedValues } = createTrackingDb(); const replyIndexer = new Indexer(trackingDb, mockLogger); const updateEvent: CommitUpdateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.post", rkey: "reply1", cid: "cid789", record: { $type: "space.atbb.post", text: "Updated reply text", title: "Title that should be stripped on update", reply: { root: { uri: "at://did:plc:user1/space.atbb.post/topic1", cid: "cidRoot" }, parent: { uri: "at://did:plc:user1/space.atbb.post/topic1", cid: "cidParent" }, }, createdAt: "2024-01-01T01:00:00Z", } as any, }, }; await replyIndexer.handlePostUpdate(updateEvent); const vals = getUpdatedValues(); expect(vals).toBeDefined(); expect(vals.title).toBeNull(); }); it("preserves title when indexing a topic starter on create", async () => { const { db: trackingDb, getInsertedValues } = createTrackingDb(); const topicIndexer = new Indexer(trackingDb, mockLogger); const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "topic1", cid: "cid111", record: { $type: "space.atbb.post", text: "Topic body text", title: "My topic title", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await topicIndexer.handlePostCreate(event); const vals = getInsertedValues(); expect(vals).toBeDefined(); expect(vals.title).toBe("My topic title"); expect(vals.rootPostId).toBeNull(); expect(vals.parentPostId).toBeNull(); expect(vals.rootUri).toBeNull(); expect(vals.parentUri).toBeNull(); }); it("preserves title when indexing a topic starter on update", async () => { const { db: trackingDb, getUpdatedValues } = createTrackingDb(); const topicIndexer = new Indexer(trackingDb, mockLogger); const event: CommitUpdateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.post", rkey: "topic1", cid: "cid222", record: { $type: "space.atbb.post", text: "Updated topic body", title: "Updated topic title", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await topicIndexer.handlePostUpdate(event); const vals = getUpdatedValues(); expect(vals).toBeDefined(); expect(vals.title).toBe("Updated topic title"); }); it("should handle post update", async () => { const event: CommitUpdateEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.post", rkey: "post1", cid: "cid789", record: { $type: "space.atbb.post", text: "Updated text", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handlePostUpdate(event); expect(mockDb.update).toHaveBeenCalled(); }); it("should handle post deletion with soft delete", async () => { const event: CommitDeleteEvent<"space.atbb.post"> = { did: "did:plc:test123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.post", rkey: "post1", }, }; await indexer.handlePostDelete(event); expect(mockDb.update).toHaveBeenCalled(); }); }); describe("Forum Handler", () => { it("should handle forum creation", async () => { const event: CommitCreateEvent<"space.atbb.forum.forum"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.forum", rkey: "self", cid: "cidForum", record: { $type: "space.atbb.forum.forum", name: "Test Forum", description: "A test forum", } as any, }, }; await indexer.handleForumCreate(event); expect(mockDb.insert).toHaveBeenCalled(); }); it("should handle forum update", async () => { const event: CommitUpdateEvent<"space.atbb.forum.forum"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.forum.forum", rkey: "self", cid: "cidForumNew", record: { $type: "space.atbb.forum.forum", name: "Updated Forum Name", description: "Updated description", } as any, }, }; await indexer.handleForumUpdate(event); expect(mockDb.update).toHaveBeenCalled(); }); it("should handle forum deletion", async () => { const event: CommitDeleteEvent<"space.atbb.forum.forum"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.forum.forum", rkey: "self", }, }; await indexer.handleForumDelete(event); expect(mockDb.delete).toHaveBeenCalled(); }); }); describe("Category Handler", () => { it("should handle category creation without errors", async () => { const event: CommitCreateEvent<"space.atbb.forum.category"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.category", rkey: "cat1", cid: "cidCat", record: { $type: "space.atbb.forum.category", name: "General Discussion", forum: { forum: { uri: "at://did:plc:forum/space.atbb.forum/self", cid: "cidForum", }, }, slug: "general-discussion", sortOrder: 0, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; // Test that function executes without throwing // Note: Since forum doesn't exist in mock, it will skip insertion await expect(indexer.handleCategoryCreate(event)).resolves.not.toThrow(); }); it("should skip category creation if forum not found", async () => { // Mock failed forum lookup vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), } as any); const event: CommitCreateEvent<"space.atbb.forum.category"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.category", rkey: "cat1", cid: "cidCat", record: { $type: "space.atbb.forum.category", name: "General Discussion", forum: { forum: { uri: "at://did:plc:forum/space.atbb.forum/self", cid: "cidForum", }, }, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handleCategoryCreate(event); expect(mockDb.insert).not.toHaveBeenCalled(); }); }); // ── Critical Test Coverage for Refactored Generic Methods ── // These tests verify behavioral equivalence after consolidating // 15 handler methods into data-driven collection configs describe("Transaction Rollback Behavior", () => { it("should rollback when ensureUser throws", async () => { const mockDbWithError = createMockDb(); mockDbWithError.transaction = vi.fn().mockImplementation(async (callback) => { const txContext = { insert: vi.fn().mockRejectedValue(new Error("User creation failed")), update: vi.fn(), delete: vi.fn(), select: vi.fn(), }; await callback(txContext); }); const indexer = new Indexer(mockDbWithError, mockLogger); const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Test", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await expect(indexer.handlePostCreate(event)).rejects.toThrow(); }); it("should rollback when insert fails after FK lookup", async () => { const mockDbWithError = createMockDb(); mockDbWithError.transaction = vi.fn().mockImplementation(async (callback) => { const txContext = { insert: vi.fn().mockReturnValue({ values: vi.fn().mockRejectedValue(new Error("Foreign key constraint failed")), }), update: vi.fn(), delete: vi.fn(), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: BigInt(1) }]), }), }), }), }; await callback(txContext); }); const indexer = new Indexer(mockDbWithError, mockLogger); const event: CommitCreateEvent<"space.atbb.forum.category"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.category", rkey: "cat1", cid: "cidCat", record: { $type: "space.atbb.forum.category", name: "General", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await expect(indexer.handleCategoryCreate(event)).rejects.toThrow("Foreign key constraint failed"); }); it("should log error and re-throw when database operation fails", async () => { const mockDbWithError = createMockDb(); mockDbWithError.transaction = vi.fn().mockRejectedValue(new Error("Database connection lost")); const indexer = new Indexer(mockDbWithError, mockLogger); const event: CommitCreateEvent<"space.atbb.forum.forum"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.forum", rkey: "self", cid: "cidForum", record: { $type: "space.atbb.forum.forum", name: "Test Forum", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await expect(indexer.handleForumCreate(event)).rejects.toThrow("Database connection lost"); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("Failed to index forum create"), expect.objectContaining({ error: "Database connection lost" }) ); }); }); describe("Null Return Path Verification", () => { it("should not insert category when getForumIdByDid returns null", async () => { vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), } as any); const event: CommitCreateEvent<"space.atbb.forum.category"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.category", rkey: "cat1", cid: "cidCat", record: { $type: "space.atbb.forum.category", name: "General", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handleCategoryCreate(event); expect(mockDb.insert).not.toHaveBeenCalled(); }); it("should not update category when getForumIdByDid returns null", async () => { vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), } as any); const event: CommitUpdateEvent<"space.atbb.forum.category"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.forum.category", rkey: "cat1", cid: "cidCat2", record: { $type: "space.atbb.forum.category", name: "General Updated", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handleCategoryUpdate(event); expect(mockDb.update).not.toHaveBeenCalled(); }); it("should not insert membership when getForumIdByUri returns null", async () => { // Note: ensureUser() will still insert a user record before forum lookup // This test verifies the membership insert is skipped, not that zero inserts happen let membershipInsertCalled = false; const mockDbWithTracking = createMockDb(); mockDbWithTracking.transaction = vi.fn().mockImplementation(async (callback) => { const txContext = { insert: vi.fn().mockImplementation((table: any) => { // Track if membership table insert is attempted if (table === memberships) { membershipInsertCalled = true; } return { values: vi.fn().mockResolvedValue(undefined), }; }), update: vi.fn(), delete: vi.fn(), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), // Forum not found }), }), }), }; return await callback(txContext); }); const indexer = new Indexer(mockDbWithTracking, mockLogger); const event: CommitCreateEvent<"space.atbb.membership"> = { did: "did:plc:user", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.membership", rkey: "membership1", cid: "cidMembership", record: { $type: "space.atbb.membership", forum: { forum: { uri: "at://did:plc:forum/space.atbb.forum.forum/self", cid: "cidForum", }, }, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handleMembershipCreate(event); expect(membershipInsertCalled).toBe(false); }); it("should not update membership when getForumIdByUri returns null", async () => { vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), } as any); const event: CommitUpdateEvent<"space.atbb.membership"> = { did: "did:plc:user", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.membership", rkey: "membership1", cid: "cidMembership2", record: { $type: "space.atbb.membership", forum: { forum: { uri: "at://did:plc:forum/space.atbb.forum.forum/self", cid: "cidForum", }, }, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handleMembershipUpdate(event); expect(mockDb.update).not.toHaveBeenCalled(); }); it("should not insert modAction when getForumIdByDid returns null", async () => { vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), } as any); const event: CommitCreateEvent<"space.atbb.modAction"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.modAction", rkey: "action1", cid: "cidAction", record: { $type: "space.atbb.modAction", action: "ban", createdBy: "did:plc:moderator", subject: { did: "did:plc:baduser", }, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handleModActionCreate(event); expect(mockDb.insert).not.toHaveBeenCalled(); }); it("should not update modAction when getForumIdByDid returns null", async () => { vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), } as any); const event: CommitUpdateEvent<"space.atbb.modAction"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.modAction", rkey: "action1", cid: "cidAction2", record: { $type: "space.atbb.modAction", action: "unban", createdBy: "did:plc:moderator", subject: { did: "did:plc:baduser", }, createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await indexer.handleModActionUpdate(event); expect(mockDb.update).not.toHaveBeenCalled(); }); }); describe("Error Re-throwing Behavior", () => { it("should re-throw errors from genericCreate", async () => { const mockDbWithError = createMockDb(); mockDbWithError.transaction = vi.fn().mockRejectedValue(new Error("Database error")); const indexer = new Indexer(mockDbWithError, mockLogger); const event: CommitCreateEvent<"space.atbb.post"> = { did: "did:plc:test", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Test", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await expect(indexer.handlePostCreate(event)).rejects.toThrow("Database error"); }); it("should re-throw errors from genericUpdate", async () => { const mockDbWithError = createMockDb(); mockDbWithError.transaction = vi.fn().mockRejectedValue(new Error("Update failed")); const indexer = new Indexer(mockDbWithError, mockLogger); const event: CommitUpdateEvent<"space.atbb.forum.forum"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "update", collection: "space.atbb.forum.forum", rkey: "self", cid: "cidForum2", record: { $type: "space.atbb.forum.forum", name: "Updated Forum", createdAt: "2024-01-01T00:00:00Z", } as any, }, }; await expect(indexer.handleForumUpdate(event)).rejects.toThrow("Update failed"); }); it("should re-throw errors from genericDelete (soft)", async () => { const mockDbWithError = createMockDb(); mockDbWithError.update = vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(new Error("Soft delete failed")), }), }); const indexer = new Indexer(mockDbWithError, mockLogger); const event: CommitDeleteEvent<"space.atbb.post"> = { did: "did:plc:test", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.post", rkey: "post1", }, }; await expect(indexer.handlePostDelete(event)).rejects.toThrow("Soft delete failed"); }); it("should re-throw errors from genericDelete (hard)", async () => { const mockDbWithError = createMockDb(); mockDbWithError.delete = vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(new Error("Hard delete failed")), }); const indexer = new Indexer(mockDbWithError, mockLogger); const event: CommitDeleteEvent<"space.atbb.forum.forum"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.forum.forum", rkey: "self", }, }; await expect(indexer.handleForumDelete(event)).rejects.toThrow("Hard delete failed"); }); it("re-throws errors from handleModActionDelete and logs with context", async () => { (mockDb.transaction as ReturnType).mockRejectedValueOnce( new Error("Transaction aborted") ); const event = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.modAction", rkey: "action1", }, } as any; await expect(indexer.handleModActionDelete(event)).rejects.toThrow("Transaction aborted"); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("Failed to delete modAction"), expect.objectContaining({ error: "Transaction aborted" }) ); }); }); describe("Delete Strategy Verification", () => { it("should tombstone posts using db.update (preserves row for FK stability)", async () => { // Capture mockSet to verify the exact tombstone payload const mockWhere = vi.fn().mockResolvedValue(undefined); const mockSet = vi.fn().mockReturnValue({ where: mockWhere }); (mockDb.update as ReturnType).mockReturnValueOnce({ set: mockSet }); const event: CommitDeleteEvent<"space.atbb.post"> = { did: "did:plc:test", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.post", rkey: "post1", }, }; await indexer.handlePostDelete(event); // Tombstone: row is updated (content replaced), not hard-deleted expect(mockDb.update).toHaveBeenCalled(); expect(mockDb.delete).not.toHaveBeenCalled(); // Must set the exact tombstone payload — not bannedByMod, not deleted expect(mockSet).toHaveBeenCalledWith({ text: "[user deleted this post]", deletedByUser: true, }); expect(mockSet).not.toHaveBeenCalledWith( expect.objectContaining({ bannedByMod: expect.anything() }) ); expect(mockSet).not.toHaveBeenCalledWith( expect.objectContaining({ deleted: expect.anything() }) ); }); it("should hard delete forums using db.delete", async () => { const event: CommitDeleteEvent<"space.atbb.forum.forum"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.forum.forum", rkey: "self", }, }; await indexer.handleForumDelete(event); expect(mockDb.delete).toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled(); }); it("should hard delete categories using db.delete", async () => { const event: CommitDeleteEvent<"space.atbb.forum.category"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.forum.category", rkey: "cat1", }, }; await indexer.handleCategoryDelete(event); expect(mockDb.delete).toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled(); }); it("should hard delete memberships using db.delete", async () => { const event: CommitDeleteEvent<"space.atbb.membership"> = { did: "did:plc:user", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.membership", rkey: "membership1", }, }; await indexer.handleMembershipDelete(event); expect(mockDb.delete).toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled(); }); it("should hard delete modActions using db.delete", async () => { const event: CommitDeleteEvent<"space.atbb.modAction"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.modAction", rkey: "action1", }, }; await indexer.handleModActionDelete(event); expect(mockDb.delete).toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled(); }); }); describe("Ban enforcement — handlePostCreate", () => { it("skips indexing when the user is banned", async () => { const { BanEnforcer } = await import("../ban-enforcer.js"); const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results.at(-1)!.value; mockBanEnforcer.isBanned.mockResolvedValue(true); const event = { did: "did:plc:banned123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Hello world", createdAt: "2024-01-01T00:00:00Z", }, }, } as any; await indexer.handlePostCreate(event); // The DB insert should NOT have been called expect(mockDb.insert).not.toHaveBeenCalled(); }); it("indexes the post normally when the user is not banned", async () => { const { BanEnforcer } = await import("../ban-enforcer.js"); const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results.at(-1)!.value; mockBanEnforcer.isBanned.mockResolvedValue(false); // Set up select to return a user (ensureUser) and no parent/root posts (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ did: "did:plc:user123" }]), }), }), }); const event = { did: "did:plc:user123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Hello world", createdAt: "2024-01-01T00:00:00Z", }, }, } as any; await indexer.handlePostCreate(event); expect(mockDb.insert).toHaveBeenCalled(); }); }); describe("ThemePolicy Handler", () => { /** * Creates a tracking DB for themePolicy tests. * ThemePolicy's genericCreate path uses afterUpsert, which requires: * 1st insert (themePolicies): .values().returning([{id}]) * delete (themePolicyAvailableThemes): .where() * 2nd insert (themePolicyAvailableThemes): .values() */ function createThemePolicyTrackingDb() { let insertCallCount = 0; let policyInsertValues: any = null; let availableThemesInsertValues: any = null; const db = { transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { const tx = { insert: vi.fn().mockImplementation(() => ({ values: vi.fn().mockImplementation((vals: any) => { insertCallCount++; if (insertCallCount === 1) { policyInsertValues = vals; return { returning: vi.fn().mockResolvedValue([{ id: 1n }]) }; } availableThemesInsertValues = vals; return Promise.resolve(undefined); }), })), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), update: vi.fn(), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), }), }), }; return await callback(tx); }), } as unknown as Database; return { db, getPolicyInsertValues: () => policyInsertValues, getAvailableThemesInsertValues: () => availableThemesInsertValues, }; } it("indexes themePolicy with flat themeRef URIs — live refs (no CID)", async () => { // This test verifies the field access uses .uri directly (not .theme.uri from old strongRef). // If the old .defaultLightTheme.theme.uri path were used, this would throw TypeError. const { db: trackingDb, getPolicyInsertValues, getAvailableThemesInsertValues } = createThemePolicyTrackingDb(); const themePolicyIndexer = new Indexer(trackingDb, mockLogger); const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.themePolicy", rkey: "self", cid: "cidPolicy", record: { $type: "space.atbb.forum.themePolicy", defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, availableThemes: [ { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, ], allowUserChoice: true, updatedAt: "2026-01-01T00:00:00Z", } as any, }, }; await expect(themePolicyIndexer.handleThemePolicyCreate(event)).resolves.not.toThrow(); const policyVals = getPolicyInsertValues(); expect(policyVals).toBeDefined(); expect(policyVals.defaultLightThemeUri).toBe( "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" ); expect(policyVals.defaultDarkThemeUri).toBe( "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" ); const availableVals = getAvailableThemesInsertValues(); expect(availableVals).toBeDefined(); expect(availableVals[0].themeUri).toBe( "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" ); // Live refs: no CID in record → themeCid must be null in DB row expect(availableVals[0].themeCid).toBeNull(); }); it("indexes themePolicy with pinned themeRefs (CID present)", async () => { const { db: trackingDb, getAvailableThemesInsertValues } = createThemePolicyTrackingDb(); const themePolicyIndexer = new Indexer(trackingDb, mockLogger); const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.forum.themePolicy", rkey: "self", cid: "cidPolicy2", record: { $type: "space.atbb.forum.themePolicy", defaultLightTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, defaultDarkTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, ], allowUserChoice: false, updatedAt: "2026-01-01T00:00:00Z", } as any, }, }; await themePolicyIndexer.handleThemePolicyCreate(event); const availableVals = getAvailableThemesInsertValues(); expect(availableVals[0].themeCid).toBe("bafylight"); expect(availableVals[1].themeCid).toBe("bafydark"); }); }); describe("Ban enforcement — handleModActionCreate", () => { it("calls applyBan when a ban mod action is created", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; // Set up select to return a forum (getForumIdByDid) and then ensureUser (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: 1n }]), }), }), }); const event = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.modAction", rkey: "action1", cid: "cid123", record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.ban", subject: { did: "did:plc:target123" }, createdBy: "did:plc:mod", createdAt: "2024-01-01T00:00:00Z", }, }, } as any; await indexer.handleModActionCreate(event); expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object)); }); it("does NOT call applyBan for non-ban actions (e.g. pin)", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: 1n }]), }), }), }); const event = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.modAction", rkey: "action2", cid: "cid124", record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.pin", subject: { post: { uri: "at://did:plc:user/space.atbb.post/abc", cid: "cid" } }, createdBy: "did:plc:mod", createdAt: "2024-01-01T00:00:00Z", }, }, } as any; await indexer.handleModActionCreate(event); expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); }); it("does NOT call applyBan when the ban record insert was skipped (unknown forum DID)", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; // Select returns empty — forum DID not found, toInsertValues returns null → insert skipped (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), }); const event = { did: "did:plc:unknown-forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.modAction", rkey: "action1", cid: "cid123", record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.ban", subject: { did: "did:plc:target123" }, createdBy: "did:plc:mod", createdAt: "2024-01-01T00:00:00Z", }, }, } as any; await indexer.handleModActionCreate(event); expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); }); it("logs warning and skips applyBan when ban action has no subject.did", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; // Mock select to return forum found (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: 1n }]), }), }), }); const event = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.modAction", rkey: "action1", cid: "cid123", record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.ban", subject: {}, // no did field createdBy: "did:plc:mod", createdAt: "2024-01-01T00:00:00Z", }, }, } as any; await indexer.handleModActionCreate(event); expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("missing subject.did"), expect.any(Object) ); }); it("calls liftBan when an unban mod action is indexed", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; // Mock select to return forum found (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: 1n }]), }), }), }); const event = { did: "did:plc:forum", time_us: 1234567891, kind: "commit", commit: { rev: "def", operation: "create", collection: "space.atbb.modAction", rkey: "action2", cid: "cid124", record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.unban", subject: { did: "did:plc:target123" }, createdBy: "did:plc:mod", createdAt: "2024-01-01T00:00:01Z", }, }, } as any; await indexer.handleModActionCreate(event); expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith( "did:plc:target123", expect.any(Object) // transaction context ); expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); }); it("race condition: post indexed before ban — ban retroactively hides it", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; // Step 1: Post arrives before ban — isBanned returns false at this moment mockBanEnforcer.isBanned.mockResolvedValueOnce(false); (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: 1n }]), }), }), }); const postEvent = { did: "did:plc:target123", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "create", collection: "space.atbb.post", rkey: "post1", cid: "cid123", record: { $type: "space.atbb.post", text: "Hello world", createdAt: "2024-01-01T00:00:00Z", }, }, } as any; await indexer.handlePostCreate(postEvent); expect(mockDb.insert).toHaveBeenCalled(); // post was actually inserted into DB before ban arrived // Step 2: Ban arrives — applyBan retroactively hides the post (mockDb.select as ReturnType).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: 1n }]), }), }), }); const banEvent = { did: "did:plc:forum", time_us: 1234567891, kind: "commit", commit: { rev: "def", operation: "create", collection: "space.atbb.modAction", rkey: "action1", cid: "cid124", record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.ban", subject: { did: "did:plc:target123" }, createdBy: "did:plc:mod", createdAt: "2024-01-01T00:00:01Z", }, }, } as any; await indexer.handleModActionCreate(banEvent); expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123", expect.any(Object)); }); }); describe("Ban enforcement — handleModActionDelete", () => { it("calls liftBan when a ban record is deleted", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; // Transaction mock: select returns a ban record, delete succeeds (mockDb.transaction as ReturnType).mockImplementation( async (callback: (tx: any) => Promise) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { action: "space.atbb.modAction.ban", subjectDid: "did:plc:target123", }, ]), }), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), }; return callback(tx); } ); const event = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.modAction", rkey: "action1", }, } as any; await indexer.handleModActionDelete(event); expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith( "did:plc:target123", expect.anything() // the transaction ); }); it("does NOT call liftBan when a non-ban record is deleted", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; (mockDb.transaction as ReturnType).mockImplementation( async (callback: (tx: any) => Promise) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { action: "space.atbb.modAction.pin", subjectDid: null, }, ]), }), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), }; return callback(tx); } ); const event = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.modAction", rkey: "action2", }, } as any; await indexer.handleModActionDelete(event); expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled(); }); it("does NOT call liftBan when the record is not found (already deleted)", async () => { const mockBanEnforcer = (indexer as any).banEnforcer; (mockDb.transaction as ReturnType).mockImplementation( async (callback: (tx: any) => Promise) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), }; return callback(tx); } ); const event = { did: "did:plc:forum", time_us: 1234567890, kind: "commit", commit: { rev: "abc", operation: "delete", collection: "space.atbb.modAction", rkey: "action3", }, } as any; await indexer.handleModActionDelete(event); expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled(); }); }); });