feat: полный бэк и фронт (попытки, разбор, импорт, ИИ, назначения)
- Сервисы: testAttemptService, testAccess, document import/gen/extract, LLM, assignment, aiEditor - Конфиг: devAuthor, featureFlags; messages/ru; интеграция V.9 (skip без БД) - API/роуты: app, auth, server; Dockerfile и env example - Фронт: TestAttempt, TestAttemptReview, AttemptReviewBlock, стили, правки App/api/login/vite - compose и README; смоук-тесты расширены Закрывает отсутствие модулей в origin после клона. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Card1 V.9: интеграция с реальной `clinic_tests` — старая попытка остаётся
|
||||
* на снимке версии и старых `question_id` после форка (новая версия).
|
||||
*
|
||||
* Запуск: `CLINIC_TESTS_INTEGRATION=1` и применённые миграции (`npm run migrate`),
|
||||
* `DATABASE_URL` (или DB_*) к той же базе. Без флага тесты помечаются skip.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import pg from 'pg';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getPoolConfig } from '../db/poolConfig.js';
|
||||
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
/** `CLINIC_TESTS_INTEGRATION=1` и успешный `SELECT 1` (без БД — skip, не fail). */
|
||||
let runDb = false;
|
||||
if (process.env.CLINIC_TESTS_INTEGRATION === '1') {
|
||||
const probe = new Pool({
|
||||
...getPoolConfig(),
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
try {
|
||||
await probe.query('SELECT 1');
|
||||
runDb = true;
|
||||
} catch {
|
||||
runDb = false;
|
||||
} finally {
|
||||
await probe.end();
|
||||
}
|
||||
}
|
||||
|
||||
const qPayload = (label) => ({
|
||||
title: 'V9 ' + label,
|
||||
questions: [
|
||||
{
|
||||
text: `Q ${label}`,
|
||||
question_order: 1,
|
||||
hasMultipleAnswers: false,
|
||||
options: [
|
||||
{ text: 'yes', isCorrect: true, option_order: 1 },
|
||||
{ text: 'no', isCorrect: false, option_order: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} testId
|
||||
* @param {string} [exceptUserId]
|
||||
*/
|
||||
async function purgeTestChain(pool, testId, exceptUserId) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_answers WHERE attempt_id IN (
|
||||
SELECT id FROM test_attempts WHERE test_version_id IN (
|
||||
SELECT id FROM test_versions WHERE test_id = $1
|
||||
)
|
||||
)`,
|
||||
[testId]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM test_attempts WHERE test_version_id IN (
|
||||
SELECT id FROM test_versions WHERE test_id = $1
|
||||
)`,
|
||||
[testId]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM answer_options WHERE question_id IN (
|
||||
SELECT id FROM questions WHERE test_version_id IN (
|
||||
SELECT id FROM test_versions WHERE test_id = $1
|
||||
)
|
||||
)`,
|
||||
[testId]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM questions WHERE test_version_id IN (
|
||||
SELECT id FROM test_versions WHERE test_id = $1
|
||||
)`,
|
||||
[testId]
|
||||
);
|
||||
await pool.query(`DELETE FROM test_versions WHERE test_id = $1`, [testId]);
|
||||
await pool.query(`DELETE FROM tests WHERE id = $1`, [testId]);
|
||||
if (exceptUserId) {
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [exceptUserId]);
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'V.9: без попыток два saveTestDraft — одна строка test_versions (редактирование на месте)',
|
||||
{ skip: !runDb },
|
||||
async () => {
|
||||
const pool = new Pool(getPoolConfig());
|
||||
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
let userId;
|
||||
let testId;
|
||||
try {
|
||||
const { rows: u } = await pool.query(
|
||||
`INSERT INTO users (login, password_hash, full_name, role, is_active)
|
||||
VALUES ($1, $2, 'V9 in-place', 'hr', true) RETURNING id`,
|
||||
[`v9p-${suffix}`, bcrypt.hashSync('x', 4)]
|
||||
);
|
||||
userId = u[0].id;
|
||||
const c = await createTestWithVersion(pool, userId, { title: 'V9P' });
|
||||
testId = c.testId;
|
||||
const { rows: v0 } = await pool.query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
|
||||
[testId]
|
||||
);
|
||||
const vid0 = v0[0].id;
|
||||
await saveTestDraft(pool, userId, testId, qPayload('A'));
|
||||
const { rows: c1 } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`,
|
||||
[testId]
|
||||
);
|
||||
assert.equal(c1[0].n, 1, 'должна остаться одна версия');
|
||||
const { rows: v1 } = await pool.query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
|
||||
[testId]
|
||||
);
|
||||
assert.equal(
|
||||
v1[0].id,
|
||||
vid0,
|
||||
'id активной версии не меняется при нуле попыток'
|
||||
);
|
||||
await saveTestDraft(pool, userId, testId, qPayload('B'));
|
||||
const { rows: c2 } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`,
|
||||
[testId]
|
||||
);
|
||||
assert.equal(c2[0].n, 1);
|
||||
const { rows: v2 } = await pool.query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
|
||||
[testId]
|
||||
);
|
||||
assert.equal(v2[0].id, vid0);
|
||||
} finally {
|
||||
if (userId && testId) {
|
||||
await purgeTestChain(pool, testId, userId);
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
'V.9: после попытки форк — попытка и user_answers остаются на старых version_id / question_id',
|
||||
{ skip: !runDb },
|
||||
async () => {
|
||||
const pool = new Pool(getPoolConfig());
|
||||
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
let userId;
|
||||
let testId;
|
||||
let v1Id;
|
||||
let q1Id;
|
||||
let opt1Id;
|
||||
let attemptId;
|
||||
try {
|
||||
const { rows: u } = await pool.query(
|
||||
`INSERT INTO users (login, password_hash, full_name, role, is_active)
|
||||
VALUES ($1, $2, 'V9 fork', 'hr', true) RETURNING id`,
|
||||
[`v9f-${suffix}`, bcrypt.hashSync('x', 4)]
|
||||
);
|
||||
userId = u[0].id;
|
||||
const c = await createTestWithVersion(pool, userId, { title: 'V9F' });
|
||||
testId = c.testId;
|
||||
await saveTestDraft(pool, userId, testId, qPayload('pre'));
|
||||
|
||||
const { rows: tv0 } = await pool.query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
|
||||
[testId]
|
||||
);
|
||||
v1Id = tv0[0].id;
|
||||
const { rows: qu } = await pool.query(
|
||||
`SELECT id FROM questions WHERE test_version_id = $1 LIMIT 1`,
|
||||
[v1Id]
|
||||
);
|
||||
q1Id = qu[0].id;
|
||||
const { rows: op } = await pool.query(
|
||||
`SELECT id FROM answer_options WHERE question_id = $1 AND is_correct = true LIMIT 1`,
|
||||
[q1Id]
|
||||
);
|
||||
opt1Id = op[0].id;
|
||||
|
||||
const { rows: at } = await pool.query(
|
||||
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status, correct_count, total_questions, passed)
|
||||
VALUES ($1, $2, 1, 'completed', 1, 1, true) RETURNING id`,
|
||||
[v1Id, userId]
|
||||
);
|
||||
attemptId = at[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO user_answers (attempt_id, question_id, selected_options) VALUES ($1, $2, $3::uuid[])`,
|
||||
[attemptId, q1Id, [opt1Id]]
|
||||
);
|
||||
|
||||
const out = await saveTestDraft(pool, userId, testId, qPayload('post-fork'));
|
||||
assert.equal(out.forked, true, 'должна создаться новая версия после попытки');
|
||||
|
||||
const { rows: att } = await pool.query(
|
||||
`SELECT test_version_id FROM test_attempts WHERE id = $1`,
|
||||
[attemptId]
|
||||
);
|
||||
assert.equal(
|
||||
att[0].test_version_id,
|
||||
v1Id,
|
||||
'попытка остаётся на версии, с которой проходили'
|
||||
);
|
||||
const { rows: ua } = await pool.query(
|
||||
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`,
|
||||
[attemptId]
|
||||
);
|
||||
assert.equal(ua[0].question_id, q1Id);
|
||||
assert.equal(ua[0].selected_options[0], opt1Id);
|
||||
|
||||
const { rows: qExists } = await pool.query(
|
||||
`SELECT 1 FROM questions WHERE id = $1 AND test_version_id = $2`,
|
||||
[q1Id, v1Id]
|
||||
);
|
||||
assert.equal(qExists.length, 1, 'старый вопрос остаётся в старой версии');
|
||||
|
||||
const { rows: active } = await pool.query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
|
||||
[testId]
|
||||
);
|
||||
assert.notEqual(active[0].id, v1Id, 'новая версия — активна');
|
||||
} finally {
|
||||
if (userId && testId) {
|
||||
await purgeTestChain(pool, testId, userId);
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user