Skip to content

Commit 3dc52d8

Browse files
test: add unit tests for OpenNode callback controller and route
1 parent 7c66709 commit 3dc52d8

File tree

2 files changed

+239
-0
lines changed

2 files changed

+239
-0
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import chai, { expect } from 'chai'
2+
import Sinon from 'sinon'
3+
import sinonChai from 'sinon-chai'
4+
5+
import * as httpUtils from '../../../../src/utils/http'
6+
import * as settingsFactory from '../../../../src/factories/settings-factory'
7+
8+
import { hmacSha256 } from '../../../../src/utils/secret'
9+
import { InvoiceStatus } from '../../../../src/@types/invoice'
10+
import { OpenNodeCallbackController } from '../../../../src/controllers/callbacks/opennode-callback-controller'
11+
12+
chai.use(sinonChai)
13+
14+
describe('OpenNodeCallbackController', () => {
15+
let createSettingsStub: Sinon.SinonStub
16+
let getRemoteAddressStub: Sinon.SinonStub
17+
let updateInvoiceStatusStub: Sinon.SinonStub
18+
let confirmInvoiceStub: Sinon.SinonStub
19+
let sendInvoiceUpdateNotificationStub: Sinon.SinonStub
20+
let statusStub: Sinon.SinonStub
21+
let setHeaderStub: Sinon.SinonStub
22+
let sendStub: Sinon.SinonStub
23+
let controller: OpenNodeCallbackController
24+
let request: any
25+
let response: any
26+
let previousOpenNodeApiKey: string | undefined
27+
28+
beforeEach(() => {
29+
previousOpenNodeApiKey = process.env.OPENNODE_API_KEY
30+
process.env.OPENNODE_API_KEY = 'test-api-key'
31+
32+
createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns({
33+
payments: { processor: 'opennode' },
34+
} as any)
35+
getRemoteAddressStub = Sinon.stub(httpUtils, 'getRemoteAddress').returns('127.0.0.1')
36+
37+
updateInvoiceStatusStub = Sinon.stub()
38+
confirmInvoiceStub = Sinon.stub()
39+
sendInvoiceUpdateNotificationStub = Sinon.stub()
40+
41+
controller = new OpenNodeCallbackController({
42+
updateInvoiceStatus: updateInvoiceStatusStub,
43+
confirmInvoice: confirmInvoiceStub,
44+
sendInvoiceUpdateNotification: sendInvoiceUpdateNotificationStub,
45+
} as any)
46+
47+
statusStub = Sinon.stub()
48+
setHeaderStub = Sinon.stub()
49+
sendStub = Sinon.stub()
50+
51+
response = {
52+
send: sendStub,
53+
setHeader: setHeaderStub,
54+
status: statusStub,
55+
}
56+
57+
statusStub.returns(response)
58+
setHeaderStub.returns(response)
59+
sendStub.returns(response)
60+
61+
request = {
62+
body: {},
63+
headers: {},
64+
}
65+
})
66+
67+
afterEach(() => {
68+
getRemoteAddressStub.restore()
69+
createSettingsStub.restore()
70+
71+
if (typeof previousOpenNodeApiKey === 'undefined') {
72+
delete process.env.OPENNODE_API_KEY
73+
} else {
74+
process.env.OPENNODE_API_KEY = previousOpenNodeApiKey
75+
}
76+
})
77+
78+
it('rejects requests when OpenNode is not the configured payment processor', async () => {
79+
createSettingsStub.returns({
80+
payments: { processor: 'lnbits' },
81+
} as any)
82+
83+
await controller.handleRequest(request, response)
84+
85+
expect(statusStub).to.have.been.calledOnceWithExactly(403)
86+
expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden')
87+
expect(updateInvoiceStatusStub).not.to.have.been.called
88+
})
89+
90+
it('returns bad request for malformed callback bodies', async () => {
91+
request.body = {
92+
id: 'invoice-id',
93+
}
94+
95+
await controller.handleRequest(request, response)
96+
97+
expect(statusStub).to.have.been.calledOnceWithExactly(400)
98+
expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8')
99+
expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request')
100+
expect(updateInvoiceStatusStub).not.to.have.been.called
101+
})
102+
103+
it('returns bad request for unknown status values', async () => {
104+
request.body = {
105+
hashed_order: 'some-hash',
106+
id: 'invoice-id',
107+
status: 'totally_made_up',
108+
}
109+
110+
await controller.handleRequest(request, response)
111+
112+
expect(statusStub).to.have.been.calledOnceWithExactly(400)
113+
expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request')
114+
expect(updateInvoiceStatusStub).not.to.have.been.called
115+
})
116+
117+
it('rejects callbacks with mismatched hashed_order', async () => {
118+
request.body = {
119+
hashed_order: 'invalid',
120+
id: 'invoice-id',
121+
status: 'paid',
122+
}
123+
124+
await controller.handleRequest(request, response)
125+
126+
expect(statusStub).to.have.been.calledOnceWithExactly(403)
127+
expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden')
128+
expect(updateInvoiceStatusStub).not.to.have.been.called
129+
})
130+
131+
it('accepts valid signed callbacks and processes the invoice update', async () => {
132+
request.body = {
133+
amount: 21,
134+
created_at: '2026-04-11T00:00:00.000Z',
135+
description: 'Admission fee',
136+
hashed_order: hmacSha256('test-api-key', 'invoice-id').toString('hex'),
137+
id: 'invoice-id',
138+
lightning: {
139+
expires_at: '2026-04-11T01:00:00.000Z',
140+
payreq: 'lnbc1test',
141+
},
142+
order_id: 'pubkey',
143+
status: 'unpaid',
144+
}
145+
146+
updateInvoiceStatusStub.resolves({
147+
confirmedAt: null,
148+
status: InvoiceStatus.PENDING,
149+
})
150+
151+
await controller.handleRequest(request, response)
152+
153+
expect(updateInvoiceStatusStub).to.have.been.calledOnce
154+
expect(confirmInvoiceStub).not.to.have.been.called
155+
expect(sendInvoiceUpdateNotificationStub).not.to.have.been.called
156+
expect(statusStub).to.have.been.calledOnceWithExactly(200)
157+
expect(sendStub).to.have.been.calledOnceWithExactly()
158+
})
159+
})

