import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { afterEach, expect, test, vi } from "vitest"; import { AppProviders } from "@/app/providers.jsx"; import { queryClient } from "@/shared/query/client"; import { CoinAdjustmentPage } from "./CoinAdjustmentPage.jsx"; vi.mock("@/app/auth/AuthProvider.jsx", () => ({ AuthProvider: ({ children }) => children, useAuth: () => ({ can: () => true }), })); afterEach(() => { queryClient.clear(); vi.unstubAllGlobals(); }); test("coin adjustment does not submit when remark is blank", async () => { const user = userEvent.setup(); vi.stubGlobal("fetch", createCoinAdjustmentFetch()); renderPage(); await openReadyDialog(user); await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001"); await user.click(screen.getByRole("button", { name: "搜索" })); await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument()); await user.type(screen.getByRole("textbox", { name: "备注" }), " "); await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500"); await user.click(screen.getByRole("button", { name: "确认" })); await waitFor(() => expect(screen.getByText("请输入备注")).toBeInTheDocument()); expect(coinAdjustmentPostBodies()).toHaveLength(0); }); test("coin adjustment request body includes trimmed remark", async () => { const user = userEvent.setup(); vi.stubGlobal("fetch", createCoinAdjustmentFetch()); renderPage(); await openReadyDialog(user); await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001"); await user.click(screen.getByRole("button", { name: "搜索" })); await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument()); await user.type(screen.getByRole("textbox", { name: "备注" }), " 客服复核后补发 "); await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500"); await user.click(screen.getByRole("button", { name: "确认" })); await waitFor(() => expect(coinAdjustmentPostBodies()).toEqual([ expect.objectContaining({ amount: 500, reason: "运营发放", remark: "客服复核后补发", targetUserId: "1001", }), ]), ); }); test("coin adjustment does not submit when remark exceeds 512 characters", async () => { const user = userEvent.setup(); vi.stubGlobal("fetch", createCoinAdjustmentFetch()); renderPage(); await openReadyDialog(user); await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001"); await user.click(screen.getByRole("button", { name: "搜索" })); await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument()); fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补".repeat(513) } }); await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500"); await user.click(screen.getByRole("button", { name: "确认" })); await waitFor(() => expect(screen.getByText("备注不能超过 512 个字符")).toBeInTheDocument()); expect(coinAdjustmentPostBodies()).toHaveLength(0); }); test("coin adjustment list displays remark and falls back for empty remark", async () => { vi.stubGlobal( "fetch", createCoinAdjustmentFetch({ items: [ coinAdjustmentFixture({ entryId: 1, remark: "客服复核后补发", transactionId: "tx-remark" }), coinAdjustmentFixture({ entryId: 2, remark: "", transactionId: "tx-empty" }), ], }), ); renderPage(); await waitFor(() => expect(screen.getByText("客服复核后补发")).toBeInTheDocument()); expect(screen.getByText("备注")).toBeInTheDocument(); const rows = document.querySelectorAll(".admin-row:not(.admin-row--head)"); expect(within(rows[1]).getByText("-")).toBeInTheDocument(); }); function renderPage() { return render( , ); } async function openReadyDialog(user) { await waitFor(() => expect(screen.getByText("当前无数据")).toBeInTheDocument()); await user.click(screen.getByRole("button", { name: "金币增减" })); await waitFor(() => expect(screen.getByRole("dialog", { name: "金币增减" })).toBeInTheDocument()); } function coinAdjustmentPostBodies() { return vi .mocked(fetch) .mock.calls.filter(([url, init]) => String(url).includes("/api/v1/admin/operations/coin-adjustments") && init?.method === "POST") .map(([, init]) => JSON.parse(String(init?.body))); } function createCoinAdjustmentFetch({ items = [] } = {}) { return vi.fn(async (input, init = {}) => { const url = String(input); const method = init.method || "GET"; if (url.includes("/api/v1/admin/operations/coin-adjustments/target")) { return jsonResponse({ code: 0, data: { displayUserId: "1001", userId: "1001", username: "测试用户", }, }); } if (url.includes("/api/v1/admin/operations/coin-adjustments") && method === "POST") { return jsonResponse({ code: 0, data: { amount: 500, availableDelta: 500, balanceAfter: 1500, transactionId: "tx-created", user: { displayUserId: "1001", userId: "1001", username: "测试用户" }, }, }); } if (url.includes("/api/v1/admin/operations/coin-adjustments")) { return jsonResponse({ code: 0, data: { items, page: 1, pageSize: 50, total: items.length } }); } return jsonResponse({ code: 0, data: {} }); }); } function coinAdjustmentFixture(patch = {}) { return { amount: 500, availableAfter: 1500, availableDelta: 500, createdAtMs: 1762300800000, direction: "income", entryId: 1, operator: { name: "运营" }, reason: "运营发放", remark: "", transactionId: "tx-1", user: { displayUserId: "1001", userId: "1001", username: "测试用户" }, userId: "1001", ...patch, }; } function jsonResponse(body) { return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status: 200, }); }