feat(flashcards): общие колоды — учитель назначает колоду классу/ученику

Учитель делится своей колодой с классом или конкретными учениками; карты общие
(одна копия), а прогресс у каждого свой — flashcard_reviews уже keyed по
user_id+card_id, поэтому ученик копит собственные интервалы на тех же картах.

- миграция 075: flashcard_deck_access (deck_id, type class|user, target_id) —
  зеркало folder_access; индексы по target и deck.
- deckAccess(): владелец/админ (canEdit) либо назначенный напрямую/через класс
  (canRead). listDecks отдаёт свои + назначенные (shared/can_edit/owner_name);
  getCards/getStudySession/submitReview пускают по canRead (ученик учится и
  ставит отзыв), правка карт/колоды — только владелец.
- share API (owner + роль teacher/admin): GET /shares, POST /share, DELETE
  /share?type=&target_id=; цель валидируется (свой класс / свой ученик).
- фронт: общие колоды с бейджем учителя, открываются read-only (CSS .readonly
  прячет ручки/удаление/правку, drag и inline-edit выключены), кнопка
  «Поделиться» с модалкой (вкладки Классы/Ученики, тоггл = add/remove share).
- тест flashcards-share 13/13 (шаринг класс/ученик, видимость, изучение+отзыв,
  правка 404, доступ 404, роль-гейт 403, чужой класс 403, снятие доступа).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:30:53 +03:00
parent f26b522207
commit 9bd40c5d1c
5 changed files with 484 additions and 44 deletions
+140
View File
@@ -0,0 +1,140 @@
'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 доступ сохранён');
});
});