feat(admin+api): 代理停用默认、结算加固与冒烟配置探针

- 代理层级默认授信比例与停用冻结/禁登全局默认

- 结算预览去重、比分校验、串关当场判负与市场类型校验

- 站内信 Banner/公告自动通知开关;开发环境动态 API 端口

- 扩充 RBAC/结算/认证/返现单元测试与 agent skills
This commit is contained in:
2026-06-23 11:08:41 +08:00
parent fa06fee64c
commit ce84226219
47 changed files with 9284 additions and 118 deletions

View File

@@ -0,0 +1,126 @@
import { Decimal } from '@prisma/client/runtime/library';
import { CashbackService } from './cashback.service';
describe('CashbackService previewBatch', () => {
const tx = {
cashbackBatch: {
findMany: jest.fn(),
create: jest.fn(),
},
cashbackItem: {
create: jest.fn(),
},
cashbackBet: {
create: jest.fn(),
},
};
const prisma = {
cashbackBatch: {
findFirst: jest.fn(),
},
cashbackBet: {
findMany: jest.fn(),
},
bet: {
findMany: jest.fn(),
},
cashbackRule: {
findMany: jest.fn(),
},
agentProfile: {
findMany: jest.fn(),
},
user: {
findMany: jest.fn(),
},
wallet: {
findMany: jest.fn(),
},
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {};
const systemConfig = {
getPlatformDirectCashbackSettings: jest.fn(),
};
let service: CashbackService;
const platformPlayerId = 100n;
const adminInvitePlayerId = 101n;
const adminSponsorId = 200n;
const periodStart = new Date('2026-06-01T00:00:00.000Z');
const periodEnd = new Date('2026-06-01T23:59:59.999Z');
beforeEach(() => {
jest.clearAllMocks();
service = new CashbackService(prisma as never, funds as never, systemConfig as never);
systemConfig.getPlatformDirectCashbackSettings.mockResolvedValue({
platformDirectRate: 0.02,
adminInviteRate: 0.05,
});
prisma.cashbackBatch.findFirst.mockResolvedValue(null);
prisma.cashbackBet.findMany.mockResolvedValue([]);
prisma.cashbackRule.findMany.mockResolvedValue([]);
prisma.agentProfile.findMany.mockResolvedValue([]);
tx.cashbackBatch.findMany.mockResolvedValue([]);
tx.cashbackItem.create.mockResolvedValue({});
tx.cashbackBet.create.mockResolvedValue({});
tx.cashbackBatch.create.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 1n, batchNo: 'CB-TEST', ...data }),
);
});
it('uses platformDirectRate for platform-direct players', async () => {
prisma.bet.findMany.mockResolvedValue([
{
id: 1n,
userId: platformPlayerId,
stake: new Decimal(1000),
status: 'WON',
settledAt: new Date('2026-06-01T12:00:00.000Z'),
user: { id: platformPlayerId, parentId: null, inviteSponsorId: null },
selections: [{ marketType: 'FT_1X2' }],
},
]);
prisma.user.findMany.mockResolvedValue([
{ id: platformPlayerId, username: 'pd1', parent: null },
]);
prisma.wallet.findMany.mockResolvedValue([
{ userId: platformPlayerId, availableBalance: new Decimal(0) },
]);
const result = await service.previewBatch(periodStart, periodEnd);
expect(result.items).toHaveLength(1);
expect(result.items[0].amount.toString()).toBe('20');
expect(result.totalAmount.toString()).toBe('20');
});
it('uses adminInviteRate for admin-invited platform-direct players', async () => {
prisma.bet.findMany.mockResolvedValue([
{
id: 2n,
userId: adminInvitePlayerId,
stake: new Decimal(1000),
status: 'LOST',
settledAt: new Date('2026-06-01T12:00:00.000Z'),
user: {
id: adminInvitePlayerId,
parentId: null,
inviteSponsorId: adminSponsorId,
},
selections: [{ marketType: 'FT_1X2' }],
},
]);
prisma.user.findMany
.mockResolvedValueOnce([{ id: adminSponsorId, userType: 'ADMIN' }])
.mockResolvedValueOnce([{ id: adminInvitePlayerId, username: 'inv1', parent: null }]);
prisma.wallet.findMany.mockResolvedValue([
{ userId: adminInvitePlayerId, availableBalance: new Decimal(0) },
]);
const result = await service.previewBatch(periodStart, periodEnd);
expect(result.items).toHaveLength(1);
expect(result.items[0].amount.toString()).toBe('50');
expect(result.totalAmount.toString()).toBe('50');
});
});

