/** * Unit tests for src/db.ts * * Covers: migrations, tenant isolation, FTS search, snapshots, * element_versions tracking, generateId uniqueness, global state race. * All tests use a real SQLite database in a tmpdir. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { initDb, closeDb, ensureTenant, setActiveTenant, getActiveTenantId, getActiveProjectId, setElement, getElement, getAllElements, deleteElement, searchElements, saveSnapshot, getSnapshot, getElementHistory, createProject, getDefaultProjectForTenant, getCurrentSyncVersion, incrementSyncVersion, } from '../../src/db.js'; import path from 'path'; import os from 'os'; import fs from 'fs'; function tmpDb(label: string): string { return path.join( os.tmpdir(), `excalidraw-db-unit-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}.db` ); } function cleanupDb(dbPath: string): void { closeDb(); for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch { /* ignore */ } } } function makeEl(id: string, overrides: Record = {}) { return { id, type: 'rectangle', x: 0, y: 0, width: 100, height: 50, ...overrides }; } // ── WAL mode ────────────────────────────────────────────────────────────────── describe('SQLite configuration', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('config'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('enables WAL journal mode', () => { // After initDb the WAL file should be created alongside the DB // (or journal_mode pragma returns 'wal'). // We verify indirectly: the -wal sidecar file exists after a write. setElement('el-wal', makeEl('el-wal')); const walPath = dbPath + '-wal'; // WAL file may or may not exist depending on checkpoint state, but // the DB must at least have been created without error. expect(fs.existsSync(dbPath)).toBe(true); }); }); // ── Migrations ──────────────────────────────────────────────────────────────── describe('Migrations', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('migrations'); }); afterEach(() => cleanupDb(dbPath)); it('runs successfully on a fresh database', () => { expect(() => { initDb(dbPath); setActiveTenant('default'); }).not.toThrow(); }); it('is idempotent — calling initDb twice with the same path does not error', () => { initDb(dbPath); setActiveTenant('default'); // initDb guards with `if (db) return`, so calling again is a no-op expect(() => initDb(dbPath)).not.toThrow(); }); }); // ── Element CRUD & tenant isolation ────────────────────────────────────────── describe('Tenant isolation', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('isolation'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('elements created in tenant A are not visible in tenant B', () => { // Create tenant A + project, write an element ensureTenant('tenant-a', 'Tenant A', '/ws/a'); setActiveTenant('tenant-a'); const projA = getDefaultProjectForTenant('tenant-a'); setElement('el-a', makeEl('el-a'), projA); // Create tenant B + project, write a different element ensureTenant('tenant-b', 'Tenant B', '/ws/b'); setActiveTenant('tenant-b'); const projB = getDefaultProjectForTenant('tenant-b'); setElement('el-b', makeEl('el-b'), projB); // Tenant A's project sees only el-a const elemsA = getAllElements(projA); expect(elemsA.map(e => e.id)).toContain('el-a'); expect(elemsA.map(e => e.id)).not.toContain('el-b'); // Tenant B's project sees only el-b const elemsB = getAllElements(projB); expect(elemsB.map(e => e.id)).toContain('el-b'); expect(elemsB.map(e => e.id)).not.toContain('el-a'); }); it('getElement with explicit projectId enforces project scope', () => { ensureTenant('tenant-c', 'Tenant C', '/ws/c'); const projC = getDefaultProjectForTenant('tenant-c'); setElement('el-c', makeEl('el-c'), projC); // The default project should NOT see el-c const found = getElement('el-c', 'default'); expect(found).toBeUndefined(); // The correct project SHOULD see el-c const foundCorrect = getElement('el-c', projC); expect(foundCorrect).toBeDefined(); expect(foundCorrect!.id).toBe('el-c'); }); // DESIGN NOTE: setActiveTenant() mutates module-level `activeTenantId` and // `activeProjectId`. Any code path that calls db functions WITHOUT an explicit // projectId override uses the current global value. If two logical "sessions" // call setActiveTenant() in an interleaved order, the later call wins. // The test below demonstrates this using explicit projectId overrides (the safe // API), contrasted with the module-global fallback. it('DESIGN GAP: global activeTenantId is shared across all callers without explicit projectId', () => { ensureTenant('tenant-x', 'X', '/ws/x'); ensureTenant('tenant-y', 'Y', '/ws/y'); const projX = getDefaultProjectForTenant('tenant-x'); const projY = getDefaultProjectForTenant('tenant-y'); // Session 1 sets active tenant to X and writes an element via global state setActiveTenant('tenant-x'); expect(getActiveTenantId()).toBe('tenant-x'); // Simulate session 2 switching tenant before session 1 does its DB work setActiveTenant('tenant-y'); // Now session 1's db call (no explicit projectId) will use Y's project setElement('el-contaminated', makeEl('el-contaminated')); // uses activeProjectId = projY // The element landed in Y's project, not X's expect(getElement('el-contaminated', projY)).toBeDefined(); expect(getElement('el-contaminated', projX)).toBeUndefined(); }); }); // ── FTS Search ──────────────────────────────────────────────────────────────── describe('FTS search', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('fts'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('finds elements by label text', () => { setElement('el-fts1', makeEl('el-fts1', { label: { text: 'Excalidraw Canvas' } })); setElement('el-fts2', makeEl('el-fts2', { label: { text: 'Something Else' } })); const results = searchElements('Excalidraw', 'default'); expect(results.map(e => e.id)).toContain('el-fts1'); expect(results.map(e => e.id)).not.toContain('el-fts2'); }); it('finds elements by type', () => { setElement('el-rect', { id: 'el-rect', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }); setElement('el-dia', { id: 'el-dia', type: 'diamond', x: 0, y: 0, width: 50, height: 50 }); const results = searchElements('diamond', 'default'); expect(results.map(e => e.id)).toContain('el-dia'); expect(results.map(e => e.id)).not.toContain('el-rect'); }); it('does not return deleted elements', () => { setElement('el-del', makeEl('el-del', { label: { text: 'FindMe' } })); deleteElement('el-del', 'default'); const results = searchElements('FindMe', 'default'); expect(results.map(e => e.id)).not.toContain('el-del'); }); }); // ── Soft delete ─────────────────────────────────────────────────────────────── describe('Soft delete', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('softdelete'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('deleted element is not returned by getAllElements', () => { setElement('el-to-delete', makeEl('el-to-delete')); deleteElement('el-to-delete', 'default'); const all = getAllElements('default'); expect(all.map(e => e.id)).not.toContain('el-to-delete'); }); it('deleted element is not returned by getElement', () => { setElement('el-gone', makeEl('el-gone')); deleteElement('el-gone', 'default'); expect(getElement('el-gone', 'default')).toBeUndefined(); }); it('re-inserting a deleted element revives it', () => { setElement('el-revive', makeEl('el-revive')); deleteElement('el-revive', 'default'); setElement('el-revive', makeEl('el-revive', { x: 99 })); const el = getElement('el-revive', 'default'); expect(el).toBeDefined(); expect(el!.x).toBe(99); }); }); // ── element_versions ───────────────────────────────────────────────────────── describe('element_versions history', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('versions'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('records a create operation', () => { setElement('el-hist', makeEl('el-hist')); const history = getElementHistory('el-hist', 50, 'default'); expect(history.length).toBeGreaterThanOrEqual(1); expect(history.some(h => h.operation === 'create')).toBe(true); }); it('records an update operation after second setElement', () => { setElement('el-hist2', makeEl('el-hist2')); setElement('el-hist2', makeEl('el-hist2', { x: 42 })); const history = getElementHistory('el-hist2', 50, 'default'); expect(history.some(h => h.operation === 'update')).toBe(true); }); it('records a delete operation', () => { setElement('el-hist3', makeEl('el-hist3')); deleteElement('el-hist3', 'default'); const history = getElementHistory('el-hist3', 50, 'default'); expect(history.some(h => h.operation === 'delete')).toBe(true); }); }); // ── Snapshot round-trip ─────────────────────────────────────────────────────── describe('Snapshot save / restore', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('snapshot'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('saves and retrieves a named snapshot', () => { const elements = [makeEl('snap-el-1'), makeEl('snap-el-2')]; saveSnapshot('my-snap', elements, 'default'); const snap = getSnapshot('my-snap', 'default'); expect(snap).toBeDefined(); expect(snap!.name).toBe('my-snap'); expect(snap!.elements).toHaveLength(2); expect(snap!.elements.map((e: any) => e.id)).toContain('snap-el-1'); }); it('snapshot content is independent of subsequent mutations', () => { setElement('snap-live', makeEl('snap-live', { x: 10 })); saveSnapshot('before-move', [makeEl('snap-live', { x: 10 })], 'default'); // Mutate the live element setElement('snap-live', makeEl('snap-live', { x: 999 })); // Snapshot still has the original coordinates const snap = getSnapshot('before-move', 'default'); expect(snap!.elements[0].x).toBe(10); }); it('returns undefined for non-existent snapshot name', () => { expect(getSnapshot('does-not-exist', 'default')).toBeUndefined(); }); }); // ── generateId uniqueness ───────────────────────────────────────────────────── describe('createProject — generateId uniqueness', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('genid'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('generates unique project IDs across 200 rapid sequential calls', () => { const ids = new Set(); for (let i = 0; i < 200; i++) { const project = createProject(`proj-${i}`); ids.add(project.id); } // All IDs must be unique expect(ids.size).toBe(200); }); }); // ── Sync version ────────────────────────────────────────────────────────────── describe('sync version monotonicity', () => { let dbPath: string; beforeEach(() => { dbPath = tmpDb('syncver'); initDb(dbPath); setActiveTenant('default'); }); afterEach(() => cleanupDb(dbPath)); it('sync version increases monotonically across setElement calls', () => { const v0 = getCurrentSyncVersion('default'); setElement('sv-el1', makeEl('sv-el1')); const v1 = getCurrentSyncVersion('default'); setElement('sv-el2', makeEl('sv-el2')); const v2 = getCurrentSyncVersion('default'); expect(v1).toBeGreaterThan(v0); expect(v2).toBeGreaterThan(v1); }); it('sync version is isolated per project (explicit projectId)', () => { ensureTenant('sv-tenant', 'SV Tenant', '/ws/sv'); const projSv = getDefaultProjectForTenant('sv-tenant'); const defaultV0 = getCurrentSyncVersion('default'); const svV0 = getCurrentSyncVersion(projSv); setElement('sv-isolated', makeEl('sv-isolated'), projSv); // Only the sv project's version should increment expect(getCurrentSyncVersion(projSv)).toBeGreaterThan(svV0); expect(getCurrentSyncVersion('default')).toBe(defaultV0); }); });