/** * A mint that is REFUSED must not cost the guest the download they already have running. * * `/export/ticket` issues the ticket before charging the daily limit, deliberately: charging first * meant a store-capacity 503 — a server-side condition the guest cannot see or cause — still spent * one of their three daily downloads, with no refund path. * * But the ticket a refused mint created was left in the store, and it occupies a slot there for six * hours, because a download ticket is long-lived so a multi-GB transfer can resume with `Range`. * The per-session cap is four tickets of the same kind, so: * * the 1.4 GB transfer starts on ticket A → the progress bar looks stuck on venue wifi → the guest * taps "Herunterladen" again → mints 2 and 3 succeed, 4 and 5 are refused with 429 but STILL mint * → the fifth evicts the oldest download ticket for the session, which is A * → the transfer drops, resumes with `Range`, and 401s * → re-minting is impossible: they are at the daily limit * * The keepsake is then unreachable until the next day, for tapping a button that appeared to do * nothing. A refused mint now discards its own ticket. */ import { test, expect } from '../../fixtures/test'; import { seedUpload } from '../../helpers/seed'; import { BASE } from '../../helpers/env'; test.describe('Export — a refused mint does not evict a running download', () => { test('the first ticket still works after the daily limit has refused later mints', async ({ host, api, adminToken, }) => { test.setTimeout(60_000); const bearer = { Authorization: `Bearer ${host.jwt}` }; await seedUpload(host.jwt, { caption: 'keepsake' }); const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer, }); expect(rel.status).toBe(204); await expect .poll( async () => { const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer }); return (await res.json()).zip?.status; }, { timeout: 45_000, intervals: [500] } ) .toBe('done'); // The e2e reseed forces every limiter OFF, so without this the daily limit never bites and the // whole test passes vacuously. `export_rate_per_day` is seeded at 3 (migration 005). await api.patchConfig(adminToken, { rate_limits_enabled: 'true', export_rate_enabled: 'true', }); try { const mint = async () => fetch(`${BASE}/api/v1/export/ticket?kind=zip`, { method: 'POST', headers: bearer }); // 1. The ticket the "running" transfer holds. const first = await mint(); expect(first.status).toBe(200); const ticketA = (await first.json()).ticket as string; expect(ticketA).toBeTruthy(); // 2. Two more legitimate mints, exhausting the day's three. for (const i of [2, 3]) { const r = await mint(); expect(r.status, `mint ${i} is still within the daily allowance`).toBe(200); } // 3. Two refused mints — the impatient taps. This is the CONTROL: if these came back 200 the // limit was not in force and step 4 would prove nothing. for (const i of [4, 5]) { const r = await mint(); expect(r.status, `mint ${i} must be refused — the daily limit is spent`).toBe(429); } // 4. The running transfer resumes. Its ticket must have survived the refused mints: it is the // guest's only remaining way to reach the keepsake today. const resumed = await fetch( `${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticketA)}` ); expect( resumed.status, 'a refused mint must not evict the ticket an in-flight download is holding' ).toBe(200); await resumed.arrayBuffer(); } finally { await api.patchConfig(adminToken, { rate_limits_enabled: 'false', export_rate_enabled: 'false', }); } }); });