View File

@@ -1,4 +1,5 @@
import { Decimal } from '@prisma/client/runtime/library';
import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service';
import type { AgentsService } from '../../agent/agents.service';
import type { BetsService } from '../../betting/bets.service';
import type { SettlementService } from '../../settlement/settlement.service';
@@ -12,7 +13,7 @@ import {
teardownBetFlowFixture,
} from './smoke-test.bet-flow.fixture';
export const BET_FLOW_PROBE_COUNT = 5;
export const BET_FLOW_PROBE_COUNT = 13;
export type BetFlowProbeDeps = {
prisma: PrismaService;
@@ -22,6 +23,38 @@ export type BetFlowProbeDeps = {
agents: AgentsService;
};
async function upsertSystemConfig(
prisma: PrismaService,
key: string,
value: string,
snapshots: Array<{ key: string; value: string | null }>,
) {
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
snapshots.push({ key, value: row?.configValue ?? null });
await prisma.systemConfig.upsert({
where: { configKey: key },
create: { configKey: key, configValue: value, description: 'smoke test' },
update: { configValue: value },
});
}
async function restoreSystemConfigs(
prisma: PrismaService,
snapshots: Array<{ key: string; value: string | null }>,
) {
for (const snap of snapshots) {
if (snap.value === null) {
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
} else {
await prisma.systemConfig.upsert({
where: { configKey: snap.key },
create: { configKey: snap.key, configValue: snap.value, description: 'smoke test restore' },
update: { configValue: snap.value },
});
}
}
}
async function confirmMatchSettlement(
deps: BetFlowProbeDeps,
fx: BetFlowFixtureIds,
@@ -201,6 +234,401 @@ export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[]
}
},
},
{
id: 'BF006',
suite: 'bet-flow',
name: '录分校验:半场比分不能大于全场',
description: 'HT>FT 时 recordScore 返回 SETTLEMENT_SCORE_INVALID',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
await expectAppErrorThrows(
'recordScore invalid HT>FT',
async () => {
await deps.settlement.recordScore(fx.matchId, 2, 0, 1, 0, fx.operatorId);
},
'SETTLEMENT_SCORE_INVALID',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF007',
suite: 'bet-flow',
name: '确认结算:拒绝已作废的旧 PREVIEW 批次',
description: '新 preview 后旧 batch 变为 CANCELLEDconfirm 旧 batchId 失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
const previewA = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 0,
});
await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
htHome: 0,
htAway: 0,
ftHome: 2,
ftAway: 1,
});
const stale = await deps.prisma.settlementBatch.findUnique({
where: { id: previewA.batch.id },
});
expectEqual('old batch cancelled', stale?.status, 'CANCELLED');
await expectAppErrorThrows(
'confirm cancelled batch',
async () => {
await deps.settlement.confirmSettlement(previewA.batch.id, fx.operatorId);
},
'SETTLEMENT_BATCH_ALREADY_CONFIRMED',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF008',
suite: 'bet-flow',
name: '串动作废VOID 腿续算,已有 LOSE 腿不退本',
description: '跨场串关一场 LOSE 后另一场取消payout=0 而非退 stake',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const matchB = await deps.prisma.match.create({
data: {
sportType: 'FOOTBALL',
leagueId: fx.leagueId,
homeTeamId: fx.homeTeamId,
awayTeamId: fx.awayTeamId,
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
status: 'PUBLISHED',
publishTime: new Date(),
},
});
const marketB = await deps.prisma.market.create({
data: {
matchId: matchB.id,
marketType: 'FT_1X2',
period: 'FT',
status: 'OPEN',
selections: {
create: [
{
selectionCode: 'HOME',
selectionName: 'Home B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 0,
},
{
selectionCode: 'AWAY',
selectionName: 'Away B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 1,
},
],
},
},
include: { selections: true },
});
const awayB = marketB.selections.find((s) => s.selectionCode === 'AWAY')!;
try {
const parlay = await deps.bets.placeParlayBet(
fx.playerId,
null,
[
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
],
100,
`smoke-parlay-void-${fx.runId}`,
);
await deps.prisma.match.update({
where: { id: matchB.id },
data: { status: 'CLOSED', closeTime: new Date() },
});
const previewB = await deps.settlement.previewSettlement(matchB.id, fx.operatorId, {
htHome: 1,
htAway: 0,
ftHome: 2,
ftAway: 1,
});
await deps.settlement.confirmSettlement(previewB.batch.id, fx.operatorId);
let w = await deps.wallet.getWallet(fx.playerId);
expectEqual('frozen while pending', w.frozenBalance.toString(), '100');
await deps.settlement.voidMatchBets(fx.matchId);
const settled = await deps.prisma.bet.findUnique({ where: { id: parlay.id } });
expectEqual('settled status', settled?.status, 'LOST');
expectEqual('actualReturn', settled?.actualReturn.toString(), '0');
w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available after void parlay', w.availableBalance.toString(), '900');
expectEqual('frozen after void parlay', w.frozenBalance.toString(), '0');
} finally {
const bets = await deps.prisma.bet.findMany({
where: { userId: fx.playerId },
select: { id: true },
});
const betIds = bets.map((b) => b.id);
if (betIds.length) {
await deps.prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
await deps.prisma.bet.deleteMany({ where: { id: { in: betIds } } });
}
const batchIds = (
await deps.prisma.settlementBatch.findMany({
where: { matchId: { in: [fx.matchId, matchB.id] } },
select: { id: true },
})
).map((b) => b.id);
if (batchIds.length) {
await deps.prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } });
await deps.prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } });
}
await deps.prisma.matchScore.deleteMany({
where: { matchId: { in: [fx.matchId, matchB.id] } },
});
const marketIds = (
await deps.prisma.market.findMany({
where: { matchId: matchB.id },
select: { id: true },
})
).map((m) => m.id);
if (marketIds.length) {
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
}
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF009',
suite: 'bet-flow',
name: '赔率版本不一致BetsService 拒绝下注',
uatRef: 'B003',
description: '提交旧 oddsVersion 应返回 ODDS_CHANGED',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
try {
await deps.prisma.marketSelection.update({
where: { id: fx.homeSelectionId },
data: { oddsVersion: { increment: 1 } },
});
await expectAppErrorThrows(
'placeSingleBet odds changed',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
`smoke-odds-${fx.runId}`,
);
},
'ODDS_CHANGED',
);
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
expectEqual('bet count', count, 0);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF010',
suite: 'bet-flow',
name: '赛前限制:开赛后拒绝下注',
description: 'startTime 已过后 placeSingleBet 返回 PRE_MATCH_ONLY',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { startTime: new Date(Date.now() - 60_000) },
});
await expectAppErrorThrows(
'placeSingleBet after kickoff',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
`smoke-prematch-${fx.runId}`,
);
},
'PRE_MATCH_ONLY',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF011',
suite: 'bet-flow',
name: '每日投注上限:超额拒单',
description: 'dailyStakeLimit=150 时第二笔 100 注单失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.dailyStakeLimit, '150', snapshots);
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-daily-1-${fx.runId}`,
);
await expectAppErrorThrows(
'placeSingleBet daily limit',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.drawSelectionId,
fx.drawOddsVersion,
100,
`smoke-daily-2-${fx.runId}`,
);
},
'DAILY_STAKE_LIMIT',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF012',
suite: 'bet-flow',
name: '串关限额:超 maxStakeParlay 拒单',
description: 'maxStakeParlay=80 时下 100 串关失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const matchB = await deps.prisma.match.create({
data: {
sportType: 'FOOTBALL',
leagueId: fx.leagueId,
homeTeamId: fx.homeTeamId,
awayTeamId: fx.awayTeamId,
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
status: 'PUBLISHED',
publishTime: new Date(),
},
});
const marketB = await deps.prisma.market.create({
data: {
matchId: matchB.id,
marketType: 'FT_1X2',
period: 'FT',
status: 'OPEN',
selections: {
create: [
{
selectionCode: 'AWAY',
selectionName: 'Away B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 0,
},
],
},
},
include: { selections: true },
});
const awayB = marketB.selections[0]!;
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.maxStakeParlay, '80', snapshots);
await expectAppErrorThrows(
'placeParlayBet max stake',
async () => {
await deps.bets.placeParlayBet(
fx.playerId,
null,
[
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
],
100,
`smoke-parlay-max-${fx.runId}`,
);
},
'MAX_STAKE',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
const markets = await deps.prisma.market.findMany({
where: { matchId: matchB.id },
select: { id: true },
});
const marketIds = markets.map((m) => m.id);
if (marketIds.length) {
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
}
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF013',
suite: 'bet-flow',
name: '改 SystemConfig 后拒单minStake',
description: 'minStake=200 时下 100 单关返回 MIN_STAKE',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.minStake, '200', snapshots);
await expectAppErrorThrows(
'placeSingleBet min stake',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-min-${fx.runId}`,
);
},
'MIN_STAKE',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF005',
suite: 'bet-flow',

