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>
179 lines
6.2 KiB
JavaScript
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')
|
|
})
|
|
})
|