'use strict'; /** * Integration tests: общие колоды флешкарт (учитель → класс/ученик). * Covers: шаринг классу/ученику, видимость у назначенного (own+published), * изучение и отзыв с личным прогрессом на общих картах, запрет правки чужой * колоды (404), запрет доступа неназначенному (404), ролевой гейт share (403), * запрет шарить в чужой класс (403), снятие доступа. */ const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { app, db, inject, getToken, cleanup } = require('./setup'); app.use('/api/flashcards', require('../src/routes/flashcards')); after(() => cleanup()); let _cc = 0; function mkClass(teacherId) { const code = 'T' + (++_cc) + Math.floor(performance.now() % 100000); const r = db.prepare(`INSERT INTO classes (name, teacher_id, invite_code) VALUES (?,?,?)`) .run('Класс ' + _cc, teacherId, code); return r.lastInsertRowid; } function enroll(classId, userId) { db.prepare(`INSERT OR IGNORE INTO class_members (class_id, user_id) VALUES (?,?)`).run(classId, userId); } describe('flashcards — общие колоды (sharing)', () => { let teacher, otherTeacher, stuIn, stuOut; let classId, deckId, cardId; before(async () => { teacher = await getToken('teacher'); otherTeacher = await getToken('teacher'); stuIn = await getToken('student'); stuOut = await getToken('student'); classId = mkClass(teacher.userId); enroll(classId, stuIn.userId); // учитель создаёт колоду с карточкой const d = await inject('POST', '/api/flashcards/decks', { title: 'Биология' }, teacher.token); deckId = d.body.id; const c = await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'Клетка?', back: 'Единица жизни' }, teacher.token); cardId = c.body.id; }); it('student cannot share (роль-гейт 403)', async () => { const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, stuIn.token); assert.equal(r.status, 403, `got ${r.status}`); }); it('teacher cannot share to a class he does not own (403)', async () => { const foreignClass = mkClass(otherTeacher.userId); const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: foreignClass }, teacher.token); assert.equal(r.status, 403, `got ${r.status}: ${JSON.stringify(r.body)}`); }); it('not-shared: ученик не в классе не видит колоду и не имеет доступа', async () => { const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token); assert.ok(!list.body.decks.some(d => d.id === deckId), 'пока не расшарена — не видна'); assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/cards`, null, stuIn.token)).status, 404); assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404); }); it('teacher shares deck to class', async () => { const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, teacher.token); assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`); }); it('назначенный ученик видит колоду как shared (read-only)', async () => { const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token); const d = list.body.decks.find(x => x.id === deckId); assert.ok(d, 'колода видна ученику'); assert.equal(d.shared, 1); assert.equal(d.can_edit, 0); assert.equal(d.owner_name, teacher.name); }); it('ученик НЕ в классе по-прежнему не видит', async () => { const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token); assert.ok(!list.body.decks.some(d => d.id === deckId)); assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuOut.token)).status, 404); }); it('ученик может изучать и ставить отзыв (свой прогресс)', async () => { const s = await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token); assert.equal(s.status, 200); assert.ok(s.body.cards.some(c => c.id === cardId), 'карта в сессии ученика'); const rev = await inject('POST', `/api/flashcards/cards/${cardId}/review`, { quality: 4 }, stuIn.token); assert.equal(rev.status, 200, `review: ${rev.status}`); // прогресс ученика — отдельная строка от учителя const row = db.prepare('SELECT COUNT(*) AS n FROM flashcard_reviews WHERE card_id=? AND user_id=?').get(cardId, stuIn.userId); assert.equal(row.n, 1, 'у ученика свой отзыв'); }); it('ученик НЕ может править общую колоду (404)', async () => { assert.equal((await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'x', back: 'y' }, stuIn.token)).status, 404); assert.equal((await inject('PUT', `/api/flashcards/decks/${deckId}`, { title: 'Взлом' }, stuIn.token)).status, 404); assert.equal((await inject('PUT', `/api/flashcards/cards/${cardId}`, { front: 'Взлом' }, stuIn.token)).status, 404); assert.equal((await inject('DELETE', `/api/flashcards/cards/${cardId}`, null, stuIn.token)).status, 404); }); it('ученик не может расшаривать чужую колоду (роль/доступ)', async () => { const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, stuIn.token); assert.equal(r.status, 403); }); it('teacher shares to a specific user (вне класса)', async () => { // делаем stuOut персональным учеником, чтобы прошла валидация цели db.prepare(`INSERT OR IGNORE INTO teacher_students (teacher_id, student_id) VALUES (?,?)`) .run(teacher.userId, stuOut.userId); const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, teacher.token); assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`); const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token); assert.ok(list.body.decks.some(d => d.id === deckId), 'stuOut теперь видит'); }); it('teacher lists shares (2: class + user)', async () => { const r = await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, teacher.token); assert.equal(r.status, 200); assert.equal(r.body.shares.length, 2); assert.ok(r.body.shares.some(s => s.type === 'class' && s.target_id === classId)); assert.ok(r.body.shares.some(s => s.type === 'user' && s.target_id === stuOut.userId)); }); it('ученик не может смотреть список share (403)', async () => { assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, stuIn.token)).status, 403); }); it('teacher unshares class → ученик в классе теряет доступ', async () => { const r = await inject('DELETE', `/api/flashcards/decks/${deckId}/share?type=class&target_id=${classId}`, null, teacher.token); assert.equal(r.status, 200); const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token); assert.ok(!list.body.decks.some(d => d.id === deckId), 'после снятия — не видит'); assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404); // прямой share для stuOut остаётся const listOut = await inject('GET', '/api/flashcards/decks', null, stuOut.token); assert.ok(listOut.body.decks.some(d => d.id === deckId), 'stuOut доступ сохранён'); }); });