Files
thebet365/apps/api/src/domains/deposit/deposit.service.spec.ts
Mars f9343b00af feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强
API:
- 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板
- 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数
- User 表增加 visible_menus 字段;新增 player_messages 表及迁移
- 充值审核通过/拒绝时按系统配置自动写入玩家站内消息
- 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count
- 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置
- 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管)
- SystemConfig 增加 inbox 功能开关及各类通知开关

Admin:
- 员工管理:按角色默认菜单 + 可勾选可见菜单项
- ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询
- Contents:富文本编辑器、图片字段组件重构
- DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列
- 多页面 i18n 与权限细节调整

Player:
- 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除
- 公告列表与详情页;走马灯可跳转详情
- 客服 Modal 改为 Panel,与邮箱 Hub 整合
- 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认
- HomeView 今日赛事板块;FootballView 等体验优化

Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码
Docs: 玩家端缺失功能分析文档
Chore: 移除 .agents/skills 设计类 skill 文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:51:10 +08:00

298 lines
8.6 KiB
TypeScript

import { Decimal } from '@prisma/client/runtime/library';
import { DepositService } from './deposit.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('DepositService', () => {
const tx = {
$queryRaw: jest.fn(),
depositOrder: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
paymentMethod: {
findUnique: jest.fn(),
},
bet: {
findMany: jest.fn(),
},
user: {
findFirst: jest.fn(),
findUnique: jest.fn(),
},
agentProfile: {
findUnique: jest.fn(),
},
depositOrderAuditLog: {
create: jest.fn(),
},
};
const prisma = {
...tx,
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {
deposit: jest.fn(),
withdraw: jest.fn(),
};
const credit = {
recalculateUsedCredit: jest.fn(),
};
const playerMessages = {
createDepositApprovedMessage: jest.fn(),
createDepositRejectedMessage: jest.fn(),
};
const systemConfig = {
getInboxNotifySettings: jest.fn().mockResolvedValue({
inboxEnabled: true,
deposit: true,
}),
};
let service: DepositService;
beforeEach(() => {
jest.clearAllMocks();
service = new DepositService(
prisma as never,
funds as never,
credit as never,
playerMessages as never,
systemConfig as never,
);
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
amount: new Decimal(100),
approvedAmount: new Decimal(100),
status: 'APPROVED',
reviewedAt: new Date(),
screenshotUrl: '/uploads/a.png',
});
tx.bet.findMany.mockResolvedValue([]);
tx.user.findFirst.mockResolvedValue(null);
tx.user.findUnique.mockResolvedValue({ locale: 'en-US' });
tx.agentProfile.findUnique.mockResolvedValue(null);
credit.recalculateUsedCredit.mockResolvedValue(undefined);
playerMessages.createDepositApprovedMessage.mockResolvedValue({});
playerMessages.createDepositRejectedMessage.mockResolvedValue({});
});
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
amount: new Decimal(100),
approvedAmount: null,
status: 'PENDING',
reviewedAt: null,
screenshotUrl: '/uploads/a.png',
});
tx.depositOrderAuditLog.create.mockResolvedValue({ id: 501n });
tx.user.findFirst.mockResolvedValue({ parentId: 3n });
tx.agentProfile.findUnique.mockResolvedValue({
creditLimit: new Decimal(1000),
usedCredit: new Decimal(100),
});
await service.approveDepositOrder(1n, 2n, 100, 'Bank receipt checked');
expect(tx.depositOrder.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'APPROVED',
approvedAmount: new Decimal(100),
}),
}),
);
expect(funds.deposit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7n,
amount: new Decimal(100),
operatorId: 2n,
remark: 'Bank receipt checked',
referenceId: 'DEP-1',
transactionType: 'PLAYER_DEPOSIT',
businessKey: 'deposit:DEP-1:approve:501',
tx,
}),
);
expect(credit.recalculateUsedCredit).toHaveBeenCalledTimes(2);
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(1, 3n, tx);
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(2, 3n, tx);
});
it('uses a fresh business key when re-approving after revoke', async () => {
const reviewedAt = new Date('2026-06-15T14:08:48.000Z');
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
amount: new Decimal(2),
approvedAmount: null,
status: 'PENDING',
reviewedAt: null,
screenshotUrl: '/uploads/a.png',
});
tx.depositOrderAuditLog.create.mockResolvedValueOnce({ id: 601n });
await service.approveDepositOrder(1n, 2n, 2, 'Second approval');
expect(funds.deposit).toHaveBeenCalledWith(
expect.objectContaining({
amount: new Decimal(2),
businessKey: 'deposit:DEP-1:approve:601',
}),
);
});
it('uses approval cycle key when revoking a funded deposit for re-review', async () => {
const reviewedAt = new Date();
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
amount: new Decimal(21),
approvedAmount: new Decimal(21),
status: 'APPROVED',
reviewedAt,
screenshotUrl: '/uploads/a.png',
});
tx.depositOrderAuditLog.create.mockResolvedValue({ id: 701n });
await service.reopenDepositOrderForReview(1n, 2n);
expect(funds.withdraw).toHaveBeenCalledWith(
expect.objectContaining({
amount: new Decimal(21),
businessKey: `deposit:DEP-1:reopen-reverse:${reviewedAt.getTime()}`,
}),
);
});
it('blocks approved deposit revoke when bets exist after approval', async () => {
tx.bet.findMany.mockResolvedValue([{ id: 99n }]);
await expect(
service.reopenDepositOrderForReview(1n, 2n),
).rejects.toMatchObject(expectAppError('DEPOSIT_REVOKE_SETTLED_BETS'));
expect(funds.withdraw).not.toHaveBeenCalled();
expect(tx.depositOrder.update).not.toHaveBeenCalled();
});
it('does not delete a funded deposit order', async () => {
await expect(
service.deleteDepositOrder(1n, 2n),
).rejects.toMatchObject(expectAppError('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN'));
expect(tx.depositOrder.delete).not.toHaveBeenCalled();
});
it('reapplies a rejected deposit on the same order for the player', async () => {
tx.depositOrder.findUnique
.mockResolvedValueOnce({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
paymentMethodId: 5n,
methodType: 'BANK',
amount: new Decimal(11),
approvedAmount: null,
status: 'REJECTED',
reviewedAt: new Date(),
rejectReason: 'Screenshot unclear',
remark: 'Screenshot unclear',
screenshotUrl: '/uploads/deposits/old.png',
})
.mockResolvedValueOnce({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
amount: new Decimal(11),
status: 'PENDING',
createdAt: new Date(),
});
tx.depositOrder.findFirst.mockResolvedValue(null);
tx.paymentMethod.findUnique.mockResolvedValue({
id: 5n,
methodType: 'BANK',
isActive: true,
});
const result = await service.reapplyDepositOrder(
7n,
1n,
'/uploads/deposits/new.png',
11,
);
expect(tx.depositOrder.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 1n },
data: expect.objectContaining({
status: 'PENDING',
screenshotUrl: '/uploads/deposits/new.png',
rejectReason: null,
remark: null,
reviewerId: null,
reviewedAt: null,
}),
}),
);
expect(tx.depositOrderAuditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'REOPENED',
actorId: 7n,
actorType: 'PLAYER',
statusBefore: 'REJECTED',
statusAfter: 'PENDING',
}),
}),
);
expect(result?.orderNo).toBe('DEP-1');
});
it('blocks reapply when player already has another pending deposit', async () => {
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
paymentMethodId: 5n,
methodType: 'BANK',
amount: new Decimal(11),
status: 'REJECTED',
screenshotUrl: '/uploads/deposits/old.png',
});
tx.depositOrder.findFirst.mockResolvedValue({ id: 99n });
await expect(
service.reapplyDepositOrder(7n, 1n, '/uploads/deposits/new.png'),
).rejects.toMatchObject(expectAppError('DEPOSIT_PENDING_ORDER_EXISTS'));
expect(tx.depositOrder.update).not.toHaveBeenCalled();
});
it('blocks reapply for non-rejected deposit orders', async () => {
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
paymentMethodId: 5n,
methodType: 'BANK',
amount: new Decimal(11),
status: 'PENDING',
screenshotUrl: '/uploads/deposits/old.png',
});
await expect(
service.reapplyDepositOrder(7n, 1n, '/uploads/deposits/new.png'),
).rejects.toMatchObject(expectAppError('ORDER_NOT_REJECTED'));
});
});