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