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:
+2
-2
@@ -28,8 +28,8 @@
|
|||||||
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
|
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
|
||||||
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
||||||
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
||||||
"test:cardav": "node --experimental-sqlite test-cardav.js",
|
"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:cardav"
|
"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": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+18
-13
@@ -1082,21 +1082,21 @@ const MIGRATIONS = [
|
|||||||
-- ========================================
|
-- ========================================
|
||||||
-- CardDAV Accounts
|
-- CardDAV Accounts
|
||||||
-- ========================================
|
-- ========================================
|
||||||
CREATE TABLE cardav_accounts (
|
CREATE TABLE carddav_accounts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
cardav_url TEXT NOT NULL,
|
carddav_url TEXT NOT NULL,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
last_sync TEXT,
|
last_sync TEXT,
|
||||||
UNIQUE(cardav_url, username)
|
UNIQUE(carddav_url, username)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ========================================
|
-- ========================================
|
||||||
-- CardDAV Addressbook Selection
|
-- CardDAV Addressbook Selection
|
||||||
-- ========================================
|
-- ========================================
|
||||||
CREATE TABLE cardav_addressbook_selection (
|
CREATE TABLE carddav_addressbook_selection (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
account_id INTEGER NOT NULL,
|
account_id INTEGER NOT NULL,
|
||||||
addressbook_url TEXT NOT NULL,
|
addressbook_url TEXT NOT NULL,
|
||||||
@@ -1104,11 +1104,11 @@ const MIGRATIONS = [
|
|||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
UNIQUE(account_id, addressbook_url),
|
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
|
CREATE INDEX idx_carddav_addressbook_account
|
||||||
ON cardav_addressbook_selection(account_id, enabled);
|
ON carddav_addressbook_selection(account_id, enabled);
|
||||||
|
|
||||||
-- ========================================
|
-- ========================================
|
||||||
-- Extend Contacts Table for CardDAV
|
-- Extend Contacts Table for CardDAV
|
||||||
@@ -1119,14 +1119,19 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE contacts ADD COLUMN website TEXT;
|
ALTER TABLE contacts ADD COLUMN website TEXT;
|
||||||
ALTER TABLE contacts ADD COLUMN photo TEXT;
|
ALTER TABLE contacts ADD COLUMN photo TEXT;
|
||||||
ALTER TABLE contacts ADD COLUMN nickname TEXT;
|
ALTER TABLE contacts ADD COLUMN nickname TEXT;
|
||||||
ALTER TABLE contacts ADD COLUMN cardav_account_id INTEGER
|
ALTER TABLE contacts ADD COLUMN carddav_account_id INTEGER
|
||||||
REFERENCES cardav_accounts(id) ON DELETE SET NULL;
|
REFERENCES carddav_accounts(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE contacts ADD COLUMN cardav_uid TEXT;
|
ALTER TABLE contacts ADD COLUMN carddav_uid TEXT;
|
||||||
ALTER TABLE contacts ADD COLUMN cardav_addressbook_url 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);
|
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)
|
-- Contact Phones (Multiple per Contact)
|
||||||
-- ========================================
|
-- ========================================
|
||||||
@@ -1351,4 +1356,4 @@ function transaction(fn) {
|
|||||||
|
|
||||||
init(); // auto-initialise when module is first imported
|
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
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
import { describe, it, before } from 'node:test';
|
import { describe, it, before } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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:';
|
const TEST_DB = ':memory:';
|
||||||
|
|
||||||
@@ -13,11 +14,12 @@ describe('CardDAV Contacts Schema (Migration 30)', () => {
|
|||||||
let db;
|
let db;
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
// Create in-memory DB
|
// Create in-memory DB with better-sqlite3 to apply migrations
|
||||||
db = new DatabaseSync(TEST_DB);
|
db = new Database(TEST_DB);
|
||||||
db.exec('PRAGMA foreign_keys = ON;');
|
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(`
|
db.exec(`
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -40,106 +42,28 @@ describe('CardDAV Contacts Schema (Migration 30)', () => {
|
|||||||
INSERT INTO users (username) VALUES ('testuser');
|
INSERT INTO users (username) VALUES ('testuser');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Apply Migration 30
|
// Find and apply Migration 30 from the MIGRATIONS array
|
||||||
db.exec(`
|
const migration30 = MIGRATIONS.find(m => m.version === 30);
|
||||||
-- CardDAV Accounts
|
if (!migration30) {
|
||||||
CREATE TABLE cardav_accounts (
|
throw new Error('Migration 30 not found in MIGRATIONS array');
|
||||||
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
|
// Apply Migration 30 (it's a string, not a function)
|
||||||
CREATE TABLE cardav_addressbook_selection (
|
db.exec(migration30.up);
|
||||||
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
|
// Table Existence Tests
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
it('should create cardav_accounts table', () => {
|
it('should create carddav_accounts table', () => {
|
||||||
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cardav_accounts'").get();
|
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='carddav_accounts'").get();
|
||||||
assert.ok(result, 'cardav_accounts table should exist');
|
assert.ok(result, 'carddav_accounts table should exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create cardav_addressbook_selection table', () => {
|
it('should create carddav_addressbook_selection table', () => {
|
||||||
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cardav_addressbook_selection'").get();
|
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='carddav_addressbook_selection'").get();
|
||||||
assert.ok(result, 'cardav_addressbook_selection table should exist');
|
assert.ok(result, 'carddav_addressbook_selection table should exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create contact_phones table', () => {
|
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('website'), 'Should have website column');
|
||||||
assert.ok(colNames.includes('photo'), 'Should have photo column');
|
assert.ok(colNames.includes('photo'), 'Should have photo column');
|
||||||
assert.ok(colNames.includes('nickname'), 'Should have nickname 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('carddav_account_id'), 'Should have carddav_account_id column');
|
||||||
assert.ok(colNames.includes('cardav_uid'), 'Should have cardav_uid column');
|
assert.ok(colNames.includes('carddav_uid'), 'Should have carddav_uid column');
|
||||||
assert.ok(colNames.includes('cardav_addressbook_url'), 'Should have cardav_addressbook_url column');
|
assert.ok(colNames.includes('carddav_addressbook_url'), 'Should have carddav_addressbook_url column');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Index Tests
|
// Index Tests
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
it('should create index on contacts.cardav_uid', () => {
|
it('should create index on contacts.carddav_uid', () => {
|
||||||
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contacts_cardav_uid'").get();
|
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_contacts_carddav_uid'").get();
|
||||||
assert.ok(result, 'Index on cardav_uid should exist');
|
assert.ok(result, 'Index on carddav_uid should exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create index on contacts.email', () => {
|
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');
|
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
|
// 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(`
|
db.prepare(`
|
||||||
INSERT INTO cardav_accounts (name, cardav_url, username, password)
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
||||||
VALUES (?, ?, ?, ?)
|
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');
|
assert.ok(account, 'Account should be inserted');
|
||||||
|
|
||||||
// Duplicate should fail
|
// Duplicate should fail
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO cardav_accounts (name, cardav_url, username, password)
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`).run('Duplicate', 'https://cardav.example.com', 'user1', 'pass2');
|
`).run('Duplicate', 'https://carddav.example.com', 'user1', 'pass2');
|
||||||
}, 'UNIQUE constraint should prevent duplicate cardav_url+username');
|
}, 'UNIQUE constraint should prevent duplicate carddav_url+username');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enforce UNIQUE(account_id, addressbook_url) on addressbook_selection', () => {
|
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(`
|
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 (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
`).run(accountId, 'https://cardav.example.com/addressbooks/main', 'Main Addressbook');
|
`).run(accountId, 'https://carddav.example.com/addressbooks/main', 'Main Addressbook');
|
||||||
|
|
||||||
// Duplicate should fail
|
// Duplicate should fail
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
db.prepare(`
|
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 (?, ?, ?)
|
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');
|
}, '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', () => {
|
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
|
// 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');
|
assert.ok(beforeDelete, 'Addressbook selection should exist before delete');
|
||||||
|
|
||||||
// Delete account
|
// 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
|
// 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');
|
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');
|
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
|
// Create new account
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO cardav_accounts (name, cardav_url, username, password)
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`).run('iCloud', 'https://contacts.icloud.com', 'user@icloud.com', 'pass');
|
`).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
|
// Create contact linked to account
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO contacts (name, category, cardav_account_id, cardav_uid)
|
INSERT INTO contacts (name, category, carddav_account_id, carddav_uid)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`).run('Alice Cooper', 'Sonstiges', accountId, 'urn:uuid:12345');
|
`).run('Alice Cooper', 'Sonstiges', accountId, 'urn:uuid:12345');
|
||||||
|
|
||||||
const contactId = db.prepare('SELECT id FROM contacts WHERE name = ?').get('Alice Cooper').id;
|
const contactId = db.prepare('SELECT id FROM contacts WHERE name = ?').get('Alice Cooper').id;
|
||||||
|
|
||||||
// Verify link
|
// Verify link
|
||||||
const beforeDelete = db.prepare('SELECT cardav_account_id FROM contacts WHERE id = ?').get(contactId);
|
const beforeDelete = db.prepare('SELECT carddav_account_id FROM contacts WHERE id = ?').get(contactId);
|
||||||
assert.strictEqual(beforeDelete.cardav_account_id, accountId, 'Contact should be linked to account');
|
assert.strictEqual(beforeDelete.carddav_account_id, accountId, 'Contact should be linked to account');
|
||||||
|
|
||||||
// Delete 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
|
// Contact should remain but link should be NULL
|
||||||
const afterDelete = db.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
const afterDelete = db.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
||||||
assert.ok(afterDelete, 'Contact should still exist');
|
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', () => {
|
it('should handle enabled/disabled addressbook selection', () => {
|
||||||
// Create account
|
// Create account
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO cardav_accounts (name, cardav_url, username, password)
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`).run('Nextcloud', 'https://nextcloud.example.com/dav', 'user@example.com', 'pass');
|
`).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
|
// Add addressbooks
|
||||||
db.prepare(`
|
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 (?, ?, ?, ?), (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?), (?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
accountId, 'https://nextcloud.example.com/dav/contacts/private', 'Private', 1,
|
accountId, 'https://nextcloud.example.com/dav/contacts/private', 'Private', 1,
|
||||||
@@ -422,12 +351,12 @@ describe('CardDAV Contacts Schema (Migration 30)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Query enabled only
|
// 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.length, 1, 'Should have 1 enabled addressbook');
|
||||||
assert.strictEqual(enabled[0].addressbook_name, 'Private');
|
assert.strictEqual(enabled[0].addressbook_name, 'Private');
|
||||||
|
|
||||||
// Query all
|
// 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');
|
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');
|
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(`
|
db.prepare(`
|
||||||
INSERT INTO contacts (name, category, phone, email, cardav_account_id)
|
INSERT INTO contacts (name, category, phone, email, carddav_account_id)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run('Manual Contact', 'Sonstiges', '+9999999999', 'manual@example.com', null);
|
`).run('Manual Contact', 'Sonstiges', '+9999999999', 'manual@example.com', null);
|
||||||
|
|
||||||
const contact = db.prepare('SELECT * FROM contacts WHERE name = ?').get('Manual Contact');
|
const contact = db.prepare('SELECT * FROM contacts WHERE name = ?').get('Manual Contact');
|
||||||
assert.ok(contact, 'Manual contact should be created');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user