|
| 1 | +import { Database } from 'bun:sqlite' |
| 2 | +import { SQL } from 'bun' |
| 3 | +import { describe, expect, test } from 'bun:test' |
| 4 | +import type { ReviewListQuery } from './commerce-review-list' |
| 5 | +import { parseReviewListQuery, readReviewPage } from './commerce-review-list' |
| 6 | +import type { CommerceSqlRunner } from './commerce-sql' |
| 7 | + |
| 8 | +/** |
| 9 | + * The Reviews list against each dialect's real engine (stacksjs/stacks#3218), |
| 10 | + * with the flags as each dialect stores booleans, and products and customers |
| 11 | + * past the 500 the page used to read. |
| 12 | + */ |
| 13 | +type Dialect = 'sqlite' | 'postgres' | 'mysql' |
| 14 | + |
| 15 | +const NOW = new Date('2026-11-06T12:00:00.000Z') |
| 16 | +const ROWS = 600 |
| 17 | +const PRODUCT_NAMES: Record<number, string> = { 1: 'Coffee Beans', 2: 'apron', 3: 'Mug', 4: 'Unreviewed', 600: 'zebra Tea' } |
| 18 | +const CUSTOMER_NAMES: Record<number, string> = { 5: 'zoe', 6: 'Adam', 599: 'mia' } |
| 19 | +const productName = (id: number) => PRODUCT_NAMES[id] ?? `Product ${id}` |
| 20 | +const customerName = (id: number) => CUSTOMER_NAMES[id] ?? `Customer ${id}` |
| 21 | + |
| 22 | +/** 30 reviews, one an hour back from NOW; 20 and 21 share a creation time. */ |
| 23 | +const REVIEWS = Array.from({ length: 30 }, (_, index) => { |
| 24 | + const id = index + 1 |
| 25 | + return { |
| 26 | + id, |
| 27 | + product_id: id === 29 ? null : [1, 2, 3, 600][id % 4]!, |
| 28 | + customer_id: [5, 6, 599, null][id % 4] ?? null, |
| 29 | + rating: id % 7 === 0 ? null : (id % 5) + 1, |
| 30 | + title: id === 11 ? 'Great_100% value' : id === 12 ? 'Great 1000 value' : `Review ${id}`, |
| 31 | + content: id === 17 ? 'Smells LIKE roses' : `Words for review ${id}`, |
| 32 | + is_approved: id % 2 === 0, |
| 33 | + is_featured: id % 5 === 0, |
| 34 | + is_verified_purchase: id % 3 === 0, |
| 35 | + helpful_votes: (id * 7) % 10, |
| 36 | + unhelpful_votes: id % 3, |
| 37 | + hoursAgo: id === 21 ? 20 : id, |
| 38 | + } |
| 39 | +}) |
| 40 | +type Review = typeof REVIEWS[number] |
| 41 | + |
| 42 | +const ids = (reviews: Review[]) => reviews.map(review => String(review.id)) |
| 43 | +const newestFirst = (a: Review, b: Review) => a.hoursAgo - b.hoursAgo || b.id - a.id |
| 44 | + |
| 45 | +function query(overrides: Partial<ReviewListQuery> = {}): ReviewListQuery { |
| 46 | + return { search: '', product: '', rating: '', moderation: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides } |
| 47 | +} |
| 48 | + |
| 49 | +async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) { |
| 50 | + const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY' |
| 51 | + const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT' |
| 52 | + const flag = dialect === 'postgres' ? 'BOOLEAN' : dialect === 'mysql' ? 'TINYINT(1)' : 'INTEGER' |
| 53 | + const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME(3)' : 'TIMESTAMP' |
| 54 | + const p = (index: number) => dialect === 'postgres' ? `$${index}` : '?' |
| 55 | + await run(`CREATE TABLE products (id ${pk}, name ${text})`) |
| 56 | + await run(`CREATE TABLE customers (id ${pk}, name ${text}, email ${text})`) |
| 57 | + await run(`CREATE TABLE reviews (id ${pk}, rating INTEGER NULL, title ${text} NULL, content ${text} NULL, is_verified_purchase ${flag}, is_approved ${flag}, is_featured ${flag}, helpful_votes INTEGER, unhelpful_votes INTEGER, product_id INTEGER NULL, customer_id INTEGER NULL, created_at ${time})`) |
| 58 | + const all = Array.from({ length: ROWS }, (_, i) => i + 1) |
| 59 | + await run(`INSERT INTO products (id, name) VALUES ${all.map((_, i) => `(${p(2 * i + 1)}, ${p(2 * i + 2)})`).join(', ')}`, all.flatMap(id => [id, productName(id)])) |
| 60 | + await run(`INSERT INTO customers (id, name, email) VALUES ${all.map((_, i) => `(${p(3 * i + 1)}, ${p(3 * i + 2)}, ${p(3 * i + 3)})`).join(', ')}`, all.flatMap(id => [id, customerName(id), `customer${id}@shop.test`])) |
| 61 | + const bool = (value: boolean) => dialect === 'postgres' ? value : (value ? 1 : 0) |
| 62 | + for (const review of REVIEWS) { |
| 63 | + await run( |
| 64 | + `INSERT INTO reviews (id, rating, title, content, is_verified_purchase, is_approved, is_featured, helpful_votes, unhelpful_votes, product_id, customer_id, created_at) VALUES (${Array.from({ length: 12 }, (_, i) => p(i + 1)).join(', ')})`, |
| 65 | + [ |
| 66 | + review.id, review.rating, review.title, review.content, bool(review.is_verified_purchase), bool(review.is_approved), |
| 67 | + bool(review.is_featured), review.helpful_votes, review.unhelpful_votes, review.product_id, review.customer_id, |
| 68 | + new Date(NOW.getTime() - review.hoursAgo * 3_600_000).toISOString().slice(0, 19).replace('T', ' '), |
| 69 | + ], |
| 70 | + ) |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +async function exercise(runner: CommerceSqlRunner, dialect: Dialect) { |
| 75 | + const read = (overrides: Partial<ReviewListQuery> = {}) => readReviewPage(query(overrides), runner, dialect) |
| 76 | + const all = async (overrides: Partial<ReviewListQuery> = {}) => (await read({ perPage: 100, ...overrides })).records.map(record => record.id) |
| 77 | + |
| 78 | + // The summary counts every review, whatever the page is filtered to. |
| 79 | + const filtered = await read({ moderation: 'featured' }) |
| 80 | + const rated = REVIEWS.flatMap(review => review.rating === null ? [] : [review.rating]) |
| 81 | + expect(filtered.summary).toEqual({ |
| 82 | + total: REVIEWS.length, |
| 83 | + approved: REVIEWS.filter(review => review.is_approved).length, |
| 84 | + pending: REVIEWS.filter(review => !review.is_approved).length, |
| 85 | + featured: REVIEWS.filter(review => review.is_featured).length, |
| 86 | + verified: REVIEWS.filter(review => review.is_verified_purchase).length, |
| 87 | + averageRating: Math.round(rated.reduce((sum, rating) => sum + rating, 0) / rated.length * 10) / 10, |
| 88 | + }) |
| 89 | + expect(filtered.total).toBe(REVIEWS.filter(review => review.is_featured).length) |
| 90 | + |
| 91 | + // The product choices are the reviewed products, by name ignoring case. |
| 92 | + expect(filtered.products).toEqual([ |
| 93 | + { id: '2', label: 'apron' }, |
| 94 | + { id: '1', label: 'Coffee Beans' }, |
| 95 | + { id: '3', label: 'Mug' }, |
| 96 | + { id: '600', label: 'zebra Tea' }, |
| 97 | + ]) |
| 98 | + |
| 99 | + // Filters. |
| 100 | + const matching = (keep: (review: Review) => boolean) => ids(REVIEWS.filter(keep).sort(newestFirst)) |
| 101 | + expect(await all({ product: '600' })).toEqual(matching(review => review.product_id === 600)) |
| 102 | + expect(await all({ rating: '5' })).toEqual(matching(review => review.rating === 5)) |
| 103 | + expect(await all({ rating: 'none' })).toEqual(matching(review => review.rating === null)) |
| 104 | + expect(await all({ moderation: 'approved' })).toEqual(matching(review => review.is_approved)) |
| 105 | + expect(await all({ moderation: 'pending' })).toEqual(matching(review => !review.is_approved)) |
| 106 | + expect(await all({ moderation: 'verified' })).toEqual(matching(review => review.is_verified_purchase)) |
| 107 | + expect(await all({ product: '1', moderation: 'approved' })).toEqual(matching(review => review.product_id === 1 && review.is_approved)) |
| 108 | + |
| 109 | + // Search: customer name and email, product name, title with a literal %, content, ignoring case. |
| 110 | + expect(await all({ search: 'ZOE' })).toEqual(matching(review => review.customer_id === 5)) |
| 111 | + expect(await all({ search: 'customer599@' })).toEqual(matching(review => review.customer_id === 599)) |
| 112 | + expect(await all({ search: 'ZEBRA' })).toEqual(matching(review => review.product_id === 600)) |
| 113 | + expect(await all({ search: '100%' })).toEqual(['11']) |
| 114 | + expect(await all({ search: 'like roses' })).toEqual(['17']) |
| 115 | + |
| 116 | + // Newest first, the id breaking the tie between 20 and 21, stable across pages. |
| 117 | + const newest = [...REVIEWS].sort(newestFirst) |
| 118 | + const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })] |
| 119 | + expect(pages.flatMap(result => result.records.map(record => record.id))).toEqual(ids(newest)) |
| 120 | + expect((await read({ page: 9 })).page).toBe(3) |
| 121 | + |
| 122 | + // Sorts: unrated last either way; ties newest first. |
| 123 | + const unrated = REVIEWS.filter(review => review.rating === null).sort(newestFirst) |
| 124 | + const ratedReviews = REVIEWS.filter(review => review.rating !== null) |
| 125 | + expect(await all({ sort: 'rating', direction: 'desc' })).toEqual(ids([...[...ratedReviews].sort((a, b) => b.rating! - a.rating! || newestFirst(a, b)), ...unrated])) |
| 126 | + expect(await all({ sort: 'rating', direction: 'asc' })).toEqual(ids([...[...ratedReviews].sort((a, b) => a.rating! - b.rating! || newestFirst(a, b)), ...unrated])) |
| 127 | + expect(await all({ sort: 'helpful', direction: 'desc' })).toEqual(ids([...REVIEWS].sort((a, b) => b.helpful_votes - a.helpful_votes || newestFirst(a, b)))) |
| 128 | + const customerKey = (review: Review) => review.customer_id === null ? '' : customerName(review.customer_id).toLowerCase() |
| 129 | + expect(await all({ sort: 'customer', direction: 'asc' })).toEqual(ids([...REVIEWS].sort((a, b) => customerKey(a).localeCompare(customerKey(b)) || newestFirst(a, b)))) |
| 130 | + const productKey = (review: Review) => review.product_id === null ? '' : productName(review.product_id).toLowerCase() |
| 131 | + expect(await all({ sort: 'product', direction: 'asc' })).toEqual(ids([...REVIEWS].sort((a, b) => productKey(a).localeCompare(productKey(b)) || newestFirst(a, b)))) |
| 132 | + expect(await all({ sort: 'approved', direction: 'desc' })).toEqual(ids([...REVIEWS].sort((a, b) => Number(b.is_approved) - Number(a.is_approved) || newestFirst(a, b)))) |
| 133 | + |
| 134 | + // Products and customers past the 500th, read by the ids the page references. |
| 135 | + const records = (await read({ perPage: 100 })).records |
| 136 | + expect(records.find(record => record.productId === '600')).toMatchObject({ productName: 'zebra Tea' }) |
| 137 | + expect(records.find(record => record.customerId === '599')).toMatchObject({ customerName: 'mia', customerEmail: 'customer599@shop.test' }) |
| 138 | + expect(records.find(record => record.id === '29')).toMatchObject({ productId: '', productName: '' }) |
| 139 | +} |
| 140 | + |
| 141 | +describe('readReviewPage on SQLite', () => { |
| 142 | + test('summary, product choices, filters, search, sorts, pages and references', async () => { |
| 143 | + const database = new Database(':memory:') |
| 144 | + await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite') |
| 145 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite') |
| 146 | + }) |
| 147 | +}) |
| 148 | + |
| 149 | +for (const dialect of ['postgres', 'mysql'] as const) { |
| 150 | + const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL |
| 151 | + |
| 152 | + test.skipIf(!connection)(`readReviewPage on ${dialect}: summary, product choices, filters, search, sorts, pages and references`, async () => { |
| 153 | + const url = new URL(connection!) |
| 154 | + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) |
| 155 | + throw new Error('Review list tests require a local disposable database server') |
| 156 | + |
| 157 | + const name = `stacks_review_list_${crypto.randomUUID().replaceAll('-', '')}` |
| 158 | + const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"` |
| 159 | + const admin = new SQL(url.href) |
| 160 | + await admin.unsafe(`CREATE DATABASE ${quoted}`) |
| 161 | + const scoped = new URL(url.href) |
| 162 | + scoped.pathname = `/${name}` |
| 163 | + const database = new SQL(scoped.href) |
| 164 | + |
| 165 | + try { |
| 166 | + await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect) |
| 167 | + await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect) |
| 168 | + } |
| 169 | + finally { |
| 170 | + await database.close() |
| 171 | + try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) } |
| 172 | + finally { await admin.close() } |
| 173 | + } |
| 174 | + }, 60_000) |
| 175 | +} |
| 176 | + |
| 177 | +describe('parseReviewListQuery', () => { |
| 178 | + const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? '' |
| 179 | + |
| 180 | + test('defaults to every review, newest first, 25 a page', () => { |
| 181 | + expect(parseReviewListQuery(reader({}))).toEqual({ search: '', product: '', rating: '', moderation: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 }) |
| 182 | + expect(parseReviewListQuery(reader({ sort: 'helpful' }))).toHaveProperty('direction', 'desc') |
| 183 | + expect(parseReviewListQuery(reader({ sort: 'customer' }))).toHaveProperty('direction', 'asc') |
| 184 | + }) |
| 185 | + |
| 186 | + test('refuses values the page does not offer', () => { |
| 187 | + expect(parseReviewListQuery(reader({ product: 'abc' }))).toHaveProperty('error') |
| 188 | + expect(parseReviewListQuery(reader({ rating: '6' }))).toHaveProperty('error') |
| 189 | + expect(parseReviewListQuery(reader({ moderation: 'spam' }))).toHaveProperty('error') |
| 190 | + expect(parseReviewListQuery(reader({ sort: 'images' }))).toHaveProperty('error') |
| 191 | + }) |
| 192 | +}) |
0 commit comments