geocrop-platform./apps/nextgen/server/smoke-ws-dashboard.cjs

237 lines
8.2 KiB
JavaScript

/**
* 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);
});