From f7eb73b83516f7e80a24ba701b9298b86b90125d Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 16:54:52 +0200 Subject: [PATCH] feat(cardav): implement POST /accounts endpoint Add account creation route with validation. Delegates to CardDAVSync.addAccount() which creates account and discovers addressbooks. Returns 201 with account + addressbooks array. Add _mockTestConnection() helper for testing CardDAV routes without real server connections. Co-Authored-By: Claude Sonnet 4.5 --- server/routes/cardav.js | 31 ++++++++++++++ server/services/cardav-sync.js | 31 +++++++++++++- test-carddav.js | 75 ++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/server/routes/cardav.js b/server/routes/cardav.js index 971dab4..bec77c0 100644 --- a/server/routes/cardav.js +++ b/server/routes/cardav.js @@ -7,8 +7,10 @@ import { createLogger } from '../logger.js'; import express from 'express'; import * as CardDAVSync from '../services/cardav-sync.js'; +import { str, collectErrors, MAX_TITLE } from '../middleware/validate.js'; const log = createLogger('CardDAV'); +const MAX_URL = 500; const router = express.Router(); /** @@ -26,4 +28,33 @@ router.get('/accounts', async (req, res) => { } }); +/** + * 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 }); + } +}); + export default router; diff --git a/server/services/cardav-sync.js b/server/services/cardav-sync.js index 1b1f1b3..c411c3a 100644 --- a/server/services/cardav-sync.js +++ b/server/services/cardav-sync.js @@ -212,6 +212,11 @@ function parseBirthday(value) { * @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({ @@ -288,7 +293,16 @@ async function addAccount(name, cardavUrl, username, password) { log.info(`Added CardDAV account "${name}" with ${addressbooks.length} addressbooks.`); - return { accountId, addressbooks: addressbookData }; + 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; @@ -869,4 +883,19 @@ export { // Helpers (exported for testing) parseVCard, + _mockTestConnection, }; + +// -------------------------------------------------------- +// Test Mocking Support +// -------------------------------------------------------- + +let _testConnectionMock = null; + +/** + * ONLY FOR TESTING: Mock testConnection for unit tests + * @param {Function|null} mockFn - Mock function or null to reset + */ +function _mockTestConnection(mockFn) { + _testConnectionMock = mockFn; +} diff --git a/test-carddav.js b/test-carddav.js index 8a6f425..1bc6098 100644 --- a/test-carddav.js +++ b/test-carddav.js @@ -1513,12 +1513,26 @@ describe('CardDAV API Routes', () => { // 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' } + ] + })); }); 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); }); describe('Account Management', () => { @@ -1576,5 +1590,66 @@ describe('CardDAV API Routes', () => { 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')); + }); }); });