397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
/**
|
|
* 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<string, any> = {}) {
|
|
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<string>();
|
|
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);
|
|
});
|
|
});
|