Files
redefined-designs/backend/tests/integration/adminInventory.integration.test.ts
T
bermudalambandClaude Opus 5 ecc2219fa5
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
feat: stage new items as pending until an admin publishes them (#90)
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>
2026-08-21 12:40:08 -05:00

233 lines
9.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();
});
async function createCategory(name: string, parentId: number | null = null): Promise<number> {
const res = await request(app).post('/api/admin/categories').send({ name, parent_id: parentId });
expect(res.status).toBe(201);
return res.body.id;
}
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;
}
async function createItem(
name: string,
priceCents: number,
options: { categoryId?: number | null; tagIds?: number[]; status?: string } = {}
): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, category_id, status) VALUES ($1, $2, $3, $4) RETURNING id`,
[name, priceCents, options.categoryId ?? null, options.status ?? 'available']
);
const itemId = rows[0].id;
for (const tagId of options.tagIds ?? []) {
await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
}
return itemId;
}
async function registerCustomer(email: string) {
const agent = request.agent(app);
await agent.post('/api/customers/register').send({ email, password: 'supersecret123' });
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
return { agent, id: rows[0].id as number };
}
const names = (body: { name: string }[]) => body.map(item => item.name).sort();
describe('GET /api/admin/items filtering', () => {
it('returns every item when nothing is filtered', async () => {
await createItem('A', 1000);
await createItem('B', 2000, { status: 'sold' });
const res = await request(app).get('/api/admin/items');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it('filters by status, which is how Reserved is surfaced', async () => {
await createItem('Free', 1000, { status: 'available' });
await createItem('Held', 1000, { status: 'reserved' });
await createItem('Gone', 1000, { status: 'sold' });
const res = await request(app).get('/api/admin/items?status=reserved');
expect(names(res.body)).toEqual(['Held']);
});
it('matches a category and all of its descendants', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const decor = await createCategory('Decor');
await createItem('Nested', 1000, { categoryId: tables });
await createItem('Top', 1000, { categoryId: furniture });
await createItem('Elsewhere', 1000, { categoryId: decor });
const res = await request(app).get(`/api/admin/items?category=${furniture}`);
expect(names(res.body)).toEqual(['Nested', 'Top']);
});
it('requires every listed tag rather than any of them', async () => {
const vintage = await createTag('vintage');
const oak = await createTag('oak');
await createItem('Both', 1000, { tagIds: [vintage, oak] });
await createItem('One', 1000, { tagIds: [vintage] });
const res = await request(app).get(`/api/admin/items?tags=${vintage},${oak}`);
expect(names(res.body)).toEqual(['Both']);
});
it('bounds the price range inclusively', async () => {
await createItem('Under', 900);
await createItem('Edge', 1000);
await createItem('Over', 5100);
const res = await request(app).get('/api/admin/items?min_price=1000&max_price=5000');
expect(names(res.body)).toEqual(['Edge']);
});
it('combines every filter with AND', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const vintage = await createTag('vintage');
await createItem('Match', 3000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
await createItem('Wrong status', 3000, { categoryId: tables, tagIds: [vintage], status: 'available' });
await createItem('Wrong category', 3000, { tagIds: [vintage], status: 'reserved' });
await createItem('Wrong price', 9000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
const res = await request(app).get(
`/api/admin/items?category=${furniture}&tags=${vintage}&min_price=1000&max_price=5000&status=reserved`
);
expect(names(res.body)).toEqual(['Match']);
});
it('rejects an unknown status rather than returning everything', async () => {
await createItem('A', 1000);
// 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);
});
it('still returns admin-only columns alongside the filters', async () => {
await createItem('A', 1000, { status: 'reserved' });
const res = await request(app).get('/api/admin/items?status=reserved');
expect(res.body[0]).toHaveProperty('reserved_until');
expect(res.body[0]).toHaveProperty('tags');
});
});
describe('admin customer reservations', () => {
it('reports how many items each customer is holding', async () => {
const itemId = await createItem('Held', 1000);
const { agent, id } = await registerCustomer('holder@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(1);
});
it('reports zero for a customer holding nothing', async () => {
const { id } = await registerCustomer('empty@example.com');
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(0);
});
it('lists the items a customer is holding', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('lister@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].name).toBe('Oak table');
expect(res.body[0].item_id).toBe(itemId);
expect(res.body[0].price_cents).toBe(34000);
expect(res.body[0].expires_at).toBeTruthy();
});
it('returns an empty list for a customer holding nothing', async () => {
const { id } = await registerCustomer('nothing@example.com');
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('releasing an item returns it to available and empties the cart row', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('release@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
expect(res.status).toBe(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.body.status).toBe('available');
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM cart_items WHERE item_id = $1`, [itemId]);
expect(rows[0].n).toBe(0);
});
it('a released item stops counting against the customer', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('recount@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(0);
});
it('a released item can be reserved again by someone else', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent: first, id } = await registerCustomer('first@example.com');
await first.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const { agent: second } = await registerCustomer('second@example.com');
const res = await second.post(`/api/cart/items/${itemId}`);
expect(res.status).toBe(201);
});
it('refuses to release an item the customer is not holding', async () => {
const itemId = await createItem('Not theirs', 1000);
const { id } = await registerCustomer('other@example.com');
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
expect(res.status).toBe(404);
});
it('does not touch a sold item when releasing', async () => {
const itemId = await createItem('Sold out', 1000);
const { agent, id } = await registerCustomer('sold@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
expect(rows[0].status).toBe('sold');
});
});