Skip to content
Closed
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
1 change: 1 addition & 0 deletions .mocharc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
spec: 'test/**/*.spec.ts',
extension: ['ts'],
require: ['ts-node/register'],
reporter: 'mochawesome',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"db:migrate:rollback": "knex migrate:rollback",
"db:seed": "knex seed:run",
"pretest:unit": "node -e \"require('fs').mkdirSync('.test-reports/unit', {recursive: true})\"",
"test:unit": "mocha 'test/**/*.spec.ts'",
"test:unit": "mocha",
"test:unit:watch": "npm run test:unit -- --min --watch --watch-files src/**/*,test/**/*",
"cover:unit": "nyc --report-dir .coverage/unit npm run test:unit",
"docker:build": "docker build -t nostream .",
Expand Down
259 changes: 259 additions & 0 deletions test/unit/repositories/invoice-repository.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import * as chai from 'chai'
import * as sinon from 'sinon'
import knex from 'knex'
import sinonChai from 'sinon-chai'

import { DatabaseClient } from '../../../src/@types/base'
import { DBInvoice, Invoice, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice'
import { IInvoiceRepository } from '../../../src/@types/repositories'
import { InvoiceRepository } from '../../../src/repositories/invoice-repository'

chai.use(sinonChai)
const { expect } = chai

const PUBKEY = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793'

function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
const now = new Date()
return {
id: 'test-invoice-id',
pubkey: PUBKEY,
bolt11: 'lnbctest',
amountRequested: 1000n,
unit: InvoiceUnit.MSATS,
status: InvoiceStatus.PENDING,
description: 'test invoice',
expiresAt: null,
updatedAt: now,
createdAt: now,
...overrides,
}
}

function makeDBInvoice(overrides: Partial<DBInvoice> = {}): DBInvoice {
const now = new Date()
return {
id: 'test-invoice-id',
pubkey: Buffer.from(PUBKEY, 'hex'),
bolt11: 'lnbctest',
amount_requested: 1000n,
amount_paid: null as any,
unit: InvoiceUnit.MSATS,
status: InvoiceStatus.PENDING,
description: 'test invoice',
confirmed_at: null as any,
expires_at: null as any,
Comment on lines +41 to +45
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makeDBInvoice uses null as any for nullable-looking DB fields (amount_paid, confirmed_at, expires_at). This weakens type-safety and likely means DBInvoice should model these as nullable (e.g., bigint | null, Date | null) so the test data can avoid any casts.

Copilot uses AI. Check for mistakes.
updated_at: now,
created_at: now,
verify_url: '',
...overrides,
}
}

