fix(admin): theme, American English, and inventory/reservation tooling (#27)
Seven reported items, of which the first four had two root causes. The active tab was invisible in dark mode because colorPrimary was hardcoded to #1a1a1a in both themes. The accent now inverts with the theme, and colorTextLightSolid inverts with it, or a near-white accent would get antd's default white label and disappear. The Category tab, Tag tab, and item-form category selector ignored the theme entirely. antd declares main: lib/index.js and module: es/index.js, so importing from 'antd' resolves to the ES build while 'antd/lib/...' loads the CommonJS one — two copies, two React contexts, and no ConfigProvider for anything deep-imported. Switching those files to antd/es/* keeps the deep-import convention and shares the instance. This was introduced by my own use of the lib path; es is correct under Vite. Two storefront components had the same latent bug. "Colour" is now "Color". The Customers tab shows how many items each customer is holding, as a link opening the item list with a Release button. Release mirrors the customer's own cart removal — drop the cart row, return the item to available, guarded on 'reserved' so it can never resurrect a sold item — and deliberately sends no email about an action the customer did not take. The count is a subquery rather than another join, which would have multiplied rows and inflated order_count and total_spent_cents. The Inventory tab filters by category, tags, price, and status, reusing the storefront's parser and query builder so the two cannot drift. Reserved is one option in a Status filter rather than a standalone toggle. Also fixes two defects the screenshots exposed: the reserved-count link bubbled to the row handler and opened the customer drawer behind the dialog, and .admin-category-node had no CSS at all, so the tree node name, item count, and actions ran together as one string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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);
|
||||
const res = await request(app).get('/api/admin/items?status=pending');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ describe('parseItemFilters', () => {
|
||||
categoryId: null,
|
||||
tagIds: [],
|
||||
minPriceCents: null,
|
||||
maxPriceCents: null
|
||||
maxPriceCents: null,
|
||||
status: null
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +73,25 @@ describe('parseItemFilters', () => {
|
||||
it('rejects a repeated query param rather than guessing which one to use', () => {
|
||||
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
|
||||
});
|
||||
|
||||
it('parses each of the item statuses', () => {
|
||||
expect(parseItemFilters({ status: 'available' }).status).toBe('available');
|
||||
expect(parseItemFilters({ status: 'reserved' }).status).toBe('reserved');
|
||||
expect(parseItemFilters({ status: 'sold' }).status).toBe('sold');
|
||||
});
|
||||
|
||||
it('treats an absent or empty status as no status filter', () => {
|
||||
expect(parseItemFilters({}).status).toBeNull();
|
||||
expect(parseItemFilters({ status: '' }).status).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a status outside the known set', () => {
|
||||
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
|
||||
});
|
||||
|
||||
it('rejects a status differing only by case, rather than silently coercing it', () => {
|
||||
expect(() => parseItemFilters({ status: 'Reserved' })).toThrow(FilterError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildItemFilterSql', () => {
|
||||
@@ -100,6 +120,12 @@ describe('buildItemFilterSql', () => {
|
||||
expect(built.clauses.join(' ')).toContain('$3');
|
||||
});
|
||||
|
||||
it('filters on status', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1);
|
||||
expect(built.clauses.join(' ')).toContain('i.status');
|
||||
expect(built.params).toEqual(['reserved']);
|
||||
});
|
||||
|
||||
it('continues numbering across multiple filters', () => {
|
||||
const built = buildItemFilterSql(
|
||||
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
|
||||
|
||||
Reference in New Issue
Block a user