/** * Acceptance criterion #1, as a runnable check. * * pnpm --filter ./api test:offline * * A mint whose /v1/info is unreachable must still serve its full detail payload from * cached metadata. This is the single most important fix over the old site, so it gets * a test that actually kills a mint rather than a unit test of a helper. * * Runs against a throwaway copy of the seeded database, so the real one is untouched. * The copy is made with the migrator, which means the check works whichever backend * holds the real data — and, as a side effect, exercises the migrator on every run. * * Set CHECK_DB_URL to run the copy on Postgres instead of a temporary SQLite file: * * CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offline */ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { parseDbTarget, resolveDbConfig } from './config.ts'; import { TABLES } from './db-schema.ts'; import { openDb } from './db.ts'; import { migrate } from './migrate.ts'; const source = resolveDbConfig(); if (source.dialect === 'sqlite' && !fs.existsSync(source.file)) { console.error(`No seeded database at ${source.file}. Run "pnpm seed" first.`); process.exit(1); } const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cashumints-offline-')); const target = parseDbTarget(process.env['CHECK_DB_URL'] ?? path.join(scratch, 'test.db')); // A reused Postgres test database still holds the last run's rows, and a stale mint row // would be picked as the victim. Start empty either way. const wipe = await openDb(target); for (const table of TABLES) await wipe.run(`DELETE FROM ${table}`); await wipe.close(); await migrate({ from: source, to: target, force: true, dryRun: false }); // Point the whole process at the copy before anything else opens a database. process.env['DATABASE_URL'] = target.dialect === 'postgres' ? target.url : `sqlite:${target.file}`; delete process.env['DB_PATH']; const { getDb, closeDb } = await import('./db.ts'); const { probeMint } = await import('./probe.ts'); const { getMintDetail, listMints, resetStatsCache } = await import('./queries.ts'); const { mintByUrl } = await import('./mints.ts'); const db = await getDb(); assert.equal(db.label, target.label, 'the check must not be talking to the real database'); // Pick a mint that is online and has real cached metadata to lose. const victim = await db.get<{ url: string; host: string; name: string }>( `SELECT url, host, name FROM mints WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL ORDER BY LENGTH(info_json) DESC LIMIT 1`, ); assert.ok(victim, 'seeded database has no online mint with cached metadata'); console.log(`victim: ${victim.name} (${victim.host})`); const before = await getMintDetail(victim.host); assert.ok(before, 'detail must exist before the mint dies'); assert.equal(before.status, 'online'); assert.ok(before.info, 'must have cached /v1/info before'); assert.ok(before.nuts.length > 0, 'must have parsed NUTs before'); // Rug it: same row, dead URL. This is the "point a mint row at a dead URL" test. const DEAD = 'https://this-mint-is-gone.invalid'; await db.run('UPDATE mints SET url = ? WHERE host = ?', DEAD, victim.host); await db.run('UPDATE reviews SET mint_url = ? WHERE mint_url = ?', DEAD, victim.url); // Three failures is the offline threshold. for (let i = 0; i < 3; i++) { const row = await mintByUrl(DEAD); assert.ok(row, 'row must survive the URL change'); const result = await probeMint(row); assert.equal(result.ok, false, 'a dead URL must not probe ok'); } resetStatsCache(); const after = await getMintDetail(victim.host); assert.ok(after, 'the mint page must still resolve when the mint is dead'); assert.equal(after.status, 'offline', 'three failures means offline'); // The whole point: none of the cached metadata was touched by the failed probes. assert.equal(after.name, before.name, 'name survives'); assert.equal(after.description, before.description, 'description survives'); assert.equal(after.version, before.version, 'version survives'); assert.equal(after.pubkey, before.pubkey, 'pubkey survives'); assert.deepEqual(after.nuts, before.nuts, 'supported NUTs survive'); assert.deepEqual(after.info, before.info, 'full cached /v1/info survives'); assert.equal(after.icon, before.icon, 'cached icon survives'); // Reviews must still be readable, so the mint stays reviewable. assert.equal(after.review_count, before.review_count, 'reviews survive'); assert.deepEqual(after.rating_distribution, before.rating_distribution, 'distribution survives'); // The page needs a date for the "Offline since {date}" banner. assert.ok(after.last_online !== null, 'last_online must be set so the banner has a date'); assert.ok(after.last_online <= Math.floor(Date.now() / 1000)); // And it must sink below every online mint without disappearing. const list = await listMints(); const index = list.findIndex((m) => m.host === victim.host); assert.ok(index >= 0, 'an offline mint must stay listed so people can find and review it'); const lastOnline = list.map((m) => m.status).lastIndexOf('online'); assert.ok(index > lastOnline, 'offline mints rank below every online mint'); await closeDb(); fs.rmSync(scratch, { recursive: true, force: true }); console.log( `ok on ${db.dialect}, offline mint still serves ${after.nuts.length} NUTs, ` + `${after.review_count} reviews, and full cached metadata ` + `(offline since ${new Date(after.last_online * 1000).toISOString()})`, );