fix: make SMS retry side effects idempotent

This commit is contained in:
hectorzhao
2026-07-26 22:31:30 +08:00
parent 04f78695ed
commit e0f6eed0d4
11 changed files with 516 additions and 105 deletions
+44 -2
View File
@@ -18,7 +18,8 @@ function createPrismaMock() {
return Promise.resolve({ count: 1 });
}),
update: jest.fn().mockImplementation(({ data }) => {
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
else if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
return Promise.resolve({ ...accountState });
}),
@@ -26,6 +27,7 @@ function createPrismaMock() {
accountTransaction: {
findMany: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
},
rechargeOrder: {
@@ -125,7 +127,7 @@ describe('BillingService', () => {
expect(order).toEqual(expect.objectContaining({ amountCents: 500, status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 1500 },
data: { balanceCents: { increment: 500 } },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -302,6 +304,46 @@ describe('BillingService', () => {
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
});
it('serializes and replays concurrent refunds with one balance mutation', async () => {
const prisma = createPrismaMock();
let transactionChain = Promise.resolve<unknown>(undefined);
let persistedTransaction: Record<string, unknown> | null = null;
prisma.$transaction.mockImplementation((callback) => {
const run = transactionChain.then(() => callback(prisma));
transactionChain = run.then(() => undefined, () => undefined);
return run;
});
prisma.accountTransaction.findUnique.mockImplementation(() => Promise.resolve(persistedTransaction));
prisma.accountTransaction.create.mockImplementation(({ data }) => {
persistedTransaction = { id: 'tx-refund-once', ...data };
return Promise.resolve(persistedTransaction);
});
const service = new BillingService(prisma as never);
const refund = {
tenantId: 'tenant-1',
amountCents: 1053,
idempotencyKey: 'sms-refund:MSG-LONG-RACE',
relatedType: 'sms_message_record',
relatedId: 'MSG-LONG-RACE',
remark: '最终失败退款',
};
const results = await Promise.all([
service.refund(refund),
service.refund(refund),
service.refund(refund),
]);
expect(results.map((result) => result.id)).toEqual([
'tx-refund-once',
'tx-refund-once',
'tx-refund-once',
]);
expect(prisma.tenantAccount.update).toHaveBeenCalledTimes(1);
expect(prisma.accountTransaction.create).toHaveBeenCalledTimes(1);
expect(prisma.accountState.balanceCents).toBe(2053);
});
it('creates SMS billing records linked to message and task identifiers', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);