diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..08cb7c7 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,314 @@ +# CardDAV API Routes Implementation - Fortschritt + +**Stand:** 2026-05-04, alle 15 Tasks abgeschlossen ✅ +**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md` +**Status:** Implementierung vollständig, bereit für Final Review + +## Abgeschlossene Tasks + +### ✅ Task 1: Multi-Value Array Validators +**Commit:** a715475 + 930800e (fixes) + +- Implementiert: `validatePhones()`, `validateEmails()`, `validateAddresses()` +- Location: `server/routes/contacts.js` +- Tests: 33 neue Tests in test-carddav.js +- Validierungen: Arrays, Objekt-Struktur, Pflichtfelder, Max-Längen, Email-Format, isPrimary-Typ, Array-Länge (max 20) + +**Review-Findings & Fixes:** +- Spec Compliance: Minor extras (logging) - akzeptabel +- Code Quality Issues behoben: + - Whitespace-Validierung ergänzt + - Null-Guards hinzugefügt + - Email-Format-Check ergänzt + - isPrimary Typ-Validierung + - DoS-Schutz: Array-Länge begrenzt auf 20 + +### ✅ Task 2: CardDAV Router Setup +**Commits:** cf68bff, 930800e + +- Erstellt: `server/routes/cardav.js` mit Express Router +- Implementiert: GET /accounts Endpoint +- Test-Infrastruktur: + - `_setTestDatabase()` / `_resetTestDatabase()` in `server/db.js` + - before()/after() Hooks in test-carddav.js + - Migration 30 wird in Tests angewendet +- Tests: 2 Tests (empty array, populated with shape validation) + +**Review-Findings & Fixes:** +- Unused imports entfernt (wurden für Task 3 wieder gebraucht) +- Error-Message leakage behoben (generic "Interner Fehler") +- Test-Cleanup mit after() Hook +- `after` aus node:test importiert + +### ✅ Task 3: POST /accounts - Create Account +**Commit:** f7eb73b + +- Implementiert: POST /accounts mit Validation +- Validierung: name, cardavUrl, username, password (alle required, mit max lengths) +- Delegiert an: `CardDAVSync.addAccount()` +- Response: 201 mit `{ account, addressbooks }` +- Tests: 2 Tests (success case, validation failure) + +**Test-Mocking:** +- `_mockTestConnection()` in cardav-sync.js hinzugefügt +- Mock gibt fake addressbooks zurück für Tests +- Mock wird in before() gesetzt, in after() zurückgesetzt + +**Wichtige Änderung:** +- `addAccount()` Return-Wert geändert von `{ accountId, addressbooks }` zu `{ account: { id, name, cardavUrl, username, createdAt, lastSync }, addressbooks }` + +### ✅ Task 4: DELETE /accounts/:id - Delete Account +**Commit:** ca92cb2 + +- Implementiert: DELETE /accounts/:id mit ID-Validierung +- Validierung: ID muss positive Ganzzahl sein +- Delegiert an: `CardDAVSync.deleteAccount(id)` +- Response: 200 mit `{ deleted: true }` +- Tests: 2 Tests (success case mit cascade, invalid ID → 400) +- CASCADE-Verhalten: Foreign Key Constraints löschen addressbooks + contacts automatisch + +### ✅ Task 5: POST /accounts/:id/test - Test Connection +**Commit:** dd5ac88 + +- Implementiert: POST /accounts/:id/test mit ID-Validierung +- Lädt Account aus DB (404 wenn nicht gefunden) +- Delegiert an: `CardDAVSync.testConnection(url, username, password)` +- Response: 200 mit `{ ok, addressbooks }` +- Test: 1 Test (success case mit addressbooks) +- Verwendet gemockten testConnection für konsistente Test-Results + +### ✅ Task 6: GET /accounts/:id/addressbooks - List Addressbooks +**Commit:** 12e8edf + +- Implementiert: GET /accounts/:id/addressbooks mit ID-Validierung +- Query: carddav_addressbook_selection table, ORDER BY addressbook_name +- Response: 200 mit Array von `{ id, url, name, enabled }` +- Tests: 2 Tests (success case mit shape validation, empty array für non-existent account) + +### ✅ Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks +**Commit:** c078a48 + +- Implementiert: POST /accounts/:id/addressbooks/refresh mit ID-Validierung +- Lädt Account aus DB (404 wenn nicht gefunden) +- Delegiert an: `CardDAVSync.discoverAddressbooks(accountId)` für PROPFIND +- Query updated addressbooks nach Discovery +- Response: 200 mit Array von addressbooks +- Test: 1 Test (success case) + +### ✅ Task 8: Add bool Validator +**Commit:** 362f711 + +- Implementiert: `bool(val, field)` Validator in server/middleware/validate.js +- Validiert: type === 'boolean', required by default +- Exportiert: bool in export statement +- Keine Tests (wird in Task 9 verwendet) + +### ✅ Task 9: PUT /addressbooks/:id - Toggle Addressbook +**Commit:** 9ec7fda + +- Implementiert: PUT /addressbooks/:id Route in server/routes/cardav.js +- Validierung: ID muss positive Ganzzahl sein, enabled muss boolean sein +- Delegiert an: `CardDAVSync.toggleAddressbook(id, enabled)` +- Response: 200 mit `{ updated: true, enabled: boolean }` +- Tests: 2 Tests (success case mit toggle, validation failure für invalid enabled) +- bool Validator verwendet + +### ✅ Task 10: POST /accounts/:id/sync - Sync Account +**Commit:** 674fe79 + +- Implementiert: POST /accounts/:id/sync Route in server/routes/cardav.js +- Validierung: ID muss positive Ganzzahl sein +- Lädt Account aus DB (404 wenn nicht gefunden) +- Delegiert an: `CardDAVSync.syncAccount(accountId)` +- Response: 200 mit `{ synced, contactsAdded, contactsUpdated }` +- Tests: 2 Tests (success case, 404 für non-existent account) +- Mock: `_mockSyncAccount()` für Tests hinzugefügt (Pattern wie `_mockTestConnection`) + +### ✅ Task 11: GET /contacts/:id - With Multi-Value Fields +**Commit:** fe8af33 + +- Implementiert: GET /contacts/:id Route in `server/routes/contacts.js` +- Queries: Separate Abfragen für `contact_phones`, `contact_emails`, `contact_addresses` +- Mapping: is_primary (Integer DB) → isPrimary (Boolean Response), snake_case → camelCase +- Sortierung: ORDER BY is_primary DESC, id ASC (Primary-Einträge zuerst) +- Response-Format: `{ ...contact, phones: [], emails: [], addresses: [] }` +- Tests: 2 neue Tests + - Contact mit allen Multi-Value Fields (phones, emails, addresses) + - Contact ohne Multi-Value Fields (leere Arrays) +- TDD-Workflow eingehalten: RED → GREEN → Commit + +### ✅ Task 12: POST /contacts - Create With Multi-Value Fields +**Commit:** 966a6d4 + +- Implementiert: POST /contacts erweitert um phones, emails, addresses Arrays +- Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Insert +- Transaktionen: `db.transaction()` für atomare Contact + Multi-Values Inserts +- Backward Compatible: Optional fields, leere Arrays wenn nicht angegeben +- Response: Contact mit allen Multi-Value Fields inkl. generierte IDs +- Tests: 3 neue Tests + - Contact mit allen Multi-Value Fields erstellen + - Validierung: Fehler bei invaliden Phone-Daten (400) + - Backward Compatibility: Contact ohne Multi-Values (leere Arrays) +- Refactoring: `loadMultiValueFields(contactId)` Helper extrahiert (DRY) +- TDD-Workflow eingehalten: RED → GREEN → REFACTOR → Commit + +### ✅ Task 13: PUT /contacts/:id - Update With Multi-Value Fields +**Commit:** 0dc303b + +- Implementiert: PUT /contacts/:id erweitert um phones, emails, addresses Arrays +- Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Update +- Replacement-Semantik: DELETE alle existierenden Multi-Values, dann INSERT neue +- Transaktionen: `db.transaction()` für atomare Contact + Multi-Values Updates +- Backward Compatible: Multi-Value Fields nur ersetzt wenn im Body vorhanden +- Response: Contact mit allen Multi-Value Fields via `loadMultiValueFields()` +- Tests: 3 neue Tests + - Update mit Multi-Value Fields (Replacement-Semantik verifiziert) + - Validierung: Fehler bei invaliden Phone-Daten (400) + - Backward Compatibility: Update ohne Multi-Values lässt sie unverändert +- TDD-Workflow eingehalten: RED → Verify RED → GREEN → Verify GREEN → Commit + +### ✅ Task 14: OpenAPI Documentation +**Commit:** d0eb638 + +- Dokumentiert: Alle 8 CardDAV Management Routes in `server/openapi.js` + - GET /api/v1/contacts/cardav/accounts + - POST /api/v1/contacts/cardav/accounts + - DELETE /api/v1/contacts/cardav/accounts/{id} + - POST /api/v1/contacts/cardav/accounts/{id}/test + - GET /api/v1/contacts/cardav/accounts/{id}/addressbooks + - POST /api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh + - PUT /api/v1/contacts/cardav/addressbooks/{id} + - POST /api/v1/contacts/cardav/accounts/{id}/sync +- Aktualisiert: Bestehende Contacts-Routes mit Multi-Value-Beschreibungen + - POST /api/v1/contacts: "Create contact with multi-value fields" + - GET /api/v1/contacts/{id}: "Get contact with multi-value fields" + - PUT /api/v1/contacts/{id}: "Update contact with multi-value fields" +- Alle Routes mit korrektem Tag ('Contacts'), CSRF-Header-Params für stateChanging +- OpenAPI Schema baut ohne Fehler + +### ✅ Task 15: Mount CardDAV Router +**Commit:** 8891097 + +- Import hinzugefügt: `import cardavRouter from './routes/cardav.js'` +- Router gemountet: `app.use('/api/v1/contacts/cardav', cardavRouter)` +- Montage-Reihenfolge: CardDAV-Router VOR Contacts-Router (Express route matching order) +- Auth-Middleware: Bereits global angewendet via `requireAuth` in index.js +- CSRF-Middleware: Automatisch über stateChanging-Flag in OpenAPI + CSRF-Middleware +- Integration Tests: Alle 109 Tests bestehen (20 Suites) +- Server startet erfolgreich auf Port 3000 + +## Wichtige Erkenntnisse + +### Test-Infrastruktur +1. **DB-Mocking:** `_setTestDatabase()` / `_resetTestDatabase()` in db.js +2. **CardDAV-Mocking:** `_mockTestConnection()` in cardav-sync.js +3. **before()/after() Pattern:** Setup in before(), Cleanup in after() +4. **Migration 30:** Muss in jedem Test-Setup angewendet werden für CardDAV-Tabellen + +### Code-Patterns +1. **Error-Handling:** Immer generic "Interner Fehler", niemals err.message leaken +2. **Validation:** str(), collectErrors(), MAX_TITLE (100), MAX_URL (500) +3. **Response-Format:** `{ data: ... }` für Success, `{ error: ..., code: ... }` für Fehler +4. **Status Codes:** 200 (GET), 201 (POST create), 400 (validation), 500 (server error) + +### Konventionen +- Tests nutzen Node built-in test runner (`node:test`) +- Test-DB ist in-memory SQLite (`:memory:`) +- Commits mit Co-Authored-By: Claude Sonnet 4.5 +- TDD-Workflow: Test → Run (fail) → Implement → Run (pass) → Commit + +## Zusammenfassung + +**Alle 15 Tasks vollständig implementiert:** +- ✅ Phase 0: Setup & Validators (Tasks 1-2) +- ✅ Phase 1: Account Management Routes (Tasks 3-4) +- ✅ Phase 2: Connection & Discovery Routes (Tasks 5-7) +- ✅ Phase 3: Toggle & Sync Routes (Tasks 8-10) +- ✅ Phase 4: Extended Contacts Routes (Tasks 11-13) +- ✅ Phase 5: OpenAPI Integration (Task 14) +- ✅ Phase 6: Server Integration (Task 15) + +**Nächste Schritte:** +1. Final Code Review (optional) +2. Release Prep via `/release-prep` Skill +3. Merge in main Branch + +## Commits-Übersicht + +``` +a715475 feat(contacts): add multi-value array validators +cf68bff feat(cardav): create cardav router with GET /accounts +930800e fix(cardav): improve router security and test coverage +f7eb73b feat(cardav): implement POST /accounts endpoint +ca92cb2 feat(cardav): implement DELETE /accounts/:id endpoint +38fa84c docs: update PROGRESS.md for completed Task 4 +dd5ac88 feat(cardav): implement POST /accounts/:id/test endpoint +2964696 docs: update PROGRESS.md for completed Task 5 +12e8edf feat(cardav): implement GET /accounts/:id/addressbooks endpoint +c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint +362f711 feat(validate): add bool validator +9ec7fda feat(cardav): implement PUT /addressbooks/:id endpoint +674fe79 feat(cardav): implement POST /accounts/:id/sync endpoint +fe8af33 feat(contacts): extend GET /contacts/:id with multi-value fields +966a6d4 feat(contacts): extend POST /contacts with multi-value fields +0dc303b feat(contacts): extend PUT /contacts/:id with multi-value fields +d0eb638 docs(openapi): add CardDAV routes and update contacts routes +8891097 feat(server): mount CardDAV router at /api/v1/contacts/cardav +``` + +## Test-Status + +- **Gesamt:** 109 Tests, alle bestehen +- **Suites:** 20 Suites +- **CardDAV API Routes Suite:** 14 Tests +- **Contacts API - Multi-Value Fields Suite:** 8 Tests + - GET /contacts/:id: 2 Tests + - POST /contacts: 3 Tests + - PUT /contacts/:id: 3 Tests + - Account Management (6 Tests): + - GET /accounts (empty) + - GET /accounts (populated with shape) + - POST /accounts (success) + - POST /accounts (validation failure) + - DELETE /accounts/:id (success with cascade) + - DELETE /accounts/:id (invalid ID → 400) + - Connection & Discovery (4 Tests): + - POST /accounts/:id/test (success) + - GET /accounts/:id/addressbooks (success with addressbooks) + - GET /accounts/:id/addressbooks (empty array) + - POST /accounts/:id/addressbooks/refresh (success) + - Addressbook Management (2 Tests): + - PUT /addressbooks/:id (toggle enabled/disabled) + - PUT /addressbooks/:id (validation failure) + - Sync (2 Tests): + - POST /accounts/:id/sync (success) + - POST /accounts/:id/sync (404 for non-existent account) + +## Branch & Remote + +- **Branch:** feature/cardav-contacts +- **Worktree:** /home/ulsklyc/Workspace/oikos/.worktrees/feature/cardav-contacts +- **Base:** main (commit 6cc7267) +- **Bereit zum Pushen:** Ja, nach diesem Status-Commit + +## Task-Liste Status + +``` +#1. [completed] Task 1: Multi-Value Array Validators +#2. [completed] Task 2: CardDAV Router Setup +#3. [completed] Task 3: POST /accounts - Create Account +#4. [completed] Task 4: DELETE /accounts/:id - Delete Account +#5. [completed] Task 5: POST /accounts/:id/test - Test Connection +#6. [completed] Task 6: GET /accounts/:id/addressbooks - List Addressbooks +#7. [completed] Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks +#8. [completed] Task 8: Add bool validator to validate.js +#9. [completed] Task 9: PUT /addressbooks/:id - Toggle Addressbook +#10. [completed] Task 10: POST /accounts/:id/sync - Sync Account +#11. [completed] Task 11: GET /contacts/:id - With Multi-Values +#12. [completed] Task 12: POST /contacts - Create With Multi-Values +#13. [completed] Task 13: PUT /contacts/:id - Update With Multi-Values +#14. [completed] Task 14: Document All Routes in OpenAPI +#15. [completed] Task 15: Mount CardDAV Router +``` + +**Alle 15 Tasks abgeschlossen! 🎉** diff --git a/docs/designs/2026-05-04-cardav-api-routes-implementation.md b/docs/designs/2026-05-04-cardav-api-routes-implementation.md new file mode 100644 index 0000000..f83556d --- /dev/null +++ b/docs/designs/2026-05-04-cardav-api-routes-implementation.md @@ -0,0 +1,910 @@ +# CardDAV API Routes — Implementation Design + +**Date:** 2026-05-04 +**Status:** Approved +**Related:** [CardDAV Contacts Design](../../designs/2026-05-04-cardav-contacts-design.md) + +## Überblick + +Implementierung von 11 API Routes für CardDAV Contacts Integration: +- 8 neue CardDAV Management Routes (Account CRUD, Addressbook Discovery, Sync) +- 3 erweiterte Contacts Routes (Multi-Value-Felder: phones, emails, addresses) + +## Entscheidungen + +### Route-Organisation +- **CardDAV Management Routes:** Neue Datei `server/routes/cardav.js` +- **Extended Contacts Routes:** Existierende `server/routes/contacts.js` erweitern +- **Rationale:** Klare Trennung (Contact CRUD vs. CardDAV Management), folgt Oikos One-Router-Per-Module Pattern + +### Implementierungs-Reihenfolge +**User Flow Approach:** +1. Account Management (POST/GET/DELETE) +2. Connection Test +3. Addressbook Discovery & Toggle +4. Sync Operations +5. Extended Contacts Routes + +**Rationale:** Natürliche User Journey, einfacher zu testen + +### Architektur +**Route-Level Validation mit Service Delegation:** +- Routes validieren Input mit `validate.js` Middleware +- Routes delegieren Business Logic an `cardav-sync.js` +- **Rationale:** Konsistent mit existierenden Oikos-Routes, bessere User-facing Error Messages + +### Error Handling +**Einfaches Fallback:** +```javascript +catch (err) { + log.error('CardDAV error:', err); + res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 }); +} +``` +**Rationale:** Funktioniert sofort, Error-Klassen können später eingeführt werden + +--- + +## File Structure + +### Neue Dateien +- `server/routes/cardav.js` — CardDAV Management Routes + +### Geänderte Dateien +- `server/routes/contacts.js` — Extended Contacts Routes (Multi-Values) +- `server/index.js` — Mount cardav.js Router +- `server/openapi.js` — 11 neue Path Definitionen +- `test-carddav.js` — API Route Tests + +### Mount Point +```javascript +// server/index.js +import cardavRouter from './routes/cardav.js'; +app.use('/api/v1/contacts/cardav', cardavRouter); +``` + +Alle CardDAV-Routes unter `/api/v1/contacts/cardav/*`, Extended Contacts unter `/api/v1/contacts/*`. + +--- + +## Route Definitions + +### CardDAV Management Routes + +#### 1. POST /api/v1/contacts/cardav/accounts +**Zweck:** Account erstellen und Addressbooks discovern + +**Request:** +```json +{ + "name": "iCloud", + "cardavUrl": "https://contacts.icloud.com", + "username": "user@icloud.com", + "password": "app-specific-password" +} +``` + +**Validation:** +- `name`: str, max MAX_TITLE, required +- `cardavUrl`: str, max MAX_URL, required +- `username`: str, max MAX_TITLE, required +- `password`: str, max MAX_TITLE, required + +**Service Call:** +```javascript +const result = await CardDAVSync.addAccount(name, cardavUrl, username, password); +``` + +**Response:** `201 Created` +```json +{ + "data": { + "account": { + "id": 1, + "name": "iCloud", + "cardavUrl": "https://contacts.icloud.com", + "username": "user@icloud.com", + "lastSync": null + }, + "addressbooks": [ + { "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 } + ] + } +} +``` + +--- + +#### 2. GET /api/v1/contacts/cardav/accounts +**Zweck:** Alle Accounts auflisten + +**Service Call:** +```javascript +const accounts = await CardDAVSync.getAllAccounts(); +``` + +**Response:** `200 OK` +```json +{ + "data": [ + { + "id": 1, + "name": "iCloud", + "cardavUrl": "https://contacts.icloud.com", + "username": "user@icloud.com", + "lastSync": "2026-05-04T10:30:00Z" + } + ] +} +``` + +--- + +#### 3. DELETE /api/v1/contacts/cardav/accounts/:id +**Zweck:** Account löschen (CASCADE löscht addressbooks + contacts) + +**Validation:** +- `id`: parseInt, must be > 0 + +**Service Call:** +```javascript +await CardDAVSync.deleteAccount(id); +``` + +**Response:** `200 OK` +```json +{ + "data": { "deleted": true } +} +``` + +--- + +#### 4. POST /api/v1/contacts/cardav/accounts/:id/test +**Zweck:** Connection testen (ohne Account zu erstellen) + +**Validation:** +- `id`: parseInt, must be > 0 + +**Logic:** +1. Account aus DB laden +2. `testConnection(cardavUrl, username, password)` aufrufen + +**Response:** `200 OK` +```json +{ + "data": { + "ok": true, + "addressbooks": [...] + } +} +``` + +--- + +#### 5. GET /api/v1/contacts/cardav/accounts/:id/addressbooks +**Zweck:** Addressbooks für Account auflisten + +**Validation:** +- `id`: parseInt, must be > 0 + +**DB Query:** +```sql +SELECT id, addressbook_url as url, addressbook_name as name, enabled +FROM carddav_addressbook_selection +WHERE account_id = ? +ORDER BY addressbook_name +``` + +**Response:** `200 OK` +```json +{ + "data": [ + { "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }, + { "id": 2, "url": "https://...", "name": "Work", "enabled": 0 } + ] +} +``` + +--- + +#### 6. POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh +**Zweck:** Addressbooks neu discovern (PROPFIND) + +**Validation:** +- `id`: parseInt, must be > 0 + +**Logic:** +1. Account aus DB laden +2. `discoverAddressbooks(account)` aufrufen +3. Addressbooks aus DB neu laden + +**Response:** `200 OK` +```json +{ + "data": [ + { "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 } + ] +} +``` + +--- + +#### 7. PUT /api/v1/contacts/cardav/addressbooks/:id +**Zweck:** Addressbook enable/disable + +**Request:** +```json +{ + "enabled": true +} +``` + +**Validation:** +- `id`: parseInt, must be > 0 +- `enabled`: bool, required + +**Service Call:** +```javascript +await CardDAVSync.toggleAddressbook(id, enabled); +``` + +**Response:** `200 OK` +```json +{ + "data": { "id": 1, "enabled": true } +} +``` + +--- + +#### 8. POST /api/v1/contacts/cardav/accounts/:id/sync +**Zweck:** Account syncen (alle enabled addressbooks) + +**Validation:** +- `id`: parseInt, must be > 0 + +**Logic:** +1. Account aus DB laden +2. `syncAccount(account)` aufrufen + +**Response:** `200 OK` +```json +{ + "data": { + "synced": 15, + "errors": 0 + } +} +``` + +--- + +### Extended Contacts Routes + +#### 9. GET /api/v1/contacts/:id +**Zweck:** Kontakt mit allen Multi-Value-Feldern laden + +**Validation:** +- `id`: parseInt, must be > 0 + +**DB Queries:** +```sql +SELECT * FROM contacts WHERE id = ?; +SELECT * FROM contact_phones WHERE contact_id = ?; +SELECT * FROM contact_emails WHERE contact_id = ?; +SELECT * FROM contact_addresses WHERE contact_id = ?; +``` + +**Response:** `200 OK` +```json +{ + "data": { + "id": 1, + "name": "Alice Smith", + "category": "Sonstiges", + "organization": "Tech Corp", + "jobTitle": "Developer", + "birthday": "1990-01-15", + "website": "https://alice.dev", + "nickname": "Ali", + "notes": "Great developer", + "cardavAccountId": 1, + "cardavUid": "urn:uuid:alice-123", + "phones": [ + { "id": 1, "label": "mobile", "value": "+1234567890", "isPrimary": 1 }, + { "id": 2, "label": "work", "value": "+0987654321", "isPrimary": 0 } + ], + "emails": [ + { "id": 1, "label": "home", "value": "alice@home.com", "isPrimary": 1 } + ], + "addresses": [ + { + "id": 1, + "label": "home", + "street": "123 Main St", + "city": "Springfield", + "state": "IL", + "postalCode": "62701", + "country": "USA", + "isPrimary": 1 + } + ] + } +} +``` + +--- + +#### 10. POST /api/v1/contacts +**Zweck:** Kontakt mit Multi-Values erstellen + +**Request:** +```json +{ + "name": "Bob Jones", + "category": "Sonstiges", + "phones": [ + { "label": "mobile", "value": "+1111111111", "isPrimary": true } + ], + "emails": [ + { "label": "work", "value": "bob@work.com", "isPrimary": true } + ], + "addresses": [] +} +``` + +**Validation:** +- `name`: str, max MAX_TITLE, required +- `category`: oneOf(VALID_CATEGORIES), default 'Sonstiges' +- `phones`: validatePhones() (siehe Validation Schema) +- `emails`: validateEmails() +- `addresses`: validateAddresses() +- Alle anderen Felder optional + +**Logic (Transaction):** +```javascript +const transaction = db.get().transaction(() => { + // 1. INSERT contact + const result = db.get().prepare(` + INSERT INTO contacts (name, category, ...) + VALUES (?, ?, ...) + `).run(...); + const contactId = result.lastInsertRowid; + + // 2. INSERT phones (bulk) + if (phones?.length) { + const placeholders = phones.map(() => '(?, ?, ?, ?)').join(', '); + const values = phones.flatMap(p => [contactId, p.label, p.value, p.isPrimary ? 1 : 0]); + db.get().prepare(`INSERT INTO contact_phones (...) VALUES ${placeholders}`).run(...values); + } + + // 3. INSERT emails (bulk) + // 4. INSERT addresses (bulk) + + return contactId; +}); + +const contactId = transaction(); +``` + +**Response:** `201 Created` +```json +{ + "data": { /* Contact mit allen Multi-Values */ } +} +``` + +--- + +#### 11. PUT /api/v1/contacts/:id +**Zweck:** Kontakt mit Multi-Values updaten + +**Request:** +```json +{ + "name": "Bob Jones Updated", + "phones": [ + { "label": "mobile", "value": "+2222222222", "isPrimary": true } + ] +} +``` + +**Validation:** +- `id`: parseInt, must be > 0 +- Alle Felder optional (nur gesendete werden geupdatet) +- **Multi-Value-Felder (phones/emails/addresses):** REPLACEMENT-Semantik — wenn gesendet, werden ALLE existierenden Werte gelöscht und durch die gesendeten ersetzt. Client muss vollständiges Array schicken, nicht nur Änderungen. + +**Logic (Transaction):** +```javascript +const transaction = db.get().transaction(() => { + // 1. UPDATE contacts (nur gesendete Felder) + const updates = []; + const params = []; + if (req.body.name !== undefined) { + updates.push('name = ?'); + params.push(req.body.name); + } + // ... andere Felder + params.push(id); + db.get().prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...params); + + // 2. Wenn phones gesendet: DELETE + INSERT + if (req.body.phones !== undefined) { + db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id); + // ... bulk INSERT wie in POST + } + + // 3. Wenn emails gesendet: DELETE + INSERT + // 4. Wenn addresses gesendet: DELETE + INSERT +}); + +transaction(); +``` + +**Response:** `200 OK` +```json +{ + "data": { /* Updated Contact mit allen Multi-Values */ } +} +``` + +--- + +## Validation Schema + +### CardDAV Routes + +```javascript +import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js'; + +// POST /accounts +const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); +const vUrl = str(req.body.cardavUrl, 'CardDAV URL', { max: MAX_URL }); +const vUsername = str(req.body.username, 'Username', { max: MAX_TITLE }); +const vPassword = str(req.body.password, 'Password', { max: MAX_TITLE }); +const errors = collectErrors([vName, vUrl, vUsername, vPassword]); +if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + +// PUT /addressbooks/:id +const vEnabled = bool(req.body.enabled, 'Enabled'); +if (vEnabled.error) return res.status(400).json({ error: vEnabled.error, code: 400 }); + +// Alle :id params +const id = parseInt(req.params.id, 10); +if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 }); +``` + +### Extended Contacts Routes + +**Multi-Value Array Validators:** + +```javascript +// phones: [{ label, value, isPrimary? }] +function validatePhones(phones) { + if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' }; + for (let p of phones) { + if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' }; + if (typeof p.label !== 'string' || p.label.length > 50) { + return { valid: false, error: 'Phone label invalid or too long' }; + } + if (typeof p.value !== 'string' || p.value.length > 50) { + return { valid: false, error: 'Phone value invalid or too long' }; + } + } + return { valid: true }; +} + +// emails: [{ label, value, isPrimary? }] +function validateEmails(emails) { + if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' }; + for (let e of emails) { + if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' }; + if (typeof e.label !== 'string' || e.label.length > 50) { + return { valid: false, error: 'Email label invalid or too long' }; + } + if (typeof e.value !== 'string' || e.value.length > 255) { + return { valid: false, error: 'Email value invalid or too long' }; + } + } + return { valid: true }; +} + +// addresses: [{ label, street?, city?, state?, postalCode?, country?, isPrimary? }] +function validateAddresses(addresses) { + if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' }; + for (let a of addresses) { + if (!a.label) return { valid: false, error: 'Address requires label' }; + if (typeof a.label !== 'string' || a.label.length > 50) { + return { valid: false, error: 'Address label invalid or too long' }; + } + // street, city, state, postalCode, country sind optional + // Wenn vorhanden: Type-Check + Max-Length (255 für Text-Felder) + const fields = ['street', 'city', 'state', 'postalCode', 'country']; + for (let field of fields) { + if (a[field] !== undefined && (typeof a[field] !== 'string' || a[field].length > 255)) { + return { valid: false, error: `Address ${field} invalid or too long` }; + } + } + } + return { valid: true }; +} +``` + +**Usage in Routes:** + +```javascript +// POST/PUT /contacts +if (req.body.phones !== undefined) { + const phoneCheck = validatePhones(req.body.phones); + if (!phoneCheck.valid) return res.status(400).json({ error: phoneCheck.error, code: 400 }); +} + +if (req.body.emails !== undefined) { + const emailCheck = validateEmails(req.body.emails); + if (!emailCheck.valid) return res.status(400).json({ error: emailCheck.error, code: 400 }); +} + +if (req.body.addresses !== undefined) { + const addressCheck = validateAddresses(req.body.addresses); + if (!addressCheck.valid) return res.status(400).json({ error: addressCheck.error, code: 400 }); +} +``` + +--- + +## Service Integration + +### Import Pattern + +```javascript +// server/routes/cardav.js +import { createLogger } from '../logger.js'; +import express from 'express'; +import * as db from '../db.js'; +import * as CardDAVSync from '../services/cardav-sync.js'; +import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js'; + +const log = createLogger('CardDAV'); +const router = express.Router(); +``` + +### Service Call Examples + +**Async/Await Pattern:** +```javascript +router.post('/accounts', async (req, res) => { + try { + // Validation... + + const result = await CardDAVSync.addAccount( + vName.value, + vUrl.value, + vUsername.value, + vPassword.value + ); + + res.status(201).json({ data: result }); + } catch (err) { + log.error('Error adding CardDAV account:', err); + res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 }); + } +}); +``` + +**DB-Direct Queries** (wo kein Service existiert): +```javascript +router.get('/accounts/:id/addressbooks', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({ error: 'Invalid ID', code: 400 }); + + const addressbooks = db.get().prepare(` + SELECT id, addressbook_url as url, addressbook_name as name, enabled + FROM carddav_addressbook_selection + WHERE account_id = ? + ORDER BY addressbook_name + `).all(id); + + res.json({ data: addressbooks }); + } catch (err) { + log.error('Error fetching addressbooks:', err); + res.status(500).json({ error: err.message, code: 500 }); + } +}); +``` + +### Transaction Handling + +**Extended Contacts Routes** nutzen Transactions für atomare Multi-Value-Updates: + +```javascript +router.post('/', async (req, res) => { + try { + // Validation... + + const transaction = db.get().transaction(() => { + // 1. Insert contact + const result = db.get().prepare(` + INSERT INTO contacts (name, category, organization, job_title, birthday, website, nickname, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + vName.value, + vCategory.value || 'Sonstiges', + req.body.organization || null, + req.body.jobTitle || null, + req.body.birthday || null, + req.body.website || null, + req.body.nickname || null, + req.body.notes || null + ); + const contactId = result.lastInsertRowid; + + // 2. Insert phones (bulk) + if (req.body.phones?.length) { + const phonePlaceholders = req.body.phones.map(() => '(?, ?, ?, ?)').join(', '); + const phoneValues = req.body.phones.flatMap(p => [ + contactId, + p.label, + p.value, + p.isPrimary ? 1 : 0 + ]); + db.get().prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES ${phonePlaceholders} + `).run(...phoneValues); + } + + // 3. Insert emails (analog) + // 4. Insert addresses (analog) + + return contactId; + }); + + const contactId = transaction(); + + // Fetch full contact with multi-values + const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId); + const phones = db.get().prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId); + const emails = db.get().prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId); + const addresses = db.get().prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId); + + res.status(201).json({ + data: { + ...contact, + phones, + emails, + addresses + } + }); + } catch (err) { + log.error('Error creating contact:', err); + res.status(500).json({ error: err.message, code: 500 }); + } +}); +``` + +--- + +## OpenAPI Integration + +Alle 11 Routes werden in `server/openapi.js` dokumentiert: + +```javascript +// CardDAV Management Routes +'/api/v1/contacts/cardav/accounts': { + get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }), + post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }), +}, + +'/api/v1/contacts/cardav/accounts/{id}': { + delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }), +}, + +'/api/v1/contacts/cardav/accounts/{id}/test': { + post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }), +}, + +'/api/v1/contacts/cardav/accounts/{id}/addressbooks': { + get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }), +}, + +'/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': { + post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }), +}, + +'/api/v1/contacts/cardav/addressbooks/{id}': { + put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), +}, + +'/api/v1/contacts/cardav/accounts/{id}/sync': { + post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }), +}, + +// Extended Contacts Routes (ersetzen existierende Definitionen) +'/api/v1/contacts/{id}': { + get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }), + put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }), +}, + +'/api/v1/contacts': { + get: op({ summary: 'List contacts', tag: 'Contacts' }), + post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }), +}, +``` + +**Hinweis:** Alle Routes bleiben unter dem `'Contacts'` Tag für konsistente Swagger-Gruppierung. + +--- + +## Testing Strategy + +### Test File Structure + +Neue Suite in `test-carddav.js`: + +```javascript +describe('CardDAV API Routes', () => { + + describe('Account Management', () => { + it('POST /accounts - should create account and discover addressbooks'); + it('POST /accounts - should return 400 for missing fields'); + it('GET /accounts - should list all accounts'); + it('GET /accounts - should return empty array when no accounts'); + it('DELETE /accounts/:id - should delete account and cascade addressbooks'); + it('DELETE /accounts/:id - should return 400 for invalid ID'); + it('POST /accounts/:id/test - should test connection'); + }); + + describe('Addressbook Management', () => { + it('GET /accounts/:id/addressbooks - should list addressbooks'); + it('GET /accounts/:id/addressbooks - should return empty array when none'); + it('POST /accounts/:id/addressbooks/refresh - should refresh addressbooks'); + it('PUT /addressbooks/:id - should enable addressbook'); + it('PUT /addressbooks/:id - should disable addressbook'); + it('PUT /addressbooks/:id - should return 400 for missing enabled field'); + }); + + describe('Sync Operations', () => { + it('POST /accounts/:id/sync - should return sync result structure'); + }); + + describe('Extended Contacts Routes', () => { + it('POST /contacts - should create contact with phones/emails/addresses'); + it('POST /contacts - should create contact without multi-values'); + it('POST /contacts - should return 400 for invalid phone array'); + it('POST /contacts - should return 400 for invalid email array'); + it('GET /contacts/:id - should return contact with all multi-values'); + it('GET /contacts/:id - should return 404 for non-existent contact'); + it('PUT /contacts/:id - should update contact and replace phones'); + it('PUT /contacts/:id - should update contact and keep existing multi-values if not sent'); + it('PUT /contacts/:id - should handle transaction rollback on error'); + }); +}); +``` + +### Testing Approach + +**Direct Handler Testing:** + +```javascript +// Mock Express req/res +function mockRequest(body = {}, params = {}, query = {}) { + return { body, params, query }; +} + +function mockResponse() { + const res = {}; + res.status = (code) => { res.statusCode = code; return res; }; + res.json = (data) => { res.data = data; return res; }; + return res; +} + +// Example Test +it('POST /accounts - should create account', async () => { + const req = mockRequest({ + name: 'Test Account', + cardavUrl: 'https://example.com/carddav', + username: 'user', + password: 'pass' + }); + const res = mockResponse(); + + // Note: Actual handler testing requires importing route handlers + // This is simplified pseudo-code + + assert.strictEqual(res.statusCode, 201); + assert.ok(res.data.data.account); +}); +``` + +### Mocking External CardDAV + +**Strategie:** Tests fokussieren auf HTTP-Layer (Validation, Response Format, DB-Operations). + +Integration Tests für `cardav-sync.js` existieren bereits (Task #2), daher müssen API Route Tests nicht externe CardDAV-Server mocken. + +**Für Sync/Discovery Routes:** +- Setup: Account + Addressbooks direkt in DB anlegen +- Test: Response-Struktur validieren +- Skip: Echte PROPFIND/REPORT Requests + +### Test Coverage Goals + +- ✅ Alle 11 Routes: mindestens 1 Happy-Path-Test +- ✅ Validation Errors (400) für alle POST/PUT Routes +- ✅ Not Found (404) für invalide IDs +- ✅ Multi-Value-Arrays korrekt gespeichert/geladen +- ✅ Transaction Rollback bei Fehlern +- ✅ Error Messages sind user-facing (nicht technische Stack Traces) + +--- + +## Implementation Order + +### Phase 1: CardDAV Management (Routes 1-3) +1. POST /accounts — Account erstellen +2. GET /accounts — Accounts auflisten +3. DELETE /accounts/:id — Account löschen + +**Tests:** Account CRUD Happy Paths + Validation Errors + +--- + +### Phase 2: Connection & Discovery (Routes 4-6) +4. POST /accounts/:id/test — Connection testen +5. GET /accounts/:id/addressbooks — Addressbooks auflisten +6. POST /accounts/:id/addressbooks/refresh — Addressbooks refreshen + +**Tests:** Discovery Flow + Error Handling + +--- + +### Phase 3: Addressbook Toggle & Sync (Routes 7-8) +7. PUT /addressbooks/:id — Addressbook togglen +8. POST /accounts/:id/sync — Sync triggern + +**Tests:** Toggle + Sync Response Structure + +--- + +### Phase 4: Extended Contacts (Routes 9-11) +9. GET /contacts/:id — Mit Multi-Values +10. POST /contacts — Mit Multi-Values erstellen +11. PUT /contacts/:id — Mit Multi-Values updaten + +**Tests:** Multi-Value CRUD + Transaction Safety + +--- + +## Next Steps + +Nach Approval dieses Designs: +1. **Invoke `writing-plans` skill** — Detaillierten Implementation Plan erstellen +2. **TDD Approach** — Tests vor Implementation schreiben +3. **Code Review** nach jeder Phase + +--- + +## Anhang: Service Functions Reference + +Aus `server/services/cardav-sync.js`: + +**Account Management:** +- `addAccount(name, cardavUrl, username, password)` → `{ account, addressbooks }` +- `getAllAccounts()` → `Account[]` +- `deleteAccount(accountId)` → `void` +- `testConnection(cardavUrl, username, password)` → `{ ok, addressbooks }` + +**Addressbook Discovery:** +- `discoverAddressbooks(account)` → `Addressbook[]` +- `toggleAddressbook(addressbookId, enabled)` → `void` + +**Contact Sync:** +- `syncAccount(account)` → `{ synced, errors }` +- `syncAddressbook(account, addressbook)` → `void` +- `parseAndMergeContact(vCardText, accountId, addressbookUrl)` → `void` + +**Helpers:** +- `parseVCard(vCardText)` → `ContactData` diff --git a/package.json b/package.json index 1a68e99..0ebfbac 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", "test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js", "test:caldav": "node --experimental-sqlite test-caldav-sync.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav" + "test:carddav": "node --experimental-sqlite test-carddav.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/server/db.js b/server/db.js index dc883b3..e60a8ee 100644 --- a/server/db.js +++ b/server/db.js @@ -1074,6 +1074,112 @@ const MIGRATIONS = [ db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`); }, }, + { + version: 30, + description: 'CardDAV multi-account contacts sync', + up: ` + -- ======================================== + -- CardDAV Accounts + -- ======================================== + CREATE TABLE carddav_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + carddav_url TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + last_sync TEXT, + UNIQUE(carddav_url, username) + ); + + -- ======================================== + -- CardDAV Addressbook Selection + -- ======================================== + CREATE TABLE carddav_addressbook_selection ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + addressbook_url TEXT NOT NULL, + addressbook_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(account_id, addressbook_url), + FOREIGN KEY(account_id) REFERENCES carddav_accounts(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_carddav_addressbook_account + ON carddav_addressbook_selection(account_id, enabled); + + -- ======================================== + -- Extend Contacts Table for CardDAV + -- ======================================== + ALTER TABLE contacts ADD COLUMN organization TEXT; + ALTER TABLE contacts ADD COLUMN job_title TEXT; + ALTER TABLE contacts ADD COLUMN birthday TEXT; + ALTER TABLE contacts ADD COLUMN website TEXT; + ALTER TABLE contacts ADD COLUMN photo TEXT; + ALTER TABLE contacts ADD COLUMN nickname TEXT; + ALTER TABLE contacts ADD COLUMN carddav_account_id INTEGER + REFERENCES carddav_accounts(id) ON DELETE SET NULL; + ALTER TABLE contacts ADD COLUMN carddav_uid TEXT; + ALTER TABLE contacts ADD COLUMN carddav_addressbook_url TEXT; + + CREATE INDEX idx_contacts_carddav_uid ON contacts(carddav_uid); + CREATE INDEX idx_contacts_email ON contacts(email); + + -- UNIQUE constraint for CardDAV UIDs (prevents duplicates per account+addressbook) + CREATE UNIQUE INDEX idx_contacts_carddav_uid_unique + ON contacts(carddav_account_id, carddav_addressbook_url, carddav_uid) + WHERE carddav_uid IS NOT NULL; + + -- ======================================== + -- Contact Phones (Multiple per Contact) + -- ======================================== + CREATE TABLE contact_phones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contact_id INTEGER NOT NULL, + label TEXT, + value TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_contact_phones_contact ON contact_phones(contact_id); + CREATE INDEX idx_contact_phones_value ON contact_phones(value); + + -- ======================================== + -- Contact Emails (Multiple per Contact) + -- ======================================== + CREATE TABLE contact_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contact_id INTEGER NOT NULL, + label TEXT, + value TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_contact_emails_contact ON contact_emails(contact_id); + CREATE INDEX idx_contact_emails_value ON contact_emails(value); + + -- ======================================== + -- Contact Addresses (Multiple per Contact) + -- ======================================== + CREATE TABLE contact_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contact_id INTEGER NOT NULL, + label TEXT, + street TEXT, + city TEXT, + state TEXT, + postal_code TEXT, + country TEXT, + is_primary INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id); + `, + }, ]; /** @@ -1247,6 +1353,27 @@ function transaction(fn) { return get().transaction(fn)(); } +let _originalDb = null; + +/** + * ONLY FOR TESTING: Override the internal db instance + * @param {import('better-sqlite3').Database} testDb + */ +function _setTestDatabase(testDb) { + if (!_originalDb) _originalDb = db; + db = testDb; +} + +/** + * ONLY FOR TESTING: Restore the original db instance + */ +function _resetTestDatabase() { + if (_originalDb) { + db = _originalDb; + _originalDb = null; + } +} + 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, _setTestDatabase, _resetTestDatabase }; diff --git a/server/index.js b/server/index.js index 037ba9d..4c76ba8 100644 --- a/server/index.js +++ b/server/index.js @@ -26,6 +26,7 @@ import recipesRouter from './routes/recipes.js'; import calendarRouter from './routes/calendar.js'; import notesRouter from './routes/notes.js'; import contactsRouter from './routes/contacts.js'; +import cardavRouter from './routes/cardav.js'; import birthdaysRouter from './routes/birthdays.js'; import budgetRouter from './routes/budget.js'; import documentsRouter from './routes/documents.js'; @@ -194,6 +195,7 @@ app.use('/api/v1/meals', mealsRouter); app.use('/api/v1/recipes', recipesRouter); app.use('/api/v1/calendar', calendarRouter); app.use('/api/v1/notes', notesRouter); +app.use('/api/v1/contacts/cardav', cardavRouter); app.use('/api/v1/contacts', contactsRouter); app.use('/api/v1/birthdays', birthdaysRouter); app.use('/api/v1/budget', budgetRouter); diff --git a/server/middleware/validate.js b/server/middleware/validate.js index ad1a46a..529a44c 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -156,8 +156,24 @@ function id(val, field) { return { value: n, error: null }; } +/** + * Validiert einen Boolean-Wert. + * @param {any} val + * @param {string} field + * @returns {{ value: boolean|null, error: string|null }} + */ +function bool(val, field) { + if (val === undefined || val === null) { + return { value: null, error: `${field} is required.` }; + } + if (typeof val !== 'boolean') { + return { value: null, error: `${field} must be a boolean.` }; + } + return { value: val, error: null }; +} + export { - str, oneOf, date, time, datetime, month, num, color, rrule, id, collectErrors, + str, oneOf, date, time, datetime, month, num, color, rrule, id, bool, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, MAX_RRULE, DATE_RE, TIME_RE, DATETIME_RE, COLOR_RE, MONTH_RE, }; diff --git a/server/openapi.js b/server/openapi.js index 62ba5fb..f7c46f6 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -457,11 +457,34 @@ function buildPaths() { }, '/api/v1/contacts': { get: op({ summary: 'List contacts', tag: 'Contacts' }), - post: op({ summary: 'Create contact', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }), + post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }), }, '/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) }, + '/api/v1/contacts/cardav/accounts': { + get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }), + post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/contacts/cardav/accounts/{id}': { + delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }), + }, + '/api/v1/contacts/cardav/accounts/{id}/test': { + post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }), + }, + '/api/v1/contacts/cardav/accounts/{id}/addressbooks': { + get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }), + }, + '/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': { + post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }), + }, + '/api/v1/contacts/cardav/addressbooks/{id}': { + put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/contacts/cardav/accounts/{id}/sync': { + post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }), + }, '/api/v1/contacts/{id}': { - put: op({ summary: 'Update contact', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }), + put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }), }, '/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) }, diff --git a/server/routes/cardav.js b/server/routes/cardav.js new file mode 100644 index 0000000..6790cb1 --- /dev/null +++ b/server/routes/cardav.js @@ -0,0 +1,205 @@ +/** + * Modul: CardDAV Management + * Zweck: REST-API-Routen für CardDAV Account Management, Addressbook Discovery, Sync + * Abhängigkeiten: express, server/db.js, server/services/cardav-sync.js + */ + +import { createLogger } from '../logger.js'; +import express from 'express'; +import * as db from '../db.js'; +import * as CardDAVSync from '../services/cardav-sync.js'; +import { str, bool, collectErrors, MAX_TITLE } from '../middleware/validate.js'; + +const log = createLogger('CardDAV'); +const MAX_URL = 500; +const router = express.Router(); + +/** + * GET /api/v1/contacts/cardav/accounts + * Liste aller CardDAV Accounts. + * Response: { data: Account[] } + */ +router.get('/accounts', async (req, res) => { + try { + const accounts = await CardDAVSync.getAllAccounts(); + res.json({ data: accounts }); + } catch (err) { + log.error('Error fetching accounts:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +/** + * POST /api/v1/contacts/cardav/accounts + * Neuen CardDAV Account erstellen und Addressbooks discovern. + * Body: { name, cardavUrl, username, password } + * Response: { data: { account, addressbooks } } + */ +router.post('/accounts', async (req, res) => { + try { + const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); + const vUrl = str(req.body.cardavUrl, 'CardDAV URL', { max: MAX_URL }); + const vUsername = str(req.body.username, 'Username', { max: MAX_TITLE }); + const vPassword = str(req.body.password, 'Password', { max: MAX_TITLE }); + const errors = collectErrors([vName, vUrl, vUsername, vPassword]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const result = await CardDAVSync.addAccount( + vName.value, + vUrl.value, + vUsername.value, + vPassword.value + ); + + res.status(201).json({ data: result }); + } catch (err) { + log.error('Error adding CardDAV account:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +/** + * DELETE /api/v1/contacts/cardav/accounts/:id + * CardDAV Account löschen (CASCADE löscht addressbooks + contacts). + * Response: { data: { deleted: true } } + */ +router.delete('/accounts/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 }); + + await CardDAVSync.deleteAccount(id); + + res.json({ data: { deleted: true } }); + } catch (err) { + log.error('Error deleting CardDAV account:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +/** + * POST /api/v1/contacts/cardav/accounts/:id/test + * Connection testen (ohne Account zu speichern). + * Response: { data: { ok, addressbooks } } + */ +router.post('/accounts/:id/test', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 }); + + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id); + if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 }); + + const result = await CardDAVSync.testConnection( + account.carddav_url, + account.username, + account.password + ); + + res.json({ data: result }); + } catch (err) { + log.error('Error testing CardDAV connection:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +/** + * GET /api/v1/contacts/cardav/accounts/:id/addressbooks + * Addressbooks für Account auflisten. + * Response: { data: Addressbook[] } + */ +router.get('/accounts/:id/addressbooks', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 }); + + const addressbooks = db.get().prepare(` + SELECT id, addressbook_url as url, addressbook_name as name, enabled + FROM carddav_addressbook_selection + WHERE account_id = ? + ORDER BY addressbook_name + `).all(id); + + res.json({ data: addressbooks }); + } catch (err) { + log.error('Error fetching addressbooks:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +/** + * POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh + * Addressbooks neu discovern (PROPFIND). + * Response: { data: Addressbook[] } + */ +router.post('/accounts/:id/addressbooks/refresh', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 }); + + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id); + if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 }); + + await CardDAVSync.discoverAddressbooks(id); + + const addressbooks = db.get().prepare(` + SELECT id, addressbook_url as url, addressbook_name as name, enabled + FROM carddav_addressbook_selection + WHERE account_id = ? + ORDER BY addressbook_name + `).all(id); + + res.json({ data: addressbooks }); + } catch (err) { + log.error('Error refreshing addressbooks:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +/** + * PUT /api/v1/contacts/cardav/addressbooks/:id + * Toggle Addressbook enabled/disabled. + * Body: { enabled: boolean } + * Response: { data: { updated: true, enabled: boolean } } + */ +router.put('/addressbooks/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 }); + + const vEnabled = bool(req.body.enabled, 'enabled'); + const errors = collectErrors([vEnabled]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + CardDAVSync.toggleAddressbook(id, vEnabled.value); + + res.json({ data: { updated: true, enabled: vEnabled.value } }); + } catch (err) { + log.error('Error toggling addressbook:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +/** + * POST /api/v1/contacts/cardav/accounts/:id/sync + * Sync all enabled addressbooks for account. + * Response: { data: { synced: boolean, contactsAdded: number, contactsUpdated: number } } + */ +router.post('/accounts/:id/sync', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 }); + + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id); + if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 }); + + const result = await CardDAVSync.syncAccount(id); + + res.json({ data: result }); + } catch (err) { + log.error('Error syncing account:', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + +export default router; diff --git a/server/routes/contacts.js b/server/routes/contacts.js index 1eba2b3..bd0861f 100644 --- a/server/routes/contacts.js +++ b/server/routes/contacts.js @@ -16,6 +16,140 @@ const router = express.Router(); const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung', 'Handwerker', 'Notfall', 'Sonstiges']; +/** + * Loads multi-value fields (phones, emails, addresses) for a contact. + * @param {number} contactId - Contact ID + * @returns {{ phones: Array, emails: Array, addresses: Array }} + */ +function loadMultiValueFields(contactId) { + const phones = db.get().prepare(` + SELECT id, label, value, is_primary FROM contact_phones + WHERE contact_id = ? + ORDER BY is_primary DESC, id ASC + `).all(contactId).map(p => ({ + id: p.id, + label: p.label, + value: p.value, + isPrimary: p.is_primary === 1 + })); + + const emails = db.get().prepare(` + SELECT id, label, value, is_primary FROM contact_emails + WHERE contact_id = ? + ORDER BY is_primary DESC, id ASC + `).all(contactId).map(e => ({ + id: e.id, + label: e.label, + value: e.value, + isPrimary: e.is_primary === 1 + })); + + const addresses = db.get().prepare(` + SELECT id, label, street, city, state, postal_code, country, is_primary + FROM contact_addresses + WHERE contact_id = ? + ORDER BY is_primary DESC, id ASC + `).all(contactId).map(a => ({ + id: a.id, + label: a.label, + street: a.street, + city: a.city, + state: a.state, + postalCode: a.postal_code, + country: a.country, + isPrimary: a.is_primary === 1 + })); + + return { phones, emails, addresses }; +} + +/** + * Validates phones array for multi-value contact fields. + * @param {Array} phones - Array of { label, value, isPrimary? } + * @returns {{ valid: boolean, error?: string }} + */ +function validatePhones(phones) { + if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' }; + if (phones.length > 20) return { valid: false, error: 'Too many phone entries (max 20)' }; + for (const p of phones) { + if (!p || typeof p !== 'object') return { valid: false, error: 'Phone entry must be an object' }; + if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' }; + if (typeof p.label !== 'string' || p.label.trim().length === 0 || p.label.length > 50) { + return { valid: false, error: 'Phone label invalid or too long' }; + } + if (typeof p.value !== 'string' || p.value.trim().length === 0 || p.value.length > 50) { + return { valid: false, error: 'Phone value invalid or too long' }; + } + if (p.isPrimary !== undefined && typeof p.isPrimary !== 'boolean') { + return { valid: false, error: 'Phone isPrimary must be boolean' }; + } + } + return { valid: true }; +} + +/** + * Validates emails array for multi-value contact fields. + * @param {Array} emails - Array of { label, value, isPrimary? } + * @returns {{ valid: boolean, error?: string }} + */ +function validateEmails(emails) { + if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' }; + if (emails.length > 20) return { valid: false, error: 'Too many email entries (max 20)' }; + for (const e of emails) { + if (!e || typeof e !== 'object') return { valid: false, error: 'Email entry must be an object' }; + if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' }; + if (typeof e.label !== 'string' || e.label.trim().length === 0 || e.label.length > 50) { + return { valid: false, error: 'Email label invalid or too long' }; + } + if (typeof e.value !== 'string' || e.value.trim().length === 0 || e.value.length > 255) { + return { valid: false, error: 'Email value invalid or too long' }; + } + if (!/^.+@.+$/.test(e.value)) { + return { valid: false, error: 'Email value must be a valid email address' }; + } + if (e.isPrimary !== undefined && typeof e.isPrimary !== 'boolean') { + return { valid: false, error: 'Email isPrimary must be boolean' }; + } + } + return { valid: true }; +} + +/** + * Validates addresses array for multi-value contact fields. + * @param {Array} addresses - Array of { label, street?, city?, state?, postalCode?, country?, isPrimary? } + * @returns {{ valid: boolean, error?: string }} + */ +function validateAddresses(addresses) { + if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' }; + if (addresses.length > 20) return { valid: false, error: 'Too many address entries (max 20)' }; + for (const a of addresses) { + if (!a || typeof a !== 'object') return { valid: false, error: 'Address entry must be an object' }; + if (!a.label) return { valid: false, error: 'Address requires label' }; + if (typeof a.label !== 'string' || a.label.trim().length === 0 || a.label.length > 50) { + return { valid: false, error: 'Address label invalid or too long' }; + } + if (a.street !== undefined && (typeof a.street !== 'string' || a.street.length > 255)) { + return { valid: false, error: 'Address street invalid or too long' }; + } + if (a.city !== undefined && (typeof a.city !== 'string' || a.city.length > 255)) { + return { valid: false, error: 'Address city invalid or too long' }; + } + if (a.state !== undefined && (typeof a.state !== 'string' || a.state.length > 255)) { + return { valid: false, error: 'Address state invalid or too long' }; + } + if (a.postalCode !== undefined && (typeof a.postalCode !== 'string' || a.postalCode.length > 255)) { + return { valid: false, error: 'Address postalCode invalid or too long' }; + } + if (a.country !== undefined && (typeof a.country !== 'string' || a.country.length > 255)) { + return { valid: false, error: 'Address country invalid or too long' }; + } + if (a.isPrimary !== undefined && typeof a.isPrimary !== 'boolean') { + return { valid: false, error: 'Address isPrimary must be boolean' }; + } + } + return { valid: true }; +} + /** * GET /api/v1/contacts * Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht. @@ -53,7 +187,7 @@ router.get('/', (req, res) => { /** * POST /api/v1/contacts * Neuen Kontakt anlegen. - * Body: { name, category?, phone?, email?, address?, notes? } + * Body: { name, category?, phone?, email?, address?, notes?, phones?, emails?, addresses? } * Response: { data: Contact } */ router.post('/', (req, res) => { @@ -67,14 +201,95 @@ router.post('/', (req, res) => { const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); - const result = db.get().prepare(` - INSERT INTO contacts (name, category, phone, email, address, notes) - VALUES (?, ?, ?, ?, ?, ?) - `).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value, - vAddress.value, vNotes.value); + // Validate multi-value fields if provided + if (req.body.phones !== undefined) { + const phonesValidation = validatePhones(req.body.phones); + if (!phonesValidation.valid) { + return res.status(400).json({ error: phonesValidation.error, code: 400 }); + } + } - const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid); - res.status(201).json({ data: contact }); + if (req.body.emails !== undefined) { + const emailsValidation = validateEmails(req.body.emails); + if (!emailsValidation.valid) { + return res.status(400).json({ error: emailsValidation.error, code: 400 }); + } + } + + if (req.body.addresses !== undefined) { + const addressesValidation = validateAddresses(req.body.addresses); + if (!addressesValidation.valid) { + return res.status(400).json({ error: addressesValidation.error, code: 400 }); + } + } + + // Insert contact and multi-value fields in a transaction + const transaction = db.get().transaction(() => { + const result = db.get().prepare(` + INSERT INTO contacts (name, category, phone, email, address, notes) + VALUES (?, ?, ?, ?, ?, ?) + `).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value, + vAddress.value, vNotes.value); + + const contactId = result.lastInsertRowid; + + // Insert phones + if (req.body.phones && Array.isArray(req.body.phones)) { + const insertPhone = db.get().prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `); + for (const phone of req.body.phones) { + insertPhone.run(contactId, phone.label, phone.value, phone.isPrimary ? 1 : 0); + } + } + + // Insert emails + if (req.body.emails && Array.isArray(req.body.emails)) { + const insertEmail = db.get().prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `); + for (const email of req.body.emails) { + insertEmail.run(contactId, email.label, email.value, email.isPrimary ? 1 : 0); + } + } + + // Insert addresses + if (req.body.addresses && Array.isArray(req.body.addresses)) { + const insertAddress = db.get().prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const address of req.body.addresses) { + insertAddress.run( + contactId, + address.label, + address.street || null, + address.city || null, + address.state || null, + address.postalCode || null, + address.country || null, + address.isPrimary ? 1 : 0 + ); + } + } + + return contactId; + }); + + const contactId = transaction(); + + // Query the created contact with multi-value fields + const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId); + const multiValueFields = loadMultiValueFields(contactId); + + res.status(201).json({ + data: { + ...contact, + ...multiValueFields + } + }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Interner Fehler', code: 500 }); @@ -84,7 +299,7 @@ router.post('/', (req, res) => { /** * PUT /api/v1/contacts/:id * Kontakt bearbeiten. - * Body: alle Felder optional + * Body: alle Felder optional, phones/emails/addresses mit Replacement-Semantik * Response: { data: Contact } */ router.put('/:id', (req, res) => { @@ -103,27 +318,111 @@ router.put('/:id', (req, res) => { const errors = collectErrors(checks); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); - db.get().prepare(` - UPDATE contacts - SET name = COALESCE(?, name), - category = COALESCE(?, category), - phone = ?, - email = ?, - address = ?, - notes = ? - WHERE id = ? - `).run( - req.body.name?.trim() ?? null, - req.body.category ?? null, - req.body.phone !== undefined ? (req.body.phone?.trim() || null) : contact.phone, - req.body.email !== undefined ? (req.body.email?.trim() || null) : contact.email, - req.body.address !== undefined ? (req.body.address?.trim() || null) : contact.address, - req.body.notes !== undefined ? (req.body.notes?.trim() || null) : contact.notes, - id - ); + // Validate multi-value fields if provided + if (req.body.phones !== undefined) { + const phonesValidation = validatePhones(req.body.phones); + if (!phonesValidation.valid) { + return res.status(400).json({ error: phonesValidation.error, code: 400 }); + } + } + if (req.body.emails !== undefined) { + const emailsValidation = validateEmails(req.body.emails); + if (!emailsValidation.valid) { + return res.status(400).json({ error: emailsValidation.error, code: 400 }); + } + } + + if (req.body.addresses !== undefined) { + const addressesValidation = validateAddresses(req.body.addresses); + if (!addressesValidation.valid) { + return res.status(400).json({ error: addressesValidation.error, code: 400 }); + } + } + + // Update contact and multi-value fields in a transaction + const transaction = db.get().transaction(() => { + // Update scalar fields + db.get().prepare(` + UPDATE contacts + SET name = COALESCE(?, name), + category = COALESCE(?, category), + phone = ?, + email = ?, + address = ?, + notes = ? + WHERE id = ? + `).run( + req.body.name?.trim() ?? null, + req.body.category ?? null, + req.body.phone !== undefined ? (req.body.phone?.trim() || null) : contact.phone, + req.body.email !== undefined ? (req.body.email?.trim() || null) : contact.email, + req.body.address !== undefined ? (req.body.address?.trim() || null) : contact.address, + req.body.notes !== undefined ? (req.body.notes?.trim() || null) : contact.notes, + id + ); + + // Replace phones (delete all, insert new) + if (req.body.phones !== undefined && Array.isArray(req.body.phones)) { + db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id); + + const insertPhone = db.get().prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `); + for (const phone of req.body.phones) { + insertPhone.run(id, phone.label, phone.value, phone.isPrimary ? 1 : 0); + } + } + + // Replace emails (delete all, insert new) + if (req.body.emails !== undefined && Array.isArray(req.body.emails)) { + db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ?').run(id); + + const insertEmail = db.get().prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `); + for (const email of req.body.emails) { + insertEmail.run(id, email.label, email.value, email.isPrimary ? 1 : 0); + } + } + + // Replace addresses (delete all, insert new) + if (req.body.addresses !== undefined && Array.isArray(req.body.addresses)) { + db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ?').run(id); + + const insertAddress = db.get().prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const address of req.body.addresses) { + insertAddress.run( + id, + address.label, + address.street || null, + address.city || null, + address.state || null, + address.postalCode || null, + address.country || null, + address.isPrimary ? 1 : 0 + ); + } + } + }); + + transaction(); + + // Query the updated contact with multi-value fields const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id); - res.json({ data: updated }); + const multiValueFields = loadMultiValueFields(id); + + res.json({ + data: { + ...updated, + ...multiValueFields + } + }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Interner Fehler', code: 500 }); @@ -169,6 +468,32 @@ router.get('/meta', (_req, res) => { } }); +/** + * GET /api/v1/contacts/:id + * Einzelnen Kontakt abrufen mit Multi-Value Fields (phones, emails, addresses). + * Response: { data: Contact } + */ +router.get('/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id); + if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 }); + + // Load multi-value fields + const multiValueFields = loadMultiValueFields(id); + + res.json({ + data: { + ...contact, + ...multiValueFields + } + }); + } catch (err) { + log.error('', err); + res.status(500).json({ error: 'Interner Fehler', code: 500 }); + } +}); + /** * GET /api/v1/contacts/:id/vcard * Kontakt als vCard 3.0 (.vcf) exportieren. @@ -208,3 +533,4 @@ router.get('/:id/vcard', (req, res) => { }); export default router; +export { validatePhones, validateEmails, validateAddresses }; diff --git a/server/services/cardav-sync.js b/server/services/cardav-sync.js new file mode 100644 index 0000000..544c84d --- /dev/null +++ b/server/services/cardav-sync.js @@ -0,0 +1,916 @@ +/** + * Modul: CardDAV Contacts Sync + * Zweck: Multi-Account CardDAV synchronization with addressbook selection + * Abhängigkeiten: tsdav, server/db.js + */ + +import { createLogger } from '../logger.js'; +const log = createLogger('CardDAV'); + +import * as db from '../db.js'; + +// -------------------------------------------------------- +// Helper Functions +// -------------------------------------------------------- + +/** + * Parse vCard text into structured object + * @param {string} vCardText - Raw vCard data + * @returns {Object} Parsed vCard object + */ +function parseVCard(vCardText) { + const lines = vCardText.split(/\r?\n/).filter(line => line.trim()); + const vcard = { + uid: null, + name: null, + phones: [], + emails: [], + addresses: [], + organization: null, + jobTitle: null, + website: null, + birthday: null, + photo: null, + nickname: null, + notes: null, + categories: null, + }; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + + // Handle line folding (continuation lines start with space or tab) + while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) { + line += lines[i + 1].substring(1); + i++; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + + const fullKey = line.substring(0, colonIndex); + const value = line.substring(colonIndex + 1).trim(); + + // Parse property and parameters + const [prop, ...params] = fullKey.split(';'); + const property = prop.toUpperCase(); + + switch (property) { + case 'UID': + vcard.uid = value; + break; + + case 'FN': + if (!vcard.name) vcard.name = value; + break; + + case 'N': + // N is fallback if FN is not present + // Format: Family;Given;Middle;Prefix;Suffix + if (!vcard.name) { + const parts = value.split(';').filter(p => p); + vcard.name = parts.join(' ').trim(); + } + break; + + case 'TEL': + const phoneType = extractType(params) || 'other'; + vcard.phones.push({ label: phoneType, value: value }); + break; + + case 'EMAIL': + const emailType = extractType(params) || 'other'; + vcard.emails.push({ label: emailType, value: value }); + break; + + case 'ADR': + // Format: POBox;Extended;Street;City;State;Postal;Country + const adrParts = value.split(';'); + const adrType = extractType(params) || 'other'; + vcard.addresses.push({ + label: adrType, + street: adrParts[2] || null, + city: adrParts[3] || null, + state: adrParts[4] || null, + postalCode: adrParts[5] || null, + country: adrParts[6] || null, + }); + break; + + case 'ORG': + vcard.organization = value; + break; + + case 'TITLE': + vcard.jobTitle = value; + break; + + case 'URL': + // Take first URL if multiple exist + if (!vcard.website) vcard.website = value; + break; + + case 'BDAY': + // Parse birthday to ISO format (YYYY-MM-DD) + vcard.birthday = parseBirthday(value); + break; + + case 'PHOTO': + // Handle base64 encoded photos + if (params.some(p => p.toUpperCase().includes('ENCODING=BASE64') || p.toUpperCase().includes('ENCODING=B'))) { + // Photo might span multiple lines in old vCard format + vcard.photo = value; + } + break; + + case 'NICKNAME': + vcard.nickname = value; + break; + + case 'NOTE': + vcard.notes = value; + break; + + case 'CATEGORIES': + vcard.categories = value; + break; + } + } + + return vcard; +} + +/** + * Extract TYPE parameter from vCard property parameters + * @param {Array} params - Property parameters + * @returns {string|null} Type value + */ +function extractType(params) { + // Priority order: more specific types first + const typeHierarchy = ['CELL', 'MOBILE', 'HOME', 'WORK', 'FAX', 'OTHER', 'VOICE']; + + let foundType = null; + + for (const param of params) { + const upper = param.toUpperCase(); + if (upper.startsWith('TYPE=')) { + return param.substring(5).toLowerCase(); + } + // Some vCards use TYPE without = + if (typeHierarchy.includes(upper)) { + // Keep the most specific type (earlier in hierarchy) + const currentIndex = typeHierarchy.indexOf(upper); + const foundIndex = foundType ? typeHierarchy.indexOf(foundType.toUpperCase()) : -1; + + if (foundIndex === -1 || currentIndex < foundIndex) { + foundType = upper.toLowerCase(); + } + } + } + + return foundType; +} + +/** + * Parse birthday from various vCard formats to ISO date + * @param {string} value - Birthday value from vCard + * @returns {string|null} ISO date (YYYY-MM-DD) or null + */ +function parseBirthday(value) { + if (!value) return null; + + // Remove any non-numeric characters except hyphens + const cleaned = value.replace(/[^\d-]/g, ''); + + // Try ISO format (YYYY-MM-DD) + if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) { + return cleaned; + } + + // Try compact format (YYYYMMDD) + if (/^\d{8}$/.test(cleaned)) { + return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 6)}-${cleaned.slice(6, 8)}`; + } + + // Try year only + if (/^\d{4}$/.test(cleaned)) { + return `${cleaned}-01-01`; + } + + return null; +} + +// -------------------------------------------------------- +// Account Management +// -------------------------------------------------------- + +/** + * Test CardDAV connection + * @param {string} cardavUrl - CardDAV server URL + * @param {string} username - Username + * @param {string} password - Password + * @returns {Promise} { ok: true, addressbooks: [...] } + */ +async function testConnection(cardavUrl, username, password) { + // Use mock if set (for testing) + if (_testConnectionMock) { + return _testConnectionMock(cardavUrl, username, password); + } + + try { + const { createDAVClient } = await import('tsdav'); + const client = await createDAVClient({ + serverUrl: cardavUrl, + credentials: { username, password }, + authMethod: 'Basic', + defaultAccountType: 'carddav', + }); + + const addressbooks = await client.fetchAddressBooks(); + if (!addressbooks.length) { + throw new Error('Connected, but no addressbooks found.'); + } + + return { ok: true, addressbooks }; + } catch (err) { + log.error('Connection test failed:', err.message); + throw new Error(`CardDAV connection failed: ${err.message}`); + } +} + +/** + * Add new CardDAV account + * @param {string} name - Account display name + * @param {string} cardavUrl - CardDAV server URL + * @param {string} username - Username + * @param {string} password - Password + * @returns {Promise} { accountId, addressbooks } + */ +async function addAccount(name, cardavUrl, username, password) { + try { + // Validate inputs + if (!name || !cardavUrl || !username || !password) { + throw new Error('All fields required: name, cardavUrl, username, password'); + } + + // Test connection first + const { addressbooks } = await testConnection(cardavUrl, username, password); + + // Check for duplicate + const existing = db.get().prepare( + 'SELECT id FROM carddav_accounts WHERE carddav_url = ? AND username = ?' + ).get(cardavUrl, username); + + if (existing) { + throw new Error('Account with this URL and username already exists.'); + } + + // Warn if DB_ENCRYPTION_KEY not set + if (!process.env.DB_ENCRYPTION_KEY) { + log.warn('WARNING: DB_ENCRYPTION_KEY is not set - CardDAV credentials will be stored unencrypted.'); + } + + // Insert account + const result = db.get().prepare(` + INSERT INTO carddav_accounts (name, carddav_url, username, password) + VALUES (?, ?, ?, ?) + `).run(name, cardavUrl, username, password); + + const accountId = result.lastInsertRowid; + + // Insert addressbook selections (all enabled by default) + const addressbookData = []; + for (const abook of addressbooks) { + const abookName = abook.displayName || 'Unnamed Addressbook'; + + db.get().prepare(` + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) + VALUES (?, ?, ?, 1) + `).run(accountId, abook.url, abookName); + + addressbookData.push({ url: abook.url, name: abookName, enabled: true }); + } + + log.info(`Added CardDAV account "${name}" with ${addressbooks.length} addressbooks.`); + + const account = { + id: accountId, + name, + cardavUrl, + username, + createdAt: new Date().toISOString(), + lastSync: null + }; + + return { account, addressbooks: addressbookData }; + } catch (err) { + log.error('Failed to add account:', err.message); + throw err; + } +} + +/** + * Get all CardDAV accounts + * @returns {Array} Array of account objects (without passwords) + */ +function getAllAccounts() { + try { + const accounts = db.get().prepare(` + SELECT id, name, carddav_url, username, created_at, last_sync + FROM carddav_accounts + ORDER BY created_at DESC + `).all(); + + return accounts.map(acc => ({ + id: acc.id, + name: acc.name, + cardavUrl: acc.carddav_url, + username: acc.username, + createdAt: acc.created_at, + lastSync: acc.last_sync, + })); + } catch (err) { + log.error('Failed to get accounts:', err.message); + throw err; + } +} + +/** + * Delete CardDAV account + * @param {number} accountId - Account ID + * @returns {Object} { success: true } + */ +function deleteAccount(accountId) { + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + // CASCADE will delete carddav_addressbook_selection entries + // Contacts will have carddav_account_id SET NULL (see migration) + db.get().prepare('DELETE FROM carddav_accounts WHERE id = ?').run(accountId); + + log.info(`Deleted CardDAV account ${accountId} ("${account.name}").`); + + return { success: true }; + } catch (err) { + log.error('Failed to delete account:', err.message); + throw err; + } +} + +// -------------------------------------------------------- +// Addressbook Discovery & Selection +// -------------------------------------------------------- + +/** + * Discover addressbooks for an account (refresh from server) + * @param {number} accountId - Account ID + * @returns {Promise>} Array of addressbook objects + */ +async function discoverAddressbooks(accountId) { + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + const { addressbooks } = await testConnection(account.carddav_url, account.username, account.password); + + // UPSERT into carddav_addressbook_selection + const result = []; + for (const abook of addressbooks) { + const abookName = abook.displayName || 'Unnamed Addressbook'; + + // Check if exists + const existing = db.get().prepare(` + SELECT id, enabled FROM carddav_addressbook_selection + WHERE account_id = ? AND addressbook_url = ? + `).get(accountId, abook.url); + + if (existing) { + // Update name only (preserve enabled state) + db.get().prepare(` + UPDATE carddav_addressbook_selection + SET addressbook_name = ? + WHERE id = ? + `).run(abookName, existing.id); + + result.push({ + id: existing.id, + url: abook.url, + name: abookName, + enabled: existing.enabled === 1 + }); + } else { + // Insert new (enabled by default) + const insertResult = db.get().prepare(` + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) + VALUES (?, ?, ?, 1) + `).run(accountId, abook.url, abookName); + + result.push({ + id: insertResult.lastInsertRowid, + url: abook.url, + name: abookName, + enabled: true + }); + } + } + + log.info(`Discovered ${addressbooks.length} addressbooks for account ${accountId}.`); + + return result; + } catch (err) { + log.error('Failed to discover addressbooks:', err.message); + throw err; + } +} + +/** + * Toggle addressbook enabled state + * @param {number} addressbookId - Addressbook selection ID + * @param {boolean} enabled - Enable or disable + * @returns {Object} { success: true } + */ +function toggleAddressbook(addressbookId, enabled) { + try { + const enabledValue = enabled ? 1 : 0; + + const result = db.get().prepare(` + UPDATE carddav_addressbook_selection + SET enabled = ? + WHERE id = ? + `).run(enabledValue, addressbookId); + + if (result.changes === 0) { + throw new Error(`Addressbook ${addressbookId} not found.`); + } + + log.info(`Addressbook ${addressbookId} ${enabled ? 'enabled' : 'disabled'}.`); + + return { success: true }; + } catch (err) { + log.error('Failed to toggle addressbook:', err.message); + throw err; + } +} + +// -------------------------------------------------------- +// Contact Sync +// -------------------------------------------------------- + +/** + * Sync all enabled addressbooks for an account + * @param {number} accountId - Account ID + * @returns {Promise} { synced, errors } + */ +async function syncAccount(accountId) { + // Use mock if set (for testing) + if (_syncAccountMock) { + return _syncAccountMock(accountId); + } + + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + log.info(`Syncing CardDAV account ${accountId} ("${account.name}")...`); + + // Create tsdav client + const { createDAVClient } = await import('tsdav'); + const client = await createDAVClient({ + serverUrl: account.carddav_url, + credentials: { username: account.username, password: account.password }, + authMethod: 'Basic', + defaultAccountType: 'carddav', + }); + + // Get enabled addressbooks for this account + const enabledAddressbooks = db.get().prepare(` + SELECT id, addressbook_url, addressbook_name + FROM carddav_addressbook_selection + WHERE account_id = ? AND enabled = 1 + `).all(accountId); + + if (enabledAddressbooks.length === 0) { + log.info(`Account ${accountId}: no enabled addressbooks, skipping.`); + return { synced: 0, errors: 0 }; + } + + let totalSynced = 0; + let totalErrors = 0; + + // Fetch all addressbooks from server + const serverAddressbooks = await client.fetchAddressBooks(); + + for (const selAbook of enabledAddressbooks) { + // Find matching addressbook from server + const serverAbook = serverAddressbooks.find(sa => sa.url === selAbook.addressbook_url); + + if (!serverAbook) { + log.warn(`Addressbook ${selAbook.addressbook_url} not found on server, disabling.`); + db.get().prepare(` + UPDATE carddav_addressbook_selection SET enabled = 0 + WHERE id = ? + `).run(selAbook.id); + continue; + } + + // Sync this addressbook + const { synced, errors } = await syncAddressbook(accountId, selAbook.addressbook_url, client, serverAbook); + totalSynced += synced; + totalErrors += errors; + } + + // Update last_sync for account + db.get().prepare(` + UPDATE carddav_accounts SET last_sync = ? WHERE id = ? + `).run(new Date().toISOString(), accountId); + + log.info(`Account ${accountId} sync complete: ${totalSynced} contacts synced, ${totalErrors} errors.`); + + return { synced: totalSynced, errors: totalErrors }; + } catch (err) { + log.error(`Sync failed for account ${accountId}:`, err.message); + throw err; + } +} + +/** + * Sync a specific addressbook + * @param {number} accountId - Account ID + * @param {string} addressbookUrl - Addressbook URL + * @param {Object} client - tsdav client instance (optional, will create if not provided) + * @param {Object} serverAddressbook - Server addressbook object (optional) + * @returns {Promise} { synced, errors } + */ +async function syncAddressbook(accountId, addressbookUrl, client = null, serverAddressbook = null) { + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + // Create client if not provided + if (!client) { + const { createDAVClient } = await import('tsdav'); + client = await createDAVClient({ + serverUrl: account.carddav_url, + credentials: { username: account.username, password: account.password }, + authMethod: 'Basic', + defaultAccountType: 'carddav', + }); + } + + // Find addressbook if not provided + if (!serverAddressbook) { + const addressbooks = await client.fetchAddressBooks(); + serverAddressbook = addressbooks.find(ab => ab.url === addressbookUrl); + + if (!serverAddressbook) { + throw new Error(`Addressbook ${addressbookUrl} not found on server.`); + } + } + + // Fetch vCards from addressbook + let vcardObjects; + try { + vcardObjects = await client.fetchVCards({ addressBook: serverAddressbook }); + } catch (err) { + log.error(`Failed to fetch vCards from ${addressbookUrl}:`, err.message); + return { synced: 0, errors: 1 }; + } + + let synced = 0; + let errors = 0; + + // Parse and merge each vCard + for (const vcardObj of vcardObjects) { + try { + const vCardText = vcardObj.data || ''; + if (!vCardText.trim()) continue; + + await parseAndMergeContact(vCardText, accountId, addressbookUrl); + synced++; + } catch (err) { + log.error(`Failed to parse/merge vCard:`, err.message); + errors++; + } + } + + log.info(`Addressbook ${addressbookUrl}: ${synced} contacts synced, ${errors} errors.`); + + return { synced, errors }; + } catch (err) { + log.error(`Failed to sync addressbook ${addressbookUrl}:`, err.message); + throw err; + } +} + +/** + * Parse vCard and merge with existing contact using Smart Merge Logic + * @param {string} vCardText - Raw vCard data + * @param {number} accountId - Account ID + * @param {string} addressbookUrl - Addressbook URL + * @returns {Promise} Contact ID + */ +async function parseAndMergeContact(vCardText, accountId, addressbookUrl) { + try { + const vcard = parseVCard(vCardText); + + if (!vcard.uid) { + throw new Error('vCard missing UID, skipping.'); + } + + if (!vcard.name) { + throw new Error('vCard missing name (FN/N), skipping.'); + } + + // Smart Merge Logic (see design doc) + + // Step 1: Check for existing contact by cardav_uid + let contact = db.get().prepare(` + SELECT * FROM contacts + WHERE carddav_account_id = ? AND carddav_addressbook_url = ? AND carddav_uid = ? + `).get(accountId, addressbookUrl, vcard.uid); + + if (contact) { + // Update existing contact (only fill NULL fields to preserve manual changes) + updateContact(contact.id, vcard, false); + updateContactMultiValues(contact.id, vcard); + return contact.id; + } + + // Step 2: Check for existing contact by email or phone match + contact = findContactByEmailOrPhone(vcard.emails, vcard.phones); + + if (contact) { + // Update existing contact and establish CardDAV link + updateContact(contact.id, vcard, true); + + // Set CardDAV link + db.get().prepare(` + UPDATE contacts + SET carddav_account_id = ?, carddav_uid = ?, carddav_addressbook_url = ? + WHERE id = ? + `).run(accountId, vcard.uid, addressbookUrl, contact.id); + + updateContactMultiValues(contact.id, vcard); + return contact.id; + } + + // Step 3: No match - insert new contact + const result = db.get().prepare(` + INSERT INTO contacts ( + name, category, organization, job_title, birthday, website, + photo, nickname, notes, + carddav_account_id, carddav_uid, carddav_addressbook_url + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + vcard.name, + vcard.categories || 'Sonstiges', + vcard.organization, + vcard.jobTitle, + vcard.birthday, + vcard.website, + vcard.photo, + vcard.nickname, + vcard.notes, + accountId, + vcard.uid, + addressbookUrl + ); + + const contactId = result.lastInsertRowid; + + // Insert multi-value fields + insertContactMultiValues(contactId, vcard); + + return contactId; + } catch (err) { + log.error('Failed to parse and merge contact:', err.message); + throw err; + } +} + +/** + * Find existing contact by email or phone match + * @param {Array} emails - Array of email objects + * @param {Array} phones - Array of phone objects + * @returns {Object|null} Contact object or null + */ +function findContactByEmailOrPhone(emails, phones) { + // Try email match first + for (const email of emails) { + const contact = db.get().prepare(` + SELECT c.* FROM contacts c + LEFT JOIN contact_emails ce ON c.id = ce.contact_id + WHERE c.email = ? OR ce.value = ? + LIMIT 1 + `).get(email.value, email.value); + + if (contact) return contact; + } + + // Try phone match + for (const phone of phones) { + const contact = db.get().prepare(` + SELECT c.* FROM contacts c + LEFT JOIN contact_phones cp ON c.id = cp.contact_id + WHERE c.phone = ? OR cp.value = ? + LIMIT 1 + `).get(phone.value, phone.value); + + if (contact) return contact; + } + + return null; +} + +/** + * Update existing contact with vCard data (only NULL fields) + * @param {number} contactId - Contact ID + * @param {Object} vcard - Parsed vCard object + * @param {boolean} fillAll - If true, update all fields; if false, only update NULL fields + */ +function updateContact(contactId, vcard, fillAll = false) { + const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId); + if (!contact) return; + + const updates = []; + const values = []; + + // Helper to conditionally update field + const maybeUpdate = (field, dbColumn, vcardValue) => { + if (vcardValue !== null && vcardValue !== undefined) { + if (fillAll || contact[dbColumn] === null) { + updates.push(`${dbColumn} = ?`); + values.push(vcardValue); + } + } + }; + + maybeUpdate('name', 'name', vcard.name); + maybeUpdate('organization', 'organization', vcard.organization); + maybeUpdate('jobTitle', 'job_title', vcard.jobTitle); + maybeUpdate('birthday', 'birthday', vcard.birthday); + maybeUpdate('website', 'website', vcard.website); + maybeUpdate('photo', 'photo', vcard.photo); + maybeUpdate('nickname', 'nickname', vcard.nickname); + maybeUpdate('notes', 'notes', vcard.notes); + maybeUpdate('categories', 'category', vcard.categories); + + if (updates.length === 0) return; + + values.push(contactId); + + db.get().prepare(` + UPDATE contacts SET ${updates.join(', ')} WHERE id = ? + `).run(...values); +} + +/** + * Update contact multi-value fields (phones, emails, addresses) + * Preserves primary entries, replaces non-primary entries + * @param {number} contactId - Contact ID + * @param {Object} vcard - Parsed vCard object + */ +function updateContactMultiValues(contactId, vcard) { + const transaction = db.get().transaction(() => { + // Delete non-primary entries + db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ? AND is_primary = 0').run(contactId); + db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ? AND is_primary = 0').run(contactId); + db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ? AND is_primary = 0').run(contactId); + + // Insert new entries from vCard + insertContactMultiValues(contactId, vcard); + }); + + transaction(); +} + +/** + * Insert contact multi-value fields (phones, emails, addresses) + * @param {number} contactId - Contact ID + * @param {Object} vcard - Parsed vCard object + */ +function insertContactMultiValues(contactId, vcard) { + // Check if primary entries exist + const hasPrimaryPhone = db.get().prepare( + 'SELECT 1 FROM contact_phones WHERE contact_id = ? AND is_primary = 1' + ).get(contactId); + + const hasPrimaryEmail = db.get().prepare( + 'SELECT 1 FROM contact_emails WHERE contact_id = ? AND is_primary = 1' + ).get(contactId); + + const hasPrimaryAddress = db.get().prepare( + 'SELECT 1 FROM contact_addresses WHERE contact_id = ? AND is_primary = 1' + ).get(contactId); + + // Batch insert phones + if (vcard.phones && vcard.phones.length > 0) { + const placeholders = vcard.phones.map(() => '(?, ?, ?, ?)').join(', '); + const values = vcard.phones.flatMap((phone, i) => [ + contactId, + phone.label || null, + phone.value, + (!hasPrimaryPhone && i === 0) ? 1 : 0 + ]); + + db.get().prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES ${placeholders} + `).run(...values); + } + + // Batch insert emails + if (vcard.emails && vcard.emails.length > 0) { + const placeholders = vcard.emails.map(() => '(?, ?, ?, ?)').join(', '); + const values = vcard.emails.flatMap((email, i) => [ + contactId, + email.label || null, + email.value, + (!hasPrimaryEmail && i === 0) ? 1 : 0 + ]); + + db.get().prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES ${placeholders} + `).run(...values); + } + + // Batch insert addresses + if (vcard.addresses && vcard.addresses.length > 0) { + const placeholders = vcard.addresses.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', '); + const values = vcard.addresses.flatMap((addr, i) => [ + contactId, + addr.label || null, + addr.street, + addr.city, + addr.state, + addr.postalCode, + addr.country, + (!hasPrimaryAddress && i === 0) ? 1 : 0 + ]); + + db.get().prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary) + VALUES ${placeholders} + `).run(...values); + } +} + +// -------------------------------------------------------- +// Exports +// -------------------------------------------------------- + +export { + // Account Management + addAccount, + getAllAccounts, + deleteAccount, + testConnection, + + // Addressbook Discovery + discoverAddressbooks, + toggleAddressbook, + + // Contact Sync + syncAccount, + syncAddressbook, + parseAndMergeContact, + + // Helpers (exported for testing) + parseVCard, + _mockTestConnection, + _mockSyncAccount, +}; + +// -------------------------------------------------------- +// Test Mocking Support +// -------------------------------------------------------- + +let _testConnectionMock = null; +let _syncAccountMock = null; + +/** + * ONLY FOR TESTING: Mock testConnection for unit tests + * @param {Function|null} mockFn - Mock function or null to reset + */ +function _mockTestConnection(mockFn) { + _testConnectionMock = mockFn; +} + +/** + * ONLY FOR TESTING: Mock syncAccount for unit tests + * @param {Function|null} mockFn - Mock function or null to reset + */ +function _mockSyncAccount(mockFn) { + _syncAccountMock = mockFn; +} diff --git a/test-carddav.js b/test-carddav.js new file mode 100644 index 0000000..c21720f --- /dev/null +++ b/test-carddav.js @@ -0,0 +1,2645 @@ +/** + * Test: CardDAV Contacts Schema + * Purpose: Verify Migration 30 - CardDAV multi-account contacts sync tables + */ + +import { describe, it, before, after } 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); + + // Mock testConnection for API route tests + const cardavSync = await import('./server/services/cardav-sync.js'); + cardavSync._mockTestConnection(async () => ({ + ok: true, + addressbooks: [ + { url: 'https://example.com/carddav/addressbook1', displayName: 'Contacts' }, + { url: 'https://example.com/carddav/addressbook2', displayName: 'Work' } + ] + })); + + // Mock syncAccount for API route tests + cardavSync._mockSyncAccount(async () => ({ + synced: true, + contactsAdded: 5, + contactsUpdated: 3 + })); + }); + + after(async () => { + // Restore original database + const dbModule = await import('./server/db.js'); + dbModule._resetTestDatabase(); + + // Reset testConnection mock + const cardavSync = await import('./server/services/cardav-sync.js'); + cardavSync._mockTestConnection(null); + + // Reset syncAccount mock + cardavSync._mockSyncAccount(null); + }); + + 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); + }); + + it('GET /accounts - should return accounts with correct shape', async () => { + // Insert test account + apiTestDb.prepare(` + INSERT INTO carddav_accounts (name, carddav_url, username, password, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('Test iCloud', 'https://contacts.icloud.com', 'test@icloud.com', 'secret', '2026-05-01T10:00:00Z'); + + 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; + + await getHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.ok(Array.isArray(res.data.data)); + assert.strictEqual(res.data.data.length, 1); + + const account = res.data.data[0]; + assert.strictEqual(account.name, 'Test iCloud'); + assert.strictEqual(account.cardavUrl, 'https://contacts.icloud.com'); + assert.strictEqual(account.username, 'test@icloud.com'); + assert.strictEqual(account.createdAt, '2026-05-01T10:00:00Z'); + assert.ok(!account.password, 'Password should not be exposed'); + }); + + it('POST /accounts - should create account and discover addressbooks', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + const req = { + params: {}, + query: {}, + body: { + name: 'Test Account', + cardavUrl: 'https://example.com/carddav', + username: 'testuser', + password: 'testpass' + } + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + assert.ok(postHandler, 'POST /accounts handler should exist'); + await postHandler(req, res); + + assert.strictEqual(res.statusCode, 201); + assert.ok(res.data.data.account); + assert.ok(res.data.data.account.id); + assert.strictEqual(res.data.data.account.name, 'Test Account'); + assert.ok(Array.isArray(res.data.data.addressbooks)); + }); + + it('POST /accounts - should return 400 for missing name', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + const req = { + params: {}, + query: {}, + body: { + cardavUrl: 'https://example.com/carddav', + username: 'testuser', + password: 'testpass' + } + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postHandler(req, res); + + assert.strictEqual(res.statusCode, 400); + assert.ok(res.data.error.includes('Name')); + }); + + it('DELETE /accounts/:id - should delete account and cascade addressbooks', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + // First create an account to delete + const createReq = { + params: {}, + query: {}, + body: { + name: 'Account to Delete', + cardavUrl: 'https://example.com/carddav', + username: 'deleteuser', + password: 'deletepass' + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postHandler(createReq, createRes); + const accountId = createRes.data.data.account.id; + + // Now delete it + const req = { + params: { id: String(accountId) }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const deleteHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts/:id' && layer.route.methods.delete + )?.route?.stack[0]?.handle; + + assert.ok(deleteHandler, 'DELETE /accounts/:id handler should exist'); + await deleteHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.data.deleted, true); + }); + + it('DELETE /accounts/:id - should return 400 for invalid ID', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + const req = { + params: { id: 'invalid' }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const deleteHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts/:id' && layer.route.methods.delete + )?.route?.stack[0]?.handle; + + await deleteHandler(req, res); + + assert.strictEqual(res.statusCode, 400); + assert.ok(res.data.error.includes('Invalid ID')); + }); + }); + + describe('Connection & Discovery', () => { + it('POST /accounts/:id/test - should test connection', async () => { + // Insert test account directly into DB + const result = apiTestDb.prepare(` + INSERT INTO carddav_accounts (name, carddav_url, username, password, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('Test Connection Account', 'https://example.com/carddav-test', 'testuser-connection', 'testpass', '2026-05-04T10:00:00Z'); + + const accountId = result.lastInsertRowid; + + const cardavRouter = await import('./server/routes/cardav.js'); + + // Test connection + const req = { + params: { id: String(accountId) }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const testHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts/:id/test' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + assert.ok(testHandler, 'POST /accounts/:id/test handler should exist'); + await testHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.ok('ok' in res.data.data); + assert.ok(Array.isArray(res.data.data.addressbooks)); + }); + + it('GET /accounts/:id/addressbooks - should list addressbooks', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + // Create account first + const createReq = { + params: {}, + query: {}, + body: { + name: 'Addressbooks Test Account', + cardavUrl: 'https://example.com/carddav-ab', + username: 'testuser-ab', + password: 'testpass' + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postAccountHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postAccountHandler(createReq, createRes); + const accountId = createRes.data.data.account.id; + + // Get addressbooks + const req = { + params: { id: String(accountId) }, + 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/:id/addressbooks' && layer.route.methods.get + )?.route?.stack[0]?.handle; + + assert.ok(getHandler, 'GET /accounts/:id/addressbooks handler should exist'); + await getHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.ok(Array.isArray(res.data.data)); + if (res.data.data.length > 0) { + const ab = res.data.data[0]; + assert.ok(ab.id); + assert.ok(ab.url); + assert.ok(ab.name); + assert.ok('enabled' in ab); + } + }); + + it('GET /accounts/:id/addressbooks - should return empty array when none', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + const req = { + params: { id: '99999' }, + 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/:id/addressbooks' && layer.route.methods.get + )?.route?.stack[0]?.handle; + + await getHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.ok(Array.isArray(res.data.data)); + assert.strictEqual(res.data.data.length, 0); + }); + + it('POST /accounts/:id/addressbooks/refresh - should refresh addressbooks', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + // Create account first + const createReq = { + params: {}, + query: {}, + body: { + name: 'Refresh Test Account', + cardavUrl: 'https://example.com/carddav-refresh', + username: 'testuser-refresh', + password: 'testpass' + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postAccountHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postAccountHandler(createReq, createRes); + const accountId = createRes.data.data.account.id; + + // Refresh addressbooks + const req = { + params: { id: String(accountId) }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const refreshHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts/:id/addressbooks/refresh' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + assert.ok(refreshHandler, 'POST /accounts/:id/addressbooks/refresh handler should exist'); + await refreshHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.ok(Array.isArray(res.data.data)); + }); + }); + + describe('Addressbook Management', () => { + it('PUT /addressbooks/:id - should toggle addressbook enabled/disabled', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + // First create an account (which creates addressbooks) + const createReq = { + params: {}, + query: {}, + body: { + name: 'Toggle Test Account', + cardavUrl: 'https://example.com/carddav-toggle', + username: 'testuser-toggle', + password: 'testpass' + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postAccountHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postAccountHandler(createReq, createRes); + const accountId = createRes.data.data.account.id; + + // Get addressbooks with IDs via GET /accounts/:id/addressbooks + const getReq = { + params: { id: String(accountId) }, + query: {}, + body: {} + }; + const getRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const getAddressbooksHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts/:id/addressbooks' && layer.route.methods.get + )?.route?.stack[0]?.handle; + + await getAddressbooksHandler(getReq, getRes); + + const addressbooks = getRes.data.data; + assert.ok(addressbooks.length > 0, 'Should have at least one addressbook'); + + const addressbookId = addressbooks[0].id; + const initialEnabled = addressbooks[0].enabled; + + // Toggle the addressbook + const req = { + params: { id: String(addressbookId) }, + query: {}, + body: { enabled: !initialEnabled } + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const putHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/addressbooks/:id' && layer.route.methods.put + )?.route?.stack[0]?.handle; + + assert.ok(putHandler, 'PUT /addressbooks/:id handler should exist'); + await putHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.data.updated, true); + assert.strictEqual(res.data.data.enabled, !initialEnabled); + }); + + it('PUT /addressbooks/:id - should return 400 for invalid enabled value', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + const req = { + params: { id: '1' }, + query: {}, + body: { enabled: 'invalid' } + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const putHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/addressbooks/:id' && layer.route.methods.put + )?.route?.stack[0]?.handle; + + await putHandler(req, res); + + assert.strictEqual(res.statusCode, 400); + assert.ok(res.data.error.includes('enabled')); + }); + }); + + describe('Sync', () => { + it('POST /accounts/:id/sync - should sync all enabled addressbooks', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + // Create account (which creates addressbooks) + const createReq = { + params: {}, + query: {}, + body: { + name: 'Sync Test Account', + cardavUrl: 'https://example.com/carddav-sync', + username: 'testuser-sync', + password: 'testpass' + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postAccountHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postAccountHandler(createReq, createRes); + const accountId = createRes.data.data.account.id; + + // Sync the account + const req = { + params: { id: String(accountId) }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const syncHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts/:id/sync' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + assert.ok(syncHandler, 'POST /accounts/:id/sync handler should exist'); + await syncHandler(req, res); + + assert.strictEqual(res.statusCode, 200); + assert.ok('synced' in res.data.data); + assert.ok('contactsAdded' in res.data.data); + assert.ok('contactsUpdated' in res.data.data); + }); + + it('POST /accounts/:id/sync - should return 404 for non-existent account', async () => { + const cardavRouter = await import('./server/routes/cardav.js'); + + const req = { + params: { id: '99999' }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const syncHandler = cardavRouter.default.stack.find( + layer => layer.route?.path === '/accounts/:id/sync' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await syncHandler(req, res); + + assert.strictEqual(res.statusCode, 404); + assert.ok(res.data.error); + }); + }); +}); + +// ======================================== +// Contacts API - Multi-Value Fields +// ======================================== + +describe('Contacts API - Multi-Value Fields', () => { + let contactsApiDb; + + before(async () => { + // Create in-memory test database + contactsApiDb = new Database(':memory:'); + contactsApiDb.pragma('foreign_keys = ON'); + + // Create minimal schema + contactsApiDb.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 Multi-Value tables + const migration30 = MIGRATIONS.find(m => m.version === 30); + if (!migration30) { + throw new Error('Migration 30 not found'); + } + contactsApiDb.exec(migration30.up); + + // Override db.get() to use our test database + const dbModule = await import('./server/db.js'); + dbModule._setTestDatabase(contactsApiDb); + }); + + after(async () => { + // Restore original database + const dbModule = await import('./server/db.js'); + dbModule._resetTestDatabase(); + }); + + describe('GET /contacts/:id', () => { + it('should return contact with multi-value fields (phones, emails, addresses)', async () => { + // Insert test contact + const result = contactsApiDb.prepare(` + INSERT INTO contacts (name, category, phone, email, notes) + VALUES (?, ?, ?, ?, ?) + `).run('Max Mustermann', 'Arzt', '+49123456789', 'max@example.com', 'Test notes'); + + const contactId = result.lastInsertRowid; + + // Insert phones + contactsApiDb.prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'Mobil', '+49171234567', 1); + + contactsApiDb.prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'Arbeit', '+49301234567', 0); + + // Insert emails + contactsApiDb.prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'Privat', 'max.privat@example.com', 1); + + contactsApiDb.prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'Arbeit', 'max.work@example.com', 0); + + // Insert addresses + contactsApiDb.prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(contactId, 'Privat', 'Musterstraße 1', 'Berlin', 'BE', '10115', 'Deutschland', 1); + + contactsApiDb.prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, postal_code, country, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(contactId, 'Arbeit', 'Arbeitsweg 10', 'München', '80331', 'Deutschland', 0); + + // Call GET /contacts/:id + const contactsRouter = await import('./server/routes/contacts.js'); + + const req = { + params: { id: String(contactId) }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const getByIdHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/:id' && layer.route.methods.get + )?.route?.stack[0]?.handle; + + assert.ok(getByIdHandler, 'GET /contacts/:id handler should exist'); + await getByIdHandler(req, res); + + // Verify response + assert.strictEqual(res.statusCode, 200); + assert.ok(res.data.data, 'Response should have data field'); + + const contact = res.data.data; + assert.strictEqual(contact.id, contactId); + assert.strictEqual(contact.name, 'Max Mustermann'); + assert.strictEqual(contact.category, 'Arzt'); + + // Verify phones array + assert.ok(Array.isArray(contact.phones), 'phones should be an array'); + assert.strictEqual(contact.phones.length, 2); + + const mobilePhone = contact.phones.find(p => p.label === 'Mobil'); + assert.ok(mobilePhone, 'Should have mobile phone'); + assert.strictEqual(mobilePhone.value, '+49171234567'); + assert.strictEqual(mobilePhone.isPrimary, true); + + const workPhone = contact.phones.find(p => p.label === 'Arbeit'); + assert.ok(workPhone, 'Should have work phone'); + assert.strictEqual(workPhone.value, '+49301234567'); + assert.strictEqual(workPhone.isPrimary, false); + + // Verify emails array + assert.ok(Array.isArray(contact.emails), 'emails should be an array'); + assert.strictEqual(contact.emails.length, 2); + + const privateEmail = contact.emails.find(e => e.label === 'Privat'); + assert.ok(privateEmail, 'Should have private email'); + assert.strictEqual(privateEmail.value, 'max.privat@example.com'); + assert.strictEqual(privateEmail.isPrimary, true); + + // Verify addresses array + assert.ok(Array.isArray(contact.addresses), 'addresses should be an array'); + assert.strictEqual(contact.addresses.length, 2); + + const homeAddress = contact.addresses.find(a => a.label === 'Privat'); + assert.ok(homeAddress, 'Should have home address'); + assert.strictEqual(homeAddress.street, 'Musterstraße 1'); + assert.strictEqual(homeAddress.city, 'Berlin'); + assert.strictEqual(homeAddress.state, 'BE'); + assert.strictEqual(homeAddress.postalCode, '10115'); + assert.strictEqual(homeAddress.country, 'Deutschland'); + assert.strictEqual(homeAddress.isPrimary, true); + + const workAddress = contact.addresses.find(a => a.label === 'Arbeit'); + assert.ok(workAddress, 'Should have work address'); + assert.strictEqual(workAddress.street, 'Arbeitsweg 10'); + assert.strictEqual(workAddress.city, 'München'); + assert.strictEqual(workAddress.postalCode, '80331'); + assert.strictEqual(workAddress.isPrimary, false); + }); + + it('should return empty arrays when contact has no multi-value fields', async () => { + // Insert contact without multi-value fields + const result = contactsApiDb.prepare(` + INSERT INTO contacts (name, category) + VALUES (?, ?) + `).run('Anna Schmidt', 'Sonstiges'); + + const contactId = result.lastInsertRowid; + + // Call GET /contacts/:id + const contactsRouter = await import('./server/routes/contacts.js'); + + const req = { + params: { id: String(contactId) }, + query: {}, + body: {} + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const getByIdHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/:id' && layer.route.methods.get + )?.route?.stack[0]?.handle; + + await getByIdHandler(req, res); + + // Verify response has empty arrays + assert.strictEqual(res.statusCode, 200); + const contact = res.data.data; + assert.strictEqual(contact.name, 'Anna Schmidt'); + assert.ok(Array.isArray(contact.phones), 'phones should be an array'); + assert.strictEqual(contact.phones.length, 0, 'phones should be empty'); + assert.ok(Array.isArray(contact.emails), 'emails should be an array'); + assert.strictEqual(contact.emails.length, 0, 'emails should be empty'); + assert.ok(Array.isArray(contact.addresses), 'addresses should be an array'); + assert.strictEqual(contact.addresses.length, 0, 'addresses should be empty'); + }); + }); + + describe('POST /contacts', () => { + it('should create contact with multi-value fields', async () => { + const contactsRouter = await import('./server/routes/contacts.js'); + + const req = { + params: {}, + query: {}, + body: { + name: 'Dr. Schmidt', + category: 'Arzt', + notes: 'Hausarzt', + phones: [ + { label: 'Praxis', value: '+4930123456', isPrimary: true }, + { label: 'Mobil', value: '+491701234567', isPrimary: false } + ], + emails: [ + { label: 'Praxis', value: 'praxis@schmidt.de', isPrimary: true } + ], + addresses: [ + { + label: 'Praxis', + street: 'Hauptstraße 10', + city: 'Berlin', + postalCode: '10115', + country: 'Deutschland', + isPrimary: true + } + ] + } + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + assert.ok(postHandler, 'POST /contacts handler should exist'); + await postHandler(req, res); + + // Verify response + assert.strictEqual(res.statusCode, 201); + assert.ok(res.data.data, 'Response should have data field'); + + const contact = res.data.data; + assert.strictEqual(contact.name, 'Dr. Schmidt'); + assert.strictEqual(contact.category, 'Arzt'); + + // Verify multi-value fields were created + assert.ok(Array.isArray(contact.phones), 'phones should be in response'); + assert.strictEqual(contact.phones.length, 2); + + const praxisPhone = contact.phones.find(p => p.label === 'Praxis'); + assert.ok(praxisPhone, 'Should have Praxis phone'); + assert.strictEqual(praxisPhone.value, '+4930123456'); + assert.strictEqual(praxisPhone.isPrimary, true); + + assert.ok(Array.isArray(contact.emails), 'emails should be in response'); + assert.strictEqual(contact.emails.length, 1); + assert.strictEqual(contact.emails[0].value, 'praxis@schmidt.de'); + + assert.ok(Array.isArray(contact.addresses), 'addresses should be in response'); + assert.strictEqual(contact.addresses.length, 1); + assert.strictEqual(contact.addresses[0].street, 'Hauptstraße 10'); + assert.strictEqual(contact.addresses[0].city, 'Berlin'); + + // Verify data persisted in database + const contactId = contact.id; + const dbPhones = contactsApiDb.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId); + assert.strictEqual(dbPhones.length, 2, 'Should have 2 phones in DB'); + + const dbEmails = contactsApiDb.prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId); + assert.strictEqual(dbEmails.length, 1, 'Should have 1 email in DB'); + + const dbAddresses = contactsApiDb.prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId); + assert.strictEqual(dbAddresses.length, 1, 'Should have 1 address in DB'); + }); + + it('should validate phones array and return 400 on invalid data', async () => { + const contactsRouter = await import('./server/routes/contacts.js'); + + const req = { + params: {}, + query: {}, + body: { + name: 'Test Contact', + phones: [ + { label: 'Invalid' } // missing value + ] + } + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postHandler(req, res); + + assert.strictEqual(res.statusCode, 400); + assert.ok(res.data.error, 'Should have error message'); + assert.ok(res.data.error.includes('Phone'), 'Error should mention Phone'); + }); + + it('should create contact without multi-value fields (backwards compatible)', async () => { + const contactsRouter = await import('./server/routes/contacts.js'); + + const req = { + params: {}, + query: {}, + body: { + name: 'Simple Contact', + category: 'Sonstiges' + } + }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postHandler(req, res); + + assert.strictEqual(res.statusCode, 201); + const contact = res.data.data; + assert.strictEqual(contact.name, 'Simple Contact'); + + // Should have empty arrays + assert.ok(Array.isArray(contact.phones)); + assert.strictEqual(contact.phones.length, 0); + assert.ok(Array.isArray(contact.emails)); + assert.strictEqual(contact.emails.length, 0); + assert.ok(Array.isArray(contact.addresses)); + assert.strictEqual(contact.addresses.length, 0); + }); + }); + + describe('PUT /contacts/:id', () => { + it('should update contact with multi-value fields (replacement semantics)', async () => { + const contactsRouter = await import('./server/routes/contacts.js'); + + // First create a contact with multi-value fields + const createReq = { + params: {}, + query: {}, + body: { + name: 'Original Contact', + category: 'Arzt', + phones: [{ label: 'Mobil', value: '+49171111111', isPrimary: true }], + emails: [{ label: 'Privat', value: 'original@example.com', isPrimary: true }], + addresses: [{ label: 'Privat', street: 'Alte Straße 1', city: 'Berlin', postalCode: '10115', country: 'Deutschland', isPrimary: true }] + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postHandler(createReq, createRes); + const contactId = createRes.data.data.id; + + // Now update it with new multi-value fields (replacement) + const updateReq = { + params: { id: String(contactId) }, + query: {}, + body: { + name: 'Updated Contact', + phones: [ + { label: 'Arbeit', value: '+49302222222', isPrimary: true }, + { label: 'Mobil', value: '+49173333333', isPrimary: false } + ], + emails: [ + { label: 'Arbeit', value: 'new.work@example.com', isPrimary: true } + ], + addresses: [ + { label: 'Arbeit', street: 'Neue Straße 10', city: 'München', postalCode: '80331', country: 'Deutschland', isPrimary: true } + ] + } + }; + const updateRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const putHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/:id' && layer.route.methods.put + )?.route?.stack[0]?.handle; + + await putHandler(updateReq, updateRes); + + assert.strictEqual(updateRes.statusCode, 200); + const updated = updateRes.data.data; + + // Check name was updated + assert.strictEqual(updated.name, 'Updated Contact'); + + // Check phones were replaced (old deleted, new inserted) + assert.strictEqual(updated.phones.length, 2); + assert.strictEqual(updated.phones[0].label, 'Arbeit'); + assert.strictEqual(updated.phones[0].value, '+49302222222'); + assert.strictEqual(updated.phones[0].isPrimary, true); + assert.strictEqual(updated.phones[1].label, 'Mobil'); + assert.strictEqual(updated.phones[1].value, '+49173333333'); + assert.strictEqual(updated.phones[1].isPrimary, false); + + // Check emails were replaced + assert.strictEqual(updated.emails.length, 1); + assert.strictEqual(updated.emails[0].label, 'Arbeit'); + assert.strictEqual(updated.emails[0].value, 'new.work@example.com'); + assert.strictEqual(updated.emails[0].isPrimary, true); + + // Check addresses were replaced + assert.strictEqual(updated.addresses.length, 1); + assert.strictEqual(updated.addresses[0].label, 'Arbeit'); + assert.strictEqual(updated.addresses[0].street, 'Neue Straße 10'); + assert.strictEqual(updated.addresses[0].city, 'München'); + assert.strictEqual(updated.addresses[0].isPrimary, true); + }); + + it('should return 400 for invalid multi-value data', async () => { + const contactsRouter = await import('./server/routes/contacts.js'); + + // Create a contact first + const createReq = { + params: {}, + query: {}, + body: { + name: 'Test Contact', + category: 'Sonstiges' + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postHandler(createReq, createRes); + const contactId = createRes.data.data.id; + + // Try to update with invalid phone data + const updateReq = { + params: { id: String(contactId) }, + query: {}, + body: { + phones: [{ label: 'Mobil', value: '', isPrimary: 'invalid' }] // empty value + invalid isPrimary + } + }; + const updateRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const putHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/:id' && layer.route.methods.put + )?.route?.stack[0]?.handle; + + await putHandler(updateReq, updateRes); + + assert.strictEqual(updateRes.statusCode, 400); + assert.ok(updateRes.data.error, 'Should have error message'); + }); + + it('should update contact without multi-value fields (backwards compatible)', async () => { + const contactsRouter = await import('./server/routes/contacts.js'); + + // Create contact with multi-value fields + const createReq = { + params: {}, + query: {}, + body: { + name: 'Test Contact', + category: 'Sonstiges', + phones: [{ label: 'Mobil', value: '+49171111111', isPrimary: true }] + } + }; + const createRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const postHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/' && layer.route.methods.post + )?.route?.stack[0]?.handle; + + await postHandler(createReq, createRes); + const contactId = createRes.data.data.id; + + // Update without touching multi-value fields (only scalar fields) + const updateReq = { + params: { id: String(contactId) }, + query: {}, + body: { + name: 'Updated Name Only', + category: 'Arzt' + } + }; + const updateRes = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { this.data = data; return this; }, + }; + + const putHandler = contactsRouter.default.stack.find( + layer => layer.route?.path === '/:id' && layer.route.methods.put + )?.route?.stack[0]?.handle; + + await putHandler(updateReq, updateRes); + + assert.strictEqual(updateRes.statusCode, 200); + const updated = updateRes.data.data; + + // Scalar fields should be updated + assert.strictEqual(updated.name, 'Updated Name Only'); + assert.strictEqual(updated.category, 'Arzt'); + + // Multi-value fields should remain unchanged + assert.strictEqual(updated.phones.length, 1); + assert.strictEqual(updated.phones[0].value, '+49171111111'); + }); + }); +});