|
| 1 | +import { Database } from 'bun:sqlite' |
| 2 | +import { SQL } from 'bun' |
| 3 | +import { describe, expect, test } from 'bun:test' |
| 4 | +import type { CommerceSqlRunner } from './commerce-sql' |
| 5 | +import type { ProductListQuery } from './commerce-product-list' |
| 6 | +import { parseProductListQuery, productsCsv, readProductPage, readProductsForExport } from './commerce-product-list' |
| 7 | + |
| 8 | +/** |
| 9 | + * The Products list against each dialect's real engine (stacksjs/stacks#3218), |
| 10 | + * with is_available as each dialect's own boolean. |
| 11 | + */ |
| 12 | +type Dialect = 'sqlite' | 'postgres' | 'mysql' |
| 13 | + |
| 14 | +const CATEGORIES: Array<[number, string]> = [[1, 'Pastry'], [2, 'drinks'], [3, 'Mains']] |
| 15 | +const MANUFACTURERS: Array<[number, string]> = [[1, 'Acme Mills'], [2, 'Bean Co']] |
| 16 | + |
| 17 | +/** 28 products across three categories, some unavailable, some out of stock. */ |
| 18 | +const PRODUCTS = Array.from({ length: 28 }, (_, index) => { |
| 19 | + const id = index + 1 |
| 20 | + return { |
| 21 | + id, |
| 22 | + name: id === 9 ? 'Flat White 100%' : `Product ${String.fromCharCode(65 + (id % 26))}${id}`, |
| 23 | + slug: `product-${id}`, |
| 24 | + price: (id * 37) % 23 * 100, |
| 25 | + is_available: id % 4 !== 0, |
| 26 | + inventory_count: id % 5 === 0 ? 0 : id * 3, |
| 27 | + category_id: (id % 3) + 1, |
| 28 | + manufacturer_id: id % 2 === 0 ? 2 : (id % 7 === 0 ? null : 1), |
| 29 | + allergens: id === 11 ? '["peanuts","milk"]' : '[]', |
| 30 | + created_at: `2026-10-${String(1 + (id % 10)).padStart(2, '0')} 09:00:00`, |
| 31 | + } |
| 32 | +}) |
| 33 | + |
| 34 | +function query(overrides: Partial<ProductListQuery> = {}): ProductListQuery { |
| 35 | + return { search: '', category: '', availability: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides } |
| 36 | +} |
| 37 | + |
| 38 | +async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) { |
| 39 | + const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY' |
| 40 | + const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT' |
| 41 | + const bool = dialect === 'postgres' ? 'BOOLEAN' : dialect === 'mysql' ? 'TINYINT(1)' : 'INTEGER' |
| 42 | + const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME' : 'TIMESTAMP' |
| 43 | + await run(`CREATE TABLE categories (id ${pk}, name ${text})`) |
| 44 | + await run(`CREATE TABLE manufacturers (id ${pk}, manufacturer ${text})`) |
| 45 | + for (const table of ['product_variants', 'product_units', 'reviews']) |
| 46 | + await run(`CREATE TABLE ${table} (id ${pk}, product_id INTEGER)`) |
| 47 | + await run(`CREATE TABLE products (id ${pk}, name ${text}, slug ${text}, description ${text} NULL, price INTEGER, image_url ${text} NULL, is_available ${bool}, inventory_count INTEGER, preparation_time INTEGER, allergens ${text} NULL, nutritional_info ${text} NULL, category_id INTEGER NULL, manufacturer_id INTEGER NULL, created_at ${time})`) |
| 48 | + const p = (count: number) => Array.from({ length: count }, (_, i) => dialect === 'postgres' ? `$${i + 1}` : '?').join(', ') |
| 49 | + for (const [id, name] of CATEGORIES) |
| 50 | + await run(`INSERT INTO categories (id, name) VALUES (${p(2)})`, [id, name]) |
| 51 | + for (const [id, name] of MANUFACTURERS) |
| 52 | + await run(`INSERT INTO manufacturers (id, manufacturer) VALUES (${p(2)})`, [id, name]) |
| 53 | + for (const product of PRODUCTS) { |
| 54 | + await run( |
| 55 | + `INSERT INTO products (id, name, slug, price, is_available, inventory_count, preparation_time, allergens, category_id, manufacturer_id, created_at) VALUES (${p(11)})`, |
| 56 | + [product.id, product.name, product.slug, product.price, dialect === 'postgres' ? product.is_available : (product.is_available ? 1 : 0), product.inventory_count, 5, product.allergens, product.category_id, product.manufacturer_id, product.created_at], |
| 57 | + ) |
| 58 | + } |
| 59 | + // Product 4 has two variants, one unit and three reviews. |
| 60 | + let next = 1 |
| 61 | + for (const [table, count] of [['product_variants', 2], ['product_units', 1], ['reviews', 3]] as const) { |
| 62 | + for (let i = 0; i < count; i++) |
| 63 | + await run(`INSERT INTO ${table} (id, product_id) VALUES (${p(2)})`, [next++, 4]) |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +async function exercise(runner: CommerceSqlRunner, dialect: Dialect) { |
| 68 | + const read = (overrides: Partial<ProductListQuery> = {}) => readProductPage(query(overrides), runner, dialect) |
| 69 | + |
| 70 | + // The summary covers the catalogue, not the page. |
| 71 | + const first = await read() |
| 72 | + expect(first.total).toBe(28) |
| 73 | + expect(first.records).toHaveLength(10) |
| 74 | + expect(first.summary).toEqual({ |
| 75 | + total: 28, |
| 76 | + available: PRODUCTS.filter(product => product.is_available).length, |
| 77 | + unavailable: PRODUCTS.filter(product => !product.is_available).length, |
| 78 | + outOfStock: PRODUCTS.filter(product => product.inventory_count === 0).length, |
| 79 | + totalInventory: PRODUCTS.reduce((sum, product) => sum + product.inventory_count, 0), |
| 80 | + }) |
| 81 | + // Case is ignored, the same on every dialect. |
| 82 | + expect(first.categories.map(option => option.label)).toEqual(['drinks', 'Mains', 'Pastry']) |
| 83 | + |
| 84 | + // Filters. |
| 85 | + expect((await read({ category: '2', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.category_id === 2).length) |
| 86 | + expect((await read({ availability: 'available', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.is_available).length) |
| 87 | + expect((await read({ availability: 'unavailable', perPage: 100 })).total).toBe(PRODUCTS.filter(product => !product.is_available).length) |
| 88 | + expect((await read({ availability: 'out-of-stock', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.inventory_count === 0).length) |
| 89 | + |
| 90 | + // Search: the joined category and manufacturer, allergens, a literal %. |
| 91 | + expect((await read({ search: 'DRINKS', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.category_id === 2).length) |
| 92 | + expect((await read({ search: 'bean co', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.manufacturer_id === 2).length) |
| 93 | + expect((await read({ search: 'peanut' })).records.map(record => record.id)).toEqual(['11']) |
| 94 | + expect((await read({ search: '100%' })).records.map(record => record.id)).toEqual(['9']) |
| 95 | + |
| 96 | + // Newest first with id breaking ties, stable across pages. |
| 97 | + const newest = [...PRODUCTS].sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id - a.id).map(product => String(product.id)) |
| 98 | + const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })] |
| 99 | + expect(pages.flatMap(result => result.records.map(record => record.id))).toEqual(newest) |
| 100 | + expect((await read({ page: 7 })).page).toBe(3) |
| 101 | + |
| 102 | + // Price high first, ties by name. |
| 103 | + const byPrice = (await read({ sort: 'price', direction: 'desc', perPage: 100 })).records |
| 104 | + const expectedPrice = [...PRODUCTS].sort((a, b) => b.price - a.price || a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || b.id - a.id).map(product => String(product.id)) |
| 105 | + expect(byPrice.map(record => record.id)).toEqual(expectedPrice) |
| 106 | + |
| 107 | + // Names sort ignoring case. |
| 108 | + const names = (await read({ sort: 'name', direction: 'asc', perPage: 100 })).records.map(record => record.name) |
| 109 | + expect(names).toEqual([...names].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0)) |
| 110 | + |
| 111 | + // Relations and references come through. |
| 112 | + const four = (await read({ search: 'product e4' })).records.find(record => record.id === '4') |
| 113 | + expect(four).toEqual(expect.objectContaining({ variantCount: 2, unitCount: 1, reviewCount: 3, categoryName: 'drinks', manufacturerName: 'Bean Co' })) |
| 114 | + |
| 115 | + // The export holds every match, across batches, in order. |
| 116 | + const exported = await readProductsForExport({ search: '', category: '', availability: '', sort: 'created', direction: 'desc' }, runner, dialect, 4) |
| 117 | + expect(exported.map(record => record.id)).toEqual(newest) |
| 118 | +} |
| 119 | + |
| 120 | +describe('readProductPage on SQLite', () => { |
| 121 | + test('summary, filters, search, sorts, pages and export', async () => { |
| 122 | + const database = new Database(':memory:') |
| 123 | + await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite') |
| 124 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite') |
| 125 | + }) |
| 126 | +}) |
| 127 | + |
| 128 | +for (const dialect of ['postgres', 'mysql'] as const) { |
| 129 | + const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL |
| 130 | + |
| 131 | + test.skipIf(!connection)(`readProductPage on ${dialect}: summary, filters, search, sorts, pages and export`, async () => { |
| 132 | + const url = new URL(connection!) |
| 133 | + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) |
| 134 | + throw new Error('Product list tests require a local disposable database server') |
| 135 | + |
| 136 | + const name = `stacks_product_list_${crypto.randomUUID().replaceAll('-', '')}` |
| 137 | + const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"` |
| 138 | + const admin = new SQL(url.href) |
| 139 | + await admin.unsafe(`CREATE DATABASE ${quoted}`) |
| 140 | + const scoped = new URL(url.href) |
| 141 | + scoped.pathname = `/${name}` |
| 142 | + const database = new SQL(scoped.href) |
| 143 | + |
| 144 | + try { |
| 145 | + await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect) |
| 146 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect) |
| 147 | + } |
| 148 | + finally { |
| 149 | + await database.close() |
| 150 | + try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) } |
| 151 | + finally { await admin.close() } |
| 152 | + } |
| 153 | + }, 60_000) |
| 154 | +} |
| 155 | + |
| 156 | +describe('parseProductListQuery', () => { |
| 157 | + const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? '' |
| 158 | + |
| 159 | + test('defaults to the newest 25', () => { |
| 160 | + expect(parseProductListQuery(reader({}))).toEqual({ search: '', category: '', availability: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 }) |
| 161 | + }) |
| 162 | + |
| 163 | + test('rejects what it cannot honour', () => { |
| 164 | + expect(parseProductListQuery(reader({ category: '1 OR 1=1' }))).toHaveProperty('error') |
| 165 | + expect(parseProductListQuery(reader({ availability: 'maybe' }))).toHaveProperty('error') |
| 166 | + expect(parseProductListQuery(reader({ sort: 'cost' }))).toHaveProperty('error') |
| 167 | + }) |
| 168 | +}) |
| 169 | + |
| 170 | +test('the CSV keeps the columns the page exported', () => { |
| 171 | + const [header] = productsCsv([], 'USD', amount => String(amount)).replace('', '').split('\n') |
| 172 | + expect(header).toBe('"Product ID","Name","Description","Price","Currency","Availability","Inventory","Preparation minutes","Category","Manufacturer","Allergens","Variants","Units","Reviews","Created at"') |
| 173 | +}) |
0 commit comments