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); }); }); }); describe('POST /api/admin/item-drafts/:itemId/publish', () => { const body = { name: 'Blue stoneware vase', description: 'Chipped base.', priceCents: 9500 }; it('writes the edited copy onto the item and publishes it', async () => { const itemId = await seedDraft(); const res = await request(app).post(`/api/admin/item-drafts/${itemId}/publish`).send(body); expect(res.status).toBe(200); const { rows } = await pool.query( `SELECT name, description, price_cents, status FROM items WHERE id = $1`, [itemId] ); expect(rows[0]).toMatchObject({ name: 'Blue stoneware vase', description: 'Chipped base.', price_cents: 9500, status: 'available' }); }); // The transition priceSource.ts defines, asserted end to end: a changed // number is now the admin's responsibility. it('records an edited price as the admin choice', async () => { const itemId = await seedDraft(); await request(app).post(`/api/admin/item-drafts/${itemId}/publish`).send(body); const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [ itemId ]); expect(rows[0]?.price_source).toBe('admin'); }); // And the case that matters more: publishing without touching the number must // leave it recorded as unconfirmed rather than quietly claiming it was chosen. it('leaves an untouched price unconfirmed', async () => { const itemId = await seedDraft(); await request(app) .post(`/api/admin/item-drafts/${itemId}/publish`) .send({ ...body, priceCents: 8000 }); const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [ itemId ]); expect(rows[0]?.price_source).toBe('ai'); }); it('refuses a publish with no name, and leaves the item unpublished', async () => { const itemId = await seedDraft(); const res = await request(app) .post(`/api/admin/item-drafts/${itemId}/publish`) .send({ ...body, name: ' ' }); expect(res.status).toBe(400); const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); expect(rows[0]?.status).toBe('pending'); }); it('refuses a negative price', async () => { const itemId = await seedDraft(); const res = await request(app) .post(`/api/admin/item-drafts/${itemId}/publish`) .send({ ...body, priceCents: -1 }); expect(res.status).toBe(400); }); it('refuses a fractional price', async () => { const itemId = await seedDraft(); const res = await request(app) .post(`/api/admin/item-drafts/${itemId}/publish`) .send({ ...body, priceCents: 95.5 }); expect(res.status).toBe(400); }); it('404s for an item with no draft', async () => { const { rows } = await pool.query<{ id: number }>( `INSERT INTO items (name) VALUES ('ordinary item') RETURNING id` ); const res = await request(app) .post(`/api/admin/item-drafts/${rows[0]!.id}/publish`) .send(body); expect(res.status).toBe(404); }); });