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>
350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
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)
|
|
})
|
|
})
|
|
})
|