Files
podsteadr/server/src/routes/marketplace.test.ts
T

457 lines
17 KiB
TypeScript
Raw Normal View History

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-<sats>" — 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<string> {
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(`<podsteadr:source type="producer" pubkey="${producerPk}" price="500"`);
expect(xml).toContain(`<podsteadr:source type="reseller" pubkey="${buyerPk}" price="300"`);
// the reseller's own mirror url must appear too — the whole point is it's discoverable
expect(xml).toContain('https://mirror.example.com');
});
it('lists this podcast in the instance-wide OPML catalog', async () => {
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('<opml version="2.0">');
expect(res.body).toContain(`xmlUrl="http://localhost:8095/feeds/${podcastId}/feed.xml"`);
});
});