Files
halobestie-clone/backend/test/routes/internal/first-session-discount.test.js
ramadhan sjamsani 1c9d81d81d 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>
2026-05-16 00:12:11 +08:00

179 lines
6.2 KiB
JavaScript

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