All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
5.6 KiB
TypeScript
183 lines
5.6 KiB
TypeScript
// 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<string, unknown> {
|
|
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();
|
|
});
|