127 lines
4.9 KiB
TypeScript
127 lines
4.9 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("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",
|
|
]);
|
|
});
|
|
});
|