import { beforeEach, describe, expect, it, vi } from "vitest"; const d = vi.hoisted(() => ({ rows: [] as unknown[][], events: [] as string[], set: vi.fn(), execute: vi.fn(), audit: vi.fn(), synchronize: vi.fn(), })); vi.mock("server-only", () => ({})); vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: 8 } }) })); vi.mock("@/lib/rate-limit", () => ({ clientIp: async () => "local", rateLimit: async () => ({ ok: true }), })); vi.mock("@/lib/services/paypal", () => ({ creditsPerUnit: () => 10 })); vi.mock("@/lib/services/send-currency", () => ({ currencyDb: {}, sendCurrency: vi.fn(), })); vi.mock("@/lib/services/rcon", () => ({ rcon: { giveBadge: vi.fn() } })); vi.mock("@/lib/services/audit", () => ({ logAudit: d.audit })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: (url: string) => { throw Object.assign(new Error(url), { digest: "NEXT_REDIRECT" }); }, })); vi.mock("@/lib/db", async (importOriginal) => { const actual = await importOriginal(); const facade = { execute: d.execute, select: () => ({ from: () => ({ where: () => ({ limit: async () => d.rows.shift() ?? [], for: async () => { d.events.push("user-lock"); return d.rows.shift() ?? []; }, }), }), }), update: () => ({ set: d.set }), }; return { ...actual, db: { ...facade, transaction: async (run: (tx: typeof facade) => unknown) => { const result = await run(facade); d.events.push("commit"); return result; }, }, }; }); vi.mock("@/lib/services/rank-assignment", async (importOriginal) => ({ ...(await importOriginal()), rankAssignmentCoordinator: { synchronize: d.synchronize }, })); import { buyShopArticle } from "./shop"; function input() { const data = new FormData(); data.set("articleId", "1"); data.set("categoryId", "1"); return data; } beforeEach(() => { vi.clearAllMocks(); d.rows.length = 0; d.events.length = 0; d.rows.push( [ { id: 1, name: "Member", costs: 100, giveRank: 4, badges: "", credits: 0, duckets: 0, diamonds: 0, }, ], [{ credits: 100, rank: 2 }], ); d.execute.mockImplementation(async () => { d.events.push("rank-lock"); return [[{ id: 4 }]]; }); d.set.mockImplementation(() => ({ where: async () => { d.events.push("update"); }, })); d.audit.mockResolvedValue(undefined); d.synchronize.mockImplementation(async () => { d.events.push("delivery"); return { status: "delivered", rank: 4 }; }); }); describe("shop rank coordination", () => { it("locks rank before user, commits the purchase, then synchronizes", async () => { d.rows.push([{ credits: 100, rank: 2 }]); await expect(buyShopArticle(input())).rejects.toThrow("bought=1"); expect(d.events).toEqual([ "rank-lock", "user-lock", "update", "update", "commit", "delivery", ]); expect(d.set).toHaveBeenCalledWith({ rank: 4 }); expect(d.audit.mock.calls.map(([entry]) => entry.outcome)).toEqual([ "intent", "success", ]); }); it("does not overwrite a newer staff promotion with the previously observed buyer rank", async () => { d.rows.push([{ credits: 100, rank: 6 }]); await expect(buyShopArticle(input())).rejects.toThrow("bought=1"); expect(d.set).not.toHaveBeenCalledWith({ rank: 4 }); expect(d.synchronize).not.toHaveBeenCalled(); }); it("does not charge when the configured package rank was deleted", async () => { d.execute.mockResolvedValueOnce([[]]); await expect(buyShopArticle(input())).rejects.toThrow("error=error"); expect(d.set).not.toHaveBeenCalled(); expect(d.synchronize).not.toHaveBeenCalled(); }); it("keeps a durable partial audit without turning a committed purchase into a retry", async () => { d.rows.push([{ credits: 100, rank: 2 }]); d.synchronize.mockResolvedValueOnce({ status: "pending", rank: 4 }); await expect(buyShopArticle(input())).rejects.toThrow("bought=1"); expect(d.audit).toHaveBeenLastCalledWith( expect.objectContaining({ outcome: "partial", correlationId: expect.any(String), }), ); }); });