feat(api): categories, tags, and storefront item filters (#23)

Adds a self-referencing categories tree, a tag registry with
deterministic colours, and item_tags, plus admin CRUD for both.

GET /api/items now accepts category, tags, min_price and max_price.
Category matching walks the subtree with a recursive CTE so selecting a
parent includes everything filed beneath it; tags match with AND via a
count check, since ANY() alone would return items carrying only one of
them. Malformed filter params return 400 rather than being ignored, so a
broken link doesn't quietly list the whole catalogue.

GET /api/filters serves the drawer its tree, tags, and price bounds in
one request.

Item image/tag aggregation moves from LEFT JOIN + GROUP BY to scalar
subqueries. Joining two one-to-many relations multiplies their rows, so
an item with 2 images and 3 tags would have repeated every image three
times once tags were added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:13:51 -05:00
co-authored by Claude Opus 5
parent 766358a9fe
commit 9222e97deb
14 changed files with 1288 additions and 29 deletions
@@ -0,0 +1,452 @@
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,
categoryId: number | null = null,
tagIds: number[] = []
): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, category_id) VALUES ($1, $2, $3) RETURNING id`,
[name, priceCents, categoryId]
);
const itemId = rows[0].id;
for (const tagId of tagIds) {
await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
}
return itemId;
}
describe('admin categories', () => {
it('creates a root category', async () => {
const res = await request(app).post('/api/admin/categories').send({ name: 'Furniture' });
expect(res.status).toBe(201);
expect(res.body.name).toBe('Furniture');
expect(res.body.parent_id).toBeNull();
});
it('creates a nested category', async () => {
const furniture = await createCategory('Furniture');
const res = await request(app).post('/api/admin/categories').send({ name: 'Tables', parent_id: furniture });
expect(res.status).toBe(201);
expect(res.body.parent_id).toBe(furniture);
});
it('rejects two root categories with the same name', async () => {
await createCategory('Furniture');
const res = await request(app).post('/api/admin/categories').send({ name: 'furniture' });
expect(res.status).toBe(409);
});
it('rejects two siblings with the same name', async () => {
const furniture = await createCategory('Furniture');
await createCategory('Tables', furniture);
const res = await request(app).post('/api/admin/categories').send({ name: 'Tables', parent_id: furniture });
expect(res.status).toBe(409);
});
it('allows the same name under different parents', async () => {
const furniture = await createCategory('Furniture');
const decor = await createCategory('Decor');
await createCategory('Vintage', furniture);
const res = await request(app).post('/api/admin/categories').send({ name: 'Vintage', parent_id: decor });
expect(res.status).toBe(201);
});
it('rejects a category with a blank name', async () => {
const res = await request(app).post('/api/admin/categories').send({ name: ' ' });
expect(res.status).toBe(400);
});
it('rejects a parent that does not exist', async () => {
const res = await request(app).post('/api/admin/categories').send({ name: 'Orphan', parent_id: 9999 });
expect(res.status).toBe(400);
});
it('lists categories with the number of items in each', async () => {
const furniture = await createCategory('Furniture');
await createItem('Chair', 1000, furniture);
await createItem('Stool', 2000, furniture);
const res = await request(app).get('/api/admin/categories');
expect(res.status).toBe(200);
const found = res.body.find((c: { id: number }) => c.id === furniture);
expect(found.item_count).toBe(2);
});
it('renames a category', async () => {
const id = await createCategory('Furnature');
const res = await request(app).put(`/api/admin/categories/${id}`).send({ name: 'Furniture' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('Furniture');
});
it('reparents a category', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables');
const res = await request(app).put(`/api/admin/categories/${tables}`).send({ parent_id: furniture });
expect(res.status).toBe(200);
expect(res.body.parent_id).toBe(furniture);
});
it('refuses to make a category its own parent', async () => {
const id = await createCategory('Furniture');
const res = await request(app).put(`/api/admin/categories/${id}`).send({ parent_id: id });
expect(res.status).toBe(400);
});
it('refuses to move a category beneath its own descendant', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const coffee = await createCategory('Coffee Tables', tables);
const res = await request(app).put(`/api/admin/categories/${furniture}`).send({ parent_id: coffee });
expect(res.status).toBe(400);
// The tree must be untouched after a rejected move.
const check = await pool.query(`SELECT parent_id FROM categories WHERE id = $1`, [furniture]);
expect(check.rows[0].parent_id).toBeNull();
});
it('deletes a category and reports what it affected', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
await createItem('Chair', 1000, furniture);
await createItem('Coffee table', 2000, tables);
const res = await request(app).delete(`/api/admin/categories/${furniture}`);
expect(res.status).toBe(200);
expect(res.body.deleted_categories).toBe(2);
expect(res.body.uncategorized_items).toBe(2);
});
it('keeps items when their category is deleted, merely uncategorizing them', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const itemId = await createItem('Coffee table', 2000, tables);
await request(app).delete(`/api/admin/categories/${furniture}`);
const res = await request(app).get(`/api/items/${itemId}`);
expect(res.status).toBe(200);
expect(res.body.category_id).toBeNull();
});
});
describe('admin tags', () => {
it('creates a tag with an auto-assigned colour', async () => {
const res = await request(app).post('/api/admin/tags').send({ name: 'vintage' });
expect(res.status).toBe(201);
expect(res.body.name).toBe('vintage');
expect(typeof res.body.color).toBe('string');
expect(res.body.color.length).toBeGreaterThan(0);
});
it('honours an explicit colour on create', async () => {
const res = await request(app).post('/api/admin/tags').send({ name: 'vintage', color: 'purple' });
expect(res.status).toBe(201);
expect(res.body.color).toBe('purple');
});
it('rejects a colour outside the palette', async () => {
const res = await request(app).post('/api/admin/tags').send({ name: 'vintage', color: 'chartreuse' });
expect(res.status).toBe(400);
});
it('rejects a duplicate tag name regardless of case', async () => {
await createTag('vintage');
const res = await request(app).post('/api/admin/tags').send({ name: 'VINTAGE' });
expect(res.status).toBe(409);
});
it('rejects a blank tag name', async () => {
const res = await request(app).post('/api/admin/tags').send({ name: ' ' });
expect(res.status).toBe(400);
});
it('lists tags with the number of items carrying each', async () => {
const vintage = await createTag('vintage');
await createTag('unused');
await createItem('Chair', 1000, null, [vintage]);
const res = await request(app).get('/api/admin/tags');
expect(res.status).toBe(200);
const found = res.body.find((t: { id: number }) => t.id === vintage);
expect(found.item_count).toBe(1);
const unused = res.body.find((t: { name: string }) => t.name === 'unused');
expect(unused.item_count).toBe(0);
});
it('overrides a tag colour', async () => {
const id = await createTag('vintage');
const res = await request(app).put(`/api/admin/tags/${id}`).send({ color: 'geekblue' });
expect(res.status).toBe(200);
expect(res.body.color).toBe('geekblue');
});
it('renames a tag', async () => {
const id = await createTag('vintge');
const res = await request(app).put(`/api/admin/tags/${id}`).send({ name: 'vintage' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('vintage');
});
it('deleting a tag detaches it from items without deleting them', async () => {
const vintage = await createTag('vintage');
const itemId = await createItem('Chair', 1000, null, [vintage]);
const res = await request(app).delete(`/api/admin/tags/${vintage}`);
expect(res.status).toBe(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.status).toBe(200);
expect(item.body.tags).toEqual([]);
});
});
describe('admin item form', () => {
it('saves a category and creates unknown tags on the fly', async () => {
const furniture = await createCategory('Furniture');
const res = await request(app)
.post('/api/admin/items')
.field('name', 'Oak table')
.field('description', '')
.field('price', '340')
.field('category_id', String(furniture))
.field('tags', JSON.stringify(['vintage', 'oak']));
expect(res.status).toBe(200);
expect(res.body.category_id).toBe(furniture);
expect(res.body.tags.map((t: { name: string }) => t.name).sort()).toEqual(['oak', 'vintage']);
});
it('reuses an existing tag rather than duplicating it', async () => {
await createTag('vintage');
await request(app)
.post('/api/admin/items')
.field('name', 'Oak table')
.field('description', '')
.field('price', '340')
.field('tags', JSON.stringify(['VINTAGE']));
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM tags`);
expect(rows[0].n).toBe(1);
});
it('replaces an item\'s tags on update rather than appending', async () => {
const create = await request(app)
.post('/api/admin/items')
.field('name', 'Oak table')
.field('description', '')
.field('price', '340')
.field('tags', JSON.stringify(['vintage', 'oak']));
const res = await request(app)
.put(`/api/admin/items/${create.body.id}`)
.field('name', 'Oak table')
.field('description', '')
.field('price', '340')
.field('tags', JSON.stringify(['oak']));
expect(res.status).toBe(200);
expect(res.body.tags.map((t: { name: string }) => t.name)).toEqual(['oak']);
});
it('clears the category when an empty category_id is submitted', async () => {
const furniture = await createCategory('Furniture');
const create = await request(app)
.post('/api/admin/items')
.field('name', 'Oak table')
.field('description', '')
.field('price', '340')
.field('category_id', String(furniture));
const res = await request(app)
.put(`/api/admin/items/${create.body.id}`)
.field('name', 'Oak table')
.field('description', '')
.field('price', '340')
.field('category_id', '');
expect(res.status).toBe(200);
expect(res.body.category_id).toBeNull();
});
it('returns each image exactly once for an item that also has tags', async () => {
const create = await request(app)
.post('/api/admin/items')
.field('name', 'Oak table')
.field('description', '')
.field('price', '340')
.field('tags', JSON.stringify(['vintage', 'oak', 'restored']));
await pool.query(
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0), ($1, '/uploads/b.jpg', 1)`,
[create.body.id]
);
const res = await request(app).get(`/api/items/${create.body.id}`);
expect(res.body.images).toHaveLength(2);
expect(res.body.tags).toHaveLength(3);
});
});
describe('GET /api/filters', () => {
it('returns the category tree, tags, and the catalogue price range', async () => {
const furniture = await createCategory('Furniture');
await createCategory('Tables', furniture);
await createTag('vintage');
await createItem('Cheap', 1200);
await createItem('Dear', 80000);
const res = await request(app).get('/api/filters');
expect(res.status).toBe(200);
expect(res.body.categories).toHaveLength(2);
expect(res.body.tags).toHaveLength(1);
expect(res.body.priceRange).toEqual({ min_cents: 1200, max_cents: 80000 });
});
it('returns a zeroed price range for an empty catalogue', async () => {
const res = await request(app).get('/api/filters');
expect(res.status).toBe(200);
expect(res.body.priceRange).toEqual({ min_cents: 0, max_cents: 0 });
});
});
describe('GET /api/items filtering', () => {
it('returns every item when nothing is filtered', async () => {
await createItem('A', 1000);
await createItem('B', 2000);
const res = await request(app).get('/api/items');
expect(res.body).toHaveLength(2);
});
it('matches a category and all of its descendants', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const coffee = await createCategory('Coffee Tables', tables);
const decor = await createCategory('Decor');
await createItem('Deep', 1000, coffee);
await createItem('Mid', 1000, tables);
await createItem('Top', 1000, furniture);
await createItem('Elsewhere', 1000, decor);
const res = await request(app).get(`/api/items?category=${furniture}`);
expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['Deep', 'Mid', 'Top']);
});
it('excludes uncategorized items from a category filter', async () => {
const furniture = await createCategory('Furniture');
await createItem('Filed', 1000, furniture);
await createItem('Loose', 1000, null);
const res = await request(app).get(`/api/items?category=${furniture}`);
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Filed']);
});
it('requires every listed tag, not merely one of them', async () => {
const vintage = await createTag('vintage');
const oak = await createTag('oak');
await createItem('Both', 1000, null, [vintage, oak]);
await createItem('Only vintage', 1000, null, [vintage]);
await createItem('Only oak', 1000, null, [oak]);
const res = await request(app).get(`/api/items?tags=${vintage},${oak}`);
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Both']);
});
it('matches items carrying extra tags beyond those selected', async () => {
const vintage = await createTag('vintage');
const oak = await createTag('oak');
const rare = await createTag('rare');
await createItem('Three tags', 1000, null, [vintage, oak, rare]);
const res = await request(app).get(`/api/items?tags=${vintage},${oak}`);
expect(res.body).toHaveLength(1);
});
it('bounds the price range inclusively', async () => {
await createItem('Under', 900);
await createItem('Low edge', 1000);
await createItem('Middle', 3000);
await createItem('High edge', 5000);
await createItem('Over', 5100);
const res = await request(app).get('/api/items?min_price=1000&max_price=5000');
expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['High edge', 'Low edge', 'Middle']);
});
it('combines category, tags, and price with AND', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const vintage = await createTag('vintage');
await createItem('Match', 3000, tables, [vintage]);
await createItem('Wrong category', 3000, null, [vintage]);
await createItem('Wrong tag', 3000, tables, []);
await createItem('Wrong price', 9000, tables, [vintage]);
const res = await request(app).get(`/api/items?category=${furniture}&tags=${vintage}&min_price=1000&max_price=5000`);
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Match']);
});
it('includes each item\'s tags and category name in the response', async () => {
const furniture = await createCategory('Furniture');
const vintage = await createTag('vintage');
await createItem('Chair', 1000, furniture, [vintage]);
const res = await request(app).get('/api/items');
expect(res.body[0].category_name).toBe('Furniture');
expect(res.body[0].tags[0].name).toBe('vintage');
expect(res.body[0].tags[0].color).toBeTruthy();
});
it('rejects a malformed category rather than silently returning everything', async () => {
await createItem('A', 1000);
const res = await request(app).get('/api/items?category=furniture');
expect(res.status).toBe(400);
});
it('rejects an inverted price range', async () => {
const res = await request(app).get('/api/items?min_price=5000&max_price=1000');
expect(res.status).toBe(400);
});
it('returns an empty list for a category that matches nothing', async () => {
const empty = await createCategory('Empty');
await createItem('A', 1000);
const res = await request(app).get(`/api/items?category=${empty}`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
+2 -1
View File
@@ -29,7 +29,8 @@ export async function migrate(): Promise<void> {
export async function resetDb(): Promise<void> {
await testPool.query(`
TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts,
customer_tokens, customer_sessions, customers, item_images, items
customer_tokens, customer_sessions, customers, item_tags, item_images, items,
tags, categories
RESTART IDENTITY CASCADE
`);
}
+114
View File
@@ -0,0 +1,114 @@
import { parseItemFilters, FilterError, buildItemFilterSql } from '../../src/itemFilters';
describe('parseItemFilters', () => {
it('returns empty filters for an empty query', () => {
expect(parseItemFilters({})).toEqual({
categoryId: null,
tagIds: [],
minPriceCents: null,
maxPriceCents: null
});
});
it('parses a category id', () => {
expect(parseItemFilters({ category: '7' }).categoryId).toBe(7);
});
it('parses a comma-separated tag list', () => {
expect(parseItemFilters({ tags: '3,1,2' }).tagIds).toEqual([3, 1, 2]);
});
it('collapses duplicate tag ids', () => {
expect(parseItemFilters({ tags: '2,2,5' }).tagIds).toEqual([2, 5]);
});
it('treats an empty tag list as no tag filter', () => {
expect(parseItemFilters({ tags: '' }).tagIds).toEqual([]);
});
it('parses an inclusive price range in cents', () => {
const filters = parseItemFilters({ min_price: '1000', max_price: '5000' });
expect(filters.minPriceCents).toBe(1000);
expect(filters.maxPriceCents).toBe(5000);
});
it('allows a price range open at one end', () => {
expect(parseItemFilters({ min_price: '1000' }).maxPriceCents).toBeNull();
expect(parseItemFilters({ max_price: '5000' }).minPriceCents).toBeNull();
});
it('allows a zero minimum price', () => {
expect(parseItemFilters({ min_price: '0' }).minPriceCents).toBe(0);
});
it('rejects a non-numeric category', () => {
expect(() => parseItemFilters({ category: 'furniture' })).toThrow(FilterError);
});
it('rejects a category id below 1', () => {
expect(() => parseItemFilters({ category: '0' })).toThrow(FilterError);
});
it('rejects a non-numeric tag id', () => {
expect(() => parseItemFilters({ tags: '1,vintage' })).toThrow(FilterError);
});
it('rejects a negative price', () => {
expect(() => parseItemFilters({ min_price: '-1' })).toThrow(FilterError);
});
it('rejects a fractional price', () => {
expect(() => parseItemFilters({ max_price: '10.5' })).toThrow(FilterError);
});
it('rejects an inverted price range', () => {
expect(() => parseItemFilters({ min_price: '5000', max_price: '1000' })).toThrow(FilterError);
});
it('accepts a price range where both ends are equal', () => {
expect(() => parseItemFilters({ min_price: '1000', max_price: '1000' })).not.toThrow();
});
it('rejects a repeated query param rather than guessing which one to use', () => {
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
});
});
describe('buildItemFilterSql', () => {
it('produces no clauses and no params when nothing is filtered', () => {
const built = buildItemFilterSql(parseItemFilters({}), 1);
expect(built.clauses).toEqual([]);
expect(built.params).toEqual([]);
});
it('matches a category and all of its descendants', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1);
expect(built.clauses.join(' ')).toContain('RECURSIVE');
expect(built.params).toEqual([4]);
});
it('requires every listed tag rather than any of them', () => {
const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1);
// The count of matched tag rows must equal the number of tags requested —
// an ANY/IN match alone would return items carrying just one of them.
expect(built.clauses.join(' ')).toContain('COUNT(*)');
expect(built.params).toEqual([[1, 2], 2]);
});
it('numbers placeholders from the given starting index', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3);
expect(built.clauses.join(' ')).toContain('$3');
});
it('continues numbering across multiple filters', () => {
const built = buildItemFilterSql(
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
1
);
expect(built.params).toEqual([4, 100, 900]);
const sql = built.clauses.join(' ');
expect(sql).toContain('$1');
expect(sql).toContain('$2');
expect(sql).toContain('$3');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { tagColorFor, TAG_COLORS } from '../../src/utils';
describe('tagColorFor', () => {
it('returns a colour from the palette', () => {
expect(TAG_COLORS).toContain(tagColorFor('vintage'));
});
it('returns the same colour for the same name every time', () => {
expect(tagColorFor('vintage')).toBe(tagColorFor('vintage'));
});
it('ignores case and surrounding whitespace, matching how tag names are deduped', () => {
expect(tagColorFor(' Vintage ')).toBe(tagColorFor('vintage'));
});
it('gives different names different colours', () => {
// Not guaranteed for every possible pair, but a handful of realistic tag
// names should spread across the palette rather than collapsing onto one.
const names = ['vintage', 'handmade', 'oak', 'restored', 'rare', 'walnut'];
const distinct = new Set(names.map(tagColorFor));
expect(distinct.size).toBeGreaterThan(1);
});
it('handles an empty name without throwing', () => {
expect(TAG_COLORS).toContain(tagColorFor(''));
});
it('handles a name of non-ASCII characters', () => {
expect(TAG_COLORS).toContain(tagColorFor('café'));
});
});