|
| 1 | +import { Database } from 'bun:sqlite' |
| 2 | +import { SQL } from 'bun' |
| 3 | +import { describe, expect, test } from 'bun:test' |
| 4 | +import type { LicenseKeyListQuery } from './commerce-license-key-list' |
| 5 | +import { parseLicenseKeyListQuery, readLicenseKeyPage, searchLicenseKeyChoices } from './commerce-license-key-list' |
| 6 | +import type { CommerceSqlRunner } from './commerce-sql' |
| 7 | + |
| 8 | +/** |
| 9 | + * The Licence Keys list and its dialog's choices against each dialect's real |
| 10 | + * engine (stacksjs/stacks#3218), with template and status as real enums, over |
| 11 | + * 520 customers, products and orders: the page used to read the first 500 of |
| 12 | + * each. |
| 13 | + */ |
| 14 | +type Dialect = 'sqlite' | 'postgres' | 'mysql' |
| 15 | + |
| 16 | +const NOW = new Date('2026-11-06T12:00:00.000Z') |
| 17 | +const DAY = 86_400_000 |
| 18 | +const ROWS = 520 |
| 19 | +const TEMPLATES = ['Standard License', 'Premium License', 'Enterprise License'] as const |
| 20 | +const STATUSES = ['active', 'inactive', 'unassigned'] as const |
| 21 | +const stamp = (ms: number) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ') |
| 22 | +const customerName = (id: number) => id === 515 ? 'zelda Late' : id === 3 ? 'Zed early' : `Customer ${String(id).padStart(3, '0')}` |
| 23 | + |
| 24 | +/** 30 keys, one a day back from NOW; 11 and 12 share a creation time. */ |
| 25 | +const KEYS = Array.from({ length: 30 }, (_, index) => { |
| 26 | + const id = index + 1 |
| 27 | + const group = (n: number) => String(n).padStart(4, '0') |
| 28 | + return { |
| 29 | + id, |
| 30 | + key: `K${group(id).slice(1)}-AAAA-BBBB-CCCC-${id === 9 ? 'X1X0' : group(id)}`, |
| 31 | + template: TEMPLATES[id % 3]!, |
| 32 | + status: STATUSES[id % 3]!, |
| 33 | + customerId: id % 4 === 0 ? 515 : id % 4 === 1 ? 3 : null, |
| 34 | + productId: id % 5 === 0 ? 520 : null, |
| 35 | + orderId: id % 6 === 0 ? 510 : null, |
| 36 | + // Day offsets around the 30-day window: some inside, some past, some long gone. |
| 37 | + expiryDays: [-5, 1, 10, 29, 31, 90][id % 6]!, |
| 38 | + daysAgo: id === 12 ? 11 : id, |
| 39 | + } |
| 40 | +}) |
| 41 | +type Key = typeof KEYS[number] |
| 42 | + |
| 43 | +const ids = (keys: Key[]) => keys.map(key => String(key.id)) |
| 44 | +const newestFirst = (a: Key, b: Key) => a.daysAgo - b.daysAgo || b.id - a.id |
| 45 | + |
| 46 | +function query(overrides: Partial<LicenseKeyListQuery> = {}): LicenseKeyListQuery { |
| 47 | + return { search: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides } |
| 48 | +} |
| 49 | + |
| 50 | +async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) { |
| 51 | + const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY' |
| 52 | + const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT' |
| 53 | + const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME(3)' : 'TIMESTAMP' |
| 54 | + const p = (index: number) => dialect === 'postgres' ? `$${index}` : '?' |
| 55 | + const rows = (count: number, columns: number) => Array.from({ length: count }, (_, i) => `(${Array.from({ length: columns }, (_, c) => p(i * columns + c + 1)).join(', ')})`).join(', ') |
| 56 | + const enumOf = (values: readonly string[]) => values.map(value => `'${value}'`).join(', ') |
| 57 | + let template = text |
| 58 | + let status = text |
| 59 | + if (dialect === 'postgres') { |
| 60 | + await run(`CREATE TYPE key_template AS ENUM (${enumOf(TEMPLATES)})`) |
| 61 | + await run(`CREATE TYPE key_status AS ENUM (${enumOf(STATUSES)})`) |
| 62 | + template = 'key_template' |
| 63 | + status = 'key_status' |
| 64 | + } |
| 65 | + if (dialect === 'mysql') { |
| 66 | + template = `ENUM(${enumOf(TEMPLATES)})` |
| 67 | + status = `ENUM(${enumOf(STATUSES)})` |
| 68 | + } |
| 69 | + await run(`CREATE TABLE customers (id ${pk}, name ${text}, email ${text})`) |
| 70 | + await run(`CREATE TABLE products (id ${pk}, name ${text})`) |
| 71 | + await run(`CREATE TABLE orders (id ${pk}, status ${text})`) |
| 72 | + await run(`CREATE TABLE license_keys (id ${pk}, ${dialect === 'mysql' ? '`key`' : 'key'} ${text}, template ${template}, expiry_date ${time}, status ${status}, customer_id INTEGER NULL, product_id INTEGER NULL, order_id INTEGER NULL, created_at ${time}, updated_at ${time} NULL, uuid ${text} NULL)`) |
| 73 | + const all = Array.from({ length: ROWS }, (_, i) => i + 1) |
| 74 | + await run(`INSERT INTO customers (id, name, email) VALUES ${rows(ROWS, 3)}`, all.flatMap(id => [id, customerName(id), `buyer${id}@shop.test`])) |
| 75 | + await run(`INSERT INTO products (id, name) VALUES ${rows(ROWS, 2)}`, all.flatMap(id => [id, id === 520 ? 'Zebra Suite' : `Product ${String(id).padStart(3, '0')}`])) |
| 76 | + await run(`INSERT INTO orders (id, status) VALUES ${rows(ROWS, 2)}`, all.flatMap(id => [id, id === 510 ? 'refunded' : 'completed'])) |
| 77 | + const keyColumn = dialect === 'mysql' ? '`key`' : 'key' |
| 78 | + await run( |
| 79 | + `INSERT INTO license_keys (id, ${keyColumn}, template, expiry_date, status, customer_id, product_id, order_id, created_at) VALUES ${rows(KEYS.length, 9)}`, |
| 80 | + KEYS.flatMap(key => [key.id, key.key, key.template, stamp(NOW.getTime() + key.expiryDays * DAY), key.status, key.customerId, key.productId, key.orderId, stamp(NOW.getTime() - key.daysAgo * DAY)]), |
| 81 | + ) |
| 82 | +} |
| 83 | + |
| 84 | +async function exercise(runner: CommerceSqlRunner, dialect: Dialect) { |
| 85 | + const read = (overrides: Partial<LicenseKeyListQuery> = {}) => readLicenseKeyPage(query(overrides), runner, dialect, NOW) |
| 86 | + const all = async (overrides: Partial<LicenseKeyListQuery> = {}) => (await read({ perPage: 100, ...overrides })).records.map(record => String(record.id)) |
| 87 | + const matching = (keep: (key: Key) => boolean) => ids(KEYS.filter(keep).sort(newestFirst)) |
| 88 | + |
| 89 | + // The summary counts every key, whatever the page is searched for. |
| 90 | + const searched = await read({ search: 'zelda' }) |
| 91 | + expect(searched.summary).toEqual({ |
| 92 | + total: KEYS.length, |
| 93 | + active: KEYS.filter(key => key.status === 'active').length, |
| 94 | + assigned: KEYS.filter(key => key.customerId || key.productId || key.orderId).length, |
| 95 | + expiring: KEYS.filter(key => key.expiryDays >= 0 && key.expiryDays <= 30).length, |
| 96 | + }) |
| 97 | + expect(searched.total).toBe(KEYS.filter(key => key.customerId === 515).length) |
| 98 | + |
| 99 | + // Search: the key with a digit pattern, the customer's name and email, the product, the order id, the enums. |
| 100 | + expect(await all({ search: 'x1x0' })).toEqual(['9']) |
| 101 | + expect(await all({ search: 'BUYER515@' })).toEqual(matching(key => key.customerId === 515)) |
| 102 | + expect(await all({ search: 'zebra suite' })).toEqual(matching(key => key.productId === 520)) |
| 103 | + expect(await all({ search: '510' })).toEqual(matching(key => key.orderId === 510)) |
| 104 | + expect(await all({ search: 'ENTERPRISE' })).toEqual(matching(key => key.template === 'Enterprise License')) |
| 105 | + |
| 106 | + // Newest first, the id breaking the tie between 11 and 12, stable across pages. |
| 107 | + const newest = [...KEYS].sort(newestFirst) |
| 108 | + const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })] |
| 109 | + expect(pages.flatMap(result => result.records.map(record => String(record.id)))).toEqual(ids(newest)) |
| 110 | + |
| 111 | + // Sorts by name, not enum order; ties newest first. |
| 112 | + const by = (value: (key: Key) => string) => (a: Key, b: Key) => value(a).localeCompare(value(b)) || newestFirst(a, b) |
| 113 | + expect(await all({ sort: 'template', direction: 'asc' })).toEqual(ids([...KEYS].sort(by(key => key.template)))) |
| 114 | + expect(await all({ sort: 'status', direction: 'asc' })).toEqual(ids([...KEYS].sort(by(key => key.status)))) |
| 115 | + expect(await all({ sort: 'customer', direction: 'asc' })).toEqual(ids([...KEYS].sort(by(key => key.customerId ? customerName(key.customerId).toLowerCase() : '')))) |
| 116 | + |
| 117 | + // The links past the 500th come with the key. |
| 118 | + const linked = (await read({ search: 'BUYER515@' })).records.find(record => record.product_id === 520 || record.order_id === 510) ?? (await read({ search: 'BUYER515@' })).records[0]! |
| 119 | + expect(linked.customer).toEqual({ id: 515, label: 'zelda Late', detail: 'buyer515@shop.test' }) |
| 120 | + const withProduct = (await read({ search: 'zebra suite' })).records[0]! |
| 121 | + expect(withProduct.product).toEqual({ id: 520, label: 'Zebra Suite' }) |
| 122 | + const withOrder = (await read({ search: '510' })).records[0]! |
| 123 | + expect(withOrder.order).toEqual({ id: 510, label: 'Order #510', detail: 'refunded' }) |
| 124 | + |
| 125 | + // The dialog's choices: bounded, searched, past the 500th. |
| 126 | + const choose = (kind: 'customers' | 'products' | 'orders', search: string, limit = 25) => searchLicenseKeyChoices(kind, { search, limit }, runner, dialect) |
| 127 | + expect((await choose('customers', 'ZE')).choices.map(choice => choice.label)).toEqual(['Zed early', 'zelda Late']) |
| 128 | + expect(await choose('products', '520')).toEqual({ choices: [{ id: 520, label: 'Zebra Suite' }], total: 1 }) |
| 129 | + expect((await choose('orders', 'refund')).choices).toEqual([{ id: 510, label: 'Order #510', detail: 'refunded' }]) |
| 130 | + const newestOrders = await choose('orders', '', 3) |
| 131 | + expect(newestOrders.choices.map(choice => choice.id)).toEqual([520, 519, 518]) |
| 132 | + expect(newestOrders.total).toBe(ROWS) |
| 133 | +} |
| 134 | + |
| 135 | +describe('readLicenseKeyPage on SQLite', () => { |
| 136 | + test('summary, search, sorts, pages, links and choices', async () => { |
| 137 | + const database = new Database(':memory:') |
| 138 | + await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite') |
| 139 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite') |
| 140 | + }) |
| 141 | +}) |
| 142 | + |
| 143 | +for (const dialect of ['postgres', 'mysql'] as const) { |
| 144 | + const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL |
| 145 | + |
| 146 | + test.skipIf(!connection)(`readLicenseKeyPage on ${dialect}: summary, search, sorts, pages, links and choices`, async () => { |
| 147 | + const url = new URL(connection!) |
| 148 | + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) |
| 149 | + throw new Error('Licence key list tests require a local disposable database server') |
| 150 | + |
| 151 | + const name = `stacks_license_keys_${crypto.randomUUID().replaceAll('-', '')}` |
| 152 | + const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"` |
| 153 | + const admin = new SQL(url.href) |
| 154 | + await admin.unsafe(`CREATE DATABASE ${quoted}`) |
| 155 | + const scoped = new URL(url.href) |
| 156 | + scoped.pathname = `/${name}` |
| 157 | + const database = new SQL(scoped.href) |
| 158 | + |
| 159 | + try { |
| 160 | + await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect) |
| 161 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect) |
| 162 | + } |
| 163 | + finally { |
| 164 | + await database.close() |
| 165 | + try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) } |
| 166 | + finally { await admin.close() } |
| 167 | + } |
| 168 | + }, 60_000) |
| 169 | +} |
| 170 | + |
| 171 | +describe('parseLicenseKeyListQuery', () => { |
| 172 | + const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? '' |
| 173 | + |
| 174 | + test('defaults to every key, newest first, 25 a page', () => { |
| 175 | + expect(parseLicenseKeyListQuery(reader({}))).toEqual({ search: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 }) |
| 176 | + expect(parseLicenseKeyListQuery(reader({ sort: 'expiry' }))).toHaveProperty('direction', 'asc') |
| 177 | + }) |
| 178 | + |
| 179 | + test('refuses values the page does not offer', () => { |
| 180 | + expect(parseLicenseKeyListQuery(reader({ sort: 'uuid' }))).toHaveProperty('error') |
| 181 | + expect(parseLicenseKeyListQuery(reader({ direction: 'up' }))).toHaveProperty('error') |
| 182 | + }) |
| 183 | +}) |
0 commit comments