import { describe, it, expect } from 'vitest' import Database from 'better-sqlite3' import { openDatabase, migrateUpgrades } from '../src/db.js' /** Builds a pre-v2 database (plan-1 schema, no version row, no new columns). */ function legacyDb(): Database.Database { const db = new Database(':memory:') db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') db.exec(` CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now'))); CREATE TABLE sessions (token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL); CREATE TABLE settings (user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, discogs_token TEXT, subsonic_url TEXT, subsonic_username TEXT, subsonic_password TEXT); CREATE TABLE collection_items (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, discogs_release_id INTEGER NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, year INTEGER, formats TEXT NOT NULL DEFAULT '[]', genres TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', tracklist TEXT NOT NULL DEFAULT '[]', catno TEXT, country TEXT, cover_url TEXT, local_artwork_path TEXT, barcodes TEXT NOT NULL DEFAULT '[]', rip_override INTEGER, date_added TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, discogs_release_id)); CREATE TABLE digital_albums (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, subsonic_id TEXT NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, UNIQUE (user_id, subsonic_id)); CREATE TABLE match_links (user_id INTEGER NOT NULL, item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE, PRIMARY KEY (user_id, item_id)); INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1); INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (1, 1, 'a1', 'Motion', 'The Cinematic Orchestra'); `) return db } function columns(db: Database.Database, table: string): string[] { return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name) } describe('schema migration v2', () => { it('migrates a legacy database: loans table, last_played_at, version row', () => { const db = legacyDb() migrateUpgrades(db) expect(columns(db, 'digital_albums')).toContain('last_played_at') expect(columns(db, 'loans')).toEqual([ 'id', 'user_id', 'item_id', 'borrower', 'lent_at', 'returned_at', ]) const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() expect(version).toEqual({ value: '2' }) // existing data survives expect(db.prepare('SELECT title FROM digital_albums').get()).toEqual({ title: 'Motion' }) }) it('openDatabase creates a fresh v2 database directly', () => { const db = openDatabase(':memory:') expect(columns(db, 'digital_albums')).toContain('last_played_at') expect(columns(db, 'loans')).toContain('borrower') const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() expect(version).toEqual({ value: '2' }) }) it('migrations are idempotent', () => { const db = openDatabase(':memory:') expect(() => migrateUpgrades(db)).not.toThrow() expect( (db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as { value: string }).value ).toBe('2') }) })