import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); interface SeedOptions { state?: string; aiName?: string | null; priceSource?: string; } /** * A submission as the intake route leaves it: a pending item priced at the * migration's 8000 default, a draft beside it, and one photo. */ async function seedDraft(overrides: SeedOptions = {}): Promise { const { rows } = await pool.query<{ id: number }>( `INSERT INTO items (name, status) VALUES ('Submission 2026-09-01', 'pending') RETURNING id` ); const itemId = rows[0]!.id; await pool.query( `INSERT INTO item_drafts (item_id, submitter_note, state, ai_name, ai_description, price_source) VALUES ($1, 'found in a loft', $2, $3, 'A blue vase.', $4)`, [ itemId, overrides.state ?? 'ready', overrides.aiName === undefined ? 'Blue vase' : overrides.aiName, overrides.priceSource ?? 'ai' ] ); await pool.query( `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0)`, [itemId] ); return itemId; } const itemIds = (body: { drafts: { item_id: number }[] }): number[] => body.drafts.map((draft) => draft.item_id); describe('GET /api/admin/item-drafts', () => { it('returns the draft with its item, photos and note', async () => { const itemId = await seedDraft(); const res = await request(app).get('/api/admin/item-drafts'); expect(res.status).toBe(200); const draft = res.body.drafts.find((d: { item_id: number }) => d.item_id === itemId); expect(draft).toBeDefined(); expect(draft.submitter_note).toBe('found in a loft'); expect(draft.ai_name).toBe('Blue vase'); expect(draft.price_cents).toBe(8000); expect(draft.price_source).toBe('ai'); expect(draft.images).toHaveLength(1); }); // The token digest lives on upload_links and must never be selected into a // response. Spelling the columns is what prevents that; this is the assertion // that keeps it spelled. it('never sends the upload link token digest', async () => { await seedDraft(); const res = await request(app).get('/api/admin/item-drafts'); expect(JSON.stringify(res.body)).not.toContain('token_hash'); }); it('filters by state', async () => { const ready = await seedDraft({ state: 'ready' }); const failed = await seedDraft({ state: 'failed' }); const res = await request(app).get('/api/admin/item-drafts?state=failed'); expect(itemIds(res.body)).toContain(failed); expect(itemIds(res.body)).not.toContain(ready); }); // Discarded is recoverable, so it has to be reachable — but it must not sit // in the default view competing with work that still needs doing. it('hides discarded drafts unless they are asked for', async () => { const discarded = await seedDraft({ state: 'discarded' }); const def = await request(app).get('/api/admin/item-drafts'); expect(itemIds(def.body)).not.toContain(discarded); const asked = await request(app).get('/api/admin/item-drafts?state=discarded'); expect(itemIds(asked.body)).toContain(discarded); }); /** * The gate is disabled when ADMIN_GATE_SECRET is unset, which is how the rest * of this suite runs, so it is set here for the length of this test alone. * Worth asserting: the gate goes on the mount in app.ts rather than inside the * router, and leaving it off a new mount is a silent hole. */ describe('with the admin gate configured', () => { const original = process.env.ADMIN_GATE_SECRET; beforeAll(() => { process.env.ADMIN_GATE_SECRET = 'integration-secret'; }); afterAll(() => { if (original === undefined) delete process.env.ADMIN_GATE_SECRET; else process.env.ADMIN_GATE_SECRET = original; }); it('refuses a request with no gate header', async () => { await seedDraft(); const res = await request(app).get('/api/admin/item-drafts'); expect(res.status).toBe(403); }); it('allows a request carrying the secret', async () => { await seedDraft(); const res = await request(app) .get('/api/admin/item-drafts') .set('X-Admin-Gate', 'integration-secret'); expect(res.status).toBe(200); }); }); });