diff --git a/PROGRESS.md b/PROGRESS.md index 1b99f77..b657626 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,8 +1,8 @@ # CardDAV API Routes Implementation - Fortschritt -**Stand:** 2026-05-04, nach Task 12 von 15 (Session 3) +**Stand:** 2026-05-04, nach Task 13 von 15 (Session 3) **Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md` -**Nächster Task:** Task 13 - PUT /contacts/:id mit Multi-Value Fields +**Nächster Task:** Task 14 - OpenAPI Documentation ## Abgeschlossene Tasks @@ -152,12 +152,22 @@ - Refactoring: `loadMultiValueFields(contactId)` Helper extrahiert (DRY) - TDD-Workflow eingehalten: RED → GREEN → REFACTOR → Commit -## Offene Tasks (13-15) +### ✅ Task 13: PUT /contacts/:id - Update With Multi-Value Fields +**Commit:** (wird erstellt) -### 🔄 Task 13: PUT /contacts/:id -- Update mit Multi-Value Fields -- Replacement-Semantik für Arrays (DELETE + INSERT) -- Atomare Transaktionen +- 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 + +## Offene Tasks (14-15) ### 🔄 Task 14: OpenAPI Documentation - Alle neuen Routes in `server/openapi.js` dokumentieren @@ -249,12 +259,13 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint ## Test-Status -- **Gesamt:** 106 Tests, alle bestehen -- **Suites:** 19 Suites +- **Gesamt:** 109 Tests, alle bestehen +- **Suites:** 20 Suites - **CardDAV API Routes Suite:** 14 Tests -- **Contacts API - Multi-Value Fields Suite:** 5 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) @@ -296,7 +307,7 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint #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. [pending] Task 13: PUT /contacts/:id - Update With Multi-Values +#13. [completed] Task 13: PUT /contacts/:id - Update With Multi-Values #14. [pending] Task 14: Document All Routes in OpenAPI #15. [pending] Task 15: Mount CardDAV Router ``` diff --git a/server/routes/contacts.js b/server/routes/contacts.js index 5d854c3..bd0861f 100644 --- a/server/routes/contacts.js +++ b/server/routes/contacts.js @@ -299,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) => { @@ -318,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 }); diff --git a/test-carddav.js b/test-carddav.js index 71a6b4a..c21720f 100644 --- a/test-carddav.js +++ b/test-carddav.js @@ -2445,4 +2445,201 @@ describe('Contacts API - Multi-Value Fields', () => { 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'); + }); + }); });