Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
228 lines
8.6 KiB
TypeScript
228 lines
8.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { prisma } from '../src/lib/prisma.js';
|
|
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 {
|
|
assertNoSecrets,
|
|
authHeader,
|
|
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),
|
|
}));
|
|
}
|
|
|
|
async function registerUser(suffix: string) {
|
|
const email = `domain-${suffix}-${Date.now()}@example.com`;
|
|
const { status, body } = await requestJson(app, '/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, password: 'password12' }),
|
|
});
|
|
assert.equal(status, 201);
|
|
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 { token, user };
|
|
}
|
|
|
|
describe('learning domain API', () => {
|
|
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('GET /ai/config is public and does not leak secrets', async () => {
|
|
const { status, body, raw } = await requestJson(app, '/ai/config');
|
|
assert.equal(status, 200);
|
|
assert.equal(typeof body.data?.configured, 'boolean');
|
|
assert.equal(typeof body.data?.model, 'string');
|
|
assert.equal(typeof body.data?.provider, 'string');
|
|
assert.doesNotMatch(raw, /sk-/);
|
|
assert.doesNotMatch(raw, /OPENAI_API_KEY/);
|
|
assertNoSecrets(raw);
|
|
});
|
|
|
|
it('gates journey, assessments, sequential sessions, and chat persistence', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
|
|
const { token, user } = await registerUser('flow');
|
|
|
|
const beforePre = await requestJson(app, '/journey', { headers: authHeader(token) });
|
|
assert.equal(beforePre.status, 200);
|
|
const beforePermissions = beforePre.body.data?.permissions as {
|
|
canTakePreTest: boolean;
|
|
canOpenTraining: boolean;
|
|
canTakePostTest: boolean;
|
|
isCompleted: boolean;
|
|
};
|
|
assert.equal(beforePermissions.canTakePreTest, true);
|
|
assert.equal(beforePermissions.canOpenTraining, false);
|
|
assert.equal(beforePermissions.canTakePostTest, false);
|
|
|
|
const lockedChat = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({}),
|
|
});
|
|
assert.equal(lockedChat.status, 403);
|
|
|
|
const postTooSoon = await requestJson(app, '/assessments/attempts', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({ phase: 'post', answers: pcqAnswers(4) }),
|
|
});
|
|
assert.equal(postTooSoon.status, 403);
|
|
|
|
const pre = await requestJson(app, '/assessments/attempts', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
|
|
});
|
|
assert.equal(pre.status, 201);
|
|
assert.equal(pre.body.data?.scores && (pre.body.data.scores as { total: number }).total, 93);
|
|
const reverseAnswer = (
|
|
pre.body.data?.answers as { questionId: string; selectedValue: number; score: number }[]
|
|
).find((answer) => answer.questionId === 'pcq_13');
|
|
assert.equal(reverseAnswer?.selectedValue, 4);
|
|
assert.equal(reverseAnswer?.score, 3);
|
|
|
|
const retake = await requestJson(app, '/assessments/attempts', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(5) }),
|
|
});
|
|
assert.equal(retake.status, 409);
|
|
assert.equal(retake.body.error?.code, 'CONFLICT');
|
|
|
|
const afterPre = await requestJson(app, '/journey', { headers: authHeader(token) });
|
|
assert.equal((afterPre.body.data as { currentStep: string }).currentStep, 'training_in_progress');
|
|
const afterPermissions = afterPre.body.data?.permissions as {
|
|
canTakePreTest: boolean;
|
|
canOpenTraining: boolean;
|
|
canTakePostTest: boolean;
|
|
};
|
|
assert.equal(afterPermissions.canTakePreTest, false);
|
|
assert.equal(afterPermissions.canOpenTraining, true);
|
|
assert.equal(afterPermissions.canTakePostTest, false);
|
|
|
|
const sessions = await requestJson(app, '/sessions', { headers: authHeader(token) });
|
|
assert.equal(sessions.status, 200);
|
|
const list = sessions.body.data as unknown as { order: number; unlocked: boolean; completed: boolean }[];
|
|
assert.equal(list.length, PROGRAM_SESSION_COUNT);
|
|
assert.equal(list[0]?.unlocked, true);
|
|
assert.equal(list[1]?.unlocked, false);
|
|
|
|
const sessionTwoComplete = await requestJson(app, '/sessions/2/complete', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({
|
|
...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(2)!)),
|
|
}),
|
|
});
|
|
assert.equal(sessionTwoComplete.status, 403);
|
|
|
|
const chat = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({ sessionOrder: 1 }),
|
|
});
|
|
assert.equal(chat.status, 201);
|
|
const chatId = chat.body.data?.id as string;
|
|
assert.ok(chatId);
|
|
|
|
const firstMessage = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({ content: 'سلام، آماده هستم.' }),
|
|
});
|
|
assert.ok(firstMessage.status === 503 || firstMessage.status === 201);
|
|
|
|
const secondMessage = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({ content: 'ادامه بده.' }),
|
|
});
|
|
assert.ok(secondMessage.status === 503 || secondMessage.status === 201);
|
|
|
|
const stored = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(stored.status, 200);
|
|
const storedMessages = (stored.body.data?.messages as { role: string; content: string }[]) ?? [];
|
|
assert.ok(storedMessages.filter((message) => message.role === 'user').length >= 2);
|
|
|
|
const complete = await requestJson(app, '/sessions/1/complete', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({
|
|
...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(1)!)),
|
|
chatSessionId: chatId,
|
|
}),
|
|
});
|
|
assert.equal(complete.status, 200);
|
|
const completedSessions = complete.body.data?.completedSessions as number[];
|
|
assert.deepEqual(completedSessions, [1]);
|
|
|
|
const endedChat = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(endedChat.status, 200);
|
|
assert.ok((endedChat.body.data?.session as { endedAt: string | null }).endedAt);
|
|
|
|
const listed = await requestJson(app, '/chat/sessions?sessionOrder=1', {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(listed.status, 200);
|
|
const listedSessions = listed.body.data as { id: string; sessionOrder: number; endedAt: string | null }[];
|
|
assert.ok(listedSessions.some((session) => session.id === chatId && session.endedAt));
|
|
|
|
const recomplete = await requestJson(app, '/sessions/1/complete', {
|
|
method: 'POST',
|
|
headers: authHeader(token),
|
|
body: JSON.stringify({
|
|
...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(1)!)),
|
|
chatSessionId: chatId,
|
|
}),
|
|
});
|
|
assert.equal(recomplete.status, 200);
|
|
assert.deepEqual(recomplete.body.data?.completedSessions as number[], [1]);
|
|
|
|
const afterSession = await requestJson(app, '/sessions', { headers: authHeader(token) });
|
|
const afterList = afterSession.body.data as unknown as { order: number; unlocked: boolean }[];
|
|
assert.equal(afterList[1]?.unlocked, true);
|
|
|
|
const stillNoPost = await requestJson(app, '/journey', { headers: authHeader(token) });
|
|
assert.equal(
|
|
(stillNoPost.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest,
|
|
false,
|
|
);
|
|
});
|
|
});
|