From a42ce53abee32741efae53aff0c823534fb46a14 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 10 Jun 2026 00:48:39 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#22):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=816?= =?UTF-8?q?=E9=80=80=E6=AC=BE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/assets/locales/en-US/order.json | 14 +- .../public/assets/locales/zh-CN/order.json | 12 +- apps/admin/src/sections/order/index.test.tsx | 466 ++++++++++++++++++ apps/admin/src/sections/order/index.tsx | 144 ++++-- 4 files changed, 595 insertions(+), 41 deletions(-) create mode 100644 apps/admin/src/sections/order/index.test.tsx diff --git a/apps/admin/public/assets/locales/en-US/order.json b/apps/admin/public/assets/locales/en-US/order.json index c0809ba..7a90de3 100644 --- a/apps/admin/public/assets/locales/en-US/order.json +++ b/apps/admin/public/assets/locales/en-US/order.json @@ -16,10 +16,11 @@ "0": "Status", "1": "Pending", "2": "Paid", - "3": "Cancelled", - "4": "Closed", - "5": "Completed", - "6": "Refunded" + "3": "Closed", + "4": "Failed", + "5": "Finished", + "6": "Claimed", + "7": "Refunded" }, "subscribe": "Subscribe", "subscribePrice": "Subscription Price", @@ -33,5 +34,8 @@ "4": "Recharge" }, "updateTime": "Update Time", - "user": "User" + "user": "User", + "refundFailed": "Refund failed. Please try again.", + "statusClaimedBlocked": "Claimed and refunded states must use the dedicated processing flow.", + "statusUpdateFailed": "Status update failed. Please try again." } diff --git a/apps/admin/public/assets/locales/zh-CN/order.json b/apps/admin/public/assets/locales/zh-CN/order.json index 20dc641..fb5a6ac 100644 --- a/apps/admin/public/assets/locales/zh-CN/order.json +++ b/apps/admin/public/assets/locales/zh-CN/order.json @@ -16,10 +16,11 @@ "0": "状态", "1": "待支付", "2": "已支付", - "3": "已取消", - "4": "已关闭", + "3": "已关闭", + "4": "失败", "5": "已完成", - "6": "已退费" + "6": "处理中", + "7": "已退费" }, "subscribe": "订阅", "subscribePrice": "订阅价格", @@ -33,5 +34,8 @@ "4": "充值" }, "updateTime": "更新时间", - "user": "用户" + "user": "用户", + "refundFailed": "退费失败,请重试。", + "statusClaimedBlocked": "处理中和已退费状态必须走专用流程,不能通过通用改状态提交。", + "statusUpdateFailed": "状态更新失败,请重试。" } diff --git a/apps/admin/src/sections/order/index.test.tsx b/apps/admin/src/sections/order/index.test.tsx new file mode 100644 index 0000000..27e68db --- /dev/null +++ b/apps/admin/src/sections/order/index.test.tsx @@ -0,0 +1,466 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { + activateOrder, + getOrderList, + refundOrder, + updateOrderStatus, +} from "@workspace/ui/services/admin/order"; +import type { AxiosResponse } from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Order, { canManuallyUpdateOrderStatus, canRefundOrderStatus } from "."; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string): string => fallback, + }), +})); + +vi.mock("@tanstack/react-router", () => ({ + useSearch: () => ({}), +})); + +vi.mock("@/stores/global", () => ({ + useGlobalStore: ( + selector?: (state: { + user: { is_admin: boolean }; + common: { currency: { currency_symbol: string } }; + }) => unknown + ) => { + const state = { + user: { + is_admin: true, + }, + common: { + currency: { + currency_symbol: "$", + }, + }, + }; + + return typeof selector === "function" ? selector(state) : state; + }, +})); + +vi.mock("@/stores/subscribe", () => ({ + useSubscribe: () => ({ + subscribes: [ + { + id: 9, + name: "Pro Plan", + }, + ], + getSubscribeName: (id?: number) => (id === 9 ? "Pro Plan" : "--"), + }), +})); + +vi.mock("@/utils/common", () => ({ + formatDate: (timestamp: number) => `date-${timestamp}`, +})); + +vi.mock("../user/user-detail", () => ({ + UserDetail: ({ id }: { id: number }) => User #{id}, +})); + +vi.mock("@/components/display", () => ({ + Display: ({ value }: { value: number }) => {value}, +})); + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); + +vi.mock("@workspace/ui/services/admin/order", () => ({ + activateOrder: vi.fn(), + getOrderList: vi.fn(), + refundOrder: vi.fn(), + updateOrderStatus: vi.fn(), +})); + +vi.mock("@workspace/ui/components/alert-dialog", () => ({ + AlertDialog: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogCancel: ({ + children, + ...props + }: React.ComponentProps<"button">) => , + AlertDialogAction: ({ + children, + ...props + }: React.ComponentProps<"button">) => , +})); + +vi.mock("@workspace/ui/composed/combobox", () => ({ + Combobox: ({ + options = [], + value, + onChange, + placeholder, + }: { + options?: Array<{ label: string; value: number | string }>; + value?: number | string; + onChange: (value: number) => void; + placeholder?: string; + }) => ( + + ), +})); + +vi.mock("@workspace/ui/composed/pro-table/pro-table", async () => { + const React = await import("react"); + + type Column = { + accessorKey?: string; + cell?: (props: { + row: { + original: Record; + getValue: (key: string) => unknown; + }; + }) => React.ReactNode; + }; + + return { + ProTable: ({ + columns, + request, + actions, + }: { + columns: Column[]; + request: ( + pagination: { page: number; size: number }, + filter: Record + ) => Promise<{ list: Record[]; total: number }>; + actions?: { + render?: (row: Record) => React.ReactNode[]; + }; + }) => { + const [state, setState] = React.useState<{ + data: Record[]; + error: boolean; + loading: boolean; + }>({ + data: [], + error: false, + loading: true, + }); + + React.useEffect(() => { + let cancelled = false; + request({ page: 1, size: 200 }, {}) + .then((response) => { + if (cancelled) return; + setState({ + data: response.list, + error: false, + loading: false, + }); + }) + .catch(() => { + if (cancelled) return; + setState({ + data: [], + error: true, + loading: false, + }); + }); + + return () => { + cancelled = true; + }; + }, [request]); + + if (state.loading) { + return
; + } + + if (state.error) { + return
Failed to load orders
; + } + + if (state.data.length === 0) { + return
No orders
; + } + + return ( +
+ {state.data.map((row) => ( +
+ {columns.map((column, index) => { + const value = column.accessorKey + ? row[column.accessorKey] + : undefined; + const rowApi = { + original: row, + getValue: (key: string) => row[key], + }; + + return ( +
+ {column.cell + ? column.cell({ row: rowApi }) + : typeof value === "object" + ? JSON.stringify(value) + : String(value ?? "")} +
+ ); + })} +
{actions?.render?.(row)}
+
+ ))} +
+ ); + }, + }; +}); + +const mockedGetOrderList = vi.mocked(getOrderList); +const mockedRefundOrder = vi.mocked(refundOrder); +const mockedUpdateOrderStatus = vi.mocked(updateOrderStatus); +const mockedActivateOrder = vi.mocked(activateOrder); + +function createOrderListResponse( + list: API.Order[] +): AxiosResponse { + return { + data: { + code: 200, + data: { + list, + total: list.length, + }, + }, + status: 200, + statusText: "OK", + headers: {}, + config: { + headers: {} as AxiosResponse["config"]["headers"], + }, + }; +} + +function createSuccessResponse(): AxiosResponse< + API.Response & { data?: unknown } +> { + return { + data: { + code: 200, + }, + status: 200, + statusText: "OK", + headers: {}, + config: { + headers: {} as AxiosResponse["config"]["headers"], + }, + }; +} + +function createOrder(status: number): API.Order { + return { + id: status, + amount: 1999, + coupon_discount: 0, + discount: 0, + fee_amount: 0, + order_no: `ORDER-${status}`, + payment: { + name: "Stripe", + platform: "stripe", + }, + price: 1999, + quantity: 1, + status, + status_name: + status === 6 ? "claimed" : status === 7 ? "refunded" : "pending", + subscribe_id: 9, + trade_no: `TRADE-${status}`, + type: 1, + updated_at: 1_716_800_000, + user_id: 88, + } as API.Order; +} + +function renderOrderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + + + + ); +} + +beforeEach(() => { + localStorage.setItem("timezone", "UTC"); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("Order", () => { + it("uses the dedicated refund endpoint for refundable orders", async () => { + mockedGetOrderList.mockResolvedValue( + createOrderListResponse([createOrder(2)]) + ); + mockedRefundOrder.mockResolvedValue(createSuccessResponse()); + + renderOrderPage(); + + expect(await screen.findByText("ORDER-2")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Confirm refund" })); + + await waitFor(() => + expect(mockedRefundOrder).toHaveBeenCalledWith( + { id: 2 }, + { skipErrorHandler: true } + ) + ); + expect(toastSuccess).toHaveBeenCalledWith("Refund completed."); + }); + + it("blocks status 6 and 7 from the generic status editor", async () => { + mockedGetOrderList.mockResolvedValue( + createOrderListResponse([createOrder(1)]) + ); + mockedUpdateOrderStatus.mockResolvedValue(createSuccessResponse()); + + renderOrderPage(); + + const statusSelect = await screen.findByLabelText("Status"); + const optionValues = Array.from( + statusSelect.querySelectorAll("option") + ).map((option) => option.textContent); + + expect(optionValues).not.toContain("Claimed"); + expect(optionValues).not.toContain("Refunded"); + expect(canManuallyUpdateOrderStatus(6)).toBe(false); + expect(canManuallyUpdateOrderStatus(7)).toBe(false); + + fireEvent.change(statusSelect, { target: { value: "2" } }); + + await waitFor(() => + expect(mockedUpdateOrderStatus).toHaveBeenCalledWith( + { id: 1, status: 2 }, + { skipErrorHandler: true } + ) + ); + }); + + it("renders loading state while orders are being fetched", () => { + mockedGetOrderList.mockReturnValue( + new Promise(() => { + // Keep the request pending so the loading UI remains visible. + }) as ReturnType + ); + + renderOrderPage(); + + expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull(); + }); + + it("renders the empty state when there are no orders", async () => { + mockedGetOrderList.mockResolvedValue(createOrderListResponse([])); + + renderOrderPage(); + + expect(await screen.findByText("No orders")).not.toBeNull(); + }); + + it("shows an error toast when refund fails", async () => { + mockedGetOrderList.mockResolvedValue( + createOrderListResponse([createOrder(5)]) + ); + mockedRefundOrder.mockRejectedValue(new Error("refund failed")); + + renderOrderPage(); + + expect(await screen.findByText("ORDER-5")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Confirm refund" })); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith( + "Refund failed. Please try again." + ) + ); + }); + + it("renders the error state when the order list request fails", async () => { + mockedGetOrderList.mockRejectedValue(new Error("network failed")); + + renderOrderPage(); + + expect(await screen.findByText("Failed to load orders")).not.toBeNull(); + }); + + it("keeps the order actions visible on a narrow viewport", async () => { + window.innerWidth = 375; + window.dispatchEvent(new Event("resize")); + mockedGetOrderList.mockResolvedValue( + createOrderListResponse([createOrder(2), createOrder(6), createOrder(7)]) + ); + + renderOrderPage(); + + expect(await screen.findByText("ORDER-2")).not.toBeNull(); + expect(screen.getByText("Claimed")).not.toBeNull(); + expect(screen.getAllByText("Refund").length).toBe(1); + expect(canRefundOrderStatus(2)).toBe(true); + expect(canRefundOrderStatus(6)).toBe(false); + expect(canRefundOrderStatus(7)).toBe(false); + expect(mockedActivateOrder).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/sections/order/index.tsx b/apps/admin/src/sections/order/index.tsx index e9d1e28..45fc10b 100644 --- a/apps/admin/src/sections/order/index.tsx +++ b/apps/admin/src/sections/order/index.tsx @@ -40,7 +40,31 @@ import { useSubscribe } from "@/stores/subscribe"; import { formatDate } from "@/utils/common"; import { UserDetail } from "../user/user-detail"; -const REFUNDED_ORDER_STATUS = 6; +const ORDER_STATUS_PENDING = 1; +const ORDER_STATUS_PAID = 2; +const ORDER_STATUS_CLOSED = 3; +const ORDER_STATUS_FAILED = 4; +const ORDER_STATUS_FINISHED = 5; +const ORDER_STATUS_CLAIMED = 6; +const ORDER_STATUS_REFUNDED = 7; + +const MANUALLY_EDITABLE_ORDER_STATUSES = [ + ORDER_STATUS_PENDING, + ORDER_STATUS_PAID, + ORDER_STATUS_CLOSED, + ORDER_STATUS_FAILED, + ORDER_STATUS_FINISHED, +] as const; + +export function canManuallyUpdateOrderStatus(status: number) { + return MANUALLY_EDITABLE_ORDER_STATUSES.includes( + status as (typeof MANUALLY_EDITABLE_ORDER_STATUSES)[number] + ); +} + +export function canRefundOrderStatus(status: number) { + return [ORDER_STATUS_PAID, ORDER_STATUS_FINISHED].includes(status); +} export default function Order() { const { t } = useTranslation("order"); @@ -56,30 +80,46 @@ export default function Order() { subscribe_id: sp.subscribe_id || undefined, }; - const statusOptions = [ + const displayStatusOptions = [ { - value: 1, + value: ORDER_STATUS_PENDING, label: t("status.1", "Pending"), className: "bg-orange-500", }, - { value: 2, label: t("status.2", "Paid"), className: "bg-green-500" }, { - value: 3, - label: t("status.3", "Cancelled"), - className: "bg-gray-500", - }, - { value: 4, label: t("status.4", "Closed"), className: "bg-red-500" }, - { - value: 5, - label: t("status.5", "Completed"), + value: ORDER_STATUS_PAID, + label: t("status.2", "Paid"), className: "bg-green-500", }, { - value: REFUNDED_ORDER_STATUS, - label: t("status.6", "Refunded"), + value: ORDER_STATUS_CLOSED, + label: t("status.3", "Closed"), + className: "bg-gray-500", + }, + { + value: ORDER_STATUS_FAILED, + label: t("status.4", "Failed"), + className: "bg-red-500", + }, + { + value: ORDER_STATUS_FINISHED, + label: t("status.5", "Finished"), + className: "bg-green-500", + }, + { + value: ORDER_STATUS_CLAIMED, + label: t("status.6", "Claimed"), + className: "bg-amber-500", + }, + { + value: ORDER_STATUS_REFUNDED, + label: t("status.7", "Refunded"), className: "bg-red-500", }, ]; + const editableStatusOptions = displayStatusOptions.filter((option) => + canManuallyUpdateOrderStatus(option.value) + ); const typeOptions = [ { value: 1, label: t("type.1", "New Purchase") }, @@ -95,29 +135,36 @@ export default function Order() { const refundMutation = useMutation({ mutationFn: async (order: API.Order) => { - await refundOrder({ id: order.id }); + await refundOrder( + { id: order.id }, + { + skipErrorHandler: true, + } + ); }, onSuccess: () => { toast.success(t("refundSuccess", "Refund completed.")); setConfirmingOrderId(null); ref.current?.refresh(); }, - onError: (error) => { - console.error("Refund order failed", error); + onError: () => { + toast.error(t("refundFailed", "Refund failed. Please try again.")); setConfirmingOrderId(null); }, }); const isRefundedOrder = (order: API.Order) => - order.status === REFUNDED_ORDER_STATUS || - order.status_name === t("status.6", "Refunded") || + order.status === ORDER_STATUS_REFUNDED || + order.status_name === t("status.7", "Refunded") || order.status_name?.toLowerCase() === "refunded"; const canRefundOrder = (order: API.Order) => - canRefundOrders && !isRefundedOrder(order); + canRefundOrders && + canRefundOrderStatus(order.status) && + !isRefundedOrder(order); return ( - + > action={ref} actions={{ render: (order) => { @@ -173,7 +220,11 @@ export default function Order() { onClick={async (event) => { event.preventDefault(); if (isPending) return; - await refundMutation.mutateAsync(order); + try { + await refundMutation.mutateAsync(order); + } catch { + // Errors are surfaced through the mutation onError handler. + } }} > {isPending @@ -335,26 +386,55 @@ export default function Order() { header: t("status.0", "Status"), cell: ({ row }) => { const order = row.original as API.Order; - const option = statusOptions.find( + const option = displayStatusOptions.find( (opt) => opt.value === order.status ); - if ([1, 3, 4].includes(row.getValue("status"))) { + if ( + [ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED, ORDER_STATUS_FAILED] + .map(String) + .includes(String(row.getValue("status"))) + ) { return (
className={cn(option?.className)} onChange={async (value) => { - await updateOrderStatus({ - id: order.id, - status: value, - }); - ref.current?.refresh(); + if (!canManuallyUpdateOrderStatus(value)) { + toast.error( + t( + "statusClaimedBlocked", + "Claimed and refunded states must use the dedicated processing flow." + ) + ); + return; + } + try { + await updateOrderStatus( + { + id: order.id, + status: value, + }, + { + skipErrorHandler: true, + } + ); + ref.current?.refresh(); + } catch { + toast.error( + t( + "statusUpdateFailed", + "Status update failed. Please try again." + ) + ); + } }} - options={statusOptions} + options={editableStatusOptions} placeholder={t("status.0", "Status")} value={order.status} /> - {[1, 3].includes(order.status) && ( + {[ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED].includes( + order.status + ) && (