Files
webmail/lib/__tests__/jmap-contact-client.test.ts
T
Linus Rath 606771fee6 fix: keep contact UIDs on update and export MEMBER URIs unchanged
`contactToWire` sends a key that is present but undefined as null, so a
caller passing a card back without `uid`, `id` or `addressBookIds` (the
plugin API does) asked the server to clear them. Stalwart 1.0 refuses a
null UID and 0.16 silently deletes it; a null `id` or `addressBookIds`
fails the update on both. Update mode now leaves those keys out.

vCard export prefixed every group member with `urn:uuid:`, but keys are
already URIs for groups made in Bulwark (members are keyed by uid) and
for groups from CardDAV clients, which gave
`MEMBER:urn:uuid:urn:uuid:…`. Keys with a URI scheme are written as they
are.
2026-09-25 18:40:22 +02:00

842 lines
30 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error';
const mockContact = {
id: 'contact-1',
addressBookIds: { 'ab-1': true },
name: { components: [{ kind: 'given' as const, value: 'John' }, { kind: 'surname' as const, value: 'Doe' }], isOrdered: true },
emails: { e0: { address: 'john@example.com' } },
};
const mockAddressBook = {
id: 'ab-1',
name: 'Default',
isDefault: true,
};
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
});
return client;
}
function mockFetch(response: object, ok = true, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}
function mockFetchOnce(spy: ReturnType<typeof vi.spyOn>, response: object) {
spy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
return spy;
}
describe('JMAPClient contact methods', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('supportsContacts', () => {
function withAccountCapabilities(
client: JMAPClient,
accountCapabilities: Record<string, unknown>,
isPersonal = true,
) {
Object.assign(client, {
capabilities: { 'urn:ietf:params:jmap:contacts': {} },
accounts: { 'account-1': { name: 'test', isPersonal, accountCapabilities } },
});
}
it('should return true when the account advertises the contacts capability', () => {
const client = createClient();
withAccountCapabilities(client, { 'urn:ietf:params:jmap:contacts': {} });
expect(client.supportsContacts()).toBe(true);
});
it('should return false when the server advertises contacts but the account does not', () => {
const client = createClient();
withAccountCapabilities(client, { 'urn:ietf:params:jmap:mail': {} });
expect(client.supportsContacts()).toBe(false);
});
it('should treat non-personal (shared/group) accounts as capable', () => {
const client = createClient();
withAccountCapabilities(client, {}, /* isPersonal */ false);
expect(client.supportsContacts()).toBe(true);
});
it('should return false when no session has been loaded', () => {
const client = createClient();
Object.assign(client, { capabilities: undefined, accounts: {} });
expect(client.supportsContacts()).toBe(false);
});
});
describe('getAddressBooks', () => {
it('should return address books from server', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
const result = await client.getAddressBooks();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('ab-1');
expect(result[0].name).toBe('Default');
});
it('should return empty array when no address books', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', { list: [] }, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array when list is missing', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', {}, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should throw on network error when throwOnError is set', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
await expect(client.getAddressBooks({ throwOnError: true })).rejects.toThrow('Network error');
});
it('should throw on a JMAP method error when throwOnError is set', async () => {
const client = createClient();
mockFetch({
methodResponses: [['error', { type: 'serverFail', description: 'Too many requests' }, '0']],
});
await expect(client.getAddressBooks({ throwOnError: true })).rejects.toThrow('Too many requests');
});
it('should still resolve to an empty list for a genuinely empty account', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', { list: [] }, '0']],
});
await expect(client.getAddressBooks({ throwOnError: true })).resolves.toEqual([]);
});
});
describe('setDefaultAddressBook', () => {
it('should send onSuccessSetIsDefault rather than an isDefault update', async () => {
const client = createClient();
const spy = mockFetch({
methodResponses: [['AddressBook/set', { updated: null }, '0']],
});
await client.setDefaultAddressBook('ab-2');
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][0]).toBe('AddressBook/set');
expect(body.methodCalls[0][1]).toMatchObject({
accountId: 'account-1',
onSuccessSetIsDefault: 'ab-2',
});
expect(body.methodCalls[0][1].update).toBeUndefined();
});
it('should target a shared account when one is given', async () => {
const client = createClient();
const spy = mockFetch({
methodResponses: [['AddressBook/set', { updated: null }, '0']],
});
await client.setDefaultAddressBook('ab-2', 'account-2');
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][1].accountId).toBe('account-2');
});
it('should throw on a JMAP method error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['error', { type: 'forbidden', description: 'Not allowed' }, '0']],
});
await expect(client.setDefaultAddressBook('ab-2')).rejects.toThrow('Not allowed');
});
it('should throw for an unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.setDefaultAddressBook('ab-2')).rejects.toThrow('Failed to set default address book');
});
});
describe('updateAddressBook', () => {
it('should drop isDefault, which AddressBook/set rejects as read-only', async () => {
const client = createClient();
const spy = mockFetch({
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
});
await client.updateAddressBook('ab-1', { name: 'Work', isDefault: true });
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][1].update['ab-1']).toEqual({ name: 'Work' });
});
});
describe('getContacts', () => {
it('should return contacts from server', async () => {
const client = createClient();
const spy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(spy, {
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, 'q'],
],
});
mockFetchOnce(spy, {
methodResponses: [
['ContactCard/get', { list: [mockContact] }, 'g'],
],
});
const result = await client.getContacts();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('contact-1');
});
it('should filter by addressBookId when provided', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
await client.getContacts('ab-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toEqual({ inAddressBook: 'ab-1' });
});
it('should not include filter when no addressBookId', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
await client.getContacts();
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toBeUndefined();
});
it('should return empty array when no contacts', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
const result = await client.getContacts();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getContacts();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response at index 1', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['SomethingElse', {}, '1'],
],
});
const result = await client.getContacts();
expect(result).toEqual([]);
});
});
describe('getContact', () => {
it('should return a single contact', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
});
const result = await client.getContact('contact-1');
expect(result).not.toBeNull();
expect(result!.id).toBe('contact-1');
});
it('should pass contact id in the request', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
});
await client.getContact('contact-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].ids).toEqual(['contact-1']);
});
it('should return null when contact not found', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/get', { list: [] }, '0']],
});
const result = await client.getContact('nonexistent');
expect(result).toBeNull();
});
it('should return null on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getContact('contact-1');
expect(result).toBeNull();
});
it('should return null for unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getContact('contact-1');
expect(result).toBeNull();
});
});
describe('createContact', () => {
it('should create contact and refetch full object', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// 1: getAddressBooks
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
// 2: ContactCard/set
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
// 3: getContact refetch
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
const result = await client.createContact({ name: mockContact.name, emails: mockContact.emails });
expect(result.id).toBe('new-id');
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it('should skip getAddressBooks when addressBookIds provided', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// 1: ContactCard/set (no getAddressBooks needed)
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
// 2: getContact refetch
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
const result = await client.createContact({
name: mockContact.name,
addressBookIds: { 'ab-1': true },
});
expect(result.id).toBe('new-id');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('should generate a urn:uuid uid when none is provided (#644)', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
await client.createContact({
emails: { email: { address: 'trusted@example.com' } },
addressBookIds: { 'ab-1': true },
});
const setBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
const created = setBody.methodCalls[0][1].create['new-contact'];
expect(created.uid).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
it('should preserve a caller-provided uid', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
await client.createContact({
uid: 'urn:uuid:12345678-1234-1234-1234-123456789abc',
addressBookIds: { 'ab-1': true },
});
const setBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
const created = setBody.methodCalls[0][1].create['new-contact'];
expect(created.uid).toBe('urn:uuid:12345678-1234-1234-1234-123456789abc');
});
it('should throw on notCreated error with description', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'Missing required fields' } },
}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Missing required fields');
});
it('should throw generic error when notCreated has no description', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'forbidden' } },
}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw when created id is missing', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: {} }, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw when refetch returns null', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [] }, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
});
describe('updateContact', () => {
it('should update contact successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name })).resolves.toBeUndefined();
});
it('should pass updates in the request body', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
});
const updates = { name: { components: [{ kind: 'given' as const, value: 'Jane' }], isOrdered: true } };
await client.updateContact('contact-1', updates);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].update['contact-1']).toEqual(updates);
});
it('should throw on notUpdated error with description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notUpdated: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Contact not found');
});
it('should throw generic error when notUpdated has no description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notUpdated: { 'contact-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Failed to update contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Failed to update contact');
});
it.each(['1.0', '0.16'] as const)('leaves a cleared id, uid and addressBookIds out of the update on Stalwart %s', async (version) => {
const client = createClient();
// What ContactCard/set answers to those keys set to null (1.0 contact/set.rs:
// 229-249, 602-607, 669-671; 0.16.23 set.rs:187-199, 524-529, 591-593).
const answer = (update: Record<string, unknown>) => {
const refuse = (property: string, description: string) =>
({ notUpdated: { 'contact-1': { type: 'invalidProperties', properties: [property], description } } });
if ('id' in update) return refuse('id', 'The id property is immutable.');
if (update.addressBookIds === null) return refuse('addressBookIds', 'Invalid patch operation for addressBookIds.');
// 0.16 takes a null uid and drops the card's UID without a word.
if (update.uid === null && version === '1.0') return refuse('uid', 'You cannot change the UID of a contact.');
return { updated: { 'contact-1': null } };
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const update = JSON.parse(String(init?.body)).methodCalls[0][1].update['contact-1'];
const body = { methodResponses: [['ContactCard/set', answer(update), '0']] };
return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)) } as Response;
});
// A plugin passes the card back with the keys it did not set.
await client.updateContact('contact-1', {
id: undefined, uid: undefined, addressBookIds: undefined, name: mockContact.name,
} as never);
const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string).methodCalls[0][1].update['contact-1'];
expect(sent).toEqual({ name: mockContact.name });
});
it('passes a Stalwart 1.0 blob photo through unchanged', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
});
const media = {
photo: { kind: 'photo' as const, blobId: 'dbcX', mediaType: 'image/jpeg' },
logo: { kind: 'logo' as const, blobId: 'dbcLogo', mediaType: 'image/png' },
};
await client.updateContact('contact-1', { media });
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].update['contact-1'].media).toEqual(media);
});
it('keeps the error type and properties when 0.16 refuses a blobId in media', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notUpdated: { 'contact-1': { type: 'invalidProperties', properties: ['media'], description: 'blobIds in media is not supported.' } },
}, '0']],
});
const error = await client.updateContact('contact-1', {
media: { photo: { kind: 'photo', blobId: 'Gupload1', mediaType: 'image/jpeg' } },
}).catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('blobIds in media is not supported.');
expect(jmapErrorType(error)).toBe('invalidProperties');
expect(jmapErrorProperties(error)).toEqual(['media']);
});
});
describe('supportsContactBlobMedia', () => {
function withBlobCapability(client: JMAPClient, supportedTypeNames?: string[]) {
Object.assign(client, {
session: {
primaryAccounts: { 'urn:ietf:params:jmap:contacts': 'account-1' },
accounts: {
'account-1': {
name: 'test',
isPersonal: true,
accountCapabilities: supportedTypeNames
? { 'urn:ietf:params:jmap:blob': { supportedTypeNames, supportedDigestAlgorithms: ['sha'] } }
: {},
},
},
},
});
}
it('is true on Stalwart 1.0, whose blob capability lists ContactCard', () => {
const client = createClient();
withBlobCapability(client, ['Email', 'Thread', 'SieveScript', 'CalendarEvent', 'ContactCard']);
expect(client.supportsContactBlobMedia()).toBe(true);
expect(client.supportsContactBlobMedia('account-1')).toBe(true);
});
it('is false on Stalwart 0.16, which lists only Email, Thread and SieveScript', () => {
const client = createClient();
withBlobCapability(client, ['Email', 'Thread', 'SieveScript']);
expect(client.supportsContactBlobMedia()).toBe(false);
});
it('is false without the blob capability or for an unknown account', () => {
const client = createClient();
withBlobCapability(client);
expect(client.supportsContactBlobMedia()).toBe(false);
withBlobCapability(client, ['ContactCard']);
expect(client.supportsContactBlobMedia('other')).toBe(false);
});
it('is false for an account that refused a blobId, for the rest of the session', () => {
const client = createClient();
withBlobCapability(client, ['ContactCard']);
client.rejectContactBlobMedia('account-1');
expect(client.supportsContactBlobMedia()).toBe(false);
});
});
describe('deleteContact', () => {
it('should delete contact successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
});
await expect(client.deleteContact('contact-1')).resolves.toBeUndefined();
});
it('should pass contact id in destroy array', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
});
await client.deleteContact('contact-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].destroy).toEqual(['contact-1']);
});
it('should throw on notDestroyed error with description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notDestroyed: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Contact not found');
});
it('should throw generic error when notDestroyed has no description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notDestroyed: { 'contact-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Failed to delete contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Failed to delete contact');
});
});
describe('searchContacts', () => {
it('should return matching contacts', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
const result = await client.searchContacts('John');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('contact-1');
});
it('should pass query as text filter', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
await client.searchContacts('Jane');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toEqual({ text: 'Jane' });
});
it('should AND quoted phrases and prefixes as separate text conditions', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
await client.searchContacts('"Anna Muster" firm*');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toEqual({
operator: 'AND',
conditions: [{ text: '"Anna Muster"' }, { text: 'firm*' }],
});
});
it('should return empty array when no results', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
const result = await client.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.searchContacts('John');
expect(result).toEqual([]);
});
it('should return empty array for unexpected response at index 1', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['SomethingElse', {}, '1'],
],
});
const result = await client.searchContacts('John');
expect(result).toEqual([]);
});
});
});