feat(m2): DB foundation tests + multi-statement migration fix
- Add SqliteDatabase.exec() using better-sqlite3 exec() so the migrator can run multi-statement SQL files (prepare() rejected 001_initial.sql) - Migrator now uses exec() for migration file content - Fix tests/helpers/db.ts: replace CJS require() with ESM import - Add unit tests for SqliteDatabase (query/get/execute/transaction/exec, WAL & foreign_keys pragmas) and Migrator (ordering, idempotency, rollback, real 001_initial.sql applies) - Restore .gitignore steering exclusion; remove stale steering files
This commit is contained in:
185
tests/unit/lib/db/migrator.test.ts
Normal file
185
tests/unit/lib/db/migrator.test.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createTestDb } from '@/tests/helpers/db';
|
||||
import { Migrator } from '@/lib/db/migrator';
|
||||
import { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
|
||||
function createTempMigrationsDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'migrations-'));
|
||||
}
|
||||
|
||||
function writeMigration(dir: string, filename: string, sql: string): void {
|
||||
fs.writeFileSync(path.join(dir, filename), sql, 'utf-8');
|
||||
}
|
||||
|
||||
describe('Migrator', () => {
|
||||
let db: SqliteDatabase;
|
||||
let migrationsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
migrationsDir = createTempMigrationsDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(migrationsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the schema_migrations table', () => {
|
||||
writeMigration(
|
||||
migrationsDir,
|
||||
'001_init.sql',
|
||||
'CREATE TABLE sample (id INTEGER);'
|
||||
);
|
||||
const migrator = new Migrator(db, migrationsDir);
|
||||
|
||||
migrator.migrate();
|
||||
|
||||
const row = db.get<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'"
|
||||
);
|
||||
expect(row?.name).toBe('schema_migrations');
|
||||
});
|
||||
|
||||
it('applies migration files in filename order', () => {
|
||||
writeMigration(
|
||||
migrationsDir,
|
||||
'002_second.sql',
|
||||
'CREATE TABLE second (id INTEGER);'
|
||||
);
|
||||
writeMigration(
|
||||
migrationsDir,
|
||||
'001_first.sql',
|
||||
'CREATE TABLE first (id INTEGER);'
|
||||
);
|
||||
const migrator = new Migrator(db, migrationsDir);
|
||||
|
||||
migrator.migrate();
|
||||
|
||||
const applied = migrator.getAppliedMigrations();
|
||||
expect(applied.map((m) => m.filename)).toEqual([
|
||||
'001_first.sql',
|
||||
'002_second.sql',
|
||||
]);
|
||||
expect(
|
||||
db.get<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE name='first'"
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
db.get<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE name='second'"
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('skips already-applied migrations on subsequent runs', () => {
|
||||
writeMigration(
|
||||
migrationsDir,
|
||||
'001_init.sql',
|
||||
'CREATE TABLE sample (id INTEGER);'
|
||||
);
|
||||
const migrator = new Migrator(db, migrationsDir);
|
||||
|
||||
migrator.migrate();
|
||||
expect(migrator.getAppliedMigrations()).toHaveLength(1);
|
||||
|
||||
expect(() => migrator.migrate()).not.toThrow();
|
||||
expect(migrator.getAppliedMigrations()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rolls back and does not record a migration when its SQL fails', () => {
|
||||
writeMigration(
|
||||
migrationsDir,
|
||||
'001_bad.sql',
|
||||
'CREATE TABLE will_exist (id INTEGER); NOT A VALID STATEMENT;'
|
||||
);
|
||||
const migrator = new Migrator(db, migrationsDir);
|
||||
|
||||
expect(() => migrator.migrate()).toThrow();
|
||||
|
||||
expect(migrator.getAppliedMigrations()).toHaveLength(0);
|
||||
expect(
|
||||
db.get<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE name='will_exist'"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns applied migrations ordered by filename', () => {
|
||||
writeMigration(migrationsDir, '001_a.sql', 'CREATE TABLE a (id INTEGER);');
|
||||
writeMigration(migrationsDir, '002_b.sql', 'CREATE TABLE b (id INTEGER);');
|
||||
const migrator = new Migrator(db, migrationsDir);
|
||||
|
||||
migrator.migrate();
|
||||
|
||||
const applied = migrator.getAppliedMigrations();
|
||||
expect(applied).toHaveLength(2);
|
||||
expect(applied[0].filename).toBe('001_a.sql');
|
||||
expect(applied[1].filename).toBe('002_b.sql');
|
||||
expect(applied[0].applied_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('only reads .sql files in the migrations directory', () => {
|
||||
writeMigration(
|
||||
migrationsDir,
|
||||
'001_real.sql',
|
||||
'CREATE TABLE real_t (id INTEGER);'
|
||||
);
|
||||
fs.writeFileSync(path.join(migrationsDir, 'README.md'), 'not a migration');
|
||||
const migrator = new Migrator(db, migrationsDir);
|
||||
|
||||
migrator.migrate();
|
||||
|
||||
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
|
||||
'001_real.sql',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies the real 001_initial.sql and creates all core tables', () => {
|
||||
const realMigrationsDir = path.join(
|
||||
process.cwd(),
|
||||
'lib',
|
||||
'db',
|
||||
'migrations'
|
||||
);
|
||||
const migrator = new Migrator(db, realMigrationsDir);
|
||||
|
||||
migrator.migrate();
|
||||
|
||||
const tables = db
|
||||
.query<{
|
||||
name: string;
|
||||
}>("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.map((t) => t.name);
|
||||
|
||||
const expectedTables = [
|
||||
'activity_logs',
|
||||
'board_comments',
|
||||
'board_threads',
|
||||
'calendar_events',
|
||||
'chat_messages',
|
||||
'file_assets',
|
||||
'meeting_members',
|
||||
'meetings',
|
||||
'milestones',
|
||||
'notifications',
|
||||
'project_members',
|
||||
'project_notes',
|
||||
'projects',
|
||||
'schema_migrations',
|
||||
'todo_columns',
|
||||
'todo_items',
|
||||
'users',
|
||||
];
|
||||
for (const table of expectedTables) {
|
||||
expect(tables).toContain(table);
|
||||
}
|
||||
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
|
||||
'001_initial.sql',
|
||||
]);
|
||||
});
|
||||
});
|
||||
252
tests/unit/lib/db/sqlite.test.ts
Normal file
252
tests/unit/lib/db/sqlite.test.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createTestDb } from '@/tests/helpers/db';
|
||||
import { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
|
||||
describe('SqliteDatabase', () => {
|
||||
let db: SqliteDatabase;
|
||||
|
||||
afterEach(() => {
|
||||
if (db) db.close();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('enables WAL journal mode', () => {
|
||||
db = createTestDb();
|
||||
const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
|
||||
expect(row?.journal_mode).toBe('wal');
|
||||
});
|
||||
|
||||
it('enables foreign key constraints', () => {
|
||||
db = createTestDb();
|
||||
const row = db.get<{ foreign_keys: number }>('PRAGMA foreign_keys');
|
||||
expect(row?.foreign_keys).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('returns multiple rows', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||
|
||||
const rows = db.query<{ id: number; name: string }>(
|
||||
'SELECT * FROM items ORDER BY id'
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].name).toBe('a');
|
||||
expect(rows[1].name).toBe('b');
|
||||
});
|
||||
|
||||
it('returns an empty array when no rows match', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
|
||||
const rows = db.query<{ id: number }>('SELECT * FROM items');
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('binds named parameters', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
db.execute("INSERT INTO items (name) VALUES ('target')");
|
||||
db.execute("INSERT INTO items (name) VALUES ('other')");
|
||||
|
||||
const rows = db.query<{ name: string }>(
|
||||
'SELECT * FROM items WHERE name = @name',
|
||||
{ name: 'target' }
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('target');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns a single row when found', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
const { lastInsertRowid } = db.execute(
|
||||
"INSERT INTO items (name) VALUES ('x')"
|
||||
);
|
||||
|
||||
const row = db.get<{ id: number; name: string }>(
|
||||
'SELECT * FROM items WHERE id = @id',
|
||||
{ id: Number(lastInsertRowid) }
|
||||
);
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.name).toBe('x');
|
||||
});
|
||||
|
||||
it('returns null when no row is found', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
|
||||
const row = db.get<{ id: number }>('SELECT * FROM items WHERE id = @id', {
|
||||
id: 999,
|
||||
});
|
||||
|
||||
expect(row).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns changes and lastInsertRowid for an insert', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
|
||||
const result = db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||
|
||||
expect(result.changes).toBe(1);
|
||||
expect(Number(result.lastInsertRowid)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns the number of affected rows for an update', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||
|
||||
const result = db.execute(
|
||||
'UPDATE items SET name = @name WHERE name = @old',
|
||||
{ name: 'updated', old: 'a' }
|
||||
);
|
||||
|
||||
expect(result.changes).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the number of deleted rows for a delete', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||
|
||||
const result = db.execute('DELETE FROM items WHERE name = @name', {
|
||||
name: 'a',
|
||||
});
|
||||
|
||||
expect(result.changes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exec', () => {
|
||||
it('executes multiple semicolon-separated statements', () => {
|
||||
db = createTestDb();
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE a (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
||||
CREATE TABLE b (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);
|
||||
INSERT INTO a (name) VALUES ('first');
|
||||
INSERT INTO b (value) VALUES (42);
|
||||
`);
|
||||
|
||||
expect(db.get<{ name: string }>('SELECT name FROM a')?.name).toBe(
|
||||
'first'
|
||||
);
|
||||
expect(db.get<{ value: number }>('SELECT value FROM b')?.value).toBe(42);
|
||||
});
|
||||
|
||||
it('handles SQL comments alongside statements', () => {
|
||||
db = createTestDb();
|
||||
|
||||
db.exec(`
|
||||
-- this is a comment
|
||||
CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
||||
/* multi-line
|
||||
comment */
|
||||
INSERT INTO items (name) VALUES ('x');
|
||||
`);
|
||||
|
||||
expect(db.query<{ name: string }>('SELECT name FROM items')).toHaveLength(
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transaction', () => {
|
||||
it('commits changes when the callback succeeds', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
|
||||
db.transaction(() => {
|
||||
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||
});
|
||||
|
||||
const rows = db.query<{ name: string }>(
|
||||
'SELECT * FROM items ORDER BY id'
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rolls back changes when the callback throws', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
db.transaction(() => {
|
||||
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||
throw new Error('boom');
|
||||
})
|
||||
).toThrow('boom');
|
||||
|
||||
const rows = db.query<{ name: string }>('SELECT * FROM items');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns the callback value', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
|
||||
const result = db.transaction(() => {
|
||||
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||
return 'committed';
|
||||
});
|
||||
|
||||
expect(result).toBe('committed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreign key enforcement', () => {
|
||||
it('rejects inserts that violate a foreign key constraint', () => {
|
||||
db = createTestDb();
|
||||
db.execute(
|
||||
'CREATE TABLE parents (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||
);
|
||||
db.execute(
|
||||
'CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL, FOREIGN KEY (parent_id) REFERENCES parents(id))'
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
db.execute('INSERT INTO children (parent_id) VALUES (@parentId)', {
|
||||
parentId: 999,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user