新增 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 同步说明。
220 lines
7.3 KiB
TypeScript
220 lines
7.3 KiB
TypeScript
// @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",
|
||
);
|
||
});
|
||
});
|