diff --git a/.changeset/nip43-invite-codes.md b/.changeset/nip43-invite-codes.md new file mode 100644 index 00000000..e5f9eb7c --- /dev/null +++ b/.changeset/nip43-invite-codes.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-43 invite code foundation: InviteCodeRepository with atomic claimCode, invite_codes migration, and event kind/tag constants. diff --git a/.knip.json b/.knip.json index eb3256db..001eba2e 100644 --- a/.knip.json +++ b/.knip.json @@ -14,7 +14,8 @@ "lzma-native" ], "ignore": [ - ".nostr/**" + ".nostr/**", + "src/repositories/invite-code-repository.ts" ], "commitlint": false, "eslint": false, diff --git a/migrations/20260624_120000_create_nip43_invite_codes_table.js b/migrations/20260624_120000_create_nip43_invite_codes_table.js new file mode 100644 index 00000000..4891ce8d --- /dev/null +++ b/migrations/20260624_120000_create_nip43_invite_codes_table.js @@ -0,0 +1,28 @@ +exports.up = async function (knex) { + await knex.schema.createTable('invite_codes', (table) => { + table.string('code', 64).primary() + table.binary('created_by').nullable() + table.binary('claimed_by').nullable() + table.timestamp('expires_at', { useTz: true }).nullable() + table.integer('max_uses').notNullable().defaultTo(1) + table.integer('use_count').notNullable().defaultTo(0) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + }) + + await knex.raw( + 'ALTER TABLE invite_codes ADD CONSTRAINT chk_use_count_non_negative CHECK (use_count >= 0)' + ) + await knex.raw( + 'ALTER TABLE invite_codes ADD CONSTRAINT chk_max_uses_non_negative CHECK (max_uses >= 0)' + ) + + // partial index: only rows with an expiry set + await knex.raw( + 'CREATE INDEX idx_invite_codes_expires_at ON invite_codes(expires_at) WHERE expires_at IS NOT NULL' + ) +} + +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('invite_codes') +} diff --git a/src/@types/invite-code.ts b/src/@types/invite-code.ts new file mode 100644 index 00000000..2b4ebc0d --- /dev/null +++ b/src/@types/invite-code.ts @@ -0,0 +1,21 @@ +export interface InviteCode { + code: string + createdBy: string | null + claimedBy: string | null + expiresAt: Date | null + maxUses: number + useCount: number + createdAt: Date + updatedAt: Date +} + +export interface DBInviteCode { + code: string + created_by: Buffer | null + claimed_by: Buffer | null + expires_at: Date | null + max_uses: number + use_count: number + created_at: Date + updated_at: Date +} diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 743812a7..3a7018ba 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -4,6 +4,7 @@ import { DatabaseClient, EventId, Pubkey } from './base' import { DBEvent, Event } from './event' import { EventKinds } from '../constants/base' import { EventKindsRange } from './settings' +import { InviteCode } from './invite-code' import { Invoice } from './invoice' import { Nip05Verification } from './nip05' import { SubscriptionFilter } from './subscription' @@ -63,3 +64,12 @@ export interface INip05VerificationRepository { findPendingVerifications(updateFrequencyMs: number, maxFailures: number, limit: number): Promise deleteByPubkey(pubkey: Pubkey): Promise } + +export interface IInviteCodeRepository { + create(code: string, expiresAt?: Date, maxUses?: number): Promise + findByCode(code: string): Promise + claimCode(code: string, pubkey: Pubkey): Promise + findActiveCodes(limit?: number): Promise + deleteExpiredCodes(): Promise +} + diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 348efdae..a7d50ea8 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -285,6 +285,14 @@ export interface WoTSettings { refreshIntervalHours: number } +export interface Nip43Settings { + enabled: boolean + inviteCodeExpiry?: number + defaultMaxUses?: number + allowInviteRequests?: boolean + inviteRequestWhitelist?: Pubkey[] +} + export interface Settings { info: Info payments?: Payments @@ -294,6 +302,7 @@ export interface Settings { limits?: Limits mirroring?: Mirroring nip05?: Nip05Settings + nip43?: Nip43Settings nip45?: Nip45Settings wot?: WoTSettings } diff --git a/src/constants/base.ts b/src/constants/base.ts index d5b5f526..09935e8d 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -33,6 +33,9 @@ export enum EventKinds { // Relay-only RELAY_INVITE = 50, INVOICE_UPDATE = 402, + // NIP-43: Relay Access Metadata and Requests + NIP43_ADD_USER = 8000, + NIP43_REMOVE_USER = 8001, // Lightning zaps ZAP_REQUEST = 9734, ZAP_RECEIPT = 9735, @@ -42,11 +45,17 @@ export enum EventKinds { RELAY_LIST = 10002, // Marmot Protocol MIP-00: KeyPackage Relay List MARMOT_KEY_PACKAGE_RELAY_LIST = 10051, + // NIP-43: Membership List + NIP43_MEMBERSHIP_LIST = 13534, REPLACEABLE_LAST = 19999, // Ephemeral events EPHEMERAL_FIRST = 20000, // NIP-42: Client Authentication AUTH = 22242, + // NIP-43: Ephemeral access request kinds + NIP43_JOIN_REQUEST = 28934, + NIP43_INVITE_REQUEST = 28935, + NIP43_LEAVE_REQUEST = 28936, EPHEMERAL_LAST = 29999, // Parameterized replaceable events PARAMETERIZED_REPLACEABLE_FIRST = 30000, @@ -81,6 +90,9 @@ export enum EventTags { Group = 'h', // NIP-70: Protected Events Protected = '-', + // NIP-43: Relay Access Metadata + Member = 'member', + Claim = 'claim', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/repositories/invite-code-repository.ts b/src/repositories/invite-code-repository.ts new file mode 100644 index 00000000..c6865fca --- /dev/null +++ b/src/repositories/invite-code-repository.ts @@ -0,0 +1,149 @@ +import { randomBytes } from 'crypto' + +import { DatabaseClient, Pubkey } from '../@types/base' +import { DBInviteCode, InviteCode } from '../@types/invite-code' +import { IInviteCodeRepository } from '../@types/repositories' +import { createLogger } from '../factories/logger-factory' +import { toBuffer } from '../utils/transform' + +const logger = createLogger('invite-code-repository') + +export function generateInviteCode(): string { + return randomBytes(16).toString('hex') +} + +function fromDBInviteCode(row: DBInviteCode): InviteCode { + return { + code: row.code, + createdBy: row.created_by ? row.created_by.toString('hex') : null, + claimedBy: row.claimed_by ? row.claimed_by.toString('hex') : null, + expiresAt: row.expires_at, + maxUses: row.max_uses, + useCount: row.use_count, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function affectedRows(result: unknown): number { + if (typeof result === 'number') { return result } + if (result && typeof (result as any).rowCount === 'number') { return (result as any).rowCount } + return 0 +} + +export class InviteCodeRepository implements IInviteCodeRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async create( + code: string, + expiresAt?: Date, + maxUses: number = 1, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('create invite code: %s (expires: %s, maxUses: %d)', code, expiresAt ?? 'never', maxUses) + + const now = new Date() + const row: DBInviteCode = { + code, + created_by: null, + claimed_by: null, + expires_at: expiresAt ?? null, + max_uses: maxUses, + use_count: 0, + created_at: now, + updated_at: now, + } + + await client('invite_codes').insert(row) + + return fromDBInviteCode(row) + } + + public async findByCode( + code: string, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('find invite code: %s', code) + + const [row] = await client('invite_codes') + .where('code', code) + .select() + + if (!row) { + return + } + + return fromDBInviteCode(row) + } + + // Atomic claim: single UPDATE ensures only one caller wins on a single-use code + public async claimCode( + code: string, + pubkey: Pubkey, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('claim invite code %s for %s', code, pubkey) + + const now = new Date() + + const result = await client('invite_codes') + .where('code', code) + .where(function () { + this.where('max_uses', 0) // 0 = unlimited uses + .orWhereRaw('use_count < max_uses') + }) + .where(function () { + this.whereNull('expires_at') + .orWhere('expires_at', '>', now) + }) + .update({ + use_count: client.raw('use_count + 1'), + claimed_by: toBuffer(pubkey), + updated_at: now, + } as any) + + return affectedRows(result) > 0 + } + + public async findActiveCodes( + limit: number = 100, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('find active invite codes (limit %d)', limit) + + const now = new Date() + + const rows = await client('invite_codes') + .where(function () { + this.whereNull('expires_at') + .orWhere('expires_at', '>', now) + }) + .where(function () { + this.where('max_uses', 0) + .orWhereRaw('use_count < max_uses') + }) + .orderBy('created_at', 'desc') + .limit(limit) + .select() + + return rows.map(fromDBInviteCode) + } + + public async deleteExpiredCodes( + client: DatabaseClient = this.dbClient, + ): Promise { + logger('delete expired invite codes') + + const now = new Date() + + const result = await client('invite_codes') + .whereNotNull('expires_at') + .where('expires_at', '<=', now) + .delete() + + const count = affectedRows(result) + logger('deleted %d expired invite codes', count) + + return count + } +} diff --git a/test/unit/repositories/invite-code-repository.spec.ts b/test/unit/repositories/invite-code-repository.spec.ts new file mode 100644 index 00000000..eff3ec91 --- /dev/null +++ b/test/unit/repositories/invite-code-repository.spec.ts @@ -0,0 +1,394 @@ +import * as chai from 'chai' +import * as sinon from 'sinon' +import knex from 'knex' +import sinonChai from 'sinon-chai' +import chaiAsPromised from 'chai-as-promised' + +import { DatabaseClient } from '../../../src/@types/base' +import { generateInviteCode, InviteCodeRepository } from '../../../src/repositories/invite-code-repository' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +describe('InviteCodeRepository', () => { + let repository: InviteCodeRepository + let sandbox: sinon.SinonSandbox + let dbClient: DatabaseClient + + const fixedDate = new Date('2026-06-24T00:00:00.000Z') + const testCode = 'abc123deadbeef4567890000cafebabe' + const pubkeyHex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + + const dbInviteCodeRow = { + code: testCode, + created_by: null as Buffer | null, + claimed_by: null as Buffer | null, + expires_at: null as Date | null, + max_uses: 1, + use_count: 0, + created_at: fixedDate, + updated_at: fixedDate, + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + sandbox.useFakeTimers(fixedDate.getTime()) + dbClient = knex({ client: 'pg' }) + + repository = new InviteCodeRepository(dbClient) + }) + + afterEach(() => { + dbClient.destroy() + sandbox.restore() + }) + + describe('generateInviteCode', () => { + it('returns a 32-character hex string', () => { + const code = generateInviteCode() + expect(code).to.be.a('string') + expect(code).to.have.lengthOf(32) + expect(code).to.match(/^[0-9a-f]{32}$/) + }) + + it('generates unique codes on successive calls', () => { + const codes = new Set(Array.from({ length: 50 }, () => generateInviteCode())) + expect(codes.size).to.equal(50) + }) + }) + + describe('.create', () => { + it('inserts into the invite_codes table', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + await repository.create(testCode, undefined, 1, client) + + expect(client).to.have.been.calledWith('invite_codes') + }) + + it('returns an InviteCode object with correct defaults', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + const result = await repository.create(testCode, undefined, 1, client) + + expect(result).to.deep.include({ + code: testCode, + createdBy: null, + claimedBy: null, + expiresAt: null, + maxUses: 1, + useCount: 0, + }) + expect(result.createdAt).to.be.instanceOf(Date) + expect(result.updatedAt).to.be.instanceOf(Date) + }) + + it('passes the expires_at date when provided', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + const expiresAt = new Date('2026-07-01T00:00:00.000Z') + const result = await repository.create(testCode, expiresAt, 5, client) + + expect(result.expiresAt).to.deep.equal(expiresAt) + expect(result.maxUses).to.equal(5) + + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.expires_at).to.deep.equal(expiresAt) + expect(insertedRow.max_uses).to.equal(5) + }) + + it('sets expiresAt to null when omitted', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + const result = await repository.create(testCode, undefined, 1, client) + + expect(result.expiresAt).to.be.null + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.expires_at).to.be.null + }) + + it('defaults maxUses to 1', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + const result = await repository.create(testCode, undefined, undefined, client) + + expect(result.maxUses).to.equal(1) + }) + }) + + describe('.findByCode', () => { + it('returns undefined when no code is found', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([]) }), + }) as unknown as DatabaseClient + + const result = await repository.findByCode('nonexistent', client) + + expect(result).to.be.undefined + }) + + it('returns a transformed InviteCode when found', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([dbInviteCodeRow]) }), + }) as unknown as DatabaseClient + + const result = await repository.findByCode(testCode, client) + + expect(result).to.not.be.undefined + expect(result!.code).to.equal(testCode) + expect(result!.createdBy).to.be.null + expect(result!.claimedBy).to.be.null + expect(result!.maxUses).to.equal(1) + expect(result!.useCount).to.equal(0) + }) + + it('decodes created_by Buffer to hex string', async () => { + const row = { + ...dbInviteCodeRow, + created_by: Buffer.from(pubkeyHex, 'hex'), + } + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([row]) }), + }) as unknown as DatabaseClient + + const result = await repository.findByCode(testCode, client) + + expect(result!.createdBy).to.equal(pubkeyHex) + }) + + it('decodes claimed_by Buffer to hex string', async () => { + const row = { + ...dbInviteCodeRow, + claimed_by: Buffer.from(pubkeyHex, 'hex'), + } + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([row]) }), + }) as unknown as DatabaseClient + + const result = await repository.findByCode(testCode, client) + + expect(result!.claimedBy).to.equal(pubkeyHex) + }) + + it('queries the invite_codes table by code', async () => { + const whereStub = sandbox.stub().returns({ select: sandbox.stub().resolves([]) }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.findByCode(testCode, client) + + expect(client).to.have.been.calledWith('invite_codes') + const [field, value] = whereStub.firstCall.args + expect(field).to.equal('code') + expect(value).to.equal(testCode) + }) + }) + + describe('.claimCode', () => { + it('returns true when claim succeeds (rowCount > 0)', async () => { + const updateStub = sandbox.stub().resolves(1) + const whereStub3 = sandbox.stub().returns({ update: updateStub }) + const whereStub2 = sandbox.stub().returns({ where: whereStub3 }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + ;(client as any).raw = sandbox.stub().returnsArg(0) + + const result = await repository.claimCode(testCode, pubkeyHex, client) + + expect(result).to.be.true + }) + + it('returns false when claim fails (rowCount = 0)', async () => { + const updateStub = sandbox.stub().resolves(0) + const whereStub3 = sandbox.stub().returns({ update: updateStub }) + const whereStub2 = sandbox.stub().returns({ where: whereStub3 }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + ;(client as any).raw = sandbox.stub().returnsArg(0) + + const result = await repository.claimCode(testCode, pubkeyHex, client) + + expect(result).to.be.false + }) + + it('returns true when pg returns { rowCount } object', async () => { + const updateStub = sandbox.stub().resolves({ rowCount: 1 }) + const whereStub3 = sandbox.stub().returns({ update: updateStub }) + const whereStub2 = sandbox.stub().returns({ where: whereStub3 }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + ;(client as any).raw = sandbox.stub().returnsArg(0) + + const result = await repository.claimCode(testCode, pubkeyHex, client) + + expect(result).to.be.true + }) + + it('returns false when pg returns { rowCount: 0 }', async () => { + const updateStub = sandbox.stub().resolves({ rowCount: 0 }) + const whereStub3 = sandbox.stub().returns({ update: updateStub }) + const whereStub2 = sandbox.stub().returns({ where: whereStub3 }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + ;(client as any).raw = sandbox.stub().returnsArg(0) + + const result = await repository.claimCode(testCode, pubkeyHex, client) + + expect(result).to.be.false + }) + + it('queries the invite_codes table', async () => { + const updateStub = sandbox.stub().resolves(0) + const whereStub3 = sandbox.stub().returns({ update: updateStub }) + const whereStub2 = sandbox.stub().returns({ where: whereStub3 }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + ;(client as any).raw = sandbox.stub().returnsArg(0) + + await repository.claimCode(testCode, pubkeyHex, client) + + expect(client).to.have.been.calledWith('invite_codes') + }) + }) + + describe('.findActiveCodes', () => { + it('returns an empty array when no active codes exist', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereStub2 = sandbox.stub().returns({ orderBy: orderByStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + const result = await repository.findActiveCodes(10, client) + + expect(result).to.be.an('array').that.is.empty + }) + + it('returns transformed InviteCode objects', async () => { + const selectStub = sandbox.stub().resolves([dbInviteCodeRow]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereStub2 = sandbox.stub().returns({ orderBy: orderByStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + const result = await repository.findActiveCodes(10, client) + + expect(result).to.have.lengthOf(1) + expect(result[0].code).to.equal(testCode) + }) + + it('limits results to the requested count', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereStub2 = sandbox.stub().returns({ orderBy: orderByStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + await repository.findActiveCodes(25, client) + + expect(limitStub).to.have.been.calledWith(25) + }) + + it('orders by created_at descending', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereStub2 = sandbox.stub().returns({ orderBy: orderByStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + await repository.findActiveCodes(10, client) + + expect(orderByStub).to.have.been.calledWith('created_at', 'desc') + }) + + it('defaults limit to 100', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereStub2 = sandbox.stub().returns({ orderBy: orderByStub }) + const whereStub1 = sandbox.stub().returns({ where: whereStub2 }) + const client = sandbox.stub().returns({ where: whereStub1 }) as unknown as DatabaseClient + + await repository.findActiveCodes(undefined, client) + + expect(limitStub).to.have.been.calledWith(100) + }) + }) + + describe('.deleteExpiredCodes', () => { + it('returns 0 when no expired codes exist', async () => { + const deleteStub = sandbox.stub().resolves(0) + const whereStub = sandbox.stub().returns({ delete: deleteStub }) + const whereNotNullStub = sandbox.stub().returns({ where: whereStub }) + const client = sandbox.stub().returns({ whereNotNull: whereNotNullStub }) as unknown as DatabaseClient + + const result = await repository.deleteExpiredCodes(client) + + expect(result).to.equal(0) + }) + + it('returns the count of deleted codes', async () => { + const deleteStub = sandbox.stub().resolves(3) + const whereStub = sandbox.stub().returns({ delete: deleteStub }) + const whereNotNullStub = sandbox.stub().returns({ where: whereStub }) + const client = sandbox.stub().returns({ whereNotNull: whereNotNullStub }) as unknown as DatabaseClient + + const result = await repository.deleteExpiredCodes(client) + + expect(result).to.equal(3) + }) + + it('handles pg { rowCount } response format', async () => { + const deleteStub = sandbox.stub().resolves({ rowCount: 5 }) + const whereStub = sandbox.stub().returns({ delete: deleteStub }) + const whereNotNullStub = sandbox.stub().returns({ where: whereStub }) + const client = sandbox.stub().returns({ whereNotNull: whereNotNullStub }) as unknown as DatabaseClient + + const result = await repository.deleteExpiredCodes(client) + + expect(result).to.equal(5) + }) + + it('queries the invite_codes table', async () => { + const deleteStub = sandbox.stub().resolves(0) + const whereStub = sandbox.stub().returns({ delete: deleteStub }) + const whereNotNullStub = sandbox.stub().returns({ where: whereStub }) + const client = sandbox.stub().returns({ whereNotNull: whereNotNullStub }) as unknown as DatabaseClient + + await repository.deleteExpiredCodes(client) + + expect(client).to.have.been.calledWith('invite_codes') + }) + + it('filters for non-null expires_at', async () => { + const deleteStub = sandbox.stub().resolves(0) + const whereStub = sandbox.stub().returns({ delete: deleteStub }) + const whereNotNullStub = sandbox.stub().returns({ where: whereStub }) + const client = sandbox.stub().returns({ whereNotNull: whereNotNullStub }) as unknown as DatabaseClient + + await repository.deleteExpiredCodes(client) + + expect(whereNotNullStub).to.have.been.calledWith('expires_at') + }) + }) +})