describe('InvoiceRepository', () => {
let repository: IInvoiceRepository
let sandbox: sinon.SinonSandbox
let dbClient: DatabaseClient

beforeEach(() => {
sandbox = sinon.createSandbox()
dbClient = knex({ client: 'pg' })
repository = new InvoiceRepository(dbClient)
})

afterEach(() => {
dbClient.destroy()
sandbox.restore()
})

describe('.updateStatus', () => {
it('returns a thenable with then, catch, and toString', () => {
const result = repository.updateStatus(makeInvoice())

expect(result).to.have.property('then')
expect(result).to.have.property('catch')
expect(result).to.have.property('toString')
})

it('toString generates UPDATE query targeting the invoice id', () => {
const sql = repository.updateStatus(makeInvoice({ id: 'inv-123', status: InvoiceStatus.COMPLETED })).toString()

expect(sql).to.include('"invoices"')
expect(sql).to.include("'completed'")
expect(sql).to.include("'inv-123'")
expect(sql).to.include('returning')
})
})

describe('.upsert', () => {
it('returns a thenable with then, catch, and toString', () => {
const result = repository.upsert(makeInvoice())

expect(result).to.have.property('then')
expect(result).to.have.property('catch')
expect(result).to.have.property('toString')
})

it('uses the existing id when invoice has a string id', () => {
const sql = repository.upsert(makeInvoice({ id: 'my-specific-id' })).toString()

expect(sql).to.include("'my-specific-id'")
})

it('generates a UUID when invoice has no id', () => {
const invoice = makeInvoice()
delete (invoice as any).id

const sql = repository.upsert(invoice).toString()
Comment on lines +103 to +107
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test deletes invoice.id via delete (invoice as any).id, which bypasses the Invoice type (where id is required) to reach a code path. If UUID generation is intended public behavior, consider making id optional in the input type/signature; otherwise, prefer a dedicated input type or explicit cast at the call site rather than mutating a typed object.

Copilot uses AI. Check for mistakes.

expect(sql).to.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)
})

it('toString contains INSERT … on conflict merge for "invoices"', () => {
const sql = repository.upsert(makeInvoice()).toString()

expect(sql).to.include('"invoices"')
expect(sql).to.include('on conflict')
expect(sql).to.include("'1000'")
})
})

describe('.findById', () => {
it('returns undefined when invoice not found', async () => {
const mockSelect = sandbox.stub().resolves([])
const mockWhere = sandbox.stub().returns({ select: mockSelect })
const mockClient = sandbox.stub().returns({ where: mockWhere }) as unknown as DatabaseClient

const repo = new InvoiceRepository(mockClient)
const result = await repo.findById('nonexistent-id')

expect(result).to.be.undefined
expect(mockWhere).to.have.been.calledWith('id', 'nonexistent-id')
})

it('returns mapped Invoice when found', async () => {
const dbRow = makeDBInvoice({ id: 'found-id', amount_requested: 2500n })
const mockSelect = sandbox.stub().resolves([dbRow])
const mockWhere = sandbox.stub().returns({ select: mockSelect })
const mockClient = sandbox.stub().returns({ where: mockWhere }) as unknown as DatabaseClient

const repo = new InvoiceRepository(mockClient)
const result = await repo.findById('found-id')

expect(result).to.not.be.undefined
expect(result!.id).to.equal('found-id')
expect(result!.pubkey).to.equal(PUBKEY)
expect(result!.amountRequested).to.equal(2500n)
})

it('maps amountPaid when present', async () => {
const dbRow = makeDBInvoice({ amount_paid: 999n })
const mockSelect = sandbox.stub().resolves([dbRow])
const mockWhere = sandbox.stub().returns({ select: mockSelect })
const mockClient = sandbox.stub().returns({ where: mockWhere }) as unknown as DatabaseClient

const repo = new InvoiceRepository(mockClient)
const result = await repo.findById('test-invoice-id')

expect(result!.amountPaid).to.equal(999n)
})
})

describe('.findPendingInvoices', () => {
it('returns mapped invoices with default offset=0 and limit=10', async () => {
const dbRow = makeDBInvoice({ id: 'pending-id' })
const mockSelect = sandbox.stub().resolves([dbRow])
const mockLimit = sandbox.stub().returns({ select: mockSelect })
const mockOffset = sandbox.stub().returns({ limit: mockLimit })
const mockWhere = sandbox.stub().returns({ offset: mockOffset })
const mockClient = sandbox.stub().returns({ where: mockWhere }) as unknown as DatabaseClient

const repo = new InvoiceRepository(mockClient)
const results = await repo.findPendingInvoices()

expect(results).to.have.length(1)
expect(results[0].id).to.equal('pending-id')
expect(mockWhere).to.have.been.calledWith('status', InvoiceStatus.PENDING)
expect(mockOffset).to.have.been.calledWith(0)
expect(mockLimit).to.have.been.calledWith(10)
})

it('forwards provided offset and limit', async () => {
const mockSelect = sandbox.stub().resolves([])
const mockLimit = sandbox.stub().returns({ select: mockSelect })
const mockOffset = sandbox.stub().returns({ limit: mockLimit })
const mockWhere = sandbox.stub().returns({ offset: mockOffset })
const mockClient = sandbox.stub().returns({ where: mockWhere }) as unknown as DatabaseClient

const repo = new InvoiceRepository(mockClient)
await repo.findPendingInvoices(5, 20)

expect(mockOffset).to.have.been.calledWith(5)
expect(mockLimit).to.have.been.calledWith(20)
})

it('returns empty array when no pending invoices exist', async () => {
const mockSelect = sandbox.stub().resolves([])
const mockLimit = sandbox.stub().returns({ select: mockSelect })
const mockOffset = sandbox.stub().returns({ limit: mockLimit })
const mockWhere = sandbox.stub().returns({ offset: mockOffset })
const mockClient = sandbox.stub().returns({ where: mockWhere }) as unknown as DatabaseClient

const repo = new InvoiceRepository(mockClient)
const results = await repo.findPendingInvoices()

expect(results).to.deep.equal([])
})
})

describe('.confirmInvoice', () => {
it('calls client.raw with invoice id, stringified amount, and ISO date', async () => {
const rawStub = sandbox.stub().resolves()
const mockClient = { raw: rawStub } as unknown as DatabaseClient

const invoiceId = 'confirm-me'
const amount = 5000n
const confirmedAt = new Date('2024-01-15T10:00:00.000Z')

const repo = new InvoiceRepository(mockClient)
await repo.confirmInvoice(invoiceId, amount, confirmedAt)

expect(rawStub).to.have.been.calledOnceWithExactly('select confirm_invoice(?, ?, ?)', [
invoiceId,
'5000',
confirmedAt.toISOString(),
])
})

it('uses the injected client parameter over the default', async () => {
const defaultRaw = sandbox.stub().resolves()
const injectedRaw = sandbox.stub().resolves()
const defaultClient = { raw: defaultRaw } as unknown as DatabaseClient
const injectedClient = { raw: injectedRaw } as unknown as DatabaseClient

const repo = new InvoiceRepository(defaultClient)
await repo.confirmInvoice('id', 100n, new Date(), injectedClient)

expect(defaultRaw).to.not.have.been.called
expect(injectedRaw).to.have.been.calledOnce
})

it('re-throws when client.raw rejects', async () => {
const err = new Error('DB unavailable')
const rawStub = sandbox.stub().rejects(err)
const mockClient = { raw: rawStub } as unknown as DatabaseClient

const repo = new InvoiceRepository(mockClient)
let thrown: Error | undefined

try {
await repo.confirmInvoice('id', 100n, new Date())
} catch (e) {
thrown = e as Error
}

expect(thrown).to.not.be.undefined
expect(thrown!.message).to.equal('DB unavailable')
})
})
})
Loading
Loading