Pricing: migrate from app_config JSON to relational tables
Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*` keys in app_config with dedicated `pricing_tiers` and `pricing_promotions` tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes) natural-key constraint, optimistic-lock via `updated_at` token returning 409 STALE_WRITE on conflicts. Every mutation writes a history row capturing the operator (changed_by from request.auth.userId) and change_kind. CC SettingsPage replaces the JSON-textarea editors with per-row tables — add / edit / soft-delete / reactivate / reorder, plus a buffered first-session discount form with the same optimistic-lock contract. `minutes` and `mode` are read-only on edit since they form the natural key; operators soft-delete and recreate to change duration. Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local `readDiscountConfig` that still read from app_config — would have silently fallen to hardcoded defaults once the legacy rows were deleted. Now reads from pricing_promotions via the shared service helper, so CC edits to the first- session discount affect actual payment pricing on the next request. Customer-facing GET /api/client/chat/pricing shape unchanged (id values are now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so no app changes needed). 27 new backend tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
508
backend/test/db/pricing-migration.test.js
Normal file
508
backend/test/db/pricing-migration.test.js
Normal file
@@ -0,0 +1,508 @@
|
||||
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_<random>`),
|
||||
* 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -52,8 +52,13 @@ export const resetDbHard = async () => {
|
||||
|
||||
/**
|
||||
* Drop and re-seed the configurable app_config rows back to their canonical defaults.
|
||||
* Tests that mutate config (e.g. flipping first_session_discount_enabled) call this
|
||||
* in afterEach.
|
||||
* Tests that mutate config (e.g. flipping pricing_promotions.enabled) call this in
|
||||
* afterEach.
|
||||
*
|
||||
* Note: the first-session discount config no longer lives in app_config (Stage 5
|
||||
* deleted those legacy keys). It now lives in the `pricing_promotions` table, which
|
||||
* is also reset here back to the seed defaults that match migrate.js + the
|
||||
* DEFAULT_DISCOUNT in pricing.service.js.
|
||||
*/
|
||||
export const resetAppConfig = async () => {
|
||||
const sql = db()
|
||||
@@ -69,12 +74,6 @@ export const resetAppConfig = async () => {
|
||||
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
||||
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
||||
['pairing_blast_timeout_seconds', { value: 60 }],
|
||||
// Phase 4
|
||||
['first_session_discount_enabled', { value: true }],
|
||||
['first_session_discount_actual_price_idr', { value: 2000 }],
|
||||
['first_session_discount_gimmick_price_idr', { value: 12000 }],
|
||||
['first_session_discount_duration_minutes', { value: 12 }],
|
||||
['first_session_discount_modes', { value: ['chat'] }],
|
||||
['three_minute_warning_enabled', { value: true }],
|
||||
]
|
||||
for (const [key, value] of defaults) {
|
||||
@@ -84,4 +83,18 @@ export const resetAppConfig = async () => {
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
|
||||
// Reset pricing_promotions to canonical Phase 4 defaults. The Stage 1 backfill
|
||||
// gates on "table empty" so we can't rely on migrate.js to restore values after
|
||||
// a test mutates them — this UPDATE is the test-side reset hook.
|
||||
await sql`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = true,
|
||||
actual_price_idr = 2000,
|
||||
gimmick_price_idr = 12000,
|
||||
duration_minutes = 12,
|
||||
modes = ${['chat']},
|
||||
updated_at = NOW()
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
}
|
||||
|
||||
@@ -57,6 +57,44 @@ export const createMitra = async ({
|
||||
*/
|
||||
export const seedDefaultConfig = () => resetAppConfig()
|
||||
|
||||
/**
|
||||
* Insert (or fetch) a control-center user with full `config` permissions. Used by
|
||||
* /internal/config/* route tests that need a JWT subject that survives the
|
||||
* `attachCcUser` + `requirePermission('config', …)` preHandler chain.
|
||||
*
|
||||
* Idempotent: re-runs return the same row by email. We do NOT truncate cc_user / roles
|
||||
* between tests (db.js documents the rationale), so subsequent test files inherit
|
||||
* whatever this seeded.
|
||||
*/
|
||||
export const createCcUser = async ({
|
||||
email = `cc-test-${randomUUID().slice(0, 8)}@halobestie.test`,
|
||||
displayName = 'CC Test User',
|
||||
permissions = {
|
||||
mitra: ['create', 'read', 'update', 'delete'],
|
||||
control_center_users: ['create', 'read', 'update', 'delete'],
|
||||
config: ['read', 'update'],
|
||||
roles: ['create', 'read', 'update', 'delete'],
|
||||
},
|
||||
} = {}) => {
|
||||
const sql = db()
|
||||
// One role per test invocation, named after a slice of the email so re-runs don't
|
||||
// collide with the seeded `super_admin` role from seed.js.
|
||||
const roleName = `cc-test-role-${email.slice(0, 16)}`
|
||||
const [role] = await sql`
|
||||
INSERT INTO roles (name, permissions)
|
||||
VALUES (${roleName}, ${sql.json(permissions)})
|
||||
ON CONFLICT (name) DO UPDATE SET permissions = EXCLUDED.permissions
|
||||
RETURNING id
|
||||
`
|
||||
const [user] = await sql`
|
||||
INSERT INTO control_center_users (email, display_name, role_id, password_hash)
|
||||
VALUES (${email}, ${displayName}, ${role.id}, 'unused-for-jwt-tests')
|
||||
ON CONFLICT (email) DO UPDATE SET role_id = EXCLUDED.role_id
|
||||
RETURNING id, email, display_name, role_id, created_at
|
||||
`
|
||||
return { ...user, role: { id: role.id, permissions } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: full reset between tests. Truncates Phase 3.7 tables, restores
|
||||
* default config rows.
|
||||
|
||||
178
backend/test/routes/internal/first-session-discount.test.js
Normal file
178
backend/test/routes/internal/first-session-discount.test.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('../../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const { buildInternal } = await import('../../helpers/server.js')
|
||||
const { resetAppConfig, db } = await import('../../helpers/db.js')
|
||||
const { createCcUser } = await import('../../helpers/fixtures.js')
|
||||
const { ccJwt, authHeader } = await import('../../helpers/jwt.js')
|
||||
|
||||
/**
|
||||
* Stage 3 tests for the relational first-session-discount endpoints.
|
||||
*
|
||||
* The migration seeded the single 'first_session' promotion row; we mutate values
|
||||
* inside the test and restore in afterAll so other test files inherit clean state.
|
||||
*/
|
||||
describe('/internal/config/first-session-discount', () => {
|
||||
let app
|
||||
let ccUser
|
||||
let token
|
||||
let initialSnapshot
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
app = await buildInternal()
|
||||
ccUser = await createCcUser({ displayName: 'DiscountOperator' })
|
||||
token = ccJwt(ccUser.id)
|
||||
|
||||
// Snapshot the pre-test row so we can restore it after the suite.
|
||||
const sql = db()
|
||||
const [row] = await sql`
|
||||
SELECT id, enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
initialSnapshot = row
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (initialSnapshot) {
|
||||
const sql = db()
|
||||
await sql`
|
||||
UPDATE pricing_promotions SET
|
||||
enabled = ${initialSnapshot.enabled},
|
||||
actual_price_idr = ${initialSnapshot.actual_price_idr},
|
||||
gimmick_price_idr = ${initialSnapshot.gimmick_price_idr},
|
||||
duration_minutes = ${initialSnapshot.duration_minutes},
|
||||
modes = ${initialSnapshot.modes},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${initialSnapshot.id}
|
||||
`
|
||||
// Drop any history rows this test file authored so the table doesn't bloat.
|
||||
await sql`
|
||||
DELETE FROM pricing_promotions_history
|
||||
WHERE promotion_id = ${initialSnapshot.id} AND changed_by = ${ccUser.id}
|
||||
`
|
||||
}
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('GET returns the current promotion row including updated_at', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data.eligibility).toBe('first_session')
|
||||
expect(typeof body.data.enabled).toBe('boolean')
|
||||
expect(typeof body.data.actual_price_idr).toBe('number')
|
||||
expect(typeof body.data.duration_minutes).toBe('number')
|
||||
expect(Array.isArray(body.data.modes)).toBe(true)
|
||||
expect(body.data.updated_at).toBeDefined()
|
||||
})
|
||||
|
||||
it('PATCH with correct updated_at updates the row and writes an update history row', async () => {
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const current = getRes.json().data
|
||||
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: {
|
||||
updated_at: current.updated_at,
|
||||
actual_price_idr: 2500,
|
||||
duration_minutes: 15,
|
||||
modes: ['chat', 'call'],
|
||||
},
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(200)
|
||||
const updated = patchRes.json().data
|
||||
expect(updated.actual_price_idr).toBe(2500)
|
||||
expect(updated.duration_minutes).toBe(15)
|
||||
expect(updated.modes).toEqual(['chat', 'call'])
|
||||
// updated_at advanced.
|
||||
expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(current.updated_at).getTime())
|
||||
|
||||
// History row.
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, changed_by, actual_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions_history
|
||||
WHERE promotion_id = ${current.id} AND changed_by = ${ccUser.id}
|
||||
ORDER BY changed_at DESC LIMIT 1
|
||||
`
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0].change_kind).toBe('update')
|
||||
expect(history[0].actual_price_idr).toBe(2500)
|
||||
expect(history[0].duration_minutes).toBe(15)
|
||||
expect(history[0].modes).toEqual(['chat', 'call'])
|
||||
})
|
||||
|
||||
it('PATCH with stale updated_at returns 409 STALE_WRITE', async () => {
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const current = getRes.json().data
|
||||
|
||||
const stale = new Date(new Date(current.updated_at).getTime() - 60_000).toISOString()
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: stale, actual_price_idr: 3000 },
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(409)
|
||||
const err = patchRes.json().error
|
||||
expect(err.code).toBe('STALE_WRITE')
|
||||
expect(err.server_updated_at).toBeDefined()
|
||||
})
|
||||
|
||||
it('PATCH without updated_at returns 422', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: { actual_price_idr: 3000 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('updated_at')
|
||||
})
|
||||
|
||||
it('PATCH with invalid modes returns 422', async () => {
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const current = getRes.json().data
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: current.updated_at, modes: ['video'] },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('modes')
|
||||
})
|
||||
})
|
||||
349
backend/test/routes/internal/pricing-tiers.test.js
Normal file
349
backend/test/routes/internal/pricing-tiers.test.js
Normal file
@@ -0,0 +1,349 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
|
||||
// The internal app doesn't pull the WS plugin but does pull notification.service via
|
||||
// session routes. Mock both for parity with other route tests + safety.
|
||||
vi.mock('../../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('../../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const { buildInternal } = await import('../../helpers/server.js')
|
||||
const { resetAppConfig, db } = await import('../../helpers/db.js')
|
||||
const { createCcUser } = await import('../../helpers/fixtures.js')
|
||||
const { ccJwt, authHeader } = await import('../../helpers/jwt.js')
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Stage 3 route tests for the relational pricing CRUD endpoints.
|
||||
*
|
||||
* We do NOT use resetDb() because:
|
||||
* 1. resetDb() doesn't truncate pricing_tiers (deliberate — backfill is expensive).
|
||||
* 2. The migration already seeded the canonical chat+call catalog at setup time.
|
||||
*
|
||||
* Tests that mutate the catalog use sql DELETE/UPDATE inside the test body and clean
|
||||
* up in afterEach. Tests that read the catalog rely on the seeded rows.
|
||||
*/
|
||||
describe('/internal/config/pricing-tiers', () => {
|
||||
let app
|
||||
let ccUser
|
||||
let token
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
app = await buildInternal()
|
||||
ccUser = await createCcUser({ displayName: 'PricingTierOperator' })
|
||||
token = ccJwt(ccUser.id)
|
||||
})
|
||||
|
||||
// Track tier UUIDs we POST/seed across ALL tests in this file so afterAll can
|
||||
// clean them up without disturbing the canonical seeded catalog. Deliberately
|
||||
// NOT reset between tests — every test that creates a row appends and the IDs
|
||||
// accumulate; afterAll iterates and DELETEs them at the end.
|
||||
const createdTierIds = []
|
||||
|
||||
afterAll(async () => {
|
||||
const sql = db()
|
||||
if (createdTierIds.length > 0) {
|
||||
await sql`DELETE FROM pricing_tiers_history WHERE tier_id = ANY(${createdTierIds})`
|
||||
await sql`DELETE FROM pricing_tiers WHERE id = ANY(${createdTierIds})`
|
||||
}
|
||||
// Defense-in-depth: also wipe any non-canonical chat/call tiers that slipped through.
|
||||
// The canonical catalog is the 5 chat + 4 call minutes seeded by migrate.js.
|
||||
await sql`
|
||||
DELETE FROM pricing_tiers
|
||||
WHERE (mode = 'chat' AND minutes NOT IN (5, 12, 30, 60, 120))
|
||||
OR (mode = 'call' AND minutes NOT IN (10, 20, 45, 60))
|
||||
`
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
describe('GET /internal/config/pricing-tiers', () => {
|
||||
it('returns the canonical chat + call catalog with updated_at on every row', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(Array.isArray(body.data.chat)).toBe(true)
|
||||
expect(Array.isArray(body.data.call)).toBe(true)
|
||||
expect(body.data.chat.length).toBeGreaterThan(0)
|
||||
expect(body.data.call.length).toBeGreaterThan(0)
|
||||
|
||||
const sample = body.data.chat[0]
|
||||
expect(UUID_RE.test(sample.id)).toBe(true)
|
||||
expect(typeof sample.minutes).toBe('number')
|
||||
expect(typeof sample.price_idr).toBe('number')
|
||||
expect('original_price_idr' in sample).toBe(true) // schema-only, but operator-facing GET exposes it
|
||||
expect('updated_at' in sample).toBe(true)
|
||||
expect('is_active' in sample).toBe(true)
|
||||
})
|
||||
|
||||
it('includes is_active=false tiers (operators must be able to see and re-activate them)', async () => {
|
||||
const sql = db()
|
||||
// Create a soft-deleted tier and verify it shows up in the internal GET.
|
||||
const [row] = await sql`
|
||||
INSERT INTO pricing_tiers (mode, minutes, price_idr, sort_order, is_active)
|
||||
VALUES ('chat', 777, 77000, 99, false)
|
||||
RETURNING id
|
||||
`
|
||||
createdTierIds.push(row.id)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const ids = res.json().data.chat.map((t) => t.id)
|
||||
expect(ids).toContain(row.id)
|
||||
const inactive = res.json().data.chat.find((t) => t.id === row.id)
|
||||
expect(inactive.is_active).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /internal/config/pricing-tiers — create', () => {
|
||||
it('happy path: creates a tier, writes a history row with change_kind=create, returns 201', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 45, price_idr: 33000, tag: 'test-create', sort_order: 99 },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(UUID_RE.test(body.data.id)).toBe(true)
|
||||
expect(body.data.mode).toBe('chat')
|
||||
expect(body.data.minutes).toBe(45)
|
||||
expect(body.data.price_idr).toBe(33000)
|
||||
expect(body.data.tag).toBe('test-create')
|
||||
expect(body.data.is_active).toBe(true)
|
||||
createdTierIds.push(body.data.id)
|
||||
|
||||
// History row was written in the same transaction.
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, changed_by, price_idr, minutes, mode
|
||||
FROM pricing_tiers_history WHERE tier_id = ${body.data.id}
|
||||
`
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0].change_kind).toBe('create')
|
||||
expect(history[0].changed_by).toBe(ccUser.id)
|
||||
expect(history[0].price_idr).toBe(33000)
|
||||
})
|
||||
|
||||
it('422 on duplicate (mode, minutes)', async () => {
|
||||
const sql = db()
|
||||
const [existing] = await sql`SELECT mode, minutes FROM pricing_tiers WHERE mode = 'chat' AND minutes = 12`
|
||||
expect(existing).toBeDefined()
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 12, price_idr: 99999 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.code).toBe('VALIDATION')
|
||||
})
|
||||
|
||||
it('422 on negative price', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 88, price_idr: -1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('price_idr')
|
||||
})
|
||||
|
||||
it('422 on non-positive minutes', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 0, price_idr: 1000 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('minutes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /internal/config/pricing-tiers/:id — update with optimistic-lock', () => {
|
||||
it('happy path: updates price_idr and writes update history with correct changed_by', async () => {
|
||||
// Create a fresh tier we own so we don't mutate the canonical catalog.
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 91, price_idr: 9100 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: created.updated_at, price_idr: 9999, tag: 'updated' },
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(200)
|
||||
const updated = patchRes.json().data
|
||||
expect(updated.price_idr).toBe(9999)
|
||||
expect(updated.tag).toBe('updated')
|
||||
expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(created.updated_at).getTime())
|
||||
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, changed_by, price_idr
|
||||
FROM pricing_tiers_history WHERE tier_id = ${created.id} ORDER BY changed_at ASC
|
||||
`
|
||||
expect(history).toHaveLength(2)
|
||||
expect(history[0].change_kind).toBe('create')
|
||||
expect(history[1].change_kind).toBe('update')
|
||||
expect(history[1].price_idr).toBe(9999)
|
||||
expect(history[1].changed_by).toBe(ccUser.id)
|
||||
})
|
||||
|
||||
it('409 STALE_WRITE on mismatched updated_at, with server_updated_at in the error', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 92, price_idr: 9200 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const stale = new Date(new Date(created.updated_at).getTime() - 60_000).toISOString()
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: stale, price_idr: 1 },
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(409)
|
||||
const err = patchRes.json().error
|
||||
expect(err.code).toBe('STALE_WRITE')
|
||||
expect(err.server_updated_at).toBeDefined()
|
||||
})
|
||||
|
||||
it('404 on unknown UUID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/pricing-tiers/00000000-0000-0000-0000-000000000000',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: new Date().toISOString(), price_idr: 1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('422 when id is not a UUID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/pricing-tiers/not-a-uuid',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: new Date().toISOString(), price_idr: 1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('id')
|
||||
})
|
||||
|
||||
it('422 when updated_at is missing', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 93, price_idr: 9300 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { price_idr: 1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('updated_at')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /internal/config/pricing-tiers/:id — soft delete', () => {
|
||||
it('flips is_active=false and writes a delete history row', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 94, price_idr: 9400 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const delRes = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: created.updated_at },
|
||||
})
|
||||
expect(delRes.statusCode).toBe(200)
|
||||
expect(delRes.json().data.is_active).toBe(false)
|
||||
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, is_active, changed_by
|
||||
FROM pricing_tiers_history WHERE tier_id = ${created.id} ORDER BY changed_at ASC
|
||||
`
|
||||
const last = history[history.length - 1]
|
||||
expect(last.change_kind).toBe('delete')
|
||||
// Per the contract: snapshot reflects POST-state, so is_active=false.
|
||||
expect(last.is_active).toBe(false)
|
||||
expect(last.changed_by).toBe(ccUser.id)
|
||||
})
|
||||
|
||||
it('409 STALE_WRITE on mismatched updated_at', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 95, price_idr: 9500 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const stale = new Date(new Date(created.updated_at).getTime() - 60_000).toISOString()
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: stale },
|
||||
})
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json().error.code).toBe('STALE_WRITE')
|
||||
})
|
||||
|
||||
it('404 on unknown UUID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/internal/config/pricing-tiers/00000000-0000-0000-0000-000000000000',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: new Date().toISOString() },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
})
|
||||
199
backend/test/services/pricing.service.test.js
Normal file
199
backend/test/services/pricing.service.test.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
|
||||
import {
|
||||
getPricingForCustomer,
|
||||
isValidTier,
|
||||
findTier,
|
||||
isCustomerEligibleForFirstSessionDiscount,
|
||||
} from '../../src/services/pricing.service.js'
|
||||
import { SessionStatus } from '../../src/constants.js'
|
||||
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
|
||||
import { createCustomer } from '../helpers/fixtures.js'
|
||||
|
||||
/**
|
||||
* Stage 3 service-layer tests for the relational-pricing rewrite.
|
||||
*
|
||||
* Scope:
|
||||
* - getPricingForCustomer returns the expected shape with UUIDs as `id`.
|
||||
* - isValidTier / findTier work for every default chat + call tier seeded by migrate.js.
|
||||
* - isCustomerEligibleForFirstSessionDiscount predicate unchanged (smoke).
|
||||
*
|
||||
* Notes:
|
||||
* - The test schema is seeded by migrate.js (run in setup.js), so pricing_tiers
|
||||
* already has the DEFAULT_* rows. We don't truncate them between tests — the
|
||||
* resetDb helper deliberately leaves pricing_* alone.
|
||||
* - UUIDs are stable across runs once seeded (backfill is empty-gated), so
|
||||
* assertions can compare on (mode, minutes) and just sanity-check `id` is a UUID.
|
||||
*/
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
// Expected post-backfill catalog (mirrors DEFAULT_* in pricing.service.js and migrate.js).
|
||||
const EXPECTED_CHAT = [
|
||||
{ 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 EXPECTED_CALL = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
describe('pricing.service (Stage 3 — relational backing tables)', () => {
|
||||
let customer
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
// Defense against a sibling test file (e.g. pricing-tiers route tests) leaking
|
||||
// non-canonical rows: scrub anything that's not in the seeded catalog before we
|
||||
// start asserting on counts. The canonical seed is 5 chat + 4 call rows.
|
||||
const sql = db()
|
||||
await sql`
|
||||
DELETE FROM pricing_tiers
|
||||
WHERE (mode = 'chat' AND minutes NOT IN (5, 12, 30, 60, 120))
|
||||
OR (mode = 'call' AND minutes NOT IN (10, 20, 45, 60))
|
||||
`
|
||||
// Restore any soft-deleted canonical rows.
|
||||
await sql`
|
||||
UPDATE pricing_tiers
|
||||
SET is_active = true
|
||||
WHERE ((mode = 'chat' AND minutes IN (5, 12, 30, 60, 120))
|
||||
OR (mode = 'call' AND minutes IN (10, 20, 45, 60)))
|
||||
AND is_active = false
|
||||
`
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
|
||||
customer = await createCustomer({ callName: 'PricingSvcTester', phone })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Leave seeded pricing rows + the customer in place; resetDb between files is sufficient.
|
||||
})
|
||||
|
||||
describe('getPricingForCustomer', () => {
|
||||
it('returns chat + call groups with UUID ids, no original_price_idr leak, and the discount block', async () => {
|
||||
const data = await getPricingForCustomer(customer.id)
|
||||
|
||||
expect(Array.isArray(data.chat.tiers)).toBe(true)
|
||||
expect(Array.isArray(data.call.tiers)).toBe(true)
|
||||
expect(data.chat.tiers).toHaveLength(EXPECTED_CHAT.length)
|
||||
expect(data.call.tiers).toHaveLength(EXPECTED_CALL.length)
|
||||
|
||||
// Per-row checks: shape + UUID `id` + correct (minutes, price, tag).
|
||||
data.chat.tiers.forEach((tier, idx) => {
|
||||
expect(UUID_RE.test(tier.id)).toBe(true)
|
||||
expect(tier.minutes).toBe(EXPECTED_CHAT[idx].minutes)
|
||||
expect(tier.price_idr).toBe(EXPECTED_CHAT[idx].price_idr)
|
||||
expect(tier.tag).toBe(EXPECTED_CHAT[idx].tag)
|
||||
// original_price_idr must NOT be in the customer-facing shape.
|
||||
expect('original_price_idr' in tier).toBe(false)
|
||||
})
|
||||
data.call.tiers.forEach((tier, idx) => {
|
||||
expect(UUID_RE.test(tier.id)).toBe(true)
|
||||
expect(tier.minutes).toBe(EXPECTED_CALL[idx].minutes)
|
||||
expect(tier.price_idr).toBe(EXPECTED_CALL[idx].price_idr)
|
||||
expect(tier.tag).toBe(EXPECTED_CALL[idx].tag)
|
||||
expect('original_price_idr' in tier).toBe(false)
|
||||
})
|
||||
|
||||
// Discount block — phone-verified customer with no prior sessions = eligible.
|
||||
expect(data.first_session_discount).toMatchObject({
|
||||
eligible: true,
|
||||
actual_price_idr: 2000,
|
||||
gimmick_price_idr: 12000,
|
||||
duration_minutes: 12,
|
||||
modes: ['chat'],
|
||||
})
|
||||
})
|
||||
|
||||
it('honors sort_order ASC, minutes ASC and hides is_active=false tiers', async () => {
|
||||
const sql = db()
|
||||
// Soft-delete the chat-12-min tier and confirm it disappears from the customer feed.
|
||||
await sql`UPDATE pricing_tiers SET is_active = false WHERE mode = 'chat' AND minutes = 12`
|
||||
|
||||
try {
|
||||
const data = await getPricingForCustomer(customer.id)
|
||||
expect(data.chat.tiers.some((t) => t.minutes === 12)).toBe(false)
|
||||
// The remaining 4 chat tiers must come back in minutes-ASC order (sort_order is
|
||||
// 0..4 from backfill, and dropping 12-min keeps the rest monotone).
|
||||
const minutes = data.chat.tiers.map((t) => t.minutes)
|
||||
expect(minutes).toEqual([...minutes].sort((a, b) => a - b))
|
||||
} finally {
|
||||
// Restore so later tests in this file see the canonical catalog.
|
||||
await sql`UPDATE pricing_tiers SET is_active = true WHERE mode = 'chat' AND minutes = 12`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidTier / findTier', () => {
|
||||
it('isValidTier accepts every default chat tier and rejects bogus combos', async () => {
|
||||
for (const t of EXPECTED_CHAT) {
|
||||
const ok = await isValidTier({ mode: 'chat', durationMinutes: t.minutes, priceIdr: t.price_idr })
|
||||
expect(ok).toBe(true)
|
||||
}
|
||||
// Right minutes, wrong price → reject.
|
||||
expect(await isValidTier({ mode: 'chat', durationMinutes: 12, priceIdr: 9999 })).toBe(false)
|
||||
// Right price, wrong mode → reject (12000 is the 12-min chat price; no 12-min call tier).
|
||||
expect(await isValidTier({ mode: 'call', durationMinutes: 12, priceIdr: 12000 })).toBe(false)
|
||||
})
|
||||
|
||||
it('isValidTier accepts every default call tier', async () => {
|
||||
for (const t of EXPECTED_CALL) {
|
||||
const ok = await isValidTier({ mode: 'call', durationMinutes: t.minutes, priceIdr: t.price_idr })
|
||||
expect(ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('findTier returns the row for every (mode, minutes) in the seed catalog', async () => {
|
||||
for (const t of EXPECTED_CHAT) {
|
||||
const row = await findTier({ mode: 'chat', durationMinutes: t.minutes })
|
||||
expect(row).toBeTruthy()
|
||||
expect(row.minutes).toBe(t.minutes)
|
||||
expect(row.price_idr).toBe(t.price_idr)
|
||||
}
|
||||
for (const t of EXPECTED_CALL) {
|
||||
const row = await findTier({ mode: 'call', durationMinutes: t.minutes })
|
||||
expect(row).toBeTruthy()
|
||||
expect(row.minutes).toBe(t.minutes)
|
||||
expect(row.price_idr).toBe(t.price_idr)
|
||||
}
|
||||
// Unknown duration → null.
|
||||
expect(await findTier({ mode: 'chat', durationMinutes: 999 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCustomerEligibleForFirstSessionDiscount (smoke — predicate unchanged)', () => {
|
||||
it('phone-verified customer with no completed sessions is eligible', async () => {
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(customer.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('anonymous customer (no phone) is NOT eligible', async () => {
|
||||
const anon = await createCustomer({ callName: 'Anon', phone: null })
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(anon.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('customer with a completed session is NOT eligible', async () => {
|
||||
const sql = db()
|
||||
await sql`
|
||||
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
|
||||
VALUES (${customer.id}, ${SessionStatus.COMPLETED}, 12, 12000)
|
||||
`
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(customer.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('eligibility goes false when the promotion is disabled', async () => {
|
||||
const sql = db()
|
||||
await sql`UPDATE pricing_promotions SET enabled = false, updated_at = NOW() WHERE eligibility = 'first_session'`
|
||||
try {
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(customer.id)).toBe(false)
|
||||
} finally {
|
||||
await sql`UPDATE pricing_promotions SET enabled = true, updated_at = NOW() WHERE eligibility = 'first_session'`
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user