mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
`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.
842 lines
30 KiB
TypeScript
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([]);
|
|
});
|
|
});
|
|
});
|