feat(fork): subtree fork service + tests

This commit is contained in:
2026-05-21 00:19:46 +02:00
parent f378c0fdb0
commit 4339728326
2 changed files with 143 additions and 0 deletions

View 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);
});
});

View 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);
}