feat(cardav): implement POST /accounts/:id/sync endpoint

Adds route to sync all enabled addressbooks for an account with mock support for tests.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 17:54:03 +02:00
parent f895776911
commit 674fe796b3
4 changed files with 145 additions and 6 deletions
+20 -6
View File
@@ -1,6 +1,6 @@
# CardDAV API Routes Implementation - Fortschritt # CardDAV API Routes Implementation - Fortschritt
**Stand:** 2026-05-04, nach Task 9 von 15 **Stand:** 2026-05-04, nach Task 10 von 15
**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md` **Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md`
## Abgeschlossene Tasks ## Abgeschlossene Tasks
@@ -112,7 +112,18 @@
- Tests: 2 Tests (success case mit toggle, validation failure für invalid enabled) - Tests: 2 Tests (success case mit toggle, validation failure für invalid enabled)
- bool Validator verwendet - bool Validator verwendet
## Offene Tasks (10-15) ### ✅ Task 10: POST /accounts/:id/sync - Sync Account
**Commit:** (pending)
- 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`)
## Offene Tasks (11-15)
### 🔄 Task 10: POST /accounts/:id/sync ### 🔄 Task 10: POST /accounts/:id/sync
- Sync Account (alle enabled addressbooks) - Sync Account (alle enabled addressbooks)
@@ -196,9 +207,9 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
## Test-Status ## Test-Status
- **Gesamt:** 99 Tests, alle bestehen - **Gesamt:** 101 Tests, alle bestehen
- **Suites:** 15 Suites - **Suites:** 16 Suites
- **CardDAV API Routes Suite:** 12 Tests - **CardDAV API Routes Suite:** 14 Tests
- Account Management (6 Tests): - Account Management (6 Tests):
- GET /accounts (empty) - GET /accounts (empty)
- GET /accounts (populated with shape) - GET /accounts (populated with shape)
@@ -214,6 +225,9 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
- Addressbook Management (2 Tests): - Addressbook Management (2 Tests):
- PUT /addressbooks/:id (toggle enabled/disabled) - PUT /addressbooks/:id (toggle enabled/disabled)
- PUT /addressbooks/:id (validation failure) - 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 & Remote
@@ -234,7 +248,7 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
#7. [completed] Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks #7. [completed] Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks
#8. [completed] Task 8: Add bool validator to validate.js #8. [completed] Task 8: Add bool validator to validate.js
#9. [completed] Task 9: PUT /addressbooks/:id - Toggle Addressbook #9. [completed] Task 9: PUT /addressbooks/:id - Toggle Addressbook
#10. [pending] Task 10: POST /accounts/:id/sync - Sync Account #10. [completed] Task 10: POST /accounts/:id/sync - Sync Account
#11. [pending] Task 11: GET /contacts/:id - With Multi-Values #11. [pending] Task 11: GET /contacts/:id - With Multi-Values
#12. [pending] Task 12: POST /contacts - Create With Multi-Values #12. [pending] Task 12: POST /contacts - Create With Multi-Values
#13. [pending] Task 13: PUT /contacts/:id - Update With Multi-Values #13. [pending] Task 13: PUT /contacts/:id - Update With Multi-Values
+22
View File
@@ -180,4 +180,26 @@ router.put('/addressbooks/:id', async (req, res) => {
} }
}); });
/**
* 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; export default router;
+15
View File
@@ -467,6 +467,11 @@ function toggleAddressbook(addressbookId, enabled) {
* @returns {Promise<Object>} { synced, errors } * @returns {Promise<Object>} { synced, errors }
*/ */
async function syncAccount(accountId) { async function syncAccount(accountId) {
// Use mock if set (for testing)
if (_syncAccountMock) {
return _syncAccountMock(accountId);
}
try { try {
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
if (!account) { if (!account) {
@@ -884,6 +889,7 @@ export {
// Helpers (exported for testing) // Helpers (exported for testing)
parseVCard, parseVCard,
_mockTestConnection, _mockTestConnection,
_mockSyncAccount,
}; };
// -------------------------------------------------------- // --------------------------------------------------------
@@ -891,6 +897,7 @@ export {
// -------------------------------------------------------- // --------------------------------------------------------
let _testConnectionMock = null; let _testConnectionMock = null;
let _syncAccountMock = null;
/** /**
* ONLY FOR TESTING: Mock testConnection for unit tests * ONLY FOR TESTING: Mock testConnection for unit tests
@@ -899,3 +906,11 @@ let _testConnectionMock = null;
function _mockTestConnection(mockFn) { function _mockTestConnection(mockFn) {
_testConnectionMock = 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;
}
+88
View File
@@ -1523,6 +1523,13 @@ describe('CardDAV API Routes', () => {
{ url: 'https://example.com/carddav/addressbook2', displayName: 'Work' } { 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 () => { after(async () => {
@@ -1533,6 +1540,9 @@ describe('CardDAV API Routes', () => {
// Reset testConnection mock // Reset testConnection mock
const cardavSync = await import('./server/services/cardav-sync.js'); const cardavSync = await import('./server/services/cardav-sync.js');
cardavSync._mockTestConnection(null); cardavSync._mockTestConnection(null);
// Reset syncAccount mock
cardavSync._mockSyncAccount(null);
}); });
describe('Account Management', () => { describe('Account Management', () => {
@@ -1996,4 +2006,82 @@ describe('CardDAV API Routes', () => {
assert.ok(res.data.error.includes('enabled')); 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);
});
});
}); });