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 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 16:54:52 +02:00
parent 930800eed9
commit f7eb73b835
3 changed files with 136 additions and 1 deletions
+31
View File
@@ -7,8 +7,10 @@
import { createLogger } from '../logger.js'; import { createLogger } from '../logger.js';
import express from 'express'; import express from 'express';
import * as CardDAVSync from '../services/cardav-sync.js'; import * as CardDAVSync from '../services/cardav-sync.js';
import { str, collectErrors, MAX_TITLE } from '../middleware/validate.js';
const log = createLogger('CardDAV'); const log = createLogger('CardDAV');
const MAX_URL = 500;
const router = express.Router(); 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; export default router;
+30 -1
View File
@@ -212,6 +212,11 @@ function parseBirthday(value) {
* @returns {Promise<Object>} { ok: true, addressbooks: [...] } * @returns {Promise<Object>} { ok: true, addressbooks: [...] }
*/ */
async function testConnection(cardavUrl, username, password) { async function testConnection(cardavUrl, username, password) {
// Use mock if set (for testing)
if (_testConnectionMock) {
return _testConnectionMock(cardavUrl, username, password);
}
try { try {
const { createDAVClient } = await import('tsdav'); const { createDAVClient } = await import('tsdav');
const client = await createDAVClient({ 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.`); 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) { } catch (err) {
log.error('Failed to add account:', err.message); log.error('Failed to add account:', err.message);
throw err; throw err;
@@ -869,4 +883,19 @@ export {
// Helpers (exported for testing) // Helpers (exported for testing)
parseVCard, 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;
}
+75
View File
@@ -1513,12 +1513,26 @@ describe('CardDAV API Routes', () => {
// Override db.get() to use our test database // Override db.get() to use our test database
const dbModule = await import('./server/db.js'); const dbModule = await import('./server/db.js');
dbModule._setTestDatabase(apiTestDb); 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 () => { after(async () => {
// Restore original database // Restore original database
const dbModule = await import('./server/db.js'); const dbModule = await import('./server/db.js');
dbModule._resetTestDatabase(); dbModule._resetTestDatabase();
// Reset testConnection mock
const cardavSync = await import('./server/services/cardav-sync.js');
cardavSync._mockTestConnection(null);
}); });
describe('Account Management', () => { describe('Account Management', () => {
@@ -1576,5 +1590,66 @@ describe('CardDAV API Routes', () => {
assert.strictEqual(account.createdAt, '2026-05-01T10:00:00Z'); assert.strictEqual(account.createdAt, '2026-05-01T10:00:00Z');
assert.ok(!account.password, 'Password should not be exposed'); 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'));
});
}); });
}); });