diff --git a/.changeset/payment-processor-unit-tests.md b/.changeset/payment-processor-unit-tests.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/payment-processor-unit-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/test/unit/payments-processors/lnurl-payments-processor.spec.ts b/test/unit/payments-processors/lnurl-payments-processor.spec.ts new file mode 100644 index 000000000..389e278d3 --- /dev/null +++ b/test/unit/payments-processors/lnurl-payments-processor.spec.ts @@ -0,0 +1,123 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus, InvoiceUnit, LnurlInvoice } from '../../../src/@types/invoice' +import { LnurlPaymentsProcessor } from '../../../src/payments-processors/lnurl-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'd'.repeat(64) + +describe('LnurlPaymentsProcessor', () => { + const settings = () => + ({ paymentsProcessors: { lnurl: { invoiceURL: 'https://pay.example.com/lnurlp/relay' } } }) as any + + const makeProcessor = (httpClient: any, currentSettings: () => any = settings) => + new LnurlPaymentsProcessor(httpClient, currentSettings) + + describe('createInvoice', () => { + const callbackResponse = { + data: { pr: 'lnbc210n1lnurl', verify: 'https://pay.example.com/verify/abc' }, + } + + it('asks the callback of the configured pay url for an invoice of the amount in msats', async () => { + const httpClient = { get: sinon.stub().resolves(callbackResponse) } + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission', requestId: PUBKEY }) + + expect(httpClient.get).to.have.been.calledOnceWithExactly( + 'https://pay.example.com/lnurlp/relay/callback?amount=21000&comment=Admission', + ) + }) + + it('maps the callback response to a pending invoice in msats with its verify url', async () => { + const httpClient = { get: sinon.stub().resolves(callbackResponse) } + const before = Date.now() + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission', + requestId: PUBKEY, + }) + + expect(invoice.id).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + expect(invoice.pubkey).to.equal(PUBKEY) + expect(invoice.bolt11).to.equal('lnbc210n1lnurl') + expect(invoice.amountRequested).to.equal(21000n) + expect(invoice.description).to.equal('Admission') + expect(invoice.unit).to.equal(InvoiceUnit.MSATS) + expect(invoice.status).to.equal(InvoiceStatus.PENDING) + expect(invoice.verifyURL).to.equal('https://pay.example.com/verify/abc') + expect(invoice.expiresAt).to.equal(null) + expect(invoice.confirmedAt).to.equal(null) + expect(invoice.createdAt.getTime()).to.be.within(before, Date.now()) + }) + + it('gives every invoice its own id, since the provider does not', async () => { + const httpClient = { get: sinon.stub().resolves(callbackResponse) } + const processor = makeProcessor(httpClient) + + const first = await processor.createInvoice({ amount: 1000n, description: 'd', requestId: PUBKEY }) + const second = await processor.createInvoice({ amount: 1000n, description: 'd', requestId: PUBKEY }) + + expect(first.id).to.not.equal(second.id) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('503 service unavailable')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('503 service unavailable') + }) + }) + + describe('getInvoice', () => { + const lnurlInvoice = { id: 'invoice-id', verifyURL: 'https://pay.example.com/verify/abc' } as LnurlInvoice + + it('asks the verify url of the invoice whether it was paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { settled: false } }) } + + await makeProcessor(httpClient).getInvoice(lnurlInvoice) + + expect(httpClient.get).to.have.been.calledOnceWithExactly('https://pay.example.com/verify/abc') + }) + + it('reports a settled invoice as completed, confirmed now', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { settled: true } }) } + const before = Date.now() + + const result = await makeProcessor(httpClient).getInvoice(lnurlInvoice) + + expect(result.id).to.equal('invoice-id') + expect(result.status).to.equal(InvoiceStatus.COMPLETED) + expect(result.confirmedAt!.getTime()).to.be.within(before, Date.now()) + }) + + it('reports an unsettled invoice as pending and unconfirmed', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { settled: false } }) } + + const result = await makeProcessor(httpClient).getInvoice(lnurlInvoice) + + expect(result.status).to.equal(InvoiceStatus.PENDING) + expect(result.confirmedAt).to.equal(undefined) + }) + + it('does not report an invoice as paid when the answer has no settled field', async () => { + const httpClient = { get: sinon.stub().resolves({ data: {} }) } + + expect((await makeProcessor(httpClient).getInvoice(lnurlInvoice)).status).to.equal(InvoiceStatus.PENDING) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice(lnurlInvoice)).to.be.rejectedWith('ETIMEDOUT') + }) + }) +}) diff --git a/test/unit/payments-processors/nodeless-payments-processor.spec.ts b/test/unit/payments-processors/nodeless-payments-processor.spec.ts new file mode 100644 index 000000000..560d72732 --- /dev/null +++ b/test/unit/payments-processors/nodeless-payments-processor.spec.ts @@ -0,0 +1,158 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus } from '../../../src/@types/invoice' +import { NodelessPaymentsProcessor } from '../../../src/payments-processors/nodeless-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'c'.repeat(64) + +const invoice = (overrides: any = {}) => ({ + id: 'nodeless-invoice-id', + status: 'new', + satsAmount: 21, + lightningInvoice: 'lnbc210n1nodeless', + metadata: { requestId: PUBKEY, description: 'Admission fee', unit: 'sats' }, + createdAt: '2029-12-31T23:50:00.000Z', + expiresAt: '2030-01-01T00:00:00.000Z', + paidAt: null, + ...overrides, +}) + +describe('NodelessPaymentsProcessor', () => { + const settings = () => ({ paymentsProcessors: { nodeless: { storeId: 'store-123' } } }) as any + + const makeProcessor = (httpClient: any) => new NodelessPaymentsProcessor(httpClient, settings) + + describe('createInvoice', () => { + it('posts the invoice to the store in sats, with the pubkey and description as metadata', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: invoice() }, headers: {} }) } + const before = Date.now() + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission fee', requestId: PUBKEY }) + + expect(httpClient.post).to.have.been.calledOnce + const [url, body, options] = httpClient.post.firstCall.args + expect(url).to.equal('/api/v1/store/store-123/invoice') + expect(options).to.deep.equal({ maxRedirects: 1 }) + expect(body).to.include({ amount: 21, currency: 'SATS' }) + expect(body.metadata).to.include({ description: 'Admission fee', requestId: PUBKEY, unit: 'sats' }) + expect(new Date(body.metadata.createdAt).getTime()).to.be.within(before, Date.now()) + }) + + it('converts msats to whole sats, dropping any remainder', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: invoice() }, headers: {} }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21999n, description: 'd', requestId: PUBKEY }) + + expect(httpClient.post.firstCall.args[1].amount).to.equal(21) + }) + + it('maps the response to an invoice in sats', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: invoice() }, headers: {} }) } + + const result = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission fee', + requestId: PUBKEY, + }) + + expect(result.id).to.equal('nodeless-invoice-id') + expect(result.pubkey).to.equal(PUBKEY) + expect(result.bolt11).to.equal('lnbc210n1nodeless') + expect(result.amountRequested).to.equal(21n) + expect(result.description).to.equal('Admission fee') + expect(result.unit).to.equal('sats') + expect(result.status).to.equal(InvoiceStatus.PENDING) + expect(result.expiresAt).to.deep.equal(new Date('2030-01-01T00:00:00.000Z')) + expect(result.createdAt).to.deep.equal(new Date('2029-12-31T23:50:00.000Z')) + expect(result.confirmedAt).to.equal(null) + }) + + it('expires an invoice fifteen minutes after it was created when the provider gives no expiry', async () => { + const httpClient = { + post: sinon.stub().resolves({ data: { data: invoice({ expiresAt: undefined }) }, headers: {} }), + } + + const result = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'd', + requestId: PUBKEY, + }) + + expect(result.expiresAt).to.deep.equal(new Date('2030-01-01T00:05:00.000Z')) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { post: sinon.stub().rejects(new Error('503 service unavailable')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('503 service unavailable') + }) + + it('fails when the provider answers without invoice data', async () => { + const httpClient = { post: sinon.stub().resolves({ data: {}, headers: {} }) } + + await expect(makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY })).to + .be.rejected + }) + }) + + describe('getInvoice', () => { + it('reads the invoice from the configured store', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: invoice() } }) } + + await makeProcessor(httpClient).getInvoice('nodeless-invoice-id') + + expect(httpClient.get).to.have.been.calledOnceWithExactly('/api/v1/store/store-123/invoice/nodeless-invoice-id', { + maxRedirects: 1, + }) + }) + + const statuses: Array<[string, InvoiceStatus]> = [ + ['new', InvoiceStatus.PENDING], + ['pending_confirmation', InvoiceStatus.PENDING], + ['underpaid', InvoiceStatus.PENDING], + ['in_flight', InvoiceStatus.PENDING], + ['paid', InvoiceStatus.COMPLETED], + ['overpaid', InvoiceStatus.COMPLETED], + ['expired', InvoiceStatus.EXPIRED], + ] + for (const [providerStatus, status] of statuses) { + it(`reports a ${providerStatus} invoice as ${status}`, async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: invoice({ status: providerStatus }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).status).to.equal(status) + }) + } + + it('treats a status it does not know as undefined rather than as paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: invoice({ status: 'something-new' }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).status).to.equal(undefined) + }) + + it('reports when a paid invoice was confirmed', async () => { + const httpClient = { + get: sinon.stub().resolves({ data: { data: invoice({ status: 'paid', paidAt: '2030-01-01T00:05:00.000Z' }) } }), + } + + expect((await makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).confirmedAt).to.deep.equal( + new Date('2030-01-01T00:05:00.000Z'), + ) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).to.be.rejectedWith('ETIMEDOUT') + }) + }) +}) diff --git a/test/unit/payments-processors/opennode-payments-processor.spec.ts b/test/unit/payments-processors/opennode-payments-processor.spec.ts new file mode 100644 index 000000000..fd92ceb2d --- /dev/null +++ b/test/unit/payments-processors/opennode-payments-processor.spec.ts @@ -0,0 +1,182 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { OpenNodePaymentsProcessor } from '../../../src/payments-processors/opennode-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'b'.repeat(64) +const EXPIRES_AT_SECONDS = 1893456000 // 2030-01-01T00:00:00Z + +const charge = (overrides: any = {}) => ({ + id: 'opennode-charge-id', + order_id: PUBKEY, + amount: 21, + description: 'Admission fee', + status: 'unpaid', + lightning_invoice: { payreq: 'lnbc210n1opennode', expires_at: EXPIRES_AT_SECONDS }, + created_at: 1893455000, + ...overrides, +}) + +describe('OpenNodePaymentsProcessor', () => { + const settings = () => + ({ paymentsProcessors: { opennode: { callbackBaseURL: 'https://relay.example.com/callbacks/opennode' } } }) as any + + const makeProcessor = (httpClient: any, currentSettings: () => any = settings) => + new OpenNodePaymentsProcessor(httpClient, currentSettings) + + describe('createInvoice', () => { + it('posts the charge in sats with the pubkey as order id, the callback url and a ten minute ttl', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission fee', requestId: PUBKEY }) + + expect(httpClient.post).to.have.been.calledOnceWithExactly( + '/v1/charges', + { + amount: 21, + description: 'Admission fee', + order_id: PUBKEY, + callback_url: 'https://relay.example.com/callbacks/opennode', + ttl: 10, + }, + { maxRedirects: 1 }, + ) + }) + + it('converts msats to whole sats, dropping any remainder', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21999n, description: 'd', requestId: PUBKEY }) + + expect(httpClient.post.firstCall.args[1].amount).to.equal(21) + }) + + it('maps the charge to an invoice in sats', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission fee', + requestId: PUBKEY, + }) + + expect(invoice.id).to.equal('opennode-charge-id') + expect(invoice.pubkey).to.equal(PUBKEY) + expect(invoice.bolt11).to.equal('lnbc210n1opennode') + expect(invoice.amountRequested).to.equal(21n) + expect(invoice.unit).to.equal(InvoiceUnit.SATS) + expect(invoice.status).to.equal(InvoiceStatus.PENDING) + expect(invoice.expiresAt).to.deep.equal(new Date(EXPIRES_AT_SECONDS * 1000)) + expect(invoice.createdAt).to.deep.equal(new Date(1893455000 * 1000)) + expect(invoice.confirmedAt).to.equal(null) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { post: sinon.stub().rejects(new Error('502 bad gateway')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('502 bad gateway') + }) + + it('fails when the provider answers without charge data', async () => { + const httpClient = { post: sinon.stub().resolves({ data: {} }) } + + await expect(makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY })).to + .be.rejected + }) + }) + + describe('getInvoice', () => { + it('reads the charge by id', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).getInvoice('opennode-charge-id') + + expect(httpClient.get).to.have.been.calledOnceWithExactly('/v2/charge/opennode-charge-id', { maxRedirects: 1 }) + }) + + const statuses: Array<[string, InvoiceStatus]> = [ + ['unpaid', InvoiceStatus.PENDING], + ['processing', InvoiceStatus.PENDING], + ['underpaid', InvoiceStatus.PENDING], + ['paid', InvoiceStatus.COMPLETED], + ['expired', InvoiceStatus.EXPIRED], + ['refunded', InvoiceStatus.EXPIRED], + ] + for (const [providerStatus, status] of statuses) { + it(`reports a ${providerStatus} charge as ${status}`, async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ status: providerStatus }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('opennode-charge-id')).status).to.equal(status) + }) + } + + it('treats a status it does not know as undefined rather than as paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ status: 'something-new' }) } }) } + + const invoice = await makeProcessor(httpClient).getInvoice('opennode-charge-id') + + expect(invoice.status).to.not.equal(InvoiceStatus.COMPLETED) + }) + + it('stamps a paid charge as confirmed now, and leaves others unconfirmed', async () => { + const paid = { get: sinon.stub().resolves({ data: { data: charge({ status: 'paid' }) } }) } + const unpaid = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + const before = Date.now() + const paidInvoice = await makeProcessor(paid).getInvoice('opennode-charge-id') + + expect(paidInvoice.confirmedAt!.getTime()).to.be.within(before, Date.now()) + expect((await makeProcessor(unpaid).getInvoice('opennode-charge-id')).confirmedAt).to.equal(null) + }) + + it('reads the invoice from the lightning object when the charge has no lightning_invoice', async () => { + const httpClient = { + get: sinon.stub().resolves({ + data: { + data: charge({ + lightning_invoice: undefined, + lightning: { payreq: 'lnbc210n1fallback', expires_at: '2030-01-01T00:00:00.000Z' }, + }), + }, + }), + } + + const invoice = await makeProcessor(httpClient).getInvoice('opennode-charge-id') + + expect(invoice.bolt11).to.equal('lnbc210n1fallback') + expect(invoice.expiresAt).to.deep.equal(new Date('2030-01-01T00:00:00.000Z')) + }) + + it('uses the price when the charge has no amount', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ amount: undefined, price: 42 }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('opennode-charge-id')).amountRequested).to.equal(42n) + }) + + it('reads a created_at that is already a date string', async () => { + const httpClient = { + get: sinon.stub().resolves({ data: { data: charge({ created_at: '2029-12-31T23:50:00.000Z' }) } }), + } + + expect((await makeProcessor(httpClient).getInvoice('opennode-charge-id')).createdAt).to.deep.equal( + new Date('2029-12-31T23:50:00.000Z'), + ) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice('opennode-charge-id')).to.be.rejectedWith('ETIMEDOUT') + }) + }) +}) diff --git a/test/unit/payments-processors/zebedee-payments-processor.spec.ts b/test/unit/payments-processors/zebedee-payments-processor.spec.ts new file mode 100644 index 000000000..8b4cf8732 --- /dev/null +++ b/test/unit/payments-processors/zebedee-payments-processor.spec.ts @@ -0,0 +1,180 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus } from '../../../src/@types/invoice' +import { ZebedeePaymentsProcessor } from '../../../src/payments-processors/zebedee-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'a'.repeat(64) + +const charge = (overrides: any = {}) => ({ + id: 'zbd-charge-id', + internalId: PUBKEY, + amount: '21000', + description: 'Admission fee', + unit: 'msats', + status: 'pending', + invoice: { request: 'lnbc210n1zebedee' }, + expiresAt: '2030-01-01T00:00:00.000Z', + confirmedAt: null, + createdAt: '2029-12-31T23:50:00.000Z', + ...overrides, +}) + +describe('ZebedeePaymentsProcessor', () => { + const settings = () => + ({ paymentsProcessors: { zebedee: { callbackBaseURL: 'https://relay.example.com/callbacks/zebedee' } } }) as any + + const makeProcessor = (httpClient: any, currentSettings: () => any = settings) => + new ZebedeePaymentsProcessor(httpClient, currentSettings) + + describe('createInvoice', () => { + it('posts the charge in msats with the pubkey as internal id and the callback url', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission fee', requestId: PUBKEY }) + + expect(httpClient.post).to.have.been.calledOnceWithExactly( + '/v0/charges', + { + amount: '21000', + description: 'Admission fee', + internalId: PUBKEY, + callbackUrl: 'https://relay.example.com/callbacks/zebedee', + }, + { maxRedirects: 1 }, + ) + }) + + it('sends the amount as a string so large msat amounts keep their precision', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ + amount: 9007199254740993000n, + description: 'big', + requestId: PUBKEY, + }) + + expect(httpClient.post.firstCall.args[1].amount).to.equal('9007199254740993000') + }) + + it('leaves the callback url unset when the processor is not configured with one', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient, () => ({ paymentsProcessors: {} })).createInvoice({ + amount: 1000n, + description: 'd', + requestId: PUBKEY, + }) + + expect(httpClient.post.firstCall.args[1].callbackUrl).to.equal(undefined) + }) + + it('maps the charge to an invoice', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission fee', + requestId: PUBKEY, + }) + + expect(invoice.id).to.equal('zbd-charge-id') + expect(invoice.pubkey).to.equal(PUBKEY) + expect(invoice.bolt11).to.equal('lnbc210n1zebedee') + expect(invoice.amountRequested).to.equal(21000n) + expect(invoice.description).to.equal('Admission fee') + expect(invoice.unit).to.equal('msats') + expect(invoice.status).to.equal(InvoiceStatus.PENDING) + expect(invoice.expiresAt).to.deep.equal(new Date('2030-01-01T00:00:00.000Z')) + expect(invoice.createdAt).to.deep.equal(new Date('2029-12-31T23:50:00.000Z')) + expect(invoice.confirmedAt).to.equal(null) + }) + + it('leaves dates the provider does not report as null', async () => { + const httpClient = { + post: sinon.stub().resolves({ data: { data: charge({ expiresAt: null, createdAt: undefined }) } }), + } + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'd', + requestId: PUBKEY, + }) + + expect(invoice.expiresAt).to.equal(null) + expect(invoice.createdAt).to.equal(null) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { post: sinon.stub().rejects(new Error('503 service unavailable')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('503 service unavailable') + }) + + it('fails when the provider answers without charge data', async () => { + const httpClient = { post: sinon.stub().resolves({ data: {} }) } + + await expect(makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY })).to + .be.rejected + }) + }) + + describe('getInvoice', () => { + it('reads the charge by id', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).getInvoice('zbd-charge-id') + + expect(httpClient.get).to.have.been.calledOnceWithExactly('/v0/charges/zbd-charge-id', { maxRedirects: 1 }) + }) + + // Zebedee reports its status as these literal strings, which the processor hands on as the invoice status. + const statuses: Array<[string, InvoiceStatus]> = [ + ['pending', InvoiceStatus.PENDING], + ['completed', InvoiceStatus.COMPLETED], + ['expired', InvoiceStatus.EXPIRED], + ] + for (const [providerStatus, status] of statuses) { + it(`reports a ${providerStatus} charge as ${status}`, async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ status: providerStatus }) } }) } + + const invoice = await makeProcessor(httpClient).getInvoice('zbd-charge-id') + + expect(invoice.status).to.equal(status) + }) + } + + it('reports when a paid charge was confirmed', async () => { + const httpClient = { + get: sinon + .stub() + .resolves({ data: { data: charge({ status: 'completed', confirmedAt: '2030-01-01T00:05:00.000Z' }) } }), + } + + const invoice = await makeProcessor(httpClient).getInvoice('zbd-charge-id') + + expect(invoice.confirmedAt).to.deep.equal(new Date('2030-01-01T00:05:00.000Z')) + }) + + it('has no confirmation date for a charge that is not paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + expect((await makeProcessor(httpClient).getInvoice('zbd-charge-id')).confirmedAt).to.equal(null) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice('zbd-charge-id')).to.be.rejectedWith('ETIMEDOUT') + }) + }) +})