import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { parseLrc } from "../src/shared/lrc"; import { scanLibrary } from "../src/content/library"; import { splitBooks } from "../src/content/homer"; import { startServer } from "../src/server"; describe("parseLrc", () => { test("parses timed lines, skips metadata, sorts, handles CRLF and multiple stamps", () => { const text = "[ar:Someone]\r\n[00:12.50]Second\r\n[00:01.2]First\r\n[01:02.345][00:30]Twice\r\n\r\nno stamp\r\n"; expect(parseLrc(text)).toEqual([ { time: 1.2, text: "First" }, { time: 12.5, text: "Second" }, { time: 30, text: "Twice" }, { time: 62.345, text: "Twice" }, ]); }); test("keeps empty timed lines as blank text", () => { expect(parseLrc("[00:05.00]")).toEqual([{ time: 5, text: "" }]); }); }); const BOOK_FIXTURE = [ "The Project Gutenberg eBook of The Odyssey", "BOOK I (in the contents, before the start marker)", "*** START OF THE PROJECT GUTENBERG EBOOK THE ODYSSEY ***", "Preface text.", "Contents", "BOOK I", "BOOK II.", "", "BOOK I", "", "", "THE GODS IN COUNCIL.", "", "", "Tell me, O Muse, of that", "ingenious hero.", "", "So he spoke.", "BOOK II.", "", "ASSEMBLY.", "", "Now when the child of morning appeared.", "*** END OF THE PROJECT GUTENBERG EBOOK THE ODYSSEY ***", "License text.", ].join("\r\n"); describe("splitBooks", () => { test("splits between the Gutenberg markers into books with summary and paragraphs", () => { const books = splitBooks(BOOK_FIXTURE); expect(books).toHaveLength(2); expect(books[0]).toEqual({ book: 1, heading: "BOOK I", summary: "THE GODS IN COUNCIL.", paragraphs: ["Tell me, O Muse, of that ingenious hero.", "So he spoke."], }); expect(books[1]).toMatchObject({ book: 2, summary: "ASSEMBLY.", paragraphs: ["Now when the child of morning appeared."] }); }); }); let dir: string; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), "epic-content-")); const cyc = join(dir, "Songs and Lyrics", "EPIC - The Cyclops Saga (2023)"); const troy = join(dir, "Songs and Lyrics", "EPIC - The Troy Saga (2022)"); const wis = join(dir, "Songs and Lyrics", "EPIC - The Wisdom Saga (2024)"); for (const d of [cyc, troy, wis]) mkdirSync(d, { recursive: true }); const f = (saga: string, n: string, t: string) => `Jorge Rivera-Herrans - EPIC - ${saga} - ${n} - ${t}`; writeFileSync(join(cyc, f("The Cyclops Saga", "02", "Survive") + ".flac"), "x".repeat(1000)); writeFileSync(join(cyc, f("The Cyclops Saga", "02", "Survive") + ".lrc"), "[00:01.00]a"); writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".flac"), "0123456789"); writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".lrc"), "[00:01.00]a"); writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".txt"), "plain"); writeFileSync(join(cyc, "folder.jpg"), "jpg"); writeFileSync(join(troy, f("The Troy Saga", "01", "The Horse and the Infant") + ".flac"), "x"); writeFileSync(join(wis, f("The Wisdom Saga", "01", "Legendary") + ".flac"), "x"); writeFileSync(join(wis, f("The Wisdom Saga", "01", "Legendary") + ".txt"), "plain lyrics"); mkdirSync(join(dir, "Homers the Odyssey Book")); writeFileSync(join(dir, "Homers the Odyssey Book", "The Odyssey.txt"), BOOK_FIXTURE); writeFileSync(join(dir, "Homers the Odyssey Book", "The Iliad.txt"), BOOK_FIXTURE.replace("ASSEMBLY.", "ROMAN NAMES.")); writeFileSync(join(dir, "Homers the Odyssey Book", "The Iliad (Lang, Leaf and Myers).txt"), BOOK_FIXTURE.replace("ASSEMBLY.", "GREEK NAMES.")); writeFileSync(join(dir, "outside-secret.txt"), "no"); }); afterAll(() => rmSync(dir, { recursive: true, force: true })); describe("scanLibrary", () => { test("lists sagas in story order with songs by track, lrc preferred over txt", () => { const lib = scanLibrary(dir); expect(lib.sagas.map((s) => s.name)).toEqual(["The Troy Saga", "The Cyclops Saga", "The Wisdom Saga"]); const cyc = lib.sagas[1]!; expect(cyc.year).toBe(2023); expect(cyc.cover).toBe("/content/files/Songs%20and%20Lyrics/EPIC%20-%20The%20Cyclops%20Saga%20(2023)/folder.jpg"); expect(cyc.songs.map((s) => [s.track, s.title])).toEqual([[1, "Polyphemus"], [2, "Survive"]]); expect(cyc.songs[0]!.lyrics).toMatchObject({ timed: true }); expect(cyc.songs[0]!.lyrics!.url.endsWith(".lrc")).toBe(true); expect(lib.sagas[2]!.songs[0]!.lyrics).toMatchObject({ timed: false }); expect(lib.sagas[0]!.cover).toBeNull(); expect(lib.sagas[0]!.songs[0]!.lyrics).toBeNull(); }); test("a missing folder gives an empty library", () => { expect(scanLibrary(join(dir, "nope")).sagas).toEqual([]); }); }); describe("/content routes", () => { let withContent: Server; let without: Server; beforeAll(() => { withContent = startServer(0, { contentDir: join(dir) }); without = startServer(0, { contentDir: undefined }); }); afterAll(() => { withContent.stop(true); without.stop(true); }); const url = (s: Server, p: string) => `http://localhost:${s.port}${p}`; test("library.json lists the scanned library", async () => { const res = await fetch(url(withContent, "/content/library.json")); expect(res.status).toBe(200); const lib = await res.json(); expect(lib.sagas).toHaveLength(3); }); test("files are served, and a range request returns 206", async () => { const lib = scanLibrary(dir); const audio = lib.sagas[1]!.songs[0]!.audio; const full = await fetch(url(withContent, audio)); expect(full.status).toBe(200); expect(full.headers.get("content-type")).toBe("audio/flac"); expect(await full.text()).toBe("0123456789"); const part = await fetch(url(withContent, audio), { headers: { Range: "bytes=2-5" } }); expect(part.status).toBe(206); expect(part.headers.get("content-range")).toBe("bytes 2-5/10"); expect(await part.text()).toBe("2345"); }); test("paths escaping the content folder are refused", async () => { for (const p of ["/content/files/..%2F..%2Fetc%2Fpasswd", "/content/files/Songs%20and%20Lyrics/..%2F..%2F..%2Fetc%2Fhosts"]) { const res = await fetch(url(withContent, p)); expect(res.status).toBe(404); } }); test("homer books are served as JSON", async () => { const res = await fetch(url(withContent, "/content/homer/odyssey/2.json")); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ book: 2, summary: "ASSEMBLY." }); expect((await fetch(url(withContent, "/content/homer/odyssey/99.json"))).status).toBe(404); }); test("the Greek-names translation is preferred, Butler is the fallback", async () => { // iliad: both files present → Lang, Leaf and Myers; odyssey: only Butler's file in the fixture expect(await (await fetch(url(withContent, "/content/homer/iliad/2.json"))).json()).toMatchObject({ summary: "GREEK NAMES." }); expect(await (await fetch(url(withContent, "/content/homer/odyssey/2.json"))).json()).toMatchObject({ summary: "ASSEMBLY." }); }); test("without a content folder every /content route is 404 and the site still works", async () => { expect((await fetch(url(without, "/content/library.json"))).status).toBe(404); expect((await fetch(url(without, "/content/homer/odyssey/1.json"))).status).toBe(404); expect((await fetch(url(without, "/"))).status).toBe(200); }); });