Files
excalidraw-mcp-sentinel/tests/backend/api.test.ts
T
Maxime Roy (new.blacc)andGitHub 1166ea5b3f chore: release v1.0.5 (#10)
feat(projects): project management UI, sync countdown, fix project switching
2026-04-06 13:18:03 +02:00

870 lines
31 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant, getActiveProjectId, getElementCountForProject } 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;
// Dynamic import to ensure DB is initialized before module-level code in server.ts runs
let app: any;
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(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-api-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
// Reset module-level active tenant/project to 'default' (may be stale from previous test)
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Health ──────────────────────────────────────────────────
describe('GET /health', () => {
it('returns healthy status', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy');
expect(res.body).toHaveProperty('elements_count');
expect(res.body).toHaveProperty('timestamp');
});
});
// ─── Elements CRUD ───────────────────────────────────────────
describe('GET /api/elements', () => {
it('returns empty list initially', async () => {
const res = await request(app).get('/api/elements');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.elements).toEqual([]);
expect(res.body.count).toBe(0);
});
it('returns elements after creation', async () => {
setElement('e1', makeElement({ id: 'e1' }));
setElement('e2', makeElement({ id: 'e2' }));
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(2);
});
});
describe('POST /api/elements', () => {
it('creates an element and returns it', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 10, y: 20, width: 100, height: 50 });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.element.type).toBe('rectangle');
expect(res.body.element.x).toBe(10);
expect(res.body.element).toHaveProperty('id');
});
it('accepts a custom id', async () => {
const res = await request(app)
.post('/api/elements')
.send({ id: 'custom-id', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 });
expect(res.body.element.id).toBe('custom-id');
});
it('rejects invalid element type', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'invalid-type', x: 0, y: 0 });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects missing required fields', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle' });
expect(res.status).toBe(400);
});
});
describe('GET /api/elements/:id', () => {
it('returns element by id', async () => {
setElement('find-me', makeElement({ id: 'find-me', type: 'diamond' }));
const res = await request(app).get('/api/elements/find-me');
expect(res.status).toBe(200);
expect(res.body.element.id).toBe('find-me');
expect(res.body.element.type).toBe('diamond');
});
it('returns 404 for missing element', async () => {
const res = await request(app).get('/api/elements/nonexistent');
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
});
});
describe('PUT /api/elements/:id', () => {
it('updates an existing element', async () => {
setElement('up1', makeElement({ id: 'up1', x: 0 }));
const res = await request(app)
.put('/api/elements/up1')
.send({ x: 500, y: 600 });
expect(res.status).toBe(200);
expect(res.body.element.x).toBe(500);
expect(res.body.element.y).toBe(600);
});
it('returns 404 for non-existent element', async () => {
const res = await request(app)
.put('/api/elements/missing')
.send({ x: 1 });
expect(res.status).toBe(404);
});
});
describe('DELETE /api/elements/:id', () => {
it('deletes an existing element', async () => {
setElement('del1', makeElement({ id: 'del1' }));
const res = await request(app).delete('/api/elements/del1');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const getRes = await request(app).get('/api/elements/del1');
expect(getRes.status).toBe(404);
});
it('returns 404 for non-existent element', async () => {
const res = await request(app).delete('/api/elements/missing');
expect(res.status).toBe(404);
});
});
describe('DELETE /api/elements/clear', () => {
it('clears all elements', async () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
const listRes = await request(app).get('/api/elements');
expect(listRes.body.count).toBe(0);
});
it('returns 0 count when already empty', async () => {
const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.body.count).toBe(0);
});
});
// ─── Batch Create ────────────────────────────────────────────
describe('POST /api/elements/batch', () => {
it('creates multiple elements at once', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ type: 'ellipse', x: 200, y: 200, width: 80, height: 80 },
{ type: 'text', x: 50, y: 50, text: 'Hello' },
],
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(3);
expect(res.body.elements.length).toBe(3);
});
it('rejects non-array input', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: 'not-an-array' });
expect(res.status).toBe(400);
});
it('resolves arrow bindings between batch elements', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
{ id: 'box1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'box2', type: 'rectangle', x: 300, y: 0, width: 100, height: 50 },
{ id: 'arr1', type: 'arrow', x: 0, y: 0, start: { id: 'box1' }, end: { id: 'box2' } },
],
});
expect(res.status).toBe(200);
const arrow = res.body.elements.find((e: any) => e.id === 'arr1');
expect(arrow).toBeDefined();
expect(arrow.points).toBeDefined();
expect(arrow.startBinding).toBeDefined();
expect(arrow.endBinding).toBeDefined();
});
});
// ─── Search ──────────────────────────────────────────────────
describe('GET /api/elements/search', () => {
it('filters by type query param', async () => {
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
const res = await request(app).get('/api/elements/search?type=rectangle');
expect(res.body.count).toBe(1);
expect(res.body.elements[0].type).toBe('rectangle');
});
it('full-text search via q param', async () => {
setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } }));
setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } }));
const res = await request(app).get('/api/elements/search?q=Hello');
expect(res.body.count).toBe(1);
expect(res.body.elements[0].id).toBe('t1');
});
});
// ─── Sync ────────────────────────────────────────────────────
describe('POST /api/elements/sync', () => {
it('replaces all elements from frontend', async () => {
setElement('old', makeElement({ id: 'old' }));
const res = await request(app)
.post('/api/elements/sync')
.send({
elements: [
{ id: 'new1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 },
{ id: 'new2', type: 'ellipse', x: 50, y: 50, width: 20, height: 20 },
],
timestamp: new Date().toISOString(),
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
const listRes = await request(app).get('/api/elements');
expect(listRes.body.count).toBe(2);
expect(listRes.body.elements.map((e: any) => e.id).sort()).toEqual(['new1', 'new2']);
});
it('rejects non-array elements', async () => {
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: 'nope', timestamp: new Date().toISOString() });
expect(res.status).toBe(400);
});
});
// ─── Snapshots ───────────────────────────────────────────────
describe('Snapshots API', () => {
it('POST creates and GET lists snapshots', async () => {
setElement('se1', makeElement({ id: 'se1' }));
const createRes = await request(app)
.post('/api/snapshots')
.send({ name: 'my-snap' });
expect(createRes.status).toBe(200);
expect(createRes.body.name).toBe('my-snap');
expect(createRes.body.elementCount).toBe(1);
const listRes = await request(app).get('/api/snapshots');
expect(listRes.body.count).toBe(1);
});
it('GET /api/snapshots/:name returns a specific snapshot', async () => {
setElement('s1', makeElement({ id: 's1' }));
await request(app).post('/api/snapshots').send({ name: 'get-snap' });
const res = await request(app).get('/api/snapshots/get-snap');
expect(res.status).toBe(200);
expect(res.body.snapshot.name).toBe('get-snap');
});
it('GET /api/snapshots/:name returns 404 for missing', async () => {
const res = await request(app).get('/api/snapshots/nonexistent');
expect(res.status).toBe(404);
});
it('POST rejects missing name', async () => {
const res = await request(app).post('/api/snapshots').send({});
expect(res.status).toBe(400);
});
});
// ─── Tenants API ─────────────────────────────────────────────
describe('Tenants API', () => {
it('GET /api/tenants returns tenant list', async () => {
const res = await request(app).get('/api/tenants');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.tenants)).toBe(true);
expect(res.body.tenants.length).toBeGreaterThanOrEqual(1);
});
it('GET /api/tenant/active returns current tenant', async () => {
const res = await request(app).get('/api/tenant/active');
expect(res.status).toBe(200);
expect(res.body.tenant.id).toBe('default');
});
it('PUT /api/tenant/active switches tenant', async () => {
const { ensureTenant } = await import('../../src/db.js');
ensureTenant('switch-test', 'Switch Test', '/test');
const res = await request(app)
.put('/api/tenant/active')
.send({ tenantId: 'switch-test' });
expect(res.status).toBe(200);
expect(res.body.tenant.id).toBe('switch-test');
});
it('PUT /api/tenant/active rejects missing tenantId', async () => {
const res = await request(app).put('/api/tenant/active').send({});
expect(res.status).toBe(400);
});
});
// ─── Settings API ────────────────────────────────────────────
describe('Settings API', () => {
it('GET returns null for missing key', async () => {
const res = await request(app).get('/api/settings/missing_key');
expect(res.status).toBe(200);
expect(res.body.value).toBeNull();
});
it('PUT + GET round-trips a value', async () => {
await request(app)
.put('/api/settings/my_key')
.send({ value: 'my_value' });
const res = await request(app).get('/api/settings/my_key');
expect(res.body.value).toBe('my_value');
});
it('PUT rejects missing value', async () => {
const res = await request(app)
.put('/api/settings/no_val')
.send({});
expect(res.status).toBe(400);
});
});
// ─── Sync Status ─────────────────────────────────────────────
describe('GET /api/sync/status', () => {
it('returns sync status', async () => {
const res = await request(app).get('/api/sync/status');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('elementCount');
expect(res.body).toHaveProperty('memoryUsage');
expect(res.body).toHaveProperty('websocketClients');
});
});
// ─── Tenant-scoped via X-Tenant-Id header ────────────────────
describe('Tenant-scoped requests via X-Tenant-Id', () => {
it('elements are isolated per tenant', async () => {
const { ensureTenant } = await import('../../src/db.js');
ensureTenant('tenant-a', 'A', '/a');
ensureTenant('tenant-b', 'B', '/b');
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'tenant-a')
.send({ type: 'rectangle', x: 0, y: 0, width: 10, height: 10 });
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'tenant-b')
.send({ type: 'ellipse', x: 0, y: 0, width: 10, height: 10 });
const resA = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'tenant-a');
expect(resA.body.count).toBe(1);
expect(resA.body.elements[0].type).toBe('rectangle');
const resB = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'tenant-b');
expect(resB.body.count).toBe(1);
expect(resB.body.elements[0].type).toBe('ellipse');
});
});
// ─── Sync Version ───────────────────────────────────────────
describe('GET /api/sync/version', () => {
it('returns syncVersion 0 initially', async () => {
const res = await request(app).get('/api/sync/version');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.syncVersion).toBe(0);
});
it('syncVersion increases after element creation', async () => {
await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
const res = await request(app).get('/api/sync/version');
expect(res.status).toBe(200);
expect(res.body.syncVersion).toBeGreaterThan(0);
});
});
// ─── Delta Sync v2 ──────────────────────────────────────────
describe('POST /api/elements/sync/v2', () => {
it('returns currentSyncVersion and empty serverChanges', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 0, changes: [] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('currentSyncVersion');
expect(typeof res.body.currentSyncVersion).toBe('number');
expect(Array.isArray(res.body.serverChanges)).toBe(true);
expect(res.body.serverChanges.length).toBe(0);
});
it('applies upsert changes', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [
{
id: 'sv2-1',
action: 'upsert',
element: { id: 'sv2-1', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
},
],
});
expect(res.status).toBe(200);
expect(res.body.appliedCount).toBe(1);
const getRes = await request(app).get('/api/elements/sv2-1');
expect(getRes.status).toBe(200);
expect(getRes.body.element.id).toBe('sv2-1');
});
it('applies delete changes', async () => {
setElement('sv2-del', makeElement({ id: 'sv2-del' }));
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [{ id: 'sv2-del', action: 'delete' }],
});
expect(res.status).toBe(200);
expect(res.body.appliedCount).toBe(1);
const getRes = await request(app).get('/api/elements/sv2-del');
expect(getRes.status).toBe(404);
});
it('returns server changes since lastSyncVersion', async () => {
setElement('sv1', makeElement({ id: 'sv1' }));
setElement('sv2', makeElement({ id: 'sv2' }));
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 0, changes: [] });
expect(res.status).toBe(200);
expect(res.body.serverChanges.length).toBeGreaterThanOrEqual(2);
const ids = res.body.serverChanges.map((c: any) => c.id);
expect(ids).toContain('sv1');
expect(ids).toContain('sv2');
});
it('rejects non-number lastSyncVersion', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 'bad', changes: [] });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
// ─── canvasStatus in mutation responses ─────────────────────
describe('canvasStatus in mutation responses', () => {
it('POST /api/elements includes syncedToCanvas and canvasStatus', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
expect(res.status).toBe(200);
expect(typeof res.body.syncedToCanvas).toBe('boolean');
expect(res.body.syncedToCanvas).toBe(false);
expect(res.body.canvasStatus).toBeDefined();
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
expect(res.body.canvasStatus).toHaveProperty('reason');
expect(res.body.canvasStatus).toHaveProperty('scope');
});
it('PUT /api/elements/:id includes canvasStatus', async () => {
setElement('cs-put', makeElement({ id: 'cs-put', x: 0 }));
const res = await request(app)
.put('/api/elements/cs-put')
.send({ x: 100 });
expect(res.status).toBe(200);
expect(typeof res.body.syncedToCanvas).toBe('boolean');
expect(res.body.canvasStatus).toBeDefined();
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
expect(res.body.canvasStatus).toHaveProperty('reason');
expect(res.body.canvasStatus).toHaveProperty('scope');
});
it('POST /api/elements/batch includes canvasStatus', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
],
});
expect(res.status).toBe(200);
expect(typeof res.body.syncedToCanvas).toBe('boolean');
expect(res.body.canvasStatus).toBeDefined();
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
expect(res.body.canvasStatus).toHaveProperty('reason');
expect(res.body.canvasStatus).toHaveProperty('scope');
});
});
// Regression: textAlign/verticalAlign/containerId must survive REST round-trip
// These fields were silently stripped by Zod before the fix (ElementSharedFieldsSchema
// did not declare them, so .parse() dropped them).
describe('Text alignment fields — REST round-trip regression', () => {
it('POST /api/elements preserves textAlign and verticalAlign', async () => {
const res = await request(app)
.post('/api/elements')
.send({
type: 'text',
x: 10, y: 20, width: 100, height: 30,
text: 'Hello',
textAlign: 'center',
verticalAlign: 'middle',
});
expect(res.status).toBe(200);
const el = res.body.element;
expect(el.textAlign).toBe('center');
expect(el.verticalAlign).toBe('middle');
});
it('POST /api/elements preserves containerId on bound text', async () => {
// Create container first
const containerRes = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: 200, height: 100 });
expect(containerRes.status).toBe(200);
const containerId = containerRes.body.element?.id;
expect(containerId).toBeTruthy();
// Create bound text referencing the container
const textRes = await request(app)
.post('/api/elements')
.send({
type: 'text',
x: 10, y: 10, width: 180, height: 20,
text: 'Title',
textAlign: 'center',
verticalAlign: 'top',
containerId,
});
expect(textRes.status).toBe(200);
const textEl = textRes.body.element;
expect(textEl.containerId).toBe(containerId);
expect(textEl.textAlign).toBe('center');
expect(textEl.verticalAlign).toBe('top');
});
it('PUT /api/elements/:id preserves textAlign on update', async () => {
const createRes = await request(app)
.post('/api/elements')
.send({ type: 'text', x: 0, y: 0, width: 100, height: 30, text: 'Hi', textAlign: 'left' });
expect(createRes.status).toBe(200);
const id = createRes.body.element?.id;
expect(id).toBeTruthy();
const updateRes = await request(app)
.put(`/api/elements/${id}`)
.send({ id, type: 'text', x: 0, y: 0, textAlign: 'center' });
expect(updateRes.status).toBe(200);
expect(updateRes.body.element?.textAlign).toBe('center');
});
});
// ─── Projects ─────────────────────────────────────────────────
describe('GET /api/projects', () => {
it('returns the default project and marks it active', async () => {
const res = await request(app).get('/api/projects');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.projects)).toBe(true);
expect(res.body.projects.length).toBeGreaterThanOrEqual(1);
expect(res.body.activeProjectId).toBeTruthy();
const active = res.body.projects.find((p: any) => p.id === res.body.activeProjectId);
expect(active).toBeDefined();
});
});
describe('POST /api/projects', () => {
it('creates a new project and returns it', async () => {
const res = await request(app)
.post('/api/projects')
.send({ name: 'My Diagram', description: 'test desc' });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.project.name).toBe('My Diagram');
expect(res.body.project.description).toBe('test desc');
expect(res.body.project.id).toBeTruthy();
});
it('returns 400 when name is missing', async () => {
const res = await request(app).post('/api/projects').send({});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('returns 400 when name is blank', async () => {
const res = await request(app).post('/api/projects').send({ name: ' ' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('new project appears in GET /api/projects list', async () => {
await request(app).post('/api/projects').send({ name: 'Alpha' });
await request(app).post('/api/projects').send({ name: 'Beta' });
const res = await request(app).get('/api/projects');
const names = res.body.projects.map((p: any) => p.name);
expect(names).toContain('Alpha');
expect(names).toContain('Beta');
});
});
describe('PUT /api/project/active', () => {
it('switches the active project', async () => {
const created = await request(app)
.post('/api/projects')
.send({ name: 'Switch Target' });
const newId = created.body.project.id;
const res = await request(app)
.put('/api/project/active')
.send({ projectId: newId });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.project.id).toBe(newId);
// DB state reflects the switch
expect(getActiveProjectId()).toBe(newId);
});
it('returns 400 when projectId is missing', async () => {
const res = await request(app).put('/api/project/active').send({});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('returns 400 for a non-existent projectId', async () => {
const res = await request(app)
.put('/api/project/active')
.send({ projectId: 'does-not-exist' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
// ─── Project switch preserves elements ──────────────────────
describe('Project switch round-trip — elements survive', () => {
it('elements saved in project A persist after switching to B and back', async () => {
// Create project "dude"
const dudeRes = await request(app).post('/api/projects').send({ name: 'dude' });
const dudeId = dudeRes.body.project.id;
const defaultId = getActiveProjectId(); // save original
// Switch to "dude"
await request(app).put('/api/project/active').send({ projectId: dudeId });
expect(getActiveProjectId()).toBe(dudeId);
// Draw 2 elements in "dude"
const el1 = makeElement({ id: 'dude-rect-1', type: 'rectangle', x: 10, y: 10, width: 100, height: 50 });
const el2 = makeElement({ id: 'dude-rect-2', type: 'rectangle', x: 200, y: 200, width: 120, height: 80 });
await request(app).post('/api/elements').send(el1);
await request(app).post('/api/elements').send(el2);
// Verify 2 elements in "dude"
const dudeElems1 = await request(app).get('/api/elements');
expect(dudeElems1.body.elements.length).toBe(2);
// Switch to "default"
await request(app).put('/api/project/active').send({ projectId: defaultId });
expect(getActiveProjectId()).toBe(defaultId);
// "default" should have 0 elements (fresh DB)
const defaultElems = await request(app).get('/api/elements');
expect(defaultElems.body.elements.length).toBe(0);
// Switch back to "dude"
await request(app).put('/api/project/active').send({ projectId: dudeId });
expect(getActiveProjectId()).toBe(dudeId);
// "dude" should still have the 2 elements
const dudeElems2 = await request(app).get('/api/elements');
expect(dudeElems2.body.elements.length).toBe(2);
const ids = dudeElems2.body.elements.map((e: any) => e.id);
expect(ids).toContain('dude-rect-1');
expect(ids).toContain('dude-rect-2');
});
it('elements in different projects are isolated', async () => {
// Create two projects
const projA = await request(app).post('/api/projects').send({ name: 'Project A' });
const projB = await request(app).post('/api/projects').send({ name: 'Project B' });
const aId = projA.body.project.id;
const bId = projB.body.project.id;
// Add element to Project A
await request(app).put('/api/project/active').send({ projectId: aId });
await request(app).post('/api/elements').send(
makeElement({ id: 'a-only', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 })
);
// Add element to Project B
await request(app).put('/api/project/active').send({ projectId: bId });
await request(app).post('/api/elements').send(
makeElement({ id: 'b-only', type: 'diamond', x: 0, y: 0, width: 50, height: 50 })
);
// Verify isolation
const bElems = await request(app).get('/api/elements');
expect(bElems.body.elements.length).toBe(1);
expect(bElems.body.elements[0].id).toBe('b-only');
await request(app).put('/api/project/active').send({ projectId: aId });
const aElems = await request(app).get('/api/elements');
expect(aElems.body.elements.length).toBe(1);
expect(aElems.body.elements[0].id).toBe('a-only');
});
});
describe('DELETE /api/projects/:id', () => {
it('deletes a non-active project', async () => {
const created = await request(app).post('/api/projects').send({ name: 'To Delete' });
const id = created.body.project.id;
const res = await request(app).delete(`/api/projects/${id}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.projectId).toBe(id);
const list = await request(app).get('/api/projects');
const ids = list.body.projects.map((p: any) => p.id);
expect(ids).not.toContain(id);
});
it('cascades and deletes elements belonging to the project', async () => {
const created = await request(app).post('/api/projects').send({ name: 'With Elements' });
const id = created.body.project.id;
// Switch to new project and add an element
await request(app).put('/api/project/active').send({ projectId: id });
await request(app).post('/api/elements').send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
expect(getElementCountForProject(id)).toBe(1);
// Switch back to default before deleting
const defaultId = getActiveProjectId() === id
? (await request(app).get('/api/projects')).body.projects.find((p: any) => p.id !== id)?.id
: getActiveProjectId();
await request(app).put('/api/project/active').send({ projectId: defaultId });
await request(app).delete(`/api/projects/${id}`);
expect(getElementCountForProject(id)).toBe(0);
});
it('refuses to delete the active project', async () => {
// Create a second project so the "last project" guard doesn't fire first
await request(app).post('/api/projects').send({ name: 'Second' });
const activeId = getActiveProjectId();
const res = await request(app).delete(`/api/projects/${activeId}`);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/active/);
});
it('refuses to delete the last project', async () => {
// Only default project exists — try to delete it (it is also active, so both guards fire)
const activeId = getActiveProjectId();
const res = await request(app).delete(`/api/projects/${activeId}`);
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('returns 400 for a non-existent project', async () => {
const res = await request(app).delete('/api/projects/ghost-id');
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});