369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|