import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { CursorManager } from "../cursor-manager.js"; import { createMockLogger } from "./mock-logger.js"; import type { Database } from "@atbb/db"; describe("CursorManager", () => { let mockDb: Database; let cursorManager: CursorManager; let mockLogger: ReturnType; beforeEach(() => { mockLogger = createMockLogger(); // Create mock database with common patterns const mockInsert = vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), }), }); const mockSelect = vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), }); mockDb = { insert: mockInsert, select: mockSelect, } as unknown as Database; cursorManager = new CursorManager(mockDb, mockLogger); }); afterEach(() => { vi.clearAllMocks(); }); describe("load", () => { it("should return null when no cursor exists", async () => { // Mock empty result vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), }), } as any); const cursor = await cursorManager.load(); expect(cursor).toBeNull(); }); it("should return saved cursor when it exists", async () => { const savedCursor = BigInt(1234567890000000); // Mock cursor retrieval vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ cursor: savedCursor }]), }), }), } as any); const cursor = await cursorManager.load(); expect(cursor).toBe(savedCursor); }); it("should return null and log error on database failure", async () => { // Mock database error vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockRejectedValue(new Error("Database error")), }), }), } as any); const cursor = await cursorManager.load(); expect(cursor).toBeNull(); expect(mockLogger.error).toHaveBeenCalledWith( "Failed to load cursor from database", expect.objectContaining({ error: "Database error" }) ); }); it("should allow custom service name", async () => { const savedCursor = BigInt(9876543210000000); // Mock cursor retrieval const whereFn = vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ cursor: savedCursor }]), }); vi.spyOn(mockDb, "select").mockReturnValue({ from: vi.fn().mockReturnValue({ where: whereFn, }), } as any); const cursor = await cursorManager.load("custom-service"); expect(cursor).toBe(savedCursor); }); }); describe("update", () => { it("should update cursor in database", async () => { const mockInsert = vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), }), }); vi.spyOn(mockDb, "insert").mockImplementation(mockInsert); await cursorManager.update(1234567890000000); expect(mockInsert).toHaveBeenCalled(); }); it("should not throw on database failure", async () => { // Mock database error vi.spyOn(mockDb, "insert").mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockRejectedValue(new Error("Database error")), }), } as any); // Should not throw await expect(cursorManager.update(1234567890000000)).resolves.toBeUndefined(); expect(mockLogger.error).toHaveBeenCalledWith( "Failed to update cursor", expect.objectContaining({ error: "Database error" }) ); }); it("should allow custom service name", async () => { const valuesFn = vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), }); vi.spyOn(mockDb, "insert").mockReturnValue({ values: valuesFn, } as any); await cursorManager.update(1234567890000000, "custom-service"); // Verify values was called with custom service expect(valuesFn).toHaveBeenCalledWith({ service: "custom-service", cursor: BigInt(1234567890000000), updatedAt: expect.any(Date), }); }); }); describe("rewind", () => { it("should rewind cursor by specified microseconds", () => { const cursor = BigInt(1234567890000000); const rewindAmount = 10_000_000; // 10 seconds const rewound = cursorManager.rewind(cursor, rewindAmount); expect(rewound).toBe(cursor - BigInt(rewindAmount)); }); it("should handle zero rewind", () => { const cursor = BigInt(1234567890000000); const rewound = cursorManager.rewind(cursor, 0); expect(rewound).toBe(cursor); }); it("should handle large rewind amounts", () => { const cursor = BigInt(1234567890000000); const rewindAmount = 1_000_000_000; // 1000 seconds const rewound = cursorManager.rewind(cursor, rewindAmount); expect(rewound).toBe(cursor - BigInt(rewindAmount)); }); }); describe("getCursorAgeHours", () => { it("returns null when cursor is null", () => { const age = cursorManager.getCursorAgeHours(null); expect(age).toBeNull(); }); it("calculates age in hours from microsecond cursor", () => { // Cursor from 24 hours ago const twentyFourHoursAgoUs = BigInt( (Date.now() - 24 * 60 * 60 * 1000) * 1000 ); const age = cursorManager.getCursorAgeHours(twentyFourHoursAgoUs); // Allow 1-hour tolerance for test execution time expect(age).toBeGreaterThanOrEqual(23); expect(age).toBeLessThanOrEqual(25); }); it("returns near-zero for recent cursor", () => { const recentCursorUs = BigInt(Date.now() * 1000); const age = cursorManager.getCursorAgeHours(recentCursorUs); expect(age).toBeGreaterThanOrEqual(0); expect(age).toBeLessThan(1); }); }); });