import "../../setup.ts"; import { assertEquals } from "@std/assert"; import { DatabaseSync } from "node:sqlite"; import { up as up0021 } from "../../../db/migrations/0021_page_urls.ts"; /** A database as v3 left it: schema.sql without 0021's tables and indexes. */ function v3Db(): DatabaseSync { const db = new DatabaseSync(":memory:"); db.exec( Deno.readTextFileSync(new URL("../../../db/schema.sql", import.meta.url)), ); db.exec(` DROP TABLE dump_slug_aliases; DROP TABLE playlist_slug_aliases; DROP TABLE user_aliases; DROP INDEX idx_dumps_user_slug; DROP INDEX idx_playlists_user_slug; CREATE INDEX idx_dumps_slug ON dumps(slug); CREATE INDEX idx_playlists_slug ON playlists(slug); `); return db; } const NOW = "2024-01-01T00:00:00.000Z"; const later = (n: number) => new Date(Date.parse(NOW) + n * 1000).toISOString(); function user(db: DatabaseSync, username: string): string { const id = crypto.randomUUID(); db.prepare( `INSERT INTO users (id, username, password_hash, created_at, email) VALUES (?, ?, 'x', ?, ?);`, ).run(id, username, NOW, `${username}@example.test`); return id; } /** Inserts a dump or playlist with its v3 slug, and returns its id and slug. */ function item( db: DatabaseSync, table: "dumps" | "playlists", userId: string, title: string, at: number, ): { id: string; v3: string } { const id = crypto.randomUUID(); const v3 = `${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${ id.slice(0, 8) }`; db.prepare( table === "dumps" ? `INSERT INTO dumps (id, kind, title, slug, user_id, created_at, url) VALUES (?, 'url', ?, ?, ?, ?, 'https://x.test');` : `INSERT INTO playlists (id, title, slug, user_id, created_at) VALUES (?, ?, ?, ?, ?);`, ).run(id, title, v3, userId, later(at)); return { id, v3 }; } const slugOf = (db: DatabaseSync, table: string, id: string) => (db.prepare(`SELECT slug FROM ${table} WHERE id = ?;`).get(id) as { slug: string; }).slug; Deno.test("migration 0021: bare slugs unique per owner, reserved categories renamed", () => { const db = v3Db(); const bob = user(db, "Bob"); const ann = user(db, "ann"); const first = item(db, "dumps", bob, "Never Gonna", 0); const second = item(db, "dumps", bob, "Never gonna!", 1); const anns = item(db, "dumps", ann, "Never Gonna", 2); const mix = item(db, "playlists", bob, "Never Gonna", 3); for ( const [slug, name] of [ ["hot", "Hot"], ["hot-2", "Hot 2"], ["follow", "F"], ["music", "M"], ] ) { db.prepare( `INSERT INTO categories (id, slug, name, created_at) VALUES (?, ?, ?, ?);`, ).run(crypto.randomUUID(), slug, name, NOW); } up0021(db); // The oldest keeps the bare slug; each kind and owner is its own space. assertEquals(slugOf(db, "dumps", first.id), "never-gonna"); assertEquals(slugOf(db, "dumps", second.id), "never-gonna-2"); assertEquals(slugOf(db, "dumps", anns.id), "never-gonna"); assertEquals(slugOf(db, "playlists", mix.id), "never-gonna"); assertEquals( (db.prepare(`SELECT slug FROM categories ORDER BY slug;`).all() as { slug: string; }[]).map((r) => r.slug), ["follow-2", "hot-2", "hot-3", "music"], ); // The new index holds. let clash = false; try { db.prepare(`UPDATE dumps SET slug = 'never-gonna' WHERE id = ?;`).run( second.id, ); } catch { clash = true; } assertEquals(clash, true); db.close(); }); Deno.test("migration 0021: v3 links in notes, comments and chat become v4 paths", () => { const db = v3Db(); const bob = user(db, "Bob"); const dump = item(db, "dumps", bob, "Never Gonna", 0); const mix = item(db, "playlists", bob, "Sunday Mix", 1); const text = [ `[by slug](http://gerbeur.test/dumps/${dump.v3}#comment-1)`, `[relative by id](/dumps/${dump.id}/edit)`, `http://gerbeur.test/playlists/${mix.id}?x=1`, ``, `[list](/users/BOB/upvoted) [settings](/users/bob/~/settings)`, `[feed](/~/followed) [search](/search/~/users?q=a)`, // Another site's, a bare path in prose and a deleted dump stay as they are. `https://elsewhere.test/dumps/${dump.v3} see /dumps/${dump.v3}`, `http://gerbeur.test/dumps/gone-12345678`, ].join("\n"); const expected = [ `[by slug](http://gerbeur.test/@Bob/dumps/never-gonna#comment-1)`, `[relative by id](/@Bob/dumps/never-gonna/edit)`, `http://gerbeur.test/@Bob/playlists/sunday-mix?x=1`, ``, `[list](/@Bob/upvoted) [settings](/settings)`, `[feed](/follow) [search](/search/users?q=a)`, `https://elsewhere.test/dumps/${dump.v3} see /dumps/${dump.v3}`, `http://gerbeur.test/dumps/gone-12345678`, ].join("\n"); const commentId = crypto.randomUUID(); const messageId = crypto.randomUUID(); db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`).run(text, dump.id); db.prepare(`UPDATE playlists SET description = ? WHERE id = ?;`).run( text, mix.id, ); db.prepare( `INSERT INTO comments (id, dump_id, user_id, body, created_at) VALUES (?, ?, ?, ?, ?);`, ).run(commentId, dump.id, bob, text, NOW); db.prepare( `INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`, ).run(messageId, bob, text, NOW); up0021(db); const read = (sql: string, id: string) => (db.prepare(sql).get(id) as { text: string }).text; assertEquals( read(`SELECT comment AS text FROM dumps WHERE id = ?;`, dump.id), expected, ); assertEquals( read(`SELECT body AS text FROM comments WHERE id = ?;`, commentId), expected, ); assertEquals( read(`SELECT body AS text FROM chat_messages WHERE id = ?;`, messageId), expected, ); assertEquals( read(`SELECT description AS text FROM playlists WHERE id = ?;`, mix.id), expected, ); db.close(); });