diff --git a/.mocharc.js b/.mocharc.js index 15255de4..1d68b53e 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -1,4 +1,5 @@ module.exports = { + spec: 'test/**/*.spec.ts', extension: ['ts'], require: ['ts-node/register'], reporter: 'mochawesome', diff --git a/package.json b/package.json index 33e61c2f..30c62366 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/test/unit/repositories/invoice-repository.spec.ts b/test/unit/repositories/invoice-repository.spec.ts new file mode 100644 index 00000000..9d9d5d23 --- /dev/null +++ b/test/unit/repositories/invoice-repository.spec.ts @@ -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 { + 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 { + 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, + 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() + + 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') + }) + }) +}) diff --git a/test/unit/repositories/user-repository.spec.ts b/test/unit/repositories/user-repository.spec.ts new file mode 100644 index 00000000..e2269500 --- /dev/null +++ b/test/unit/repositories/user-repository.spec.ts @@ -0,0 +1,283 @@ +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 { DBUser, User } from '../../../src/@types/user' +import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' +import { UserRepository } from '../../../src/repositories/user-repository' + +chai.use(sinonChai) +const { expect } = chai + +const PUBKEY = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + +function makeDBUser(overrides: Partial = {}): DBUser { + const now = new Date() + return { + pubkey: Buffer.from(PUBKEY, 'hex'), + is_admitted: true, + is_vanished: false, + balance: 0n, + created_at: now, + updated_at: now, + ...overrides, + } +} + +describe('UserRepository', () => { + let repository: UserRepository + let sandbox: sinon.SinonSandbox + let dbClient: DatabaseClient + let eventRepository: sinon.SinonStubbedInstance + + beforeEach(() => { + sandbox = sinon.createSandbox() + dbClient = knex({ client: 'pg' }) + eventRepository = { + hasActiveRequestToVanish: sandbox.stub().resolves(false), + create: sandbox.stub(), + createMany: sandbox.stub(), + upsert: sandbox.stub(), + upsertMany: sandbox.stub(), + findByFilters: sandbox.stub(), + deleteByPubkeyAndIds: sandbox.stub(), + deleteByPubkeyExceptKinds: sandbox.stub(), + deleteExpiredAndRetained: sandbox.stub(), + } as any + + repository = new UserRepository(dbClient, eventRepository as unknown as IEventRepository) + }) + + afterEach(() => { + dbClient.destroy() + sandbox.restore() + }) + + describe('.upsert', () => { + it('returns a thenable with then, catch, and toString', () => { + const result = repository.upsert({ pubkey: PUBKEY }) + + expect(result).to.have.property('then') + expect(result).to.have.property('catch') + expect(result).to.have.property('toString') + }) + + it('resolves to a number when the query succeeds', async () => { + const mockQuery = { + then: (fn: any) => Promise.resolve().then(() => fn({ rowCount: 1 })), + catch: () => {}, + toString: () => '', + } + const mergeStub = sandbox.stub().returns(mockQuery) + const onConflictStub = sandbox.stub().returns({ merge: mergeStub }) + const insertStub = sandbox.stub().returns({ onConflict: onConflictStub }) + const mockClient = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + const repo = new UserRepository(mockClient, eventRepository as unknown as IEventRepository) + const result = await repo.upsert({ pubkey: PUBKEY, isAdmitted: true, isVanished: false }) + + expect(result).to.equal(1) + }) + + it('defaults isAdmitted and isVanished to false when omitted', async () => { + const mockQuery = { + then: (fn: any) => Promise.resolve().then(() => fn({ rowCount: 1 })), + catch: () => {}, + toString: () => '', + } + const mergeStub = sandbox.stub().returns(mockQuery) + const onConflictStub = sandbox.stub().returns({ merge: mergeStub }) + const insertStub = sandbox.stub().returns({ onConflict: onConflictStub }) + const mockClient = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + const repo = new UserRepository(mockClient, eventRepository as unknown as IEventRepository) + await repo.upsert({ pubkey: PUBKEY }) + + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.is_admitted).to.equal(false) + expect(insertedRow.is_vanished).to.equal(false) + }) + }) + + describe('.setVanished', () => { + it('returns a thenable with then, catch, and toString', () => { + const result = repository.setVanished(PUBKEY, true) + + expect(result).to.have.property('then') + expect(result).to.have.property('catch') + expect(result).to.have.property('toString') + }) + + it('toString targets "users" with is_vanished and on conflict clause', () => { + const sql = repository.setVanished(PUBKEY, true).toString() + + expect(sql).to.include('"users"') + expect(sql).to.include('is_vanished') + expect(sql).to.include('on conflict') + }) + }) + + describe('.findByPubkey', () => { + it('returns undefined when user 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 UserRepository(mockClient, eventRepository as unknown as IEventRepository) + const result = await repo.findByPubkey(PUBKEY) + + expect(result).to.be.undefined + }) + + it('returns mapped User when found', async () => { + const dbUser = makeDBUser({ is_admitted: true, is_vanished: false, balance: 9000n }) + const mockSelect = sandbox.stub().resolves([dbUser]) + const mockWhere = sandbox.stub().returns({ select: mockSelect }) + const mockClient = sandbox.stub().returns({ where: mockWhere }) as unknown as DatabaseClient + + const repo = new UserRepository(mockClient, eventRepository as unknown as IEventRepository) + const result = await repo.findByPubkey(PUBKEY) + + expect(result).to.not.be.undefined + expect(result!.pubkey).to.equal(PUBKEY) + expect(result!.isAdmitted).to.equal(true) + expect(result!.isVanished).to.equal(false) + expect(result!.balance).to.equal(9000n) + }) + }) + + describe('.isVanished', () => { + it('returns isVanished=true from existing user without querying events', async () => { + const existingUser: User = { + pubkey: PUBKEY, + isAdmitted: true, + isVanished: true, + balance: 0n, + createdAt: new Date(), + updatedAt: new Date(), + } + sandbox.stub(repository, 'findByPubkey').resolves(existingUser) + + const result = await repository.isVanished(PUBKEY) + + expect(result).to.equal(true) + expect(eventRepository.hasActiveRequestToVanish).to.not.have.been.called + }) + + it('returns isVanished=false from existing user without querying events', async () => { + const existingUser: User = { + pubkey: PUBKEY, + isAdmitted: true, + isVanished: false, + balance: 0n, + createdAt: new Date(), + updatedAt: new Date(), + } + sandbox.stub(repository, 'findByPubkey').resolves(existingUser) + + const result = await repository.isVanished(PUBKEY) + + expect(result).to.equal(false) + expect(eventRepository.hasActiveRequestToVanish).to.not.have.been.called + }) + + it('falls back to event repo and upserts when no user row exists (vanished)', async () => { + sandbox.stub(repository, 'findByPubkey').resolves(undefined) + ;(eventRepository.hasActiveRequestToVanish as sinon.SinonStub).resolves(true) + const upsertVanishStub = sandbox.stub(repository as any, 'upsertVanishState').resolves(1) + + const result = await repository.isVanished(PUBKEY) + + expect(result).to.equal(true) + expect(eventRepository.hasActiveRequestToVanish).to.have.been.calledWith(PUBKEY) + expect(upsertVanishStub).to.have.been.calledWith(PUBKEY, true, sinon.match.any) + }) + + it('falls back to event repo and upserts when no user row exists (not vanished)', async () => { + sandbox.stub(repository, 'findByPubkey').resolves(undefined) + ;(eventRepository.hasActiveRequestToVanish as sinon.SinonStub).resolves(false) + const upsertVanishStub = sandbox.stub(repository as any, 'upsertVanishState').resolves(0) + + const result = await repository.isVanished(PUBKEY) + + expect(result).to.equal(false) + expect(upsertVanishStub).to.have.been.calledWith(PUBKEY, false, sinon.match.any) + }) + }) + + describe('.getBalanceByPubkey', () => { + it('returns 0n when no user row found', async () => { + const mockLimit = sandbox.stub().resolves([]) + const mockWhere = sandbox.stub().returns({ limit: mockLimit }) + const mockSelect = sandbox.stub().returns({ where: mockWhere }) + const mockClient = sandbox.stub().returns({ select: mockSelect }) as unknown as DatabaseClient + + const repo = new UserRepository(mockClient, eventRepository as unknown as IEventRepository) + const result = await repo.getBalanceByPubkey(PUBKEY) + + expect(result).to.equal(0n) + }) + + it('returns BigInt balance when user row found', async () => { + const mockLimit = sandbox.stub().resolves([{ balance: '7777' }]) + const mockWhere = sandbox.stub().returns({ limit: mockLimit }) + const mockSelect = sandbox.stub().returns({ where: mockWhere }) + const mockClient = sandbox.stub().returns({ select: mockSelect }) as unknown as DatabaseClient + + const repo = new UserRepository(mockClient, eventRepository as unknown as IEventRepository) + const result = await repo.getBalanceByPubkey(PUBKEY) + + expect(result).to.equal(7777n) + }) + }) + + describe('.admitUser', () => { + it('calls client.raw with pubkey buffer and ISO date string', async () => { + const rawStub = sandbox.stub().resolves() + const mockClient = { raw: rawStub } as unknown as DatabaseClient + + const admittedAt = new Date('2024-03-01T12:00:00.000Z') + const repo = new UserRepository(mockClient, eventRepository as unknown as IEventRepository) + await repo.admitUser(PUBKEY, admittedAt) + + expect(rawStub).to.have.been.calledOnce + const [sql, [pubkeyBuf, isoDate]] = rawStub.firstCall.args + expect(sql).to.equal('select admit_user(?, ?)') + expect(Buffer.isBuffer(pubkeyBuf)).to.equal(true) + expect(isoDate).to.equal(admittedAt.toISOString()) + }) + + it('uses injected client 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 UserRepository(defaultClient, eventRepository as unknown as IEventRepository) + await repo.admitUser(PUBKEY, 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('connection refused') + const rawStub = sandbox.stub().rejects(err) + const mockClient = { raw: rawStub } as unknown as DatabaseClient + + const repo = new UserRepository(mockClient, eventRepository as unknown as IEventRepository) + let thrown: Error | undefined + + try { + await repo.admitUser(PUBKEY, new Date()) + } catch (e) { + thrown = e as Error + } + + expect(thrown).to.not.be.undefined + expect(thrown!.message).to.equal('connection refused') + }) + }) +})