Registration collected one optional Name, so every greeting had only a whole name to use: "Hi Thom Lamb," rather than "Hi Thom,". Both parts are now captured, and the cart reminder greets by first name. Both are required of anyone new, refused individually rather than as a single "name is required" so a form that filled one and not the other is told which. The columns are nullable even so, and that is deliberate. Marking them NOT NULL would mean backfilling legacy rows with empty strings, which asserts that every customer has a name — untrue of anyone who registered while the field was optional. The table records what is actually the case; the rule that new registrations must supply both lives in the route, where a missing field can produce a message naming it. The backfill splits on the first space, and it is lossy in a way no version of this avoids. "Thom Lamb" becomes Thom and Lamb; "Mary Jane Smith" gets a last name of "Jane Smith"; names that are not two parts fare worse. It was chosen over leaving the columns empty because nothing currently lets a customer correct their own name — PUT /api/customers/me exists but no frontend calls it — so empty would have meant permanently unpersonalised for every existing customer. The migration says so, so nobody later reads backfilled values as data the customer supplied in that shape. Verified against a seeded database rather than reasoned about, because this is the part that cannot be covered by the suite: migrations run in globalSetup before any test, and the old column is gone afterwards. Six representative rows through the real migration gave Thom/Lamb, Mary/"Jane Smith", Cher/null, " Padded Name " trimmed to Padded/Name, and null and whitespace-only names left as null on both. The down migration rejoins the parts and returns all six to their original strings. The old column is dropped rather than kept alongside, so there is one source of truth instead of two that drift. The admin keeps receiving a single composed display name. It only ever shows one — the list cell and the drawer title — and never edits one, so giving it both parts plus the joining logic would be work for no reader. Churn was the bulk of this: 14 backend registrations and 10 end-to-end registration forms. A first attempt at the backend fixtures also added names to login and password-reset payloads, which would still have passed since the server ignores unknown fields, but a login test implying login takes a name is a small lie; that was reverted and redone against register calls only. Verified: 172 unit, 183 integration and 95 end-to-end passing, lint unchanged at 4 backend and 27 frontend warnings. Not covered: the cart reminder itself, which runs from a cron and had no test before this either. The greeting change is a one-line substitution in that query. Refs #106 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
9.4 KiB
TypeScript
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({ firstName: 'Test', lastName: 'Customer', 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');
|
|
});
|
|
});
|