/** * Smoke test — WebSocket hub + role-aware dashboard analytics. * Boots against an already-running server on http://localhost:3001. * * Usage: * node evidence/smoke-ws-dashboard.cjs * * Output is captured via redirection (printed to stdout) — caller saves to * evidence/smoke-ws-dashboard.txt. */ const http = require('http'); const WebSocket = require('ws'); const BASE_URL = process.env.BASE_URL || 'http://localhost:3001'; const WS_URL = process.env.WS_URL || 'ws://localhost:3001'; function log(...args) { const ts = new Date().toISOString(); console.log(`[${ts}]`, ...args); } function httpRequest(method, path, body, token) { return new Promise((resolve, reject) => { const url = new URL(path, BASE_URL); const data = body ? JSON.stringify(body) : null; const opts = { method, hostname: url.hostname, port: url.port || 80, path: url.pathname + url.search, headers: { 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), ...(token ? { 'Authorization': `Bearer ${token}` } : {}), }, }; const req = http.request(opts, (res) => { let buf = ''; res.on('data', (c) => { buf += c; }); res.on('end', () => { let parsed = null; try { parsed = JSON.parse(buf); } catch (_e) { parsed = buf; } resolve({ status: res.statusCode, body: parsed }); }); }); req.on('error', reject); if (data) req.write(data); req.end(); }); } async function login(email, password) { const r = await httpRequest('POST', '/api/auth/login', { email, password }); if (r.status !== 200) { throw new Error(`Login failed for ${email}: ${r.status} ${JSON.stringify(r.body)}`); } return r.body; } async function timeMs(fn) { const t0 = Date.now(); const r = await fn(); return { r, ms: Date.now() - t0 }; } async function main() { log('=== WS Hub + Role-aware Dashboard smoke test ==='); log(`HTTP base: ${BASE_URL}`); log(`WS base: ${WS_URL}`); log(''); // --- 1. Login as each role ---------------------------------------- log('--- 1. Login as each demo role ---'); const admin = await login('admin@school.com', 'admin123'); log(`admin id=${admin.user.id} role=${admin.user.role}`); const teacher = await login('teacher@school.com', 'teacher123'); log(`teacher id=${teacher.user.id} role=${teacher.user.role}`); const student = await login('student@school.com', 'student123'); log(`student id=${student.user.id} role=${student.user.role}`); // Try parent login — fallback to whatever parent account exists in init.js let parent; try { parent = await login('parent@school.com', 'parent123'); log(`parent id=${parent.user.id} role=${parent.user.role}`); } catch (e) { log(`WARN parent@school.com login failed: ${e.message}`); } // --- 2. WebSocket: invalid JWT rejected ---------------------------- log(''); log('--- 2. WS invalid-JWT rejection ---'); await new Promise((resolve) => { const ws = new WebSocket(`${WS_URL}/ws?token=BAD_TOKEN`); let resolved = false; const finish = (status, msg) => { if (resolved) return; resolved = true; log(`WS(invalid) result: ${status} — ${msg}`); try { ws.terminate(); } catch (_) {} resolve(); }; ws.on('open', () => finish('CONNECTED_BUT_INVALID', 'server accepted bad token (BAD!)')); ws.on('error', (err) => finish('REJECTED', `error event: ${err.message || err.code || 'unknown'}`)); ws.on('unexpected-response', (_req, res) => finish('REJECTED', `HTTP ${res.statusCode}`)); ws.on('close', (code, reason) => finish('CLOSED', `code=${code} reason=${reason || '(empty)'}`)); setTimeout(() => finish('TIMEOUT', 'no event within 5s'), 5000); }); // --- 3. WebSocket: valid JWT connects + subscribes ----------------- log(''); log('--- 3. WS valid-JWT connect + subscribe ---'); const wsMessages = []; await new Promise((resolve, reject) => { const ws = new WebSocket(`${WS_URL}/ws?token=${admin.token}`); let resolved = false; const finish = (err) => { if (resolved) return; resolved = true; if (err) reject(err); else resolve(); try { ws.close(1000, 'test done'); } catch (_) {} }; ws.on('open', () => { log('WS(open): connection established'); ws.send(JSON.stringify({ type: 'subscribe', channels: ['class:1', 'class:2'] })); }); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); wsMessages.push(msg); log(`WS(msg): ${JSON.stringify(msg)}`); if (msg.type === 'subscribed') { // Send a ping to confirm two-way traffic setTimeout(() => ws.send(JSON.stringify({ type: 'ping' })), 200); } if (msg.type === 'pong') { // Done after pong received setTimeout(() => finish(null), 200); } }); ws.on('error', (err) => finish(new Error(`ws error: ${err.message}`))); ws.on('close', (code, reason) => { if (!resolved) log(`WS(close): code=${code} reason=${reason || '(empty)'}`); }); setTimeout(() => finish(new Error('timeout waiting for pong')), 8000); }); log(`WS(valid) received ${wsMessages.length} message(s):`, JSON.stringify(wsMessages.map(m => m.type))); // --- 4. Dashboard analytics for each role -------------------------- log(''); log('--- 4. Role-aware dashboard analytics ---'); const roles = [ { name: 'admin', token: admin.token, url: '/api/dashboard/admin' }, { name: 'teacher', token: teacher.token, url: '/api/dashboard/teacher' }, { name: 'student', token: student.token, url: '/api/dashboard/student' }, ]; if (parent) roles.push({ name: 'parent', token: parent.token, url: '/api/dashboard/parent' }); const summary = {}; for (const r of roles) { const { r: res, ms } = await timeMs(() => httpRequest('GET', r.url, null, r.token)); const under500 = ms < 500; const ok = res.status === 200 && res.body && typeof res.body === 'object'; const newFields = pickAnalyticsFields(r.name, res.body || {}); log(`${r.name.padEnd(8)} status=${res.status} time=${ms}ms under500=${under500}`); log(` analytics fields: ${JSON.stringify(newFields)}`); summary[r.name] = { status: res.status, timeMs: ms, under500, ok, newFields, bodyKeys: res.body && typeof res.body === 'object' ? Object.keys(res.body) : [], }; if (!ok) { log(` BODY: ${JSON.stringify(res.body).slice(0, 500)}`); } } // --- 5. Verdict ----------------------------------------------------- log(''); log('--- 5. Verdict ---'); const allOk = Object.values(summary).every(s => s.ok); const allUnder500 = Object.values(summary).every(s => s.under500); log(`all_ok=${allOk} all_under_500ms=${allUnder500}`); const result = { pass: allOk && allUnder500, summary, wsMessages, }; log(`VERDICT: ${result.pass ? 'PASS' : 'FAIL'}`); // Output a machine-readable summary on the last line for easy grep console.log('===SMOKE_RESULT_JSON===' + JSON.stringify(result)); } function pickAnalyticsFields(role, body) { switch (role) { case 'admin': return { active_enrollments: body.active_enrollments, attendance_rate_30d: body.attendance_rate_30d, grade_distribution: body.grade_distribution, programs_count: body.programs_count, classes_count: body.classes_count, users_by_role: body.users_by_role, }; case 'teacher': return { per_course: body.per_course, overall_students_count: body.overall_students_count, pending_grading_count: body.pending_grading_count, }; case 'student': return { per_course: body.per_course, pending_assignments: body.pending_assignments, recent_marks: body.recent_marks, gamification_points: body.gamification_points, leaderboard_rank: body.leaderboard_rank, }; case 'parent': return { per_dependent: body.per_dependent, }; default: return {}; } } main().catch((err) => { console.error('SMOKE TEST CRASH:', err); console.log('===SMOKE_RESULT_JSON===' + JSON.stringify({ pass: false, crash: err.message })); process.exit(1); });