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