Files
sundynix-agentix/sundynix-admin/src/api.test.ts
T
Blizzard 30e80c0eed feat(admin): 提示词控制面页 + 概览/状态页重做 + 接入 vitest
- 新增「提示词」页(/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>
2026-07-02 08:59:40 +08:00

270 lines
9.1 KiB
TypeScript
Raw 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.
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("guard401 清令牌并广播登出", () => {
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");
});
});