From 18310dbfe532bce0bc0e8f2fa8b2e19127b92c6a Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 10:47:16 +0200 Subject: [PATCH] feat(cardav): add Migration 30 for CardDAV contacts schema Add comprehensive database schema for CardDAV multi-account contacts sync: New tables: - cardav_accounts: Store CardDAV server credentials - cardav_addressbook_selection: Per-account addressbook enable/disable - contact_phones: Multiple phone numbers per contact with labels - contact_emails: Multiple email addresses per contact with labels - contact_addresses: Multiple postal addresses per contact with labels Extended contacts table with 9 new columns: - organization, job_title, birthday, website, photo, nickname - cardav_account_id (FK to cardav_accounts, ON DELETE SET NULL) - cardav_uid (vCard UID from server) - cardav_addressbook_url (source addressbook URL) Features: - UNIQUE constraints on (cardav_url, username) and (account_id, addressbook_url) - CASCADE delete for addressbook selection and contact sub-tables - Performance indices for cardav_uid and email lookups - Support for manual contacts (NULL cardav_account_id) - is_primary flag for phone/email/address selection Test coverage: - 23 comprehensive tests verify all tables, constraints, indices - FK CASCADE delete behavior validated - UNIQUE constraints enforced - Data integrity scenarios tested Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 4 +- package.json | 3 +- server/db.js | 101 ++++++++++ test-cardav.js | 469 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 test-cardav.js diff --git a/package-lock.json b/package-lock.json index a6c55b9..5621e01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.43.0", + "version": "0.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.43.0", + "version": "0.44.0", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 0e197e9..1706601 100644 --- a/package.json +++ b/package.json @@ -28,7 +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": "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" + "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" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/server/db.js b/server/db.js index 3d8b6be..567419a 100644 --- a/server/db.js +++ b/server/db.js @@ -1075,6 +1075,107 @@ const MIGRATIONS = [ db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`); }, }, + { + version: 30, + description: 'CardDAV multi-account contacts sync', + up: ` + -- ======================================== + -- 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) + ); + + -- ======================================== + -- 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 for CardDAV + -- ======================================== + 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 (Multiple per Contact) + -- ======================================== + 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 (Multiple per Contact) + -- ======================================== + 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 (Multiple per Contact) + -- ======================================== + 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); + `, + }, ]; /** diff --git a/test-cardav.js b/test-cardav.js new file mode 100644 index 0000000..fc1ce76 --- /dev/null +++ b/test-cardav.js @@ -0,0 +1,469 @@ +/** + * Test: CardDAV Contacts Schema + * Purpose: Verify Migration 30 - CardDAV multi-account contacts sync tables + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +const TEST_DB = ':memory:'; + +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 base contacts table (from Migration 1) and family_user_id (Migration 23) + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL + ); + + CREATE TABLE contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Sonstiges', + phone TEXT, + email TEXT, + address TEXT, + notes TEXT, + family_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + 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) + ); + + -- 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); + `); + }); + + // ======================================== + // 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 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 contact_phones table', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='contact_phones'").get(); + assert.ok(result, 'contact_phones table should exist'); + }); + + it('should create contact_emails table', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='contact_emails'").get(); + assert.ok(result, 'contact_emails table should exist'); + }); + + it('should create contact_addresses table', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='contact_addresses'").get(); + assert.ok(result, 'contact_addresses table should exist'); + }); + + // ======================================== + // Contacts Table Extension Tests + // ======================================== + + it('should extend contacts table with CardDAV columns', () => { + const cols = db.prepare("PRAGMA table_info(contacts)").all(); + const colNames = cols.map(c => c.name); + + assert.ok(colNames.includes('organization'), 'Should have organization column'); + assert.ok(colNames.includes('job_title'), 'Should have job_title column'); + assert.ok(colNames.includes('birthday'), 'Should have birthday column'); + 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'); + }); + + // ======================================== + // 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.email', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contacts_email'").get(); + assert.ok(result, 'Index on email should exist'); + }); + + it('should create index on contact_phones.contact_id', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contact_phones_contact'").get(); + assert.ok(result, 'Index on contact_phones.contact_id should exist'); + }); + + it('should create index on contact_phones.value', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contact_phones_value'").get(); + assert.ok(result, 'Index on contact_phones.value should exist'); + }); + + it('should create index on contact_emails.contact_id', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contact_emails_contact'").get(); + assert.ok(result, 'Index on contact_emails.contact_id should exist'); + }); + + it('should create index on contact_emails.value', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contact_emails_value'").get(); + assert.ok(result, 'Index on contact_emails.value should exist'); + }); + + it('should create index on contact_addresses.contact_id', () => { + const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contact_addresses_contact'").get(); + assert.ok(result, 'Index on contact_addresses.contact_id should exist'); + }); + + // ======================================== + // UNIQUE Constraint Tests + // ======================================== + + it('should enforce UNIQUE(cardav_url, username) on cardav_accounts', () => { + db.prepare(` + INSERT INTO cardav_accounts (name, cardav_url, username, password) + VALUES (?, ?, ?, ?) + `).run('Test Account', 'https://cardav.example.com', 'user1', 'pass1'); + + const account = db.prepare('SELECT * FROM cardav_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) + VALUES (?, ?, ?, ?) + `).run('Duplicate', 'https://cardav.example.com', 'user1', 'pass2'); + }, 'UNIQUE constraint should prevent duplicate cardav_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; + + db.prepare(` + INSERT INTO cardav_addressbook_selection (account_id, addressbook_url, addressbook_name) + VALUES (?, ?, ?) + `).run(accountId, 'https://cardav.example.com/addressbooks/main', 'Main Addressbook'); + + // Duplicate should fail + assert.throws(() => { + db.prepare(` + INSERT INTO cardav_addressbook_selection (account_id, addressbook_url, addressbook_name) + VALUES (?, ?, ?) + `).run(accountId, 'https://cardav.example.com/addressbooks/main', 'Duplicate'); + }, 'UNIQUE constraint should prevent duplicate account_id+addressbook_url'); + }); + + // ======================================== + // Foreign Key Cascade Tests + // ======================================== + + it('should CASCADE delete addressbook_selection when account deleted', () => { + const accountId = db.prepare('SELECT id FROM cardav_accounts WHERE name = ?').get('Test Account').id; + + // Verify addressbook exists + const beforeDelete = db.prepare('SELECT * FROM cardav_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); + + // Addressbook selection should be deleted + const afterDelete = db.prepare('SELECT * FROM cardav_addressbook_selection WHERE account_id = ?').get(accountId); + assert.strictEqual(afterDelete, undefined, 'Addressbook selection should CASCADE delete'); + }); + + it('should CASCADE delete contact_phones when contact deleted', () => { + // Create contact + db.prepare(` + INSERT INTO contacts (name, category) + VALUES (?, ?) + `).run('John Doe', 'Sonstiges'); + + const contactId = db.prepare('SELECT id FROM contacts WHERE name = ?').get('John Doe').id; + + // Add phones + db.prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'mobile', '+1234567890', 1); + + db.prepare(` + INSERT INTO contact_phones (contact_id, label, value) + VALUES (?, ?, ?) + `).run(contactId, 'work', '+0987654321'); + + // Verify phones exist + const phonesBefore = db.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId); + assert.strictEqual(phonesBefore.length, 2, 'Should have 2 phone numbers'); + + // Delete contact + db.prepare('DELETE FROM contacts WHERE id = ?').run(contactId); + + // Phones should be deleted + const phonesAfter = db.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId); + assert.strictEqual(phonesAfter.length, 0, 'Phone numbers should CASCADE delete'); + }); + + it('should CASCADE delete contact_emails when contact deleted', () => { + // Create contact + db.prepare(` + INSERT INTO contacts (name, category) + VALUES (?, ?) + `).run('Jane Smith', 'Sonstiges'); + + const contactId = db.prepare('SELECT id FROM contacts WHERE name = ?').get('Jane Smith').id; + + // Add emails + db.prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'work', 'jane@work.com', 1); + + db.prepare(` + INSERT INTO contact_emails (contact_id, label, value) + VALUES (?, ?, ?) + `).run(contactId, 'home', 'jane@home.com'); + + // Verify emails exist + const emailsBefore = db.prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId); + assert.strictEqual(emailsBefore.length, 2, 'Should have 2 email addresses'); + + // Delete contact + db.prepare('DELETE FROM contacts WHERE id = ?').run(contactId); + + // Emails should be deleted + const emailsAfter = db.prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId); + assert.strictEqual(emailsAfter.length, 0, 'Email addresses should CASCADE delete'); + }); + + it('should CASCADE delete contact_addresses when contact deleted', () => { + // Create contact + db.prepare(` + INSERT INTO contacts (name, category) + VALUES (?, ?) + `).run('Bob Johnson', 'Sonstiges'); + + const contactId = db.prepare('SELECT id FROM contacts WHERE name = ?').get('Bob Johnson').id; + + // Add addresses + db.prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, is_primary) + VALUES (?, ?, ?, ?, ?) + `).run(contactId, 'home', '123 Main St', 'Springfield', 1); + + db.prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city) + VALUES (?, ?, ?, ?) + `).run(contactId, 'work', '456 Office Blvd', 'Metropolis'); + + // Verify addresses exist + const addressesBefore = db.prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId); + assert.strictEqual(addressesBefore.length, 2, 'Should have 2 addresses'); + + // Delete contact + db.prepare('DELETE FROM contacts WHERE id = ?').run(contactId); + + // Addresses should be deleted + const addressesAfter = db.prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId); + assert.strictEqual(addressesAfter.length, 0, 'Addresses should CASCADE delete'); + }); + + it('should SET NULL on contacts.cardav_account_id when account deleted', () => { + // Create new account + db.prepare(` + INSERT INTO cardav_accounts (name, cardav_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; + + // Create contact linked to account + db.prepare(` + INSERT INTO contacts (name, category, cardav_account_id, cardav_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'); + + // Delete account + db.prepare('DELETE FROM cardav_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'); + }); + + // ======================================== + // Data Integrity Tests + // ======================================== + + it('should handle enabled/disabled addressbook selection', () => { + // Create account + db.prepare(` + INSERT INTO cardav_accounts (name, cardav_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; + + // Add addressbooks + db.prepare(` + INSERT INTO cardav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) + VALUES (?, ?, ?, ?), (?, ?, ?, ?) + `).run( + accountId, 'https://nextcloud.example.com/dav/contacts/private', 'Private', 1, + accountId, 'https://nextcloud.example.com/dav/contacts/work', 'Work', 0 + ); + + // Query enabled only + const enabled = db.prepare('SELECT * FROM cardav_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); + assert.strictEqual(all.length, 2, 'Should have 2 total addressbooks'); + }); + + it('should handle is_primary flag on contact phones', () => { + // Create contact + db.prepare(` + INSERT INTO contacts (name, category) + VALUES (?, ?) + `).run('Test Primary', 'Sonstiges'); + + const contactId = db.prepare('SELECT id FROM contacts WHERE name = ?').get('Test Primary').id; + + // Add multiple phones with one primary + db.prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?), (?, ?, ?, ?) + `).run( + contactId, 'mobile', '+1111111111', 1, + contactId, 'home', '+2222222222', 0 + ); + + // Query primary + const primary = db.prepare('SELECT * FROM contact_phones WHERE contact_id = ? AND is_primary = 1').get(contactId); + assert.ok(primary, 'Should have a primary phone'); + assert.strictEqual(primary.value, '+1111111111'); + assert.strictEqual(primary.label, 'mobile'); + }); + + it('should allow manual contacts (NULL cardav_account_id)', () => { + db.prepare(` + INSERT INTO contacts (name, category, phone, email, cardav_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'); + }); +});