cf68bff25f
Add CardDAV API router with GET /accounts endpoint. Returns all CardDAV accounts from database via cardav-sync service. Added test infrastructure with _setTestDatabase helper for isolated API testing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1542 lines
58 KiB
JavaScript
1542 lines
58 KiB
JavaScript
/**
|
|
* 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 Database from 'better-sqlite3';
|
|
import { MIGRATIONS } from './server/db.js';
|
|
|
|
const TEST_DB = ':memory:';
|
|
|
|
describe('CardDAV Contacts Schema (Migration 30)', () => {
|
|
let db;
|
|
|
|
before(() => {
|
|
// Create in-memory DB with better-sqlite3 to apply migrations
|
|
db = new Database(TEST_DB);
|
|
db.pragma('foreign_keys = ON');
|
|
|
|
// 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,
|
|
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');
|
|
`);
|
|
|
|
// 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');
|
|
}
|
|
|
|
// Apply Migration 30 (it's a string, not a function)
|
|
db.exec(migration30.up);
|
|
});
|
|
|
|
// ========================================
|
|
// Table Existence Tests
|
|
// ========================================
|
|
|
|
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 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', () => {
|
|
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('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.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', () => {
|
|
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');
|
|
});
|
|
|
|
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(carddav_url, username) on carddav_accounts', () => {
|
|
db.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Test Account', 'https://carddav.example.com', 'user1', 'pass1');
|
|
|
|
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 carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).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 carddav_accounts WHERE name = ?').get('Test Account').id;
|
|
|
|
db.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name)
|
|
VALUES (?, ?, ?)
|
|
`).run(accountId, 'https://carddav.example.com/addressbooks/main', 'Main Addressbook');
|
|
|
|
// Duplicate should fail
|
|
assert.throws(() => {
|
|
db.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name)
|
|
VALUES (?, ?, ?)
|
|
`).run(accountId, 'https://carddav.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 carddav_accounts WHERE name = ?').get('Test Account').id;
|
|
|
|
// Verify addressbook exists
|
|
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 carddav_accounts WHERE id = ?').run(accountId);
|
|
|
|
// Addressbook selection should be deleted
|
|
const afterDelete = db.prepare('SELECT * FROM carddav_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.carddav_account_id when account deleted', () => {
|
|
// Create new account
|
|
db.prepare(`
|
|
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 carddav_accounts WHERE name = ?').get('iCloud').id;
|
|
|
|
// Create contact linked to account
|
|
db.prepare(`
|
|
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 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 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.carddav_account_id, null, 'carddav_account_id should be SET NULL');
|
|
});
|
|
|
|
// ========================================
|
|
// Data Integrity Tests
|
|
// ========================================
|
|
|
|
it('should handle enabled/disabled addressbook selection', () => {
|
|
// Create account
|
|
db.prepare(`
|
|
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 carddav_accounts WHERE name = ?').get('Nextcloud').id;
|
|
|
|
// Add addressbooks
|
|
db.prepare(`
|
|
INSERT INTO carddav_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 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 carddav_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 carddav_account_id)', () => {
|
|
db.prepare(`
|
|
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.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');
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// CardDAV Sync Service Tests
|
|
// ========================================
|
|
|
|
describe('CardDAV Sync Service', () => {
|
|
let testDb;
|
|
let parseVCard;
|
|
|
|
before(async () => {
|
|
// Create in-memory test database
|
|
testDb = new Database(':memory:');
|
|
testDb.pragma('foreign_keys = ON');
|
|
|
|
// Create minimal schema
|
|
testDb.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
|
|
const migration30 = MIGRATIONS.find(m => m.version === 30);
|
|
if (!migration30) {
|
|
throw new Error('Migration 30 not found');
|
|
}
|
|
testDb.exec(migration30.up);
|
|
|
|
// Import parseVCard helper for testing
|
|
const cardavSync = await import('./server/services/cardav-sync.js');
|
|
parseVCard = cardavSync.parseVCard;
|
|
});
|
|
|
|
// ========================================
|
|
// vCard Parsing Tests
|
|
// ========================================
|
|
|
|
describe('parseVCard', () => {
|
|
it('should parse basic vCard with FN and UID', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.uid, 'urn:uuid:12345');
|
|
assert.strictEqual(result.name, 'John Doe');
|
|
});
|
|
|
|
it('should parse N as fallback when FN missing', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
N:Doe;John;Middle;Mr.;Jr.
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.uid, 'urn:uuid:12345');
|
|
assert.ok(result.name.includes('Doe'));
|
|
assert.ok(result.name.includes('John'));
|
|
});
|
|
|
|
it('should parse TEL fields with types', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
TEL;TYPE=CELL:+1234567890
|
|
TEL;TYPE=WORK:+0987654321
|
|
TEL;TYPE=HOME:+1111111111
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.phones.length, 3);
|
|
|
|
const cellPhone = result.phones.find(p => p.label === 'cell');
|
|
assert.ok(cellPhone);
|
|
assert.strictEqual(cellPhone.value, '+1234567890');
|
|
|
|
const workPhone = result.phones.find(p => p.label === 'work');
|
|
assert.ok(workPhone);
|
|
assert.strictEqual(workPhone.value, '+0987654321');
|
|
});
|
|
|
|
it('should parse EMAIL fields with types', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
EMAIL;TYPE=HOME:john@home.com
|
|
EMAIL;TYPE=WORK:john@work.com
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.emails.length, 2);
|
|
|
|
const homeEmail = result.emails.find(e => e.label === 'home');
|
|
assert.ok(homeEmail);
|
|
assert.strictEqual(homeEmail.value, 'john@home.com');
|
|
});
|
|
|
|
it('should parse ADR fields', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
ADR;TYPE=HOME:;;123 Main St;Springfield;IL;62701;USA
|
|
ADR;TYPE=WORK:;;456 Office Blvd;Metropolis;NY;10001;USA
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.addresses.length, 2);
|
|
|
|
const homeAddr = result.addresses.find(a => a.label === 'home');
|
|
assert.ok(homeAddr);
|
|
assert.strictEqual(homeAddr.street, '123 Main St');
|
|
assert.strictEqual(homeAddr.city, 'Springfield');
|
|
assert.strictEqual(homeAddr.state, 'IL');
|
|
assert.strictEqual(homeAddr.postalCode, '62701');
|
|
assert.strictEqual(homeAddr.country, 'USA');
|
|
});
|
|
|
|
it('should parse organization and job title', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
ORG:Acme Corporation
|
|
TITLE:Senior Engineer
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.organization, 'Acme Corporation');
|
|
assert.strictEqual(result.jobTitle, 'Senior Engineer');
|
|
});
|
|
|
|
it('should parse birthday in various formats', () => {
|
|
const vCardText1 = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
BDAY:1990-05-15
|
|
END:VCARD`;
|
|
|
|
const result1 = parseVCard(vCardText1);
|
|
assert.strictEqual(result1.birthday, '1990-05-15');
|
|
|
|
const vCardText2 = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:Jane Doe
|
|
BDAY:19850312
|
|
END:VCARD`;
|
|
|
|
const result2 = parseVCard(vCardText2);
|
|
assert.strictEqual(result2.birthday, '1985-03-12');
|
|
});
|
|
|
|
it('should parse URL, NICKNAME, NOTE, CATEGORIES', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
URL:https://example.com
|
|
NICKNAME:Johnny
|
|
NOTE:Important contact
|
|
CATEGORIES:Friends
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.website, 'https://example.com');
|
|
assert.strictEqual(result.nickname, 'Johnny');
|
|
assert.strictEqual(result.notes, 'Important contact');
|
|
assert.strictEqual(result.categories, 'Friends');
|
|
});
|
|
|
|
it('should handle line folding', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
NOTE:This is a very long note that spans
|
|
multiple lines and should be
|
|
concatenated properly
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.ok(result.notes.includes('very long note'));
|
|
assert.ok(result.notes.includes('multiple lines'));
|
|
assert.ok(result.notes.includes('concatenated properly'));
|
|
});
|
|
|
|
it('should handle vCards with minimal data', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:minimal
|
|
FN:Minimal Contact
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.uid, 'urn:uuid:minimal');
|
|
assert.strictEqual(result.name, 'Minimal Contact');
|
|
assert.strictEqual(result.phones.length, 0);
|
|
assert.strictEqual(result.emails.length, 0);
|
|
assert.strictEqual(result.addresses.length, 0);
|
|
});
|
|
|
|
it('should handle TEL without TYPE parameter', () => {
|
|
const vCardText = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
UID:urn:uuid:12345
|
|
FN:John Doe
|
|
TEL;CELL:+1234567890
|
|
TEL;VOICE;WORK:+0987654321
|
|
END:VCARD`;
|
|
|
|
const result = parseVCard(vCardText);
|
|
assert.strictEqual(result.phones.length, 2);
|
|
|
|
// Should extract CELL and WORK from parameter names
|
|
const cellPhone = result.phones.find(p => p.label === 'cell');
|
|
assert.ok(cellPhone);
|
|
|
|
const workPhone = result.phones.find(p => p.label === 'work');
|
|
assert.ok(workPhone);
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Database Integration Tests
|
|
// ========================================
|
|
|
|
describe('Account Management (DB)', () => {
|
|
it('should store and retrieve account correctly', () => {
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Test Account', 'https://carddav.example.com', 'user@example.com', 'password123');
|
|
|
|
const account = testDb.prepare('SELECT * FROM carddav_accounts WHERE name = ?').get('Test Account');
|
|
assert.ok(account);
|
|
assert.strictEqual(account.name, 'Test Account');
|
|
assert.strictEqual(account.carddav_url, 'https://carddav.example.com');
|
|
assert.strictEqual(account.username, 'user@example.com');
|
|
assert.strictEqual(account.password, 'password123');
|
|
});
|
|
|
|
it('should create addressbook selections for account', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Account For Addressbooks', 'https://example.com/dav', 'user1@example.com', 'pass');
|
|
|
|
const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Account For Addressbooks').id;
|
|
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
|
VALUES (?, ?, ?, ?), (?, ?, ?, ?)
|
|
`).run(
|
|
accountId, 'https://example.com/dav/addressbooks/personal', 'Personal', 1,
|
|
accountId, 'https://example.com/dav/addressbooks/work', 'Work', 0
|
|
);
|
|
|
|
const enabled = testDb.prepare(`
|
|
SELECT * FROM carddav_addressbook_selection
|
|
WHERE account_id = ? AND enabled = 1
|
|
`).all(accountId);
|
|
|
|
assert.strictEqual(enabled.length, 1);
|
|
assert.strictEqual(enabled[0].addressbook_name, 'Personal');
|
|
|
|
const all = testDb.prepare(`
|
|
SELECT * FROM carddav_addressbook_selection
|
|
WHERE account_id = ?
|
|
`).all(accountId);
|
|
|
|
assert.strictEqual(all.length, 2);
|
|
});
|
|
|
|
it('should reject duplicate accounts (same URL + username)', () => {
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Nextcloud Test', 'https://nextcloud.test.com/dav', 'user@nextcloud.com', 'pass1');
|
|
|
|
// Attempt to insert duplicate
|
|
assert.throws(() => {
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Nextcloud Test 2', 'https://nextcloud.test.com/dav', 'user@nextcloud.com', 'pass2');
|
|
}, 'UNIQUE constraint should prevent duplicate carddav_url+username');
|
|
});
|
|
|
|
it('should delete account and set carddav_account_id = NULL on contacts', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Account For Deletion', 'https://delete.example.com', 'user@delete.com', 'pass');
|
|
|
|
const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Account For Deletion').id;
|
|
|
|
// Create contact linked to this account
|
|
testDb.prepare(`
|
|
INSERT INTO contacts (name, category, carddav_account_id, carddav_uid)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Test Contact For Deletion', 'Sonstiges', accountId, 'urn:uuid:test-contact-delete');
|
|
|
|
const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('Test Contact For Deletion').id;
|
|
|
|
// Verify contact is linked
|
|
const beforeDelete = testDb.prepare('SELECT carddav_account_id FROM contacts WHERE id = ?').get(contactId);
|
|
assert.strictEqual(beforeDelete.carddav_account_id, accountId);
|
|
|
|
// Delete account
|
|
testDb.prepare('DELETE FROM carddav_accounts WHERE id = ?').run(accountId);
|
|
|
|
// Contact should remain but carddav_account_id should be NULL
|
|
const afterDelete = testDb.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
|
assert.ok(afterDelete, 'Contact should still exist');
|
|
assert.strictEqual(afterDelete.carddav_account_id, null, 'carddav_account_id should be SET NULL');
|
|
});
|
|
|
|
it('should retrieve password correctly from database', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('iCloud Password Test', 'https://contacts.icloud.com', 'test@icloud.com', 'my-secret-password');
|
|
|
|
const account = testDb.prepare('SELECT * FROM carddav_accounts WHERE name = ?').get('iCloud Password Test');
|
|
assert.strictEqual(account.password, 'my-secret-password', 'Password should be retrievable');
|
|
});
|
|
});
|
|
|
|
describe('Addressbook Discovery UPSERT', () => {
|
|
it('should insert new addressbook for account', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('iCloud UPSERT', 'https://contacts.upsert.icloud.com', 'test@upsert.com', 'pass');
|
|
|
|
const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('iCloud UPSERT').id;
|
|
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(accountId, 'https://contacts.upsert.icloud.com/123456/personal', 'Personal', 1);
|
|
|
|
const addressbook = testDb.prepare(`
|
|
SELECT * FROM carddav_addressbook_selection
|
|
WHERE account_id = ? AND addressbook_url = ?
|
|
`).get(accountId, 'https://contacts.upsert.icloud.com/123456/personal');
|
|
|
|
assert.ok(addressbook);
|
|
assert.strictEqual(addressbook.addressbook_name, 'Personal');
|
|
assert.strictEqual(addressbook.enabled, 1);
|
|
});
|
|
|
|
it('should update existing addressbook name while preserving enabled state', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('iCloud Update', 'https://contacts.update.icloud.com', 'test@update.com', 'pass');
|
|
|
|
const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('iCloud Update').id;
|
|
|
|
// Create initial addressbook
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(accountId, 'https://contacts.update.icloud.com/123456/personal', 'Personal', 1);
|
|
|
|
const existing = testDb.prepare(`
|
|
SELECT id, enabled FROM carddav_addressbook_selection
|
|
WHERE account_id = ? AND addressbook_url = ?
|
|
`).get(accountId, 'https://contacts.update.icloud.com/123456/personal');
|
|
|
|
// Disable it
|
|
testDb.prepare('UPDATE carddav_addressbook_selection SET enabled = 0 WHERE id = ?').run(existing.id);
|
|
|
|
// Update name (simulating rediscovery)
|
|
testDb.prepare(`
|
|
UPDATE carddav_addressbook_selection
|
|
SET addressbook_name = ?
|
|
WHERE id = ?
|
|
`).run('Personal Contacts', existing.id);
|
|
|
|
const updated = testDb.prepare('SELECT * FROM carddav_addressbook_selection WHERE id = ?').get(existing.id);
|
|
assert.strictEqual(updated.addressbook_name, 'Personal Contacts', 'Name should be updated');
|
|
assert.strictEqual(updated.enabled, 0, 'Enabled state should be preserved');
|
|
});
|
|
|
|
it('should not insert duplicate addressbook for same account+url', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('iCloud Duplicate', 'https://contacts.duplicate.icloud.com', 'test@dup.com', 'pass');
|
|
|
|
const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('iCloud Duplicate').id;
|
|
|
|
// Create first addressbook
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(accountId, 'https://contacts.duplicate.icloud.com/123456/personal', 'Personal', 1);
|
|
|
|
assert.throws(() => {
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(accountId, 'https://contacts.duplicate.icloud.com/123456/personal', 'Duplicate', 1);
|
|
}, 'UNIQUE constraint should prevent duplicate account_id+addressbook_url');
|
|
});
|
|
});
|
|
|
|
describe('Addressbook Toggle', () => {
|
|
it('should toggle addressbook enabled state', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('iCloud Toggle', 'https://contacts.toggle.icloud.com', 'test@toggle.com', 'pass');
|
|
|
|
const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('iCloud Toggle').id;
|
|
|
|
// Create addressbook with enabled=0
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(accountId, 'https://contacts.toggle.icloud.com/123456/personal', 'Personal', 0);
|
|
|
|
const addressbook = testDb.prepare(`
|
|
SELECT * FROM carddav_addressbook_selection
|
|
WHERE account_id = ? AND addressbook_url = ?
|
|
`).get(accountId, 'https://contacts.toggle.icloud.com/123456/personal');
|
|
|
|
// Initially disabled
|
|
assert.strictEqual(addressbook.enabled, 0);
|
|
|
|
// Enable it
|
|
testDb.prepare('UPDATE carddav_addressbook_selection SET enabled = 1 WHERE id = ?').run(addressbook.id);
|
|
|
|
const enabled = testDb.prepare('SELECT * FROM carddav_addressbook_selection WHERE id = ?').get(addressbook.id);
|
|
assert.strictEqual(enabled.enabled, 1);
|
|
|
|
// Disable it again
|
|
testDb.prepare('UPDATE carddav_addressbook_selection SET enabled = 0 WHERE id = ?').run(addressbook.id);
|
|
|
|
const disabled = testDb.prepare('SELECT * FROM carddav_addressbook_selection WHERE id = ?').get(addressbook.id);
|
|
assert.strictEqual(disabled.enabled, 0);
|
|
});
|
|
});
|
|
|
|
describe('Contact Merge Logic (DB)', () => {
|
|
let aliceContact;
|
|
let accountId;
|
|
|
|
before(() => {
|
|
// Create account
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Account For vCard', 'https://vcard.example.com', 'user@vcard.com', 'pass');
|
|
|
|
accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Account For vCard').id;
|
|
|
|
// Create Alice Smith
|
|
testDb.prepare(`
|
|
INSERT INTO contacts (
|
|
name, category, organization, job_title, birthday, website,
|
|
nickname, notes,
|
|
carddav_account_id, carddav_uid, carddav_addressbook_url
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
'Alice Smith',
|
|
'Sonstiges',
|
|
'Tech Corp',
|
|
'Developer',
|
|
'1990-01-15',
|
|
'https://alice.dev',
|
|
'Ali',
|
|
'Great developer',
|
|
accountId,
|
|
'urn:uuid:alice-123',
|
|
'https://vcard.example.com/addressbooks/personal'
|
|
);
|
|
|
|
aliceContact = testDb.prepare('SELECT * FROM contacts WHERE name = ?').get('Alice Smith');
|
|
});
|
|
|
|
it('should create new contact from vCard', () => {
|
|
assert.ok(aliceContact);
|
|
assert.strictEqual(aliceContact.organization, 'Tech Corp');
|
|
assert.strictEqual(aliceContact.job_title, 'Developer');
|
|
assert.strictEqual(aliceContact.birthday, '1990-01-15');
|
|
assert.strictEqual(aliceContact.carddav_uid, 'urn:uuid:alice-123');
|
|
});
|
|
|
|
it('should add multiple phones to contact', () => {
|
|
testDb.prepare(`
|
|
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
|
VALUES (?, ?, ?, ?), (?, ?, ?, ?)
|
|
`).run(
|
|
aliceContact.id, 'mobile', '+1234567890', 1,
|
|
aliceContact.id, 'work', '+0987654321', 0
|
|
);
|
|
|
|
const phones = testDb.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(aliceContact.id);
|
|
assert.strictEqual(phones.length, 2);
|
|
|
|
const primary = phones.find(p => p.is_primary === 1);
|
|
assert.ok(primary);
|
|
assert.strictEqual(primary.value, '+1234567890');
|
|
});
|
|
|
|
it('should add multiple emails to contact', () => {
|
|
testDb.prepare(`
|
|
INSERT INTO contact_emails (contact_id, label, value, is_primary)
|
|
VALUES (?, ?, ?, ?), (?, ?, ?, ?)
|
|
`).run(
|
|
aliceContact.id, 'home', 'alice@home.com', 1,
|
|
aliceContact.id, 'work', 'alice@work.com', 0
|
|
);
|
|
|
|
const emails = testDb.prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(aliceContact.id);
|
|
assert.strictEqual(emails.length, 2);
|
|
|
|
const primary = emails.find(e => e.is_primary === 1);
|
|
assert.ok(primary);
|
|
assert.strictEqual(primary.value, 'alice@home.com');
|
|
});
|
|
|
|
it('should add multiple addresses to contact', () => {
|
|
testDb.prepare(`
|
|
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
aliceContact.id, 'home', '123 Main St', 'Springfield', 'IL', '62701', 'USA', 1
|
|
);
|
|
|
|
const addresses = testDb.prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(aliceContact.id);
|
|
assert.strictEqual(addresses.length, 1);
|
|
assert.strictEqual(addresses[0].street, '123 Main St');
|
|
assert.strictEqual(addresses[0].is_primary, 1);
|
|
});
|
|
|
|
it('should preserve primary entries when updating multi-values', () => {
|
|
// Mark first phone as primary (manually set)
|
|
testDb.prepare('UPDATE contact_phones SET is_primary = 1 WHERE contact_id = ? AND label = ?')
|
|
.run(aliceContact.id, 'mobile');
|
|
|
|
// Delete non-primary phones (simulating sync update)
|
|
testDb.prepare('DELETE FROM contact_phones WHERE contact_id = ? AND is_primary = 0')
|
|
.run(aliceContact.id);
|
|
|
|
// Add new phones from vCard
|
|
testDb.prepare(`
|
|
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(aliceContact.id, 'home', '+9999999999', 0);
|
|
|
|
const phones = testDb.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(aliceContact.id);
|
|
|
|
// Should have primary mobile + new home phone
|
|
assert.strictEqual(phones.length, 2);
|
|
|
|
const primaryPhone = phones.find(p => p.is_primary === 1);
|
|
assert.ok(primaryPhone);
|
|
assert.strictEqual(primaryPhone.label, 'mobile');
|
|
});
|
|
|
|
it('should find contact by email match', () => {
|
|
// Create manual contact with email
|
|
testDb.prepare(`
|
|
INSERT INTO contacts (name, category, email)
|
|
VALUES (?, ?, ?)
|
|
`).run('Bob Jones', 'Sonstiges', 'bob@example.com');
|
|
|
|
const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('Bob Jones').id;
|
|
|
|
// Also add to contact_emails
|
|
testDb.prepare(`
|
|
INSERT INTO contact_emails (contact_id, label, value, is_primary)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(contactId, 'work', 'bob@work.com', 0);
|
|
|
|
// Search by email (simulating merge logic)
|
|
const foundByOldEmail = testDb.prepare(`
|
|
SELECT c.* FROM contacts c
|
|
WHERE c.email = ?
|
|
`).get('bob@example.com');
|
|
|
|
assert.ok(foundByOldEmail);
|
|
assert.strictEqual(foundByOldEmail.name, 'Bob Jones');
|
|
|
|
const foundByNewEmail = testDb.prepare(`
|
|
SELECT c.* FROM contacts c
|
|
LEFT JOIN contact_emails ce ON c.id = ce.contact_id
|
|
WHERE ce.value = ?
|
|
`).get('bob@work.com');
|
|
|
|
assert.ok(foundByNewEmail);
|
|
assert.strictEqual(foundByNewEmail.name, 'Bob Jones');
|
|
});
|
|
|
|
it('should find contact by phone match', () => {
|
|
// Create manual contact with phone
|
|
testDb.prepare(`
|
|
INSERT INTO contacts (name, category, phone)
|
|
VALUES (?, ?, ?)
|
|
`).run('Carol White', 'Sonstiges', '+5555555555');
|
|
|
|
const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('Carol White').id;
|
|
|
|
// Also add to contact_phones
|
|
testDb.prepare(`
|
|
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(contactId, 'mobile', '+6666666666', 0);
|
|
|
|
// Search by phone (simulating merge logic)
|
|
const foundByOldPhone = testDb.prepare(`
|
|
SELECT c.* FROM contacts c
|
|
WHERE c.phone = ?
|
|
`).get('+5555555555');
|
|
|
|
assert.ok(foundByOldPhone);
|
|
assert.strictEqual(foundByOldPhone.name, 'Carol White');
|
|
|
|
const foundByNewPhone = testDb.prepare(`
|
|
SELECT c.* FROM contacts c
|
|
LEFT JOIN contact_phones cp ON c.id = cp.contact_id
|
|
WHERE cp.value = ?
|
|
`).get('+6666666666');
|
|
|
|
assert.ok(foundByNewPhone);
|
|
assert.strictEqual(foundByNewPhone.name, 'Carol White');
|
|
});
|
|
|
|
it('should only update NULL fields when merging', () => {
|
|
// Create contact with some fields filled
|
|
testDb.prepare(`
|
|
INSERT INTO contacts (name, category, organization, job_title)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('Dave Brown', 'Sonstiges', 'Local Company', 'Manager');
|
|
|
|
const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('Dave Brown').id;
|
|
|
|
// Simulate merge: only update NULL fields
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
const contact = testDb.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
|
|
|
// birthday is NULL, should update
|
|
if (contact.birthday === null) {
|
|
updates.push('birthday = ?');
|
|
values.push('1985-07-20');
|
|
}
|
|
|
|
// organization is NOT NULL, should not update
|
|
if (contact.organization === null) {
|
|
updates.push('organization = ?');
|
|
values.push('New Company');
|
|
}
|
|
|
|
values.push(contactId);
|
|
|
|
if (updates.length > 0) {
|
|
testDb.prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
const updated = testDb.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
|
|
|
// birthday should be updated
|
|
assert.strictEqual(updated.birthday, '1985-07-20');
|
|
|
|
// organization should remain unchanged
|
|
assert.strictEqual(updated.organization, 'Local Company');
|
|
});
|
|
|
|
it('should update existing contact when cardav_uid matches', () => {
|
|
// Create own account first
|
|
testDb.prepare(`
|
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run('iCloud Sync Account', 'https://contacts.sync.icloud.com', 'test@sync.com', 'pass');
|
|
|
|
const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('iCloud Sync Account').id;
|
|
|
|
// Create initial contact from CardDAV
|
|
testDb.prepare(`
|
|
INSERT INTO contacts (
|
|
name, category, organization, job_title,
|
|
carddav_account_id, carddav_uid, carddav_addressbook_url
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
'John Sync',
|
|
'Sonstiges',
|
|
'SyncCorp',
|
|
'Engineer',
|
|
accountId,
|
|
'urn:uuid:sync-test-123',
|
|
'https://contacts.sync.icloud.com/123456/personal'
|
|
);
|
|
|
|
const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('John Sync').id;
|
|
|
|
// User manually sets birthday
|
|
testDb.prepare('UPDATE contacts SET birthday = ? WHERE id = ?').run('1990-05-15', contactId);
|
|
|
|
// Simulate sync update (only update NULL fields)
|
|
const contact = testDb.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
|
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
// website is NULL, should update
|
|
if (contact.website === null) {
|
|
updates.push('website = ?');
|
|
values.push('https://john.example.com');
|
|
}
|
|
|
|
// birthday is NOT NULL (user set it), should not update
|
|
if (contact.birthday === null) {
|
|
updates.push('birthday = ?');
|
|
values.push('1985-01-01');
|
|
}
|
|
|
|
// organization is NOT NULL, should not update
|
|
if (contact.organization === null) {
|
|
updates.push('organization = ?');
|
|
values.push('Different Corp');
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
values.push(contactId);
|
|
testDb.prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
const updated = testDb.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
|
|
|
// website should be updated (was NULL)
|
|
assert.strictEqual(updated.website, 'https://john.example.com');
|
|
|
|
// birthday should remain unchanged (user's manual value)
|
|
assert.strictEqual(updated.birthday, '1990-05-15');
|
|
|
|
// organization should remain unchanged
|
|
assert.strictEqual(updated.organization, 'SyncCorp');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Multi-Value Validators
|
|
// ========================================
|
|
|
|
describe('Multi-Value Validators', () => {
|
|
let validatePhones, validateEmails, validateAddresses;
|
|
|
|
before(async () => {
|
|
const validators = await import('./server/routes/contacts.js');
|
|
validatePhones = validators.validatePhones;
|
|
validateEmails = validators.validateEmails;
|
|
validateAddresses = validators.validateAddresses;
|
|
});
|
|
|
|
describe('validatePhones', () => {
|
|
it('should reject non-array input', () => {
|
|
const result = validatePhones('not-an-array');
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Phones must be an array');
|
|
});
|
|
|
|
it('should reject null element', () => {
|
|
const result = validatePhones([null]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Phone entry must be an object');
|
|
});
|
|
|
|
it('should reject primitive element', () => {
|
|
const result = validatePhones(['string']);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Phone entry must be an object');
|
|
});
|
|
|
|
it('should reject phone without label', () => {
|
|
const result = validatePhones([{ value: '+123' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Phone requires label and value');
|
|
});
|
|
|
|
it('should reject phone with whitespace-only label', () => {
|
|
const result = validatePhones([{ label: ' ', value: '+123' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Phone label invalid or too long');
|
|
});
|
|
|
|
it('should reject phone with whitespace-only value', () => {
|
|
const result = validatePhones([{ label: 'mobile', value: ' ' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Phone value invalid or too long');
|
|
});
|
|
|
|
it('should reject phone with too long label', () => {
|
|
const result = validatePhones([{ label: 'x'.repeat(51), value: '+123' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.ok(result.error.includes('Phone label invalid or too long'));
|
|
});
|
|
|
|
it('should reject non-boolean isPrimary', () => {
|
|
const result = validatePhones([{ label: 'mobile', value: '+123', isPrimary: 'true' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Phone isPrimary must be boolean');
|
|
});
|
|
|
|
it('should reject array exceeding max length', () => {
|
|
const phones = Array(21).fill({ label: 'mobile', value: '+123' });
|
|
const result = validatePhones(phones);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Too many phone entries (max 20)');
|
|
});
|
|
|
|
it('should accept valid phones array', () => {
|
|
const result = validatePhones([
|
|
{ label: 'mobile', value: '+1234567890', isPrimary: true },
|
|
{ label: 'work', value: '+0987654321' }
|
|
]);
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
|
|
it('should accept phones at max array length', () => {
|
|
const phones = Array(20).fill({ label: 'mobile', value: '+123' });
|
|
const result = validatePhones(phones);
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
});
|
|
|
|
describe('validateEmails', () => {
|
|
it('should reject non-array input', () => {
|
|
const result = validateEmails('not-an-array');
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Emails must be an array');
|
|
});
|
|
|
|
it('should reject null element', () => {
|
|
const result = validateEmails([null]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Email entry must be an object');
|
|
});
|
|
|
|
it('should reject primitive element', () => {
|
|
const result = validateEmails([42]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Email entry must be an object');
|
|
});
|
|
|
|
it('should reject email without value', () => {
|
|
const result = validateEmails([{ label: 'work' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Email requires label and value');
|
|
});
|
|
|
|
it('should reject email with whitespace-only label', () => {
|
|
const result = validateEmails([{ label: ' ', value: 'test@example.com' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Email label invalid or too long');
|
|
});
|
|
|
|
it('should reject email with whitespace-only value', () => {
|
|
const result = validateEmails([{ label: 'work', value: ' ' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Email value invalid or too long');
|
|
});
|
|
|
|
it('should reject invalid email format', () => {
|
|
const result = validateEmails([{ label: 'work', value: 'notanemail' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Email value must be a valid email address');
|
|
});
|
|
|
|
it('should reject email with too long value', () => {
|
|
const result = validateEmails([{ label: 'work', value: 'x'.repeat(256) }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.ok(result.error.includes('Email value invalid or too long'));
|
|
});
|
|
|
|
it('should reject non-boolean isPrimary', () => {
|
|
const result = validateEmails([{ label: 'work', value: 'test@example.com', isPrimary: 1 }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Email isPrimary must be boolean');
|
|
});
|
|
|
|
it('should reject array exceeding max length', () => {
|
|
const emails = Array(21).fill({ label: 'work', value: 'test@example.com' });
|
|
const result = validateEmails(emails);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Too many email entries (max 20)');
|
|
});
|
|
|
|
it('should accept valid emails array', () => {
|
|
const result = validateEmails([
|
|
{ label: 'work', value: 'john@work.com', isPrimary: true },
|
|
{ label: 'home', value: 'john@home.com' }
|
|
]);
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
|
|
it('should accept emails at max array length', () => {
|
|
const emails = Array(20).fill({ label: 'work', value: 'test@example.com' });
|
|
const result = validateEmails(emails);
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
});
|
|
|
|
describe('validateAddresses', () => {
|
|
it('should reject non-array input', () => {
|
|
const result = validateAddresses('not-an-array');
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Addresses must be an array');
|
|
});
|
|
|
|
it('should reject null element', () => {
|
|
const result = validateAddresses([null]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Address entry must be an object');
|
|
});
|
|
|
|
it('should reject undefined element', () => {
|
|
const result = validateAddresses([undefined]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Address entry must be an object');
|
|
});
|
|
|
|
it('should reject address without label', () => {
|
|
const result = validateAddresses([{ street: '123 Main St' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Address requires label');
|
|
});
|
|
|
|
it('should reject address with whitespace-only label', () => {
|
|
const result = validateAddresses([{ label: '\t\n ', street: '123 Main St' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Address label invalid or too long');
|
|
});
|
|
|
|
it('should reject address with too long street', () => {
|
|
const result = validateAddresses([{ label: 'home', street: 'x'.repeat(256) }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.ok(result.error.includes('Address street invalid or too long'));
|
|
});
|
|
|
|
it('should reject non-boolean isPrimary', () => {
|
|
const result = validateAddresses([{ label: 'home', street: '123 Main', isPrimary: 'yes' }]);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Address isPrimary must be boolean');
|
|
});
|
|
|
|
it('should reject array exceeding max length', () => {
|
|
const addresses = Array(21).fill({ label: 'home' });
|
|
const result = validateAddresses(addresses);
|
|
assert.strictEqual(result.valid, false);
|
|
assert.strictEqual(result.error, 'Too many address entries (max 20)');
|
|
});
|
|
|
|
it('should accept valid addresses array', () => {
|
|
const result = validateAddresses([
|
|
{
|
|
label: 'home',
|
|
street: '123 Main St',
|
|
city: 'Springfield',
|
|
state: 'IL',
|
|
postalCode: '62701',
|
|
country: 'USA',
|
|
isPrimary: true
|
|
},
|
|
{
|
|
label: 'work',
|
|
street: '456 Office Blvd',
|
|
city: 'Metropolis'
|
|
}
|
|
]);
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
|
|
it('should accept addresses at max array length', () => {
|
|
const addresses = Array(20).fill({ label: 'home' });
|
|
const result = validateAddresses(addresses);
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// CardDAV API Routes
|
|
// ========================================
|
|
|
|
describe('CardDAV API Routes', () => {
|
|
let apiTestDb;
|
|
|
|
before(async () => {
|
|
// Create in-memory test database for API routes
|
|
apiTestDb = new Database(':memory:');
|
|
apiTestDb.pragma('foreign_keys = ON');
|
|
|
|
// Create minimal schema
|
|
apiTestDb.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 to create CardDAV tables
|
|
const migration30 = MIGRATIONS.find(m => m.version === 30);
|
|
if (!migration30) {
|
|
throw new Error('Migration 30 not found');
|
|
}
|
|
apiTestDb.exec(migration30.up);
|
|
|
|
// Override db.get() to use our test database
|
|
const dbModule = await import('./server/db.js');
|
|
dbModule._setTestDatabase(apiTestDb);
|
|
});
|
|
|
|
describe('Account Management', () => {
|
|
it('GET /accounts - should return empty array when no accounts', async () => {
|
|
const cardavRouter = await import('./server/routes/cardav.js');
|
|
|
|
const req = { params: {}, query: {}, body: {} };
|
|
const res = {
|
|
statusCode: 200,
|
|
status(code) { this.statusCode = code; return this; },
|
|
json(data) { this.data = data; return this; },
|
|
};
|
|
|
|
const getHandler = cardavRouter.default.stack.find(
|
|
layer => layer.route?.path === '/accounts' && layer.route.methods.get
|
|
)?.route?.stack[0]?.handle;
|
|
|
|
assert.ok(getHandler, 'GET /accounts handler should exist');
|
|
await getHandler(req, res);
|
|
|
|
assert.strictEqual(res.statusCode, 200);
|
|
assert.ok(Array.isArray(res.data.data));
|
|
assert.strictEqual(res.data.data.length, 0);
|
|
});
|
|
});
|
|
});
|