Files
XQKqueue/web/src/pages/VisitorTakeTicketPage.test.tsx
brother7 c2a5281534 修复:调整游客取号限流策略
问题:移动网络共享出口 IP 会造成游客取号被误限流。

实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
2026-07-31 22:56:33 +08:00

123 lines
5.2 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { ApiError, api } from "../api";
import { usePollingResource } from "../hooks/usePollingResource";
import { VisitorTakeTicketForm } from "./VisitorTakeTicketPage";
vi.mock("../hooks/usePollingResource", () => ({ usePollingResource: vi.fn() }));
const mockedUsePollingResource = vi.mocked(usePollingResource);
beforeEach(() => {
mockedUsePollingResource.mockReturnValue({
data: {
projects: [{ id: "project-a", name: "云岭漂流", status: "RUNNING", visitor_notice: "请在入口附近等候。", min_party_size: 1, max_party_size: 8 }],
},
loading: false,
refreshing: false,
error: null,
offline: false,
lastClientSuccessAt: new Date().toISOString(),
refresh: vi.fn(),
resourceKey: "visitor-public-projects",
});
});
describe("VisitorTakeTicketForm", () => {
it("允许清空默认同行人数后输入新值", () => {
render(<MemoryRouter><VisitorTakeTicketForm /></MemoryRouter>);
const partySizeInput = screen.getByRole("spinbutton", { name: /同行人数/ });
expect(partySizeInput).toHaveValue(1);
fireEvent.change(partySizeInput, { target: { value: "" } });
expect(partySizeInput).toHaveValue(null);
fireEvent.change(partySizeInput, { target: { value: "5" } });
expect(partySizeInput).toHaveValue(5);
});
it("creates a ticket from the visitor form and offers the private status page", async () => {
const createTicket = vi.spyOn(api, "publicCreateTicket").mockResolvedValueOnce({
ticket: { id: "ticket-1", ticket_number: "00021", status: "WAITING", phone_last4: "8000", party_size: 3 },
public_token: "visitor-token",
public_url: "/visitor/visitor-token",
revision: 4,
});
try {
render(<MemoryRouter><VisitorTakeTicketForm /></MemoryRouter>);
expect(screen.queryByText(/一个号码对应您填写的同行人数/)).not.toBeInTheDocument();
expect(screen.getByLabelText("排队项目")).toHaveValue("project-a");
fireEvent.change(screen.getByLabelText("手机号"), { target: { value: "13800138000" } });
fireEvent.change(screen.getByLabelText("姓氏(选填)"), { target: { value: "李" } });
fireEvent.change(screen.getByRole("spinbutton", { name: /同行人数/ }), { target: { value: "3" } });
fireEvent.click(screen.getByRole("button", { name: "创建排队号码" }));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("00021"));
expect(createTicket).toHaveBeenCalledWith("project-a", {
phone: "13800138000",
party_size: 3,
last_name: "李",
honorific: "",
allow_duplicate: false,
}, expect.any(String));
expect(screen.getByRole("link", { name: "查看排队状态" })).toHaveAttribute("href", "/visitor/visitor-token");
} finally {
createTicket.mockRestore();
}
});
it("requires explicit confirmation before creating another active ticket for the same phone", async () => {
const createTicket = vi.spyOn(api, "publicCreateTicket")
.mockRejectedValueOnce(new ApiError("该手机号已有活动号码,请确认后继续", 409, undefined, "DUPLICATE_PHONE"))
.mockResolvedValueOnce({
ticket: { id: "ticket-2", ticket_number: "00022", status: "WAITING", phone_last4: "8000", party_size: 1 },
public_token: "visitor-token-2",
revision: 5,
});
try {
render(<MemoryRouter><VisitorTakeTicketForm /></MemoryRouter>);
fireEvent.change(screen.getByLabelText("手机号"), { target: { value: "13800138000" } });
fireEvent.click(screen.getByRole("button", { name: "创建排队号码" }));
await waitFor(() => expect(screen.getByText("该手机号已有活动号码")).toBeVisible());
expect(screen.getByRole("button", { name: "确认继续取号" })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "确认继续取号" }));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("00022"));
expect(createTicket).toHaveBeenLastCalledWith("project-a", {
phone: "13800138000",
party_size: 1,
last_name: "",
honorific: "",
allow_duplicate: true,
}, expect.any(String));
} finally {
createTicket.mockRestore();
}
});
it("shows the Retry-After wait without clearing the visitor form", async () => {
const createTicket = vi.spyOn(api, "publicCreateTicket").mockRejectedValueOnce(
new ApiError("取号次数过多,请稍后再试", 429, undefined, "PUBLIC_TICKET_RATE_LIMITED", 17),
);
try {
render(<MemoryRouter><VisitorTakeTicketForm /></MemoryRouter>);
const phoneInput = screen.getByLabelText("手机号");
fireEvent.change(phoneInput, { target: { value: "13800138000" } });
fireEvent.click(screen.getByRole("button", { name: "创建排队号码" }));
await waitFor(() => expect(screen.getByText(/17 秒后重试/)).toBeVisible());
expect(phoneInput).toHaveValue("13800138000");
expect(screen.getByRole("button", { name: /请等待 17 秒/ })).toBeDisabled();
} finally {
createTicket.mockRestore();
}
});
});