Files
pwa/backend/tests/e2e-qa.test.ts
T

318 lines
13 KiB
TypeScript
Raw Normal View History

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