zhx 379703cd76 Merge branch 'test' of gitea.haiyihy.com:hy/hyapp-admin-platform into test
# Conflicts:
#	ops-center/src/configForm.js
#	src/app/permissions.ts
2026-07-16 10:43:03 +08:00

380 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" },
});
});