import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure'; import type { FastifyInstance } from 'fastify'; // The real CashuMintClient talks to a live mint over the network (NUT-04/05), which isn't // available in this test environment — fake it deterministically so the surrounding purchase // flow (DB writes, receipt signing, paywall gating) can be verified end to end. let nextQuote = 0; vi.mock('../services/cashu.js', () => { class FakeCashuMintClient { async requestMintQuote(amountSats: number) { return { quoteId: `fake-quote-${++nextQuote}`, invoice: `lnbc-fake-invoice-for-${amountSats}-sats` }; } async isQuotePaid() { return true; } async mintAndStore() { return 'fake-proof-row-id'; } async payout(lud16: string, amountSats: number) { return { preimage: `fake-preimage-for-${lud16}-${amountSats}`, paidSats: amountSats }; } // Fake tokens are just "fake-token-" — real ones would be decoded and swapped with // the mint; here the "amount" is however many sats the test wrote into the string. async receiveToken(token: string) { const match = /^fake-token-(\d+)$/.exec(token); if (!match) throw new CashuTokenInvalidError(`could not redeem token: malformed test token "${token}"`); return { amountSats: Number(match[1]), rowId: 'fake-proof-row-id' }; } } class CashuClientPool { client() { return new FakeCashuMintClient(); } } class CashuUnreachableError extends Error {} class CashuInsufficientFundsError extends Error {} class CashuTokenInvalidError extends Error {} return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError, CashuTokenInvalidError }; }); const { buildApp } = await import('../app.js'); const { loadConfig } = await import('../config.js'); const producerSk = generateSecretKey(); const producerPk = getPublicKey(producerSk); const buyerSk = generateSecretKey(); const buyerPk = getPublicKey(buyerSk); let app: FastifyInstance; let dataDir: string; let producerCookie: string; let buyerCookie: string; let podcastId: string; let episodeId: string; const sha = 'e'.repeat(64); function nip98Header(sk: Uint8Array, url: string, method: string): string { const event = finalizeEvent( { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]], }, sk, ); return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; } async function login(sk: Uint8Array): Promise { const res = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: nip98Header(sk, 'http://localhost:8095/api/auth/login', 'POST') }, }); expect(res.statusCode).toBe(200); return (res.headers['set-cookie'] as string).split(';')[0]; } beforeAll(async () => { dataDir = mkdtempSync(join(tmpdir(), 'podsteadr-marketplace-test-')); const config = loadConfig({ DATA_DIR: dataDir, PUBLIC_URL: 'http://localhost:8095', NOSTR_RELAYS: '', } as NodeJS.ProcessEnv); app = await buildApp({ config, dbPath: ':memory:', logger: false }); producerCookie = await login(producerSk); buyerCookie = await login(buyerSk); const podcastRes = await app.inject({ method: 'POST', url: '/api/podcasts', headers: { cookie: producerCookie }, payload: { title: 'Paid Show', lightning_address: 'producer@getalby.com' }, }); podcastId = podcastRes.json().id; vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '1000' } }))); const episodeRes = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes`, headers: { cookie: producerCookie }, payload: { title: 'Paid Ep', sha256: sha, size: 1000, mime: 'video/mp4', price_sats: 500 }, }); vi.unstubAllGlobals(); episodeId = episodeRes.json().id; expect(episodeRes.json().price_sats).toBe(500); }); afterAll(async () => { await app.close(); rmSync(dataDir, { recursive: true, force: true }); }); describe('paywall gating', () => { it('denies download-url before purchase', async () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`, headers: { cookie: buyerCookie }, }); expect(res.statusCode).toBe(402); expect(res.json().price_sats).toBe(500); }); it('always lets the producer through', async () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`, headers: { cookie: producerCookie }, }); expect(res.statusCode).toBe(200); expect(res.json().url).toContain(`${sha}.mp4`); }); }); describe('purchase flow', () => { let quoteId: string; it('rejects the producer buying their own episode', async () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, headers: { cookie: producerCookie }, payload: { source: 'producer' }, }); expect(res.statusCode).toBe(400); }); it('requests a mint quote for the episode price', async () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, headers: { cookie: buyerCookie }, payload: { source: 'producer' }, }); expect(res.statusCode).toBe(201); expect(res.json().amountSats).toBe(500); expect(res.json().invoice).toMatch(/^lnbc-fake-invoice-for-500-sats/); quoteId = res.json().quoteId; }); it('confirms the purchase and returns a valid signed receipt', async () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`, headers: { cookie: buyerCookie }, }); expect(res.statusCode).toBe(201); const purchase = res.json(); expect(purchase.buyer_pubkey).toBe(buyerPk); expect(purchase.seller_pubkey).toBe(producerPk); expect(purchase.amount_sats).toBe(500); expect(purchase.generation).toBe(0); const receipt = JSON.parse(purchase.receipt_json); expect(receipt.kind).toBe(30356); expect(verifyEvent(receipt)).toBe(true); expect(receipt.tags).toContainEqual(['x', sha]); expect(receipt.tags).toContainEqual(['p', buyerPk, '', 'buyer']); expect(receipt.tags).toContainEqual(['p', producerPk, '', 'seller']); }); it('re-confirming the same quote is idempotent', async () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`, headers: { cookie: buyerCookie }, }); expect(res.statusCode).toBe(200); }); it('rejects buying the same episode twice', async () => { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, headers: { cookie: buyerCookie }, payload: { source: 'producer' }, }); expect(res.statusCode).toBe(400); }); it('lets the buyer through download-url after purchase', async () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`, headers: { cookie: buyerCookie }, }); expect(res.statusCode).toBe(200); expect(res.json().url).toContain(`${sha}.mp4`); }); }); describe('paying with a Cashu token', () => { // Isolated podcast/episode/producer — purchases here would otherwise credit the shared // producerPk's earnings and break the later "earnings" describe block's exact-total assertions. let tokenProducerPk: string; let tokenPodcastId: string; let tokenEpisodeId: string; beforeAll(async () => { const sk = generateSecretKey(); tokenProducerPk = getPublicKey(sk); const cookie = await login(sk); const podcastRes = await app.inject({ method: 'POST', url: '/api/podcasts', headers: { cookie }, payload: { title: 'Token-Paid Show', lightning_address: 'tokenproducer@getalby.com' }, }); tokenPodcastId = podcastRes.json().id; vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '1000' } }))); const episodeRes = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes`, headers: { cookie }, payload: { title: 'Token-Paid Ep', sha256: 'f'.repeat(64), size: 1000, mime: 'video/mp4', price_sats: 500 }, }); vi.unstubAllGlobals(); tokenEpisodeId = episodeRes.json().id; }); it('redeems a token worth exactly the price and unlocks the episode', async () => { const sk = generateSecretKey(); const pk = getPublicKey(sk); const cookie = await login(sk); const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, headers: { cookie }, payload: { source: 'producer', token: 'fake-token-500' }, }); expect(res.statusCode).toBe(201); const purchase = res.json(); expect(purchase.buyer_pubkey).toBe(pk); expect(purchase.seller_pubkey).toBe(tokenProducerPk); expect(purchase.amount_sats).toBe(500); }); it('treats a token worth more than the price as a tip, crediting the full amount', async () => { const sk = generateSecretKey(); const cookie = await login(sk); const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, headers: { cookie }, payload: { source: 'producer', token: 'fake-token-600' }, }); expect(res.statusCode).toBe(201); expect(res.json().amount_sats).toBe(600); }); it('rejects a token worth less than the price', async () => { const sk = generateSecretKey(); const cookie = await login(sk); const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, headers: { cookie }, payload: { source: 'producer', token: 'fake-token-100' }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/worth 100 sats/); }); it('rejects a token it cannot redeem', async () => { const sk = generateSecretKey(); const cookie = await login(sk); const res = await app.inject({ method: 'POST', url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`, headers: { cookie }, payload: { source: 'producer', token: 'not-a-real-token' }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/could not redeem token/); }); }); describe('resale', () => { const resellerMirrorUrl = 'https://mirror.example.com'; let resellerQuoteId: string; let firstPurchaseId: string; it('refuses to certify a reseller who never purchased', async () => { const strangerSk = generateSecretKey(); const strangerCookie = await login(strangerSk); const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`, headers: { cookie: strangerCookie }, payload: { download_url: resellerMirrorUrl, price_sats: 300 }, }); expect(res.statusCode).toBe(403); }); it('certifies the original buyer as a reseller after verifying their mirror', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '1000' } }))); let res; try { res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`, headers: { cookie: buyerCookie }, payload: { download_url: resellerMirrorUrl, price_sats: 300 }, }); } finally { vi.unstubAllGlobals(); } expect(res!.statusCode).toBe(201); expect(res!.json().pubkey).toBe(buyerPk); expect(res!.json().price_sats).toBe(300); }); it('lists the producer and the certified reseller as sources', async () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/sources`, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.producer).toMatchObject({ pubkey: producerPk, price_sats: 500 }); expect(body.resellers).toHaveLength(1); expect(body.resellers[0]).toMatchObject({ pubkey: buyerPk, price_sats: 300, sales_count: 0 }); expect(body.resellers[0].url).toBe(`${resellerMirrorUrl}/${sha}.mp4`); }); it('a second listener buys from the reseller instead of the producer', async () => { const secondBuyerSk = generateSecretKey(); const secondBuyerCookie = await login(secondBuyerSk); const purchaseRes = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`, headers: { cookie: secondBuyerCookie }, payload: { source: buyerPk }, }); expect(purchaseRes.statusCode).toBe(201); expect(purchaseRes.json().amountSats).toBe(300); resellerQuoteId = purchaseRes.json().quoteId; const confirmRes = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${resellerQuoteId}/confirm`, headers: { cookie: secondBuyerCookie }, }); expect(confirmRes.statusCode).toBe(201); const purchase = confirmRes.json(); expect(purchase.seller_pubkey).toBe(buyerPk); expect(purchase.generation).toBe(1); expect(purchase.amount_sats).toBe(300); firstPurchaseId = purchase.id; const receipt = JSON.parse(purchase.receipt_json); expect(receipt.tags).toContainEqual(['p', buyerPk, '', 'seller']); expect(receipt.tags.some((t: string[]) => t[0] === 'e')).toBe(true); }); it('reflects the new sale in the reseller\'s reputation count', async () => { const res = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}/episodes/${episodeId}/sources`, }); expect(res.json().resellers[0].sales_count).toBe(1); expect(firstPurchaseId).toBeTruthy(); }); }); describe('earnings', () => { it('credits the producer for both the direct sale and their share of the resale', async () => { const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: producerCookie } }); expect(res.statusCode).toBe(200); // 500 sats direct sale + 50% of the 300-sat resale (default resale_producer_share_pct) expect(res.json().unwithdrawn_sats).toBe(500 + 150); }); it("credits the reseller for their share of the resale they made", async () => { const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie } }); expect(res.statusCode).toBe(200); expect(res.json().unwithdrawn_sats).toBe(150); }); it('refuses to withdraw without a lightning address on file', async () => { const res = await app.inject({ method: 'POST', url: '/api/earnings/withdraw', headers: { cookie: buyerCookie } }); expect(res.statusCode).toBe(400); }); it('withdraws to a provided lud16 and zeroes the balance', async () => { const res = await app.inject({ method: 'POST', url: '/api/earnings/withdraw', headers: { cookie: buyerCookie }, payload: { lud16: 'reseller@getalby.com' }, }); expect(res.statusCode).toBe(200); expect(res.json().paid_sats).toBe(150); expect(res.json().preimage).toContain('reseller@getalby.com'); const after = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie } }); expect(after.json().unwithdrawn_sats).toBe(0); }); it('remembers the lud16 for next time', async () => { const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: buyerCookie } }); expect(res.json().lud16).toBe('reseller@getalby.com'); }); }); describe('open catalog discovery (RSS + OPML)', () => { it('lists the producer and certified reseller as podsteadr:source tags in the RSS feed', async () => { const res = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` }); expect(res.statusCode).toBe(200); const xml = res.body; expect(xml).toContain('xmlns:podsteadr="https://podsteadr.dev/ns/1.0"'); expect(xml).toContain(` { const res = await app.inject({ method: 'GET', url: '/catalog.opml' }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('text/x-opml'); expect(res.body).toContain(''); expect(res.body).toContain(`xmlUrl="http://localhost:8095/feeds/${podcastId}/feed.xml"`); }); });