Files
podsteadr/server/src/routes/marketplace.test.ts
T
ssmithxandClaude Sonnet 5 e04d35b131 feat: recorded-episode pricing, Cashu-token payment, podcast editing
- Editable podcast settings: new /podcasts/:id/settings page, reusing
  PodcastForm.vue in an edit mode (PUT instead of POST) since it was
  previously create-only with no way to fix a field (e.g. lightning
  address) after the fact.
- Recorded episodes can now be priced same as uploads: "Publish
  recording" gained an optional price_sats field, wired through the
  existing episode paywall machinery. Live streams themselves stay
  unpaywalled by design — only the resulting recording can be priced.
- Accept Cashu tokens as an alternative to a Lightning invoice:
  POST .../purchase/token redeems a pasted token directly (via the
  mint's swap/receive flow) and finalizes the purchase in one step,
  no quote/confirm round trip. A token worth more than the price is
  treated as a tip (seller gets the full amount); worth less is
  rejected. Added a "pay with a Cashu token instead" option next to
  the existing invoice flow.
- cashu.ts: fixed payout() always requesting an invoice for the full
  held balance with no room for the mint's routing-fee reserve, which
  made a balance that exactly matched one sale's price permanently
  unwithdrawable (needed slightly more than held to cover the fee).
  Now shrinks the request and requotes once if the first quote doesn't
  fit.
- docker-compose.yml / mediamtx.yml: renamed the podsteadr container's
  DNS alias away from the literal string "podsteadr" — on a host whose
  own hostname is "podsteadr", cloud-init's self-hostname /etc/hosts
  entry shadowed the container-network alias, so mediamtx's auth
  webhook callback resolved to the wrong address and rejected every
  RTMP publish attempt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:18:36 +00:00

457 lines
17 KiB
TypeScript

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"`);
});
});