Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
231 lines
8.1 KiB
TypeScript
231 lines
8.1 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 { COACH_SESSION_START_CUE } from '../src/modules/ai/coachPrompt.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 = `chat-${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 };
|
|
}
|
|
|
|
async function completePreTest(token: string) {
|
|
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);
|
|
}
|
|
|
|
describe('chat persistence and AI runtime', () => {
|
|
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('rejects unauthenticated chat access', async () => {
|
|
const created = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
assert.equal(created.status, 401);
|
|
});
|
|
|
|
it('does not leak AI secrets through public config', 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?.provider, 'string');
|
|
assert.ok((body.data?.provider as string).length > 0);
|
|
assert.doesNotMatch(raw, /sk-/);
|
|
assert.doesNotMatch(raw, /OPENAI_API_KEY/);
|
|
assert.doesNotMatch(raw, /Bearer /);
|
|
assertNoSecrets(raw);
|
|
});
|
|
|
|
it('lists AI runs for the authenticated user only', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
|
|
const owner = await registerUser('runs-owner');
|
|
const other = await registerUser('runs-other');
|
|
await completePreTest(owner.token);
|
|
|
|
const created = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: authHeader(owner.token),
|
|
body: JSON.stringify({ sessionOrder: 1 }),
|
|
});
|
|
assert.equal(created.status, 201);
|
|
const chatId = created.body.data?.id as string;
|
|
|
|
await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
method: 'POST',
|
|
headers: authHeader(owner.token),
|
|
body: JSON.stringify({ content: 'سلام' }),
|
|
});
|
|
|
|
const unauthorized = await requestJson(app, '/ai/runs');
|
|
assert.equal(unauthorized.status, 401);
|
|
|
|
const runs = await requestJson(app, '/ai/runs?limit=10', {
|
|
headers: authHeader(owner.token),
|
|
});
|
|
assert.equal(runs.status, 200);
|
|
const ownerRuns = runs.body.data as {
|
|
id: string;
|
|
provider: string;
|
|
chatSessionId: string | null;
|
|
status: string;
|
|
}[];
|
|
assert.ok(Array.isArray(ownerRuns));
|
|
assert.ok(ownerRuns.length >= 1);
|
|
assert.ok(ownerRuns.every((run) => typeof run.provider === 'string' && run.provider.length > 0));
|
|
assert.ok(ownerRuns.some((run) => run.chatSessionId === chatId));
|
|
assert.doesNotMatch(JSON.stringify(runs.body), /sk-/);
|
|
assert.doesNotMatch(JSON.stringify(runs.body), /OPENAI_API_KEY/);
|
|
|
|
const otherRuns = await requestJson(app, '/ai/runs', {
|
|
headers: authHeader(other.token),
|
|
});
|
|
assert.equal(otherRuns.status, 200);
|
|
const otherList = otherRuns.body.data as { chatSessionId: string | null }[];
|
|
assert.equal(otherList.some((run) => run.chatSessionId === chatId), false);
|
|
});
|
|
|
|
it('persists chat ownership, order, and history without exposing hidden prompts', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
|
|
const owner = await registerUser('owner');
|
|
const other = await registerUser('other');
|
|
await completePreTest(owner.token);
|
|
|
|
const lockedFuture = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: authHeader(owner.token),
|
|
body: JSON.stringify({ sessionOrder: 7 }),
|
|
});
|
|
assert.equal(lockedFuture.status, 403);
|
|
|
|
const created = await requestJson(app, '/chat/sessions', {
|
|
method: 'POST',
|
|
headers: authHeader(owner.token),
|
|
body: JSON.stringify({ sessionOrder: 1 }),
|
|
});
|
|
assert.equal(created.status, 201);
|
|
const chatId = created.body.data?.id as string;
|
|
assert.ok(chatId);
|
|
assert.equal(created.body.data?.sessionOrder, 1);
|
|
assert.equal(created.body.data?.endedAt, null);
|
|
|
|
const listed = await requestJson(app, '/chat/sessions', {
|
|
headers: authHeader(owner.token),
|
|
});
|
|
assert.equal(listed.status, 200);
|
|
const listedSessions = listed.body.data as { id: string; sessionOrder: number }[];
|
|
assert.ok(listedSessions.some((session) => session.id === chatId));
|
|
|
|
const start = await requestJson(app, `/chat/sessions/${chatId}/start`, {
|
|
method: 'POST',
|
|
headers: authHeader(owner.token),
|
|
});
|
|
assert.ok(start.status === 200 || start.status === 503);
|
|
if (start.status === 200) {
|
|
const startMessages =
|
|
(start.body.data?.messages as { role: string; content: string }[]) ?? [];
|
|
assert.equal(startMessages.some((message) => message.role === 'system'), false);
|
|
assert.equal(
|
|
startMessages.some((message) => message.content.includes(COACH_SESSION_START_CUE)),
|
|
false,
|
|
);
|
|
}
|
|
|
|
const first = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
method: 'POST',
|
|
headers: authHeader(owner.token),
|
|
body: JSON.stringify({ content: 'سلام، آماده هستم.' }),
|
|
});
|
|
assert.ok(first.status === 201 || first.status === 503);
|
|
|
|
const second = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
method: 'POST',
|
|
headers: authHeader(owner.token),
|
|
body: JSON.stringify({ content: 'لطفا ادامه بده.' }),
|
|
});
|
|
assert.ok(second.status === 201 || second.status === 503);
|
|
|
|
const history = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
headers: authHeader(owner.token),
|
|
});
|
|
assert.equal(history.status, 200);
|
|
const messages =
|
|
(history.body.data?.messages as { role: string; content: string; createdAt: string }[]) ?? [];
|
|
const userMessages = messages.filter((message) => message.role === 'user');
|
|
assert.ok(userMessages.length >= 2);
|
|
assert.equal(messages.some((message) => message.role === 'system'), false);
|
|
assert.equal(
|
|
messages.some((message) => message.content.includes(COACH_SESSION_START_CUE)),
|
|
false,
|
|
);
|
|
assert.equal(userMessages[0]?.content, 'سلام، آماده هستم.');
|
|
assert.equal(userMessages[1]?.content, 'لطفا ادامه بده.');
|
|
assert.ok(new Date(userMessages[0].createdAt).getTime() <= new Date(userMessages[1].createdAt).getTime());
|
|
|
|
const otherRead = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
headers: authHeader(other.token),
|
|
});
|
|
assert.equal(otherRead.status, 404);
|
|
|
|
const otherWrite = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
|
|
method: 'POST',
|
|
headers: authHeader(other.token),
|
|
body: JSON.stringify({ content: 'پیام غیرمجاز' }),
|
|
});
|
|
assert.equal(otherWrite.status, 404);
|
|
});
|
|
});
|