Files
writer-work-flow/apps/web/lib/settings/useKimiOauth.test.ts
Yaojia Wang a6f5d085e5 test: 接入 jsdom 测试栈, 把 15 个 React hooks 纳入 80% 覆盖率门禁
新增 jsdom + @testing-library/react, 为 lib/** 全部 use*.ts hooks 写 renderHook 单测:
生成/CRUD(world/character/generator/outline/accept/foreshadow/rules)、
SSE流(draftStream/reviewStream)、定时轮询(autosave/jobPoll/kimiOauth)、
文风与注入(refine/styleLearn/injection)。覆盖率 mock api 客户端+Toast, 不打真实 LLM。
vitest.config.ts 去掉 use*.ts 排除; 前端覆盖率 63%→95%。CLAUDE.md 同步说明。
2026-06-27 06:21:42 +02:00

220 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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 () => {
// Arrangestart 拿到 device + job_idjob 首拍即 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 failedphase=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",
);
});
});