Phase 6: Valkey availability mirror — move read path off Postgres
Mitra-availability state (online flag, deactivated flag, per-mitra session count, heartbeat liveness) mirrored into Valkey so the customer beacon + pairing blast + dashboard counts no longer hit Postgres on the hot path. Postgres remains the durable source of truth; Valkey state is fully derivable via seedFromPostgres on startup + reconnect. Schema - mitras:online SET — mirror of is_online - mitras:deactivated SET — mirror of is_active=false - mitra:capacity:<id> STRING — active+pending_payment session count - mitra💓<id> STRING — ISO timestamp of last ping - availability:snapshot JSON — beacon cache, TTL 10s, cluster-shared Write paths (Postgres first, best-effort Valkey) - setOnline/setOffline mirror SADD/SREM + heartbeat SET/DEL - updateMitraStatus mirrors mitras:deactivated AND revokes auth_sessions on deactivate (bounds the "ghost online" window to access-token TTL) - heartbeat is Valkey-only on the hot path; the per-ping Postgres UPDATE on last_heartbeat_at is eliminated (was 1,200 ops/min at prod scale) - chat_session lifecycle (accept/end/reroute/extension/expiry) calls recomputeCapacityForMitra after each UPDATE — derive-from-truth avoids the bookkeeping risk of per-transition INCR/DECR Read paths (Valkey-first, Postgres fallback on Valkey error) - isMitraReachable: SISMEMBER mitras:online + heartbeat freshness - findAvailableMitras: SDIFF + pipelined GETs, filter by capacity + heartbeat - countAvailableMitrasFromCache: Valkey-driven, cached cluster-wide 10s TTL - dashboard online count: SCARD - Each reader wraps Valkey ops in try/catch → Postgres fallback on outage Heartbeat path on /api/mitra/status/heartbeat - resolveMitra preHandler replaced with heartbeatGuard: SISMEMBER on mitras:deactivated (~0 DB hits per ping). Falls back to full DB resolveMitra if Valkey is unreachable so a Valkey outage doesn't silently accept heartbeats from deactivated mitras. Three sweeps, env-configurable cadences - MITRA_AUTO_OFFLINE_SWEEP_SECONDS (30) — Valkey-driven stale detection - HEARTBEAT_MIRROR_INTERVAL_SECONDS (60) — batched UPSERT writes Valkey timestamps to Postgres last_heartbeat_at via UNNEST (1 statement per cycle, idempotent across instances) - VALKEY_ONLINE_MIRROR_SWEEP_SECONDS (300) — periodic reseed heals drift Startup - restoreActiveTimers → seedFromPostgres → bind listeners - onValkeyReady re-runs the seed on every reconnect (cold start + reseed on Valkey restart, no manual intervention) Failure semantics - Read fallback: every Valkey read wrapped, falls back to existing Postgres JOIN query — system stays correct during Valkey outage, performance degrades not breaks - Write best-effort: Postgres write commits before Valkey is touched; Valkey errors log + continue; reconciliation sweep heals drift - Auto-offline sweep aborts entirely on Valkey error (does NOT mass- offline via Postgres scan during Valkey hiccup) Tests - New: 32 integration tests in mitra-status.valkey-mirror.test.js covering seed, write-through, fallbacks, capacity lifecycle, auto-offline sweep, heartbeat mirror, deactivation flow, beacon cache - Updated: fixtures.js seeds Valkey alongside Postgres when isOnline=true - Updated: helpers/db.js resetDb also flushes test Valkey - Fixed 2 pre-existing session-timer flakes (string IDs failed uuid parse; vi.advanceTimersByTimeAsync raced real Postgres I/O) - All 124/124 backend tests pass (was 90/92) Docs - requirement/valkey-online-mirror-plan.md — canonical plan - requirement/valkey-online-mirror-testing.md — manual E2E checklist - requirement/deployment.md — infra + Valkey persistence guidance for prod (Memorystore Standard tier recommended; migration from self-hosted Valkey is zero-downtime via reseed-from-Postgres) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
494
backend/test/services/mitra-status.valkey-mirror.test.js
Normal file
494
backend/test/services/mitra-status.valkey-mirror.test.js
Normal file
@@ -0,0 +1,494 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Integration tests for the Valkey availability mirror
|
||||
* (requirement/valkey-online-mirror-plan.md).
|
||||
*
|
||||
* Real Postgres + real Valkey via test/setup.js — no mocks. We assert on both
|
||||
* stores' state after each operation to catch missed mirrors or order bugs.
|
||||
*/
|
||||
|
||||
vi.mock('../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(),
|
||||
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 valkey = await import('../../src/plugins/valkey.js')
|
||||
const {
|
||||
setOnline,
|
||||
setOffline,
|
||||
heartbeat,
|
||||
isMitraReachable,
|
||||
recomputeCapacityForMitra,
|
||||
recomputeCapacityBySession,
|
||||
seedFromPostgres,
|
||||
autoOfflineStaleMitras,
|
||||
mirrorHeartbeatsToPostgres,
|
||||
countAvailableMitrasFromCache,
|
||||
invalidateAvailabilityCache,
|
||||
VK_MITRAS_ONLINE,
|
||||
VK_MITRAS_DEACTIVATED,
|
||||
vkCapacityKey,
|
||||
vkHeartbeatKey,
|
||||
} = await import('../../src/services/mitra-status.service.js')
|
||||
const { updateMitraStatus } = await import('../../src/services/mitra.service.js')
|
||||
const { findAvailableMitras, acceptPairingRequest, createPairingRequest } = await import('../../src/services/pairing.service.js')
|
||||
const { createPaymentSession, confirmPaymentSession } = await import('../../src/services/payment.service.js')
|
||||
const { db, resetDb, resetAppConfig } = await import('../helpers/db.js')
|
||||
const { getTestValkey } = await import('../helpers/valkey.js')
|
||||
const { createCustomer, createMitra } = await import('../helpers/fixtures.js')
|
||||
const { SessionStatus, UserType } = await import('../../src/constants.js')
|
||||
|
||||
const v = () => getTestValkey()
|
||||
|
||||
describe('mitra-status valkey mirror', () => {
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
})
|
||||
|
||||
// ---------- Seed ----------
|
||||
|
||||
describe('seedFromPostgres', () => {
|
||||
it('populates mitras:online from is_online=true rows', async () => {
|
||||
const m1 = await createMitra({ callName: 'M1', isOnline: true })
|
||||
const m2 = await createMitra({ callName: 'M2', isOnline: true })
|
||||
await createMitra({ callName: 'M3', isOnline: false })
|
||||
|
||||
await v().flushdb()
|
||||
await seedFromPostgres()
|
||||
|
||||
const members = await v().smembers(VK_MITRAS_ONLINE)
|
||||
expect(members.sort()).toEqual([m1.id, m2.id].sort())
|
||||
})
|
||||
|
||||
it('seeds mitras:deactivated from is_active=false', async () => {
|
||||
const m = await createMitra({ callName: 'Dead', isActive: false })
|
||||
await createMitra({ callName: 'Alive', isActive: true })
|
||||
|
||||
await v().flushdb()
|
||||
await seedFromPostgres()
|
||||
|
||||
const members = await v().smembers(VK_MITRAS_DEACTIVATED)
|
||||
expect(members).toEqual([m.id])
|
||||
})
|
||||
|
||||
it('seeds heartbeat keys for online mitras with current timestamp', async () => {
|
||||
const m = await createMitra({ callName: 'Live', isOnline: true })
|
||||
await v().flushdb()
|
||||
|
||||
const before = Date.now()
|
||||
await seedFromPostgres()
|
||||
const after = Date.now()
|
||||
|
||||
const ts = await v().get(vkHeartbeatKey(m.id))
|
||||
expect(ts).toBeTruthy()
|
||||
const seeded = Date.parse(ts)
|
||||
expect(seeded).toBeGreaterThanOrEqual(before)
|
||||
expect(seeded).toBeLessThanOrEqual(after)
|
||||
})
|
||||
|
||||
it('seeds capacity counters from chat_sessions', async () => {
|
||||
const c = await createCustomer({ callName: 'C' })
|
||||
const m = await createMitra({ callName: 'M', isOnline: true })
|
||||
const sql = db()
|
||||
await sql`
|
||||
INSERT INTO chat_sessions (customer_id, mitra_id, status)
|
||||
VALUES (${c.id}, ${m.id}, ${SessionStatus.ACTIVE})
|
||||
`
|
||||
|
||||
await v().flushdb()
|
||||
await seedFromPostgres()
|
||||
|
||||
expect(await v().get(vkCapacityKey(m.id))).toBe('1')
|
||||
})
|
||||
|
||||
it('is idempotent — running twice yields the same state', async () => {
|
||||
const m = await createMitra({ callName: 'Idem', isOnline: true })
|
||||
await seedFromPostgres()
|
||||
const first = {
|
||||
online: (await v().smembers(VK_MITRAS_ONLINE)).sort(),
|
||||
heartbeat: await v().get(vkHeartbeatKey(m.id)),
|
||||
}
|
||||
await seedFromPostgres()
|
||||
const second = {
|
||||
online: (await v().smembers(VK_MITRAS_ONLINE)).sort(),
|
||||
heartbeat: await v().get(vkHeartbeatKey(m.id)),
|
||||
}
|
||||
expect(second.online).toEqual(first.online)
|
||||
// Heartbeat is reseeded with NOW each call — must be >= first
|
||||
expect(Date.parse(second.heartbeat)).toBeGreaterThanOrEqual(Date.parse(first.heartbeat))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- setOnline / setOffline ----------
|
||||
|
||||
describe('setOnline / setOffline write-through', () => {
|
||||
it('setOnline adds to mitras:online + writes heartbeat key', async () => {
|
||||
const m = await createMitra({ callName: 'Toggle', isOnline: false })
|
||||
await v().flushdb()
|
||||
|
||||
await setOnline(m.id)
|
||||
|
||||
expect(await v().sismember(VK_MITRAS_ONLINE, m.id)).toBe(1)
|
||||
expect(await v().get(vkHeartbeatKey(m.id))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('setOffline removes from mitras:online + deletes heartbeat key', async () => {
|
||||
const m = await createMitra({ callName: 'Toggle', isOnline: true })
|
||||
await setOnline(m.id) // ensure heartbeat key exists
|
||||
|
||||
await setOffline(m.id)
|
||||
|
||||
expect(await v().sismember(VK_MITRAS_ONLINE, m.id)).toBe(0)
|
||||
expect(await v().get(vkHeartbeatKey(m.id))).toBeNull()
|
||||
})
|
||||
|
||||
it('setOffline is no-op when mitra was already offline', async () => {
|
||||
const m = await createMitra({ callName: 'OffAlready', isOnline: false })
|
||||
const sql = db()
|
||||
const beforeLogs = await sql`SELECT COUNT(*)::int AS c FROM mitra_online_logs WHERE mitra_id=${m.id}`
|
||||
|
||||
await setOffline(m.id)
|
||||
|
||||
const afterLogs = await sql`SELECT COUNT(*)::int AS c FROM mitra_online_logs WHERE mitra_id=${m.id}`
|
||||
expect(afterLogs[0].c).toBe(beforeLogs[0].c)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- heartbeat ----------
|
||||
|
||||
describe('heartbeat (Valkey-only)', () => {
|
||||
it('writes Valkey timestamp without touching Postgres last_heartbeat_at', async () => {
|
||||
const m = await createMitra({ callName: 'Pinger', isOnline: true })
|
||||
const sql = db()
|
||||
const [before] = await sql`SELECT last_heartbeat_at FROM mitra_online_status WHERE mitra_id=${m.id}`
|
||||
const pgBefore = before.last_heartbeat_at
|
||||
|
||||
// Make sure subsequent NOW() would differ
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
await heartbeat(m.id)
|
||||
|
||||
const [after] = await sql`SELECT last_heartbeat_at FROM mitra_online_status WHERE mitra_id=${m.id}`
|
||||
// Postgres untouched
|
||||
expect(after.last_heartbeat_at).toEqual(pgBefore)
|
||||
// Valkey updated
|
||||
const ts = await v().get(vkHeartbeatKey(m.id))
|
||||
expect(ts).toBeTruthy()
|
||||
expect(Date.parse(ts)).toBeGreaterThan(pgBefore.getTime())
|
||||
})
|
||||
|
||||
it('advances the heartbeat timestamp on each call', async () => {
|
||||
const m = await createMitra({ callName: 'P', isOnline: true })
|
||||
await heartbeat(m.id)
|
||||
const t1 = await v().get(vkHeartbeatKey(m.id))
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
await heartbeat(m.id)
|
||||
const t2 = await v().get(vkHeartbeatKey(m.id))
|
||||
|
||||
expect(Date.parse(t2)).toBeGreaterThan(Date.parse(t1))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- isMitraReachable ----------
|
||||
|
||||
describe('isMitraReachable', () => {
|
||||
it('returns true for online mitra with fresh heartbeat', async () => {
|
||||
const m = await createMitra({ callName: 'Reach', isOnline: true })
|
||||
expect(await isMitraReachable(m.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when mitra is not in mitras:online', async () => {
|
||||
const m = await createMitra({ callName: 'NoReach', isOnline: false })
|
||||
expect(await isMitraReachable(m.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when heartbeat is stale', async () => {
|
||||
const m = await createMitra({ callName: 'Stale', isOnline: true })
|
||||
// Force stale heartbeat (one hour ago)
|
||||
const ancient = new Date(Date.now() - 3_600_000).toISOString()
|
||||
await v().set(vkHeartbeatKey(m.id), ancient)
|
||||
|
||||
expect(await isMitraReachable(m.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- recomputeCapacity ----------
|
||||
|
||||
describe('recomputeCapacityForMitra', () => {
|
||||
it('counts ACTIVE + PENDING_PAYMENT sessions', async () => {
|
||||
const c = await createCustomer({ callName: 'C' })
|
||||
const c2 = await createCustomer({ callName: 'C2' })
|
||||
const m = await createMitra({ callName: 'Cap', isOnline: true })
|
||||
const sql = db()
|
||||
await sql`
|
||||
INSERT INTO chat_sessions (customer_id, mitra_id, status)
|
||||
VALUES (${c.id}, ${m.id}, ${SessionStatus.ACTIVE}),
|
||||
(${c2.id}, ${m.id}, ${SessionStatus.PENDING_PAYMENT})
|
||||
`
|
||||
await recomputeCapacityForMitra(m.id)
|
||||
expect(await v().get(vkCapacityKey(m.id))).toBe('2')
|
||||
})
|
||||
|
||||
it('excludes ended/closing/extending sessions', async () => {
|
||||
const c = await createCustomer({ callName: 'C' })
|
||||
const m = await createMitra({ callName: 'Cap', isOnline: true })
|
||||
const sql = db()
|
||||
await sql`
|
||||
INSERT INTO chat_sessions (customer_id, mitra_id, status)
|
||||
VALUES (${c.id}, ${m.id}, ${SessionStatus.COMPLETED})
|
||||
`
|
||||
await recomputeCapacityForMitra(m.id)
|
||||
expect(await v().get(vkCapacityKey(m.id))).toBe('0')
|
||||
})
|
||||
|
||||
it('no-op when mitraId is null/undefined', async () => {
|
||||
await recomputeCapacityForMitra(null) // should not throw
|
||||
await recomputeCapacityForMitra(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- findAvailableMitras ----------
|
||||
|
||||
describe('findAvailableMitras (Valkey-driven)', () => {
|
||||
it('returns online + not-deactivated + under-capacity + fresh-heartbeat mitras', async () => {
|
||||
const ok = await createMitra({ callName: 'OK', isOnline: true })
|
||||
const deact = await createMitra({ callName: 'Deact', isOnline: true, isActive: false })
|
||||
const offline = await createMitra({ callName: 'Off', isOnline: false })
|
||||
const stale = await createMitra({ callName: 'Stale', isOnline: true })
|
||||
await v().set(vkHeartbeatKey(stale.id), new Date(Date.now() - 3_600_000).toISOString())
|
||||
|
||||
const result = await findAvailableMitras()
|
||||
const ids = result.map(r => r.id).sort()
|
||||
expect(ids).toEqual([ok.id].sort())
|
||||
expect(result.find(r => r.id === ok.id).active_session_count).toBe(0)
|
||||
})
|
||||
|
||||
it('excludes a mitra whose capacity is at max', async () => {
|
||||
const m = await createMitra({ callName: 'AtCap', isOnline: true })
|
||||
// max_customers_per_mitra default is 3
|
||||
await v().set(vkCapacityKey(m.id), 3)
|
||||
|
||||
const result = await findAvailableMitras()
|
||||
expect(result.find(r => r.id === m.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns capacity in the result for the blast loop', async () => {
|
||||
const m = await createMitra({ callName: 'WithCap', isOnline: true })
|
||||
await v().set(vkCapacityKey(m.id), 2)
|
||||
const result = await findAvailableMitras()
|
||||
expect(result.find(r => r.id === m.id).active_session_count).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- countAvailableMitrasFromCache ----------
|
||||
|
||||
describe('countAvailableMitrasFromCache (beacon)', () => {
|
||||
it('caches the snapshot in Valkey with TTL', async () => {
|
||||
await createMitra({ callName: 'On', isOnline: true })
|
||||
await v().del('availability:snapshot')
|
||||
|
||||
const first = await countAvailableMitrasFromCache()
|
||||
expect(first.available).toBe(true)
|
||||
expect(first.count).toBe(1)
|
||||
|
||||
const cached = await v().get('availability:snapshot')
|
||||
expect(cached).toBeTruthy()
|
||||
expect(JSON.parse(cached)).toEqual(first)
|
||||
|
||||
const ttl = await v().ttl('availability:snapshot')
|
||||
expect(ttl).toBeGreaterThan(0)
|
||||
expect(ttl).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('returns cached snapshot on subsequent calls without recompute', async () => {
|
||||
await createMitra({ callName: 'On', isOnline: true })
|
||||
await countAvailableMitrasFromCache() // primes cache
|
||||
|
||||
// Manually corrupt SET to prove subsequent call reads cache, not Valkey state
|
||||
await v().flushdb()
|
||||
await v().set('availability:snapshot', JSON.stringify({ available: true, count: 42 }), 'EX', 10)
|
||||
|
||||
const result = await countAvailableMitrasFromCache()
|
||||
expect(result.count).toBe(42)
|
||||
})
|
||||
|
||||
it('invalidateAvailabilityCache deletes the snapshot', async () => {
|
||||
await v().set('availability:snapshot', JSON.stringify({ available: true, count: 1 }), 'EX', 10)
|
||||
await invalidateAvailabilityCache()
|
||||
expect(await v().get('availability:snapshot')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- autoOfflineStaleMitras ----------
|
||||
|
||||
describe('autoOfflineStaleMitras', () => {
|
||||
it('flips Postgres + cleans Valkey for mitras with stale heartbeat', async () => {
|
||||
const m = await createMitra({ callName: 'WillStale', isOnline: true })
|
||||
const sql = db()
|
||||
|
||||
// Force stale heartbeat
|
||||
await v().set(vkHeartbeatKey(m.id), new Date(Date.now() - 3_600_000).toISOString())
|
||||
|
||||
const count = await autoOfflineStaleMitras()
|
||||
expect(count).toBe(1)
|
||||
|
||||
const [row] = await sql`SELECT is_online FROM mitra_online_status WHERE mitra_id=${m.id}`
|
||||
expect(row.is_online).toBe(false)
|
||||
expect(await v().sismember(VK_MITRAS_ONLINE, m.id)).toBe(0)
|
||||
expect(await v().get(vkHeartbeatKey(m.id))).toBeNull()
|
||||
|
||||
const [log] = await sql`
|
||||
SELECT status FROM mitra_online_logs
|
||||
WHERE mitra_id=${m.id} ORDER BY timestamp DESC LIMIT 1
|
||||
`
|
||||
expect(log.status).toBe('offline')
|
||||
})
|
||||
|
||||
it('no-op when no mitras are stale', async () => {
|
||||
await createMitra({ callName: 'Fresh', isOnline: true })
|
||||
const count = await autoOfflineStaleMitras()
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
it('no-op when require_ping=false', async () => {
|
||||
const sql = db()
|
||||
await sql`
|
||||
UPDATE app_config SET value=${sql.json({ value: false })}
|
||||
WHERE key='require_mitra_ping'
|
||||
`
|
||||
const m = await createMitra({ callName: 'WouldBeStale', isOnline: true })
|
||||
await v().set(vkHeartbeatKey(m.id), new Date(Date.now() - 3_600_000).toISOString())
|
||||
|
||||
const count = await autoOfflineStaleMitras()
|
||||
expect(count).toBe(0)
|
||||
// Restore for other tests
|
||||
await sql`
|
||||
UPDATE app_config SET value=${sql.json({ value: true })}
|
||||
WHERE key='require_mitra_ping'
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- mirrorHeartbeatsToPostgres ----------
|
||||
|
||||
describe('mirrorHeartbeatsToPostgres', () => {
|
||||
it('writes Valkey heartbeat timestamps to Postgres last_heartbeat_at in one batch', async () => {
|
||||
const m1 = await createMitra({ callName: 'P1', isOnline: true })
|
||||
const m2 = await createMitra({ callName: 'P2', isOnline: true })
|
||||
const sql = db()
|
||||
|
||||
const ts = new Date(Date.now() - 2_000).toISOString()
|
||||
await v().set(vkHeartbeatKey(m1.id), ts)
|
||||
await v().set(vkHeartbeatKey(m2.id), ts)
|
||||
|
||||
const count = await mirrorHeartbeatsToPostgres()
|
||||
expect(count).toBe(2)
|
||||
|
||||
const rows = await sql`
|
||||
SELECT mitra_id, last_heartbeat_at FROM mitra_online_status
|
||||
WHERE mitra_id IN (${m1.id}, ${m2.id})
|
||||
`
|
||||
for (const row of rows) {
|
||||
expect(row.last_heartbeat_at.toISOString()).toBe(ts)
|
||||
}
|
||||
})
|
||||
|
||||
it('no-op when no mitras are online', async () => {
|
||||
await v().del(VK_MITRAS_ONLINE)
|
||||
const count = await mirrorHeartbeatsToPostgres()
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- updateMitraStatus / revokeAllSessions ----------
|
||||
|
||||
describe('updateMitraStatus + auth_session revocation', () => {
|
||||
it('deactivation adds to mitras:deactivated AND revokes all auth_sessions', async () => {
|
||||
const m = await createMitra({ callName: 'Banned', isActive: true })
|
||||
const sql = db()
|
||||
const tokenHash = '$2b$10$abcdefghijklmnopqrstuv'
|
||||
await sql`
|
||||
INSERT INTO auth_sessions (user_type, user_id, refresh_token_hash, expires_at)
|
||||
VALUES (${UserType.MITRA}, ${m.id}, ${tokenHash}, NOW() + INTERVAL '30 days')
|
||||
`
|
||||
|
||||
await updateMitraStatus(m.id, false)
|
||||
|
||||
expect(await v().sismember(VK_MITRAS_DEACTIVATED, m.id)).toBe(1)
|
||||
const [auth] = await sql`SELECT revoked_at FROM auth_sessions WHERE user_id=${m.id}`
|
||||
expect(auth.revoked_at).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reactivation removes from mitras:deactivated', async () => {
|
||||
const m = await createMitra({ callName: 'Pardoned', isActive: false })
|
||||
await v().sadd(VK_MITRAS_DEACTIVATED, m.id)
|
||||
|
||||
await updateMitraStatus(m.id, true)
|
||||
|
||||
expect(await v().sismember(VK_MITRAS_DEACTIVATED, m.id)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- E2E: blast lifecycle ----------
|
||||
|
||||
describe('end-to-end: blast lifecycle drives capacity counter', () => {
|
||||
it('mitra accept → capacity++; session end is covered separately', async () => {
|
||||
const c = await createCustomer({ callName: 'BlastC' })
|
||||
const m = await createMitra({ callName: 'BlastM', isOnline: true })
|
||||
|
||||
const pay = await createPaymentSession({
|
||||
customerId: c.id,
|
||||
durationMinutes: 15,
|
||||
amount: 30000,
|
||||
})
|
||||
await confirmPaymentSession(pay.id, c.id)
|
||||
const session = await createPairingRequest(c.id, { paymentRequestId: pay.id })
|
||||
expect(await v().get(vkCapacityKey(m.id))).toBe('0') // no accept yet
|
||||
|
||||
await acceptPairingRequest(session.id, m.id)
|
||||
expect(await v().get(vkCapacityKey(m.id))).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Reader fallback when Valkey is unavailable ----------
|
||||
|
||||
describe('reader fallback', () => {
|
||||
it('isMitraReachable falls back to Postgres on Valkey error', async () => {
|
||||
const m = await createMitra({ callName: 'Fallback', isOnline: true })
|
||||
// Stub sismember to throw
|
||||
const spy = vi.spyOn(valkey, 'sismember').mockRejectedValue(new Error('valkey down'))
|
||||
try {
|
||||
// Postgres has is_online=true → fallback returns true
|
||||
const result = await isMitraReachable(m.id)
|
||||
expect(result).toBe(true)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('findAvailableMitras falls back to Postgres JOIN when Valkey sdiff throws', async () => {
|
||||
const m = await createMitra({ callName: 'FallbackBlast', isOnline: true })
|
||||
const spy = vi.spyOn(valkey, 'sdiff').mockRejectedValue(new Error('valkey down'))
|
||||
try {
|
||||
const result = await findAvailableMitras()
|
||||
expect(result.find(r => r.id === m.id)).toBeDefined()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
// Capture calls to sendToSessionParticipant so we can assert the 3-min warning event.
|
||||
vi.mock('../../src/plugins/websocket.js', () => ({
|
||||
@@ -15,10 +16,42 @@ vi.mock('../../src/services/notification.service.js', () => ({
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/plugins/valkey.js', () => ({
|
||||
publish: vi.fn(async () => {}),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
}))
|
||||
// Real DB queries don't settle under fake timers (they're real socket I/O, not
|
||||
// microtasks). Stub getDb() with a tagged-template-compatible mock so onThreeMinuteWarning's
|
||||
// `SELECT expires_at FROM chat_sessions WHERE id = ${sessionId}` resolves synchronously.
|
||||
vi.mock('../../src/db/client.js', () => {
|
||||
const fakeSql = () => Promise.resolve([{ expires_at: null }])
|
||||
fakeSql.unsafe = () => Promise.resolve([])
|
||||
fakeSql.array = (arr) => arr
|
||||
fakeSql.json = (v) => v
|
||||
return { getDb: () => fakeSql }
|
||||
})
|
||||
|
||||
vi.mock('../../src/plugins/valkey.js', () => {
|
||||
const noopPipeline = { sadd: () => noopPipeline, srem: () => noopPipeline, set: () => noopPipeline, get: () => noopPipeline, del: () => noopPipeline, exec: async () => [] }
|
||||
return {
|
||||
publish: vi.fn(async () => {}),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
onValkeyReady: vi.fn(),
|
||||
getValkeyClient: vi.fn(() => ({ setex: vi.fn(async () => 'OK') })),
|
||||
getValkeyPub: vi.fn(),
|
||||
getValkeySub: vi.fn(),
|
||||
sadd: vi.fn(async () => 1),
|
||||
srem: vi.fn(async () => 1),
|
||||
sismember: vi.fn(async () => false),
|
||||
smembers: vi.fn(async () => []),
|
||||
sdiff: vi.fn(async () => []),
|
||||
scard: vi.fn(async () => 0),
|
||||
set: vi.fn(async () => 'OK'),
|
||||
get: vi.fn(async () => null),
|
||||
del: vi.fn(async () => 1),
|
||||
incr: vi.fn(async () => 1),
|
||||
decr: vi.fn(async () => 0),
|
||||
exists: vi.fn(async () => 0),
|
||||
pipeline: vi.fn(() => noopPipeline),
|
||||
multi: vi.fn(() => noopPipeline),
|
||||
}
|
||||
})
|
||||
|
||||
const { sendToSessionParticipant } = await import('../../src/plugins/websocket.js')
|
||||
const { startSessionTimer, clearSessionTimer } = await import('../../src/services/session-timer.service.js')
|
||||
@@ -35,7 +68,9 @@ describe('session-timer 3-minute warning (Phase 4)', () => {
|
||||
})
|
||||
|
||||
it('emits session_warning kind:three_minutes_left exactly once at the 3-min mark', async () => {
|
||||
const sessionId = 'sess-3min-test'
|
||||
// Real UUID — onThreeMinuteWarning runs a Postgres SELECT against chat_sessions.id
|
||||
// which is uuid-typed; string ids throw a parse error before we hit the row check.
|
||||
const sessionId = randomUUID()
|
||||
const expiresAt = new Date(Date.now() + 5 * 60_000) // 5 minutes from now
|
||||
|
||||
startSessionTimer(sessionId, expiresAt)
|
||||
@@ -65,7 +100,7 @@ describe('session-timer 3-minute warning (Phase 4)', () => {
|
||||
})
|
||||
|
||||
it('does NOT re-fire the 3-min warning when the timer is rescheduled (e.g. extension)', async () => {
|
||||
const sessionId = 'sess-rescheduled'
|
||||
const sessionId = randomUUID()
|
||||
const initial = new Date(Date.now() + 5 * 60_000)
|
||||
startSessionTimer(sessionId, initial)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user