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,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;
`);
};
+6
View File
@@ -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);
+145
View File
@@ -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<string, unknown>): 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 };
}
+43
View File
@@ -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}`;
+94 -15
View File
@@ -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<number[]> {
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<void> {
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');
+159
View File
@@ -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<boolean> {
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;
+104
View File
@@ -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;
+36
View File
@@ -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;
+18 -13
View File
@@ -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<string, unknown>);
} 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]);
});
+24
View File
@@ -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.';
@@ -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é'));
});
});