Ship admin auth, user overview, assessments, chats, assignment filters/export, security hardening, and a separate desktop admin shell with clearer assessment answers. Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { prisma } from '../src/lib/prisma.js';
|
|
import { ChatRole } from '@prisma/client';
|
|
import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js';
|
|
import { exampleCompletionSignal } from '../src/modules/ai/sessionCompletion.js';
|
|
import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.js';
|
|
import { getSessionByOrder, PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.js';
|
|
import { seedCourseSessions } from '../src/modules/sessions/sessions.service.js';
|
|
import {
|
|
createTestApp,
|
|
isDatabaseAvailable,
|
|
requestJson,
|
|
} from './helpers.js';
|
|
|
|
const app = createTestApp();
|
|
const createdUserIds: bigint[] = [];
|
|
|
|
function pcqAnswers(value: number) {
|
|
return PCQ_QUESTION_IDS.map((questionId) => ({
|
|
questionId,
|
|
selectedValue: String(value),
|
|
}));
|
|
}
|
|
|
|
function headers(token?: string, ip = 'e2e-qa'): HeadersInit {
|
|
const next: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'x-real-ip': ip,
|
|
};
|
|
if (token) next.Authorization = `Bearer ${token}`;
|
|
return next;
|
|
}
|
|
|
|
async function registerUser(suffix: string, ip: string) {
|
|
const email = `e2e-${suffix}-${Date.now()}@example.com`;
|
|
const { status, body } = await requestJson(app, '/auth/register', {
|
|
method: 'POST',
|
|
headers: headers(undefined, ip),
|
|
body: JSON.stringify({ email, password: 'password12' }),
|
|
});
|
|
assert.equal(status, 201, `register failed: ${JSON.stringify(body)}`);
|
|
const token = body.data?.sessionToken as string;
|
|
const externalId = (body.data?.user as { id: string }).id;
|
|
const user = await prisma.user.findUnique({ where: { externalId } });
|
|
assert.ok(user);
|
|
createdUserIds.push(user.id);
|
|
return { email, token, user, externalId };
|
|
}
|
|
|
|
async function completeSession(token: string, ip: string, order: number, userId: bigint) {
|
|
const chat = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: headers(token, ip),
|
|
body: JSON.stringify({ sessionOrder: order }),
|
|
});
|
|
assert.equal(chat.status, 201, `chat create ${order} failed: ${JSON.stringify(chat.body)}`);
|
|
const chatId = chat.body.data?.id as string;
|
|
|
|
await prisma.chatMessage.createMany({
|
|
data: [
|
|
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `سلام جلسه ${order}` },
|
|
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `ادامه جلسه ${order}` },
|
|
],
|
|
});
|
|
|
|
const session = getSessionByOrder(order);
|
|
assert.ok(session);
|
|
const complete = await requestJson(app, `/sessions/${order}/complete`, {
|
|
method: 'POST',
|
|
headers: headers(token, ip),
|
|
body: JSON.stringify({
|
|
...exampleCompletionSignal(buildAiCoachSessionContext(session)),
|
|
chatSessionId: chatId,
|
|
}),
|
|
});
|
|
assert.equal(
|
|
complete.status,
|
|
200,
|
|
`complete session ${order} failed: ${JSON.stringify(complete.body)}`,
|
|
);
|
|
|
|
await prisma.sessionProgress.update({
|
|
where: { userId_sessionOrder: { userId, sessionOrder: order } },
|
|
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
|
|
});
|
|
|
|
return { chatId, progress: complete.body.data, userId };
|
|
}
|
|
|
|
describe('full E2E learning journey QA', () => {
|
|
let dbAvailable = false;
|
|
|
|
before(async () => {
|
|
dbAvailable = await isDatabaseAvailable();
|
|
if (dbAvailable) {
|
|
await seedCourseSessions();
|
|
}
|
|
});
|
|
|
|
after(async () => {
|
|
if (dbAvailable && createdUserIds.length > 0) {
|
|
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
|
}
|
|
});
|
|
|
|
it('covers auth, profile, assessments, sessions 1-11, post-test, isolation, and 401', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
|
|
const ownerIp = `e2e-owner-${Date.now()}`;
|
|
const otherIp = `e2e-other-${Date.now()}`;
|
|
const owner = await registerUser('owner', ownerIp);
|
|
|
|
const me = await requestJson(app, '/auth/me', { headers: headers(owner.token, ownerIp) });
|
|
assert.equal(me.status, 200);
|
|
assert.equal((me.body.data as { id: string }).id, owner.externalId);
|
|
|
|
const profile = await requestJson(app, '/users/me/profile', {
|
|
method: 'PATCH',
|
|
headers: headers(owner.token, ownerIp),
|
|
body: JSON.stringify({ name: 'آزمون', age: 28 }),
|
|
});
|
|
assert.equal(profile.status, 200, JSON.stringify(profile.body));
|
|
assert.equal((profile.body.data as { profile: { name: string; age: number } }).profile.name, 'آزمون');
|
|
assert.equal((profile.body.data as { profile: { name: string; age: number } }).profile.age, 28);
|
|
|
|
const profileAgain = await requestJson(app, '/users/me', {
|
|
headers: headers(owner.token, ownerIp),
|
|
});
|
|
assert.equal(profileAgain.status, 200);
|
|
assert.equal((profileAgain.body.data as { profileComplete: boolean }).profileComplete, true);
|
|
|
|
const badLogin = await requestJson(app, '/auth/login', {
|
|
method: 'POST',
|
|
headers: headers(undefined, ownerIp),
|
|
body: JSON.stringify({ email: owner.email, password: 'wrong-password' }),
|
|
});
|
|
assert.equal(badLogin.status, 401);
|
|
|
|
const login = await requestJson(app, '/auth/login', {
|
|
method: 'POST',
|
|
headers: headers(undefined, ownerIp),
|
|
body: JSON.stringify({ email: owner.email, password: 'password12' }),
|
|
});
|
|
assert.equal(login.status, 200);
|
|
const token = login.body.data?.sessionToken as string;
|
|
assert.ok(token);
|
|
|
|
const beforePre = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
|
|
assert.equal(beforePre.status, 200);
|
|
assert.equal((beforePre.body.data?.permissions as { canTakePreTest: boolean }).canTakePreTest, true);
|
|
assert.equal((beforePre.body.data?.permissions as { canOpenTraining: boolean }).canOpenTraining, false);
|
|
|
|
const pre = await requestJson(app, '/assessments/attempts', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
|
|
});
|
|
assert.equal(pre.status, 201);
|
|
assert.equal(typeof (pre.body.data?.scores as { total: number }).total, 'number');
|
|
|
|
const duplicatePre = await requestJson(app, '/assessments/attempts', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(5) }),
|
|
});
|
|
assert.equal(duplicatePre.status, 409);
|
|
|
|
const afterPre = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
|
|
assert.equal((afterPre.body.data?.permissions as { canOpenTraining: boolean }).canOpenTraining, true);
|
|
assert.equal((afterPre.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest, false);
|
|
assert.ok(afterPre.body.data?.preTestCompletedAt);
|
|
|
|
const sessions = await requestJson(app, '/sessions', { headers: headers(token, ownerIp) });
|
|
const list = sessions.body.data as unknown as { order: number; unlocked: boolean; completed: boolean }[];
|
|
assert.equal(list[0]?.unlocked, true);
|
|
assert.equal(list[1]?.unlocked, false);
|
|
assert.equal(list[4]?.unlocked, false);
|
|
|
|
const lockedFive = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify({ sessionOrder: 5 }),
|
|
});
|
|
assert.equal(lockedFive.status, 403);
|
|
|
|
const tooSoonComplete = await requestJson(app, '/sessions/1/complete', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify(exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(1)!))),
|
|
});
|
|
assert.equal(tooSoonComplete.status, 422);
|
|
|
|
const first = await completeSession(token, ownerIp, 1, owner.user.id);
|
|
const liveMessage = await requestJson(app, `/chat/sessions/${first.chatId}/messages`, {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify({ content: 'یک پیام زنده برای پایداری گفتگو' }),
|
|
});
|
|
assert.ok(liveMessage.status === 201 || liveMessage.status === 503, `live chat ${liveMessage.status}`);
|
|
const afterOne = await requestJson(app, '/sessions', { headers: headers(token, ownerIp) });
|
|
const afterOneList = afterOne.body.data as unknown as { order: number; unlocked: boolean; completed: boolean }[];
|
|
assert.equal(afterOneList[0]?.completed, true);
|
|
assert.equal(afterOneList[1]?.unlocked, true);
|
|
assert.equal(afterOneList[4]?.unlocked, false);
|
|
|
|
const history = await requestJson(app, `/chat/sessions/${first.chatId}/messages`, {
|
|
headers: headers(token, ownerIp),
|
|
});
|
|
assert.equal(history.status, 200);
|
|
const messages = (history.body.data?.messages as { role: string; content: string }[]) ?? [];
|
|
assert.ok(messages.filter((m) => m.role === 'user').length >= 2);
|
|
assert.equal(messages.some((m) => m.role === 'system'), false);
|
|
|
|
await completeSession(token, ownerIp, 2, owner.user.id);
|
|
for (let order = 3; order <= PROGRAM_SESSION_COUNT - 2; order += 1) {
|
|
await completeSession(token, ownerIp, order, owner.user.id);
|
|
}
|
|
await completeSession(token, ownerIp, PROGRAM_SESSION_COUNT - 1, owner.user.id);
|
|
|
|
const beforeFinal = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
|
|
assert.equal(
|
|
(beforeFinal.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest,
|
|
true,
|
|
'post-test should unlock once the learner reaches session 11',
|
|
);
|
|
|
|
await completeSession(token, ownerIp, PROGRAM_SESSION_COUNT, owner.user.id);
|
|
|
|
const afterTraining = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
|
|
assert.ok(afterTraining.body.data?.trainingCompletedAt);
|
|
assert.equal((afterTraining.body.data as { currentStep: string }).currentStep, 'training_completed');
|
|
assert.equal(
|
|
(afterTraining.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest,
|
|
true,
|
|
);
|
|
|
|
const postTooLateLocked = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify({ sessionOrder: 1 }),
|
|
});
|
|
assert.ok(postTooLateLocked.status === 201 || postTooLateLocked.status === 403);
|
|
|
|
const post = await requestJson(app, '/assessments/attempts', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify({ phase: 'post', answers: pcqAnswers(5) }),
|
|
});
|
|
assert.equal(post.status, 201, JSON.stringify(post.body));
|
|
|
|
const duplicatePost = await requestJson(app, '/assessments/attempts', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
body: JSON.stringify({ phase: 'post', answers: pcqAnswers(6) }),
|
|
});
|
|
assert.equal(duplicatePost.status, 409);
|
|
|
|
const finished = await requestJson(app, '/journey', { headers: headers(token, ownerIp) });
|
|
assert.equal((finished.body.data as { currentStep: string }).currentStep, 'completed');
|
|
assert.equal((finished.body.data?.permissions as { isCompleted: boolean }).isCompleted, true);
|
|
assert.ok(finished.body.data?.postTestCompletedAt);
|
|
|
|
const other = await registerUser('other', otherIp);
|
|
const otherJourney = await requestJson(app, '/journey', {
|
|
headers: headers(other.token, otherIp),
|
|
});
|
|
assert.equal(otherJourney.status, 200);
|
|
assert.equal((otherJourney.body.data?.permissions as { canTakePreTest: boolean }).canTakePreTest, true);
|
|
assert.equal(otherJourney.body.data?.preTestCompletedAt, null);
|
|
|
|
const otherChat = await requestJson(app, `/chat/sessions/${first.chatId}/messages`, {
|
|
headers: headers(other.token, otherIp),
|
|
});
|
|
assert.equal(otherChat.status, 404);
|
|
|
|
const otherProfile = await requestJson(app, '/users/me', {
|
|
headers: headers(other.token, otherIp),
|
|
});
|
|
assert.equal((otherProfile.body.data as { id: string }).id, other.externalId);
|
|
assert.notEqual((otherProfile.body.data as { id: string }).id, owner.externalId);
|
|
|
|
const loggedOut = await requestJson(app, '/auth/logout', {
|
|
method: 'POST',
|
|
headers: headers(token, ownerIp),
|
|
});
|
|
assert.equal(loggedOut.status, 200);
|
|
|
|
const expired = await requestJson(app, '/auth/me', { headers: headers(token, ownerIp) });
|
|
assert.equal(expired.status, 401);
|
|
|
|
const ownerRow = await prisma.user.findUnique({
|
|
where: { id: owner.user.id },
|
|
include: {
|
|
profile: true,
|
|
journey: true,
|
|
assessmentAttempts: true,
|
|
sessionProgress: true,
|
|
chatSessions: { include: { messages: true } },
|
|
},
|
|
});
|
|
assert.ok(ownerRow?.profile);
|
|
assert.ok(ownerRow.journey?.preTestCompletedAt);
|
|
assert.ok(ownerRow.journey?.trainingCompletedAt);
|
|
assert.ok(ownerRow.journey?.postTestCompletedAt);
|
|
assert.equal(ownerRow.assessmentAttempts.length, 2);
|
|
assert.equal(
|
|
ownerRow.sessionProgress.filter((row) => row.status === 'completed').length,
|
|
PROGRAM_SESSION_COUNT,
|
|
);
|
|
assert.ok(ownerRow.chatSessions.length >= 1);
|
|
assert.ok(ownerRow.chatSessions.some((session) => session.messages.length >= 2));
|
|
});
|
|
});
|