View File

@@ -380,6 +380,36 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs });
},
},
{
id: 'S020',
suite: 'settlement',
name: '串关 VOID 腿 + LOSE 腿:整单全输',
description: '比赛取消腿按 odds=1.0 续算,已有 LOSE 腿时 payout=0',
run: () => {
const legs = [
{ odds: 2.0, result: 'VOID' as const },
{ odds: 2.0, result: 'LOSE' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'LOST', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs });
},
},
{
id: 'S021',
suite: 'settlement',
name: '串关 VOID 腿 + WIN 腿:按 WIN 腿赔率结算',
description: '作废腿不参与升赔,其余 WIN 腿正常连乘',
run: () => {
const legs = [
{ odds: 2.0, result: 'VOID' as const },
{ odds: 1.5, result: 'WIN' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 150, { stake: 100, legs });
},
},
{
id: 'OUT001',
suite: 'settlement',
@@ -431,17 +461,6 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
},
// —— Betting rules ——
{
id: 'B003',
suite: 'betting',
name: '赔率版本不一致应拒绝',
uatRef: 'B003',
run: () => {
const submitted = BigInt(1);
const current = BigInt(2);
expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' });
},
},
{
id: 'B006',
suite: 'betting',
@@ -760,7 +779,10 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
];
export const SMOKE_SUITE_META: Record<string, { name: string; description: string }> = {
settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' },
settlement: {
name: '结算引擎',
description: '独赢、波胆、让球、大小、串关、冠军盘、作废腿续算',
},
settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' },
betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' },
betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' },
@@ -771,4 +793,8 @@ export const SMOKE_SUITE_META: Record<string, { name: string; description: strin
name: '下注结算链路',
description: '真实 DB下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
},
config: {
name: '系统配置',
description: '真实 DBSystemConfig 接线后 AgentsService 行为(临时数据自动清理)',
},
};

View File

@@ -0,0 +1,197 @@
import { Decimal } from '@prisma/client/runtime/library';
import {
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
} from '../../../shared/config/system-config.service';
import type { AgentsService } from '../../agent/agents.service';
import type { PrismaService } from '../../../shared/prisma/prisma.service';
import { expectEqual, expectTrue } from './smoke-test.helpers';
import type { SmokeTestCaseDef } from './smoke-test.cases';
export const CONFIG_PROBE_COUNT = 2;
export type ConfigProbeDeps = {
prisma: PrismaService;
agents: AgentsService;
};
async function upsertBooleanConfig(
prisma: PrismaService,
key: string,
value: boolean,
snapshots: Array<{ key: string; value: string | null }>,
) {
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
snapshots.push({ key, value: row?.configValue ?? null });
await prisma.systemConfig.upsert({
where: { configKey: key },
create: {
configKey: key,
configValue: value ? 'true' : 'false',
description: 'smoke config probe',
},
update: { configValue: value ? 'true' : 'false' },
});
}
async function restoreConfigs(
prisma: PrismaService,
snapshots: Array<{ key: string; value: string | null }>,
) {
for (const snap of snapshots) {
if (snap.value === null) {
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
} else {
await prisma.systemConfig.upsert({
where: { configKey: snap.key },
create: {
configKey: snap.key,
configValue: snap.value,
description: 'smoke config restore',
},
update: { configValue: snap.value },
});
}
}
}
export function createConfigProbes(deps: ConfigProbeDeps): SmokeTestCaseDef[] {
return [
{
id: 'CFG001',
suite: 'config',
name: '代理停用默认:全局 freeze 级联冻结直属玩家',
description: 'AgentsService.updateAgentAdmin 未传 flag 时读取 agent.suspend_freeze_direct_players',
run: async () => {
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const snapshots: Array<{ key: string; value: string | null }> = [];
let agentId: bigint | undefined;
let playerId: bigint | undefined;
try {
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
true,
snapshots,
);
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
false,
snapshots,
);
const agent = await deps.prisma.user.create({
data: {
username: `smoke_cfg_ag_${runId}`,
userType: 'AGENT',
status: 'ACTIVE',
auth: { create: { passwordHash: 'smoke' } },
agentProfile: {
create: { level: 1, creditLimit: new Decimal(10000) },
},
},
});
agentId = agent.id;
await deps.prisma.agentClosure.create({
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
});
const player = await deps.prisma.user.create({
data: {
username: `smoke_cfg_pl_${runId}`,
userType: 'PLAYER',
status: 'ACTIVE',
parentId: agent.id,
auth: { create: { passwordHash: 'smoke' } },
},
});
playerId = player.id;
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
const frozen = await deps.prisma.user.findUnique({ where: { id: player.id } });
expectEqual('player status after suspend', frozen?.status, 'SUSPENDED');
} finally {
if (playerId) {
await deps.prisma.userAuth.deleteMany({ where: { userId: playerId } });
await deps.prisma.user.deleteMany({ where: { id: playerId } });
}
if (agentId) {
await deps.prisma.agentClosure.deleteMany({
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
});
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
await deps.prisma.user.deleteMany({ where: { id: agentId } });
}
await restoreConfigs(deps.prisma, snapshots);
}
},
},
{
id: 'CFG002',
suite: 'config',
name: '代理停用默认:全局 block 禁止直属玩家登录',
description: 'AgentsService.updateAgentAdmin 未传 flag 时写入 blockDirectPlayerLogin',
run: async () => {
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const snapshots: Array<{ key: string; value: string | null }> = [];
let agentId: bigint | undefined;
try {
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
false,
snapshots,
);
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
true,
snapshots,
);
const agent = await deps.prisma.user.create({
data: {
username: `smoke_cfg2_ag_${runId}`,
userType: 'AGENT',
status: 'ACTIVE',
auth: { create: { passwordHash: 'smoke' } },
agentProfile: {
create: { level: 1, creditLimit: new Decimal(10000) },
},
},
});
agentId = agent.id;
await deps.prisma.agentClosure.create({
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
});
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
const profile = await deps.prisma.agentProfile.findUnique({
where: { userId: agent.id },
});
expectTrue(
'blockDirectPlayerLogin persisted',
profile?.blockDirectPlayerLogin === true,
{ value: profile?.blockDirectPlayerLogin },
);
} finally {
if (agentId) {
await deps.prisma.agentClosure.deleteMany({
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
});
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
await deps.prisma.user.deleteMany({ where: { id: agentId } });
}
await restoreConfigs(deps.prisma, snapshots);
}
},
},
];
}