test/unit/routes/callbacks.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import axios from 'axios'
2+
import { expect } from 'chai'
3+
import express from 'express'
4+
import Sinon from 'sinon'
5+
6+
import * as openNodeControllerFactory from '../../../src/factories/controllers/opennode-callback-controller-factory'
7+
8+
describe('callbacks router', () => {
9+
let createOpenNodeCallbackControllerStub: Sinon.SinonStub
10+
let receivedBody: unknown
11+
let server: any
12+
13+
beforeEach(async () => {
14+
receivedBody = undefined
15+
16+
createOpenNodeCallbackControllerStub = Sinon.stub(openNodeControllerFactory, 'createOpenNodeCallbackController').returns({
17+
handleRequest: async (request: any, response: any) => {
18+
receivedBody = request.body
19+
response.status(200).send('OK')
20+
},
21+
} as any)
22+
23+
// eslint-disable-next-line @typescript-eslint/no-var-requires
24+
delete require.cache[require.resolve('../../../src/routes/callbacks')]
25+
// eslint-disable-next-line @typescript-eslint/no-var-requires
26+
const router = require('../../../src/routes/callbacks').default
27+
28+
const app = express()
29+
app.use(router)
30+
31+
server = await new Promise((resolve) => {
32+
const listeningServer = app.listen(0, () => resolve(listeningServer))
33+
})
34+
})
35+
36+
afterEach(async () => {
37+
createOpenNodeCallbackControllerStub.restore()
38+
delete require.cache[require.resolve('../../../src/routes/callbacks')]
39+
40+
if (server) {
41+
await new Promise<void>((resolve, reject) => {
42+
server.close((error: Error | undefined) => {
43+
if (error) {
44+
reject(error)
45+
return
46+
}
47+
48+
resolve()
49+
})
50+
})
51+
}
52+
})
53+
54+
it('parses form-urlencoded OpenNode callbacks', async () => {
55+
const { port } = server.address()
56+
const response = await axios.post(
57+
`http://127.0.0.1:${port}/opennode`,
58+
new URLSearchParams({
59+
hashed_order: 'signature',
60+
id: 'invoice-id',
61+
order_id: 'pubkey',
62+
status: 'paid',
63+
}).toString(),
64+
{
65+
headers: {
66+
'content-type': 'application/x-www-form-urlencoded',
67+
},
68+
validateStatus: () => true,
69+
},
70+
)
71+
72+
expect(response.status).to.equal(200)
73+
expect(receivedBody).to.deep.equal({
74+
hashed_order: 'signature',
75+
id: 'invoice-id',
76+
order_id: 'pubkey',
77+
status: 'paid',
78+
})
79+
})
80+
})

0 commit comments

Comments
 (0)