// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useKimiOauth } from "./useKimiOauth"; // 后端客户端(POST start/disconnect + GET job/status,经真实 useJobPoll)与 Toast 是副作用边界。 // kimiOauth.ts/job.ts 纯逻辑保留真实。 const post = vi.fn(); const get = vi.fn(); const toast = vi.fn(); vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a), GET: (...a: unknown[]) => get(...a), }, })); vi.mock("@/components/Toast", () => ({ useToast: () => toast })); const START = "/settings/providers/kimi-code/oauth/start"; const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect"; const STATUS = "/settings/providers/kimi-code/oauth/status"; const JOB = "/jobs/{job_id}"; const startOk = { data: { job_id: "job-1", user_code: "ABCD-1234", verification_uri: "https://kimi.example/verify", verification_uri_complete: "https://kimi.example/verify?code=ABCD-1234", expires_in: 600, interval: 5, }, error: null, }; // 把 GET 按 path 路由:job 轮询 vs 状态查询。 function routeGet(jobResult: unknown, statusResult: unknown): void { get.mockImplementation((path: string) => { if (path === JOB) return Promise.resolve(jobResult); return Promise.resolve(statusResult); }); } describe("useKimiOauth", () => { beforeEach(() => { vi.useFakeTimers(); post.mockReset(); get.mockReset(); toast.mockReset(); vi.spyOn(window, "open").mockReturnValue(null); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it("初始未连接:phase=idle、无 device、不忙、无错误", () => { const { result } = renderHook(() => useKimiOauth({ initialConnected: false, initialExpiresAt: null }), ); expect(result.current.phase).toBe("idle"); expect(result.current.device).toBeNull(); expect(result.current.busy).toBe(false); expect(result.current.error).toBeNull(); }); it("初始已连接:phase=connected 并带过期时刻", () => { const { result } = renderHook(() => useKimiOauth({ initialConnected: true, initialExpiresAt: "2026-12-31T00:00:00Z", }), ); expect(result.current.phase).toBe("connected"); expect(result.current.expiresAt).toBe("2026-12-31T00:00:00Z"); }); it("connect 成功(pending→authorized):展示 device、轮询 done 后置已连接并弹成功 toast", async () => { // Arrange:start 拿到 device + job_id;job 首拍即 done 且 connected;状态查询确认已连接。 post.mockImplementation((path: string) => path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }), ); routeGet( { data: { id: "job-1", status: "done", progress: 100, result: { connected: true } }, error: null }, { data: { connected: true, expires_at: "2027-01-01T00:00:00Z" }, error: null }, ); const { result } = renderHook(() => useKimiOauth({ initialConnected: false, initialExpiresAt: null }), ); // Act:发起连接,刷掉 job 轮询 + refreshStatus 的微任务。 await act(async () => { await result.current.connect(); await vi.advanceTimersByTimeAsync(0); }); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // Assert:已展示 device、phase=connected、刷新到新过期时刻、弹成功 toast。 expect(result.current.device?.userCode).toBe("ABCD-1234"); expect(window.open).toHaveBeenCalledWith( "https://kimi.example/verify?code=ABCD-1234", "_blank", "noopener,noreferrer", ); expect(result.current.phase).toBe("connected"); expect(result.current.expiresAt).toBe("2027-01-01T00:00:00Z"); expect(toast).toHaveBeenCalledWith("已连接 Kimi Code。", "success"); }); it("connect 后授权进行中:job 仍 running → phase=awaiting 且 busy", async () => { post.mockImplementation((path: string) => path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }), ); routeGet( { data: { id: "job-1", status: "running", progress: 20 }, error: null }, { data: { connected: false }, error: null }, ); const { result } = renderHook(() => useKimiOauth({ initialConnected: false, initialExpiresAt: null }), ); await act(async () => { await result.current.connect(); await vi.advanceTimersByTimeAsync(0); }); expect(result.current.phase).toBe("awaiting"); expect(result.current.busy).toBe(true); }); it("connect 轮询失败(job failed):phase=error 并弹失败 toast", async () => { post.mockImplementation((path: string) => path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }), ); routeGet( { data: { id: "job-1", status: "failed", progress: 0, error: "授权已过期" }, error: null }, { data: { connected: false }, error: null }, ); const { result } = renderHook(() => useKimiOauth({ initialConnected: false, initialExpiresAt: null }), ); await act(async () => { await result.current.connect(); await vi.advanceTimersByTimeAsync(0); }); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); expect(result.current.phase).toBe("error"); expect(result.current.error).toBe("授权已过期"); expect(toast).toHaveBeenCalledWith( expect.stringContaining("连接失败"), "error", ); }); it("connect 发起失败(start 返回 error):保持 idle 并弹错误 toast", async () => { post.mockResolvedValue({ data: null, error: { detail: "rate limited" } }); const { result } = renderHook(() => useKimiOauth({ initialConnected: false, initialExpiresAt: null }), ); await act(async () => { await result.current.connect(); }); expect(result.current.phase).toBe("idle"); expect(result.current.device).toBeNull(); expect(toast).toHaveBeenCalledWith( "发起 Kimi Code 连接失败,请稍后重试。", "error", ); }); it("disconnect 成功:复位为未连接并弹成功 toast", async () => { post.mockResolvedValue({ data: { ok: true }, error: null }); const { result } = renderHook(() => useKimiOauth({ initialConnected: true, initialExpiresAt: "2026-12-31T00:00:00Z", }), ); await act(async () => { await result.current.disconnect(); }); expect(result.current.phase).toBe("idle"); expect(result.current.device).toBeNull(); expect(result.current.expiresAt).toBeNull(); expect(toast).toHaveBeenCalledWith("已断开 Kimi Code。", "success"); }); it("disconnect 失败(返回 error):弹错误 toast、不复位", async () => { post.mockResolvedValue({ data: null, error: { detail: "boom" } }); const { result } = renderHook(() => useKimiOauth({ initialConnected: true, initialExpiresAt: "2026-12-31T00:00:00Z", }), ); await act(async () => { await result.current.disconnect(); }); expect(result.current.phase).toBe("connected"); expect(toast).toHaveBeenCalledWith( "断开 Kimi Code 失败,请稍后重试。", "error", ); }); });