diff --git a/packages/feature-studio/package.json b/packages/feature-studio/package.json index 0e7914e..fa1d8e4 100644 --- a/packages/feature-studio/package.json +++ b/packages/feature-studio/package.json @@ -3,5 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", - "license": "AGPL-3.0-only" + "license": "AGPL-3.0-only", + "exports": { + ".": "./src/index.ts", + "./web": "./src/web.ts" + } } diff --git a/packages/feature-studio/src/index.ts b/packages/feature-studio/src/index.ts new file mode 100644 index 0000000..1cf5e9b --- /dev/null +++ b/packages/feature-studio/src/index.ts @@ -0,0 +1,10 @@ +// Server entry of @stc/feature-studio. The browser imports "@stc/feature-studio/web" instead (no server code). +export { studioFeature, OUTSIDE_MESSAGE, type StudioFeature, type StudioFeatureOptions } from "./server/feature"; +export { createDraftWriter, type DraftWriter, type DraftWriterOptions } from "./server/draft-writer"; +export { createStudioStore, contentHash, type StudioStore, type Identity, type DraftStatus, type PublishResult, type SyncResult } from "./server/store"; +export { createPublishedWatcher, type PublishedWatcher } from "./server/watch"; +export { studioCourseDirs, studioWorktreesDir } from "./server/paths"; +export { studioWeb } from "./web"; + +/** The studio adds no tables: authors use Phase 0's `course_authors`. */ +export const migrationsDirs: string[] = []; diff --git a/packages/feature-studio/src/server/feature.ts b/packages/feature-studio/src/server/feature.ts new file mode 100644 index 0000000..0460772 --- /dev/null +++ b/packages/feature-studio/src/server/feature.ts @@ -0,0 +1,300 @@ +import { basename, resolve, sep } from "node:path"; +import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts"; +import { serveMedia } from "@stc/core"; +import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format"; +import { aclRepo } from "@stc/db"; +import { createDraftWriter, type DraftWriter } from "./draft-writer"; +import { studioWorktreesDir } from "./paths"; +import { checkRelPath, createStudioStore, MANIFEST, StudioError, type FileWrite, type StudioStore } from "./store"; +import { createPublishedWatcher, type PublishedWatcher } from "./watch"; + +export const OUTSIDE_MESSAGE = "This course is managed outside STCourses"; +const MAX_UPLOAD = 2 * 1024 * 1024 * 1024; // 2 GB; large videos belong in the media dir, not git, but we do not forbid them + +export interface StudioFeature extends Feature { + store: StudioStore; + watcher: PublishedWatcher; + draftWriter: DraftWriter; + stop(): void; +} + +export interface StudioFeatureOptions { + /** Poll the published worktrees for external commits (default true). */ + watch?: boolean; + watchIntervalMs?: number; +} + +const json = (body: unknown, status = 200) => Response.json(body, { status }); +const fail = (status: number, error: string) => json({ error }, status); + +async function body(req: Request): Promise { + try { + return (await req.json()) as T; + } catch { + throw new StudioError("request body must be JSON"); + } +} + +function manifestToYaml(m: unknown): string { + return Bun.YAML.stringify(m, null, 2) + "\n"; +} + +export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature { + const store = createStudioStore({ dataDir: ctx.config.dataDir }); + const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs }); + const acl = aclRepo(ctx.db); + const wtDir = resolve(studioWorktreesDir(ctx.config.dataDir)); + const draftWriter = createDraftWriter(ctx); + + const hasRole = (u: AuthUser) => u.roles.includes("owner") || u.roles.includes("author"); + const authoredCourses = async (u: AuthUser): Promise => + (await ctx.db.sql`SELECT course_id FROM course_authors WHERE user_id = ${u.id}`).map((r: { course_id: string }) => r.course_id); + const canEdit = async (u: AuthUser, courseId: string) => hasRole(u) || (await acl.isAuthor(courseId, u.id)); + const isStudioCourse = (c: LoadedCourse) => resolve(c.manifestPath).startsWith(wtDir + sep); + const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid` }); + + const afterPublishedChange = async (courseId: string) => { + await ctx.courses.reload(courseId); + watcher.note(courseId, await store.publishedHead(courseId)); + }; + + /** Wraps a handler: signed-in author/owner (or per-course author), error mapping. */ + const guard = (h: (req: Request, rc: RequestContext, user: AuthUser) => Promise): RouteHandler => async (req, rc) => { + const user = rc.user; + if (!user) return fail(401, "sign in to use the studio"); + try { + if (!hasRole(user) && (await authoredCourses(user)).length === 0) return fail(403, "the studio is for authors"); + return await h(req, rc, user); + } catch (e) { + if (e instanceof StudioError) return fail(e.status, e.message); + const msg = e instanceof Error ? e.message : String(e); + if (/^invalid path/.test(msg)) return fail(400, msg); + console.error("[studio]", e); + return fail(500, msg); + } + }; + + /** For per-course routes: the course must be studio-managed and editable by the user. */ + const course = (h: (req: Request, rc: RequestContext, user: AuthUser, courseId: string) => Promise) => + guard(async (req, rc, user) => { + const courseId = rc.params.courseId ?? ""; + if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course"); + if (!store.isManaged(courseId)) { + return ctx.courses.get(courseId) ? fail(403, OUTSIDE_MESSAGE) : fail(404, "no such course"); + } + return h(req, rc, user, courseId); + }); + + async function courseState(courseId: string, user: AuthUser) { + const status = await store.status(courseId, user.id); + const m = await store.readFile(courseId, user.id, MANIFEST); + let manifestRaw: unknown = null; + let manifestErrors: string[] = []; + if (m) { + try { manifestRaw = Bun.YAML.parse(m.content); } catch { /* reported by parseManifest */ } + const parsed = parseManifest(m.content); + manifestErrors = parsed.ok ? [] : parsed.errors; + } else { + manifestErrors = [`${MANIFEST} is missing`]; + } + return { + readOnly: false, + course: { id: courseId, title: (manifestRaw as { title?: string } | null)?.title ?? courseId, managed: true }, + status, + manifestText: m?.content ?? "", + manifestHash: m?.hash ?? null, + manifestRaw, + manifestErrors, + diagnostics: status.merging ? [] : await store.validateDraft(courseId, user.id), + files: await store.listFiles(courseId, user.id), + }; + } + + const routes: Feature["routes"] = { + "/api/studio/courses": { + GET: guard(async (_req, _rc, user) => { + const mine = new Set(await authoredCourses(user)); + const all = hasRole(user); + const managed = await store.listManaged(); + const courses: unknown[] = []; + for (const id of managed) { + if (!all && !mine.has(id)) continue; + const loaded = ctx.courses.get(id); + const st = await store.status(id, user.id).catch(() => null); + courses.push({ + id, managed: true, + title: loaded?.manifest?.title ?? id, + pitch: loaded?.manifest?.pitch ?? null, + accent: loaded?.manifest?.accent ?? null, + publishedStatus: loaded?.status ?? "error", + ahead: st?.ahead ?? 0, behind: st?.behind ?? 0, merging: st?.merging ?? false, remote: st?.remote ?? null, + }); + } + if (all) { + for (const c of ctx.courses.list()) { + if (isStudioCourse(c) || managed.includes(c.id)) continue; + courses.push({ id: c.id, managed: false, title: c.manifest?.title ?? c.id, pitch: c.manifest?.pitch ?? null, accent: c.manifest?.accent ?? null, publishedStatus: c.status, message: OUTSIDE_MESSAGE }); + } + } + return json({ courses, canCreate: all }); + }), + POST: guard(async (req, _rc, user) => { + if (!hasRole(user)) return fail(403, "only authors can create courses"); + const b = await body<{ id?: unknown; title?: unknown; pitch?: unknown }>(req); + if (typeof b.id !== "string" || typeof b.title !== "string" || !b.title.trim()) return fail(400, "id and title are required"); + if (ctx.courses.get(b.id) || store.isManaged(b.id)) return fail(409, `course "${b.id}" already exists`); + await store.createCourse({ id: b.id, title: b.title, pitch: typeof b.pitch === "string" ? b.pitch : undefined }, who(user)); + await ctx.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${b.id}, ${user.id})`; + await afterPublishedChange(b.id); + return json({ id: b.id }, 201); + }), + }, + + "/api/studio/courses/:courseId": { + GET: guard(async (_req, rc, user) => { + const courseId = rc.params.courseId ?? ""; + if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course"); + if (!store.isManaged(courseId)) { + const c = ctx.courses.get(courseId); + if (!c) return fail(404, "no such course"); + return json({ readOnly: true, message: OUTSIDE_MESSAGE, course: { id: c.id, title: c.manifest?.title ?? c.id, managed: false, root: c.root }, diagnostics: c.diagnostics }); + } + return json(await courseState(courseId, user)); + }), + }, + + "/api/studio/courses/:courseId/file": { + GET: course(async (_req, rc, user, courseId) => { + const path = rc.url.searchParams.get("path") ?? ""; + const f = await store.readFile(courseId, user.id, path); + return f ? json({ path, ...f }) : fail(404, "no such file"); + }), + }, + + "/api/studio/courses/:courseId/files": { + PUT: course(async (req, _rc, user, courseId) => { + const b = await body<{ message?: string; files?: FileWrite[]; manifest?: unknown; manifestBaseHash?: string | null }>(req); + const files: FileWrite[] = Array.isArray(b.files) ? b.files.map((f) => ({ path: String(f.path), content: f.delete ? undefined : String(f.content ?? ""), baseHash: f.baseHash, delete: !!f.delete })) : []; + if (b.manifest !== undefined) { + files.unshift({ path: MANIFEST, content: manifestToYaml(b.manifest), baseHash: b.manifestBaseHash }); + } + const mf = files.find((f) => checkRelPath(f.path) === MANIFEST); + if (mf && typeof mf.content === "string") { + const parsed = parseManifest(mf.content); + if (parsed.ok && parsed.manifest.source && parsed.manifest.source.kind !== "authored" && parsed.manifest.visibility !== "restricted" && !user.roles.includes("owner")) { + return fail(403, "only the owner can make an imported course public or signed-in visible"); + } + } + const r = await store.saveFiles(courseId, who(user), files, b.message ?? ""); + if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409); + return json({ ok: true, commit: r.commit }); + }), + }, + + "/api/studio/courses/:courseId/validate": { + POST: course(async (req) => { + const b = await body<{ manifest?: unknown; manifestText?: string }>(req); + if (typeof b.manifestText === "string") { + const r = parseManifest(b.manifestText); + return json({ errors: r.ok ? [] : r.errors }); + } + const r = ManifestSchema.safeParse(b.manifest); + return json({ errors: r.success ? [] : r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) }); + }), + }, + + "/api/studio/courses/:courseId/upload": { + POST: course(async (req, _rc, user, courseId) => { + let form: FormData; + try { form = await req.formData(); } catch { return fail(400, "expected multipart form data"); } + const file = form.get("file"); + if (!(file instanceof Blob)) return fail(400, "file is required"); + if (file.size > MAX_UPLOAD) return fail(413, "file too large"); + const explicit = form.get("path"); + const dir = String(form.get("dir") ?? "").replace(/^\/+|\/+$/g, ""); + const name = basename((file as File).name || "upload.bin"); + const path = checkRelPath(typeof explicit === "string" && explicit ? explicit : dir ? `${dir}/${name}` : name); + const message = String(form.get("message") ?? "") || `Upload ${path}`; + const r = await store.saveFiles(courseId, who(user), [{ path, content: new Uint8Array(await file.arrayBuffer()) }], message); + if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409); + return json({ ok: true, path, commit: r.commit }); + }), + }, + + "/api/studio/courses/:courseId/raw/*": { + GET: course(async (req, rc, user, courseId) => { + const m = /^\/api\/studio\/courses\/[^/]+\/raw\/(.*)$/s.exec(rc.url.pathname); + let rel: string; + try { rel = decodeURIComponent(m?.[1] ?? ""); } catch { return fail(404, "not found"); } + const dir = await store.draftDir(courseId, user.id); + try { checkRelPath(rel); } catch { return fail(404, "not found"); } + const abs = resolveInside(dir, rel); + return abs ? serveMedia(req, abs, { download: rc.url.searchParams.has("download") }) : fail(404, "not found"); + }), + }, + + "/api/studio/courses/:courseId/publish": { + POST: course(async (_req, _rc, user, courseId) => { + const r = await store.publish(courseId, who(user)); + if (r.ok) { + await afterPublishedChange(courseId); + return json(r); + } + return json(r, r.reason === "invalid" ? 422 : 409); + }), + }, + + "/api/studio/courses/:courseId/update-draft": { + POST: course(async (req, _rc, user, courseId) => { + const b = req.headers.get("content-type")?.includes("json") ? await body<{ from?: string }>(req) : {}; + const r = await store.updateDraft(courseId, who(user), b.from === "remote" ? "remote" : "published"); + return json(r, r.ok ? 200 : r.reason === "no-remote" ? 400 : 409); + }), + }, + + "/api/studio/courses/:courseId/conflicts": { + GET: course(async (_req, _rc, user, courseId) => json({ files: await store.conflicts(courseId, user.id) })), + }, + + "/api/studio/courses/:courseId/resolve": { + POST: course(async (req, _rc, user, courseId) => { + const b = await body<{ files?: { path: string; content: string | null }[] }>(req); + if (!Array.isArray(b.files) || !b.files.length) return fail(400, "files are required"); + return json(await store.resolve(courseId, who(user), b.files.map((f) => ({ path: String(f.path), content: f.content === null ? null : String(f.content) })))); + }), + }, + + "/api/studio/courses/:courseId/abort": { + POST: course(async (_req, _rc, user, courseId) => { + await store.abortMerge(courseId, user.id); + return json({ ok: true }); + }), + }, + + "/api/studio/courses/:courseId/remote": { + PUT: course(async (req, _rc, _user, courseId) => { + const b = await body<{ url?: string | null }>(req); + await store.setRemote(courseId, b.url ? String(b.url) : null); + return json({ ok: true, remote: await store.remote(courseId) }); + }), + }, + + "/api/studio/courses/:courseId/sync": { + POST: course(async (_req, _rc, user, courseId) => { + const r = await store.sync(courseId, who(user)); + if (r.ok) { + await afterPublishedChange(courseId); + return json(r); + } + return json(r, r.reason === "no-remote" ? 400 : r.reason === "network" ? 502 : 409); + }), + }, + }; + + if (opts.watch !== false) { + watcher.check().catch((e) => console.error("[studio] watcher", e)); + watcher.start(); + } + + return { name: "studio", routes, store, watcher, draftWriter, stop: () => watcher.stop() }; +} diff --git a/packages/feature-studio/test/acceptance.test.ts b/packages/feature-studio/test/acceptance.test.ts new file mode 100644 index 0000000..80d9505 --- /dev/null +++ b/packages/feature-studio/test/acceptance.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { extGit } from "./helpers"; +import { REPO, setupApp } from "./app-helpers"; + +let env: Awaited>; +afterEach(() => env?.stop()); + +const lesson = (path: string) => `/api/studio/courses/c1/file?path=${encodeURIComponent(path)}`; + +test("acceptance: create a course, add and edit a lesson, publish, a learner sees it", async () => { + env = await setupApp(); + const { call, ctx } = env; + expect((await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" })).status).toBe(201); + expect(ctx.courses.get("c1")?.status).toBe("ok"); + expect(ctx.courses.get("c1")?.root).toBe(join(env.dataDir, "worktrees/c1/published")); + + // add a lesson (new file + manifest entry, one commit) and make the course public + const st = (await call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st.readOnly).toBe(false); + const m = structuredClone(st.manifestRaw); + m.visibility = "public"; + m.modules[0].lessons.push({ id: "second", title: "Second lesson", type: "text", file: "lessons/second.md" }); + let r = await call("ada", "PUT", "/api/studio/courses/c1/files", { + message: "Add second lesson", + manifest: m, manifestBaseHash: st.manifestHash, + files: [{ path: "lessons/second.md", content: "# Second\n\nFirst draft.\n", baseHash: null }], + }); + expect(r.status).toBe(200); + expect(r.json.commit).toBeTruthy(); + + // edit it + const f = (await call("ada", "GET", lesson("lessons/second.md"))).json; + expect(f.content).toContain("First draft."); + r = await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Edit second", files: [{ path: "lessons/second.md", content: "# Second\n\nEdited text.\n", baseHash: f.hash }] }); + expect(r.status).toBe(200); + + // drafts never reach learners + expect(ctx.courses.get("c1")?.lessonIndex.has("second")).toBe(false); + expect((await call(null, "GET", "/api/media/c1/lessons/second.md")).status).toBe(404); + + // publish + r = await call("ada", "POST", "/api/studio/courses/c1/publish"); + expect(r.status).toBe(200); + expect(r.json).toMatchObject({ ok: true, published: true }); + expect(await ctx.courses.readLessonMarkdown("c1", "second")).toBe("# Second\n\nEdited text.\n"); + const media = await call(null, "GET", "/api/media/c1/lessons/second.md"); + expect(media.status).toBe(200); + expect(media.json).toContain("Edited text."); +}); + +test("acceptance: a conflicting external commit is shown, not overwritten", async () => { + env = await setupApp(); + const { call, ctx, feature } = env; + await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" }); + const f = (await call("ada", "GET", lesson("lessons/welcome.md"))).json; + await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Studio edit", files: [{ path: "lessons/welcome.md", content: "# Studio\n", baseHash: f.hash }] }); + + const pub = join(env.dataDir, "worktrees/c1/published"); + writeFileSync(join(pub, "lessons/welcome.md"), "# External\n"); + await extGit(pub, "commit", "-qam", "Edited in VS Code"); + expect(await feature.watcher.check()).toEqual(["c1"]); + expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); + + const st = (await call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st.status.behind).toBe(1); + + const r = await call("ada", "POST", "/api/studio/courses/c1/publish"); + expect(r.status).toBe(409); + expect(r.json).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); + expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); + + const u = await call("ada", "POST", "/api/studio/courses/c1/update-draft", { from: "published" }); + expect(u.status).toBe(409); + const c = (await call("ada", "GET", "/api/studio/courses/c1/conflicts")).json; + expect(c.files[0]).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio\n", theirs: "# External\n" }); + expect((await call("ada", "POST", "/api/studio/courses/c1/resolve", { files: [{ path: "lessons/welcome.md", content: "# Studio + External\n" }] })).json.remaining).toEqual([]); + expect((await call("ada", "POST", "/api/studio/courses/c1/publish")).status).toBe(200); + expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# Studio + External\n"); +}); + +test("overlay and other outside courses are read-only", async () => { + env = await setupApp([resolve(REPO, "fixtures/courses")]); + const list = (await env.call("ada", "GET", "/api/studio/courses")).json; + expect(list.courses).toContainEqual(expect.objectContaining({ id: "sample", managed: false, message: "This course is managed outside STCourses" })); + const one = await env.call("ada", "GET", "/api/studio/courses/sample"); + expect(one.json).toMatchObject({ readOnly: true, message: "This course is managed outside STCourses" }); + const w = await env.call("ada", "PUT", "/api/studio/courses/sample/files", { message: "x", files: [{ path: "a.md", content: "x" }] }); + expect(w.status).toBe(403); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "sample", title: "Dup" })).status).toBe(409); +}); diff --git a/packages/feature-studio/test/app-helpers.ts b/packages/feature-studio/test/app-helpers.ts new file mode 100644 index 0000000..0c46da1 --- /dev/null +++ b/packages/feature-studio/test/app-helpers.ts @@ -0,0 +1,43 @@ +import { resolve } from "node:path"; +import type { AuthUser, ServerContext } from "@stc/contracts"; +import { createApp } from "@stc/core"; +import { createCourseRepository } from "@stc/course-format"; +import { connect, migrate } from "@stc/db"; +import { studioFeature } from "../src/server/feature"; +import { studioCourseDirs } from "../src/server/paths"; +import { tempDir } from "./helpers"; + +export const REPO = resolve(import.meta.dir, "../../.."); +export const users: Record = { + ada: { id: "u-ada", email: "ada@example.org", name: "Ada Lovelace", roles: ["author"], groups: [] }, + owner: { id: "u-own", email: "own@example.org", name: "Olive Owner", roles: ["owner"], groups: [] }, + lea: { id: "u-lea", email: "lea@example.org", name: "Lea Learner", roles: ["learner"], groups: [] }, +}; + +/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */ +export async function setupApp(extraCourseDirs: string[] = []) { + const dataDir = tempDir(); + const db = await connect("sqlite://:memory:"); + await migrate(db); + const config = { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x" }; + const courses = await createCourseRepository(studioCourseDirs(config)); + const ctx = { + config, db, courses, jobs: null as never, + auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null }, + } satisfies ServerContext; + const feature = studioFeature(ctx, { watch: false }); + const app = createApp(ctx, [feature]); + const call = async (as: string | null, method: string, path: string, body?: unknown) => { + const headers: Record = {}; + if (as) headers["x-user"] = as; + let payload: BodyInit | undefined; + if (body instanceof FormData) payload = body; + else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); } + const r = await app.fetch(new Request(`http://x${path}`, { method, headers, body: payload })); + const text = await r.text(); + let json: any = null; + try { json = JSON.parse(text); } catch { json = text; } + return { status: r.status, json }; + }; + return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); } }; +} diff --git a/packages/feature-studio/test/feature.test.ts b/packages/feature-studio/test/feature.test.ts new file mode 100644 index 0000000..2ed53a0 --- /dev/null +++ b/packages/feature-studio/test/feature.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from "bun:test"; +import { setupApp } from "./app-helpers"; + +let env: Awaited>; +afterEach(() => env?.stop()); + +test("anonymous gets 401, learners 403", async () => { + env = await setupApp(); + expect((await env.call(null, "GET", "/api/studio/courses")).status).toBe(401); + expect((await env.call("lea", "GET", "/api/studio/courses")).status).toBe(403); + expect((await env.call("lea", "POST", "/api/studio/courses", { id: "x", title: "X" })).status).toBe(403); +}); + +test("creating a course records the creator in course_authors; a per-course author without the role can edit it", async () => { + env = await setupApp(); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201); + const rows = await env.db.sql`SELECT course_id, user_id FROM course_authors`; + expect(rows.map((r: any) => [r.course_id, r.user_id])).toEqual([["c1", "u-ada"]]); + await env.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${"c1"}, ${"u-lea"})`; + const list = (await env.call("lea", "GET", "/api/studio/courses")).json; + expect(list.courses.map((c: any) => c.id)).toEqual(["c1"]); + expect((await env.call("lea", "GET", "/api/studio/courses/c1")).status).toBe(200); + expect((await env.call("lea", "POST", "/api/studio/courses", { id: "c2", title: "C2" })).status).toBe(403); +}); + +test("bad input: invalid id, missing title, bad JSON, bad paths, stale base hash", async () => { + env = await setupApp(); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "Bad Id", title: "x" })).status).toBe(400); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1" })).status).toBe(400); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + expect((await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "../x", content: "x" }] })).status).toBe(400); + const r = await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "lessons/welcome.md", content: "x", baseHash: "stale" }] }); + expect(r.status).toBe(409); + expect(r.json.conflicts[0].path).toBe("lessons/welcome.md"); + expect((await env.call("ada", "GET", "/api/studio/courses/nope")).status).toBe(404); + expect((await env.call("ada", "GET", "/api/studio/courses/c1/file?path=../../etc/passwd")).status).toBe(404); +}); + +test("manifest validation errors come from @stc/course-format", async () => { + env = await setupApp(); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + const st = (await env.call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st.manifestErrors).toEqual([]); + const bad = { ...st.manifestRaw, accent: "orange", modules: [{ id: "m", title: "M", lessons: [{ id: "x", title: "X", type: "text" }] }] }; + const v = (await env.call("ada", "POST", "/api/studio/courses/c1/validate", { manifest: bad })).json; + expect(v.errors).toEqual(expect.arrayContaining([expect.stringContaining("accent"), expect.stringContaining("file is required")])); + // saving an invalid draft is allowed (it is a draft) but publishing is refused with diagnostics + await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "wip", manifest: bad, manifestBaseHash: st.manifestHash, files: [] }); + const st2 = (await env.call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st2.manifestErrors.length).toBeGreaterThan(0); + const p = await env.call("ada", "POST", "/api/studio/courses/c1/publish"); + expect(p.status).toBe(422); + expect(p.json.reason).toBe("invalid"); +}); + +test("upload an attachment and serve it back from the draft", async () => { + env = await setupApp(); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + const fd = new FormData(); + fd.set("file", new File([new Uint8Array([37, 80, 68, 70])], "Æble slides.pdf", { type: "application/pdf" })); + fd.set("dir", "media"); + const r = await env.call("ada", "POST", "/api/studio/courses/c1/upload", fd); + expect(r.status).toBe(200); + expect(r.json.path).toBe("media/Æble slides.pdf"); + const raw = await env.app.fetch(new Request(`http://x/api/studio/courses/c1/raw/${encodeURIComponent("media/Æble slides.pdf")}`, { headers: { "x-user": "ada" } })); + expect(raw.status).toBe(200); + expect(raw.headers.get("content-type")).toBe("application/pdf"); + expect((await env.call("lea", "GET", `/api/studio/courses/c1/raw/media/x`)).status).toBe(403); +}); + +test("remote config and sync without a remote", async () => { + env = await setupApp(); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(400); + expect((await env.call("ada", "PUT", "/api/studio/courses/c1/remote", { url: "/tmp/nowhere.git" })).status).toBe(200); + expect((await env.call("ada", "GET", "/api/studio/courses/c1")).json.status.remote).toBe("/tmp/nowhere.git"); + expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(502); +});