Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/payment-processor-unit-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
123 changes: 123 additions & 0 deletions test/unit/payments-processors/lnurl-payments-processor.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
158 changes: 158 additions & 0 deletions test/unit/payments-processors/nodeless-payments-processor.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading
Loading