feat(fork): subtree fork service + tests
This commit is contained in:
59
packages/backend/src/services/fork.test.ts
Normal file
59
packages/backend/src/services/fork.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
|
||||
import { createCard, listCardsByLesson } from './cards.js';
|
||||
import { lessons } from '../db/schema.js';
|
||||
import { forkLesson } from './fork.js';
|
||||
|
||||
let env: ReturnType<typeof makeTestDb>;
|
||||
beforeEach(() => { env = makeTestDb(); });
|
||||
|
||||
describe('fork', () => {
|
||||
it('forks a single shared lesson with cards', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
|
||||
await createCard(env.db, o.id, l.id, { question: 'q1', answer: 'a1' });
|
||||
await createCard(env.db, o.id, l.id, { question: 'q2', answer: 'a2' });
|
||||
|
||||
const fork = await forkLesson(env.db, u.id, l.id);
|
||||
expect(fork.ownerId).toBe(u.id);
|
||||
expect(fork.visibility).toBe('private');
|
||||
expect(fork.sourceLessonId).toBe(l.id);
|
||||
expect(fork.parentId).toBeNull();
|
||||
const forkCards = await listCardsByLesson(env.db, u.id, fork.id);
|
||||
expect(forkCards).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forks the whole subtree and rewrites parent_id', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
const root = await createLessonOwnedBy(env.db, o.id, { name: 'R', visibility: 'shared' });
|
||||
const child = await createLessonOwnedBy(env.db, o.id, { name: 'C', parentId: root.id });
|
||||
await createCard(env.db, o.id, child.id, { question: 'qc', answer: 'ac' });
|
||||
|
||||
const fork = await forkLesson(env.db, u.id, root.id);
|
||||
const allUser = env.db.select().from(lessons).where(eq(lessons.ownerId, u.id)).all();
|
||||
expect(allUser).toHaveLength(2);
|
||||
const forkChild = allUser.find((x) => x.id !== fork.id)!;
|
||||
expect(forkChild.parentId).toBe(fork.id);
|
||||
expect(forkChild.name).toBe('C');
|
||||
const childCards = await listCardsByLesson(env.db, u.id, forkChild.id);
|
||||
expect(childCards).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects forking a private lesson owned by someone else', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'private' });
|
||||
await expect(forkLesson(env.db, u.id, l.id)).rejects.toThrow(/forbid|private|cannot/i);
|
||||
});
|
||||
|
||||
it('allows forking own lesson', async () => {
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
const l = await createLessonOwnedBy(env.db, u.id, { name: 'L', visibility: 'private' });
|
||||
const fork = await forkLesson(env.db, u.id, l.id);
|
||||
expect(fork.ownerId).toBe(u.id);
|
||||
expect(fork.sourceLessonId).toBe(l.id);
|
||||
});
|
||||
});
|
||||
84
packages/backend/src/services/fork.ts
Normal file
84
packages/backend/src/services/fork.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.js';
|
||||
import { cards, lessons } from '../db/schema.js';
|
||||
import { getDescendantLessonIds } from './lessons.js';
|
||||
import { ApiError } from '../lib/errors.js';
|
||||
import type { Lesson } from '@flashcard/shared';
|
||||
|
||||
function rowToLesson(r: typeof lessons.$inferSelect): Lesson {
|
||||
return {
|
||||
id: r.id,
|
||||
parentId: r.parentId ?? null,
|
||||
name: r.name,
|
||||
description: r.description ?? null,
|
||||
position: r.position,
|
||||
bidirectional: r.bidirectional,
|
||||
ownerId: r.ownerId ?? null,
|
||||
visibility: r.visibility,
|
||||
isCurated: r.isCurated,
|
||||
sourceLessonId: r.sourceLessonId ?? null,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function forkLesson(db: Db, userId: number, lessonId: number): Promise<Lesson> {
|
||||
const source = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
|
||||
if (!source) throw ApiError.notFound('Lesson');
|
||||
const isOwner = source.ownerId === userId;
|
||||
if (!isOwner && source.visibility !== 'shared') {
|
||||
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot fork a private lesson you do not own');
|
||||
}
|
||||
|
||||
const descendantIds = await getDescendantLessonIds(db, lessonId);
|
||||
const sourceLessons = db.select().from(lessons).where(inArray(lessons.id, descendantIds)).all();
|
||||
const sourceCards = db.select().from(cards).where(inArray(cards.lessonId, descendantIds)).all();
|
||||
|
||||
// Sort by depth (parents before children).
|
||||
const idSet = new Set(descendantIds);
|
||||
function depth(l: typeof sourceLessons[number]): number {
|
||||
let d = 0;
|
||||
let cur: number | null = l.parentId ?? null;
|
||||
while (cur !== null && idSet.has(cur)) {
|
||||
d += 1;
|
||||
const next = sourceLessons.find((x) => x.id === cur);
|
||||
cur = next?.parentId ?? null;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
const ordered = [...sourceLessons].sort((a, b) => depth(a) - depth(b));
|
||||
|
||||
const idMap = new Map<number, number>();
|
||||
let newRoot: typeof sourceLessons[number] | null = null;
|
||||
for (const L of ordered) {
|
||||
const newParent = L.parentId !== null && idMap.has(L.parentId) ? idMap.get(L.parentId)! : null;
|
||||
const [inserted] = db.insert(lessons).values({
|
||||
name: L.name,
|
||||
description: L.description ?? null,
|
||||
position: L.position,
|
||||
bidirectional: L.bidirectional,
|
||||
parentId: newParent,
|
||||
ownerId: userId,
|
||||
visibility: 'private',
|
||||
isCurated: false,
|
||||
sourceLessonId: L.id,
|
||||
}).returning().all();
|
||||
idMap.set(L.id, inserted!.id);
|
||||
if (L.id === source.id) newRoot = inserted!;
|
||||
}
|
||||
|
||||
for (const C of sourceCards) {
|
||||
const newLessonId = idMap.get(C.lessonId);
|
||||
if (!newLessonId) continue;
|
||||
db.insert(cards).values({
|
||||
lessonId: newLessonId,
|
||||
question: C.question,
|
||||
answer: C.answer,
|
||||
hint: C.hint ?? null,
|
||||
position: C.position,
|
||||
}).run();
|
||||
}
|
||||
|
||||
if (!newRoot) throw new ApiError(500, 'INTERNAL', 'Fork failed: root not produced');
|
||||
return rowToLesson(newRoot);
|
||||
}
|
||||
Reference in New Issue
Block a user