feat(admin+api): 代理停用默认、结算加固与冒烟配置探针
- 代理层级默认授信比例与停用冻结/禁登全局默认 - 结算预览去重、比分校验、串关当场判负与市场类型校验 - 站内信 Banner/公告自动通知开关;开发环境动态 API 端口 - 扩充 RBAC/结算/认证/返现单元测试与 agent skills
This commit is contained in:
@@ -85,6 +85,28 @@ describe('Admin RBAC (SEC010–SEC012)', () => {
|
||||
expect(guardAllows(user, 'settlement.resettle')).toBe(false);
|
||||
});
|
||||
|
||||
it('FINANCE_ADMIN has deposit and cashback permissions but not reset database', () => {
|
||||
const user = userWithRole('FINANCE_ADMIN');
|
||||
expect(guardAllows(user, 'deposit.manage')).toBe(true);
|
||||
expect(guardAllows(user, 'deposit.review')).toBe(true);
|
||||
expect(guardAllows(user, 'cashback.confirm')).toBe(true);
|
||||
expect(guardAllows(user, 'settings.reset_database')).toBe(false);
|
||||
});
|
||||
|
||||
it('MATCH_ADMIN cannot manage deposits or confirm cashback', () => {
|
||||
const user = userWithRole('MATCH_ADMIN');
|
||||
expect(guardAllows(user, 'deposit.manage')).toBe(false);
|
||||
expect(guardAllows(user, 'deposit.review')).toBe(false);
|
||||
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
|
||||
});
|
||||
|
||||
it('SUPPORT cannot confirm cashback or manage deposits', () => {
|
||||
const user = userWithRole('SUPPORT');
|
||||
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
|
||||
expect(guardAllows(user, 'deposit.manage')).toBe(false);
|
||||
expect(guardAllows(user, 'deposit.review')).toBe(false);
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN bypasses permission checks', () => {
|
||||
const user = userWithRole('SUPER_ADMIN');
|
||||
expect(guardAllows(user, 'wallet.deposit')).toBe(true);
|
||||
|
||||
255
apps/api/src/applications/admin/admin.controller.spec.ts
Normal file
255
apps/api/src/applications/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AgentsService } from '../../domains/agent/agents.service';
|
||||
import { createPrismaMock } from '../../testing/prisma-mock';
|
||||
|
||||
function stubDeps() {
|
||||
return {
|
||||
users: {},
|
||||
agents: { updateAgentAdmin: jest.fn() } as AgentsService | { updateAgentAdmin: jest.Mock },
|
||||
wallet: {},
|
||||
matches: {},
|
||||
catalogArchive: {},
|
||||
outright: {},
|
||||
markets: {},
|
||||
settlement: {},
|
||||
cashback: {},
|
||||
content: { create: jest.fn() },
|
||||
i18n: {},
|
||||
audit: { log: jest.fn().mockResolvedValue(undefined) },
|
||||
bets: {},
|
||||
prisma: {},
|
||||
dashboardService: {},
|
||||
systemConfig: { getInboxNotifySettings: jest.fn() },
|
||||
bettingLimits: {},
|
||||
databaseReset: {},
|
||||
smokeTests: {},
|
||||
depositService: {},
|
||||
playerMessages: {
|
||||
broadcastBannerPromotion: jest.fn(),
|
||||
broadcastAnnouncementPromotion: jest.fn(),
|
||||
},
|
||||
staff: {},
|
||||
presence: {},
|
||||
depositCleanup: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildController(deps: ReturnType<typeof stubDeps>) {
|
||||
return new AdminController(
|
||||
deps.users as never,
|
||||
deps.agents as never,
|
||||
deps.wallet as never,
|
||||
deps.matches as never,
|
||||
deps.catalogArchive as never,
|
||||
deps.outright as never,
|
||||
deps.markets as never,
|
||||
deps.settlement as never,
|
||||
deps.cashback as never,
|
||||
deps.content as never,
|
||||
deps.i18n as never,
|
||||
deps.audit as never,
|
||||
deps.bets as never,
|
||||
deps.prisma as never,
|
||||
deps.dashboardService as never,
|
||||
deps.systemConfig as never,
|
||||
deps.bettingLimits as never,
|
||||
deps.databaseReset as never,
|
||||
deps.smokeTests as never,
|
||||
deps.depositService as never,
|
||||
deps.playerMessages as never,
|
||||
deps.staff as never,
|
||||
deps.presence as never,
|
||||
deps.depositCleanup as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AdminController createContent inbox notify gating', () => {
|
||||
const deps = stubDeps();
|
||||
const controller = buildController(deps);
|
||||
|
||||
const baseDto = {
|
||||
contentType: 'BANNER' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
notifyInbox: true,
|
||||
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
deps.content.create.mockResolvedValue({ id: '88' });
|
||||
deps.playerMessages.broadcastBannerPromotion.mockResolvedValue(3);
|
||||
deps.playerMessages.broadcastAnnouncementPromotion.mockResolvedValue(5);
|
||||
});
|
||||
|
||||
it('does not broadcast banner when inbox.notify.banner is false', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
banner: false,
|
||||
announcement: true,
|
||||
});
|
||||
|
||||
const res = await controller.createContent(baseDto);
|
||||
|
||||
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
|
||||
expect((res.data as { notifiedCount?: number }).notifiedCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('broadcasts banner when inbox.notify.banner is true', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
banner: true,
|
||||
announcement: false,
|
||||
});
|
||||
|
||||
const res = await controller.createContent(baseDto);
|
||||
|
||||
expect(deps.playerMessages.broadcastBannerPromotion).toHaveBeenCalledWith({
|
||||
contentId: 88n,
|
||||
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
|
||||
});
|
||||
expect((res.data as { notifiedCount?: number }).notifiedCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does not broadcast announcement when inbox.notify.announcement is false', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
banner: true,
|
||||
announcement: false,
|
||||
});
|
||||
|
||||
await controller.createContent({
|
||||
...baseDto,
|
||||
contentType: 'NOTICE',
|
||||
});
|
||||
|
||||
expect(deps.playerMessages.broadcastAnnouncementPromotion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips notify when inbox feature is disabled even if notifyInbox is checked', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: false,
|
||||
deposit: true,
|
||||
banner: true,
|
||||
announcement: true,
|
||||
});
|
||||
|
||||
await controller.createContent(baseDto);
|
||||
|
||||
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController updateAgent suspend wiring', () => {
|
||||
const agentId = 10n;
|
||||
const operatorId = 1n;
|
||||
|
||||
const tx = {
|
||||
user: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
userAuth: { create: jest.fn() },
|
||||
userPreference: { create: jest.fn() },
|
||||
userInvite: { create: jest.fn(), findUnique: jest.fn() },
|
||||
agentProfile: { create: jest.fn(), findUnique: jest.fn() },
|
||||
agentClosure: { create: jest.fn(), findMany: jest.fn() },
|
||||
};
|
||||
|
||||
const prismaBase = {
|
||||
...tx,
|
||||
agentProfile: { ...tx.agentProfile, update: jest.fn() },
|
||||
user: { ...tx.user, updateMany: jest.fn() },
|
||||
};
|
||||
const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock };
|
||||
|
||||
const systemConfig = {
|
||||
getAgentHierarchySettings: jest.fn(),
|
||||
getAgentSuspendSettings: jest.fn(),
|
||||
};
|
||||
const audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
let agentsService: AgentsService;
|
||||
let controller: AdminController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
prisma.$transaction.mockImplementation(async (arg: unknown) => {
|
||||
if (Array.isArray(arg)) return Promise.all(arg);
|
||||
return (arg as (client: typeof tx) => Promise<unknown>)(tx);
|
||||
});
|
||||
|
||||
systemConfig.getAgentSuspendSettings.mockResolvedValue({
|
||||
suspendFreezeDirectPlayers: true,
|
||||
suspendBlockPlayerLogin: true,
|
||||
});
|
||||
|
||||
agentsService = new AgentsService(
|
||||
prisma as never,
|
||||
{ hashPassword: jest.fn() } as never,
|
||||
systemConfig as never,
|
||||
{} as never,
|
||||
{ recalculateUsedCredit: jest.fn() } as never,
|
||||
);
|
||||
jest.spyOn(agentsService, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never);
|
||||
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: agentId,
|
||||
user: { username: 'agent-a', locale: 'zh-CN' },
|
||||
parentAgentId: null,
|
||||
});
|
||||
prisma.user.updateMany.mockResolvedValue({ count: 2 });
|
||||
prisma.agentProfile.update.mockResolvedValue({});
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
const deps = stubDeps();
|
||||
deps.agents = agentsService as never;
|
||||
deps.audit = audit;
|
||||
controller = buildController(deps);
|
||||
});
|
||||
|
||||
it('forwards bare SUSPENDED body to AgentsService and applies global defaults', async () => {
|
||||
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
|
||||
|
||||
expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ parentId: agentId, userType: 'PLAYER' }),
|
||||
data: { status: 'SUSPENDED' },
|
||||
}),
|
||||
);
|
||||
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ blockDirectPlayerLogin: true }),
|
||||
}),
|
||||
);
|
||||
expect(audit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operatorId,
|
||||
action: 'UPDATE_AGENT',
|
||||
module: 'AGENTS',
|
||||
targetId: agentId.toString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not freeze players when global default is false and body omits flag', async () => {
|
||||
systemConfig.getAgentSuspendSettings.mockResolvedValue({
|
||||
suspendFreezeDirectPlayers: false,
|
||||
suspendBlockPlayerLogin: false,
|
||||
});
|
||||
|
||||
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
|
||||
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ blockDirectPlayerLogin: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1110,6 +1110,14 @@ class InboxNotifySettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
deposit?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
banner?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
announcement?: boolean;
|
||||
}
|
||||
|
||||
class BroadcastTranslationDto {
|
||||
@@ -3444,21 +3452,21 @@ export class AdminController {
|
||||
const item = await this.content.create(createDto);
|
||||
let notifiedCount: number | undefined;
|
||||
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
|
||||
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
|
||||
if (inboxEnabled) {
|
||||
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||||
if (inboxNotify.inboxEnabled) {
|
||||
const translations = createDto.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title,
|
||||
body: tr.body,
|
||||
}));
|
||||
if (createDto.contentType === 'BANNER') {
|
||||
if (createDto.contentType === 'BANNER' && inboxNotify.banner) {
|
||||
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
|
||||
contentId: BigInt(item.id),
|
||||
translations,
|
||||
});
|
||||
} else if (
|
||||
createDto.contentType === 'NOTICE' ||
|
||||
createDto.contentType === 'TICKER'
|
||||
(createDto.contentType === 'NOTICE' || createDto.contentType === 'TICKER') &&
|
||||
inboxNotify.announcement
|
||||
) {
|
||||
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
|
||||
contentId: BigInt(item.id),
|
||||
|
||||
Reference in New Issue
Block a user