View File

@@ -7,6 +7,7 @@ import { SettlementService } from '../../settlement/settlement.service';
import { WalletService } from '../../ledger/wallet.service';
import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases';
import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes';
import { CONFIG_PROBE_COUNT, createConfigProbes } from './smoke-test.config-probes';
import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes';
import {
beginSmokeSteps,
@@ -49,6 +50,7 @@ export class SmokeTestService {
}
counts.set('database', DATABASE_PROBE_COUNT);
counts.set('bet-flow', BET_FLOW_PROBE_COUNT);
counts.set('config', CONFIG_PROBE_COUNT);
return [...counts.entries()].map(([id, caseCount]) => ({
id,
@@ -77,6 +79,7 @@ export class SmokeTestService {
const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite));
const runDb = !allow || allow.has('database');
const runBetFlow = !allow || allow.has('bet-flow');
const runConfig = !allow || allow.has('config');
const results: SmokeTestCaseResult[] = [];
@@ -102,6 +105,15 @@ export class SmokeTestService {
}
}
if (runConfig) {
for (const probe of createConfigProbes({
prisma: this.prisma,
agents: this.agents,
})) {
results.push(await this.executeCase(probe));
}
}
const finished = Date.now();
const passed = results.filter((r) => r.status === 'PASS').length;
const failed = results.filter((r) => r.status === 'FAIL').length;