contacts: owners' contacts on their desk phones, copy builtin contacts to Contrack (C6)
ci / check (push) Successful in 1m2s
ci / images (push) Successful in 20s
ci / check (pull_request) Successful in 1m4s
ci / images (pull_request) Successful in 5s

Spec 11 §6.2: a phone's directory is the extensions, its owner's own contacts (accepted shares
included), then the company's, in both modes. In Contrack mode, contacts from before Contrack
are offered for copying (contrack.toCopy, POST …/copy-to-contrack: one Contrack bulk import,
skipping what's already there; the builtin rows stay). Tested against a fake and a real
Contrack 2.0.2.
This commit is contained in:
iamdoubz committed 2026-10-23 16:07:19 -05:00
1 parent e6a1373f36
commit 604e40adf1
16 files changed
+294 -15

No files matched your search

+30 -2
View File
@@ -11,7 +11,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { contactNames } from '../src/contacts/book.ts';
import { logCallsToContrack } from '../src/contacts/timeline.ts';
import { callExtensions, calls, extensions, recordings, users } from '../src/db/schema.ts';
import {
callExtensions,
calls,
contacts,
extensions,
recordings,
users,
} from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
@@ -188,6 +195,7 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
toCopy: 0,
});
expect(book.items).toEqual([
expect.objectContaining({
@@ -307,6 +315,26 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
expect(content).not.toContain('<quote>');
});
it('contacts from before Contrack copy into the account in one import (C6)', async () => {
await t.db.insert(contacts).values([
{
name: 'Old Friend',
ownerUserId: danId,
numbers: [{ label: 'home', number: '13125550202' }],
},
{ name: 'Jane Doe', ownerUserId: danId, numbers: [{ label: '', number: '13125551234' }] },
]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
const r = await dan('POST', '/api/me/contacts/copy-to-contrack');
expect(r.json(), r.body).toEqual({ added: 1, duplicates: 1, failed: 0 });
const rows = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
phones: { phone: string }[];
}[];
expect(rows.find((x) => x.name === 'Old Friend')?.phones).toEqual([{ phone: '13125550202' }]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
});
it('a share is accepted into the recipient’s own Contrack account', async () => {
const jane = (await dan('GET', '/api/me/contacts'))
.json()
@@ -328,6 +356,6 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
const dans = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
}[];
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe']);
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe', 'Old Friend']);
});
});
+80 -1
View File
@@ -11,6 +11,7 @@ import { logCallsToContrack } from '../src/contacts/timeline.ts';
import {
callExtensions,
calls,
contacts,
contrackCallLog,
extensions,
recordings,
@@ -67,6 +68,30 @@ function fakeContrack() {
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const book = books[token];
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
if (req.method === 'POST' && req.url === '/api/contacts/bulk') {
if (!/^[0-9a-f-]{36}$/.test(String(req.headers['x-import-id'])))
return send(400, { error: { code: 'VALIDATION_ERROR', message: 'X-Import-Id' } });
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const rows = JSON.parse(raw) as { name: string; phones: { phone: string }[] }[];
for (const r of rows)
book.push({
id: `bulk-${++n}`,
name: r.name,
company: null,
emails: [],
phones: r.phones,
});
send(201, {
success: true,
count: rows.length,
failed: 0,
importId: req.headers['x-import-id'],
});
});
return;
}
const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
if (req.method === 'POST' && into) {
if (state.readOnly.has(token))
@@ -217,6 +242,7 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
toCopy: 0,
});
expect(book.items).toEqual([
{
@@ -436,10 +462,63 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
expect(jane).toHaveLength(8);
});
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
await t.db.insert(contacts).values([
{
name: 'Jane Doe',
ownerUserId: danId,
numbers: [{ label: 'mobile', number: '13125551234' }],
}, // in Contrack
{
name: 'Old Friend',
ownerUserId: danId,
numbers: [{ label: 'home', number: '13125550202' }],
},
{ name: 'Plumber', numbers: [{ label: '', number: '13125557777' }] }, // in the company account
{ name: 'Locksmith', numbers: [{ label: 'work', number: '13125550303' }] },
]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
added: 1,
duplicates: 1,
failed: 0,
});
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
name: 'Old Friend',
phones: [{ phone: '13125550202', label: 'home' }],
});
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
added: 0,
duplicates: 2,
failed: 0,
});
// The company book, by an admin only.
expect((await dan('POST', '/api/contacts/copy-to-contrack')).statusCode).toBe(403);
expect((await admin('GET', '/api/contacts')).json().contrack.toCopy).toBe(1);
expect((await admin('POST', '/api/contacts/copy-to-contrack')).json()).toEqual({
added: 1,
duplicates: 1,
failed: 0,
});
expect(fake.books.ctk_company!.map((c) => c.name)).toEqual(['Plumber', 'Locksmith']);
// Dan's desk phone: his Contrack contacts, then the company's.
const names = (await loadPhonebook(t.db, danId)).map((e) => e.name);
expect(names.indexOf('Old Friend')).toBeGreaterThan(-1);
expect(names.indexOf('Old Friend')).toBeLessThan(names.indexOf('Locksmith'));
expect((await loadPhonebook(t.db, null)).map((e) => e.name)).not.toContain('Old Friend');
});
it('back to builtin: the table again, tokens kept for later', async () => {
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book).toEqual({ items: [], provider: 'builtin', contrack: null });
expect([book.provider, book.contrack]).toEqual(['builtin', null]);
expect(book.items.map((c: { name: string }) => c.name)).toEqual([
'Jane Doe',
'Old Friend',
'Locksmith',
'Plumber',
]);
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
});
+26
View File
@@ -245,6 +245,32 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
await setSetting(t.db, 'general.timezone', 'America/Chicago');
});
it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => {
const owner = await t.addUser('dan@example.com', 'dan-password-123');
const other = await t.addUser('sue@example.com', 'sue-password-123');
await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id });
await t.db.insert(contacts).values([
{ name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] },
{
name: 'Sue Secret',
ownerUserId: other.id,
numbers: [{ label: '', number: '13125550102' }],
},
]);
const book = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(book).toContain('<Telephone label="mobile">13125550101</Telephone>');
expect(book).not.toContain('Sue Secret');
expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom'));
expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's
await req('PATCH', `/api/extensions/${extId}`, { userId: null });
const unowned = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(unowned).not.toContain('Mom');
});
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
expect(r.statusCode, r.body).toBe(200);