454 lines
19 KiB
JavaScript
454 lines
19 KiB
JavaScript
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||
import { beforeEach, expect, test, vi } from "vitest";
|
||
import { PERMISSIONS } from "@/app/permissions";
|
||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||
import { adjustLuckyGiftPool, fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
|
||
|
||
vi.mock("./api.js", () => ({
|
||
DEFAULT_POOL_ID: "default",
|
||
adjustLuckyGiftPool: vi.fn(),
|
||
fetchLuckyGiftDraws: vi.fn(),
|
||
fetchOpsDashboard: vi.fn(),
|
||
luckyGiftPoolKey: (item = {}) =>
|
||
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
|
||
saveLuckyGiftConfig: vi.fn(),
|
||
}));
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
window.history.replaceState({}, "", "/");
|
||
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
||
adjustLuckyGiftPool.mockResolvedValue({
|
||
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
|
||
idempotent_replay: false,
|
||
});
|
||
fetchLuckyGiftDraws.mockResolvedValue({
|
||
items: [
|
||
{
|
||
app_code: "lalu",
|
||
created_at_ms: 1767000000000,
|
||
draw_id: "draw-1",
|
||
effective_reward_coins: 100,
|
||
external_user_id: "ext-1",
|
||
gift_id: "gift-9",
|
||
multiplier_ppm: 2_000_000,
|
||
pool_id: "super_lucky",
|
||
reward_status: "granted",
|
||
rule_version: 2,
|
||
},
|
||
],
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: 1,
|
||
});
|
||
fetchOpsDashboard.mockResolvedValue({
|
||
apps: [
|
||
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
||
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
|
||
],
|
||
appSummaries: [
|
||
{
|
||
actual_rtp_ppm: 900000,
|
||
app_code: "lalu",
|
||
failed_draws: 0,
|
||
granted_draws: 3,
|
||
pending_draws: 0,
|
||
total_draws: 3,
|
||
total_reward_coins: 900,
|
||
total_spent_coins: 1000,
|
||
unique_rooms: 2,
|
||
unique_users: 2,
|
||
},
|
||
],
|
||
luckyGifts: [
|
||
{
|
||
...mockDynamicConfig(),
|
||
app_code: "lalu",
|
||
enabled: true,
|
||
is_default: false,
|
||
pool_id: "lucky",
|
||
rule_version: 3,
|
||
},
|
||
{
|
||
...mockFixedConfig(95),
|
||
app_code: "lalu",
|
||
enabled: false,
|
||
is_default: false,
|
||
pool_id: "lucky",
|
||
rule_version: 2,
|
||
},
|
||
{
|
||
...mockFixedConfig(92),
|
||
app_code: "lalu",
|
||
enabled: false,
|
||
is_default: false,
|
||
pool_id: "super_lucky",
|
||
rule_version: 2,
|
||
},
|
||
{
|
||
...mockDynamicConfig(),
|
||
app_code: "yumi",
|
||
enabled: false,
|
||
is_default: true,
|
||
pool_id: "default",
|
||
rule_version: 0,
|
||
},
|
||
],
|
||
poolBalances: [
|
||
{
|
||
app_code: "lalu",
|
||
available_balance: 37950017,
|
||
balance: 38559017,
|
||
manual_credit_total: 1_000_000,
|
||
manual_debit_total: 200_000,
|
||
materialized: true,
|
||
pool_id: "lucky",
|
||
reserve_floor: 609000,
|
||
strategy_version: "dynamic_v3",
|
||
updated_at_ms: 1767000000000,
|
||
},
|
||
{
|
||
app_code: "lalu",
|
||
available_balance: 0,
|
||
balance: 900,
|
||
manual_credit_total: 100,
|
||
manual_debit_total: 20,
|
||
materialized: true,
|
||
pool_id: "lucky",
|
||
reserve_floor: 900,
|
||
strategy_version: "fixed_v2",
|
||
updated_at_ms: 1767000000000,
|
||
},
|
||
{
|
||
app_code: "yumi",
|
||
available_balance: 361500,
|
||
balance: 382500,
|
||
materialized: false,
|
||
pool_id: "default",
|
||
reserve_floor: 21000,
|
||
strategy_version: "dynamic_v3",
|
||
},
|
||
],
|
||
summary: {
|
||
activeApps: 2,
|
||
configuredPools: 2,
|
||
enabledPools: 1,
|
||
failedDraws: 0,
|
||
grantedDraws: 3,
|
||
pendingDraws: 0,
|
||
poolAvailableTotal: 38311517,
|
||
poolBalanceTotal: 38941517,
|
||
poolReserveTotal: 630000,
|
||
totalDraws: 3,
|
||
totalRewardCoins: 900,
|
||
totalSpentCoins: 1000,
|
||
},
|
||
});
|
||
});
|
||
|
||
const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate];
|
||
|
||
function renderApp(permissions = allPermissions) {
|
||
return render(
|
||
<ToastProvider>
|
||
<OpsCenterApp can={(code) => permissions.includes(code)} />
|
||
</ToastProvider>,
|
||
);
|
||
}
|
||
|
||
test("renders overview with aggregated kpis and pool balances", async () => {
|
||
renderApp();
|
||
|
||
expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy();
|
||
expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy();
|
||
["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => {
|
||
expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy();
|
||
});
|
||
|
||
expect(await screen.findByText("运营应用")).toBeTruthy();
|
||
// 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。
|
||
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
|
||
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
|
||
expect(screen.getByText("奖池实时水位")).toBeTruthy();
|
||
expect(screen.getAllByText("已入账").length).toBeGreaterThan(1);
|
||
expect(screen.getByText("默认水位")).toBeTruthy();
|
||
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
test("opens the requested config view from the legacy admin document redirect", async () => {
|
||
expect(initialOpsCenterView("?view=configs")).toBe("configs");
|
||
expect(initialOpsCenterView("?view=unknown")).toBe("overview");
|
||
window.history.replaceState({}, "", "/ops-center/?view=configs");
|
||
|
||
renderApp();
|
||
|
||
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
|
||
expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test("lists every pool config including published non-default pools", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||
|
||
// 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
|
||
expect((await screen.findAllByText("lucky")).length).toBeGreaterThan(1);
|
||
expect(screen.getByText("super_lucky")).toBeTruthy();
|
||
expect(screen.getByText("v3")).toBeTruthy();
|
||
expect(screen.getAllByText("v2").length).toBeGreaterThan(0);
|
||
expect(screen.getByText("已启用")).toBeTruthy();
|
||
expect(screen.getAllByText("已停用").length).toBeGreaterThan(1);
|
||
expect(screen.getByText("默认草稿")).toBeTruthy();
|
||
});
|
||
|
||
test("shows strategy-specific balances and adjustment actions directly in the config list", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||
|
||
expect(await screen.findByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
|
||
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeEnabled();
|
||
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / fixed_v2" })).toBeTruthy();
|
||
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / fixed_v2" })).toBeDisabled();
|
||
expect(screen.getByText("38,559,017")).toBeTruthy();
|
||
expect(screen.getByText("900")).toBeTruthy();
|
||
});
|
||
|
||
test("shows credit and debit buttons only when the standalone session grants each permission", async () => {
|
||
renderApp([PERMISSIONS.luckyGiftPoolCredit]);
|
||
await screen.findByText("运营应用");
|
||
|
||
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
|
||
expect(screen.queryByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeNull();
|
||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||
expect(screen.queryByRole("button", { name: "添加配置" })).toBeNull();
|
||
expect(screen.queryByRole("button", { name: /新增版本 lalu/ })).toBeNull();
|
||
});
|
||
|
||
test("submits a pool credit, reports before and after balances, closes, and refreshes", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
fireEvent.click(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" }));
|
||
|
||
const dialog = await screen.findByRole("dialog", { name: "添加奖池水位" });
|
||
expect(within(dialog).getByText("dynamic_v3")).toBeTruthy();
|
||
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "1000000" } });
|
||
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "运营补水" } });
|
||
fireEvent.click(within(dialog).getByRole("button", { name: "确认添加" }));
|
||
|
||
await waitFor(() => {
|
||
expect(adjustLuckyGiftPool).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
adjustmentId: expect.any(String),
|
||
amountCoins: 1_000_000,
|
||
appCode: "lalu",
|
||
direction: "in",
|
||
poolId: "lucky",
|
||
reason: "运营补水",
|
||
strategyVersion: "dynamic_v3",
|
||
}),
|
||
);
|
||
});
|
||
expect(await screen.findByText(/38,559,017 → 39,559,017/)).toBeTruthy();
|
||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull());
|
||
expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
test("keeps the form and adjustment id stable when a debit request is retried", async () => {
|
||
adjustLuckyGiftPool.mockRejectedValueOnce(new Error("可用水位已变化")).mockResolvedValueOnce({
|
||
adjustment: { balance_after: 38_558_917, balance_before: 38_559_017 },
|
||
idempotent_replay: false,
|
||
});
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
fireEvent.click(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" }));
|
||
|
||
const dialog = await screen.findByRole("dialog", { name: "扣减奖池水位" });
|
||
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "100" } });
|
||
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "冲正" } });
|
||
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
|
||
|
||
expect(await screen.findByText("可用水位已变化")).toBeTruthy();
|
||
expect(within(dialog).getByLabelText(/调整金币/)).toHaveValue("100");
|
||
expect(within(dialog).getByLabelText(/调整原因/)).toHaveValue("冲正");
|
||
const firstAdjustmentID = adjustLuckyGiftPool.mock.calls[0][0].adjustmentId;
|
||
|
||
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
|
||
await waitFor(() => expect(adjustLuckyGiftPool).toHaveBeenCalledTimes(2));
|
||
expect(adjustLuckyGiftPool.mock.calls[1][0].adjustmentId).toBe(firstAdjustmentID);
|
||
});
|
||
|
||
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||
fireEvent.click(await screen.findByRole("button", { name: "添加配置" }));
|
||
|
||
const dialog = await screen.findByRole("dialog");
|
||
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
|
||
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
||
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
|
||
// 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。
|
||
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
|
||
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
|
||
});
|
||
|
||
test("opens config dialog from a published pool row and saves a new version", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||
await screen.findAllByText("lucky");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
|
||
const dialog = await screen.findByRole("dialog");
|
||
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
||
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
||
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
|
||
expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled();
|
||
expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000");
|
||
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
|
||
|
||
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
|
||
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||
|
||
await waitFor(() => {
|
||
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
app_code: "lalu",
|
||
anchor_rate_percent: 1,
|
||
initial_pool_coins: 0,
|
||
jackpot_multipliers: [200, 500, 1000],
|
||
pool_id: "lucky",
|
||
profit_rate_percent: 1,
|
||
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
|
||
strategy_version: "dynamic_v3",
|
||
target_rtp_percent: 88,
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
test("blocks an invalid enabled dynamic rule before calling the save API", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||
await screen.findAllByText("lucky");
|
||
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
|
||
|
||
const dialog = await screen.findByRole("dialog");
|
||
fireEvent.change(within(dialog).getByLabelText(/低水位金币/), { target: { value: "0" } });
|
||
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||
|
||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("高水位必须大于正数低水位");
|
||
expect(saveLuckyGiftConfig).not.toHaveBeenCalled();
|
||
|
||
// 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。
|
||
fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } });
|
||
expect(within(dialog).queryByRole("alert")).toBeNull();
|
||
});
|
||
|
||
function mockDynamicConfig() {
|
||
return {
|
||
anchor_daily_payout_cap: 6_000_000,
|
||
anchor_rate_percent: 1,
|
||
control_band_percent: 3,
|
||
device_daily_payout_cap: 4_000_000,
|
||
effective_from_ms: 0,
|
||
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_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: 1_000_000,
|
||
normal_max_equivalent_draws: 20_000,
|
||
novice_max_equivalent_draws: 2_000,
|
||
pool_rate_percent: 98,
|
||
profit_rate_percent: 1,
|
||
recharge_boost_factor_percent: 110,
|
||
recharge_boost_window_ms: 300_000,
|
||
room_hourly_payout_cap: 5_000_000,
|
||
settlement_window_wager: 1_000_000,
|
||
stages: mockStages(98),
|
||
strategy_version: "dynamic_v3",
|
||
target_rtp_percent: 98,
|
||
user_daily_payout_cap: 3_000_000,
|
||
user_hourly_payout_cap: 2_000_000,
|
||
};
|
||
}
|
||
|
||
function mockFixedConfig(targetRTP) {
|
||
return {
|
||
control_band_percent: 1,
|
||
gift_price_reference: 100,
|
||
normal_max_equivalent_draws: 20_000,
|
||
novice_max_equivalent_draws: 2_000,
|
||
pool_rate_percent: targetRTP + 1,
|
||
settlement_window_wager: 1_000_000,
|
||
stages: mockStages(targetRTP),
|
||
strategy_version: "fixed_v2",
|
||
target_rtp_percent: targetRTP,
|
||
};
|
||
}
|
||
|
||
function mockStages(targetRTP) {
|
||
return ["novice", "normal", "advanced"].map((stage, index) => ({
|
||
min_recharge_30d_coins: index === 0 ? 0 : index,
|
||
min_recharge_7d_coins: index === 2 ? 1 : 0,
|
||
stage,
|
||
tiers: [
|
||
{
|
||
enabled: true,
|
||
high_water_only: false,
|
||
multiplier: 0,
|
||
probability_percent: 100 - targetRTP,
|
||
tier_id: `${stage}_none`,
|
||
},
|
||
{
|
||
enabled: true,
|
||
high_water_only: false,
|
||
multiplier: 1,
|
||
probability_percent: targetRTP,
|
||
tier_id: `${stage}_1x`,
|
||
},
|
||
],
|
||
}));
|
||
}
|
||
|
||
test("loads draw records for the first app with server pagination", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ }));
|
||
|
||
await waitFor(() => {
|
||
expect(fetchLuckyGiftDraws).toHaveBeenCalledWith(
|
||
expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }),
|
||
);
|
||
});
|
||
expect(await screen.findByText("ext-1")).toBeTruthy();
|
||
expect(screen.getByText("已发放")).toBeTruthy();
|
||
expect(screen.getByText("2x")).toBeTruthy();
|
||
});
|
||
|
||
test("shows app registry and fixed integrations in apps view", async () => {
|
||
renderApp();
|
||
await screen.findByText("运营应用");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: /应用列表/ }));
|
||
|
||
expect(await screen.findByText("Lalu")).toBeTruthy();
|
||
expect(screen.getByText("主数据")).toBeTruthy();
|
||
expect(screen.getByText("外部接入")).toBeTruthy();
|
||
});
|