Files
sofa/packages/db/test/migration-upgrade.test.ts
T
2026-10-06 12:50:18 -04:00

146 lines
5.6 KiB
TypeScript

import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, describe, expect, test } from "vitest";
const MIGRATIONS_DIR = fileURLToPath(new URL("../drizzle", import.meta.url));
// Last migration shipped in the v0.2.0 release.
const V0_2_0_LAST_MIGRATION = "20260324223903_secret_rhodey";
const tempDirs: string[] = [];
const clients: Database.Database[] = [];
afterEach(() => {
for (const client of clients.splice(0)) client.close();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function migrationFolders(): string[] {
return readdirSync(MIGRATIONS_DIR)
.filter((name) => existsSync(path.join(MIGRATIONS_DIR, name, "migration.sql")))
.sort();
}
function createDbAt(lastMigration: string) {
const dir = mkdtempSync(path.join(os.tmpdir(), "sofa-migrations-"));
tempDirs.push(dir);
for (const name of migrationFolders().filter((n) => n <= lastMigration)) {
cpSync(path.join(MIGRATIONS_DIR, name, ""), path.join(dir, name), { recursive: true });
}
const client = new Database(":memory:");
clients.push(client);
client.pragma("foreign_keys = ON");
const db = drizzle({ client });
migrate(db, { migrationsFolder: dir });
return { client, db };
}
const T = 1700000000;
function seedV020(client: Database.Database) {
client.exec(`
INSERT INTO "user" (id, name, email, emailVerified, createdAt, updatedAt) VALUES
('u1', 'Alice', 'alice@example.com', 1, ${T}, ${T}),
('u2', 'Bob', 'bob@example.com', 0, ${T}, ${T});
INSERT INTO titles (id, tmdbId, type, title) VALUES
('t-movie-a', 100, 'movie', 'Movie A'),
('t-show', 200, 'tv', 'Show'),
('t-movie-b', 300, 'movie', 'Movie B');
INSERT INTO seasons (id, titleId, seasonNumber) VALUES ('s1', 't-show', 1);
INSERT INTO episodes (id, seasonId, episodeNumber) VALUES ('e1', 's1', 1), ('e2', 's1', 2);
INSERT INTO userTitleStatus (userId, titleId, status, addedAt, updatedAt) VALUES
('u1', 't-show', 'completed', ${T}, ${T}),
('u1', 't-movie-a', 'in_progress', ${T}, ${T}),
('u1', 't-movie-b', 'in_progress', ${T}, ${T});
INSERT INTO userMovieWatches (id, userId, titleId, watchedAt) VALUES ('w1', 'u1', 't-movie-a', ${T});
INSERT INTO userEpisodeWatches (id, userId, episodeId, watchedAt) VALUES ('ew1', 'u1', 'e1', ${T});
INSERT INTO userRatings (userId, titleId, ratingStars, ratedAt) VALUES ('u1', 't-movie-a', 4, ${T});
INSERT INTO persons (id, tmdbId, name) VALUES ('p1', 500, 'Person');
INSERT INTO titleCast (id, titleId, personId, character, department, job, displayOrder) VALUES
('c1', 't-movie-a', 'p1', NULL, 'Directing', 'Director', 0),
('c2', 't-movie-a', 'p1', NULL, 'Directing', 'Director', 0);
INSERT INTO platforms (id, name) VALUES ('pl1', 'Platform');
INSERT INTO platformTmdbIds (platformId, tmdbProviderId) VALUES ('pl1', 8);
INSERT INTO userPlatforms (userId, platformId) VALUES ('u1', 'pl1');
INSERT INTO titleAvailability (titleId, platformId, offerType, region) VALUES ('t-movie-a', 'pl1', 'flatrate', 'US');
INSERT INTO integrations (id, userId, provider, type, token, createdAt) VALUES
('i1', 'u1', 'plex', 'webhook', 'secret-token', ${T});
INSERT INTO importJobs (id, userId, source, status, payload, importWatches, importWatchlist, importRatings, createdAt) VALUES
('j1', 'u1', 'trakt', 'success', '{}', 1, 1, 1, ${T});
INSERT INTO appSettings (key, value) VALUES ('someSetting', 'someValue');
`);
}
const SEEDED_TABLES = [
"user",
"titles",
"seasons",
"episodes",
"userTitleStatus",
"userMovieWatches",
"userEpisodeWatches",
"userRatings",
"persons",
"platforms",
"platformTmdbIds",
"userPlatforms",
"titleAvailability",
"integrations",
"importJobs",
"appSettings",
];
function count(client: Database.Database, table: string): number {
return (client.prepare(`SELECT count(*) AS c FROM "${table}"`).get() as { c: number }).c;
}
describe("migrations on an older database", () => {
test("builds a v0.2.0-schema database", () => {
const { client } = createDbAt(V0_2_0_LAST_MIGRATION);
const expected = migrationFolders().filter((n) => n <= V0_2_0_LAST_MIGRATION).length;
expect(count(client, "__drizzle_migrations")).toBe(expected);
});
test("applies newer migrations to a populated v0.2.0 database", () => {
const { client, db } = createDbAt(V0_2_0_LAST_MIGRATION);
seedV020(client);
const before = Object.fromEntries(SEEDED_TABLES.map((t) => [t, count(client, t)]));
expect(count(client, "titleCast")).toBe(2);
expect(() => migrate(db, { migrationsFolder: MIGRATIONS_DIR })).not.toThrow();
expect(count(client, "__drizzle_migrations")).toBe(migrationFolders().length);
expect(client.pragma("foreign_key_check")).toEqual([]);
expect(client.pragma("integrity_check", { simple: true })).toBe("ok");
const after = Object.fromEntries(SEEDED_TABLES.map((t) => [t, count(client, t)]));
expect(after).toEqual(before);
expect(count(client, "titleCast")).toBe(1);
const statuses = client.prepare("SELECT titleId, status FROM userTitleStatus").all() as {
titleId: string;
status: string;
}[];
const byTitle = Object.fromEntries(statuses.map((s) => [s.titleId, s.status]));
expect(byTitle).toEqual({
"t-show": "in_progress",
"t-movie-a": "completed",
"t-movie-b": "watchlist",
});
});
});