Files
opengroupware/tests/unit/lib/db/sqlite.test.ts
Ken Yasue d7c4cd5e58 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
2026-06-25 00:21:26 +02:00

253 lines
7.1 KiB
TypeScript

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();
});
});
});