Skip to content

Commit f1cf70a

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

File tree

2 files changed

+226
-0
lines changed

2 files changed

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

test/unit/routes/callbacks.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { expect } from 'chai'
2+
import express from 'express'
3+
import axios from 'axios'
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
20+
.status(200)
21+
.send('OK')
22+
},
23+
} as any)
24+
25+
delete require.cache[require.resolve('../../../src/routes/callbacks')]
26+
27+
const router = require('../../../src/routes/callbacks').default
28+
const app = express()
29+
30+
app.use(router)
31+
32+
server = await new Promise((resolve) => {
33+
const listeningServer = app.listen(0, () => resolve(listeningServer))
34+
})
35+
})
36+
37+
afterEach(async () => {
38+
createOpenNodeCallbackControllerStub.restore()
39+
delete require.cache[require.resolve('../../../src/routes/callbacks')]
40+
41+
if (server) {
42+
await new Promise<void>((resolve, reject) => {
43+
server.close((error: Error | undefined) => {
44+
if (error) {
45+
reject(error)
46+
return
47+
}
48+
49+
resolve()
50+
})
51+
})
52+
}
53+
})
54+
55+
it('parses form-urlencoded OpenNode callbacks', async () => {
56+
const { port } = server.address()
57+
const response = await axios.post(
58+
`http://127.0.0.1:${port}/opennode`,
59+
new URLSearchParams({
60+
hashed_order: 'signature',
61+
id: 'invoice-id',
62+
order_id: 'pubkey',
63+
status: 'paid',
64+
}).toString(),
65+
{
66+
headers: {
67+
'content-type': 'application/x-www-form-urlencoded',
68+
},
69+
validateStatus: () => true,
70+
},
71+
)
72+
73+
expect(response.status).to.equal(200)
74+
expect(receivedBody).to.deep.equal({
75+
hashed_order: 'signature',
76+
id: 'invoice-id',
77+
order_id: 'pubkey',
78+
status: 'paid',
79+
})
80+
})
81+
})

0 commit comments

Comments
 (0)