feat: stage new items as pending until an admin publishes them (#90)
SonarQube Analysis / sonarqube (pull_request) Failing after 38m41s
Tests / lint (pull_request) Successful in 8m37s
Tests / backend-unit (pull_request) Successful in 1m22s
Tests / frontend-e2e (pull_request) Failing after 30m44s

An item used to be live on the storefront the instant it was created. Now it starts pending, and a customer sees it only once it is published.

The migration changes the column default and nothing else. Backfilling would un-publish the entire live catalogue, which is the one thing it must not do.

Hiding a pending item took four separate changes, not one, and that is the part worth knowing. The storefront's item routes had no status filter at all — sold items are listed and rendered with a Sold badge deliberately — so pending could not be expressed as one more optional filter. GET /api/items now carries an exclusion the caller cannot opt out of; GET /api/items/:id carries the same, because hiding an item from the list while still serving it by id would leave it reachable to anyone who kept a link; and GET /api/filters excludes pending from both aggregates it computes. That last one is the least obvious: a pending item would have inflated its tags' counts, so a customer would read "Rare (1)", filter by it, and be told nothing matches — and its price would have stretched the slider to a range no visible item occupies.

The tag count is computed over the joined items rather than filtered with a WHERE. A WHERE would have dropped the row for a tag whose only item is pending, and the tag would have vanished from the drawer instead of showing zero. There is a test for exactly that, because the first version of this query had that bug.

parseItemFilters is shared by the storefront and admin routes, so 'pending' parses on both. The public route refuses it explicitly rather than answering with an empty list, which would read as "no items match" instead of "you may not ask that". The storefront's URL reader is deliberately left not accepting it either, with a comment saying so, since a request guaranteed to fail is not worth constructing.

Publishing is the existing mark-available: same transition, same UPDATE, so the admin UI labels that button "Publish" when the item is pending rather than adding a second endpoint that does the same thing. Unpublish is new and is not symmetrical — it is refused for a reserved item, which someone is holding in their cart right now, and for a sold one, which is a record of something that happened rather than a draft. Both refusals name their reason, and the buttons are hidden in those states so the refusal is not how you find out.

Changing a column default has reach, and it surfaced eight test fixtures that silently depended on it. Each is now explicit about the status it wants rather than inheriting one — better practice regardless, and immune to the next default change. Two tests also used 'pending' as their example of an *unknown* status; both would have quietly become tautologies, so they now use one that is genuinely unknown.

Verified: 98 unit, 160 integration and 94 end-to-end passing, the last on a freshly created container. One earlier run showed a single failure in favorites.spec.ts; it passes in isolation and on a clean container, and is the cross-spec interference already recorded against the suite rather than anything from this change.

Refs #90
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 12:40:08 -05:00
co-authored by Claude Opus 5
parent adf480faf6
commit ecc2219fa5
24 changed files with 495 additions and 26 deletions
@@ -118,7 +118,9 @@ describe('GET /api/admin/items filtering', () => {
it('rejects an unknown status rather than returning everything', async () => {
await createItem('A', 1000);
const res = await request(app).get('/api/admin/items?status=pending');
// Not 'pending': that is a real status now, and deliberately valid on the
// admin route — filtering for staged items is the point of it.
const res = await request(app).get('/api/admin/items?status=archived');
expect(res.status).toBe(400);
});
@@ -20,7 +20,7 @@ async function registerAndGetAgent(email: string) {
describe('cart', () => {
it('adding an item to cart marks it reserved', async () => {
const { rows } = await pool.query(`INSERT INTO items (name, price_cents) VALUES ('Test Item', 1000) RETURNING id`);
const { rows } = await pool.query(`INSERT INTO items (name, price_cents, status) VALUES ('Test Item', 1000, 'available') RETURNING id`);
const itemId = rows[0].id;
const agent = await registerAndGetAgent('cart1@example.com');
@@ -36,7 +36,7 @@ describe('cart', () => {
});
it('refuses to add an item that is already reserved', async () => {
const { rows } = await pool.query(`INSERT INTO items (name, price_cents) VALUES ('Test Item', 1000) RETURNING id`);
const { rows } = await pool.query(`INSERT INTO items (name, price_cents, status) VALUES ('Test Item', 1000, 'available') RETURNING id`);
const itemId = rows[0].id;
const agentA = await registerAndGetAgent('cartA@example.com');
const agentB = await registerAndGetAgent('cartB@example.com');
@@ -47,7 +47,7 @@ describe('cart', () => {
});
it('removing an item from cart releases it back to available', async () => {
const { rows } = await pool.query(`INSERT INTO items (name, price_cents) VALUES ('Test Item', 1000) RETURNING id`);
const { rows } = await pool.query(`INSERT INTO items (name, price_cents, status) VALUES ('Test Item', 1000, 'available') RETURNING id`);
const itemId = rows[0].id;
const agent = await registerAndGetAgent('cart2@example.com');
@@ -74,7 +74,7 @@ describe('cart demo checkout', () => {
it('completes a multi-item cart purchase and marks all items sold', async () => {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents) VALUES ('Item A', 1000), ('Item B', 2000) RETURNING id`
`INSERT INTO items (name, price_cents, status) VALUES ('Item A', 1000, 'available'), ('Item B', 2000, 'available') RETURNING id`
);
const [itemA, itemB] = rows;
const agent = await registerAndGetAgent('checkout1@example.com');
@@ -100,7 +100,7 @@ describe('cart demo checkout', () => {
});
it('refuses checkout without a shipping address', async () => {
const { rows } = await pool.query(`INSERT INTO items (name, price_cents) VALUES ('Item A', 1000) RETURNING id`);
const { rows } = await pool.query(`INSERT INTO items (name, price_cents, status) VALUES ('Item A', 1000, 'available') RETURNING id`);
const agent = await registerAndGetAgent('checkout2@example.com');
await agent.post(`/api/cart/items/${rows[0].id}`);
@@ -30,8 +30,11 @@ async function createItem(
categoryId: number | null = null,
tagIds: number[] = []
): Promise<number> {
// status is explicit rather than left to the column default. These tests are
// about items a customer can see, and the default is 'pending' — an item is
// staged until an admin publishes it.
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, category_id) VALUES ($1, $2, $3) RETURNING id`,
`INSERT INTO items (name, price_cents, category_id, status) VALUES ($1, $2, $3, 'available') RETURNING id`,
[name, priceCents, categoryId]
);
const itemId = rows[0].id;
@@ -311,6 +314,11 @@ describe('admin item form', () => {
[create.body.id]
);
// Published first: this asserts against PUBLIC_ITEM_SELECT specifically,
// which is the shape the images-times-tags join bug would show up in, and
// a new item is pending and therefore not publicly fetchable.
await request(app).post(`/api/admin/items/${create.body.id}/mark-available`);
const res = await request(app).get(`/api/items/${create.body.id}`);
expect(res.body.images).toHaveLength(2);
expect(res.body.tags).toHaveLength(3);
@@ -24,7 +24,7 @@ async function register(email: string) {
async function createItem(name: string) {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents) VALUES ($1, 1000) RETURNING id`,
`INSERT INTO items (name, price_cents, status) VALUES ($1, 1000, 'available') RETURNING id`,
[name]
);
return rows[0].id as number;
@@ -30,7 +30,7 @@ async function register(email: string) {
async function createItem(name: string) {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents) VALUES ($1, 1000) RETURNING id`,
`INSERT INTO items (name, price_cents, status) VALUES ($1, 1000, 'available') RETURNING id`,
[name]
);
return rows[0].id as number;
@@ -0,0 +1,225 @@
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();
});
async function createTag(name: string): Promise<number> {
const res = await request(app).post('/api/admin/tags').send({ name });
expect(res.status).toBe(201);
return res.body.id;
}
// Direct insert so a test can put an item in a specific state without going
// through the transitions being tested.
async function insertItem(
name: string,
priceCents: number,
status: string,
tagIds: number[] = []
): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, status) VALUES ($1, $2, $3) RETURNING id`,
[name, priceCents, status]
);
const id = rows[0].id;
for (const tagId of tagIds) {
await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [id, tagId]);
}
return id;
}
describe('a new item is staged rather than published', () => {
it('arrives pending when created through the admin API', async () => {
const res = await request(app)
.post('/api/admin/items')
.field('name', 'Fresh')
.field('description', '')
.field('price', '25');
expect(res.status).toBe(200);
expect(res.body.status).toBe('pending');
});
});
// Each of these is a separate query, so fixing one proves nothing about the
// others. They are tested separately for that reason.
describe('a pending item does not reach the storefront', () => {
it('is absent from the catalogue listing', async () => {
await insertItem('Staged', 1000, 'pending');
await insertItem('Live', 2000, 'available');
const res = await request(app).get('/api/items');
expect(res.status).toBe(200);
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Live']);
});
// Hiding it from the list but serving it by id would leave it reachable to
// anyone who guessed the id or kept an old link.
it('is not fetchable by direct id', async () => {
const id = await insertItem('Staged', 1000, 'pending');
const res = await request(app).get(`/api/items/${id}`);
expect(res.status).toBe(404);
});
it('is still fetchable by id once published', async () => {
const id = await insertItem('Staged', 1000, 'pending');
await request(app).post(`/api/admin/items/${id}/mark-available`);
const res = await request(app).get(`/api/items/${id}`);
expect(res.status).toBe(200);
expect(res.body.name).toBe('Staged');
});
// A count including pending items would show a customer "Rare (1)", and
// filtering by it would then report that nothing matches.
it('is not counted in the filter drawer tag counts', async () => {
const tag = await createTag('rare');
await insertItem('Staged', 1000, 'pending', [tag]);
await insertItem('Live', 2000, 'available', [tag]);
const res = await request(app).get('/api/filters');
const rare = res.body.tags.find((t: { name: string }) => t.name === 'rare');
expect(rare.item_count).toBe(1);
});
// The tag must still be listed, at zero. Excluding pending items with a WHERE
// rather than in the count would drop the tag's row entirely and make the tag
// vanish from the drawer.
it('leaves a tag whose only item is pending listed with a count of zero', async () => {
const tag = await createTag('unreleased');
await insertItem('Staged', 1000, 'pending', [tag]);
const res = await request(app).get('/api/filters');
const unreleased = res.body.tags.find((t: { name: string }) => t.name === 'unreleased');
expect(unreleased).toBeDefined();
expect(unreleased.item_count).toBe(0);
});
// A staged item priced far outside the live range would stretch the slider to
// a range no visible item occupies.
it('does not stretch the price slider bounds', async () => {
await insertItem('Cheap live', 1000, 'available');
await insertItem('Dear live', 5000, 'available');
await insertItem('Absurd staged', 999999, 'pending');
const res = await request(app).get('/api/filters');
expect(res.body.priceRange.min_cents).toBe(1000);
expect(res.body.priceRange.max_cents).toBe(5000);
});
});
describe('asking the public API for pending items', () => {
// Refused rather than answered with an empty list, which would read as "no
// items match" instead of "you may not ask that".
it('is refused on the storefront route', async () => {
await insertItem('Staged', 1000, 'pending');
const res = await request(app).get('/api/items?status=pending');
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid status');
});
it('is still allowed on the admin route, which is the point of it', async () => {
await insertItem('Staged', 1000, 'pending');
await insertItem('Live', 2000, 'available');
const res = await request(app).get('/api/admin/items?status=pending');
expect(res.status).toBe(200);
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Staged']);
});
it('leaves the other statuses working on the storefront', async () => {
await insertItem('Live', 1000, 'available');
await insertItem('Gone', 2000, 'sold');
const res = await request(app).get('/api/items?status=sold');
expect(res.status).toBe(200);
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Gone']);
});
});
describe('unpublishing', () => {
it('returns an available item to pending and removes it from the catalogue', async () => {
const id = await insertItem('Live', 1000, 'available');
const res = await request(app).post(`/api/admin/items/${id}/unpublish`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('pending');
const listing = await request(app).get('/api/items');
expect(listing.body).toHaveLength(0);
});
// Not a draft: someone is holding it in their cart right now, and hiding it
// would strand them mid-checkout.
it('refuses a reserved item and says why', async () => {
const id = await insertItem('Held', 1000, 'reserved');
const res = await request(app).post(`/api/admin/items/${id}/unpublish`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('holding this item');
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [id]);
expect(rows[0].status).toBe('reserved');
});
// Not a draft either: a sold item is a record of something that happened.
it('refuses a sold item and says why', async () => {
const id = await insertItem('Gone', 1000, 'sold');
const res = await request(app).post(`/api/admin/items/${id}/unpublish`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('sold item');
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [id]);
expect(rows[0].status).toBe('sold');
});
it('refuses an item that is already pending', async () => {
const id = await insertItem('Staged', 1000, 'pending');
const res = await request(app).post(`/api/admin/items/${id}/unpublish`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('already pending');
});
it('reports a missing item as not found rather than as a bad request', async () => {
const res = await request(app).post('/api/admin/items/999999/unpublish');
expect(res.status).toBe(404);
});
});
describe('publishing', () => {
it('puts a pending item into the catalogue', async () => {
const id = await insertItem('Staged', 1000, 'pending');
expect((await request(app).get('/api/items')).body).toHaveLength(0);
const res = await request(app).post(`/api/admin/items/${id}/mark-available`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('available');
const listing = await request(app).get('/api/items');
expect(listing.body.map((i: { name: string }) => i.name)).toEqual(['Staged']);
});
});
+3 -1
View File
@@ -107,7 +107,9 @@ describe('parseItemFilters', () => {
});
it('rejects a status outside the known set', () => {
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
// 'pending' used to be the example here and is now a real status, which is
// exactly the sort of thing that quietly turns a test into a tautology.
expect(() => parseItemFilters({ status: 'archived' })).toThrow(FilterError);
});
it('rejects a status differing only by case, rather than silently coercing it', () => {