Backend half of Stage 10 — the new Chat tab in the customer app that replaces /chat/history with a 3-sub-tab list (Aktif / Pembayaran / Selesai). - New GET /api/client/payment-sessions/pending — returns the customer's pending initial + extension payment sessions. Filter is status='pending' AND expires_at > NOW(). Mitra info comes from session_extensions → chat_sessions for extension rows, payment_sessions.targeted_mitra_id for targeted-curhat-lagi initial rows. TTL reuses the existing payment_session_timeout_minutes app_config row (default 20m) — no new config row needed since payment is still mocked. - getCustomerHistory migrated from offset (page/limit) to cursor pagination. Cursor is base64url(`<endedAtIso>|<id>`) with id-tiebreak in ORDER BY so rows with identical timestamps don't duplicate or skip across pages. SELECT now JOINs payment_sessions to surface `mode` (chat/call) for the Selesai-row voice-call pill. - requirement/flow_customer.mermaid.md: new §7 Chat Tab subgraph + Figma cross-ref entry for SChatList. - requirement/phase4-customer-flow-plan.md: Stage 10 plan section. Also carries forward earlier uncommitted "Post-Stage-8 corrections" notes from the Stage 9 sweep (boot path / SHome1st / onboarding fixes). Tests: +7 for getCustomerPendingPayments (initial null mitra, targeted-mitra fill, extension-via-session JOIN, mixed-newest-first, expired excluded, non-pending excluded, customer scoping). +10 for cursor history (empty, exact-fit, multi-page walk, same-timestamp tiebreak, limit clamp, customer scoping, CLOSING+COMPLETED only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
6.0 KiB
JavaScript
168 lines
6.0 KiB
JavaScript
import { describe, it, expect, beforeAll, beforeEach } from 'vitest'
|
|
import { getCustomerHistory } from '../../src/services/session.service.js'
|
|
import { SessionStatus } from '../../src/constants.js'
|
|
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
|
|
import { createCustomer, createMitra } from '../helpers/fixtures.js'
|
|
|
|
// Phase 4 Stage 10 — Chat Tab Selesai feed uses cursor pagination.
|
|
// Cursor is base64url(`<endedAtIso>|<id>`); response is { items, next_cursor, has_more }.
|
|
|
|
describe('session.service.getCustomerHistory (cursor paginated)', () => {
|
|
let customer
|
|
let mitra
|
|
|
|
beforeAll(async () => {
|
|
await resetAppConfig()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
await resetDb()
|
|
customer = await createCustomer({ callName: 'Alice' })
|
|
mitra = await createMitra({ callName: 'kak Sari' })
|
|
})
|
|
|
|
// Seed N completed sessions ended at deterministically spaced times.
|
|
// i=0 is the OLDEST, i=N-1 is the NEWEST.
|
|
const seedCompleted = async (count) => {
|
|
const sql = db()
|
|
const now = Date.now()
|
|
const ids = []
|
|
for (let i = 0; i < count; i++) {
|
|
const endedAt = new Date(now - (count - i) * 60_000) // 1 min apart
|
|
const [row] = await sql`
|
|
INSERT INTO chat_sessions (
|
|
customer_id, mitra_id, status, duration_minutes, price, ended_at, created_at
|
|
)
|
|
VALUES (
|
|
${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000,
|
|
${endedAt}, ${endedAt}
|
|
)
|
|
RETURNING id
|
|
`
|
|
ids.push(row.id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
it('returns empty + has_more=false when there is no history', async () => {
|
|
const result = await getCustomerHistory(customer.id, { limit: 10 })
|
|
expect(result.items).toEqual([])
|
|
expect(result.has_more).toBe(false)
|
|
expect(result.next_cursor).toBeNull()
|
|
})
|
|
|
|
it('returns all items + has_more=false when count <= limit', async () => {
|
|
await seedCompleted(5)
|
|
const result = await getCustomerHistory(customer.id, { limit: 10 })
|
|
expect(result.items).toHaveLength(5)
|
|
expect(result.has_more).toBe(false)
|
|
expect(result.next_cursor).toBeNull()
|
|
})
|
|
|
|
it('first page returns `limit` items, has_more=true, and a usable cursor', async () => {
|
|
await seedCompleted(7)
|
|
const page1 = await getCustomerHistory(customer.id, { limit: 3 })
|
|
expect(page1.items).toHaveLength(3)
|
|
expect(page1.has_more).toBe(true)
|
|
expect(page1.next_cursor).toBeTruthy()
|
|
})
|
|
|
|
it('walks across pages without duplicates or skips', async () => {
|
|
const seeded = await seedCompleted(7) // ids[0]=oldest .. ids[6]=newest
|
|
const collected = []
|
|
|
|
let cursor = null
|
|
do {
|
|
const page = await getCustomerHistory(customer.id, { cursor, limit: 3 })
|
|
collected.push(...page.items.map((r) => r.id))
|
|
cursor = page.next_cursor
|
|
if (!page.has_more) break
|
|
} while (cursor)
|
|
|
|
// Newest → oldest = reverse of seeded
|
|
expect(collected).toEqual([...seeded].reverse())
|
|
// No duplicates
|
|
expect(new Set(collected).size).toBe(collected.length)
|
|
})
|
|
|
|
it('orders by ended_at DESC with id DESC tiebreak (no gaps for same-timestamp rows)', async () => {
|
|
const sql = db()
|
|
const sameTime = new Date()
|
|
// Insert 3 rows with the EXACT same ended_at — only id distinguishes them.
|
|
const ids = []
|
|
for (let i = 0; i < 3; i++) {
|
|
const [row] = await sql`
|
|
INSERT INTO chat_sessions (
|
|
customer_id, mitra_id, status, duration_minutes, price, ended_at, created_at
|
|
)
|
|
VALUES (
|
|
${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000,
|
|
${sameTime}, ${sameTime}
|
|
)
|
|
RETURNING id
|
|
`
|
|
ids.push(row.id)
|
|
}
|
|
|
|
// Page through them 1 at a time.
|
|
const collected = []
|
|
let cursor = null
|
|
for (let i = 0; i < 5; i++) {
|
|
const page = await getCustomerHistory(customer.id, { cursor, limit: 1 })
|
|
collected.push(...page.items.map((r) => r.id))
|
|
cursor = page.next_cursor
|
|
if (!page.has_more) break
|
|
}
|
|
|
|
expect(collected).toHaveLength(3)
|
|
expect(new Set(collected).size).toBe(3)
|
|
})
|
|
|
|
it('clamps limit to 50 max', async () => {
|
|
await seedCompleted(60)
|
|
const result = await getCustomerHistory(customer.id, { limit: 999 })
|
|
expect(result.items.length).toBeLessThanOrEqual(50)
|
|
})
|
|
|
|
it('scopes by customer — does not leak other customers history', async () => {
|
|
const other = await createCustomer({ callName: 'Bob' })
|
|
const sql = db()
|
|
await sql`
|
|
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
|
|
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW())
|
|
`
|
|
await sql`
|
|
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
|
|
VALUES (${other.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW())
|
|
`
|
|
const result = await getCustomerHistory(customer.id, { limit: 20 })
|
|
expect(result.items).toHaveLength(1)
|
|
})
|
|
|
|
it('includes CLOSING status (grace period) alongside COMPLETED', async () => {
|
|
const sql = db()
|
|
await sql`
|
|
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
|
|
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.CLOSING}, 12, 5000, NOW())
|
|
`
|
|
await sql`
|
|
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
|
|
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW())
|
|
`
|
|
const result = await getCustomerHistory(customer.id, { limit: 20 })
|
|
expect(result.items).toHaveLength(2)
|
|
})
|
|
|
|
it('excludes ACTIVE / PENDING_PAYMENT / EXTENDING', async () => {
|
|
const sql = db()
|
|
for (const status of [SessionStatus.ACTIVE, SessionStatus.PENDING_PAYMENT, SessionStatus.EXTENDING]) {
|
|
await sql`
|
|
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price)
|
|
VALUES (${customer.id}, ${mitra.id}, ${status}, 12, 5000)
|
|
`
|
|
}
|
|
const result = await getCustomerHistory(customer.id, { limit: 20 })
|
|
expect(result.items).toHaveLength(0)
|
|
})
|
|
})
|