30e80c0eed
- 新增「提示词」页(/prompts):接 /api/v1/prompts,建版本/激活热下发/ 对比激活版行级 diff/撤销,挂到配置组 - 概览页重做为系统控制塔:吃 admin/overview(全平台口径)——平台任务/ 评测/用户规模/服务在线 + 模型路由态 + 提示词覆盖态 + 健康拓扑 + 30s自刷 - 服务状态页重做:中性克制风 + 请求链路做成深色实时数据管道(流动光点/ 节点辉光) + MCP 工具改能力域配色小卡片 - 接入 vitest(此前零测试):41 单测(api 控制面/lib diff/路由派生) - api.ts 增 statsOverview/adminOverview/prompt 控制面四接口 + groupPrompts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
9.1 KiB
TypeScript
270 lines
9.1 KiB
TypeScript
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<string, string>;
|
||
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<string, string>;
|
||
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<string, string>;
|
||
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");
|
||
});
|
||
});
|