From ce7bc2cde958ce2f4bc39242d1c8e957bd03cf96 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Tue, 24 Feb 2026 19:25:42 -0500 Subject: [PATCH] db created, added tests -- tentative --- README.md | 29 ++++++- backend/db/index.js | 13 +++ backend/db/migrate.js | 30 +++++++ backend/db/notes.dao.js | 54 ++++++++++++ backend/db/seed.js | 40 +++++++++ backend/package-lock.json | 148 ++++++++++++++++++++++++++++++++ backend/package.json | 5 +- backend/routes/notes.routes.js | 12 +-- backend/tests/Notes.test.js | 41 ++++----- backend/tests/notes.dao.test.js | 113 ++++++++++++++++++++++++ 10 files changed, 447 insertions(+), 38 deletions(-) create mode 100644 backend/db/index.js create mode 100644 backend/db/migrate.js create mode 100644 backend/db/notes.dao.js create mode 100644 backend/db/seed.js create mode 100644 backend/tests/notes.dao.test.js diff --git a/README.md b/README.md index 5042b72..cdb66cf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # kongruity -kongruity pulls in the unstructured artifacts of the creative-engineering process -- to-dos, action items, agile tickets, Jira comment threads, retrospective notes -- and synthesizes them into semantically coherent, prioritized clusters ready for implementation planning. +kongruity pulls in the unstructured artifacts of the creative-engineering process -- to-dos, action items, agile tickets, Jira comment threads, Slack threads, retrospective notes -- and synthesizes them into semantically coherent, prioritized clusters ready for implementation planning. In kongruity world, these artifacts are "sticky notes." A board full of them looks chaotic. With a click, an LLM analyzes their semantic meaning and groups them into thematic clusters, each with a descriptive header. @@ -21,6 +21,7 @@ Developers may swap in other LLM SDKs/APIs and alter prompt syntax in `backend/s ## Prerequisites - Node.js (v18 or later recommended) +- PostgreSQL (v14 or later recommended) - An [Anthropic API key](https://console.anthropic.com/) (or other LLM platform, for clustering) - A [Voyage AI API key](https://dash.voyageai.com/) (for embedding-based evaluation) @@ -41,12 +42,34 @@ The backend expects a `.env` file in the `backend/` directory. This file is git- cat > backend/.env << 'EOF' ANTHROPIC_API_KEY= (or other LLM platform key) VOYAGEAI_API_KEY= +DATABASE_URL=postgresql://:@localhost:5432/kongruity EOF ``` -Replace placeholder values with your actual keys. +Replace placeholder values with your actual keys and database credentials. -### 3. Install dependencies +### 3. Set up the database + +Create a PostgreSQL database for the project: + +```bash +createdb kongruity +``` + +Run the migration to create tables: + +```bash +cd backend +npm run db:migrate +``` + +Seed the database with the sample sticky notes: + +```bash +npm run db:seed +``` + +### 4. Install dependencies ```bash cd backend diff --git a/backend/db/index.js b/backend/db/index.js new file mode 100644 index 0000000..5128ce1 --- /dev/null +++ b/backend/db/index.js @@ -0,0 +1,13 @@ +import pg from 'pg'; + +const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, +}); + +export const query = (text, params) => pool.query(text, params); + +export const getPool = () => pool; + +export const close = () => pool.end(); + +export default pool; diff --git a/backend/db/migrate.js b/backend/db/migrate.js new file mode 100644 index 0000000..6f453ea --- /dev/null +++ b/backend/db/migrate.js @@ -0,0 +1,30 @@ +import 'dotenv/config'; +import { query, close } from './index.js'; + +const up = ` +CREATE TABLE IF NOT EXISTS notes ( + id VARCHAR(64) PRIMARY KEY, + text TEXT NOT NULL, + x INTEGER DEFAULT 0, + y INTEGER DEFAULT 0, + author VARCHAR(128), + color VARCHAR(32) DEFAULT 'yellow', + source_meta JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +`; + +const run = async () => { + try { + await query(up); + console.log('Migration complete — notes table ready.'); + } catch (err) { + console.error('Migration failed:', err.message); + process.exit(1); + } finally { + await close(); + } +}; + +run(); diff --git a/backend/db/notes.dao.js b/backend/db/notes.dao.js new file mode 100644 index 0000000..1818e83 --- /dev/null +++ b/backend/db/notes.dao.js @@ -0,0 +1,54 @@ +import { query } from './index.js'; + +export const getAllNotes = async () => { + const { rows } = await query( + 'SELECT id, text, x, y, author, color FROM notes ORDER BY id' + ); + return rows; +}; + +export const getNoteById = async (id) => { + const { rows } = await query( + 'SELECT id, text, x, y, author, color FROM notes WHERE id = $1', + [id] + ); + return rows[0] || null; +}; + +export const createNote = async (note) => { + const { rows } = await query( + `INSERT INTO notes (id, text, x, y, author, color) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, text, x, y, author, color`, + [note.id, note.text, note.x ?? 0, note.y ?? 0, note.author, note.color ?? 'yellow'] + ); + return rows[0]; +}; + +export const createNotes = async (notes) => { + const values = []; + const placeholders = []; + + notes.forEach((note, i) => { + const offset = i * 6; + placeholders.push( + `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6})` + ); + values.push( + note.id, + note.text, + note.x ?? 0, + note.y ?? 0, + note.author, + note.color ?? 'yellow' + ); + }); + + const { rows } = await query( + `INSERT INTO notes (id, text, x, y, author, color) + VALUES ${placeholders.join(', ')} + RETURNING id, text, x, y, author, color`, + values + ); + return rows; +}; diff --git a/backend/db/seed.js b/backend/db/seed.js new file mode 100644 index 0000000..84935fb --- /dev/null +++ b/backend/db/seed.js @@ -0,0 +1,40 @@ +import 'dotenv/config'; +import { readFile } from 'fs/promises'; +import { query, close } from './index.js'; + +const NOTES_PATH = new URL('../data/notes.json', import.meta.url); + +const run = async () => { + try { + const raw = await readFile(NOTES_PATH, 'utf-8'); + const notes = JSON.parse(raw); + + const insertQuery = ` + INSERT INTO notes (id, text, x, y, author, color) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO NOTHING + `; + + let inserted = 0; + for (const note of notes) { + const result = await query(insertQuery, [ + note.id, + note.text, + note.x, + note.y, + note.author, + note.color, + ]); + inserted += result.rowCount; + } + + console.log(`Seed complete — ${inserted} notes inserted (${notes.length - inserted} already existed).`); + } catch (err) { + console.error('Seed failed:', err.message); + process.exit(1); + } finally { + await close(); + } +}; + +run(); diff --git a/backend/package-lock.json b/backend/package-lock.json index 3874b22..08b4700 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "pg": "^8.18.0", "voyageai": "^0.1.0" }, "devDependencies": { @@ -2297,6 +2298,96 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2346,6 +2437,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -2680,6 +2810,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3250,6 +3389,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/backend/package.json b/backend/package.json index 819d058..481200d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,13 +7,16 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", - "test": "vitest run" + "test": "vitest run", + "db:migrate": "node db/migrate.js", + "db:seed": "node db/seed.js" }, "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "pg": "^8.18.0", "voyageai": "^0.1.0" }, "devDependencies": { diff --git a/backend/routes/notes.routes.js b/backend/routes/notes.routes.js index 36e72e1..b2c7232 100644 --- a/backend/routes/notes.routes.js +++ b/backend/routes/notes.routes.js @@ -1,18 +1,12 @@ import { Router } from 'express'; -import { readFile } from 'fs/promises'; +import { getAllNotes } from '../db/notes.dao.js'; import { clusterNotes } from '../services/clustering.service.js'; const router = Router(); -const DATA_PATH = new URL('../data/notes.json', import.meta.url); - -const loadNotes = async () => { - const raw = await readFile(DATA_PATH, 'utf-8'); - return JSON.parse(raw); -}; router.get('/', async (req, res) => { try { - const notes = await loadNotes(); + const notes = await getAllNotes(); res.json(notes); } catch (err) { console.error(`Error loading notes: ${err}`); @@ -22,7 +16,7 @@ router.get('/', async (req, res) => { router.post('/cluster', async (req, res) => { try { - const notes = await loadNotes(); + const notes = await getAllNotes(); const result = await clusterNotes(notes); res.json(result); } catch (err) { diff --git a/backend/tests/Notes.test.js b/backend/tests/Notes.test.js index 776703f..47a1bfd 100644 --- a/backend/tests/Notes.test.js +++ b/backend/tests/Notes.test.js @@ -2,16 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import request from 'supertest'; import app from '../app.js'; +vi.mock('../db/notes.dao.js', () => ({ + getAllNotes: vi.fn(), +})); + vi.mock('../services/clustering.service.js', () => ({ clusterNotes: vi.fn(), })); -vi.mock('fs/promises', () => ({ - readFile: vi.fn(), -})); - +import { getAllNotes } from '../db/notes.dao.js'; import { clusterNotes } from '../services/clustering.service.js'; -import { readFile } from 'fs/promises'; const MOCK_NOTES = [ { id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' }, @@ -31,7 +31,7 @@ describe('GET /v1/notes', () => { }); it('should return 200 and an array of notes', async () => { - readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); + getAllNotes.mockResolvedValue(MOCK_NOTES); const res = await request(app).get('/v1/notes'); @@ -41,7 +41,7 @@ describe('GET /v1/notes', () => { }); it('should return notes with expected properties', async () => { - readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); + getAllNotes.mockResolvedValue(MOCK_NOTES); const res = await request(app).get('/v1/notes'); const note = res.body[0]; @@ -54,8 +54,8 @@ describe('GET /v1/notes', () => { expect(note).toHaveProperty('color'); }); - it('should return 500 when the data file cannot be read', async () => { - readFile.mockRejectedValue(new Error('ENOENT: file not found')); + it('should return 500 when the database query fails', async () => { + getAllNotes.mockRejectedValue(new Error('connection refused')); const res = await request(app).get('/v1/notes'); @@ -63,15 +63,6 @@ describe('GET /v1/notes', () => { expect(res.body).toHaveProperty('error'); expect(res.body.error).toBe('Failed to load notes'); }); - - it('should return 500 when data file is invalid JSON', async () => { - readFile.mockResolvedValue('{ this is not valid json }'); - - const res = await request(app).get('/v1/notes'); - - expect(res.status).toBe(500); - expect(res.body).toHaveProperty('error'); - }); }); describe('POST /v1/notes/cluster', () => { @@ -81,7 +72,7 @@ describe('POST /v1/notes/cluster', () => { }); it('should return 200 and clustered results', async () => { - readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); + getAllNotes.mockResolvedValue(MOCK_NOTES); clusterNotes.mockResolvedValue(MOCK_CLUSTERS); const res = await request(app).post('/v1/notes/cluster'); @@ -91,7 +82,7 @@ describe('POST /v1/notes/cluster', () => { }); it('should return clusters with the expected shape (label, noteIds)', async () => { - readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); + getAllNotes.mockResolvedValue(MOCK_NOTES); clusterNotes.mockResolvedValue(MOCK_CLUSTERS); const res = await request(app).post('/v1/notes/cluster'); @@ -104,7 +95,7 @@ describe('POST /v1/notes/cluster', () => { }); it('should pass the loaded notes to clusterNotes', async () => { - readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); + getAllNotes.mockResolvedValue(MOCK_NOTES); clusterNotes.mockResolvedValue(MOCK_CLUSTERS); await request(app).post('/v1/notes/cluster'); @@ -114,7 +105,7 @@ describe('POST /v1/notes/cluster', () => { }); it('should return 500 when clusterNotes (API call) fails', async () => { - readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); + getAllNotes.mockResolvedValue(MOCK_NOTES); clusterNotes.mockRejectedValue(new Error('LLM API error')); const res = await request(app).post('/v1/notes/cluster'); @@ -124,12 +115,12 @@ describe('POST /v1/notes/cluster', () => { expect(res.body.error).toMatch(/^Clustering failed/); }); - it('should return 500 when the data file cannot be read', async () => { - readFile.mockRejectedValue(new Error('ENOENT: file not found')); + it('should return 500 when the database query fails', async () => { + getAllNotes.mockRejectedValue(new Error('connection refused')); const res = await request(app).post('/v1/notes/cluster'); expect(res.status).toBe(500); expect(res.body).toHaveProperty('error'); }); -}); \ No newline at end of file +}); diff --git a/backend/tests/notes.dao.test.js b/backend/tests/notes.dao.test.js new file mode 100644 index 0000000..8bda4ba --- /dev/null +++ b/backend/tests/notes.dao.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockQuery } = vi.hoisted(() => ({ + mockQuery: vi.fn(), +})); + +vi.mock('../db/index.js', () => ({ + query: mockQuery, +})); + +import { getAllNotes, getNoteById, createNote, createNotes } from '../db/notes.dao.js'; + +const MOCK_ROWS = [ + { id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' }, + { id: 'note_002', text: 'Login flow is broken on mobile', x: 214, y: 281, author: 'user_9', color: 'yellow' }, +]; + +describe('notes.dao', () => { + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getAllNotes', () => { + it('should return all notes ordered by id', async () => { + mockQuery.mockResolvedValue({ rows: MOCK_ROWS }); + + const result = await getAllNotes(); + + expect(result).toEqual(MOCK_ROWS); + expect(mockQuery).toHaveBeenCalledWith( + 'SELECT id, text, x, y, author, color FROM notes ORDER BY id' + ); + }); + + it('should return an empty array when no notes exist', async () => { + mockQuery.mockResolvedValue({ rows: [] }); + + const result = await getAllNotes(); + + expect(result).toEqual([]); + }); + + it('should propagate database errors', async () => { + mockQuery.mockRejectedValue(new Error('connection refused')); + + await expect(getAllNotes()).rejects.toThrow('connection refused'); + }); + }); + + describe('getNoteById', () => { + it('should return a single note when found', async () => { + mockQuery.mockResolvedValue({ rows: [MOCK_ROWS[0]] }); + + const result = await getNoteById('note_001'); + + expect(result).toEqual(MOCK_ROWS[0]); + expect(mockQuery).toHaveBeenCalledWith( + 'SELECT id, text, x, y, author, color FROM notes WHERE id = $1', + ['note_001'] + ); + }); + + it('should return null when note is not found', async () => { + mockQuery.mockResolvedValue({ rows: [] }); + + const result = await getNoteById('note_999'); + + expect(result).toBeNull(); + }); + }); + + describe('createNote', () => { + it('should insert a note and return it', async () => { + const input = { id: 'note_003', text: 'Export fails', x: 100, y: 200, author: 'user_1', color: 'blue' }; + mockQuery.mockResolvedValue({ rows: [input] }); + + const result = await createNote(input); + + expect(result).toEqual(input); + expect(mockQuery).toHaveBeenCalledOnce(); + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO notes'); + expect(params).toEqual(['note_003', 'Export fails', 100, 200, 'user_1', 'blue']); + }); + + it('should use defaults for missing x, y, and color', async () => { + const input = { id: 'note_004', text: 'Needs fixing', author: 'user_2' }; + mockQuery.mockResolvedValue({ rows: [{ ...input, x: 0, y: 0, color: 'yellow' }] }); + + await createNote(input); + + const params = mockQuery.mock.calls[0][1]; + expect(params[2]).toBe(0); + expect(params[3]).toBe(0); + expect(params[5]).toBe('yellow'); + }); + }); + + describe('createNotes', () => { + it('should batch-insert multiple notes and return them', async () => { + mockQuery.mockResolvedValue({ rows: MOCK_ROWS }); + + const result = await createNotes(MOCK_ROWS); + + expect(result).toEqual(MOCK_ROWS); + expect(mockQuery).toHaveBeenCalledOnce(); + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO notes'); + expect(params).toHaveLength(12); + }); + }); +});