// Migrations run against fresh databases (built from schema.sql) and old ones; // each must be idempotent, and the data rewrites must touch only what they // mean to. import "./setup.ts"; import { assertEquals } from "@std/assert"; import { DatabaseSync } from "node:sqlite"; import { runMigrations } from "../db/migrate.ts"; import { up as up0004 } from "../db/migrations/0004_user_roles.ts"; import { up as up0005 } from "../db/migrations/0005_drop_is_admin.ts"; import { up as up0010 } from "../db/migrations/0010_youtube_embed_start.ts"; import { up as up0011 } from "../db/migrations/0011_split_favicon_thumbnail.ts"; import { up as up0012 } from "../db/migrations/0012_fix_favicon_reclassification.ts"; const SCHEMA = Deno.readTextFileSync( new URL("../db/schema.sql", import.meta.url), ); function freshDb(): DatabaseSync { const db = new DatabaseSync(":memory:"); db.exec(SCHEMA); return db; } function schemaOf(db: DatabaseSync): string[] { return (db.prepare( `SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name != 'schema_migrations' ORDER BY name;`, ).all() as { sql: string }[]).map((r) => r.sql); } Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => { const db = freshDb(); const before = schemaOf(db); runMigrations(db); assertEquals(schemaOf(db), before); const applied = db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`) .get() as { n: number; }; assertEquals(applied.n, 15); // Running again applies nothing. runMigrations(db); assertEquals( (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { n: number; }).n, 15, ); db.close(); }); Deno.test("0004/0005: is_admin becomes role, then goes", () => { const db = new DatabaseSync(":memory:"); db.exec( `CREATE TABLE users (id TEXT PRIMARY KEY, is_admin INTEGER NOT NULL DEFAULT 0);`, ); db.exec(`INSERT INTO users (id, is_admin) VALUES ('a', 1), ('b', 0);`); up0004(db); up0004(db); up0005(db); up0005(db); assertEquals( db.prepare(`SELECT id, role FROM users ORDER BY id;`).all().map((r) => ({ ...r, })), [{ id: "a", role: "admin" }, { id: "b", role: "user" }], ); const cols = (db.prepare(`PRAGMA table_info(users);`).all() as { name: string }[]) .map((c) => c.name); assertEquals(cols, ["id", "role"]); db.close(); }); function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) { db.prepare( `INSERT INTO users (id, username, password_hash, created_at, email) VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`, ).run(); db.prepare( `INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content) VALUES (?, 'url', 't', 'u', 'now', ?, ?);`, ).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich)); } function richOf(db: DatabaseSync, id: string): Record { return JSON.parse( (db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as { rich_content: string; }).rich_content, ); } Deno.test("0010: adds the start offset to YouTube embeds that lost it", () => { const db = freshDb(); const embed = "https://www.youtube.com/embed/abc?rel=0"; insertRich(db, "hms", "https://youtu.be/abc?t=1h2m3s", { type: "youtube", embedUrl: embed, }); insertRich(db, "plain", "https://youtu.be/abc?start=42", { type: "youtube", embedUrl: embed, }); insertRich(db, "none", "https://youtu.be/abc", { type: "youtube", embedUrl: embed, }); insertRich(db, "has", "https://youtu.be/abc?t=5", { type: "youtube", embedUrl: `${embed}&start=9`, }); insertRich(db, "other", "https://example.com/?t=5", { type: "generic", embedUrl: embed, }); insertRich(db, "bad", "https://youtu.be/abc?t=5", "{not json"); up0010(db); up0010(db); assertEquals(richOf(db, "hms").embedUrl, `${embed}&start=3723`); assertEquals(richOf(db, "plain").embedUrl, `${embed}&start=42`); assertEquals(richOf(db, "none").embedUrl, embed); assertEquals(richOf(db, "has").embedUrl, `${embed}&start=9`); assertEquals(richOf(db, "other").embedUrl, embed); db.close(); }); Deno.test("0011 then 0012: icons move to faviconUrl, artwork stays or comes back", () => { const db = freshDb(); const cases: Record< string, [string, string | undefined, string | undefined] > = { // id: [thumbnailUrl before, thumbnailUrl after, faviconUrl after] ico: [ "https://a.test/favicon.ico", undefined, "https://a.test/favicon.ico", ], touch: [ "https://a.test/apple-touch-icon.png", undefined, "https://a.test/apple-touch-icon.png", ], art: ["https://a.test/cover.jpg", "https://a.test/cover.jpg", undefined], // 0011 mistook these for icons; 0012 puts them back. logoFavicon: [ "https://a.test/uploads/mir-logo-favicon.png", "https://a.test/uploads/mir-logo-favicon.png", undefined, ], svgArt: [ "https://a.test/art/poster.svg", "https://a.test/art/poster.svg", undefined, ], // 0011 missed this one; 0012 catches it. iconName: [ "https://a.test/img/icon_SEARCH.png", undefined, "https://a.test/img/icon_SEARCH.png", ], }; for (const [id, [thumbnailUrl]] of Object.entries(cases)) { insertRich(db, id, "https://a.test/", { type: "generic", url: "u", thumbnailUrl, }); } up0011(db); up0011(db); up0012(db); up0012(db); for (const [id, [, thumbnailUrl, faviconUrl]] of Object.entries(cases)) { const rich = richOf(db, id); assertEquals([rich.thumbnailUrl, rich.faviconUrl], [ thumbnailUrl, faviconUrl, ], id); } db.close(); });