Files
redefined-designs/backend/tests/integration/adminItemDrafts.integration.test.ts
T
bermudalambandClaude Opus 5 44a9037121 feat(intake): publish a reviewed item to the storefront (#225)
The only path from an intake submission to the storefront. It performs what mark-available performs — the status, and clearing the sale and reservation fields — rather than calling that route, because the copy and the publish have to be one transaction: an item published carrying the previous draft's name would be worse than one not published at all.

The price rule is applied here rather than trusted from the client. A changed number becomes the admin's; an unchanged one keeps whatever it was, so publishing without touching the field records that nobody chose it. The row is locked for the transaction so two admins publishing the same submission cannot interleave one's price decision with another's name.

Whole cents only. A fractional value would round somewhere nobody is looking and put the item on sale at a price no one entered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:11:51 -05:00

221 lines
7.4 KiB
TypeScript

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<number> {
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);
});
});