feat(learner): library, search, course outline with gating, lesson payload API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8a8e32bcb
commit
5f85ce1d4b
10 files changed
+699
-1
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "./helpers";
|
||||
import { FIXTURES, makeApp } from "./app";
|
||||
import { extraCourses } from "./courses";
|
||||
|
||||
forEachDb("library shows only visible courses, with formats and lengths", async (db) => {
|
||||
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
|
||||
const anon = await (await t.call("GET", "/api/library")).json();
|
||||
expect(anon.signedIn).toBe(false);
|
||||
expect(anon.courses.map((c: { id: string }) => c.id)).toEqual(["sample"]);
|
||||
const sample = anon.courses[0];
|
||||
expect(sample.formats).toEqual(["text", "video", "slides", "exercises"]);
|
||||
expect(sample.lessonCount).toBe(5);
|
||||
expect(sample.minutes).toBeGreaterThan(14);
|
||||
expect(anon.featured.id).toBe("sample");
|
||||
|
||||
const owner = await t.signUp("owner@example.com");
|
||||
const learner = await t.signUp("l@example.com");
|
||||
const asOwner = await (await t.call("GET", "/api/library", { cookie: owner })).json();
|
||||
expect(asOwner.courses.map((c: { id: string }) => c.id).sort()).toEqual(["gated", "sample", "secret"]);
|
||||
const asLearner = await (await t.call("GET", "/api/library", { cookie: learner })).json();
|
||||
expect(asLearner.courses.map((c: { id: string }) => c.id).sort()).toEqual(["gated", "sample"]);
|
||||
// manifest collections become rows; restricted courses do not leak into them
|
||||
expect(asLearner.collections).toEqual([
|
||||
{ id: "devops", title: "DevOps", courseIds: ["gated"] },
|
||||
{ id: "functional-programming", title: "Functional programming", courseIds: ["gated"] },
|
||||
]);
|
||||
expect((await t.call("GET", "/api/courses/secret", { cookie: learner })).status).toBe(404);
|
||||
expect((await t.call("GET", "/api/courses/secret/lessons/s1", { cookie: learner })).status).toBe(404);
|
||||
|
||||
// continue row: after touching a lesson
|
||||
await db.sql`INSERT INTO progress (user_id, course_id, lesson_id, state, position_seconds, updated_at)
|
||||
SELECT id, 'gated', 'g1a', 'done', NULL, '2026-10-30T00:00:00Z' FROM auth_user WHERE email = 'l@example.com'`;
|
||||
const again = await (await t.call("GET", "/api/library", { cookie: learner })).json();
|
||||
expect(again.continue).toEqual([{ courseId: "gated", lessonId: "g1b", lessonTitle: "Then this", moduleTitle: "First", moduleIndex: 0, moduleDone: 1, moduleTotal: 2, progress: 1 / 3 }]);
|
||||
expect(again.featured.id).toBe("gated");
|
||||
}, 30_000);
|
||||
|
||||
forEachDb("search matches titles, pitch and lesson titles", async (db) => {
|
||||
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
|
||||
const hits = await (await t.call("GET", "/api/library/search?q=clip")).json();
|
||||
expect(hits).toEqual([{ courseId: "sample", courseTitle: "Sample course", course: false, lessons: [{ id: "m1-video", title: "Watch the clip", moduleTitle: "First steps" }] }]);
|
||||
const tiny = await (await t.call("GET", "/api/library/search?q=TINY")).json();
|
||||
expect(tiny[0].course).toBe(true);
|
||||
expect(await (await t.call("GET", "/api/library/search?q=")).json()).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
forEachDb("course outline carries gating locks and progress", async (db) => {
|
||||
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
|
||||
await t.signUp("owner@example.com");
|
||||
const learner = await t.signUp("l@example.com");
|
||||
const c = await (await t.call("GET", "/api/courses/gated", { cookie: learner })).json();
|
||||
expect(c.modules.map((m: { locked: boolean }) => m.locked)).toEqual([false, true]);
|
||||
expect(c.modules[1].lessons[0].hasDeck).toBe(true);
|
||||
expect(c.canEdit).toBe(false);
|
||||
expect(c.diagnostics).toEqual([]);
|
||||
expect((await t.call("GET", "/api/courses/gated")).status).toBe(404); // signed-in only
|
||||
const locked = await (await t.call("GET", "/api/courses/gated/lessons/g2a", { cookie: learner })).json();
|
||||
expect(locked.locked).toBe(true);
|
||||
expect(locked.markdown).toBeNull();
|
||||
}, 30_000);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Writes extra test courses to a temp dir: a restricted one and a gated one with a deck on a module. */
|
||||
export async function extraCourses(): Promise<string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "stc-courses-"));
|
||||
await Bun.write(join(dir, "secret/stcourse.yaml"), `schema: 1
|
||||
id: secret
|
||||
title: Secret course
|
||||
visibility: restricted
|
||||
collections: [DevOps]
|
||||
modules:
|
||||
- id: m1
|
||||
title: Only module
|
||||
lessons:
|
||||
- { id: s1, title: Hidden lesson, type: text, file: a.md }
|
||||
`);
|
||||
await Bun.write(join(dir, "secret/a.md"), "# Hidden\n\nwords here");
|
||||
await Bun.write(join(dir, "gated/stcourse.yaml"), `schema: 1
|
||||
id: gated
|
||||
title: Gated course
|
||||
pitch: Learn things in order
|
||||
visibility: signed-in
|
||||
gating: sequential
|
||||
collections: [DevOps, Functional programming]
|
||||
decks:
|
||||
- { id: dk, file: "Æble slides/deck one.pdf", title: Deck one, attachTo: g2 }
|
||||
attachments:
|
||||
- { file: "Æble slides/cheat sheet.pdf", title: Cheat sheet, module: g1 }
|
||||
modules:
|
||||
- id: g1
|
||||
title: First
|
||||
lessons:
|
||||
- { id: g1a, title: Start here, type: text, file: "lessons/one.md" }
|
||||
- { id: g1b, title: Then this, type: text, file: "lessons/two.md" }
|
||||
- id: g2
|
||||
title: Second
|
||||
lessons:
|
||||
- { id: g2a, title: Locked lesson, type: text, file: "lessons/three.md" }
|
||||
`);
|
||||
await Bun.write(join(dir, "gated/lessons/one.md"), "# One\n\nSee [two](two.md).\n");
|
||||
await Bun.write(join(dir, "gated/lessons/two.md"), "# Two\n");
|
||||
await Bun.write(join(dir, "gated/lessons/three.md"), "# Three\n\nsecret content");
|
||||
await Bun.write(join(dir, "gated/Æble slides/deck one.pdf"), "%PDF-1.4\n");
|
||||
await Bun.write(join(dir, "gated/Æble slides/cheat sheet.pdf"), "%PDF-1.4\ncheat");
|
||||
return dir;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parseManifest } from "@stc/course-format";
|
||||
import { moduleLocks } from "../src/server/gating";
|
||||
|
||||
const m = (gating: string) => {
|
||||
const r = parseManifest(`schema: 1\nid: g\ntitle: G\ngating: ${gating}\nmodules:\n - { id: a, title: A, lessons: [{ id: a1, title: A1, type: exercise }, { id: a2, title: A2, type: exercise }] }\n - { id: b, title: B, lessons: [{ id: b1, title: B1, type: exercise }] }\n - { id: c, title: C, lessons: [{ id: c1, title: C1, type: exercise }] }\n`);
|
||||
if (!r.ok) throw new Error(r.errors.join());
|
||||
return r.manifest;
|
||||
};
|
||||
|
||||
test("no gating unlocks everything", () => {
|
||||
expect(moduleLocks(m("none"), {})).toEqual([false, false, false]);
|
||||
});
|
||||
test("sequential gating locks until the previous module is done", () => {
|
||||
expect(moduleLocks(m("sequential"), {})).toEqual([false, true, true]);
|
||||
expect(moduleLocks(m("sequential"), { a1: { state: "done" } })).toEqual([false, true, true]);
|
||||
expect(moduleLocks(m("sequential"), { a1: { state: "done" }, a2: { state: "done" } })).toEqual([false, false, true]);
|
||||
// done later modules do not unlock past an unfinished one
|
||||
expect(moduleLocks(m("sequential"), { a1: { state: "done" }, a2: { state: "done" }, c1: { state: "done" } })).toEqual([false, false, true]);
|
||||
expect(moduleLocks(m("sequential"), {}, true)).toEqual([false, false, false]);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "./helpers";
|
||||
import { FIXTURES, makeApp } from "./app";
|
||||
import { extraCourses } from "./courses";
|
||||
|
||||
forEachDb("lesson payloads for every sample lesson type", async (db) => {
|
||||
const t = await makeApp(db);
|
||||
const get = async (id: string, cookie?: string) => (await t.call("GET", `/api/courses/sample/lessons/${id}`, { cookie })).json();
|
||||
|
||||
const intro = await get("m1-intro");
|
||||
expect(intro.markdown).toContain("Welcome to the sample course");
|
||||
expect(intro.prev).toBeNull();
|
||||
expect(intro.next).toEqual({ id: "m1-video", title: "Watch the clip" });
|
||||
expect(intro.code).toEqual({ moduleId: "m1", dir: "code/S01 - First steps", command: "sh hello.sh" });
|
||||
expect(intro.lessonFiles["lessons/01-intro.md"]).toBe("m1-intro");
|
||||
|
||||
const video = await get("m1-video");
|
||||
expect(video.video).toEqual({ url: "/api/media/sample/media/clip.mp4", file: "media/clip.mp4", duration: 2 });
|
||||
expect(video.transcript).toContain("test pattern");
|
||||
expect(video.decks).toEqual([{ id: "d1", title: "deck", url: "/api/media/sample/media/deck.pdf", attachTo: "m1-video" }]);
|
||||
|
||||
const ex = await get("m2-ex-add");
|
||||
expect(ex.markdown?.startsWith("### Exercise 2.1")).toBe(true);
|
||||
expect(ex.markdown).not.toContain("Hints");
|
||||
expect(ex.hints.map((h: { label: string }) => h.label)).toEqual(["nudge", "near-solution"]);
|
||||
expect(ex.browser.language).toBe("js");
|
||||
expect(ex.browser.starter).toContain("a - b");
|
||||
expect(ex.browser.tests).toContain("export const tests");
|
||||
expect(ex.browser.solution).toBeNull(); // not an author
|
||||
expect(ex.localCommand).toBe("node --test");
|
||||
|
||||
const owner = await t.signUp("owner@example.com");
|
||||
expect((await get("m2-ex-add", owner)).browser.solution).toContain("a + b");
|
||||
expect((await t.call("GET", "/api/courses/sample/lessons/nope")).status).toBe(404);
|
||||
}, 30_000);
|
||||
|
||||
forEachDb("module decks and attachments with non-ASCII paths", async (db) => {
|
||||
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
|
||||
const owner = await t.signUp("owner@example.com");
|
||||
const l = await (await t.call("GET", "/api/courses/gated/lessons/g2a", { cookie: owner })).json();
|
||||
expect(l.locked).toBe(false); // owners bypass gating
|
||||
expect(l.decks[0].url).toBe("/api/media/gated/%C3%86ble%20slides/deck%20one.pdf");
|
||||
const first = await (await t.call("GET", "/api/courses/gated/lessons/g1a", { cookie: owner })).json();
|
||||
expect(first.attachments).toEqual([{ title: "Cheat sheet", file: "Æble slides/cheat sheet.pdf", url: "/api/media/gated/%C3%86ble%20slides/cheat%20sheet.pdf", size: 14 }]);
|
||||
const media = await t.call("GET", first.attachments[0].url, { cookie: owner });
|
||||
expect(media.status).toBe(200);
|
||||
}, 30_000);
|
||||
Reference in new issue
Block a user