Files
XQKqueue/web/src/api.test.ts
brother7 9948c23bef 支持项目编码访问单项目公示屏
需求:无需查询公示 Token,直接通过项目编码打开单项目公示页。

实现:规范化项目编码并按 code 查询,同时保留既有 Token 哈希查询;补充前后端及集成回归测试。
2026-07-31 17:11:21 +08:00

155 lines
5.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { api } from "./api";
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("mutating queue requests", () => {
it("sends an idempotency key when creating a ticket", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
ticket: { id: "ticket-1", display_number: "00001", status: "WAITING", party_size: 3 },
revision: 2,
}));
vi.stubGlobal("fetch", fetchMock);
const response = await api.createTicket("project-1", { phone: "13800138000", party_size: 3 }, "ticket-intent-key");
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Headers;
expect(headers.get("Idempotency-Key")).toBe("ticket-intent-key");
expect(response.ticket.ticket_number).toBe("00001");
expect(response.ticket.party_size).toBe(3);
});
it("sends the current revision and an idempotency key when calling next", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
batch: { id: "batch-1", batch_number: 3, status: "ACTIVE", tickets: [], called_at: null },
revision: 8,
device_results: [],
}));
vi.stubGlobal("fetch", fetchMock);
await api.callNext("project-1", 7, "PEOPLE", 3, "call-intent-key");
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Headers;
expect(headers.get("Idempotency-Key")).toBe("call-intent-key");
expect(JSON.parse(String(init.body))).toEqual({ expected_revision: 7, mode: "PEOPLE", count: 3 });
});
it("exposes nested API error code and details for explicit duplicate confirmation", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: {
code: "DUPLICATE_PHONE",
message: "该手机号已有活动号码",
details: { existing_ticket: "00001" },
},
}), {
status: 409,
headers: { "Content-Type": "application/json" },
}));
vi.stubGlobal("fetch", fetchMock);
await expect(api.createTicket("project-1", { phone: "13800138000", party_size: 2 })).rejects.toMatchObject({
code: "DUPLICATE_PHONE",
status: 409,
details: { existing_ticket: "00001" },
});
});
});
describe("public visitor lookup", () => {
it("posts the phone number and normalizes returned ticket numbers", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
tickets: [{ display_number: "00012", status: "WAITING" }],
}));
vi.stubGlobal("fetch", fetchMock);
const response = await api.publicStatusByPhone("13800138000");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/public/status/search");
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({ phone: "13800138000" });
expect(response.tickets[0].ticket_number).toBe("00012");
});
it("loads public projects and creates a ticket through the public endpoint", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse({ projects: [{ id: "project-1", name: "云岭漂流", status: "RUNNING", min_party_size: 1, max_party_size: 8 }] }))
.mockResolvedValueOnce(jsonResponse({
ticket: { display_number: "00013", status: "WAITING", phone_last4: "8000", party_size: 4 },
public_token: "visitor-token",
public_url: "/visitor/visitor-token",
revision: 3,
}));
vi.stubGlobal("fetch", fetchMock);
await expect(api.publicProjects()).resolves.toEqual({
projects: [{ id: "project-1", name: "云岭漂流", status: "RUNNING", min_party_size: 1, max_party_size: 8 }],
});
const response = await api.publicCreateTicket("project-1", { phone: "13800138000", party_size: 4 }, "visitor-ticket-key");
const [url, init] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(url).toBe("/api/public/projects/project-1/tickets");
expect(init.method).toBe("POST");
expect((init.headers as Headers).get("Idempotency-Key")).toBe("visitor-ticket-key");
expect(response.ticket.ticket_number).toBe("00013");
expect(response.ticket.party_size).toBe(4);
expect(response.public_token).toBe("visitor-token");
});
});
describe("public display overview", () => {
it("loads the display wall without using an admin endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
projects: [{ id: "project-1", name: "东门观光车", status: "RUNNING" }],
server_time: "2026-07-31T08:00:00Z",
}));
vi.stubGlobal("fetch", fetchMock);
await api.displayOverview();
expect(fetchMock.mock.calls[0][0]).toBe("/api/display/overview");
});
it("loads one project display using its project code", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
project_name: "峡谷漂流",
status: "RUNNING",
current_batch: null,
recent_batches: [],
}));
vi.stubGlobal("fetch", fetchMock);
await api.display("RAFT");
expect(fetchMock.mock.calls[0][0]).toBe("/api/display/RAFT/snapshot");
});
});
describe("isolated portal authentication", () => {
it("uses different endpoints for staff and admin sessions", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ user: { id: "u1", role: "STAFF" }, projects: [] }));
vi.stubGlobal("fetch", fetchMock);
await api.login("staff", "employee", "secret");
await api.me("admin");
await api.logout("staff");
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/staff/auth/login",
"/api/admin/auth/me",
"/api/staff/auth/logout",
]);
});
});