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:
@@ -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 доступ сохранён');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user