feat(projects): project management UI, sync countdown, fix project switching
870 lines
31 KiB
TypeScript
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);
|
|
});
|
|
});
|