9c6e2c76e8
Root cause of 'waiting for escalations': no queue/membership meant tickets were never assigned. Now: createTicket find-or-creates a default queue; new joinDefault endpoint + useGoOnline hook; agent-demo joins+goes-AVAILABLE on load (assignPending picks up any waiting ticket). smoke-support self-seeds (no seed script needed). Also: vitest singleFork to end cross-file DB races (45 tests deterministic). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
73 lines
3.2 KiB
JavaScript
73 lines
3.2 KiB
JavaScript
// P4 support smoke: customer escalates a chat → ticket created + auto-assigned to
|
|
// an available agent → agent (now a thread participant) replies → customer sees it
|
|
// live. Requires: service running (relay timer on) + `node scripts/seed-support.mjs`.
|
|
import 'dotenv/config';
|
|
import { io } from 'socket.io-client';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
|
const APP_ID = 'portal-demo';
|
|
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
|
const sign = (u) => jwt.sign({ sub: u, name: u, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
|
|
const connect = (u) => io(`${SERVICE}/message`, { auth: { token: sign(u) }, transports: ['websocket'], forceNew: true });
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
|
|
|
async function api(path, method, token, body) {
|
|
const r = await fetch(`${SERVICE}${path}`, {
|
|
method,
|
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
|
|
return r.json();
|
|
}
|
|
async function pollUntil(fn, ms = 8000) {
|
|
const end = Date.now() + ms;
|
|
while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(300); }
|
|
return null;
|
|
}
|
|
function waitForMessage(socket, pred, ms = 6000) {
|
|
return new Promise((res) => {
|
|
const to = setTimeout(() => res(null), ms);
|
|
const h = (m) => { if (pred(m)) { socket.off('message', h); clearTimeout(to); res(m); } };
|
|
socket.on('message', h);
|
|
});
|
|
}
|
|
|
|
const custToken = sign('cust');
|
|
const agentId = process.env.AGENT_ID ?? 'agent1';
|
|
const agentToken = sign(agentId);
|
|
const cust = connect('cust');
|
|
const agent = connect(agentId);
|
|
|
|
try {
|
|
// Agent self-seeds exactly like the agent-demo does (join default queue + go online).
|
|
await api('/v1/support/agents/me/join', 'POST', agentToken);
|
|
await api('/v1/support/members/me/availability', 'PATCH', agentToken, { state: 'AVAILABLE' });
|
|
|
|
const { threadId } = await cust.emitWithAck('open_thread', {});
|
|
await cust.emitWithAck('send_message', { threadId, content: 'my payment failed' });
|
|
|
|
const ticket = await api('/v1/support/escalate', 'POST', custToken, { threadId });
|
|
assert(!!ticket.id, `customer escalated → ticket ${ticket.id} (${ticket.state})`);
|
|
|
|
const assigned = await pollUntil(async () => {
|
|
const ts = await api('/v1/support/tickets?scope=mine', 'GET', custToken);
|
|
const t = ts.find((x) => x.id === ticket.id);
|
|
return t && t.state === 'OPEN' && t.assignedActorId ? t : null;
|
|
});
|
|
assert(assigned, 'ticket auto-assigned to an agent (state OPEN)');
|
|
|
|
await agent.emitWithAck('open_thread', { threadId });
|
|
const gotReply = waitForMessage(cust, (m) => m.content === 'Hi, I can help with that.');
|
|
await agent.emitWithAck('send_message', { threadId, content: 'Hi, I can help with that.' });
|
|
assert(await gotReply, 'customer received the agent reply live');
|
|
|
|
console.log('\nP4 support smoke: PASS');
|
|
} finally {
|
|
cust.close();
|
|
agent.close();
|
|
}
|
|
process.exit(0);
|