diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index efc5e50a..bd571a6a 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -1,33 +1,55 @@ +import EventEmitter from 'events' + import chai from 'chai' import chaiAsPromised from 'chai-as-promised' import Sinon from 'sinon' import sinonChai from 'sinon-chai' -chai.use(sinonChai) -chai.use(chaiAsPromised) - -import { applyReverificationOutcome, MaintenanceWorker } from '../../../src/app/maintenance-worker' -import { IMaintenanceService, IPaymentsService } from '../../../src/@types/services' +import { InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' import { Nip05Verification } from '../../../src/@types/nip05' +import { IMaintenanceService, IPaymentsService } from '../../../src/@types/services' import { Settings } from '../../../src/@types/settings' - +import { applyReverificationOutcome, MaintenanceWorker } from '../../../src/app/maintenance-worker' +import * as misc from '../../../src/utils/misc' import * as nip05Utils from '../../../src/utils/nip05' +chai.use(sinonChai) +chai.use(chaiAsPromised) + const { expect } = chai describe('MaintenanceWorker', () => { let sandbox: Sinon.SinonSandbox let worker: MaintenanceWorker - let nip05VerificationRepository: any - let verifyStub: Sinon.SinonStub - let settings: Settings - let mockProcess: any + let fakeProcess: EventEmitter & { exit: Sinon.SinonStub } let paymentsService: Sinon.SinonStubbedInstance let maintenanceService: Sinon.SinonStubbedInstance + let settings: Sinon.SinonStub + let settingsState: Settings + let nip05VerificationRepository: any + let verifyStub: Sinon.SinonStub + + const pendingInvoice = { + id: 'inv-1', + pubkey: 'pubkey1234', + status: InvoiceStatus.PENDING, + amountRequested: 1000n, + unit: InvoiceUnit.SATS, + bolt11: 'lnbc...', + description: 'test', + confirmedAt: null, + expiresAt: new Date('2099-01-01'), + updatedAt: new Date(), + createdAt: new Date(), + } beforeEach(() => { sandbox = Sinon.createSandbox() + fakeProcess = Object.assign(new EventEmitter(), { + exit: sandbox.stub(), + }) as EventEmitter & { exit: Sinon.SinonStub } + nip05VerificationRepository = { findByPubkey: sandbox.stub(), upsert: sandbox.stub().resolves(1), @@ -37,7 +59,10 @@ describe('MaintenanceWorker', () => { verifyStub = sandbox.stub(nip05Utils, 'verifyNip05Identifier') - settings = { + settingsState = { + payments: { + enabled: true, + }, info: { relay_url: 'relay_url', }, @@ -51,28 +76,28 @@ describe('MaintenanceWorker', () => { }, } as any - mockProcess = { - on: sandbox.stub().returnsThis(), - exit: sandbox.stub(), - } + settings = sandbox.stub().callsFake(() => settingsState) paymentsService = { getPendingInvoices: sandbox.stub().resolves([]), getInvoiceFromPaymentsProcessor: sandbox.stub(), - updateInvoiceStatus: sandbox.stub(), - confirmInvoice: sandbox.stub(), - sendInvoiceUpdateNotification: sandbox.stub(), + updateInvoiceStatus: sandbox.stub().resolves(), + confirmInvoice: sandbox.stub().resolves(), + sendInvoiceUpdateNotification: sandbox.stub().resolves(), } as any maintenanceService = { clearOldEvents: sandbox.stub().resolves(), } as any + // Prevent real timeouts and randomized per-invoice delays. + sandbox.stub(misc, 'delayMs').resolves() + worker = new MaintenanceWorker( - mockProcess, - paymentsService as any, - maintenanceService as any, - () => settings, + fakeProcess as any, + paymentsService, + maintenanceService, + settings as any, nip05VerificationRepository, ) }) @@ -94,6 +119,43 @@ describe('MaintenanceWorker', () => { ...overrides, }) + describe('constructor', () => { + it('registers SIGINT, SIGHUP, and SIGTERM handlers', () => { + expect(fakeProcess.listenerCount('SIGINT')).to.equal(1) + expect(fakeProcess.listenerCount('SIGHUP')).to.equal(1) + expect(fakeProcess.listenerCount('SIGTERM')).to.equal(1) + }) + + it('registers uncaughtException and unhandledRejection handlers', () => { + expect(fakeProcess.listenerCount('uncaughtException')).to.equal(1) + expect(fakeProcess.listenerCount('unhandledRejection')).to.equal(1) + }) + }) + + describe('run', () => { + let clock: Sinon.SinonFakeTimers + + beforeEach(() => { + clock = Sinon.useFakeTimers() + }) + + afterEach(() => { + worker.close() + clock.restore() + }) + + it('sets up a 60-second interval that triggers onSchedule', async () => { + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([]) + + worker.run() + await clock.tickAsync(60000) + + expect(maintenanceService.clearOldEvents).to.have.been.calledOnce + expect(paymentsService.getPendingInvoices).to.have.been.calledOnce + }) + }) + describe('applyReverificationOutcome', () => { it('marks verified on a successful outcome and resets failureCount', () => { const existing = verification({ isVerified: false, lastVerifiedAt: null, failureCount: 5 }) @@ -140,17 +202,17 @@ describe('MaintenanceWorker', () => { describe('processNip05Reverifications', () => { it('returns early when nip05 settings are undefined', async () => { - ;(settings as any).nip05 = undefined + settingsState.nip05 = undefined - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.findPendingVerifications).not.to.have.been.called }) it('returns early when mode is disabled', async () => { - ;(settings as any).nip05.mode = 'disabled' + settingsState.nip05!.mode = 'disabled' - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.findPendingVerifications).not.to.have.been.called }) @@ -158,7 +220,7 @@ describe('MaintenanceWorker', () => { it('does nothing when no pending verifications', async () => { nip05VerificationRepository.findPendingVerifications.resolves([]) - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly(86400000, 20, 50) expect(verifyStub).not.to.have.been.called @@ -169,7 +231,7 @@ describe('MaintenanceWorker', () => { nip05VerificationRepository.findPendingVerifications.resolves([row]) verifyStub.resolves({ status: 'verified' }) - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(verifyStub).to.have.been.calledOnceWithExactly('alice@example.com', 'a'.repeat(64)) expect(nip05VerificationRepository.upsert).to.have.been.calledOnce @@ -185,7 +247,7 @@ describe('MaintenanceWorker', () => { nip05VerificationRepository.findPendingVerifications.resolves([row]) verifyStub.resolves({ status: 'mismatch' }) - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.upsert).to.have.been.calledOnce const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] @@ -200,7 +262,7 @@ describe('MaintenanceWorker', () => { nip05VerificationRepository.findPendingVerifications.resolves([row]) verifyStub.resolves({ status: 'error', reason: 'ETIMEDOUT' }) - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.upsert).to.have.been.calledOnce const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] @@ -216,7 +278,7 @@ describe('MaintenanceWorker', () => { verifyStub.onFirstCall().rejects(new Error('network error')) verifyStub.onSecondCall().resolves({ status: 'verified' }) - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.upsert).to.have.been.calledOnce const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] @@ -224,30 +286,30 @@ describe('MaintenanceWorker', () => { }) it('uses configured updateFrequency and maxFailures', async () => { - ;(settings as any).nip05.verifyUpdateFrequency = 3600000 - ;(settings as any).nip05.maxConsecutiveFailures = 5 + settingsState.nip05!.verifyUpdateFrequency = 3600000 + settingsState.nip05!.maxConsecutiveFailures = 5 - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly(3600000, 5, 50) }) it('uses defaults when settings values are undefined', async () => { - ;(settings as any).nip05.verifyUpdateFrequency = undefined - ;(settings as any).nip05.maxConsecutiveFailures = undefined + settingsState.nip05!.verifyUpdateFrequency = undefined + settingsState.nip05!.maxConsecutiveFailures = undefined - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly(86400000, 20, 50) }) it('processes in passive mode', async () => { - ;(settings as any).nip05.mode = 'passive' + settingsState.nip05!.mode = 'passive' const row = verification({ pubkey: 'c'.repeat(64), nip05: 'charlie@example.com' }) nip05VerificationRepository.findPendingVerifications.resolves([row]) verifyStub.resolves({ status: 'verified' }) - await (worker as any).processNip05Reverifications(settings) + await (worker as any).processNip05Reverifications(settingsState) expect(verifyStub).to.have.been.calledOnce expect(nip05VerificationRepository.upsert).to.have.been.calledOnce @@ -256,7 +318,7 @@ describe('MaintenanceWorker', () => { describe('onSchedule', () => { it('calls maintenance service and processes invoices', async () => { - ;(settings as any).payments = { enabled: true } + settingsState.payments = { enabled: true } as any maintenanceService.clearOldEvents.resolves() paymentsService.getPendingInvoices.resolves([]) @@ -267,13 +329,149 @@ describe('MaintenanceWorker', () => { }) it('calls maintenance service even if payments are disabled', async () => { - ;(settings as any).payments = { enabled: false } + settingsState.payments = { enabled: false } as any maintenanceService.clearOldEvents.resolves() await (worker as any).onSchedule() expect(maintenanceService.clearOldEvents).to.have.been.calledOnce + expect(paymentsService.updateInvoiceStatus).not.to.have.been.called expect(paymentsService.getPendingInvoices).not.to.have.been.called }) + + it('skips an invoice when the processor returns no id', async () => { + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ status: InvoiceStatus.PENDING }) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).not.to.have.been.called + }) + + it('skips an invoice when the processor returns no status', async () => { + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ id: 'inv-1' }) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).not.to.have.been.called + }) + + it('updates invoice status when id and status are valid', async () => { + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'inv-1', + status: InvoiceStatus.PENDING, + }) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnceWithExactly({ + id: 'inv-1', + status: InvoiceStatus.PENDING, + }) + expect(paymentsService.confirmInvoice).not.to.have.been.called + }) + + it('does not confirm when status changes but is not COMPLETED', async () => { + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'inv-1', + status: InvoiceStatus.EXPIRED, + }) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnce + expect(paymentsService.confirmInvoice).not.to.have.been.called + }) + + it('does not confirm when status is COMPLETED but confirmedAt is missing', async () => { + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'inv-1', + status: InvoiceStatus.COMPLETED, + confirmedAt: null, + }) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnce + expect(paymentsService.confirmInvoice).not.to.have.been.called + expect(paymentsService.sendInvoiceUpdateNotification).not.to.have.been.called + }) + + it('confirms and notifies when status changes to COMPLETED with confirmedAt', async () => { + const confirmedAt = new Date() + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'inv-1', + status: InvoiceStatus.COMPLETED, + confirmedAt, + }) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnce + expect(paymentsService.confirmInvoice).to.have.been.calledOnce + expect(paymentsService.sendInvoiceUpdateNotification).to.have.been.calledOnce + + const [confirmArg] = paymentsService.confirmInvoice.firstCall.args + expect(confirmArg).to.include({ id: 'inv-1', status: InvoiceStatus.COMPLETED }) + expect(confirmArg.amountPaid).to.equal(pendingInvoice.amountRequested) + }) + + it('continues processing remaining invoices when one throws', async () => { + const secondInvoice = { ...pendingInvoice, id: 'inv-2' } + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice, secondInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor + .onFirstCall().rejects(new Error('processor error')) + .onSecondCall().resolves({ id: 'inv-2', status: InvoiceStatus.PENDING }) + + const consoleErrorStub = sandbox.stub(console, 'error') + + await (worker as any).onSchedule() + + expect(maintenanceService.clearOldEvents).to.have.been.calledOnce + expect(consoleErrorStub).to.have.been.calledOnce + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnce + }) + }) + + describe('onError', () => { + it('re-throws the error received from the process', () => { + const err = new Error('uncaught error') + + expect(() => fakeProcess.emit('uncaughtException', err)).to.throw('uncaught error') + }) + }) + + describe('onExit', () => { + it('calls close and then exits the process with code 0', () => { + fakeProcess.emit('SIGTERM') + + expect(fakeProcess.exit).to.have.been.calledOnceWithExactly(0) + }) + }) + + describe('close', () => { + it('invokes the callback when one is provided', () => { + const callback = sandbox.stub() + + worker.close(callback) + + expect(callback).to.have.been.calledOnce + }) + + it('does not throw when called without a callback', () => { + expect(() => worker.close()).not.to.throw() + }) }) }) diff --git a/test/unit/services/payments-service.spec.ts b/test/unit/services/payments-service.spec.ts new file mode 100644 index 00000000..6d4e7035 --- /dev/null +++ b/test/unit/services/payments-service.spec.ts @@ -0,0 +1,480 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import * as eventUtils from '../../../src/utils/event' +import { Invoice, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { PaymentsService } from '../../../src/services/payments-service' + +const { expect } = chai + +describe('PaymentsService', () => { + let sandbox: Sinon.SinonSandbox + let service: PaymentsService + let mockTrx: { commit: Sinon.SinonStub; rollback: Sinon.SinonStub } + let dbClient: any + let paymentsProcessor: any + let userRepository: any + let invoiceRepository: any + let eventRepository: any + let settings: Sinon.SinonStub + + const stubInvoice = (overrides: Partial = {}): Invoice => ({ + id: 'invoice-id', + pubkey: 'pubkey1234', + bolt11: 'lnbc500n1...', + amountRequested: 1000n, + unit: InvoiceUnit.SATS, + status: InvoiceStatus.PENDING, + description: 'test invoice', + confirmedAt: null, + expiresAt: new Date('2099-01-01'), + updatedAt: new Date(), + createdAt: new Date(), + ...overrides, + }) + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + mockTrx = { + commit: sandbox.stub().resolves([]), + rollback: sandbox.stub().resolves([]), + } + + // Simulate Knex's client.transaction(null, opts) → returns a trx object + dbClient = { transaction: sandbox.stub().resolves(mockTrx) } + + paymentsProcessor = { + createInvoice: sandbox.stub(), + getInvoice: sandbox.stub(), + } + + userRepository = { + upsert: sandbox.stub().resolves(), + admitUser: sandbox.stub().resolves(), + findByPubkey: sandbox.stub(), + } + + invoiceRepository = { + findPendingInvoices: sandbox.stub(), + upsert: sandbox.stub().resolves(), + updateStatus: sandbox.stub(), + confirmInvoice: sandbox.stub().resolves(), + } + + eventRepository = { + create: sandbox.stub().resolves(), + } + + settings = sandbox.stub() + + // Stub module-level utilities used inside PaymentsService + sandbox.stub(eventUtils, 'getRelayPrivateKey').returns('fakeprivkey') + sandbox.stub(eventUtils, 'getPublicKey').returns('fakepubkey') + sandbox.stub(eventUtils, 'identifyEvent').resolves({ + id: 'eventid', + pubkey: 'fakepubkey', + kind: 402, + created_at: 1000, + content: '', + tags: [], + } as any) + sandbox.stub(eventUtils, 'signEvent').returns( + async () => ({ + id: 'eventid', + pubkey: 'fakepubkey', + kind: 402, + created_at: 1000, + content: '', + tags: [], + sig: 'fakesig', + } as any) + ) + sandbox.stub(eventUtils, 'broadcastEvent').resolves() + + service = new PaymentsService( + dbClient, + paymentsProcessor, + userRepository, + invoiceRepository, + eventRepository, + settings, + ) + }) + + afterEach(() => { + sandbox.restore() + }) + + + describe('getPendingInvoices', () => { + it('returns invoices from the repository with offset 0 and limit 10', async () => { + const invoices = [stubInvoice()] + invoiceRepository.findPendingInvoices.resolves(invoices) + + const result = await service.getPendingInvoices() + + expect(result).to.deep.equal(invoices) + expect(invoiceRepository.findPendingInvoices).to.have.been.calledOnceWithExactly(0, 10) + }) + + it('re-throws repository errors', async () => { + invoiceRepository.findPendingInvoices.rejects(new Error('db error')) + + await expect(service.getPendingInvoices()).to.be.rejectedWith('db error') + }) + }) + + + describe('getInvoiceFromPaymentsProcessor', () => { + it('passes a string invoice ID directly to the payments processor', async () => { + const partial = { id: 'inv', status: InvoiceStatus.PENDING } + paymentsProcessor.getInvoice.resolves(partial) + + const result = await service.getInvoiceFromPaymentsProcessor('string-invoice-id') + + expect(paymentsProcessor.getInvoice).to.have.been.calledOnceWithExactly('string-invoice-id') + expect(result).to.deep.equal(partial) + }) + + it('passes the full invoice object when it has a verifyURL', async () => { + const invoice = stubInvoice({ verifyURL: 'https://verify.example.com/inv' }) + paymentsProcessor.getInvoice.resolves({}) + + await service.getInvoiceFromPaymentsProcessor(invoice) + + expect(paymentsProcessor.getInvoice).to.have.been.calledOnceWithExactly(invoice) + }) + + it('passes invoice.id when the invoice has no verifyURL', async () => { + const invoice = stubInvoice({ id: 'target-id', verifyURL: undefined }) + paymentsProcessor.getInvoice.resolves({}) + + await service.getInvoiceFromPaymentsProcessor(invoice) + + expect(paymentsProcessor.getInvoice).to.have.been.calledOnceWithExactly('target-id') + }) + + it('re-throws payments processor errors', async () => { + paymentsProcessor.getInvoice.rejects(new Error('processor error')) + + await expect( + service.getInvoiceFromPaymentsProcessor('any-id') + ).to.be.rejectedWith('processor error') + }) + }) + + + describe('createInvoice', () => { + const invoiceResponse = { + id: 'new-inv-id', + bolt11: 'lnbc...', + amountRequested: 1000n, + description: 'test', + unit: InvoiceUnit.SATS, + status: InvoiceStatus.PENDING, + expiresAt: new Date('2099-01-01'), + createdAt: new Date(), + verifyURL: undefined, + } + + beforeEach(() => { + paymentsProcessor.createInvoice.resolves(invoiceResponse) + }) + + it('upserts user, creates invoice via processor, persists, and returns the invoice', async () => { + const result = await service.createInvoice('pubkey1234', 1000n, 'test') + + expect(dbClient.transaction).to.have.been.called + expect(userRepository.upsert).to.have.been.calledOnce + expect(paymentsProcessor.createInvoice).to.have.been.calledOnceWithExactly({ + amount: 1000n, + description: 'test', + requestId: 'pubkey1234', + }) + expect(invoiceRepository.upsert).to.have.been.calledOnce + expect(mockTrx.commit).to.have.been.calledOnce + expect(result.id).to.equal('new-inv-id') + expect(result.pubkey).to.equal('pubkey1234') + }) + + it('rolls back the transaction and re-throws when the processor fails', async () => { + paymentsProcessor.createInvoice.rejects(new Error('processor fail')) + + await expect( + service.createInvoice('pubkey1234', 1000n, 'test') + ).to.be.rejectedWith('processor fail') + + expect(mockTrx.rollback).to.have.been.calledOnce + expect(mockTrx.commit).not.to.have.been.called + }) + }) + + + describe('updateInvoice', () => { + it('delegates to invoiceRepository.updateStatus with id and status', async () => { + invoiceRepository.updateStatus.resolves() + + await service.updateInvoice({ id: 'inv-id', status: InvoiceStatus.COMPLETED }) + + expect(invoiceRepository.updateStatus).to.have.been.calledOnceWithExactly({ + id: 'inv-id', + status: InvoiceStatus.COMPLETED, + }) + }) + + it('re-throws repository errors', async () => { + invoiceRepository.updateStatus.rejects(new Error('update error')) + + await expect( + service.updateInvoice({ id: 'inv-id', status: InvoiceStatus.PENDING }) + ).to.be.rejectedWith('update error') + }) + }) + + + describe('updateInvoiceStatus', () => { + it('returns the updated invoice from the repository', async () => { + const updated = stubInvoice({ status: InvoiceStatus.COMPLETED }) + invoiceRepository.updateStatus.resolves(updated) + + const result = await service.updateInvoiceStatus({ id: 'inv-id', status: InvoiceStatus.COMPLETED }) + + expect(result).to.deep.equal(updated) + expect(invoiceRepository.updateStatus).to.have.been.calledOnceWithExactly({ + id: 'inv-id', + status: InvoiceStatus.COMPLETED, + }) + }) + + it('re-throws repository errors', async () => { + invoiceRepository.updateStatus.rejects(new Error('update error')) + + await expect( + service.updateInvoiceStatus({ id: 'inv-id', status: InvoiceStatus.PENDING }) + ).to.be.rejectedWith('update error') + }) + }) + + + describe('confirmInvoice', () => { + const makeCompletedInvoice = (overrides: Partial = {}): Invoice => + stubInvoice({ + status: InvoiceStatus.COMPLETED, + confirmedAt: new Date(), + amountPaid: 2000n, + unit: InvoiceUnit.MSATS, + ...overrides, + }) + + const makeSettings = (admissionFeeSchedules: any[] = []) => ({ + payments: { + feeSchedules: { admission: admissionFeeSchedules }, + }, + }) + + beforeEach(() => { + settings.returns(makeSettings()) + }) + + it('throws when confirmedAt is not set', async () => { + // Validation fires before transaction.begin(); rollback() on an unstarted + // transaction throws its own error, which is what ultimately propagates. + await expect( + service.confirmInvoice(makeCompletedInvoice({ confirmedAt: null })) + ).to.be.rejectedWith('Unable to get transaction: transaction not started.') + }) + + it('throws when status is not COMPLETED', async () => { + await expect( + service.confirmInvoice(makeCompletedInvoice({ status: InvoiceStatus.PENDING })) + ).to.be.rejectedWith('Unable to get transaction: transaction not started.') + }) + + it('throws when amountPaid is not a bigint', async () => { + await expect( + service.confirmInvoice(makeCompletedInvoice({ amountPaid: undefined })) + ).to.be.rejectedWith('Unable to get transaction: transaction not started.') + }) + + it('converts SATS to msats before comparing against the fee', async () => { + // 2 sats = 2000 msats; fee = 1000 msats → should admit + settings.returns(makeSettings([{ enabled: true, amount: 1000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ + unit: InvoiceUnit.SATS, + amountPaid: 2n, + })) + + expect(userRepository.admitUser).to.have.been.calledOnce + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('converts BTC to msats before comparing against the fee', async () => { + // 1 btc = 100_000_000 sats = 100_000_000_000 msats; fee = 1000 msats → should admit + settings.returns(makeSettings([{ enabled: true, amount: 1000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ + unit: InvoiceUnit.BTC, + amountPaid: 1n, + })) + + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('does not convert MSATS (uses amount directly)', async () => { + settings.returns(makeSettings([{ enabled: true, amount: 1000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ + unit: InvoiceUnit.MSATS, + amountPaid: 2000n, + })) + + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('admits the user when the paid amount meets the admission fee', async () => { + settings.returns(makeSettings([{ enabled: true, amount: 1000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ + pubkey: 'admittedpubkey', + unit: InvoiceUnit.MSATS, + amountPaid: 5000n, + })) + + expect(userRepository.admitUser).to.have.been.calledOnce + const [pubkeyArg, admittedAtArg] = userRepository.admitUser.firstCall.args + expect(pubkeyArg).to.equal('admittedpubkey') + expect(admittedAtArg).to.be.instanceOf(Date) + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('does not admit the user when the paid amount is below the admission fee', async () => { + settings.returns(makeSettings([{ enabled: true, amount: 10000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ + unit: InvoiceUnit.MSATS, + amountPaid: 500n, + })) + + expect(userRepository.admitUser).not.to.have.been.called + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('does not admit the user when there are no admission fee schedules', async () => { + settings.returns(makeSettings([])) + + await service.confirmInvoice(makeCompletedInvoice()) + + expect(userRepository.admitUser).not.to.have.been.called + }) + + it('falls back to an empty admission array when feeSchedules is missing', async () => { + // Covers the `?? []` branch on `payments?.feeSchedules?.admission` + settings.returns({ payments: {} }) + + await service.confirmInvoice(makeCompletedInvoice()) + + expect(userRepository.admitUser).not.to.have.been.called + }) + + it('ignores disabled fee schedules when computing the admission amount', async () => { + settings.returns(makeSettings([{ enabled: false, amount: 1000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ + unit: InvoiceUnit.MSATS, + amountPaid: 5000n, + })) + + // disabled → admissionFeeAmount = 0 → condition false → user not admitted + expect(userRepository.admitUser).not.to.have.been.called + }) + + it('skips the fee for whitelisted pubkeys', async () => { + settings.returns(makeSettings([ + { enabled: true, amount: 1000n, whitelists: { pubkeys: ['whitelisted'] } }, + ])) + + await service.confirmInvoice(makeCompletedInvoice({ + pubkey: 'whitelistedpubkey', + unit: InvoiceUnit.MSATS, + amountPaid: 5000n, + })) + + // pubkey starts with 'whitelisted' → isApplicableFee = false → admissionFeeAmount = 0 → not admitted + expect(userRepository.admitUser).not.to.have.been.called + }) + + it('rolls back the transaction and re-throws on error', async () => { + settings.returns(makeSettings([])) + invoiceRepository.confirmInvoice.rejects(new Error('db error')) + + await expect(service.confirmInvoice(makeCompletedInvoice())).to.be.rejectedWith('db error') + + expect(mockTrx.rollback).to.have.been.calledOnce + expect(mockTrx.commit).not.to.have.been.called + }) + }) + + + describe('sendInvoiceUpdateNotification', () => { + beforeEach(() => { + settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) + }) + + it('throws when amountPaid is undefined', async () => { + await expect( + service.sendInvoiceUpdateNotification(stubInvoice({ amountPaid: undefined })) + ).to.be.rejectedWith('Unable to notify user') + }) + + it('converts MSATS to SATS in the notification content', async () => { + await service.sendInvoiceUpdateNotification(stubInvoice({ + unit: InvoiceUnit.MSATS, + amountPaid: 5000n, + })) + + const [unsignedEvent] = (eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args + expect(unsignedEvent.content).to.equal('Invoice paid: 5 sats') + }) + + it('keeps SATS amount and unit unchanged', async () => { + await service.sendInvoiceUpdateNotification(stubInvoice({ + unit: InvoiceUnit.SATS, + amountPaid: 100n, + })) + + const [unsignedEvent] = (eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args + expect(unsignedEvent.content).to.equal('Invoice paid: 100 sats') + }) + + it('persists and broadcasts the signed event', async () => { + await service.sendInvoiceUpdateNotification(stubInvoice({ + unit: InvoiceUnit.SATS, + amountPaid: 100n, + })) + + expect(eventRepository.create).to.have.been.calledOnce + expect(eventUtils.broadcastEvent as Sinon.SinonStub).to.have.been.calledOnce + }) + + it('calls logError and does not throw when the pipeline fails', async () => { + const consoleErrorStub = sandbox.stub(console, 'error') + ;(eventUtils.identifyEvent as Sinon.SinonStub).rejects(new Error('identify failed')) + + // otherwise() swallows the error — the method must resolve, not reject + await service.sendInvoiceUpdateNotification(stubInvoice({ amountPaid: 100n })) + + expect(consoleErrorStub).to.have.been.calledWith( + 'Unable to send notification', + Sinon.match.instanceOf(Error), + ) + expect(eventRepository.create).not.to.have.been.called + }) + }) +})