contacts: Contrack as the robust contacts (C3), picked in Settings or the setup wizard
Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings, books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack (2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake and a real Contrack 2.0.2 (contrack-live.test.ts).
This commit is contained in:
1 parent
a754974a90
commit
692dd59552
32 files changed
+5787
-120
No files matched your search
@@ -0,0 +1,261 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C3 against a real Contrack (decision 0003): the routes we use, as the pinned version answers
|
||||
// them. Skipped unless CONTRACK_LIVE_URL is set; it needs a fresh Contrack with sign-in on and no
|
||||
// account yet (it makes the accounts and tokens itself):
|
||||
//
|
||||
// docker run -d --rm --name contrack-live -p 127.0.0.1:3210:3210 -e AUTH_REQUIRED=true \
|
||||
// ghcr.io/arvarik/contrack:2.0.2
|
||||
// CONTRACK_LIVE_URL=http://127.0.0.1:3210 pnpm vitest run apps/api/test/contrack-live.test.ts
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { loadPhonebook } from '../src/provisioning/context.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const LIVE = process.env.CONTRACK_LIVE_URL;
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
|
||||
/** Contrack's own API, signed in with a session cookie (accounts, tokens) or a token. */
|
||||
async function ctk(
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown; cookie?: string; token?: string } = {},
|
||||
) {
|
||||
const r = await fetch(`${LIVE}${path}`, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(init.cookie ? { Cookie: init.cookie } : {}),
|
||||
...(init.token ? { Authorization: `Bearer ${init.token}` } : {}),
|
||||
},
|
||||
body: init.body ? JSON.stringify(init.body) : undefined,
|
||||
});
|
||||
const cookie = r.headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(';')[0])
|
||||
.join('; ');
|
||||
return {
|
||||
status: r.status,
|
||||
body: (await r.json().catch(() => null)) as Record<string, unknown>,
|
||||
cookie,
|
||||
};
|
||||
}
|
||||
|
||||
const PASSWORD = 'Correct-horse-battery-9';
|
||||
|
||||
/** A Contrack account's read-write personal token that never expires. */
|
||||
async function tokenOf(cookie: string, name: string) {
|
||||
const t = await ctk('/api/auth/tokens', { method: 'POST', cookie, body: { name } });
|
||||
expect(t.status, JSON.stringify(t.body)).toBe(201);
|
||||
return t.body.token as string;
|
||||
}
|
||||
|
||||
/** An admin makes a member, who signs in, sets their own password, and makes a token. */
|
||||
async function member(adminCookie: string, username: string) {
|
||||
const u = await ctk('/api/admin/users', {
|
||||
method: 'POST',
|
||||
cookie: adminCookie,
|
||||
body: {
|
||||
email: `${username}@example.com`,
|
||||
username,
|
||||
displayName: username,
|
||||
temporaryPassword: 'Temporary-pass-123',
|
||||
},
|
||||
});
|
||||
expect(u.status, JSON.stringify(u.body)).toBe(201);
|
||||
const login = await ctk('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { identifier: username, password: 'Temporary-pass-123' },
|
||||
});
|
||||
expect(login.status, JSON.stringify(login.body)).toBe(200);
|
||||
const ch = await ctk('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
cookie: login.cookie,
|
||||
body: { currentPassword: 'Temporary-pass-123', newPassword: PASSWORD },
|
||||
});
|
||||
expect(ch.status, JSON.stringify(ch.body)).toBeLessThan(300);
|
||||
const again = await ctk('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { identifier: username, password: PASSWORD },
|
||||
});
|
||||
return tokenOf(again.cookie, 'SelfPhoneNG');
|
||||
}
|
||||
|
||||
describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: Req;
|
||||
let dan: Req;
|
||||
let sue: Req;
|
||||
let danId: string;
|
||||
const tok = { company: '', dan: '', sue: '' };
|
||||
|
||||
beforeAll(async () => {
|
||||
// Contrack: the first account is the company's (and Contrack's admin); Dan and Sue are members.
|
||||
const setup = await ctk('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email: 'company@example.com',
|
||||
username: 'company',
|
||||
password: PASSWORD,
|
||||
displayName: 'Company',
|
||||
},
|
||||
});
|
||||
expect(setup.status, `needs a fresh Contrack: ${JSON.stringify(setup.body)}`).toBe(201);
|
||||
tok.company = await tokenOf(setup.cookie, 'SelfPhoneNG company');
|
||||
tok.dan = await member(setup.cookie, 'dan');
|
||||
tok.sue = await member(setup.cookie, 'sue');
|
||||
for (const [token, body] of [
|
||||
[tok.company, { name: 'Plumber', phones: ['+1 312 555 7777'] }],
|
||||
[
|
||||
tok.dan,
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme',
|
||||
emails: [{ email: 'jane@acme.example' }],
|
||||
phones: [{ phone: '(312) 555-1234', label: 'mobile' }, 'front desk ext 123'],
|
||||
},
|
||||
],
|
||||
] as const) {
|
||||
const c = await ctk('/api/contacts', { method: 'POST', token, body });
|
||||
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
||||
}
|
||||
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const as =
|
||||
(cookie: string): Req =>
|
||||
(method, u, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url: u,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
admin = as(await t.login(ADMIN.email, ADMIN.password));
|
||||
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
|
||||
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
|
||||
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
|
||||
dan = as(await t.login('dan@example.com', 'dan-password-123'));
|
||||
sue = as(await t.login('sue@example.com', 'sue-password-123'));
|
||||
}, 60_000);
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('Test, then save: Contrack answers and reads the company account', async () => {
|
||||
expect(
|
||||
(
|
||||
await admin('POST', '/api/contacts/contrack/test', { url: LIVE, companyToken: tok.company })
|
||||
).json(),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
detail: 'Contrack answers; the token reads 1 contact.',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await admin('POST', '/api/contacts/contrack/test', {
|
||||
url: LIVE,
|
||||
companyToken: 'ctk_wrong_token',
|
||||
})
|
||||
).json(),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'Contrack refused the token',
|
||||
});
|
||||
const r = await admin('PATCH', '/api/settings', {
|
||||
'contacts.provider': 'contrack',
|
||||
'contacts.contrack': {
|
||||
url: LIVE,
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
companyToken: tok.company,
|
||||
},
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
});
|
||||
|
||||
it('a person connects; their book and the company book come from Contrack', async () => {
|
||||
expect((await dan('PUT', '/api/me/contrack', { token: 'ctk_wrong_token' })).statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
expect(
|
||||
(await dan('PUT', '/api/me/contrack', { token: tok.dan })).json().contrack.connected,
|
||||
).toBe(true);
|
||||
const book = (await dan('GET', '/api/me/contacts')).json();
|
||||
expect(book.contrack).toEqual({
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
connected: true,
|
||||
error: null,
|
||||
});
|
||||
expect(book.items).toEqual([
|
||||
expect.objectContaining({
|
||||
book: 'mine',
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme',
|
||||
email: 'jane@acme.example',
|
||||
numbers: [{ label: '', number: '13125551234' }],
|
||||
url: expect.stringMatching(
|
||||
/^https:\/\/contacts\.example\.com\/contact\/jane-doe-[0-9a-f]{8}$/,
|
||||
),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
book: 'company',
|
||||
name: 'Plumber',
|
||||
numbers: [{ label: '', number: '13125557777' }],
|
||||
}),
|
||||
]);
|
||||
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
|
||||
expect(Object.fromEntries(names)).toEqual({
|
||||
'13125551234': 'Jane Doe',
|
||||
'13125557777': 'Plumber',
|
||||
});
|
||||
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
|
||||
'Dan Doe',
|
||||
'Sue Smith',
|
||||
'Plumber',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Add to contacts lands in Contrack, with its label', async () => {
|
||||
const r = await dan('POST', '/api/me/contacts', {
|
||||
name: 'Bob Smith',
|
||||
company: 'Widgets',
|
||||
email: 'bob@widgets.example',
|
||||
numbers: [{ label: 'work', number: '312-555-9999' }],
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
const id = r.json().id as string;
|
||||
const full = await ctk(`/api/contacts/${id}`, { token: tok.dan });
|
||||
expect(full.body).toMatchObject({
|
||||
name: 'Bob Smith',
|
||||
company: 'Widgets',
|
||||
emails: [expect.objectContaining({ email: 'bob@widgets.example' })],
|
||||
phones: [expect.objectContaining({ phone: '13125559999', label: 'work' })],
|
||||
});
|
||||
expect(
|
||||
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
|
||||
).toContain('Bob Smith');
|
||||
});
|
||||
|
||||
it('a share is accepted into the recipient’s own Contrack account', async () => {
|
||||
const jane = (await dan('GET', '/api/me/contacts'))
|
||||
.json()
|
||||
.items.find((c: { name: string }) => c.name === 'Jane Doe');
|
||||
await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id], extensions: ['102'] });
|
||||
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).statusCode,
|
||||
).toBe(409);
|
||||
await sue('PUT', '/api/me/contrack', { token: tok.sue });
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
|
||||
).toEqual({ added: 1, duplicates: 0 });
|
||||
const sues = (await ctk('/api/contacts?view=slim', { token: tok.sue })).body as unknown as {
|
||||
name: string;
|
||||
}[];
|
||||
expect(sues.map((c) => c.name)).toEqual(['Jane Doe']);
|
||||
// Dan's account is untouched: Sue got a copy.
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C3: contacts in Contrack (11 §4), against a fake Contrack that answers the routes we use.
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { loadPhonebook } from '../src/provisioning/context.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
type Slim = {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string | null;
|
||||
emails: { email: string }[];
|
||||
phones: { phone: string }[];
|
||||
};
|
||||
|
||||
/** Contrack's slim rows, one private book per token (11 §4.1). */
|
||||
function fakeContrack() {
|
||||
const books: Record<string, Slim[]> = {
|
||||
ctk_company: [
|
||||
{
|
||||
id: 'plumber-1a2b3c4d',
|
||||
name: 'Plumber',
|
||||
company: null,
|
||||
emails: [],
|
||||
phones: [{ phone: '+1 312 555 7777' }],
|
||||
},
|
||||
],
|
||||
ctk_dan_0001: [
|
||||
{
|
||||
id: 'jane-doe-00000001',
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme',
|
||||
emails: [{ email: 'jane@acme.example' }],
|
||||
phones: [{ phone: '(312) 555-1234' }, { phone: 'ask reception' }],
|
||||
},
|
||||
],
|
||||
ctk_sue_0001: [],
|
||||
};
|
||||
let n = 0;
|
||||
const server: Server = createServer((req, res) => {
|
||||
const send = (status: number, body: unknown) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (req.url === '/healthz') return send(200, { ok: true });
|
||||
const book = books[(req.headers.authorization ?? '').replace(/^Bearer /, '')];
|
||||
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
|
||||
if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book);
|
||||
if (req.method === 'POST' && req.url === '/api/contacts') {
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const b = JSON.parse(raw) as {
|
||||
name: string;
|
||||
company?: string;
|
||||
emails?: { email: string }[];
|
||||
phones: { phone: string }[];
|
||||
};
|
||||
const row = {
|
||||
id: `new-${++n}`,
|
||||
name: b.name,
|
||||
company: b.company ?? null,
|
||||
emails: b.emails ?? [],
|
||||
phones: b.phones,
|
||||
};
|
||||
book.push(row);
|
||||
send(201, row);
|
||||
});
|
||||
return;
|
||||
}
|
||||
send(404, { error: { code: 'ROUTE_NOT_FOUND', message: 'No route' } });
|
||||
});
|
||||
return { server, books };
|
||||
}
|
||||
|
||||
describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: Req;
|
||||
let dan: Req;
|
||||
let sue: Req;
|
||||
let danId: string;
|
||||
const fake = fakeContrack();
|
||||
let url: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((ok) => fake.server.listen(0, '127.0.0.1', ok));
|
||||
url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`;
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const as =
|
||||
(cookie: string): Req =>
|
||||
(method, u, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url: u,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
admin = as(await t.login(ADMIN.email, ADMIN.password));
|
||||
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
|
||||
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
|
||||
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
|
||||
dan = as(await t.login('dan@example.com', 'dan-password-123'));
|
||||
sue = as(await t.login('sue@example.com', 'sue-password-123'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
fake.server.close();
|
||||
});
|
||||
|
||||
it('Settings → Contacts: a local address only, the company token write-only', async () => {
|
||||
const far = await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url: 'https://contrack.example.com', companyToken: 'ctk_company' },
|
||||
});
|
||||
expect(far.statusCode).toBe(400);
|
||||
expect(far.json().error.message).toMatch(/IP address/);
|
||||
expect(
|
||||
(
|
||||
await admin('POST', '/api/contacts/contrack/test', { url, companyToken: 'ctk_company' })
|
||||
).json(),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
detail: 'Contrack answers; the token reads 1 contact.',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await admin('POST', '/api/contacts/contrack/test', {
|
||||
url: 'http://127.0.0.1:9',
|
||||
companyToken: 'x',
|
||||
})
|
||||
).json().ok,
|
||||
).toBe(false);
|
||||
const r = await admin('PATCH', '/api/settings', {
|
||||
'contacts.provider': 'contrack',
|
||||
'contacts.contrack': {
|
||||
url: `${url}/`,
|
||||
publicUrl: 'https://contacts.example.com/',
|
||||
companyToken: 'ctk_company',
|
||||
},
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
expect(r.json()['contacts.contrack']).toEqual({
|
||||
url,
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
hasCompanyToken: true,
|
||||
});
|
||||
expect(r.body).not.toContain('ctk_company');
|
||||
// Saving again without a token keeps it.
|
||||
await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url, publicUrl: 'https://contacts.example.com' },
|
||||
});
|
||||
expect((await admin('GET', '/api/settings')).json()['contacts.contrack'].hasCompanyToken).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('each person connects their own token; until then they see the company book', async () => {
|
||||
expect((await dan('GET', '/api/me')).json().contrack).toEqual({
|
||||
connected: false,
|
||||
tokenPrefix: null,
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
});
|
||||
const before = (await dan('GET', '/api/me/contacts')).json();
|
||||
expect(before.provider).toBe('contrack');
|
||||
expect(before.contrack.connected).toBe(false);
|
||||
expect(before.items.map((c: { name: string }) => c.name)).toEqual(['Plumber']);
|
||||
|
||||
const bad = await dan('PUT', '/api/me/contrack', { token: 'ctk_not_mine' });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
expect(bad.json().error.message).toMatch(/refused the token/);
|
||||
const ok = await dan('PUT', '/api/me/contrack', { token: 'ctk_dan_0001' });
|
||||
expect(ok.json().contrack).toMatchObject({ connected: true, tokenPrefix: 'ctk_dan_0001…' });
|
||||
|
||||
const book = (await dan('GET', '/api/me/contacts')).json();
|
||||
expect(book.contrack).toEqual({
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
connected: true,
|
||||
error: null,
|
||||
});
|
||||
expect(book.items).toEqual([
|
||||
{
|
||||
id: 'jane-doe-00000001',
|
||||
book: 'mine',
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme',
|
||||
email: 'jane@acme.example',
|
||||
numbers: [{ label: '', number: '13125551234' }], // "ask reception" isn't a number
|
||||
notes: null,
|
||||
url: 'https://contacts.example.com/contact/jane-doe-00000001',
|
||||
},
|
||||
expect.objectContaining({ name: 'Plumber', book: 'company' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('adding works, editing and importing happen in Contrack', async () => {
|
||||
const r = await dan('POST', '/api/me/contacts', {
|
||||
name: 'Bob',
|
||||
numbers: [{ label: 'mobile', number: '312-555-9999' }],
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
|
||||
name: 'Bob',
|
||||
phones: [{ phone: '13125559999', label: 'mobile' }],
|
||||
});
|
||||
expect(
|
||||
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
|
||||
).toContain('Bob');
|
||||
expect(
|
||||
(
|
||||
await dan('PATCH', `/api/me/contacts/${'00000000-0000-4000-8000-000000000000'}`, {
|
||||
name: 'x',
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(409);
|
||||
expect(
|
||||
(
|
||||
await dan('POST', '/api/me/contacts/import', {
|
||||
files: [{ name: 'a.vcf', text: 'BEGIN:VCARD\nFN:A\nEND:VCARD' }],
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
it('names come from my Contrack book, then the company account; desk phones get the company', async () => {
|
||||
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
|
||||
expect(Object.fromEntries(names)).toEqual({
|
||||
'13125551234': 'Jane Doe',
|
||||
'13125557777': 'Plumber',
|
||||
});
|
||||
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
|
||||
'Dan Doe',
|
||||
'Sue Smith',
|
||||
'Plumber',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a share waits until the recipient has connected Contrack, then lands in their account', async () => {
|
||||
const r = await dan('POST', '/api/me/contacts/share', {
|
||||
contactIds: ['jane-doe-00000001'],
|
||||
extensions: ['102'],
|
||||
});
|
||||
expect(r.json()).toEqual({ offers: 1, people: 1 });
|
||||
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
|
||||
expect(offer.contact).toMatchObject({
|
||||
name: 'Jane Doe',
|
||||
numbers: [{ label: '', number: '13125551234' }],
|
||||
});
|
||||
|
||||
const early = await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] });
|
||||
expect(early.statusCode).toBe(409);
|
||||
expect(early.json().error.message).toMatch(/Connect your Contrack account/);
|
||||
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toHaveLength(1);
|
||||
|
||||
await sue('PUT', '/api/me/contrack', { token: 'ctk_sue_0001' });
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
|
||||
).toEqual({ added: 1, duplicates: 0 });
|
||||
expect(fake.books.ctk_sue_0001!.map((c) => c.name)).toEqual(['Jane Doe']);
|
||||
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
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((await dan('GET', '/api/me')).json().contrack).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
|
||||
callReportTime: '08:00',
|
||||
callReportWeekday: 1,
|
||||
callReportSummaries: false,
|
||||
contrack: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
totp_last_step: 'bigint|YES|',
|
||||
must_change_password: 'boolean|NO|false',
|
||||
last_login_at: 'timestamp with time zone|YES|',
|
||||
contrack_token_enc: 'text|YES|',
|
||||
...ts,
|
||||
},
|
||||
extensions: {
|
||||
|
||||
Reference in new issue
Block a user