237 lines
8.2 KiB
JavaScript
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);
|
|
}); |