feat(contacts): add GET /contacts/:id with multi-value fields
Implements Task 11: Extend GET /contacts/:id to include phones, emails, and addresses arrays. Each multi-value field is queried from its respective table (contact_phones, contact_emails, contact_addresses) and mapped to camelCase response format with isPrimary boolean conversion. Tests: 2 new tests (contact with multi-values, empty arrays) TDD workflow: RED → GREEN → Commit Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+18
-10
@@ -1,8 +1,8 @@
|
|||||||
# CardDAV API Routes Implementation - Fortschritt
|
# CardDAV API Routes Implementation - Fortschritt
|
||||||
|
|
||||||
**Stand:** 2026-05-04, nach Task 10 von 15 (Session 2 pausiert bei ~77k tokens)
|
**Stand:** 2026-05-04, nach Task 11 von 15 (Session 3)
|
||||||
**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md`
|
**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md`
|
||||||
**Nächster Task:** Task 11 - GET /contacts/:id mit Multi-Value Fields
|
**Nächster Task:** Task 12 - POST /contacts mit Multi-Value Fields
|
||||||
|
|
||||||
## Abgeschlossene Tasks
|
## Abgeschlossene Tasks
|
||||||
|
|
||||||
@@ -124,13 +124,20 @@
|
|||||||
- Tests: 2 Tests (success case, 404 für non-existent account)
|
- Tests: 2 Tests (success case, 404 für non-existent account)
|
||||||
- Mock: `_mockSyncAccount()` für Tests hinzugefügt (Pattern wie `_mockTestConnection`)
|
- Mock: `_mockSyncAccount()` für Tests hinzugefügt (Pattern wie `_mockTestConnection`)
|
||||||
|
|
||||||
## Offene Tasks (11-15)
|
### ✅ Task 11: GET /contacts/:id - With Multi-Value Fields
|
||||||
|
**Commit:** [wird gesetzt nach commit]
|
||||||
|
|
||||||
### 🔄 Task 11: GET /contacts/:id
|
- Implementiert: GET /contacts/:id Route in `server/routes/contacts.js`
|
||||||
- Erweitern um Multi-Value Fields (phones, emails, addresses)
|
- Queries: Separate Abfragen für `contact_phones`, `contact_emails`, `contact_addresses`
|
||||||
- Bestehende Route in `server/routes/contacts.js` erweitern
|
- Mapping: is_primary (Integer DB) → isPrimary (Boolean Response), snake_case → camelCase
|
||||||
- Zusätzliche Queries für `contact_phones`, `contact_emails`, `contact_addresses`
|
- Sortierung: ORDER BY is_primary DESC, id ASC (Primary-Einträge zuerst)
|
||||||
- Response-Format: `{ ...contact, phones: [], emails: [], addresses: [] }`
|
- 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
|
||||||
|
|
||||||
|
## Offene Tasks (12-15)
|
||||||
|
|
||||||
### 🔄 Task 12: POST /contacts
|
### 🔄 Task 12: POST /contacts
|
||||||
- Erstellen mit Multi-Value Fields
|
- Erstellen mit Multi-Value Fields
|
||||||
@@ -232,9 +239,10 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
|
|||||||
|
|
||||||
## Test-Status
|
## Test-Status
|
||||||
|
|
||||||
- **Gesamt:** 101 Tests, alle bestehen
|
- **Gesamt:** 103 Tests, alle bestehen
|
||||||
- **Suites:** 16 Suites
|
- **Suites:** 18 Suites
|
||||||
- **CardDAV API Routes Suite:** 14 Tests
|
- **CardDAV API Routes Suite:** 14 Tests
|
||||||
|
- **Contacts API - Multi-Value Fields Suite:** 2 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)
|
||||||
@@ -274,7 +282,7 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
|
|||||||
#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. [completed] 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. [completed] 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
|
||||||
#14. [pending] Task 14: Document All Routes in OpenAPI
|
#14. [pending] Task 14: Document All Routes in OpenAPI
|
||||||
|
|||||||
@@ -256,6 +256,71 @@ 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 });
|
||||||
|
|
||||||
|
// Query multi-value fields
|
||||||
|
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(id).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(id).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(id).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
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Combine contact with multi-value fields
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
...contact,
|
||||||
|
phones,
|
||||||
|
emails,
|
||||||
|
addresses
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.error('', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/contacts/:id/vcard
|
* GET /api/v1/contacts/:id/vcard
|
||||||
* Kontakt als vCard 3.0 (.vcf) exportieren.
|
* Kontakt als vCard 3.0 (.vcf) exportieren.
|
||||||
|
|||||||
+213
@@ -2085,3 +2085,216 @@ describe('CardDAV API Routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user