0fe04d4d99
- Сервисы: 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
235 lines
7.8 KiB
JavaScript
235 lines
7.8 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
}
|
|
);
|