init
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
||||
import { Invoice } from '../entities/Invoice';
|
||||
import { InvoicePayment } from '../entities/InvoicePayment';
|
||||
import { PaymentMethod } from '../types/PaymentMethod';
|
||||
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
|
||||
import { InvoicePaymentService } from './InvoicePaymentService';
|
||||
|
||||
const minIncomingAtomic = '100000000';
|
||||
|
||||
const buildTransfer = (
|
||||
overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
|
||||
): MoneroWalletRpcIncomingTransfer => ({
|
||||
txHash: 'tx-hash-1',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 1,
|
||||
subaddrIndex: 3,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
||||
({
|
||||
id: 'invoice-1',
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 },
|
||||
payments: [],
|
||||
...overrides
|
||||
}) as Invoice;
|
||||
|
||||
describe('InvoicePaymentService', () => {
|
||||
let service: InvoicePaymentServiceTest;
|
||||
let invoiceRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let pollQueryBuilder: {
|
||||
innerJoinAndSelect: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
getMany: jest.Mock;
|
||||
};
|
||||
let walletRpcClient: {
|
||||
getIncomingTransfers: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let transactionalInvoiceRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let transactionalInvoiceQueryBuilder: {
|
||||
leftJoinAndSelect: jest.Mock;
|
||||
where: jest.Mock;
|
||||
setLock: jest.Mock;
|
||||
getOne: jest.Mock;
|
||||
};
|
||||
let paymentRepo: {
|
||||
update: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let insertQueryBuilder: {
|
||||
insert: jest.Mock;
|
||||
values: jest.Mock;
|
||||
orIgnore: jest.Mock;
|
||||
execute: jest.Mock;
|
||||
};
|
||||
let manager: EntityManager;
|
||||
let processInvoiceSpy: jest.SpyInstance;
|
||||
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
pollQueryBuilder = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
invoiceRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(pollQueryBuilder)
|
||||
};
|
||||
|
||||
transactionalInvoiceQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn()
|
||||
};
|
||||
|
||||
transactionalInvoiceRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(transactionalInvoiceQueryBuilder)
|
||||
};
|
||||
|
||||
insertQueryBuilder = {
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
orIgnore: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
paymentRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
|
||||
};
|
||||
|
||||
manager = {
|
||||
getRepository: jest.fn((entity: { name: string }) => {
|
||||
if (entity.name === Invoice.name) {
|
||||
return transactionalInvoiceRepo;
|
||||
}
|
||||
|
||||
if (entity.name === InvoicePayment.name) {
|
||||
return paymentRepo;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected repository: ${entity.name}`);
|
||||
})
|
||||
} as unknown as EntityManager;
|
||||
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (callback: (entityManager: EntityManager) => Promise<void>) =>
|
||||
callback(manager)
|
||||
)
|
||||
};
|
||||
|
||||
walletRpcClient = {
|
||||
getIncomingTransfers: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
minByMethod: {
|
||||
[PaymentMethod.Xmr]: minIncomingAtomic
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
service = new InvoicePaymentService(
|
||||
invoiceRepo as unknown as Repository<Invoice>,
|
||||
dataSource as unknown as DataSource,
|
||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
||||
configService as unknown as ConfigService
|
||||
) as unknown as InvoicePaymentServiceTest;
|
||||
|
||||
processInvoiceSpy = jest.spyOn(service, 'processInvoice').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
processInvoiceSpy.mockRestore();
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('pollInvoices', () => {
|
||||
it('returns early when there are no open invoices', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
|
||||
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns early when incoming transfers cannot be fetched', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]);
|
||||
walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
||||
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes transfers to each invoice by subaddress index', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([
|
||||
buildInvoice({
|
||||
id: 'invoice-1',
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
}),
|
||||
buildInvoice({
|
||||
id: 'invoice-2',
|
||||
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
})
|
||||
]);
|
||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([
|
||||
buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
|
||||
buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
|
||||
]);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [
|
||||
expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 })
|
||||
]);
|
||||
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [
|
||||
expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('continues processing other invoices when one invoice fails', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([
|
||||
buildInvoice({ id: 'invoice-1' }),
|
||||
buildInvoice({ id: 'invoice-2' })
|
||||
]);
|
||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
|
||||
processInvoiceSpy.mockRestore();
|
||||
processInvoiceSpy = jest
|
||||
.spyOn(service, 'processInvoice')
|
||||
.mockRejectedValueOnce(new Error('invoice-1 failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('deduplicates subaddress indices when fetching incoming transfers', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([
|
||||
buildInvoice({
|
||||
id: 'invoice-1',
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
}),
|
||||
buildInvoice({
|
||||
id: 'invoice-2',
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
})
|
||||
]);
|
||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
||||
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('processInvoice', () => {
|
||||
beforeEach(() => {
|
||||
processInvoiceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when the invoice is missing inside the transaction', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null);
|
||||
|
||||
await service.processInvoice('invoice-1', [buildTransfer()]);
|
||||
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips transfers below the configured minimum', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
||||
|
||||
await service.processInvoice('invoice-1', [
|
||||
buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' })
|
||||
]);
|
||||
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
it('inserts a payment when the transfer amount equals the configured minimum', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
||||
|
||||
await service.processInvoice('invoice-1', [
|
||||
buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 })
|
||||
]);
|
||||
|
||||
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||
invoice: { id: 'invoice-1' },
|
||||
txHash: 'min-tx',
|
||||
amountAtomic: minIncomingAtomic,
|
||||
confirmations: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('processes a mixed batch of dust, new, and existing transfers', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||
buildInvoice({
|
||||
payments: [
|
||||
{
|
||||
id: 'payment-1',
|
||||
txHash: 'known-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 1
|
||||
}
|
||||
] as InvoicePayment[]
|
||||
})
|
||||
);
|
||||
|
||||
await service.processInvoice('invoice-1', [
|
||||
buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }),
|
||||
buildTransfer({ txHash: 'known-tx', confirmations: 4 }),
|
||||
buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
|
||||
]);
|
||||
|
||||
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 });
|
||||
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||
invoice: { id: 'invoice-1' },
|
||||
txHash: 'new-tx',
|
||||
amountAtomic: '300000000',
|
||||
confirmations: 2
|
||||
});
|
||||
expect(insertQueryBuilder.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
it('inserts a new payment for transfers at or above the minimum', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
||||
const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
|
||||
|
||||
await service.processInvoice('invoice-1', [transfer]);
|
||||
|
||||
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||
invoice: { id: 'invoice-1' },
|
||||
txHash: 'new-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 2
|
||||
});
|
||||
expect(insertQueryBuilder.orIgnore).toHaveBeenCalled();
|
||||
expect(insertQueryBuilder.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates confirmations for an existing payment when they change', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||
buildInvoice({
|
||||
payments: [
|
||||
{
|
||||
id: 'payment-1',
|
||||
txHash: 'known-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 1
|
||||
}
|
||||
] as InvoicePayment[]
|
||||
})
|
||||
);
|
||||
|
||||
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]);
|
||||
|
||||
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not update an existing payment when confirmations are unchanged', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||
buildInvoice({
|
||||
payments: [
|
||||
{
|
||||
id: 'payment-1',
|
||||
txHash: 'known-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 3
|
||||
}
|
||||
] as InvoicePayment[]
|
||||
})
|
||||
);
|
||||
|
||||
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]);
|
||||
|
||||
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user