feat(admin+api): 代理停用默认、结算加固与冒烟配置探针
- 代理层级默认授信比例与停用冻结/禁登全局默认 - 结算预览去重、比分校验、串关当场判负与市场类型校验 - 站内信 Banner/公告自动通知开关;开发环境动态 API 端口 - 扩充 RBAC/结算/认证/返现单元测试与 agent skills
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 变为 CANCELLED,confirm 旧 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',
|
||||
|
||||
@@ -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: '真实 DB:SystemConfig 接线后 AgentsService 行为(临时数据自动清理)',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user