contacts: owners' contacts on their desk phones, copy builtin contacts to Contrack (C6)
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:
1 parent
e6a1373f36
commit
604e40adf1
16 files changed
+294
-15
No files matched your search
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user