Files
excalidraw-mcp-sentinel/tests/backend/db.test.ts
T
2cca18153f feat(sync): implement scoped sync architecture with ACK model and comprehensive tests (#11)
Implement a complete sync architecture overhaul (12 tasks) replacing the flat
WebSocket broadcast with scoped, acknowledged delivery:

**Backend (server.ts, db.ts, types.ts, index.ts):**
- Scoped connection registry: Map<tenant, Map<project, Set<ClientConnection>>>
- Hello handshake: WS clients identify tenant/project, server responds with scoped elements
- broadcastToScope() replaces global broadcast for element mutations
- broadcastWithAck() waits for browser ACK before returning syncedToCanvas status
- sync_version: monotonic counter per project, stamped on every mutation
- Delta sync v2: POST /api/elements/sync/v2 for incremental sync with version tracking
- GET /api/sync/version endpoint
- Honest syncedToCanvas + canvasStatus in all mutation responses
- Fixed silent try/catch in tenant switch verification

**Frontend (App.tsx):**
- ACK sending after every updateScene() with element verification
- Delta sync v2 integration in syncToBackend()
- Gap detection: triggers resync when sync_version gaps are detected
- lastSyncVersion tracking via refs + localStorage persistence

**Tests (40 new tests, 168 total):**
- db.test.ts: +11 tests for sync_version CRUD, scoping, getChangesSince
- ws.test.ts: +8 tests for hello handshake, scoped broadcast, ACK model
- api.test.ts: +10 tests for sync/v2, sync/version, canvasStatus responses
- helpers.test.ts: +11 tests for isImageElement, normalizeImageElement, restoreBindings
- canvas.spec.ts: +8 e2e tests including full ACK pipeline verification
- Fixed stale tenant state bug in api.test.ts beforeEach

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 23:44:17 +05:30

521 lines
16 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
initDb,
closeDb,
setElement,
getElement,
hasElement,
deleteElement,
getAllElements,
getElementCount,
clearElements,
queryElements,
searchElements,
getElementHistory,
getProjectHistory,
saveSnapshot,
getSnapshot,
listSnapshots,
ensureTenant,
setActiveTenant,
getActiveTenant,
getActiveTenantId,
listTenants,
createProject,
listProjects,
setActiveProject,
getActiveProject,
getActiveProjectId,
getDefaultProjectForTenant,
bulkReplaceElements,
getSetting,
setSetting,
incrementSyncVersion,
getCurrentSyncVersion,
getChangesSince,
} from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
beforeEach(() => {
dbPath = path.join(os.tmpdir(), `excalidraw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
// Reset module-level active tenant/project to 'default' which initDb() always creates
setActiveTenant('default');
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Element CRUD ────────────────────────────────────────────
describe('Element CRUD', () => {
it('setElement + getElement round-trips correctly', () => {
const el = makeElement({ id: 'e1' });
setElement('e1', el);
const fetched = getElement('e1');
expect(fetched).toBeDefined();
expect(fetched!.id).toBe('e1');
expect(fetched!.type).toBe('rectangle');
expect(fetched!.x).toBe(100);
expect(fetched!.y).toBe(200);
});
it('hasElement returns true for existing, false for missing', () => {
expect(hasElement('missing')).toBe(false);
setElement('exists', makeElement({ id: 'exists' }));
expect(hasElement('exists')).toBe(true);
});
it('getElement returns undefined for non-existent id', () => {
expect(getElement('nope')).toBeUndefined();
});
it('setElement updates an existing element and increments version', () => {
const el = makeElement({ id: 'e1' });
setElement('e1', el);
const updated = makeElement({ id: 'e1', x: 999 });
setElement('e1', updated);
const fetched = getElement('e1');
expect(fetched!.x).toBe(999);
});
it('deleteElement soft-deletes and returns true', () => {
setElement('del1', makeElement({ id: 'del1' }));
expect(deleteElement('del1')).toBe(true);
expect(getElement('del1')).toBeUndefined();
expect(hasElement('del1')).toBe(false);
});
it('deleteElement returns false for non-existent id', () => {
expect(deleteElement('nope')).toBe(false);
});
it('deleted element can be re-created', () => {
setElement('recr', makeElement({ id: 'recr' }));
deleteElement('recr');
expect(getElement('recr')).toBeUndefined();
setElement('recr', makeElement({ id: 'recr', x: 42 }));
expect(getElement('recr')!.x).toBe(42);
});
it('getAllElements returns all non-deleted elements', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
setElement('c', makeElement({ id: 'c' }));
deleteElement('b');
const all = getAllElements();
expect(all.length).toBe(2);
expect(all.map(e => e.id).sort()).toEqual(['a', 'c']);
});
it('getElementCount returns correct count', () => {
expect(getElementCount()).toBe(0);
setElement('x', makeElement({ id: 'x' }));
setElement('y', makeElement({ id: 'y' }));
expect(getElementCount()).toBe(2);
deleteElement('x');
expect(getElementCount()).toBe(1);
});
it('clearElements soft-deletes all and returns count', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
setElement('c', makeElement({ id: 'c' }));
const count = clearElements();
expect(count).toBe(3);
expect(getAllElements()).toEqual([]);
expect(getElementCount()).toBe(0);
});
it('clearElements on empty canvas returns 0', () => {
expect(clearElements()).toBe(0);
});
});
// ─── Query & Search ──────────────────────────────────────────
describe('queryElements', () => {
it('filters by type', () => {
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
setElement('r2', makeElement({ id: 'r2', type: 'rectangle' }));
const rects = queryElements('rectangle');
expect(rects.length).toBe(2);
expect(rects.every(e => e.type === 'rectangle')).toBe(true);
});
it('filters by arbitrary property', () => {
setElement('a', makeElement({ id: 'a', x: 10, y: 20 }));
setElement('b', makeElement({ id: 'b', x: 10, y: 99 }));
const results = queryElements(undefined, { x: 10 });
expect(results.length).toBe(2);
});
it('returns all when no filters', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
expect(queryElements().length).toBe(2);
});
});
describe('searchElements (FTS)', () => {
it('finds elements by label text', () => {
setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } }));
setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } }));
const results = searchElements('Hello');
expect(results.length).toBe(1);
expect(results[0]!.id).toBe('t1');
});
it('finds elements by type in FTS', () => {
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
const results = searchElements('rectangle');
expect(results.length).toBe(1);
expect(results[0]!.id).toBe('r1');
});
});
// ─── Version History ─────────────────────────────────────────
describe('Version History', () => {
it('records create and update operations', () => {
setElement('v1', makeElement({ id: 'v1' }));
setElement('v1', makeElement({ id: 'v1', x: 999 }));
const history = getElementHistory('v1');
expect(history.length).toBe(2);
expect(history[0]!.operation).toBe('update');
expect(history[1]!.operation).toBe('create');
});
it('records delete operation', () => {
setElement('v2', makeElement({ id: 'v2' }));
deleteElement('v2');
const history = getElementHistory('v2');
expect(history.length).toBe(2);
expect(history[0]!.operation).toBe('delete');
expect(history[1]!.operation).toBe('create');
});
it('getProjectHistory returns all operations across elements', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
deleteElement('a');
const history = getProjectHistory();
expect(history.length).toBe(3);
});
it('respects limit parameter', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
setElement('c', makeElement({ id: 'c' }));
const history = getProjectHistory(2);
expect(history.length).toBe(2);
});
});
// ─── Snapshots ───────────────────────────────────────────────
describe('Snapshots', () => {
it('save and retrieve a snapshot', () => {
const elements = [makeElement({ id: 's1' }), makeElement({ id: 's2' })];
saveSnapshot('snap1', elements);
const snapshot = getSnapshot('snap1');
expect(snapshot).toBeDefined();
expect(snapshot!.name).toBe('snap1');
expect(snapshot!.elements.length).toBe(2);
});
it('getSnapshot returns undefined for missing name', () => {
expect(getSnapshot('nonexistent')).toBeUndefined();
});
it('listSnapshots returns all snapshots with counts', () => {
saveSnapshot('snap-a', [makeElement()]);
saveSnapshot('snap-b', [makeElement(), makeElement()]);
const list = listSnapshots();
expect(list.length).toBe(2);
const snapB = list.find(s => s.name === 'snap-b');
expect(snapB!.elementCount).toBe(2);
});
it('saveSnapshot with same name overwrites', () => {
saveSnapshot('dup', [makeElement()]);
saveSnapshot('dup', [makeElement(), makeElement(), makeElement()]);
const snapshot = getSnapshot('dup');
expect(snapshot!.elements.length).toBe(3);
const list = listSnapshots();
expect(list.filter(s => s.name === 'dup').length).toBe(1);
});
});
// ─── Tenants ─────────────────────────────────────────────────
describe('Tenants', () => {
it('default tenant exists after initDb', () => {
const tenant = getActiveTenant();
expect(tenant).toBeDefined();
expect(tenant.id).toBe('default');
});
it('ensureTenant creates a new tenant', () => {
const t = ensureTenant('t1', 'Test Tenant', '/workspace/test');
expect(t.id).toBe('t1');
expect(t.name).toBe('Test Tenant');
expect(t.workspace_path).toBe('/workspace/test');
});
it('ensureTenant is idempotent', () => {
ensureTenant('t1', 'Test', '/path');
const t2 = ensureTenant('t1', 'Test', '/path');
expect(t2.id).toBe('t1');
const tenants = listTenants();
expect(tenants.filter(t => t.id === 't1').length).toBe(1);
});
it('setActiveTenant switches the active tenant', () => {
ensureTenant('t2', 'Tenant 2', '/t2');
setActiveTenant('t2');
expect(getActiveTenantId()).toBe('t2');
});
it('setActiveTenant throws for non-existent tenant', () => {
expect(() => setActiveTenant('no-such')).toThrow();
});
it('listTenants returns all tenants', () => {
ensureTenant('a', 'A', '/a');
ensureTenant('b', 'B', '/b');
const tenants = listTenants();
expect(tenants.length).toBeGreaterThanOrEqual(3); // default + a + b
});
});
// ─── Projects ────────────────────────────────────────────────
describe('Projects', () => {
it('default project exists', () => {
const project = getActiveProject();
expect(project).toBeDefined();
expect(project.id).toBe('default');
});
it('createProject creates and can be listed', () => {
const proj = createProject('My Project', 'A test project');
expect(proj.name).toBe('My Project');
const projects = listProjects();
expect(projects.some(p => p.name === 'My Project')).toBe(true);
});
it('setActiveProject changes the active project', () => {
const proj = createProject('Switch Me');
setActiveProject(proj.id);
expect(getActiveProjectId()).toBe(proj.id);
});
it('setActiveProject throws for non-existent project', () => {
expect(() => setActiveProject('fake')).toThrow();
});
it('elements are scoped to the active project', () => {
const proj1 = createProject('P1');
const proj2 = createProject('P2');
setActiveProject(proj1.id);
setElement('e1', makeElement({ id: 'e1' }));
setActiveProject(proj2.id);
setElement('e2', makeElement({ id: 'e2' }));
setActiveProject(proj1.id);
expect(getAllElements().length).toBe(1);
expect(getAllElements()[0]!.id).toBe('e1');
setActiveProject(proj2.id);
expect(getAllElements().length).toBe(1);
expect(getAllElements()[0]!.id).toBe('e2');
});
it('getDefaultProjectForTenant creates a default project if none exists', () => {
ensureTenant('orphan', 'Orphan', '/orphan');
const projId = getDefaultProjectForTenant('orphan');
expect(projId).toBe('orphan-default');
});
});
// ─── Settings ────────────────────────────────────────────────
describe('Settings', () => {
it('getSetting returns undefined for missing key', () => {
expect(getSetting('nonexistent')).toBeUndefined();
});
it('setSetting + getSetting round-trips', () => {
setSetting('theme', 'dark');
expect(getSetting('theme')).toBe('dark');
});
it('setSetting overwrites existing value', () => {
setSetting('key', 'val1');
setSetting('key', 'val2');
expect(getSetting('key')).toBe('val2');
});
});
// ─── Bulk Operations ─────────────────────────────────────────
describe('bulkReplaceElements', () => {
it('replaces all elements atomically', () => {
setElement('old1', makeElement({ id: 'old1' }));
setElement('old2', makeElement({ id: 'old2' }));
const newElements = [makeElement({ id: 'new1' }), makeElement({ id: 'new2' }), makeElement({ id: 'new3' })];
const count = bulkReplaceElements(newElements);
expect(count).toBe(3);
const all = getAllElements();
expect(all.length).toBe(3);
expect(all.map(e => e.id).sort()).toEqual(['new1', 'new2', 'new3']);
});
it('replaces with empty array clears all', () => {
setElement('x', makeElement({ id: 'x' }));
bulkReplaceElements([]);
expect(getAllElements()).toEqual([]);
});
});
// ─── Sync Version ───────────────────────────────────────────
describe('Sync Version', () => {
it('getCurrentSyncVersion returns 0 initially', () => {
expect(getCurrentSyncVersion()).toBe(0);
});
it('incrementSyncVersion increments and returns new version', () => {
expect(incrementSyncVersion()).toBe(1);
expect(incrementSyncVersion()).toBe(2);
expect(incrementSyncVersion()).toBe(3);
});
it('setElement increments sync_version', () => {
setElement('sv1', makeElement({ id: 'sv1' }));
expect(getCurrentSyncVersion()).toBeGreaterThan(0);
});
it('setElement returns sync_version', () => {
const sv = setElement('sv2', makeElement({ id: 'sv2' }));
expect(sv).toBeGreaterThan(0);
});
it('deleteElement increments sync_version', () => {
setElement('del-sv', makeElement({ id: 'del-sv' }));
const versionAfterCreate = getCurrentSyncVersion();
deleteElement('del-sv');
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreate);
});
it('clearElements increments sync_version', () => {
setElement('clr1', makeElement({ id: 'clr1' }));
setElement('clr2', makeElement({ id: 'clr2' }));
const versionAfterCreates = getCurrentSyncVersion();
clearElements();
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreates);
});
it('getChangesSince returns empty for version 0 when no elements', () => {
const changes = getChangesSince(0);
expect(changes).toEqual([]);
});
it('getChangesSince returns upserts after setElement', () => {
setElement('cs1', makeElement({ id: 'cs1' }));
setElement('cs2', makeElement({ id: 'cs2' }));
const changes = getChangesSince(0);
expect(changes.length).toBe(2);
expect(changes.every(c => c.action === 'upsert')).toBe(true);
});
it('getChangesSince returns delete entries', () => {
setElement('csd1', makeElement({ id: 'csd1' }));
deleteElement('csd1');
const changes = getChangesSince(0);
const deleteChange = changes.find(c => c.action === 'delete');
expect(deleteChange).toBeDefined();
});
it('getChangesSince filters by version', () => {
const sv1 = setElement('fv1', makeElement({ id: 'fv1' }));
setElement('fv2', makeElement({ id: 'fv2' }));
const changes = getChangesSince(sv1);
expect(changes.length).toBe(1);
expect(changes[0]!.id).toBe('fv2');
});
it('sync_version is scoped per project', () => {
const proj1 = createProject('SV-P1');
const proj2 = createProject('SV-P2');
setActiveProject(proj1.id);
setElement('sp1', makeElement({ id: 'sp1' }));
const sv1 = getCurrentSyncVersion(proj1.id);
setActiveProject(proj2.id);
setElement('sp2', makeElement({ id: 'sp2' }));
setElement('sp3', makeElement({ id: 'sp3' }));
const sv2 = getCurrentSyncVersion(proj2.id);
// Each project tracks its own sync_version independently
expect(sv1).toBeGreaterThan(0);
expect(sv2).toBeGreaterThan(0);
// P2 had more mutations so its version should be higher than P1's
expect(sv2).toBeGreaterThan(sv1);
});
});