2026-07-29 12:43:46 +00:00
|
|
|
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) {
|
2026-07-30 23:18:36 +00:00
|
|
|
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' };
|
2026-07-29 12:43:46 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
class CashuClientPool {
|
|
|
|
|
client() {
|
|
|
|
|
return new FakeCashuMintClient();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
class CashuUnreachableError extends Error {}
|
|
|
|
|
class CashuInsufficientFundsError extends Error {}
|
2026-07-30 23:18:36 +00:00
|
|
|
class CashuTokenInvalidError extends Error {}
|
|
|
|
|
return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError, CashuTokenInvalidError };
|
2026-07-29 12:43:46 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-30 23:18:36 +00:00
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-29 12:43:46 +00:00
|
|
|
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"`);
|
|
|
|
|
});
|
|
|
|
|
});
|