import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { sendFile, sendFileData, withRetry, type FeishuRuntime } from "../../src/feishu/client.js"; describe("withRetry", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it("succeeds on first attempt without delaying", async () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const fn = vi.fn(async () => "ok"); await expect(withRetry(fn)).resolves.toBe("ok"); expect(fn).toHaveBeenCalledTimes(1); expect(setTimeoutSpy).not.toHaveBeenCalled(); }); it("retries on failure then succeeds", async () => { const fn = vi.fn() .mockRejectedValueOnce(new Error("temporary failure")) .mockResolvedValueOnce("ok"); const result = withRetry(fn); await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1000); await expect(result).resolves.toBe("ok"); expect(fn).toHaveBeenCalledTimes(2); }); it("exhausts all attempts and rethrows", async () => { const error = new Error("still failing"); const fn = vi.fn(async () => { throw error; }); const result = withRetry(fn); const assertion = expect(result).rejects.toBe(error); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(2000); await assertion; expect(fn).toHaveBeenCalledTimes(3); }); it("respects shouldRetry predicate", async () => { const error = new Error("validation failure"); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const shouldRetry = vi.fn(() => false); const fn = vi.fn(async () => { throw error; }); await expect(withRetry(fn, { shouldRetry })).rejects.toBe(error); expect(fn).toHaveBeenCalledTimes(1); expect(shouldRetry).toHaveBeenCalledWith(error); expect(setTimeoutSpy).not.toHaveBeenCalled(); }); it("uses exponential backoff delays", async () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const fn = vi.fn() .mockRejectedValueOnce(new Error("first failure")) .mockRejectedValueOnce(new Error("second failure")) .mockResolvedValueOnce("ok"); const result = withRetry(fn); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(2000); await expect(result).resolves.toBe("ok"); expect(setTimeoutSpy.mock.calls.map(([, delay]) => delay)).toEqual([1000, 2000]); }); }); describe("sendFile retry", () => { afterEach(() => { vi.useRealTimers(); }); it("retries upload on transient failure", async () => { const dir = await mkdtemp(join(tmpdir(), "hub-feishu-retry-")); try { const file = join(dir, "student.pdf"); await writeFile(file, "pdf bytes"); let failFirstUpload: (error: Error) => void = () => { throw new Error("upload rejection was not initialized"); }; const firstUpload = new Promise((_resolve, reject) => { failFirstUpload = reject; }); const fileCreate = vi.fn() .mockReturnValueOnce(firstUpload) .mockResolvedValueOnce({ data: { file_key: "file-key-1" } }); const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } })); const rt = mockRuntime({ fileCreate, messageCreate }); const result = sendFile(rt, "chat-1", file, "student.pdf"); await waitForCalls(fileCreate, 1); vi.useFakeTimers(); failFirstUpload(new Error("temporary upload failure")); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1000); await expect(result).resolves.toBe("message-1"); expect(fileCreate).toHaveBeenCalledTimes(2); expect(messageCreate).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); await rm(dir, { recursive: true, force: true }); } }); it("sendFileData preserves an exhausted upload failure as its cause", async () => { vi.useFakeTimers(); const cause = new Error("persistent upload failure"); const fileCreate = vi.fn(async () => { throw cause; }); const rt = mockRuntime({ fileCreate, messageCreate: vi.fn() }); const result = sendFileData(rt, "chat-1", Buffer.from("pdf bytes"), "student.pdf"); const assertion = expect(result).rejects.toMatchObject({ name: "FeishuFileDeliveryError", cause, }); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(2000); await assertion; expect(fileCreate).toHaveBeenCalledTimes(3); }); }); async function waitForCalls(mock: Mock, expectedCalls: number): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { if (mock.mock.calls.length >= expectedCalls) return; await new Promise((resolve) => setTimeout(resolve, 0)); } throw new Error(`Timed out waiting for mock to be called ${expectedCalls} time(s)`); } function mockRuntime(options: { readonly fileCreate: (payload: unknown) => Promise; readonly messageCreate: (payload: unknown) => Promise; }): FeishuRuntime { return { client: { im: { v1: { file: { create: options.fileCreate }, message: { create: options.messageCreate }, }, }, } as unknown as FeishuRuntime["client"], logger: { info() {}, warn() {}, error() {}, debug() {}, fatal() {}, child() { return this; }, level: "silent", } as unknown as FeishuRuntime["logger"], }; }