diff --git a/backend/migrations/1786974336000_add-categories-and-tags.js b/backend/migrations/1786974336000_add-categories-and-tags.js new file mode 100644 index 0000000..3684c3e --- /dev/null +++ b/backend/migrations/1786974336000_add-categories-and-tags.js @@ -0,0 +1,60 @@ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + -- Siblings can't share a name. Two partial indexes rather than one plain + -- unique constraint, because a NULL parent_id (a root category) compares + -- unequal to every other NULL and would let duplicate roots straight in. + CREATE UNIQUE INDEX IF NOT EXISTS categories_child_name_uniq + ON categories (parent_id, lower(name)) WHERE parent_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS categories_root_name_uniq + ON categories (lower(name)) WHERE parent_id IS NULL; + + CREATE INDEX IF NOT EXISTS categories_parent_id_idx ON categories (parent_id); + + CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + color TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE UNIQUE INDEX IF NOT EXISTS tags_name_uniq ON tags (lower(name)); + + CREATE TABLE IF NOT EXISTS item_tags ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (item_id, tag_id) + ); + + -- The item_id half is covered by the primary key; filtering by tag needs + -- the other direction. + CREATE INDEX IF NOT EXISTS item_tags_tag_id_idx ON item_tags (tag_id); + + -- Deleting a category leaves its items in place and merely uncategorized, + -- rather than taking inventory down with it. + ALTER TABLE items ADD COLUMN IF NOT EXISTS category_id INTEGER + REFERENCES categories(id) ON DELETE SET NULL; + + CREATE INDEX IF NOT EXISTS items_category_id_idx ON items (category_id); + `); +}; + +// Unlike the baseline migration, this one is safe to reverse: it drops only +// what it created. Item rows themselves are untouched — they just lose their +// category assignment along with the column. +exports.down = (pgm) => { + pgm.sql(` + DROP INDEX IF EXISTS items_category_id_idx; + ALTER TABLE items DROP COLUMN IF EXISTS category_id; + DROP TABLE IF EXISTS item_tags; + DROP TABLE IF EXISTS tags; + DROP TABLE IF EXISTS categories; + `); +}; diff --git a/backend/src/app.ts b/backend/src/app.ts index e0a4df0..7d3b533 100755 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,9 @@ import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRoute import adminRouter from './routes/admin'; import adminCustomersRouter from './routes/adminCustomers'; import adminSettingsRouter from './routes/adminSettings'; +import adminCategoriesRouter from './routes/adminCategories'; +import adminTagsRouter from './routes/adminTags'; +import filtersRouter from './routes/filters'; import customersRouter from './routes/customers'; import publicRouter from './routes/public'; import cartRouter from './routes/cart'; @@ -35,10 +38,13 @@ app.get('/api/config', (_req, res) => { }); app.use('/api/items', itemsRouter); +app.use('/api/filters', filtersRouter); app.use('/api/cart', cartRouter); app.use('/api/checkout/cart', cartCheckoutRouter); app.use('/api/admin/customers', adminCustomersRouter); app.use('/api/admin/settings', adminSettingsRouter); +app.use('/api/admin/categories', adminCategoriesRouter); +app.use('/api/admin/tags', adminTagsRouter); app.use('/api/admin', adminRouter); app.use('/api/customers/me/addresses', shippingAddressesRouter); app.use('/api/customers', customersRouter); diff --git a/backend/src/itemFilters.ts b/backend/src/itemFilters.ts new file mode 100644 index 0000000..a075fac --- /dev/null +++ b/backend/src/itemFilters.ts @@ -0,0 +1,145 @@ +// Parsing and SQL construction for the storefront's category / tag / price +// filters. Kept apart from the route so the rules can be unit-tested without a +// database, and so items.ts stays a thin handler. + +export class FilterError extends Error {} + +export interface ItemFilters { + categoryId: number | null; + tagIds: number[]; + minPriceCents: number | null; + maxPriceCents: number | null; +} + +export interface BuiltFilter { + clauses: string[]; + params: unknown[]; +} + +// Deliberately excludes a leading sign and any decimal point: every filter +// value is a non-negative integer (an id, or a price in cents), so '-1' and +// '10.5' are caller mistakes worth surfacing rather than silently coercing. +const NON_NEGATIVE_INTEGER = /^\d+$/; + +function singleValue(value: unknown, name: string): string | null { + if (value === undefined || value === null) { + return null; + } + // Express parses `?category=1&category=2` into an array. Picking one silently + // would make a malformed link look like it worked, so refuse it instead. + if (Array.isArray(value)) { + throw new FilterError(`${name} may only be given once`); + } + if (typeof value !== 'string') { + throw new FilterError(`invalid ${name}`); + } + return value; +} + +function parseNonNegativeInteger(raw: string, name: string): number { + if (!NON_NEGATIVE_INTEGER.test(raw)) { + throw new FilterError(`invalid ${name}`); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) { + throw new FilterError(`invalid ${name}`); + } + return parsed; +} + +function parseId(raw: string, name: string): number { + const parsed = parseNonNegativeInteger(raw, name); + if (parsed < 1) { + throw new FilterError(`invalid ${name}`); + } + return parsed; +} + +function parsePrice(value: unknown, name: string): number | null { + const raw = singleValue(value, name); + if (raw === null || raw === '') { + return null; + } + return parseNonNegativeInteger(raw, name); +} + +export function parseItemFilters(query: Record): ItemFilters { + const categoryRaw = singleValue(query.category, 'category'); + const categoryId = categoryRaw === null || categoryRaw === '' ? null : parseId(categoryRaw, 'category'); + + const tagsRaw = singleValue(query.tags, 'tags'); + const tagIds: number[] = []; + if (tagsRaw) { + for (const part of tagsRaw.split(',')) { + const trimmed = part.trim(); + if (trimmed === '') { + continue; + } + const id = parseId(trimmed, 'tags'); + // Duplicates would inflate the required-match count below and make the + // filter match nothing at all. + if (!tagIds.includes(id)) { + tagIds.push(id); + } + } + } + + const minPriceCents = parsePrice(query.min_price, 'min_price'); + const maxPriceCents = parsePrice(query.max_price, 'max_price'); + if (minPriceCents !== null && maxPriceCents !== null && minPriceCents > maxPriceCents) { + throw new FilterError('min_price may not exceed max_price'); + } + + return { categoryId, tagIds, minPriceCents, maxPriceCents }; +} + +// Returns WHERE fragments plus their parameters, with placeholders numbered +// from `startIndex` so the caller can splice these in after its own params. +export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter { + const clauses: string[] = []; + const params: unknown[] = []; + let next = startIndex; + + if (filters.categoryId !== null) { + params.push(filters.categoryId); + // Selecting a category means "and everything filed beneath it", so walk the + // tree down from the chosen node. A recursive CTE keeps the tree + // un-denormalized: reparenting stays a single UPDATE with no stored paths + // to rewrite. + clauses.push(`i.category_id IN ( + WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = $${next} + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + ) + SELECT id FROM subtree + )`); + next++; + } + + if (filters.tagIds.length) { + params.push(filters.tagIds, filters.tagIds.length); + // AND, not OR: the item must carry every selected tag. Matching with + // `tag_id = ANY(...)` alone would return items holding just one of them, so + // the count of matched rows has to equal the number requested. + clauses.push( + `(SELECT COUNT(*) FROM item_tags it + WHERE it.item_id = i.id AND it.tag_id = ANY($${next}::int[])) = $${next + 1}` + ); + next += 2; + } + + if (filters.minPriceCents !== null) { + params.push(filters.minPriceCents); + clauses.push(`i.price_cents >= $${next}`); + next++; + } + + if (filters.maxPriceCents !== null) { + params.push(filters.maxPriceCents); + clauses.push(`i.price_cents <= $${next}`); + next++; + } + + return { clauses, params }; +} diff --git a/backend/src/itemSelect.ts b/backend/src/itemSelect.ts new file mode 100644 index 0000000..3b490ad --- /dev/null +++ b/backend/src/itemSelect.ts @@ -0,0 +1,43 @@ +// Shared item SELECT shapes for the public and admin routes. +// +// Images and tags are pulled as scalar subqueries rather than LEFT JOIN + +// GROUP BY. Joining two one-to-many relations in the same query multiplies +// their rows together — an item with 2 images and 3 tags would aggregate 6 +// rows, silently repeating every image three times. Subqueries keep each +// aggregate independent and drop the GROUP BY entirely. + +const IMAGES_SUBQUERY = ` + COALESCE(( + SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) + ORDER BY img.sort_order) + FROM item_images img + WHERE img.item_id = i.id + ), '[]') AS images`; + +const TAGS_SUBQUERY = ` + COALESCE(( + SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) ORDER BY t.name) + FROM item_tags it + JOIN tags t ON t.id = it.tag_id + WHERE it.item_id = i.id + ), '[]') AS tags`; + +const FROM_CLAUSE = ` + FROM items i + LEFT JOIN categories c ON c.id = i.category_id`; + +// The storefront gets an explicit column list — it has no business seeing +// paypal_order_id or reserved_until. +export const PUBLIC_ITEM_SELECT = ` + SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, i.category_id, + c.name AS category_name, + ${IMAGES_SUBQUERY}, + ${TAGS_SUBQUERY} + ${FROM_CLAUSE}`; + +export const ADMIN_ITEM_SELECT = ` + SELECT i.*, + c.name AS category_name, + ${IMAGES_SUBQUERY}, + ${TAGS_SUBQUERY} + ${FROM_CLAUSE}`; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index b542586..90488d6 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -2,7 +2,10 @@ import { Router, Request, Response, NextFunction } from 'express'; import multer from 'multer'; import path from 'path'; import { randomUUID } from 'crypto'; +import { PoolClient } from 'pg'; import { pool } from '../db'; +import { ADMIN_ITEM_SELECT } from '../itemSelect'; +import { tagColorFor } from '../utils'; const router = Router(); @@ -52,31 +55,86 @@ const uploadImages = (req: Request, res: Response, next: NextFunction) => { }); }; -const SELECT_WITH_IMAGES = ` - SELECT i.*, - COALESCE( - json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) - ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL), - '[]' - ) AS images - FROM items i - LEFT JOIN item_images img ON img.item_id = i.id -`; +// The multipart body carries category_id and tags as text fields. An absent +// field means "leave as-is" on update, which is why these return undefined +// rather than null for a missing value. +function readCategoryId(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined; + return parsed; +} + +function readTagNames(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.trim() === '') return []; + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return undefined; + return parsed.filter((name): name is string => typeof name === 'string'); + } catch { + return undefined; + } +} + +// Tags typed into the item form may not exist yet. Look each name up +// case-insensitively — matching the unique index — and create the missing ones +// inside the caller's transaction so a later failure rolls them back too. +async function resolveTagIds(client: PoolClient, names: string[]): Promise { + const ids: number[] = []; + for (const raw of names) { + const name = raw.trim(); + if (!name) continue; + + const existing = await client.query(`SELECT id FROM tags WHERE lower(name) = lower($1)`, [name]); + if (existing.rows.length) { + if (!ids.includes(existing.rows[0].id)) ids.push(existing.rows[0].id); + continue; + } + + const inserted = await client.query( + `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id`, + [name, tagColorFor(name)] + ); + ids.push(inserted.rows[0].id); + } + return ids; +} + +// Tags are replaced wholesale rather than merged — the form submits the full +// set it wants, so removing a chip has to actually remove the row. +async function setItemTags(client: PoolClient, itemId: number, tagIds: number[]): Promise { + await client.query(`DELETE FROM item_tags WHERE item_id = $1`, [itemId]); + for (const tagId of tagIds) { + await client.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]); + } +} router.get('/items', async (_req: Request, res: Response) => { - const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`); + const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ORDER BY i.created_at DESC`); res.json(rows); }); router.post('/items', uploadImages, async (req: Request, res: Response) => { const { name, description, price } = req.body; + + const categoryId = readCategoryId(req.body.category_id); + if (categoryId === undefined && req.body.category_id !== undefined) { + return res.status(400).json({ error: 'invalid category_id' }); + } + const tagNames = readTagNames(req.body.tags); + if (tagNames === undefined && req.body.tags !== undefined) { + return res.status(400).json({ error: 'invalid tags' }); + } + const files = (req.files as Express.Multer.File[]) || []; const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( - `INSERT INTO items (name, description, price_cents) VALUES ($1, $2, $3) RETURNING *`, - [name, description, Math.round(parseFloat(price) * 100)] + `INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`, + [name, description, Math.round(parseFloat(price) * 100), categoryId ?? null] ); const item = rows[0]; for (let i = 0; i < files.length; i++) { @@ -85,8 +143,11 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => { [item.id, `/uploads/${files[i].filename}`, i] ); } + if (tagNames) { + await setItemTags(client, item.id, await resolveTagIds(client, tagNames)); + } await client.query('COMMIT'); - const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [item.id]); + const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]); res.json(full[0]); } catch (err) { await client.query('ROLLBACK'); @@ -99,6 +160,16 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => { router.put('/items/:id', uploadImages, async (req: Request, res: Response) => { const { name, description, price } = req.body; + + const categoryId = readCategoryId(req.body.category_id); + if (categoryId === undefined && req.body.category_id !== undefined) { + return res.status(400).json({ error: 'invalid category_id' }); + } + const tagNames = readTagNames(req.body.tags); + if (tagNames === undefined && req.body.tags !== undefined) { + return res.status(400).json({ error: 'invalid tags' }); + } + const files = (req.files as Express.Multer.File[]) || []; const client = await pool.connect(); try { @@ -107,6 +178,14 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => { `UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`, [name, description, Math.round(parseFloat(price) * 100), req.params.id] ); + // Only touch the category when the field was actually submitted, so a + // caller that omits it doesn't silently uncategorize the item. + if (categoryId !== undefined) { + await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, req.params.id]); + } + if (tagNames) { + await setItemTags(client, Number(req.params.id), await resolveTagIds(client, tagNames)); + } if (files.length) { const { rows: existing } = await client.query( `SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`, @@ -121,7 +200,7 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => { } } await client.query('COMMIT'); - const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]); + const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]); res.json(full[0]); } catch (err) { await client.query('ROLLBACK'); diff --git a/backend/src/routes/adminCategories.ts b/backend/src/routes/adminCategories.ts new file mode 100644 index 0000000..ca8c75d --- /dev/null +++ b/backend/src/routes/adminCategories.ts @@ -0,0 +1,159 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +// Postgres unique-violation SQLSTATE — raised by the two partial indexes that +// stop siblings sharing a name. +const UNIQUE_VIOLATION = '23505'; + +// Walks down from a node, collecting it and every descendant. Used both for +// cycle detection on reparent and for reporting the blast radius of a delete. +const SUBTREE_CTE = ` + WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = $1 + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + )`; + +function readName(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + +// Distinguishes "not supplied" from "explicitly cleared to root". +function readParentId(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined; + return parsed; +} + +async function parentExists(id: number): Promise { + const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]); + return rows.length > 0; +} + +router.get('/', async (_req: Request, res: Response) => { + const { rows } = await pool.query( + `SELECT c.id, c.name, c.parent_id, c.sort_order, + (SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count + FROM categories c + ORDER BY c.sort_order, lower(c.name)` + ); + res.json(rows); +}); + +router.post('/', async (req: Request, res: Response) => { + const name = readName(req.body.name); + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const parentId = readParentId(req.body.parent_id); + if (parentId === undefined && req.body.parent_id !== undefined) { + return res.status(400).json({ error: 'invalid parent_id' }); + } + const parent = parentId ?? null; + if (parent !== null && !(await parentExists(parent))) { + return res.status(400).json({ error: 'parent category does not exist' }); + } + + const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0; + + try { + const { rows } = await pool.query( + `INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3) + RETURNING id, name, parent_id, sort_order`, + [name, parent, sortOrder] + ); + res.status(201).json({ ...rows[0], item_count: 0 }); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a category with that name already exists here' }); + } + throw err; + } +}); + +router.put('/:id', async (req: Request, res: Response) => { + const id = Number(req.params.id); + const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]); + if (!existing.rows.length) { + return res.status(404).json({ error: 'not found' }); + } + + let name = existing.rows[0].name; + if (req.body.name !== undefined) { + const parsed = readName(req.body.name); + if (!parsed) { + return res.status(400).json({ error: 'name is required' }); + } + name = parsed; + } + + let parent = existing.rows[0].parent_id; + if (req.body.parent_id !== undefined) { + const parsed = readParentId(req.body.parent_id); + if (parsed === undefined) { + return res.status(400).json({ error: 'invalid parent_id' }); + } + if (parsed !== null) { + if (!(await parentExists(parsed))) { + return res.status(400).json({ error: 'parent category does not exist' }); + } + // Moving a node beneath itself or one of its own descendants would + // detach that whole branch from the tree into an unreachable cycle. + const { rows: cycle } = await pool.query( + `${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`, + [id, parsed] + ); + if (cycle.length) { + return res.status(400).json({ error: 'a category cannot be moved beneath itself' }); + } + } + parent = parsed; + } + + const sortOrder = Number.isSafeInteger(req.body.sort_order) + ? req.body.sort_order + : existing.rows[0].sort_order; + + try { + const { rows } = await pool.query( + `UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4 + RETURNING id, name, parent_id, sort_order`, + [name, parent, sortOrder, id] + ); + res.json(rows[0]); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a category with that name already exists here' }); + } + throw err; + } +}); + +router.delete('/:id', async (req: Request, res: Response) => { + const id = Number(req.params.id); + const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]); + if (!subtree.length) { + return res.status(404).json({ error: 'not found' }); + } + + const ids = subtree.map((row: { id: number }) => row.id); + const { rows: affected } = await pool.query( + `SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`, + [ids] + ); + + // The FK cascade takes the descendants; items fall back to NULL rather than + // being deleted along with their category. + await pool.query(`DELETE FROM categories WHERE id = $1`, [id]); + + res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n }); +}); + +export default router; diff --git a/backend/src/routes/adminTags.ts b/backend/src/routes/adminTags.ts new file mode 100644 index 0000000..f825902 --- /dev/null +++ b/backend/src/routes/adminTags.ts @@ -0,0 +1,104 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; +import { TAG_COLORS, tagColorFor } from '../utils'; + +const router = Router(); + +const UNIQUE_VIOLATION = '23505'; + +function readName(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + +// Colours are constrained to the antd preset palette the frontend can actually +// render — an arbitrary string would come out as an unstyled grey chip. +function readColor(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !TAG_COLORS.includes(value)) return null; + return value; +} + +router.get('/', async (_req: Request, res: Response) => { + const { rows } = await pool.query( + `SELECT t.id, t.name, t.color, + (SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count + FROM tags t + ORDER BY lower(t.name)` + ); + res.json(rows); +}); + +router.post('/', async (req: Request, res: Response) => { + const name = readName(req.body.name); + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const requestedColor = readColor(req.body.color); + if (requestedColor === null) { + return res.status(400).json({ error: 'invalid color' }); + } + const color = requestedColor ?? tagColorFor(name); + + try { + const { rows } = await pool.query( + `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`, + [name, color] + ); + res.status(201).json({ ...rows[0], item_count: 0 }); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a tag with that name already exists' }); + } + throw err; + } +}); + +router.put('/:id', async (req: Request, res: Response) => { + const id = Number(req.params.id); + const existing = await pool.query(`SELECT id, name, color FROM tags WHERE id = $1`, [id]); + if (!existing.rows.length) { + return res.status(404).json({ error: 'not found' }); + } + + let name = existing.rows[0].name; + if (req.body.name !== undefined) { + const parsed = readName(req.body.name); + if (!parsed) { + return res.status(400).json({ error: 'name is required' }); + } + name = parsed; + } + + let color = existing.rows[0].color; + if (req.body.color !== undefined) { + const parsed = readColor(req.body.color); + if (!parsed) { + return res.status(400).json({ error: 'invalid color' }); + } + color = parsed; + } + + try { + const { rows } = await pool.query( + `UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`, + [name, color, id] + ); + res.json(rows[0]); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a tag with that name already exists' }); + } + throw err; + } +}); + +router.delete('/:id', async (req: Request, res: Response) => { + // item_tags cascades; the items themselves are untouched. + await pool.query(`DELETE FROM tags WHERE id = $1`, [req.params.id]); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/filters.ts b/backend/src/routes/filters.ts new file mode 100644 index 0000000..8a5d5cf --- /dev/null +++ b/backend/src/routes/filters.ts @@ -0,0 +1,36 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +// Everything the storefront's filter drawer needs, in one request: the whole +// category tree (flat — the frontend nests it), every tag with its colour, and +// the catalogue's price bounds for the slider. +router.get('/', async (_req: Request, res: Response) => { + const [categories, tags, price] = await Promise.all([ + pool.query( + `SELECT id, name, parent_id, sort_order FROM categories ORDER BY sort_order, lower(name)` + ), + pool.query( + `SELECT t.id, t.name, t.color, COUNT(it.item_id)::int AS item_count + FROM tags t + LEFT JOIN item_tags it ON it.tag_id = t.id + GROUP BY t.id + ORDER BY lower(t.name)` + ), + // An empty catalogue would otherwise hand the slider a null range. + pool.query( + `SELECT COALESCE(MIN(price_cents), 0)::int AS min_cents, + COALESCE(MAX(price_cents), 0)::int AS max_cents + FROM items` + ) + ]); + + res.json({ + categories: categories.rows, + tags: tags.rows, + priceRange: price.rows[0] + }); +}); + +export default router; diff --git a/backend/src/routes/items.ts b/backend/src/routes/items.ts index 6f6a575..222d026 100755 --- a/backend/src/routes/items.ts +++ b/backend/src/routes/items.ts @@ -1,26 +1,31 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; +import { PUBLIC_ITEM_SELECT } from '../itemSelect'; +import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; const router = Router(); -const SELECT_WITH_IMAGES = ` - SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, - COALESCE( - json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) - ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL), - '[]' - ) AS images - FROM items i - LEFT JOIN item_images img ON img.item_id = i.id -`; +router.get('/', async (req: Request, res: Response) => { + let filters; + try { + filters = parseItemFilters(req.query as Record); + } catch (err) { + // A malformed filter is returned as an error rather than ignored, so a + // broken link shows itself instead of quietly listing the whole catalogue. + if (err instanceof FilterError) { + return res.status(400).json({ error: err.message }); + } + throw err; + } -router.get('/', async (_req: Request, res: Response) => { - const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`); + const { clauses, params } = buildItemFilterSql(filters, 1); + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); res.json(rows); }); router.get('/:id', async (req: Request, res: Response) => { - const { rows } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]); + const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'not found' }); res.json(rows[0]); }); diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 29aa561..6a923b7 100755 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -40,5 +40,29 @@ export function isValidEmail(email: string): boolean { return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label)); } +// antd's preset Tag colours. Kept as the single source of truth for tag +// colours so the admin palette picker and the auto-assignment below can never +// drift apart — the frontend renders whatever string lands in tags.color. +export const TAG_COLORS = [ + 'magenta', 'red', 'volcano', 'orange', 'gold', 'lime', + 'green', 'cyan', 'blue', 'geekblue', 'purple' +]; + +// Tags get a colour the moment they're created inline from the item form, with +// no prompt. Deriving it from the name (rather than picking at random or +// round-robining on insert order) means the same tag name always lands on the +// same colour, so a tag deleted and re-added doesn't silently change colour. +// The admin can still override it afterwards. +export function tagColorFor(name: string): string { + const normalized = name.trim().toLowerCase(); + // djb2 — cheap, well-spread for short strings, and stable across Node + // versions. `| 0` keeps it in int32 range instead of drifting into float. + let hash = 5381; + for (let i = 0; i < normalized.length; i++) { + hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0; + } + return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]; +} + export const MARKETING_CONSENT_TEXT = 'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.'; diff --git a/backend/tests/integration/categoriesTags.integration.test.ts b/backend/tests/integration/categoriesTags.integration.test.ts new file mode 100644 index 0000000..2cd42e9 --- /dev/null +++ b/backend/tests/integration/categoriesTags.integration.test.ts @@ -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 { + 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 { + 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 { + 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([]); + }); +}); diff --git a/backend/tests/integration/setup/testDb.ts b/backend/tests/integration/setup/testDb.ts index d35605b..207dece 100755 --- a/backend/tests/integration/setup/testDb.ts +++ b/backend/tests/integration/setup/testDb.ts @@ -29,7 +29,8 @@ export async function migrate(): Promise { export async function resetDb(): Promise { 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 `); } diff --git a/backend/tests/unit/itemFilters.test.ts b/backend/tests/unit/itemFilters.test.ts new file mode 100644 index 0000000..7015d50 --- /dev/null +++ b/backend/tests/unit/itemFilters.test.ts @@ -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'); + }); +}); diff --git a/backend/tests/unit/tagColor.test.ts b/backend/tests/unit/tagColor.test.ts new file mode 100644 index 0000000..fe8b3c7 --- /dev/null +++ b/backend/tests/unit/tagColor.test.ts @@ -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é')); + }); +});