import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { spawnSync } from 'node:child_process' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import postgres from 'postgres' /** * Stage 1 migration test for the pricing relational rollout. * * Scope (matches §"Tests" #1 in requirement/pricing-relational-migration-plan.md): * 1. Empty DB → backfill seeds the DEFAULT_* values from pricing.service.js. * 2. Pre-existing app_config JSON → backfill copies those values, not defaults. * 3. Re-running the migration → no duplicate rows. * * Isolation strategy * ------------------ * Each test creates its OWN throwaway schema (`pricing_mig_test_`), * runs migrate.js as a child process against that schema, inspects the * resulting rows via a one-off postgres client scoped to the schema, then * drops the schema in afterAll. * * Why a child process: migrate.js calls `sql.end()` at the bottom, which would * tear down the singleton sql client shared with the rest of the test process * if invoked in-process. Same trick setup.js uses for the global migration. * * Why a separate schema per test (vs. the shared `halobestie_test` schema): * the shared schema already has app_config seeded with Phase 4 defaults, which * would make the "empty app_config → fall back to DEFAULTS" path untestable. * Per-schema isolation lets us control the initial state precisely. */ const __dirname = dirname(fileURLToPath(import.meta.url)) const backendRoot = resolve(__dirname, '../..') const migratePath = resolve(backendRoot, 'src/db/migrate.js') // Mirrors DEFAULT_CHAT_TIERS / DEFAULT_CALL_TIERS / DEFAULT_DISCOUNT in // src/services/pricing.service.js. Duplicated here so the test fails loudly // if either side drifts — that's a feature, not duplication-tax. const DEFAULT_CHAT_TIERS = [ { minutes: 5, price_idr: 5000, tag: null }, { minutes: 12, price_idr: 12000, tag: 'paling pas' }, { minutes: 30, price_idr: 25000, tag: 'hemat' }, { minutes: 60, price_idr: 45000, tag: null }, { minutes: 120, price_idr: 80000, tag: 'best deal' }, ] const DEFAULT_CALL_TIERS = [ { minutes: 10, price_idr: 9000, tag: null }, { minutes: 20, price_idr: 17000, tag: 'paling pas' }, { minutes: 45, price_idr: 35000, tag: null }, { minutes: 60, price_idr: 45000, tag: 'hemat' }, ] const DEFAULT_DISCOUNT = { enabled: true, actual_price_idr: 2000, gimmick_price_idr: 12000, duration_minutes: 12, modes: ['chat'], } /** * Build a DATABASE_URL that pins search_path to the given schema. Postgres applies * the search_path on every new connection, so all CREATE TABLE / INSERT / SELECT * resolve to this schema without further qualification. */ const scopedUrl = (baseUrl, schema) => { const sep = baseUrl.includes('?') ? '&' : '?' return `${baseUrl}${sep}options=${encodeURIComponent(`-c search_path=${schema},public`)}` } /** * Spawn migrate.js as a child process against the given schema. Throws on non-zero * exit so the test fails with the migration's stdout/stderr. */ const runMigration = (schema) => { const result = spawnSync( process.execPath, [migratePath], { env: { ...process.env, DATABASE_URL: scopedUrl(process.env.TEST_DATABASE_URL, schema), }, cwd: backendRoot, encoding: 'utf8', } ) if (result.status !== 0) { throw new Error( `Migration failed (exit ${result.status}):\n` + `stdout: ${result.stdout}\nstderr: ${result.stderr}` ) } } /** * Open a one-off postgres client scoped to the given schema. Caller MUST `.end()` it. * We intentionally do not reuse the singleton from src/db/client.js — that's scoped to * the shared test schema, not the throwaway one we built here. */ const openClient = (schema) => postgres(scopedUrl(process.env.TEST_DATABASE_URL, schema)) describe('pricing relational migration — Stage 1 backfill', () => { // Test schemas we create. Tracked so afterAll can drop them even if a test throws. const createdSchemas = [] /** * Allocate a fresh schema, register it for teardown, return its name. Schemas are * suffixed with a random hex string + the test's nominal label so collisions across * parallel CI runs (or repeated local runs after a crash) are vanishingly unlikely. */ const newSchema = async (label) => { const suffix = Math.random().toString(16).slice(2, 10) const name = `pricing_mig_test_${label}_${suffix}` const admin = postgres(process.env.TEST_DATABASE_URL) try { await admin`CREATE SCHEMA ${admin(name)}` } finally { await admin.end() } createdSchemas.push(name) return name } beforeAll(() => { if (!process.env.TEST_DATABASE_URL) { throw new Error('TEST_DATABASE_URL must be set (loaded by test/setup.js)') } }) afterAll(async () => { // Drop every schema we created. CASCADE so we don't have to enumerate tables. const admin = postgres(process.env.TEST_DATABASE_URL) try { for (const schema of createdSchemas) { await admin`DROP SCHEMA IF EXISTS ${admin(schema)} CASCADE` } } finally { await admin.end() } }) it('empty DB → backfill seeds DEFAULT_CHAT_TIERS / DEFAULT_CALL_TIERS / DEFAULT_DISCOUNT', async () => { const schema = await newSchema('empty') runMigration(schema) const sql = openClient(schema) try { // 1. Tiers: count + per-row equivalence with DEFAULT_* (sort_order = array index). const chatTiers = await sql` SELECT minutes, price_idr, tag, sort_order FROM pricing_tiers WHERE mode = 'chat' ORDER BY sort_order ` expect(chatTiers).toHaveLength(DEFAULT_CHAT_TIERS.length) chatTiers.forEach((row, idx) => { expect(row.minutes).toBe(DEFAULT_CHAT_TIERS[idx].minutes) expect(row.price_idr).toBe(DEFAULT_CHAT_TIERS[idx].price_idr) expect(row.tag).toBe(DEFAULT_CHAT_TIERS[idx].tag) expect(row.sort_order).toBe(idx) }) const callTiers = await sql` SELECT minutes, price_idr, tag, sort_order FROM pricing_tiers WHERE mode = 'call' ORDER BY sort_order ` expect(callTiers).toHaveLength(DEFAULT_CALL_TIERS.length) callTiers.forEach((row, idx) => { expect(row.minutes).toBe(DEFAULT_CALL_TIERS[idx].minutes) expect(row.price_idr).toBe(DEFAULT_CALL_TIERS[idx].price_idr) expect(row.tag).toBe(DEFAULT_CALL_TIERS[idx].tag) expect(row.sort_order).toBe(idx) }) // All freshly inserted rows must be active. const [{ inactive_count }] = await sql` SELECT COUNT(*)::int AS inactive_count FROM pricing_tiers WHERE is_active = false ` expect(inactive_count).toBe(0) // 2. Promotions: single 'first_session' row matching DEFAULT_DISCOUNT. const promos = await sql` SELECT enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes FROM pricing_promotions ` expect(promos).toHaveLength(1) expect(promos[0]).toMatchObject({ enabled: DEFAULT_DISCOUNT.enabled, eligibility: 'first_session', actual_price_idr: DEFAULT_DISCOUNT.actual_price_idr, gimmick_price_idr: DEFAULT_DISCOUNT.gimmick_price_idr, duration_minutes: DEFAULT_DISCOUNT.duration_minutes, }) expect(promos[0].modes).toEqual(DEFAULT_DISCOUNT.modes) } finally { await sql.end() } }) it('pre-existing app_config JSON → backfill copies those values, not defaults', async () => { const schema = await newSchema('preseed') // Custom values that DO NOT overlap with any DEFAULT_* entry — guarantees the // assertions can tell the two sources apart. const customChat = [ { minutes: 7, price_idr: 7700, tag: 'custom-a' }, { minutes: 22, price_idr: 21000, tag: null }, { minutes: 99, price_idr: 75000, tag: 'custom-b' }, ] const customCall = [ { minutes: 11, price_idr: 9900, tag: 'custom-c' }, { minutes: 33, price_idr: 27500, tag: null }, ] const customDiscount = { enabled: false, actual_price_idr: 1500, gimmick_price_idr: 11000, duration_minutes: 8, modes: ['chat', 'call'], } // Seed app_config BEFORE the pricing migration block runs. The earlier // migration sections create the app_config table itself; running the migration // once gives us the table + the default Phase 4 app_config rows. But that same // run also backfills pricing_tiers from those defaults — we don't want that. // // Workaround: bootstrap just enough of the migration prologue manually // (create app_config + insert our custom JSON), THEN run migrate.js. The // tier/promo CREATE TABLE IF NOT EXISTS is idempotent, and the backfill is // gated on "table empty", so it'll read our app_config rows on first pass. const bootstrap = openClient(schema) try { await bootstrap`CREATE EXTENSION IF NOT EXISTS "pgcrypto"` await bootstrap` CREATE TABLE app_config ( key VARCHAR(100) PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) ` await bootstrap` INSERT INTO app_config (key, value) VALUES ('pricing_chat_tiers_json', ${bootstrap.json({ tiers: customChat })}), ('pricing_call_tiers_json', ${bootstrap.json({ tiers: customCall })}), ('first_session_discount_enabled', ${bootstrap.json({ value: customDiscount.enabled })}), ('first_session_discount_actual_price_idr', ${bootstrap.json({ value: customDiscount.actual_price_idr })}), ('first_session_discount_gimmick_price_idr', ${bootstrap.json({ value: customDiscount.gimmick_price_idr })}), ('first_session_discount_duration_minutes', ${bootstrap.json({ value: customDiscount.duration_minutes })}), ('first_session_discount_modes', ${bootstrap.json({ value: customDiscount.modes })}) ` } finally { await bootstrap.end() } // Now run the full migration. The pricing block sees pre-seeded app_config // and an empty pricing_tiers, so it should copy our custom values. runMigration(schema) const sql = openClient(schema) try { const chatTiers = await sql` SELECT minutes, price_idr, tag, sort_order FROM pricing_tiers WHERE mode = 'chat' ORDER BY sort_order ` expect(chatTiers).toHaveLength(customChat.length) chatTiers.forEach((row, idx) => { expect(row.minutes).toBe(customChat[idx].minutes) expect(row.price_idr).toBe(customChat[idx].price_idr) expect(row.tag).toBe(customChat[idx].tag) expect(row.sort_order).toBe(idx) }) // Spot-check that DEFAULTS are NOT present (e.g. 5/12/30/60/120 minute chat // tiers from DEFAULT_CHAT_TIERS shouldn't appear unless customChat includes them). const defaultsThatShouldNotBeHere = await sql` SELECT minutes FROM pricing_tiers WHERE mode = 'chat' AND minutes IN (5, 12, 30, 120) ` expect(defaultsThatShouldNotBeHere).toHaveLength(0) const callTiers = await sql` SELECT minutes, price_idr, tag, sort_order FROM pricing_tiers WHERE mode = 'call' ORDER BY sort_order ` expect(callTiers).toHaveLength(customCall.length) callTiers.forEach((row, idx) => { expect(row.minutes).toBe(customCall[idx].minutes) expect(row.price_idr).toBe(customCall[idx].price_idr) expect(row.tag).toBe(customCall[idx].tag) expect(row.sort_order).toBe(idx) }) const [promo] = await sql` SELECT enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes FROM pricing_promotions ` expect(promo).toMatchObject({ enabled: customDiscount.enabled, eligibility: 'first_session', actual_price_idr: customDiscount.actual_price_idr, gimmick_price_idr: customDiscount.gimmick_price_idr, duration_minutes: customDiscount.duration_minutes, }) expect(promo.modes).toEqual(customDiscount.modes) } finally { await sql.end() } }) it('Stage 5 cleanup: legacy pricing app_config rows are deleted after backfill, idempotent on re-run', async () => { const schema = await newSchema('stage5') // First migration pass: seeds the legacy app_config keys, backfills relational // tables, then the Stage 5 DELETE block removes the legacy keys. runMigration(schema) const sql = openClient(schema) try { // The seven legacy keys must be gone post-migration. const legacyKeys = [ 'first_session_discount_enabled', 'first_session_discount_actual_price_idr', 'first_session_discount_gimmick_price_idr', 'first_session_discount_duration_minutes', 'first_session_discount_modes', 'pricing_chat_tiers_json', 'pricing_call_tiers_json', ] const remaining = await sql` SELECT key FROM app_config WHERE key IN ${sql(legacyKeys)} ` expect(remaining).toHaveLength(0) // Sanity: relational tables still hold the values that were copied off. const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers` expect(tierCount).toBeGreaterThan(0) const [{ n: promoCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_promotions` expect(promoCount).toBe(1) // Re-run the migration. The DELETE must remain a no-op (zero affected rows) // and not error — i.e. cleanup is idempotent. runMigration(schema) const remainingAfter = await sql` SELECT key FROM app_config WHERE key IN ${sql(legacyKeys)} ` expect(remainingAfter).toHaveLength(0) } finally { await sql.end() } }) it('Stage 5 cleanup: legacy keys present from a pre-Stage-5 DB are still deleted on first run', async () => { // Simulates a DB that was already migrated to Stage 1 (pricing_tiers/promotions // populated, legacy app_config keys still around) and is being upgraded to // Stage 5 for the first time. The DELETE should remove the legacy keys // without disturbing the relational data. const schema = await newSchema('stage5_existing') // Bootstrap: schema + app_config table + legacy keys with custom values, then // run the migration. Pre-seeding here is what differentiates this from the // empty-DB case — it proves the DELETE doesn't depend on the seeding INSERT. const bootstrap = openClient(schema) try { await bootstrap`CREATE EXTENSION IF NOT EXISTS "pgcrypto"` await bootstrap` CREATE TABLE app_config ( key VARCHAR(100) PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) ` await bootstrap` INSERT INTO app_config (key, value) VALUES ('first_session_discount_enabled', ${bootstrap.json({ value: false })}), ('first_session_discount_actual_price_idr', ${bootstrap.json({ value: 1234 })}), ('first_session_discount_gimmick_price_idr', ${bootstrap.json({ value: 5678 })}), ('first_session_discount_duration_minutes', ${bootstrap.json({ value: 9 })}), ('first_session_discount_modes', ${bootstrap.json({ value: ['chat', 'call'] })}), ('pricing_chat_tiers_json', ${bootstrap.json({ tiers: [{ minutes: 3, price_idr: 3000, tag: null }] })}), ('pricing_call_tiers_json', ${bootstrap.json({ tiers: [{ minutes: 4, price_idr: 4000, tag: null }] })}) ` } finally { await bootstrap.end() } runMigration(schema) const sql = openClient(schema) try { const remaining = await sql` SELECT key FROM app_config WHERE key IN ( 'first_session_discount_enabled', 'first_session_discount_actual_price_idr', 'first_session_discount_gimmick_price_idr', 'first_session_discount_duration_minutes', 'first_session_discount_modes', 'pricing_chat_tiers_json', 'pricing_call_tiers_json' ) ` expect(remaining).toHaveLength(0) // Backfill copied the pre-seeded values into the relational tables (Stage 1 // semantics) and the Stage 5 DELETE didn't touch them. const [promo] = await sql` SELECT enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes FROM pricing_promotions WHERE eligibility = 'first_session' ` expect(promo.enabled).toBe(false) expect(promo.actual_price_idr).toBe(1234) expect(promo.gimmick_price_idr).toBe(5678) expect(promo.duration_minutes).toBe(9) expect(promo.modes).toEqual(['chat', 'call']) } finally { await sql.end() } }) it('re-running the migration → no duplicate rows, existing data preserved', async () => { const schema = await newSchema('rerun') // First migration pass: backfills defaults (app_config rows are seeded by the // earlier sections of the same migration, then pricing_tiers fills from them). runMigration(schema) // Capture state after pass 1 — ids included so we can prove rows weren't // re-inserted (UUIDs would change if they were). const snapshot = openClient(schema) let tiersBefore, promoBefore try { tiersBefore = await snapshot` SELECT id, mode, minutes, price_idr, tag, sort_order, is_active FROM pricing_tiers ORDER BY mode, sort_order ` promoBefore = await snapshot` SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes FROM pricing_promotions ` expect(tiersBefore.length).toBeGreaterThan(0) expect(promoBefore).toHaveLength(1) } finally { await snapshot.end() } // Second migration pass: must be a no-op for pricing data. runMigration(schema) const sql = openClient(schema) try { const tiersAfter = await sql` SELECT id, mode, minutes, price_idr, tag, sort_order, is_active FROM pricing_tiers ORDER BY mode, sort_order ` const promoAfter = await sql` SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes FROM pricing_promotions ` // Row count unchanged. expect(tiersAfter).toHaveLength(tiersBefore.length) expect(promoAfter).toHaveLength(1) // Every id from pass 1 still exists with identical values in pass 2. // UUIDs are stable across re-runs because the backfill is gated on "table empty". expect(tiersAfter.map((r) => r.id).sort()).toEqual( tiersBefore.map((r) => r.id).sort() ) expect(promoAfter[0].id).toBe(promoBefore[0].id) // Defense against an "INSERT … ON CONFLICT DO UPDATE" regression: spot-check // that price_idr / tag for one tier wasn't silently clobbered. tiersAfter.forEach((after) => { const before = tiersBefore.find((b) => b.id === after.id) expect(after.price_idr).toBe(before.price_idr) expect(after.tag).toBe(before.tag) expect(after.sort_order).toBe(before.sort_order) }) // UNIQUE (mode, minutes) on pricing_tiers should give us zero duplicate // (mode, minutes) pairs. Explicit query in case the constraint is ever // accidentally dropped in a future migration. const dupes = await sql` SELECT mode, minutes, COUNT(*)::int AS n FROM pricing_tiers GROUP BY mode, minutes HAVING COUNT(*) > 1 ` expect(dupes).toHaveLength(0) // UNIQUE (eligibility) on pricing_promotions: same check. const promoDupes = await sql` SELECT eligibility, COUNT(*)::int AS n FROM pricing_promotions GROUP BY eligibility HAVING COUNT(*) > 1 ` expect(promoDupes).toHaveLength(0) } finally { await sql.end() } }) })