Skip to content

Commit f92d818

Browse files
test: add unit tests for OpenNode callback controller and route
1 parent 356f4fa commit f92d818

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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('rejects callbacks with mismatched hashed_order', async () => {
104+
request.body = {
105+
hashed_order: 'invalid',
106+
id: 'invoice-id',
107+
}
108+
109+
await controller.handleRequest(request, response)
110+
111+
expect(statusStub).to.have.been.calledOnceWithExactly(403)
112+
expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden')
113+
expect(updateInvoiceStatusStub).not.to.have.been.called
114+
})
115+
116+
it('accepts valid signed callbacks and processes the invoice update', async () => {
117+
request.body = {
118+
amount: 21,
119+
created_at: '2026-04-11T00:00:00.000Z',
120+
description: 'Admission fee',
121+
hashed_order: hmacSha256('test-api-key', 'invoice-id').toString('hex'),
122+
id: 'invoice-id',
123+
lightning: {
124+
expires_at: '2026-04-11T01:00:00.000Z',
125+
payreq: 'lnbc1test',
126+
},
127+
order_id: 'pubkey',
128+
status: 'unpaid',
129+
}
130+
131+
updateInvoiceStatusStub.resolves({
132+
confirmedAt: null,
133+
status: InvoiceStatus.PENDING,
134+
})
135+
136+
await controller.handleRequest(request, response)
137+
138+
expect(updateInvoiceStatusStub).to.have.been.calledOnce
139+
expect(confirmInvoiceStub).not.to.have.been.called
140+
expect(sendInvoiceUpdateNotificationStub).not.to.have.been.called
141+
expect(statusStub).to.have.been.calledOnceWithExactly(200)
142+
expect(sendStub).to.have.been.calledOnceWithExactly()
143+
})
144+
})

test/unit/routes/callbacks.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import express, { json, urlencoded } from 'express'
2+
import axios from 'axios'
3+
import { expect } from 'chai'
4+
5+
describe('callbacks router', () => {
6+
let receivedBody: unknown
7+
let server: any
8+
9+
beforeEach(async () => {
10+
receivedBody = undefined
11+
12+
const app = express()
13+
app.post(
14+
'/opennode',
15+
urlencoded({ extended: false }),
16+
json(),
17+
(req: any, res: any) => {
18+
receivedBody = req.body
19+
res.status(200).send('OK')
20+
},
21+
)
22+
23+
server = await new Promise((resolve) => {
24+
const listeningServer = app.listen(0, () => resolve(listeningServer))
25+
})
26+
})
27+
28+
afterEach(async () => {
29+
if (server) {
30+
await new Promise<void>((resolve, reject) => {
31+
server.close((error: Error | undefined) => {
32+
if (error) {
33+
reject(error)
34+
return
35+
}
36+
37+
resolve()
38+
})
39+
})
40+
}
41+
})
42+
43+
it('parses form-urlencoded OpenNode callbacks', async () => {
44+
const { port } = server.address()
45+
const response = await axios.post(
46+
`http://127.0.0.1:${port}/opennode`,
47+
new URLSearchParams({
48+
hashed_order: 'signature',
49+
id: 'invoice-id',
50+
order_id: 'pubkey',
51+
status: 'paid',
52+
}).toString(),
53+
{
54+
headers: {
55+
'content-type': 'application/x-www-form-urlencoded',
56+
},
57+
validateStatus: () => true,
58+
},
59+
)
60+
61+
expect(response.status).to.equal(200)
62+
expect(receivedBody).to.deep.equal({
63+
hashed_order: 'signature',
64+
id: 'invoice-id',
65+
order_id: 'pubkey',
66+
status: 'paid',
67+
})
68+
})
69+
})

0 commit comments

Comments
 (0)