From 3f77fdb11d6e82ca2c8f07adfc513fda4ae1fd7d Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 10:55:30 +0200 Subject: [PATCH] Fix Migration 30 code quality issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 (CRITICAL): Refactor test-carddav.js to import migrations - Import MIGRATIONS from server/db.js instead of duplicating SQL - Export MIGRATIONS array from server/db.js - Use better-sqlite3 to apply Migration 30 dynamically - Ensures future changes to Migration 30 are reflected in tests I1 (IMPORTANT): Add UNIQUE index on carddav_uid - Create idx_contacts_carddav_uid_unique partial index - Prevents duplicate CardDAV contacts per account+addressbook - WHERE clause excludes NULL values (manual contacts allowed) - Add test to verify unique constraint enforcement I3 (IMPORTANT): Rename cardav → carddav (RFC 6352 compliance) - Table: cardav_accounts → carddav_accounts - Table: cardav_addressbook_selection → carddav_addressbook_selection - Column: contacts.cardav_* → contacts.carddav_* - Index: idx_cardav_* → idx_carddav_* - Test file: test-cardav.js → test-carddav.js - Package.json: test:cardav → test:carddav - All test assertions updated All 25 tests pass. Co-Authored-By: Claude Opus 4.7 --- package.json | 4 +- server/db.js | 53 ++++--- test-cardav.js => test-carddav.js | 250 ++++++++++++++---------------- 3 files changed, 147 insertions(+), 160 deletions(-) rename test-cardav.js => test-carddav.js (61%) diff --git a/package.json b/package.json index 1706601..f904ce6 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", "test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js", "test:caldav": "node --experimental-sqlite test-caldav-sync.js", - "test:cardav": "node --experimental-sqlite test-cardav.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:cardav" + "test:carddav": "node --experimental-sqlite test-carddav.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/server/db.js b/server/db.js index 567419a..f09e81b 100644 --- a/server/db.js +++ b/server/db.js @@ -1082,33 +1082,33 @@ const MIGRATIONS = [ -- ======================================== -- CardDAV Accounts -- ======================================== - CREATE TABLE cardav_accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - cardav_url TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - last_sync TEXT, - UNIQUE(cardav_url, username) + CREATE TABLE carddav_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + carddav_url TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + last_sync TEXT, + UNIQUE(carddav_url, username) ); -- ======================================== -- CardDAV Addressbook Selection -- ======================================== - CREATE TABLE cardav_addressbook_selection ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL, - addressbook_url TEXT NOT NULL, + CREATE TABLE carddav_addressbook_selection ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + addressbook_url TEXT NOT NULL, addressbook_name TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(account_id, addressbook_url), - FOREIGN KEY(account_id) REFERENCES cardav_accounts(id) ON DELETE CASCADE + FOREIGN KEY(account_id) REFERENCES carddav_accounts(id) ON DELETE CASCADE ); - CREATE INDEX idx_cardav_addressbook_account - ON cardav_addressbook_selection(account_id, enabled); + CREATE INDEX idx_carddav_addressbook_account + ON carddav_addressbook_selection(account_id, enabled); -- ======================================== -- Extend Contacts Table for CardDAV @@ -1119,14 +1119,19 @@ const MIGRATIONS = [ ALTER TABLE contacts ADD COLUMN website TEXT; ALTER TABLE contacts ADD COLUMN photo TEXT; ALTER TABLE contacts ADD COLUMN nickname TEXT; - ALTER TABLE contacts ADD COLUMN cardav_account_id INTEGER - REFERENCES cardav_accounts(id) ON DELETE SET NULL; - ALTER TABLE contacts ADD COLUMN cardav_uid TEXT; - ALTER TABLE contacts ADD COLUMN cardav_addressbook_url TEXT; + ALTER TABLE contacts ADD COLUMN carddav_account_id INTEGER + REFERENCES carddav_accounts(id) ON DELETE SET NULL; + ALTER TABLE contacts ADD COLUMN carddav_uid TEXT; + ALTER TABLE contacts ADD COLUMN carddav_addressbook_url TEXT; - CREATE INDEX idx_contacts_cardav_uid ON contacts(cardav_uid); + CREATE INDEX idx_contacts_carddav_uid ON contacts(carddav_uid); CREATE INDEX idx_contacts_email ON contacts(email); + -- UNIQUE constraint for CardDAV UIDs (prevents duplicates per account+addressbook) + CREATE UNIQUE INDEX idx_contacts_carddav_uid_unique + ON contacts(carddav_account_id, carddav_addressbook_url, carddav_uid) + WHERE carddav_uid IS NOT NULL; + -- ======================================== -- Contact Phones (Multiple per Contact) -- ======================================== @@ -1351,4 +1356,4 @@ function transaction(fn) { init(); // auto-initialise when module is first imported -export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile }; +export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile, MIGRATIONS }; diff --git a/test-cardav.js b/test-carddav.js similarity index 61% rename from test-cardav.js rename to test-carddav.js index fc1ce76..53dc6ad 100644 --- a/test-cardav.js +++ b/test-carddav.js @@ -5,7 +5,8 @@ import { describe, it, before } from 'node:test'; import assert from 'node:assert/strict'; -import { DatabaseSync } from 'node:sqlite'; +import Database from 'better-sqlite3'; +import { MIGRATIONS } from './server/db.js'; const TEST_DB = ':memory:'; @@ -13,11 +14,12 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { let db; before(() => { - // Create in-memory DB - db = new DatabaseSync(TEST_DB); - db.exec('PRAGMA foreign_keys = ON;'); + // Create in-memory DB with better-sqlite3 to apply migrations + db = new Database(TEST_DB); + db.pragma('foreign_keys = ON'); - // Create base contacts table (from Migration 1) and family_user_id (Migration 23) + // Create minimal schema to satisfy Migration 30 dependencies + // Migration 30 expects: users table and contacts table to exist db.exec(` CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -40,106 +42,28 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { INSERT INTO users (username) VALUES ('testuser'); `); - // Apply Migration 30 - db.exec(` - -- CardDAV Accounts - CREATE TABLE cardav_accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - cardav_url TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - last_sync TEXT, - UNIQUE(cardav_url, username) - ); + // Find and apply Migration 30 from the MIGRATIONS array + const migration30 = MIGRATIONS.find(m => m.version === 30); + if (!migration30) { + throw new Error('Migration 30 not found in MIGRATIONS array'); + } - -- CardDAV Addressbook Selection - CREATE TABLE cardav_addressbook_selection ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL, - addressbook_url TEXT NOT NULL, - addressbook_name TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - UNIQUE(account_id, addressbook_url), - FOREIGN KEY(account_id) REFERENCES cardav_accounts(id) ON DELETE CASCADE - ); - - CREATE INDEX idx_cardav_addressbook_account - ON cardav_addressbook_selection(account_id, enabled); - - -- Extend Contacts Table - ALTER TABLE contacts ADD COLUMN organization TEXT; - ALTER TABLE contacts ADD COLUMN job_title TEXT; - ALTER TABLE contacts ADD COLUMN birthday TEXT; - ALTER TABLE contacts ADD COLUMN website TEXT; - ALTER TABLE contacts ADD COLUMN photo TEXT; - ALTER TABLE contacts ADD COLUMN nickname TEXT; - ALTER TABLE contacts ADD COLUMN cardav_account_id INTEGER - REFERENCES cardav_accounts(id) ON DELETE SET NULL; - ALTER TABLE contacts ADD COLUMN cardav_uid TEXT; - ALTER TABLE contacts ADD COLUMN cardav_addressbook_url TEXT; - - CREATE INDEX idx_contacts_cardav_uid ON contacts(cardav_uid); - CREATE INDEX idx_contacts_email ON contacts(email); - - -- Contact Phones - CREATE TABLE contact_phones ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contact_id INTEGER NOT NULL, - label TEXT, - value TEXT NOT NULL, - is_primary INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE - ); - - CREATE INDEX idx_contact_phones_contact ON contact_phones(contact_id); - CREATE INDEX idx_contact_phones_value ON contact_phones(value); - - -- Contact Emails - CREATE TABLE contact_emails ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contact_id INTEGER NOT NULL, - label TEXT, - value TEXT NOT NULL, - is_primary INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE - ); - - CREATE INDEX idx_contact_emails_contact ON contact_emails(contact_id); - CREATE INDEX idx_contact_emails_value ON contact_emails(value); - - -- Contact Addresses - CREATE TABLE contact_addresses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contact_id INTEGER NOT NULL, - label TEXT, - street TEXT, - city TEXT, - state TEXT, - postal_code TEXT, - country TEXT, - is_primary INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE - ); - - CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id); - `); + // Apply Migration 30 (it's a string, not a function) + db.exec(migration30.up); }); // ======================================== // Table Existence Tests // ======================================== - it('should create cardav_accounts table', () => { - const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cardav_accounts'").get(); - assert.ok(result, 'cardav_accounts table should exist'); + it('should create carddav_accounts table', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='carddav_accounts'").get(); + assert.ok(result, 'carddav_accounts table should exist'); }); - it('should create cardav_addressbook_selection table', () => { - const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cardav_addressbook_selection'").get(); - assert.ok(result, 'cardav_addressbook_selection table should exist'); + it('should create carddav_addressbook_selection table', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='carddav_addressbook_selection'").get(); + assert.ok(result, 'carddav_addressbook_selection table should exist'); }); it('should create contact_phones table', () => { @@ -171,18 +95,18 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { assert.ok(colNames.includes('website'), 'Should have website column'); assert.ok(colNames.includes('photo'), 'Should have photo column'); assert.ok(colNames.includes('nickname'), 'Should have nickname column'); - assert.ok(colNames.includes('cardav_account_id'), 'Should have cardav_account_id column'); - assert.ok(colNames.includes('cardav_uid'), 'Should have cardav_uid column'); - assert.ok(colNames.includes('cardav_addressbook_url'), 'Should have cardav_addressbook_url column'); + assert.ok(colNames.includes('carddav_account_id'), 'Should have carddav_account_id column'); + assert.ok(colNames.includes('carddav_uid'), 'Should have carddav_uid column'); + assert.ok(colNames.includes('carddav_addressbook_url'), 'Should have carddav_addressbook_url column'); }); // ======================================== // Index Tests // ======================================== - it('should create index on contacts.cardav_uid', () => { - const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contacts_cardav_uid'").get(); - assert.ok(result, 'Index on cardav_uid should exist'); + it('should create index on contacts.carddav_uid', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contacts_carddav_uid'").get(); + assert.ok(result, 'Index on carddav_uid should exist'); }); it('should create index on contacts.email', () => { @@ -215,42 +139,47 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { assert.ok(result, 'Index on contact_addresses.contact_id should exist'); }); + it('should create unique index on carddav_uid per account+addressbook', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contacts_carddav_uid_unique'").get(); + assert.ok(result, 'Unique index on carddav_uid should exist'); + }); + // ======================================== // UNIQUE Constraint Tests // ======================================== - it('should enforce UNIQUE(cardav_url, username) on cardav_accounts', () => { + it('should enforce UNIQUE(carddav_url, username) on carddav_accounts', () => { db.prepare(` - INSERT INTO cardav_accounts (name, cardav_url, username, password) + INSERT INTO carddav_accounts (name, carddav_url, username, password) VALUES (?, ?, ?, ?) - `).run('Test Account', 'https://cardav.example.com', 'user1', 'pass1'); + `).run('Test Account', 'https://carddav.example.com', 'user1', 'pass1'); - const account = db.prepare('SELECT * FROM cardav_accounts WHERE name = ?').get('Test Account'); + const account = db.prepare('SELECT * FROM carddav_accounts WHERE name = ?').get('Test Account'); assert.ok(account, 'Account should be inserted'); // Duplicate should fail assert.throws(() => { db.prepare(` - INSERT INTO cardav_accounts (name, cardav_url, username, password) + INSERT INTO carddav_accounts (name, carddav_url, username, password) VALUES (?, ?, ?, ?) - `).run('Duplicate', 'https://cardav.example.com', 'user1', 'pass2'); - }, 'UNIQUE constraint should prevent duplicate cardav_url+username'); + `).run('Duplicate', 'https://carddav.example.com', 'user1', 'pass2'); + }, 'UNIQUE constraint should prevent duplicate carddav_url+username'); }); it('should enforce UNIQUE(account_id, addressbook_url) on addressbook_selection', () => { - const accountId = db.prepare('SELECT id FROM cardav_accounts WHERE name = ?').get('Test Account').id; + const accountId = db.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Test Account').id; db.prepare(` - INSERT INTO cardav_addressbook_selection (account_id, addressbook_url, addressbook_name) + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name) VALUES (?, ?, ?) - `).run(accountId, 'https://cardav.example.com/addressbooks/main', 'Main Addressbook'); + `).run(accountId, 'https://carddav.example.com/addressbooks/main', 'Main Addressbook'); // Duplicate should fail assert.throws(() => { db.prepare(` - INSERT INTO cardav_addressbook_selection (account_id, addressbook_url, addressbook_name) + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name) VALUES (?, ?, ?) - `).run(accountId, 'https://cardav.example.com/addressbooks/main', 'Duplicate'); + `).run(accountId, 'https://carddav.example.com/addressbooks/main', 'Duplicate'); }, 'UNIQUE constraint should prevent duplicate account_id+addressbook_url'); }); @@ -259,17 +188,17 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { // ======================================== it('should CASCADE delete addressbook_selection when account deleted', () => { - const accountId = db.prepare('SELECT id FROM cardav_accounts WHERE name = ?').get('Test Account').id; + const accountId = db.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Test Account').id; // Verify addressbook exists - const beforeDelete = db.prepare('SELECT * FROM cardav_addressbook_selection WHERE account_id = ?').get(accountId); + const beforeDelete = db.prepare('SELECT * FROM carddav_addressbook_selection WHERE account_id = ?').get(accountId); assert.ok(beforeDelete, 'Addressbook selection should exist before delete'); // Delete account - db.prepare('DELETE FROM cardav_accounts WHERE id = ?').run(accountId); + db.prepare('DELETE FROM carddav_accounts WHERE id = ?').run(accountId); // Addressbook selection should be deleted - const afterDelete = db.prepare('SELECT * FROM cardav_addressbook_selection WHERE account_id = ?').get(accountId); + const afterDelete = db.prepare('SELECT * FROM carddav_addressbook_selection WHERE account_id = ?').get(accountId); assert.strictEqual(afterDelete, undefined, 'Addressbook selection should CASCADE delete'); }); @@ -369,34 +298,34 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { assert.strictEqual(addressesAfter.length, 0, 'Addresses should CASCADE delete'); }); - it('should SET NULL on contacts.cardav_account_id when account deleted', () => { + it('should SET NULL on contacts.carddav_account_id when account deleted', () => { // Create new account db.prepare(` - INSERT INTO cardav_accounts (name, cardav_url, username, password) + INSERT INTO carddav_accounts (name, carddav_url, username, password) VALUES (?, ?, ?, ?) `).run('iCloud', 'https://contacts.icloud.com', 'user@icloud.com', 'pass'); - const accountId = db.prepare('SELECT id FROM cardav_accounts WHERE name = ?').get('iCloud').id; + const accountId = db.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('iCloud').id; // Create contact linked to account db.prepare(` - INSERT INTO contacts (name, category, cardav_account_id, cardav_uid) + INSERT INTO contacts (name, category, carddav_account_id, carddav_uid) VALUES (?, ?, ?, ?) `).run('Alice Cooper', 'Sonstiges', accountId, 'urn:uuid:12345'); const contactId = db.prepare('SELECT id FROM contacts WHERE name = ?').get('Alice Cooper').id; // Verify link - const beforeDelete = db.prepare('SELECT cardav_account_id FROM contacts WHERE id = ?').get(contactId); - assert.strictEqual(beforeDelete.cardav_account_id, accountId, 'Contact should be linked to account'); + const beforeDelete = db.prepare('SELECT carddav_account_id FROM contacts WHERE id = ?').get(contactId); + assert.strictEqual(beforeDelete.carddav_account_id, accountId, 'Contact should be linked to account'); // Delete account - db.prepare('DELETE FROM cardav_accounts WHERE id = ?').run(accountId); + db.prepare('DELETE FROM carddav_accounts WHERE id = ?').run(accountId); // Contact should remain but link should be NULL const afterDelete = db.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId); assert.ok(afterDelete, 'Contact should still exist'); - assert.strictEqual(afterDelete.cardav_account_id, null, 'cardav_account_id should be SET NULL'); + assert.strictEqual(afterDelete.carddav_account_id, null, 'carddav_account_id should be SET NULL'); }); // ======================================== @@ -406,15 +335,15 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { it('should handle enabled/disabled addressbook selection', () => { // Create account db.prepare(` - INSERT INTO cardav_accounts (name, cardav_url, username, password) + INSERT INTO carddav_accounts (name, carddav_url, username, password) VALUES (?, ?, ?, ?) `).run('Nextcloud', 'https://nextcloud.example.com/dav', 'user@example.com', 'pass'); - const accountId = db.prepare('SELECT id FROM cardav_accounts WHERE name = ?').get('Nextcloud').id; + const accountId = db.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Nextcloud').id; // Add addressbooks db.prepare(` - INSERT INTO cardav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) VALUES (?, ?, ?, ?), (?, ?, ?, ?) `).run( accountId, 'https://nextcloud.example.com/dav/contacts/private', 'Private', 1, @@ -422,12 +351,12 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { ); // Query enabled only - const enabled = db.prepare('SELECT * FROM cardav_addressbook_selection WHERE account_id = ? AND enabled = 1').all(accountId); + const enabled = db.prepare('SELECT * FROM carddav_addressbook_selection WHERE account_id = ? AND enabled = 1').all(accountId); assert.strictEqual(enabled.length, 1, 'Should have 1 enabled addressbook'); assert.strictEqual(enabled[0].addressbook_name, 'Private'); // Query all - const all = db.prepare('SELECT * FROM cardav_addressbook_selection WHERE account_id = ?').all(accountId); + const all = db.prepare('SELECT * FROM carddav_addressbook_selection WHERE account_id = ?').all(accountId); assert.strictEqual(all.length, 2, 'Should have 2 total addressbooks'); }); @@ -456,14 +385,67 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { assert.strictEqual(primary.label, 'mobile'); }); - it('should allow manual contacts (NULL cardav_account_id)', () => { + it('should allow manual contacts (NULL carddav_account_id)', () => { db.prepare(` - INSERT INTO contacts (name, category, phone, email, cardav_account_id) + INSERT INTO contacts (name, category, phone, email, carddav_account_id) VALUES (?, ?, ?, ?, ?) `).run('Manual Contact', 'Sonstiges', '+9999999999', 'manual@example.com', null); const contact = db.prepare('SELECT * FROM contacts WHERE name = ?').get('Manual Contact'); assert.ok(contact, 'Manual contact should be created'); - assert.strictEqual(contact.cardav_account_id, null, 'Manual contact should have NULL cardav_account_id'); + assert.strictEqual(contact.carddav_account_id, null, 'Manual contact should have NULL carddav_account_id'); + }); + + it('should enforce UNIQUE constraint on carddav_uid per account+addressbook', () => { + // Create account + db.prepare(` + INSERT INTO carddav_accounts (name, carddav_url, username, password) + VALUES (?, ?, ?, ?) + `).run('Test Sync Account', 'https://carddav.test.com', 'sync@test.com', 'pass'); + + const accountId = db.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Test Sync Account').id; + + // Create first contact with CardDAV UID + db.prepare(` + INSERT INTO contacts (name, category, carddav_account_id, carddav_uid, carddav_addressbook_url) + VALUES (?, ?, ?, ?, ?) + `).run('Contact A', 'Sonstiges', accountId, 'urn:uuid:12345', 'https://carddav.test.com/addressbooks/main'); + + const firstContact = db.prepare('SELECT * FROM contacts WHERE name = ?').get('Contact A'); + assert.ok(firstContact, 'First contact should be created'); + + // Attempt to create duplicate with same account_id, addressbook_url, and uid should fail + assert.throws(() => { + db.prepare(` + INSERT INTO contacts (name, category, carddav_account_id, carddav_uid, carddav_addressbook_url) + VALUES (?, ?, ?, ?, ?) + `).run('Contact B', 'Sonstiges', accountId, 'urn:uuid:12345', 'https://carddav.test.com/addressbooks/main'); + }, 'UNIQUE constraint should prevent duplicate carddav_uid in same account+addressbook'); + + // But same UID in different addressbook should work + db.prepare(` + INSERT INTO contacts (name, category, carddav_account_id, carddav_uid, carddav_addressbook_url) + VALUES (?, ?, ?, ?, ?) + `).run('Contact C', 'Sonstiges', accountId, 'urn:uuid:12345', 'https://carddav.test.com/addressbooks/work'); + + const differentAddressbook = db.prepare('SELECT * FROM contacts WHERE name = ?').get('Contact C'); + assert.ok(differentAddressbook, 'Same UID in different addressbook should be allowed'); + + // Create another account + db.prepare(` + INSERT INTO carddav_accounts (name, carddav_url, username, password) + VALUES (?, ?, ?, ?) + `).run('Another Account', 'https://other.carddav.com', 'user@other.com', 'pass'); + + const otherAccountId = db.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Another Account').id; + + // Same UID in different account should work + db.prepare(` + INSERT INTO contacts (name, category, carddav_account_id, carddav_uid, carddav_addressbook_url) + VALUES (?, ?, ?, ?, ?) + `).run('Contact D', 'Sonstiges', otherAccountId, 'urn:uuid:12345', 'https://other.carddav.com/addressbooks/main'); + + const differentAccount = db.prepare('SELECT * FROM contacts WHERE name = ?').get('Contact D'); + assert.ok(differentAccount, 'Same UID in different account should be allowed'); }); });