la-rose/scripts/verify.ts

138 lines
8 KiB
TypeScript

import { PGlite } from '@electric-sql/pglite';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readImages } from '../src/db/images';
import { migrate } from '../src/db/migrate';
import { seed } from '../src/db/seed';
import { STORE_CURRENCY } from '../src/store/constants';
import { toMinor, formatMinor } from '../src/store/money';
import { verifyImageArtifacts } from './verify-images';
/**
* Verification harness for the data layer.
*
* Runs the exact same migrations + idempotent seed against an in-process Postgres
* (PGlite) so the acceptance criteria can be checked on a clean database without a
* running server:
* • migrate + seed run clean (twice, proving idempotency)
* • select count(*) from product = 93
* • select count(*) from category = 14
* • select count(*) from category visible = 11
* • product_image covers 93 products, every url exists on disk (docs/PLAN.md §11)
* • money is integer BYN minor units, no float
*/
function assertEqual(actual: unknown, expected: unknown, label: string): void {
if (actual !== expected) {
throw new Error(`verify: ${label} = ${String(actual)}, expected ${String(expected)}`);
}
}
const db = new PGlite();
// Run twice: the second pass proves the seed is idempotent.
await migrate(db);
const first = await seed(db);
await migrate(db);
const second = await seed(db);
const productCount = (await db.query<{ c: number }>('select count(*)::int as c from product')).rows[0].c;
const categoryCount = (await db.query<{ c: number }>('select count(*)::int as c from category')).rows[0].c;
const visibleCount = (await db.query<{ c: number }>('select count(*)::int as c from category where visible = true')).rows[0].c;
const hiddenCount = (await db.query<{ c: number }>('select count(*)::int as c from category where visible = false')).rows[0].c;
const images = readImages();
const imageCount = (await db.query<{ c: number }>('select count(*)::int as c from product_image')).rows[0].c;
const imageProducts = (await db.query<{ c: number }>('select count(distinct product_id)::int as c from product_image')).rows[0].c;
const productsWithMain = (await db.query<{ c: number }>(
'select count(*)::int as c from (select product_id from product_image where position = 1 group by product_id) t',
)).rows[0].c;
const badImageUrls = (await db.query<{ c: number }>(
`select count(*)::int as c from product_image pi join product p on p.id = pi.product_id
where pi.url <> '/images/products/' || p.slug || '/' || pi.position || '.jpg'`,
)).rows[0].c;
const variantCount = (await db.query<{ c: number }>('select count(*)::int as c from product_variant')).rows[0].c;
const orderCount = (await db.query<{ c: number }>('select count(*)::int as c from orders')).rows[0].c;
const admins = (await db.query<{ c: number }>('select count(*)::int as c from customer where role = $1', ['admin'])).rows[0].c;
const customers = (await db.query<{ c: number }>('select count(*)::int as c from customer where role = $1', ['customer'])).rows[0].c;
// Money must be integer minor units in BYN — reject any non-integer / non-BYN row.
const badMoney = (await db.query<{ c: number }>(
`select count(*)::int as c from product
where currency <> $1
or price_minor <> floor(price_minor)
or (old_price_minor is not null and old_price_minor <> floor(old_price_minor))`,
[STORE_CURRENCY],
)).rows[0].c;
const badOrderMoney = (await db.query<{ c: number }>(
`select count(*)::int as c from orders
where currency <> $1 or total_minor <> floor(total_minor) or subtotal_minor <> floor(subtotal_minor)`,
[STORE_CURRENCY],
)).rows[0].c;
assertEqual(STORE_CURRENCY, 'BYN', 'STORE_CURRENCY');
assertEqual(first.products, 93, 'seed products (first run)');
assertEqual(second.products, 93, 'seed products (second run)');
assertEqual(productCount, 93, 'select count(*) from product');
assertEqual(categoryCount, 14, 'select count(*) from category');
assertEqual(visibleCount, 11, 'select count(*) from category where visible = true');
assertEqual(hiddenCount, 3, 'hidden categories');
assertEqual(imageCount, images.length, 'product_image rows = db/seed/images.tsv rows');
assertEqual(imageProducts, 93, 'product_image distinct products');
assertEqual(productsWithMain, 93, 'products with a position = 1 image');
assertEqual(badImageUrls, 0, 'product_image urls follow /images/products/<slug>/<n>.jpg');
assertEqual(variantCount, 3, 'product_variant (15/19/25 роз)');
assertEqual(orderCount, 2, 'orders');
assertEqual(admins >= 1, true, 'at least one admin');
assertEqual(customers >= 1, true, 'at least one customer');
assertEqual(badMoney, 0, 'non-BYN / non-integer product prices');
assertEqual(badOrderMoney, 0, 'non-BYN / non-integer order amounts');
assertEqual(toMinor(71), 7100, 'toMinor(71)');
assertEqual(formatMinor(7100).includes('71'), true, 'formatMinor(7100) contains 71');
// Same checks again, but through the generated single-file SQL seed
// (db/seed/0001_seed.sql) used by the W4C Data Tables module (`run_sql`).
const sqlDb = new PGlite();
await migrate(sqlDb);
const seedSql = readFileSync(fileURLToPath(new URL('../db/seed/0001_seed.sql', import.meta.url)), 'utf8');
await sqlDb.exec(seedSql);
const sqlProducts = (await sqlDb.query<{ c: number }>('select count(*)::int as c from product')).rows[0].c;
const sqlCategories = (await sqlDb.query<{ c: number }>('select count(*)::int as c from category')).rows[0].c;
const sqlVisible = (await sqlDb.query<{ c: number }>('select count(*)::int as c from category where visible = true')).rows[0].c;
const sqlImages = (await sqlDb.query<{ c: number }>('select count(*)::int as c from product_image')).rows[0].c;
assertEqual(sqlProducts, 93, 'generated SQL seed: product');
assertEqual(sqlCategories, 14, 'generated SQL seed: category');
assertEqual(sqlVisible, 11, 'generated SQL seed: visible category');
assertEqual(sqlImages, images.length, 'generated SQL seed: product_image rows');
await sqlDb.close();
// The guard-safe variant feeds the W4C /db-forms module — prove it runs and
// carries the same product_image rows, with no `ON CONFLICT` / dotted aliases.
const guardDb = new PGlite();
await migrate(guardDb);
const guardSql = readFileSync(fileURLToPath(new URL('../db/seed/0001_seed.guardsafe.sql', import.meta.url)), 'utf8');
await guardDb.exec(guardSql);
const guardImages = (await guardDb.query<{ c: number }>('select count(*)::int as c from product_image')).rows[0].c;
assertEqual(guardImages, images.length, 'guard-safe SQL seed: product_image rows');
await guardDb.close();
// File-side acceptance: images.tsv ⇄ public/images/products/** ⇄ guard-safe SQL.
const artifacts = verifyImageArtifacts();
assertEqual(artifacts.products, 93, 'gallery: 93 products in db/seed/images.tsv');
assertEqual(artifacts.productsWithMain, 93, 'gallery: 93 products with a main photo');
assertEqual(artifacts.missing.length, 0, 'gallery: every url exists as a file');
assertEqual(artifacts.rows, artifacts.files, 'gallery: rows = files on disk');
assertEqual(artifacts.extra.length, 0, 'gallery: no orphan image files');
assertEqual(artifacts.guardsafeHasAllRows, true, 'guard-safe seed carries every product_image row');
assertEqual(artifacts.guardsafeHasOnConflict, false, 'guard-safe seed has no ON CONFLICT');
console.log('verify: OK');
console.log(` select count(*) from product = ${productCount}`);
console.log(` select count(*) from category where visible = true = ${visibleCount} (total ${categoryCount}, hidden ${hiddenCount})`);
console.log(` generated SQL seed (db/seed/0001_seed.sql) = product ${sqlProducts}, category ${sqlCategories} (visible ${sqlVisible})`);
console.log(` guard-safe SQL seed = product_image ${guardImages} (no ON CONFLICT)`);
console.log(` product_image=${imageCount} files=${artifacts.files} (${(artifacts.bytes / (1024 * 1024)).toFixed(1)} MiB) products with main photo=${productsWithMain}`);
console.log(` product_variant=${variantCount} orders=${orderCount} admins=${admins} customers=${customers}`);
console.log(` money: integer ${STORE_CURRENCY} minor units (0 float rows)`);
await db.close();