diff --git a/.gitignore b/.gitignore index 7ad0d1c..7b57aac 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ playwright-report/ coverage/ docs/* -!docs/screenshots/ \ No newline at end of file +!docs/screenshots/ + +# Local planning + reports — not part of the codebase +plans/ \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd13a15..e82d727 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -357,6 +357,28 @@ function App(): JSX.Element { } } + // Sync the local "last seen" baseline after applying a WS broadcast so + // syncToBackend's delta detection treats those elements as already in sync + // and doesn't re-upload them. Without this, every MCP-driven create fires a + // follow-up sync/v2 POST per element. Phase 2 (deterministic ids) means + // those POSTs are no-ops on the row, but they still bump sync_version and + // generate broadcast traffic. Hygiene, not correctness. + const applyWsBaseline = ( + sceneAfter: readonly any[], + upserted: ServerElement[], + deleted: string[], + syncVersion?: number + ): void => { + for (const el of upserted) lastSyncedElementsRef.current.set(el.id, el) + for (const id of deleted) lastSyncedElementsRef.current.delete(id) + lastSyncedHashRef.current = computeElementHash(sceneAfter) + if (typeof syncVersion === 'number') { + lastSyncVersionRef.current = syncVersion + // lastReceivedSyncVersionRef is already updated above, before the switch. + localStorage.setItem('excalidraw-last-sync-version', String(syncVersion)) + } + } + const handleWebSocketMessage = async (data: WebSocketMessage): Promise => { // Gap detection (Task 12): if a message carries sync_version, check for gaps if (data.sync_version !== undefined && typeof data.sync_version === 'number') { @@ -418,10 +440,11 @@ function App(): JSX.Element { } const scene = api.getSceneElements() const landed = scene.some(s => s.id === data.element!.id) + applyWsBaseline(scene, [data.element], [], data.sync_version) sendAck(data.msgId, landed ? 'applied' : 'failed', landed ? 1 : 0, 1) } break - + case 'element_updated': if (data.element) { const cleanedUpdatedElement = cleanElementForExcalidraw(data.element) @@ -433,6 +456,7 @@ function App(): JSX.Element { elements: updatedElements, captureUpdate: CaptureUpdateAction.NEVER }) + applyWsBaseline(api.getSceneElements(), [data.element], [], data.sync_version) sendAck(data.msgId, 'applied', 1, 1) } break @@ -444,6 +468,7 @@ function App(): JSX.Element { elements: filteredElements, captureUpdate: CaptureUpdateAction.NEVER }) + applyWsBaseline(api.getSceneElements(), [], [data.elementId], data.sync_version) sendAck(data.msgId, 'applied', 1, 1) } break @@ -472,6 +497,7 @@ function App(): JSX.Element { const expectedIds = data.elements.map((e: ServerElement) => e.id) const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length const status = landedCount === expectedIds.length ? 'applied' : landedCount > 0 ? 'partial' : 'failed' + applyWsBaseline(scene, data.elements, [], data.sync_version) sendAck(data.msgId, status, landedCount, expectedIds.length) } break diff --git a/scripts/dedupe-elements.cjs b/scripts/dedupe-elements.cjs new file mode 100644 index 0000000..055a045 --- /dev/null +++ b/scripts/dedupe-elements.cjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * One-time cleanup of duplicate elements caused by retried batch_create_elements. + * + * Mirrors the server-side fingerprint in src/server.ts (deriveContentId). + * Groups by fingerprint, keeps the lowest sync_version (the original), + * soft-deletes the rest, bumps project sync_version per survivor so connected + * browsers reconcile via delta sync. + * + * Usage: + * node scripts/dedupe-elements.cjs # dry-run, all projects + * node scripts/dedupe-elements.cjs --execute # apply + * node scripts/dedupe-elements.cjs --project foo # restrict + * node scripts/dedupe-elements.cjs --db /path/excalidraw.db + */ + +const path = require('node:path'); +const crypto = require('node:crypto'); + +function fingerprint(projectId, type, x, y, w, h, text, startRefId, endRefId) { + const input = [ + projectId, + type, + Math.round(x || 0), + Math.round(y || 0), + Math.round(w || 0), + Math.round(h || 0), + text || '', + startRefId || '', + endRefId || '' + ].join('|'); + return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12); +} + +function elementText(el) { + if (el && el.label && el.label.text) return String(el.label.text); + if (el && typeof el.text === 'string') return el.text; + return ''; +} + +function elementStartRef(el) { return (el && el.start && el.start.id) || ''; } +function elementEndRef(el) { return (el && el.end && el.end.id) || ''; } + +// Pure: returns { groups, totalDups } +// groups :: { [fp]: [{id, sync_version, data}, ...] } +function bucketByFingerprint(rows, projectId) { + const groups = {}; + for (const row of rows) { + let data; + try { data = JSON.parse(row.data); } catch { continue; } + const fp = fingerprint( + projectId, + data.type || row.type, + data.x, data.y, data.width, data.height, + elementText(data), + elementStartRef(data), + elementEndRef(data) + ); + (groups[fp] = groups[fp] || []).push(row); + } + let dups = 0; + for (const fp of Object.keys(groups)) if (groups[fp].length > 1) dups += groups[fp].length - 1; + return { groups, totalDups: dups }; +} + +function dedupeProject(db, projectId, execute) { + const rows = db.prepare( + 'SELECT id, type, data, sync_version FROM elements WHERE project_id = ? AND is_deleted = 0' + ).all(projectId); + const { groups, totalDups } = bucketByFingerprint(rows, projectId); + const dupGroups = Object.values(groups).filter(g => g.length > 1); + + const plan = []; + for (const group of dupGroups) { + // Keep lowest sync_version (original) + const sorted = group.slice().sort((a, b) => a.sync_version - b.sync_version); + const keep = sorted[0]; + const drop = sorted.slice(1); + plan.push({ keepId: keep.id, dropIds: drop.map(r => r.id) }); + } + + if (execute && plan.length > 0) { + const txn = db.transaction(() => { + const now = new Date().toISOString(); + for (const { keepId, dropIds } of plan) { + // Bump global project sync_version once per survivor and stamp it on + // both the survivor and the soft-deleted dups so getChangesSince emits + // both events to clients (delete dups, upsert survivor). + db.prepare('UPDATE projects SET sync_version = sync_version + 1 WHERE id = ?').run(projectId); + const newSv = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(projectId).sync_version; + for (const id of dropIds) { + db.prepare( + 'UPDATE elements SET is_deleted = 1, sync_version = ?, updated_at = ? WHERE id = ?' + ).run(newSv, now, id); + } + db.prepare( + 'UPDATE elements SET sync_version = ?, updated_at = ? WHERE id = ?' + ).run(newSv, now, keepId); + } + }); + txn(); + } + + return { + projectId, + total: rows.length, + dupGroups: dupGroups.length, + softDeleted: plan.reduce((acc, p) => acc + p.dropIds.length, 0), + plan + }; +} + +function parseArgs(argv) { + const args = { execute: false, db: null, project: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--execute') args.execute = true; + else if (a === '--db') args.db = argv[++i]; + else if (a === '--project') args.project = argv[++i]; + } + return args; +} + +function main() { + const Database = require('better-sqlite3'); + const args = parseArgs(process.argv.slice(2)); + const dbPath = args.db || process.env.EXCALIDRAW_DB_PATH || path.resolve(process.cwd(), 'data/excalidraw.db'); + const db = new Database(dbPath); + + const projects = args.project + ? db.prepare('SELECT id, name FROM projects WHERE id = ?').all(args.project) + : db.prepare('SELECT id, name FROM projects ORDER BY id').all(); + + if (projects.length === 0) { + console.log(`No matching project. db=${dbPath}`); + process.exit(1); + } + + console.log(`db: ${dbPath}`); + console.log(`mode: ${args.execute ? 'EXECUTE (will mutate)' : 'dry-run (no changes)'}`); + console.log(''); + + let totalSoftDeleted = 0; + for (const p of projects) { + const res = dedupeProject(db, p.id, args.execute); + console.log(`[${p.id}] "${p.name}": active=${res.total}, dup-groups=${res.dupGroups}, would-soft-delete=${res.softDeleted}`); + if (res.plan.length > 0 && !args.execute) { + for (const { keepId, dropIds } of res.plan.slice(0, 5)) { + console.log(` keep ${keepId} drop ${dropIds.join(',')}`); + } + if (res.plan.length > 5) console.log(` ... +${res.plan.length - 5} more groups`); + } + totalSoftDeleted += res.softDeleted; + } + + console.log(''); + console.log(`TOTAL: ${args.execute ? 'soft-deleted' : 'would soft-delete'} ${totalSoftDeleted} rows across ${projects.length} project(s)`); + if (!args.execute && totalSoftDeleted > 0) { + console.log('Re-run with --execute to apply.'); + } +} + +if (require.main === module) { + try { main(); } catch (e) { console.error(e); process.exit(1); } +} + +module.exports = { fingerprint, bucketByFingerprint, dedupeProject }; diff --git a/src/index.ts b/src/index.ts index ab9b3b2..5754f21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -237,7 +237,9 @@ async function syncToCanvas(operation: string, data: any): Promise { +// id is optional: when absent, the server derives a deterministic content-based +// id (see deriveContentId in src/server.ts). Same content → same id → upsert. +async function createElementOnCanvas(elementData: Omit & { id?: string }): Promise { const result = await syncToCanvas('create', elementData); return result ?? null; } @@ -255,7 +257,7 @@ async function deleteElementOnCanvas(elementId: string): Promise { } // Helper to sync batch creation to canvas -async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise { +async function batchCreateElementsOnCanvas(elementsData: Array & { id?: string }>): Promise { const result = await syncToCanvas('batch_create', elementsData); return result ?? null; } @@ -1060,10 +1062,12 @@ const callToolHandler = async (request: CallToolRequest) => { logger.info('Creating element via MCP', { type: params.type }); const { startElementId, endElementId, id: customId, ...elementProps } = params; - const id = customId || generateId(); const normalizedFont = normalizeFontFamily(elementProps.fontFamily); - const element: ServerElement = { - id, + // No id minted here — server derives a content-based deterministic id + // (see deriveContentId in src/server.ts) so re-issued create with same + // content upserts the existing row instead of duplicating it. + const element: Omit & { id?: string } = { + ...(customId ? { id: customId } : {}), ...elementProps, fontFamily: normalizedFont ?? USER_PREFS.fontFamily, roughness: elementProps.roughness ?? USER_PREFS.roughness, @@ -1083,7 +1087,7 @@ const callToolHandler = async (request: CallToolRequest) => { } // Convert text to label format for Excalidraw - const excalidrawElement = convertTextToLabel(element); + const excalidrawElement = convertTextToLabel(element as ServerElement); // Create element directly on HTTP server (no local storage) const canvasResponse = await createElementOnCanvas(excalidrawElement); @@ -1093,17 +1097,18 @@ const callToolHandler = async (request: CallToolRequest) => { } const synced = canvasResponse.syncedToCanvas ?? false; + const assignedId = canvasResponse.element?.id ?? customId ?? '(server-assigned)'; logger.info('Element created via MCP', { - id: excalidrawElement.id, + id: assignedId, type: excalidrawElement.type, synced, canvasStatus: canvasResponse.canvasStatus }); - const statusEmoji = synced ? '✅' : '⚠️'; + const statusEmoji = synced ? '✅' : 'ℹ️'; const statusText = synced - ? 'Synced to canvas and confirmed by browser' - : `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`; + ? 'Visible on canvas now' + : `Persisted. Canvas browser will pick up on reconnect or refresh (${canvasResponse.canvasStatus?.reason ?? 'no_clients'}).`; return { content: [{ @@ -1148,7 +1153,7 @@ const callToolHandler = async (request: CallToolRequest) => { return { content: [{ type: 'text', - text: `Element updated successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${synced ? '✅ Synced to canvas and confirmed' : `⚠️ Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`}` + text: `Element updated successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${synced ? '✅ Visible on canvas now' : `ℹ️ Persisted. Canvas browser will pick up on reconnect or refresh (${canvasResponse.canvasStatus?.reason ?? 'no_clients'}).`}` }] }; } @@ -1565,14 +1570,16 @@ const callToolHandler = async (request: CallToolRequest) => { const params = z.object({ elements: z.array(ElementSchema) }).parse(args); logger.info('Batch creating elements via MCP', { count: params.elements.length }); - const createdElements: ServerElement[] = []; + type CreatePayload = Omit & { id?: string }; + const createdElements: CreatePayload[] = []; for (const elementData of params.elements) { const { startElementId, endElementId, id: customId, ...elementProps } = elementData; - const id = customId || generateId(); const normalizedFont = normalizeFontFamily(elementProps.fontFamily); - const element: ServerElement = { - id, + // Same as create_element: omit id when caller didn't supply one so + // the server can derive a content-based deterministic id. + const element: CreatePayload = { + ...(customId ? { id: customId } : {}), ...elementProps, fontFamily: normalizedFont ?? USER_PREFS.fontFamily, roughness: elementProps.roughness ?? USER_PREFS.roughness, @@ -1591,7 +1598,7 @@ const callToolHandler = async (request: CallToolRequest) => { (element as any).points = [[0, 0], [100, 0]]; } - const excalidrawElement = convertTextToLabel(element); + const excalidrawElement = convertTextToLabel(element as ServerElement); createdElements.push(excalidrawElement); } @@ -1615,10 +1622,10 @@ const callToolHandler = async (request: CallToolRequest) => { canvasStatus: result.canvasStatus }); - const statusEmoji = result.syncedToCanvas ? '✅' : '⚠️'; + const statusEmoji = result.syncedToCanvas ? '✅' : 'ℹ️'; const statusText = result.syncedToCanvas - ? 'All elements synced to canvas and confirmed by browser' - : `Canvas sync not confirmed (${result.canvasStatus?.reason ?? 'unknown'})`; + ? 'All elements visible on canvas now' + : `Persisted. Canvas browser will pick up on reconnect or refresh (${result.canvasStatus?.reason ?? 'no_clients'}).`; return { content: [{ diff --git a/src/server.ts b/src/server.ts index e23918d..f277ca0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import express, { type Application, Request, Response, NextFunction } from 'expr import cors from 'cors'; import { WebSocketServer } from 'ws'; import { createServer } from 'http'; +import { createHash } from 'node:crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -51,6 +52,53 @@ app.use(express.static(staticDir)); // Also serve frontend assets app.use(express.static(path.join(__dirname, '../dist/frontend'))); +// Compute a deterministic ID from element content. Same project + type + +// position + size + text → same id. Makes element creation idempotent without +// idempotency keys: a re-issued create with identical content matches the +// existing row by id and becomes an upsert (version bump). Coords rounded to +// nearest int to absorb sub-pixel drift across calls. Empty text/refs hashed +// as "" — unbound shapes still get stable ids. start/end ref ids are part of +// the input so bound arrows in a batch (which share placeholder coords until +// resolveArrowBindings runs) don't collapse to one row. +export function deriveContentId( + projectId: string, + type: string, + x: number, + y: number, + width: number, + height: number, + text: string | null | undefined, + startRefId?: string | null, + endRefId?: string | null +): string { + const input = [ + projectId, + type, + Math.round(x || 0), + Math.round(y || 0), + Math.round(width || 0), + Math.round(height || 0), + text || '', + startRefId || '', + endRefId || '' + ].join('|'); + return createHash('sha256').update(input).digest('hex').slice(0, 12); +} + +function elementText(el: any): string { + if (el?.label?.text) return String(el.label.text); + if (typeof el?.text === 'string') return el.text; + return ''; +} + +function elementStartRef(el: any): string { + return el?.start?.id ?? ''; +} + +function elementEndRef(el: any): string { + return el?.end?.id ?? ''; +} + // Resolve tenant from X-Tenant-Id header to a projectId override. // Returns undefined when header is absent (browser requests), falling back to global state. function resolveTenantProject(req: Request): string | undefined { @@ -447,7 +495,17 @@ app.post('/api/elements', async (req: Request, res: Response) => { const params = CreateElementSchema.parse(req.body); logger.info('Creating element via API', { type: params.type }); - const id = params.id || generateId(); + const id = params.id || deriveContentId( + projId ?? store.getActiveProjectId(), + params.type, + params.x, + params.y, + params.width ?? 0, + params.height ?? 0, + elementText(params), + elementStartRef(params), + elementEndRef(params) + ); const normalizedFont = normalizeFontFamily(params.fontFamily); const element: ServerElement = { id, @@ -830,9 +888,20 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => { const createdElements: ServerElement[] = []; + const effectiveProjId = projId ?? store.getActiveProjectId(); elementsToCreate.forEach(elementData => { const params = CreateElementSchema.parse(elementData); - const id = params.id || generateId(); + const id = params.id || deriveContentId( + effectiveProjId, + params.type, + params.x, + params.y, + params.width ?? 0, + params.height ?? 0, + elementText(params), + elementStartRef(params), + elementEndRef(params) + ); const normalizedFont = normalizeFontFamily(params.fontFamily); const element: ServerElement = { id, diff --git a/tests/backend/dedupe-script.test.ts b/tests/backend/dedupe-script.test.ts new file mode 100644 index 0000000..da28cf4 --- /dev/null +++ b/tests/backend/dedupe-script.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; +import { initDb, closeDb, setActiveTenant } from '../../src/db.js'; +// CJS interop — script intentionally exports its core logic +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fingerprint, dedupeProject, bucketByFingerprint } = require('../../scripts/dedupe-elements.cjs'); +import Database from 'better-sqlite3'; + +let dbPath: string; +let db: any; + +function insertElement(rawDb: any, projectId: string, id: string, type: string, x: number, y: number, w: number, h: number, text: string | null, syncVersion: number) { + const data: any = { type, x, y, width: w, height: h }; + if (text) data.text = text; + const now = new Date().toISOString(); + rawDb.prepare( + 'INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, is_deleted, sync_version) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 0, ?)' + ).run(id, projectId, type, JSON.stringify(data), text, now, now, syncVersion); +} + +beforeEach(() => { + dbPath = path.join(os.tmpdir(), `excalidraw-dedupe-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + initDb(dbPath); + setActiveTenant('default'); + closeDb(); + db = new Database(dbPath); +}); + +afterEach(() => { + try { db?.close(); } catch {} + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +describe('dedupe script', () => { + it('fingerprint matches deriveContentId format (12 hex chars)', () => { + const fp = fingerprint('proj', 'rectangle', 100, 100, 200, 100, 'X', '', ''); + expect(fp).toHaveLength(12); + expect(fp).toMatch(/^[a-f0-9]+$/); + }); + + it('bucketByFingerprint groups same content', () => { + const rows = [ + { id: 'a', sync_version: 1, type: 'rectangle', data: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50, text: 'Foo' }) }, + { id: 'b', sync_version: 2, type: 'rectangle', data: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50, text: 'Foo' }) }, + { id: 'c', sync_version: 3, type: 'rectangle', data: JSON.stringify({ type: 'rectangle', x: 100, y: 0, width: 50, height: 50, text: 'Foo' }) }, + ]; + const { groups, totalDups } = bucketByFingerprint(rows, 'p'); + expect(totalDups).toBe(1); + const buckets = Object.values(groups) as any[]; + expect(buckets.find(g => g.length > 1).length).toBe(2); + }); + + it('dry-run reports counts without mutating', () => { + insertElement(db, 'default', 'a', 'rectangle', 0, 0, 50, 50, 'X', 1); + insertElement(db, 'default', 'b', 'rectangle', 0, 0, 50, 50, 'X', 2); + insertElement(db, 'default', 'c', 'rectangle', 100, 0, 50, 50, 'Y', 3); + + const res = dedupeProject(db, 'default', false); + expect(res.dupGroups).toBe(1); + expect(res.softDeleted).toBe(1); + // Confirm nothing changed + const stillActive = db.prepare('SELECT COUNT(*) as c FROM elements WHERE project_id = ? AND is_deleted = 0').get('default').c; + expect(stillActive).toBe(3); + }); + + it('execute soft-deletes dupes, keeps lowest sync_version, bumps survivor', () => { + insertElement(db, 'default', 'a', 'rectangle', 0, 0, 50, 50, 'X', 1); + insertElement(db, 'default', 'b', 'rectangle', 0, 0, 50, 50, 'X', 2); + insertElement(db, 'default', 'c', 'rectangle', 0, 0, 50, 50, 'X', 3); + + const res = dedupeProject(db, 'default', true); + expect(res.softDeleted).toBe(2); + + const aliveIds = db.prepare('SELECT id FROM elements WHERE project_id = ? AND is_deleted = 0').all('default').map((r: any) => r.id); + expect(aliveIds).toEqual(['a']); + + const projSv = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get('default').sync_version; + const aSv = db.prepare('SELECT sync_version FROM elements WHERE id = ?').get('a').sync_version; + expect(projSv).toBeGreaterThan(0); + // Survivor stamped with the latest project sync_version so connected + // browsers pick it up via getChangesSince. + expect(aSv).toBe(projSv); + }); + + it('re-run after execute is a no-op', () => { + insertElement(db, 'default', 'a', 'rectangle', 0, 0, 50, 50, 'X', 1); + insertElement(db, 'default', 'b', 'rectangle', 0, 0, 50, 50, 'X', 2); + + dedupeProject(db, 'default', true); + const res2 = dedupeProject(db, 'default', true); + expect(res2.softDeleted).toBe(0); + expect(res2.dupGroups).toBe(0); + }); +}); diff --git a/tests/backend/idempotency.test.ts b/tests/backend/idempotency.test.ts new file mode 100644 index 0000000..25c59ab --- /dev/null +++ b/tests/backend/idempotency.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; +import { initDb, closeDb, setActiveTenant, getAllElements, ensureTenant, getDefaultProjectForTenant } from '../../src/db.js'; + +let dbPath: string; +let app: any; +let deriveContentId: (...args: any[]) => string; + +beforeEach(async () => { + dbPath = path.join(os.tmpdir(), `excalidraw-idem-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + initDb(dbPath); + setActiveTenant('default'); + const mod = await import('../../src/server.js'); + app = mod.default; + deriveContentId = mod.deriveContentId; +}); + +afterEach(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +describe('deriveContentId (pure)', () => { + it('is deterministic for identical inputs', () => { + const a = deriveContentId('p', 'rectangle', 100, 100, 200, 100, 'Foo', '', ''); + const b = deriveContentId('p', 'rectangle', 100, 100, 200, 100, 'Foo', '', ''); + expect(a).toBe(b); + expect(a).toHaveLength(12); + expect(a).toMatch(/^[a-f0-9]+$/); + }); + + it('changes when any input changes', () => { + const base = deriveContentId('p', 'rectangle', 100, 100, 200, 100, 'Foo', '', ''); + expect(deriveContentId('q', 'rectangle', 100, 100, 200, 100, 'Foo', '', '')).not.toBe(base); + expect(deriveContentId('p', 'ellipse', 100, 100, 200, 100, 'Foo', '', '')).not.toBe(base); + expect(deriveContentId('p', 'rectangle', 101, 100, 200, 100, 'Foo', '', '')).not.toBe(base); + expect(deriveContentId('p', 'rectangle', 100, 100, 200, 100, 'Bar', '', '')).not.toBe(base); + expect(deriveContentId('p', 'rectangle', 100, 100, 200, 100, 'Foo', 'a', '')).not.toBe(base); + expect(deriveContentId('p', 'rectangle', 100, 100, 200, 100, 'Foo', '', 'b')).not.toBe(base); + }); + + it('rounds sub-pixel coords', () => { + expect( + deriveContentId('p', 'rectangle', 100.4, 100.4, 200, 100, '', '', '') + ).toBe( + deriveContentId('p', 'rectangle', 100.3, 100.4, 200, 100, '', '', '') + ); + }); +}); + +describe('POST /api/elements idempotency', () => { + it('two identical creates → 1 row, same id', async () => { + const payload = { type: 'rectangle', x: 100, y: 100, width: 200, height: 100, text: 'Foo' }; + const r1 = await request(app).post('/api/elements').send(payload); + const r2 = await request(app).post('/api/elements').send(payload); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r1.body.element.id).toBe(r2.body.element.id); + const all = getAllElements(); + expect(all.filter(e => e.id === r1.body.element.id)).toHaveLength(1); + }); + + it('explicit id short-circuits content hash', async () => { + const r = await request(app) + .post('/api/elements') + .send({ id: 'custom-abc', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 }); + expect(r.body.element.id).toBe('custom-abc'); + }); + + it('position change → new id', async () => { + const a = await request(app).post('/api/elements').send({ type: 'rectangle', x: 100, y: 100, width: 50, height: 50 }); + const b = await request(app).post('/api/elements').send({ type: 'rectangle', x: 101, y: 100, width: 50, height: 50 }); + expect(a.body.element.id).not.toBe(b.body.element.id); + expect(getAllElements()).toHaveLength(2); + }); + + it('empty text element fingerprint stable', async () => { + const payload = { type: 'rectangle', x: 50, y: 50, width: 30, height: 30 }; + await request(app).post('/api/elements').send(payload); + await request(app).post('/api/elements').send(payload); + expect(getAllElements()).toHaveLength(1); + }); + + it('same-position different-text → different ids', async () => { + const r1 = await request(app).post('/api/elements').send({ type: 'rectangle', x: 100, y: 100, width: 50, height: 50, text: 'Foo' }); + const r2 = await request(app).post('/api/elements').send({ type: 'rectangle', x: 100, y: 100, width: 50, height: 50, text: 'Bar' }); + expect(r1.body.element.id).not.toBe(r2.body.element.id); + expect(getAllElements()).toHaveLength(2); + }); + + it('cross-project (tenant) isolation: same content → different ids', async () => { + ensureTenant('tenant-a', 'tenant-a', 'http:tenant-a'); + ensureTenant('tenant-b', 'tenant-b', 'http:tenant-b'); + const projA = getDefaultProjectForTenant('tenant-a'); + const projB = getDefaultProjectForTenant('tenant-b'); + expect(projA).not.toBe(projB); + + const payload = { type: 'rectangle', x: 100, y: 100, width: 50, height: 50, text: 'X' }; + const ra = await request(app).post('/api/elements').set('X-Tenant-Id', 'tenant-a').send(payload); + const rb = await request(app).post('/api/elements').set('X-Tenant-Id', 'tenant-b').send(payload); + expect(ra.body.element.id).not.toBe(rb.body.element.id); + }); +}); + +describe('POST /api/elements/batch idempotency', () => { + it('two identical batches → N rows, not 2N', async () => { + const elements = [ + { type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }, + { type: 'ellipse', x: 200, y: 200, width: 80, height: 80 }, + { type: 'rectangle', x: 400, y: 0, width: 60, height: 30, text: 'Hi' }, + ]; + await request(app).post('/api/elements/batch').send({ elements }); + await request(app).post('/api/elements/batch').send({ elements }); + expect(getAllElements()).toHaveLength(3); + }); + + it('bound arrows in batch get distinct ids (no collapse from placeholder coords)', async () => { + // 3 boxes + 3 arrows binding A→B, A→C, B→C; arrows have no x,y/points + // so server defaults coords. Without start/end refs in the fingerprint + // the 3 arrows would collapse to 1 row. + const elements = [ + { id: 'A', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }, + { id: 'B', type: 'rectangle', x: 300, y: 0, width: 100, height: 50 }, + { id: 'C', type: 'rectangle', x: 600, y: 0, width: 100, height: 50 }, + { type: 'arrow', x: 0, y: 0, start: { id: 'A' }, end: { id: 'B' } }, + { type: 'arrow', x: 0, y: 0, start: { id: 'A' }, end: { id: 'C' } }, + { type: 'arrow', x: 0, y: 0, start: { id: 'B' }, end: { id: 'C' } }, + ]; + const res = await request(app).post('/api/elements/batch').send({ elements }); + expect(res.status).toBe(200); + const arrows = getAllElements().filter(e => e.type === 'arrow'); + expect(arrows).toHaveLength(3); + const ids = new Set(arrows.map(a => a.id)); + expect(ids.size).toBe(3); + }); +});