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'` } }) }) })