- 代理层级默认授信比例与停用冻结/禁登全局默认 - 结算预览去重、比分校验、串关当场判负与市场类型校验 - 站内信 Banner/公告自动通知开关;开发环境动态 API 端口 - 扩充 RBAC/结算/认证/返现单元测试与 agent skills
300 lines
8.7 KiB
TypeScript
300 lines
8.7 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,
|
|
banner: true,
|
|
announcement: 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'));
|
|
});
|
|
});
|