import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { setToken, getToken, clearToken, login, me, listModels, saveModel, gatewayOnline, groupPrompts, listPrompts, createPromptVersion, activatePrompt, deactivatePrompt, type PromptVersion, } from "./api"; // 构造一个 fetch 响应桩。 function res(body: unknown, init?: { ok?: boolean; status?: number }): Response { return { ok: init?.ok ?? true, status: init?.status ?? 200, json: async () => body, } as unknown as Response; } const fetchMock = vi.fn(); beforeEach(() => { clearToken(); localStorage.clear(); fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("令牌持久化", () => { it("setToken 写入内存并持久化到 localStorage", () => { setToken("abc"); expect(getToken()).toBe("abc"); expect(localStorage.getItem("sdx_admin_token")).toBe("abc"); }); it("clearToken 清空内存与 localStorage", () => { setToken("abc"); clearToken(); expect(getToken()).toBe(""); expect(localStorage.getItem("sdx_admin_token")).toBeNull(); }); }); describe("authHeaders(经由带鉴权的请求观测)", () => { it("有令牌时带上 Bearer 头", async () => { setToken("tok123"); fetchMock.mockResolvedValue(res({ models: [] })); await listModels("chat"); const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; expect(headers.Authorization).toBe("Bearer tok123"); }); it("无令牌时不带 Authorization 头", async () => { fetchMock.mockResolvedValue(res({ models: [] })); await listModels("chat"); const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; expect(headers.Authorization).toBeUndefined(); }); it("写请求带 JSON Content-Type", async () => { setToken("tok"); fetchMock.mockResolvedValue(res({ id: "1" })); await saveModel({ kind: "chat", provider: "openai", base_url: "u", api_key: "k", model: "m" }); const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; expect(headers["Content-Type"]).toBe("application/json"); }); }); describe("guard:401 清令牌并广播登出", () => { it("401 时清令牌并派发 sdx:logout 事件", async () => { setToken("stale"); const onLogout = vi.fn(); window.addEventListener("sdx:logout", onLogout); fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 })); await expect(listModels("chat")).rejects.toThrow(); expect(getToken()).toBe(""); expect(onLogout).toHaveBeenCalledOnce(); window.removeEventListener("sdx:logout", onLogout); }); it("403(已登录非管理员)照常抛错,但不清令牌/不登出", async () => { setToken("validuser"); const onLogout = vi.fn(); window.addEventListener("sdx:logout", onLogout); fetchMock.mockResolvedValue(res({}, { ok: false, status: 403 })); await expect(listModels("chat")).rejects.toThrow(); expect(getToken()).toBe("validuser"); // 令牌保留 expect(onLogout).not.toHaveBeenCalled(); window.removeEventListener("sdx:logout", onLogout); }); }); describe("login", () => { it("成功时存令牌并返回用户", async () => { fetchMock.mockResolvedValue( res({ token: "t1", user: { id: "u1", email: "a@b.c" } }), ); const user = await login("a@b.c", "pw"); expect(user.id).toBe("u1"); expect(getToken()).toBe("t1"); }); it("失败时抛出后端 error 文案且不存令牌", async () => { fetchMock.mockResolvedValue(res({ error: "密码错误" }, { ok: false, status: 401 })); await expect(login("a@b.c", "bad")).rejects.toThrow("密码错误"); expect(getToken()).toBe(""); }); it("无 error 字段时回退到状态码文案", async () => { fetchMock.mockResolvedValue(res({}, { ok: false, status: 500 })); await expect(login("a@b.c", "pw")).rejects.toThrow("500"); }); }); describe("me", () => { it("无令牌时直接返回 null(不发请求)", async () => { const u = await me(); expect(u).toBeNull(); expect(fetchMock).not.toHaveBeenCalled(); }); it("响应非 ok 时清令牌并返回 null", async () => { setToken("expired"); fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 })); expect(await me()).toBeNull(); expect(getToken()).toBe(""); }); it("成功时返回用户", async () => { setToken("ok"); fetchMock.mockResolvedValue(res({ user: { id: "u1", email: "a@b.c" } })); expect(await me()).toEqual({ id: "u1", email: "a@b.c" }); }); }); describe("gatewayOnline", () => { it("healthz ok 时为 true", async () => { fetchMock.mockResolvedValue(res(null, { ok: true })); expect(await gatewayOnline()).toBe(true); }); it("healthz 非 ok 时为 false", async () => { fetchMock.mockResolvedValue(res(null, { ok: false, status: 503 })); expect(await gatewayOnline()).toBe(false); }); it("网络异常时吞掉错误并返回 false", async () => { fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); expect(await gatewayOnline()).toBe(false); }); }); // —— Prompt 控制面 —— function pv(key: string, version: number, active = false): PromptVersion { return { key, version, active, note: "", content: `c${version}` }; } describe("groupPrompts(控制面分组:纯函数)", () => { it("保持 keys 注册顺序,未建版本的 key 也成组(active=null)", () => { const gs = groupPrompts({ keys: ["a", "b", "c"], versions: [pv("b", 1)] }); expect(gs.map((g) => g.key)).toEqual(["a", "b", "c"]); expect(gs[0]).toMatchObject({ key: "a", versions: [], active: null }); expect(gs[1].versions).toHaveLength(1); }); it("每组版本号倒序,挑出激活版", () => { const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 3, true), pv("k", 2)] }); expect(gs[0].versions.map((v) => v.version)).toEqual([3, 2, 1]); expect(gs[0].active?.version).toBe(3); }); it("无激活版时 active 为 null(回退代码默认)", () => { const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 2)] }); expect(gs[0].active).toBeNull(); }); it("DB 出现 Known 未列的 key 也容错展示(追加在已知之后)", () => { const gs = groupPrompts({ keys: ["known"], versions: [pv("orphan", 1, true)] }); expect(gs.map((g) => g.key)).toEqual(["known", "orphan"]); expect(gs[1].active?.version).toBe(1); }); }); describe("listPrompts", () => { it("缺字段时回退为空数组(不崩)", async () => { setToken("t"); fetchMock.mockResolvedValue(res({})); expect(await listPrompts()).toEqual({ keys: [], versions: [] }); }); it("非 ok 抛错", async () => { setToken("t"); fetchMock.mockResolvedValue(res({}, { ok: false, status: 502 })); await expect(listPrompts()).rejects.toThrow("502"); }); it("命中 /api/v1/prompts(不在 /admin 前缀下)", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ keys: [], versions: [] })); await listPrompts(); expect(fetchMock.mock.calls[0][0]).toMatch(/\/api\/v1\/prompts$/); }); }); describe("createPromptVersion", () => { it("成功返回新版本号", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ key: "k", version: 4 })); expect(await createPromptVersion("k", "body", "note")).toBe(4); const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); expect(body).toEqual({ key: "k", content: "body", note: "note" }); }); it("失败抛后端 error 文案", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ error: "key/content required" }, { ok: false, status: 400 })); await expect(createPromptVersion("k", "", "")).rejects.toThrow("key/content required"); }); }); describe("activatePrompt", () => { it("成功无异常", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ activated: true })); await expect(activatePrompt("k", 2)).resolves.toBeUndefined(); }); it("已激活但广播失败(warn) → 当作错误抛出提醒运维", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ warn: "已激活但广播失败: nats down" })); await expect(activatePrompt("k", 2)).rejects.toThrow("广播失败"); }); it("HTTP 失败抛 error", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ error: "key/version required" }, { ok: false, status: 400 })); await expect(activatePrompt("k", 0)).rejects.toThrow("key/version required"); }); }); describe("deactivatePrompt", () => { it("成功无异常并带上 key", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ deactivated: true })); await deactivatePrompt("k"); const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); expect(body).toEqual({ key: "k" }); }); it("失败抛错", async () => { setToken("t"); fetchMock.mockResolvedValue(res({ error: "boom" }, { ok: false, status: 502 })); await expect(deactivatePrompt("k")).rejects.toThrow("boom"); }); });