380 lines
14 KiB
JavaScript
380 lines
14 KiB
JavaScript
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: 100, enabled: true }],
|
||
},
|
||
],
|
||
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: 100,
|
||
tier_id: "",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
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" },
|
||
});
|
||
});
|