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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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é'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user