Fix Migration 30 code quality issues

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 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 10:55:30 +02:00
parent 18310dbfe5
commit 3f77fdb11d
3 changed files with 147 additions and 160 deletions
+2 -2
View File
@@ -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",
+18 -13
View File
@@ -1082,21 +1082,21 @@ const MIGRATIONS = [
-- ========================================
-- CardDAV Accounts
-- ========================================
CREATE TABLE cardav_accounts (
CREATE TABLE carddav_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cardav_url 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(cardav_url, username)
UNIQUE(carddav_url, username)
);
-- ========================================
-- CardDAV Addressbook Selection
-- ========================================
CREATE TABLE cardav_addressbook_selection (
CREATE TABLE carddav_addressbook_selection (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
addressbook_url TEXT NOT NULL,
@@ -1104,11 +1104,11 @@ const MIGRATIONS = [
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 };
+116 -134
View File
@@ -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');
});
});