import { afterEach, expect, test, vi } from "vitest"; import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request"; import { adjustLuckyGiftPool, buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, luckyGiftPoolKey, normalizeDashboard, opsRequest, OPS_API_PREFIX, saveLuckyGiftConfig, } from "./api.js"; afterEach(() => { vi.restoreAllMocks(); setAccessToken(""); setRefreshHandler(null); setUnauthorizedHandler(null); }); function jsonResponse(data) { return new Response(JSON.stringify({ code: 0, data }), { headers: { "Content-Type": "application/json" }, status: 200, }); } test("builds ops api urls under the dedicated prefix", () => { const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" }); expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center"); expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); }); test("refreshes a stale access token before retrying an ops-center request", async () => { setAccessToken("stale-token"); setRefreshHandler(async () => { setAccessToken("fresh-token"); return true; }); vi.spyOn(window, "fetch") .mockResolvedValueOnce( new Response(JSON.stringify({ code: 40100, message: "访问凭证已失效" }), { headers: { "Content-Type": "application/json" }, status: 401, }), ) .mockResolvedValueOnce(jsonResponse({ items: [{ app_code: "lalu" }] })); await expect(opsRequest("/apps")).resolves.toEqual({ items: [{ app_code: "lalu" }] }); expect(window.fetch).toHaveBeenCalledTimes(2); expect(window.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer stale-token"); expect(window.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer fresh-token"); }); test("loads every pool config per app through the plural configs endpoint", async () => { const calls = []; vi.spyOn(window, "fetch").mockImplementation(async (url) => { const text = String(url); calls.push(text); if (text.endsWith("/apps")) { return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); } if (text.includes("/lucky-gifts/configs?")) { // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 return jsonResponse([ { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }, ]); } if (text.includes("/lucky-gifts/pools?")) { return jsonResponse({ items: [ { app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, }, { app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000, }, ], }); } if (text.includes("/lucky-gifts/summary?")) { return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000, }); } return jsonResponse({ items: [] }); }); const data = await fetchOpsDashboard(); expect(calls).toEqual([ "http://localhost:3000/api/v1/admin/ops-center/apps", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu", ]); expect(data.luckyGifts).toEqual([ expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }), ]); expect(data.summary).toMatchObject({ activeApps: 1, configuredPools: 2, enabledPools: 1, poolBalanceTotal: 38941517, totalDraws: 10, }); }); test("falls back to the server default draft when an app has no published config", async () => { const calls = []; vi.spyOn(window, "fetch").mockImplementation(async (url) => { const text = String(url); calls.push(text); if (text.endsWith("/apps")) { return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] }); } if (text.includes("/lucky-gifts/configs?")) { return jsonResponse([]); } if (text.includes("/lucky-gifts/config?")) { return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95, }); } return jsonResponse({ items: [] }); }); const data = await fetchOpsDashboard(); expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); expect(data.luckyGifts).toEqual([ expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }), ]); }); test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => { const calls = []; vi.spyOn(window, "fetch").mockImplementation(async (url) => { calls.push(String(url)); return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 }); }); const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" }); await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" }); const numericParams = new URL(calls[0]).searchParams; expect(numericParams.get("app_code")).toBe("lalu"); expect(numericParams.get("pool_id")).toBe("lucky"); expect(numericParams.get("user_id")).toBe("10001"); expect(numericParams.get("external_user_id")).toBeNull(); const externalParams = new URL(calls[1]).searchParams; expect(externalParams.get("external_user_id")).toBe("ext-abc"); expect(externalParams.get("user_id")).toBeNull(); expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 }); }); test("saves lucky gift config through admin ops route", async () => { const calls = []; vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { calls.push({ body: init.body, method: init.method, url: String(url) }); return jsonResponse({ app_code: "aslan", pool_id: "default" }); }); await saveLuckyGiftConfig({ anchor_daily_payout_cap: 12_312_000, anchor_rate_percent: 1, app_code: "aslan", control_band_percent: 3, device_daily_payout_cap: 1_026_000, enabled: true, gift_price_reference: 100, high_water_nonzero_factor_percent: 130, high_watermark_coins: 20_000_000, initial_pool_coins: 1_000_000, jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 50_000, jackpot_user_72h_rtp_max_percent: 96, jackpot_user_day_rtp_max_percent: 96, loss_streak_guarantee: 5, low_water_nonzero_factor_percent: 70, low_watermark_coins: 10_000_000, max_jackpot_hits_per_user_day: 5, max_single_payout: 50_000, normal_max_equivalent_draws: 20_000, novice_max_equivalent_draws: 2_000, pool_id: "default", pool_rate_percent: 98, profit_rate_percent: 1, recharge_boost_factor_percent: 110, recharge_boost_window_ms: 300_000, room_hourly_payout_cap: 684_000, settlement_window_wager: 1_000_000, stages: [ { min_recharge_30d_coins: 0, min_recharge_7d_coins: 0, stage: "novice", tiers: [ { multiplier: 1, probabilityPercent: 99.99, enabled: true }, { enabled: true, highWaterOnly: true, multiplier: 200, probabilityPercent: 0.01, tierId: "novice_200x", }, ], }, ], strategy_version: "dynamic_v3", target_rtp_percent: 98, user_daily_payout_cap: 615_600, user_hourly_payout_cap: 34_200, }); expect(calls[0].method).toBe("PUT"); expect(calls[0].url).toBe( "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default", ); // 精确比较完整 owner payload;子集断言会让新增/遗漏的策略字段在回归中静默通过。 expect(JSON.parse(calls[0].body)).toEqual({ anchor_daily_payout_cap: 12_312_000, anchor_rate_percent: 1, app_code: "aslan", control_band_percent: 3, device_daily_payout_cap: 1_026_000, effective_from_ms: 0, enabled: true, gift_price_reference: 100, high_water_nonzero_factor_percent: 130, high_watermark_coins: 20_000_000, initial_pool_coins: 0, jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 50_000, jackpot_user_48h_rtp_max_percent: 96, jackpot_user_round_rtp_max_percent: 96, loss_streak_guarantee: 5, low_water_nonzero_factor_percent: 70, low_watermark_coins: 10_000_000, max_jackpot_hits_per_user_day: 5, max_single_payout: 50_000, normal_max_equivalent_draws: 20_000, novice_max_equivalent_draws: 2_000, pool_id: "default", pool_rate_percent: 98, profit_rate_percent: 1, recharge_boost_factor_percent: 110, recharge_boost_window_ms: 300_000, room_hourly_payout_cap: 684_000, settlement_window_wager: 1_000_000, stages: [ { min_recharge_30d_coins: 0, min_recharge_7d_coins: 0, stage: "novice", tiers: [ { enabled: true, high_water_only: false, multiplier: 1, probability_percent: 99.99, tier_id: "", }, { enabled: true, high_water_only: true, multiplier: 200, probability_percent: 0.01, tier_id: "novice_200x", }, ], }, ], strategy_version: "dynamic_v3", target_rtp_percent: 98, user_daily_payout_cap: 615_600, user_hourly_payout_cap: 34_200, }); }); test("keeps fixed_v2 and dynamic_v3 balances independent for the same app and pool", () => { const data = normalizeDashboard({ apps: [{ app_code: "lalu" }], perApp: [ { appCode: "lalu", poolBalances: [ { availableBalance: 700, balance: 900, manualCreditTotal: 100, manualDebitTotal: 20, poolId: "lucky", reserveFloor: 200, strategyVersion: "fixed_v2", }, { available_balance: 3_000, balance: 4_000, manual_credit_total: 2_000, manual_debit_total: 500, pool_id: "lucky", reserve_floor: 1_000, strategy_version: "dynamic_v3", }, ], }, ], }); expect(data.poolBalances).toHaveLength(2); expect(new Set(data.poolBalances.map(luckyGiftPoolKey))).toEqual( new Set(["lalu:lucky:fixed_v2", "lalu:lucky:dynamic_v3"]), ); expect(data.poolBalances[0]).toMatchObject({ manual_credit_total: 100, manual_debit_total: 20 }); expect(data.poolBalances[1]).toMatchObject({ manual_credit_total: 2_000, manual_debit_total: 500 }); expect(data.summary.poolBalanceTotal).toBe(4_900); }); test.each([ ["in", "credit"], ["out", "debit"], ])("posts %s pool adjustments with the strategy identity and idempotency key", async (direction, route) => { const calls = []; vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) }); return jsonResponse({ adjustment: { adjustment_id: "adjust-1", amount_coins: 1_000, balance_after: direction === "out" ? 9_000 : 11_000, balance_before: 10_000, strategy_version: "dynamic_v3", }, idempotent_replay: false, pool: { app_code: "lalu", available_balance: 8_000, balance: direction === "out" ? 9_000 : 11_000, manual_credit_total: direction === "in" ? 1_000 : 0, manual_debit_total: direction === "out" ? 1_000 : 0, materialized: true, pool_id: "lucky", reserve_floor: 1_000, strategy_version: "dynamic_v3", }, }); }); const result = await adjustLuckyGiftPool({ adjustmentId: "adjust-1", amountCoins: 1_000, appCode: "LALU", direction, poolId: "lucky", reason: " 运营调节 ", strategyVersion: "dynamic_v3", }); expect(calls).toEqual([ { body: { adjustment_id: "adjust-1", amount_coins: 1_000, reason: "运营调节" }, method: "POST", url: `http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools/${route}?app_code=lalu&pool_id=lucky&strategy_version=dynamic_v3`, }, ]); expect(result).toMatchObject({ adjustment: { balance_before: 10_000, strategy_version: "dynamic_v3" }, pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" }, }); });