v4: playlists have categories of their own — a set apart from the dumps', which admins manage on /manage under "Playlist categories", next to "Dump categories". Each category now has a kind, dump or playlist, its slug and name unique within its kind only; migration 0025 rebuilds categories with the column, every existing one becoming a dump category with its dumps, and adds playlist_categories. A playlist's owner files it under any of them in the create and edit forms, and it shows them as chips on its card and its page, each leading to /playlists/<slug>.
/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it. The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
108
api/test/db/migrations/0025_playlist_categories.test.ts
Normal file
108
api/test/db/migrations/0025_playlist_categories.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import "../../setup.ts";
|
||||
import { assertEquals, assertThrows } from "@std/assert";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { up as up0025 } from "../../../db/migrations/0025_playlist_categories.ts";
|
||||
|
||||
/** Returns a database with the tables 0025 changes, as 0006 made them. */
|
||||
function oldDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(`PRAGMA foreign_keys = ON;`);
|
||||
db.exec(`
|
||||
CREATE TABLE dumps (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE playlists (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE dump_categories (
|
||||
dump_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (dump_id, category_id),
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
Deno.test("migration 0025 keeps the categories as dump categories, with their dumps", () => {
|
||||
const db = oldDb();
|
||||
db.exec(`INSERT INTO dumps (id) VALUES ('d1'), ('d2');`);
|
||||
db.exec(
|
||||
`INSERT INTO categories (id, slug, name, position, created_at) VALUES
|
||||
('c1', 'music', 'Music', 2, '2026-01-01T00:00:00.000Z'),
|
||||
('c2', 'playlists', 'Playlists', 1, '2026-01-02T00:00:00.000Z');`,
|
||||
);
|
||||
db.exec(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES
|
||||
('d1', 'c1'), ('d2', 'c1'), ('d2', 'c2');`,
|
||||
);
|
||||
|
||||
db.exec("BEGIN;");
|
||||
up0025(db);
|
||||
db.exec("COMMIT;");
|
||||
up0025(db); // idempotent
|
||||
|
||||
assertEquals(
|
||||
db.prepare(
|
||||
`SELECT id, kind, slug, name, position FROM categories ORDER BY id;`,
|
||||
).all().map((r) => ({ ...r })),
|
||||
[
|
||||
{ id: "c1", kind: "dump", slug: "music", name: "Music", position: 2 },
|
||||
// `playlists` is now the page of every playlist.
|
||||
{
|
||||
id: "c2",
|
||||
kind: "dump",
|
||||
slug: "playlists-2",
|
||||
name: "Playlists",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
assertEquals(
|
||||
db.prepare(
|
||||
`SELECT dump_id, category_id FROM dump_categories ORDER BY dump_id, category_id;`,
|
||||
).all().map((r) => ({ ...r })),
|
||||
[
|
||||
{ dump_id: "d1", category_id: "c1" },
|
||||
{ dump_id: "d2", category_id: "c1" },
|
||||
{ dump_id: "d2", category_id: "c2" },
|
||||
],
|
||||
);
|
||||
|
||||
// A slug or name is unique within its kind only.
|
||||
db.exec(
|
||||
`INSERT INTO categories (id, kind, slug, name, created_at)
|
||||
VALUES ('p1', 'playlist', 'music', 'Music', '2026-01-03T00:00:00.000Z');`,
|
||||
);
|
||||
assertThrows(() =>
|
||||
db.exec(
|
||||
`INSERT INTO categories (id, kind, slug, name, created_at)
|
||||
VALUES ('p2', 'playlist', 'music', 'Other', '2026-01-03T00:00:00.000Z');`,
|
||||
)
|
||||
);
|
||||
|
||||
// The links still cascade.
|
||||
db.exec(`INSERT INTO playlists (id) VALUES ('pl1');`);
|
||||
db.exec(
|
||||
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES ('pl1', 'p1');`,
|
||||
);
|
||||
db.exec(`DELETE FROM categories WHERE id IN ('c1', 'p1');`);
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT COUNT(*) as n FROM dump_categories;`).get() as {
|
||||
n: number;
|
||||
}).n,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT COUNT(*) as n FROM playlist_categories;`).get() as {
|
||||
n: number;
|
||||
}).n,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
Reference in New Issue
Block a user