|
| 1 | +import { Database } from 'bun:sqlite' |
| 2 | +import { SQL } from 'bun' |
| 3 | +import { describe, expect, test } from 'bun:test' |
| 4 | +import type { OrderListQuery, OrderListRunner } from './commerce-order-list' |
| 5 | +import { ordersCsv, parseOrderListQuery, readOrderPage, readOrdersForExport } from './commerce-order-list' |
| 6 | + |
| 7 | +/** |
| 8 | + * The Orders list against each dialect's real engine (stacksjs/stacks#3218): |
| 9 | + * search, filters, sorts, paging, store-wide summary and the full export. |
| 10 | + */ |
| 11 | +type Dialect = 'sqlite' | 'postgres' | 'mysql' |
| 12 | + |
| 13 | +const CUSTOMERS: Array<[number, string, string]> = [ |
| 14 | + [1, 'Ava Stone', 'ava@example.com'], |
| 15 | + [2, 'ben carter', 'BEN@example.com'], |
| 16 | + [3, 'Cara Lim', 'cara@example.com'], |
| 17 | +] |
| 18 | +const STATUSES = ['PENDING', 'preparing', 'DELIVERED', 'CANCELED', 'READY'] |
| 19 | +const TYPES = ['DELIVERY', 'TAKEOUT', 'DINE_IN'] |
| 20 | + |
| 21 | +/** 30 orders: customers 1-3 and unassigned in turn, five statuses, three types. */ |
| 22 | +const ORDERS = Array.from({ length: 30 }, (_, index) => { |
| 23 | + const id = index + 1 |
| 24 | + return { |
| 25 | + id, |
| 26 | + customer_id: id % 4 === 0 ? null : (id % 3) + 1, |
| 27 | + status: STATUSES[id % STATUSES.length]!, |
| 28 | + total_amount: (id * 37) % 101 * 100, |
| 29 | + // Two spellings of one currency must be one total. |
| 30 | + currency: id % 7 === 0 ? 'eur' : (id % 2 === 0 ? 'EUR' : 'USD'), |
| 31 | + order_type: TYPES[id % TYPES.length]!, |
| 32 | + delivery_address: id === 13 ? '100% Sure Street' : `${id} Main Street`, |
| 33 | + coupon_id: id % 10 === 0 ? 2 : null, |
| 34 | + created_at: `2026-11-${String(1 + (id % 9)).padStart(2, '0')} 10:00:00`, |
| 35 | + } |
| 36 | +}) |
| 37 | + |
| 38 | +function query(overrides: Partial<OrderListQuery> = {}): OrderListQuery { |
| 39 | + return { search: '', status: '', type: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides } |
| 40 | +} |
| 41 | + |
| 42 | +async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) { |
| 43 | + const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY' |
| 44 | + const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME' : 'TIMESTAMP' |
| 45 | + const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT' |
| 46 | + await run(`CREATE TABLE customers (id ${pk}, name ${text}, email ${text})`) |
| 47 | + await run(`CREATE TABLE coupons (id ${pk})`) |
| 48 | + await run(`CREATE TABLE order_items (id ${pk}, order_id INTEGER, quantity INTEGER)`) |
| 49 | + await run(`CREATE TABLE orders (id ${pk}, uuid ${text}, customer_id INTEGER NULL, coupon_id INTEGER NULL, status ${text}, total_amount INTEGER, currency ${text}, tax_amount INTEGER, discount_amount INTEGER, delivery_fee INTEGER, tip_amount INTEGER, order_type ${text}, delivery_address ${text} NULL, special_instructions ${text} NULL, estimated_delivery_time ${text} NULL, applied_coupon_id ${text} NULL, created_at ${time})`) |
| 50 | + const p = (n: number) => dialect === 'postgres' ? `$${n}` : '?' |
| 51 | + const ps = (count: number) => Array.from({ length: count }, (_, i) => p(i + 1)).join(', ') |
| 52 | + for (const customer of CUSTOMERS) |
| 53 | + await run(`INSERT INTO customers (id, name, email) VALUES (${ps(3)})`, customer) |
| 54 | + await run(`INSERT INTO coupons (id) VALUES (${ps(1)})`, [2]) |
| 55 | + for (const order of ORDERS) { |
| 56 | + await run( |
| 57 | + `INSERT INTO orders (id, customer_id, coupon_id, status, total_amount, currency, tax_amount, discount_amount, delivery_fee, tip_amount, order_type, delivery_address, created_at) VALUES (${ps(13)})`, |
| 58 | + [order.id, order.customer_id, order.coupon_id, order.status, order.total_amount, order.currency, 0, 0, 0, 0, order.order_type, order.delivery_address, order.created_at], |
| 59 | + ) |
| 60 | + } |
| 61 | + // Order 5 has two lines, 3 + 2 items. |
| 62 | + await run(`INSERT INTO order_items (id, order_id, quantity) VALUES (${ps(3)})`, [1, 5, 3]) |
| 63 | + await run(`INSERT INTO order_items (id, order_id, quantity) VALUES (${ps(3)})`, [2, 5, 2]) |
| 64 | +} |
| 65 | + |
| 66 | +async function exercise(runner: OrderListRunner, dialect: Dialect) { |
| 67 | + const read = (overrides: Partial<OrderListQuery> = {}) => readOrderPage(query(overrides), runner, dialect) |
| 68 | + |
| 69 | + // The summary covers every order, not the page of 10. |
| 70 | + const first = await read() |
| 71 | + expect(first.total).toBe(30) |
| 72 | + expect(first.records).toHaveLength(10) |
| 73 | + expect(first.summary.total).toBe(30) |
| 74 | + const statusCount = (names: string[]) => ORDERS.filter(order => names.includes(order.status.toUpperCase())).length |
| 75 | + expect(first.summary.pending).toBe(statusCount(['PENDING', 'NEW'])) |
| 76 | + expect(first.summary.inProgress).toBe(statusCount(['PREPARING', 'PROCESSING', 'READY', 'SHIPPED'])) |
| 77 | + expect(first.summary.completed).toBe(statusCount(['DELIVERED', 'COMPLETED', 'PAID'])) |
| 78 | + expect(first.summary.cancelled).toBe(statusCount(['CANCELED', 'CANCELLED', 'REFUNDED'])) |
| 79 | + const sum = (currency: string) => ORDERS.filter(order => order.currency.toUpperCase() === currency).reduce((total, order) => total + order.total_amount, 0) |
| 80 | + expect(first.summary.currencyTotals).toEqual([{ currency: 'EUR', amount: sum('EUR') }, { currency: 'USD', amount: sum('USD') }]) |
| 81 | + expect(first.statuses).toContain('preparing') |
| 82 | + |
| 83 | + // Newest first, ties broken by id descending: stable across page boundaries. |
| 84 | + const expected = [...ORDERS].sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id - a.id).map(order => String(order.id)) |
| 85 | + const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })] |
| 86 | + expect(pages.flatMap(result => result.records.map(record => record.id))).toEqual(expected) |
| 87 | + |
| 88 | + // Past the end moves back to the last page. |
| 89 | + expect((await read({ page: 9 })).page).toBe(3) |
| 90 | + |
| 91 | + // Search: customer name in any case, address, literal %, id. |
| 92 | + const ben = await read({ search: 'BEN', perPage: 100 }) |
| 93 | + expect(ben.total).toBe(ORDERS.filter(order => order.customer_id === 2).length) |
| 94 | + expect(ben.records.every(record => record.customerName === 'ben carter')).toBeTrue() |
| 95 | + expect((await read({ search: '100%' })).records.map(record => record.id)).toEqual(['13']) |
| 96 | + expect((await read({ search: '27', perPage: 100 })).records.map(record => record.id)).toContain('27') |
| 97 | + |
| 98 | + // Filters are exact values. |
| 99 | + expect((await read({ status: 'preparing', perPage: 100 })).total).toBe(ORDERS.filter(order => order.status === 'preparing').length) |
| 100 | + expect((await read({ type: 'TAKEOUT', perPage: 100 })).total).toBe(ORDERS.filter(order => order.order_type === 'TAKEOUT').length) |
| 101 | + |
| 102 | + // Totals sort, high first. |
| 103 | + const byTotal = await read({ sort: 'total', direction: 'desc', perPage: 100 }) |
| 104 | + const totals = byTotal.records.map(record => record.totalAmount) |
| 105 | + expect(totals).toEqual([...totals].sort((a, b) => b - a)) |
| 106 | + |
| 107 | + // Customer sort: unassigned first ascending, then by name ignoring case. |
| 108 | + const byCustomer = (await read({ sort: 'customer', direction: 'asc', perPage: 100 })).records.map(record => record.customerName) |
| 109 | + const unassigned = ORDERS.filter(order => order.customer_id === null).length |
| 110 | + expect(byCustomer.slice(0, unassigned).every(name => name === '')).toBeTrue() |
| 111 | + expect(byCustomer.slice(unassigned)).toEqual([...byCustomer.slice(unassigned)].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))) |
| 112 | + |
| 113 | + // Item counts and coupon references come through. |
| 114 | + const five = (await read({ search: '5 Main', perPage: 100 })).records.find(record => record.id === '5') |
| 115 | + expect(five?.itemCount).toBe(5) |
| 116 | + expect((await read({ search: '10 Main' })).records[0]?.couponId).toBe('2') |
| 117 | + |
| 118 | + // The export reads every match, in order, across batches. |
| 119 | + const exported = await readOrdersForExport({ search: '', status: '', type: '', sort: 'created', direction: 'desc' }, runner, dialect, 4) |
| 120 | + expect(exported.map(record => record.id)).toEqual(expected) |
| 121 | + const filtered = await readOrdersForExport({ search: 'ben', status: '', type: '', sort: 'created', direction: 'desc' }, runner, dialect, 4) |
| 122 | + expect(filtered).toHaveLength(ben.total) |
| 123 | +} |
| 124 | + |
| 125 | +describe('readOrderPage on SQLite', () => { |
| 126 | + test('searches, filters, sorts, pages, summarises and exports', async () => { |
| 127 | + const database = new Database(':memory:') |
| 128 | + await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite') |
| 129 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite') |
| 130 | + }) |
| 131 | +}) |
| 132 | + |
| 133 | +for (const dialect of ['postgres', 'mysql'] as const) { |
| 134 | + const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL |
| 135 | + |
| 136 | + test.skipIf(!connection)(`readOrderPage on ${dialect} searches, filters, sorts, pages, summarises and exports`, async () => { |
| 137 | + const url = new URL(connection!) |
| 138 | + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) |
| 139 | + throw new Error('Order list tests require a local disposable database server') |
| 140 | + |
| 141 | + const name = `stacks_order_list_${crypto.randomUUID().replaceAll('-', '')}` |
| 142 | + const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"` |
| 143 | + const admin = new SQL(url.href) |
| 144 | + await admin.unsafe(`CREATE DATABASE ${quoted}`) |
| 145 | + const scoped = new URL(url.href) |
| 146 | + scoped.pathname = `/${name}` |
| 147 | + const database = new SQL(scoped.href) |
| 148 | + |
| 149 | + try { |
| 150 | + await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect) |
| 151 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect) |
| 152 | + } |
| 153 | + finally { |
| 154 | + await database.close() |
| 155 | + try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) } |
| 156 | + finally { await admin.close() } |
| 157 | + } |
| 158 | + }, 60_000) |
| 159 | +} |
| 160 | + |
| 161 | +describe('parseOrderListQuery', () => { |
| 162 | + const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? '' |
| 163 | + |
| 164 | + test('defaults to the newest 25', () => { |
| 165 | + expect(parseOrderListQuery(reader({}))).toEqual({ search: '', status: '', type: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 }) |
| 166 | + }) |
| 167 | + |
| 168 | + test('rejects what it cannot honour', () => { |
| 169 | + expect(parseOrderListQuery(reader({ sort: 'secret_column' }))).toHaveProperty('error') |
| 170 | + expect(parseOrderListQuery(reader({ direction: 'sideways' }))).toHaveProperty('error') |
| 171 | + expect(parseOrderListQuery(reader({ per_page: '7' }))).toHaveProperty('error') |
| 172 | + expect(parseOrderListQuery(reader({ page: '0' }))).toHaveProperty('error') |
| 173 | + }) |
| 174 | +}) |
| 175 | + |
| 176 | +test('the CSV keeps the columns the page exported, with every row', () => { |
| 177 | + const record = { id: '7', customerId: '', customerName: 'Ava "A" Stone', customerEmail: 'ava@example.com', status: 'READY', totalAmount: 1250, currency: 'USD', taxAmount: 0, discountAmount: 0, deliveryFee: 0, tipAmount: 0, orderType: 'DELIVERY', deliveryAddress: '1 Main', specialInstructions: '', estimatedDeliveryTime: '', appliedCouponId: '', couponId: '', itemCount: 2, createdAt: '2026-11-01T10:00:00.000Z' } |
| 178 | + const csv = ordersCsv([record], (amount) => (amount / 100).toFixed(2)) |
| 179 | + const [header, row] = csv.replace('', '').split('\n') |
| 180 | + expect(header).toBe('"Order ID","Customer","Customer email","Status","Order type","Items","Total","Currency","Tax","Discount","Delivery fee","Tip","Delivery address","Created at"') |
| 181 | + expect(row).toBe('"7","Ava ""A"" Stone","ava@example.com","READY","DELIVERY","2","12.50","USD","0.00","0.00","0.00","0.00","1 Main","2026-11-01T10:00:00.000Z"') |
| 182 | +}) |
0 commit comments