mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
1437 lines
62 KiB
TypeScript
1437 lines
62 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import {
|
|
useContactStore,
|
|
findContactPhotoEntry,
|
|
getContactPhotoIndex,
|
|
getContactPhotoRef,
|
|
getContactPhotoUri,
|
|
hasContactPhoto,
|
|
normalizeContactPhotoUri,
|
|
TRUSTED_SENDERS_BOOK_NAME,
|
|
} from '../contact-store';
|
|
import type { ContactCard, ContactMedia } from '@/lib/jmap/types';
|
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
|
|
import { PrincipalsRequestError } from '@/lib/jmap/principals-error';
|
|
import { setClientLookup } from '../client-registry';
|
|
|
|
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
|
|
|
|
const makeContact = (overrides: Partial<ContactCard> = {}): ContactCard => ({
|
|
id: 'contact-1',
|
|
addressBookIds: { 'ab-1': true },
|
|
name: { components: [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }], isOrdered: true },
|
|
emails: { e0: { address: 'john@example.com' } },
|
|
...overrides,
|
|
});
|
|
|
|
const makeGroup = (overrides: Partial<ContactCard> = {}): ContactCard => ({
|
|
id: 'group-1',
|
|
addressBookIds: {},
|
|
kind: 'group',
|
|
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
|
members: {},
|
|
...overrides,
|
|
});
|
|
|
|
const defaultState = {
|
|
contacts: [],
|
|
addressBooks: [],
|
|
selectedContactId: null,
|
|
searchQuery: '',
|
|
isLoading: false,
|
|
error: null,
|
|
supportsSync: false,
|
|
selectedContactIds: new Set<string>(),
|
|
activeTab: 'all' as const,
|
|
directoryPrincipals: [],
|
|
directoryLoaded: false,
|
|
directoryStatus: 'idle' as const,
|
|
};
|
|
|
|
describe('contact-store', () => {
|
|
beforeEach(() => {
|
|
useContactStore.setState(defaultState);
|
|
});
|
|
|
|
describe('addLocalContact', () => {
|
|
it('should append contact to array', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact());
|
|
expect(useContactStore.getState().contacts).toHaveLength(1);
|
|
expect(useContactStore.getState().contacts[0].id).toBe('contact-1');
|
|
});
|
|
|
|
it('should preserve existing contacts', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
|
|
expect(useContactStore.getState().contacts).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('updateLocalContact', () => {
|
|
it('should update matching contact', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
|
await useContactStore.getState().updateLocalContact('c1', {
|
|
emails: { e0: { address: 'updated@example.com' } },
|
|
});
|
|
expect(useContactStore.getState().contacts[0].emails!.e0.address).toBe('updated@example.com');
|
|
});
|
|
|
|
it('should not modify other contacts', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } }));
|
|
await useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } });
|
|
expect(useContactStore.getState().contacts[1].emails!.e0.address).toBe('c2@test.com');
|
|
});
|
|
|
|
it('should no-op for non-existent id', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact());
|
|
await useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' });
|
|
expect(useContactStore.getState().contacts).toHaveLength(1);
|
|
expect(useContactStore.getState().contacts[0].kind).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('deleteLocalContact', () => {
|
|
it('should remove contact by id', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
|
|
await useContactStore.getState().deleteLocalContact('c1');
|
|
expect(useContactStore.getState().contacts).toHaveLength(1);
|
|
expect(useContactStore.getState().contacts[0].id).toBe('c2');
|
|
});
|
|
|
|
it('should clear selectedContactId when deleting selected', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
|
useContactStore.getState().setSelectedContact('c1');
|
|
await useContactStore.getState().deleteLocalContact('c1');
|
|
expect(useContactStore.getState().selectedContactId).toBeNull();
|
|
});
|
|
|
|
it('should preserve selectedContactId when deleting other', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
|
|
useContactStore.getState().setSelectedContact('c1');
|
|
await useContactStore.getState().deleteLocalContact('c2');
|
|
expect(useContactStore.getState().selectedContactId).toBe('c1');
|
|
});
|
|
});
|
|
|
|
describe('setSelectedContact', () => {
|
|
it('should set selectedContactId', () => {
|
|
useContactStore.getState().setSelectedContact('c1');
|
|
expect(useContactStore.getState().selectedContactId).toBe('c1');
|
|
});
|
|
|
|
it('should allow null to deselect', () => {
|
|
useContactStore.getState().setSelectedContact('c1');
|
|
useContactStore.getState().setSelectedContact(null);
|
|
expect(useContactStore.getState().selectedContactId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('setSearchQuery', () => {
|
|
it('should set search query', () => {
|
|
useContactStore.getState().setSearchQuery('john');
|
|
expect(useContactStore.getState().searchQuery).toBe('john');
|
|
});
|
|
});
|
|
|
|
describe('setSupportsSync', () => {
|
|
it('should set supportsSync flag', () => {
|
|
useContactStore.getState().setSupportsSync(true);
|
|
expect(useContactStore.getState().supportsSync).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('setActiveTab', () => {
|
|
it('should set active tab', () => {
|
|
useContactStore.getState().setActiveTab('groups');
|
|
expect(useContactStore.getState().activeTab).toBe('groups');
|
|
});
|
|
});
|
|
|
|
describe('clearContacts', () => {
|
|
it('should reset all contact-related state', () => {
|
|
useContactStore.setState({
|
|
contacts: [makeContact()],
|
|
addressBooks: [{ id: 'ab-1', name: 'Default', isDefault: true }],
|
|
selectedContactId: 'c1',
|
|
searchQuery: 'test',
|
|
error: 'some error',
|
|
selectedContactIds: new Set(['c1', 'c2']),
|
|
activeTab: 'groups',
|
|
});
|
|
|
|
useContactStore.getState().clearContacts();
|
|
const state = useContactStore.getState();
|
|
expect(state.contacts).toEqual([]);
|
|
expect(state.addressBooks).toEqual([]);
|
|
expect(state.selectedContactId).toBeNull();
|
|
expect(state.searchQuery).toBe('');
|
|
expect(state.error).toBeNull();
|
|
expect(state.selectedContactIds.size).toBe(0);
|
|
expect(state.activeTab).toBe('all');
|
|
});
|
|
});
|
|
|
|
describe('setDefaultAddressBook', () => {
|
|
const makeClient = () => ({
|
|
setDefaultAddressBook: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
it('should move the default flag to the chosen book', async () => {
|
|
const books = [
|
|
{ id: 'ab-1', name: 'Personal', isDefault: true },
|
|
{ id: 'ab-2', name: 'Work', isDefault: false },
|
|
];
|
|
useContactStore.setState({ addressBooks: books });
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().setDefaultAddressBook(
|
|
client as unknown as IJMAPClient,
|
|
books[1],
|
|
);
|
|
|
|
expect(client.setDefaultAddressBook).toHaveBeenCalledWith('ab-2', undefined);
|
|
expect(useContactStore.getState().addressBooks.map((b) => b.isDefault)).toEqual([false, true]);
|
|
});
|
|
|
|
it('should send the un-namespaced id and leave other accounts alone', async () => {
|
|
const book = {
|
|
id: 'local-2::ab-9',
|
|
originalId: 'ab-9',
|
|
localAccountId: 'local-2',
|
|
name: 'Work',
|
|
};
|
|
useContactStore.setState({
|
|
addressBooks: [
|
|
{ id: 'local-1::ab-1', originalId: 'ab-1', localAccountId: 'local-1', name: 'Personal', isDefault: true },
|
|
book,
|
|
],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().setDefaultAddressBook(client as unknown as IJMAPClient, book);
|
|
|
|
expect(client.setDefaultAddressBook).toHaveBeenCalledWith('ab-9', undefined);
|
|
// The other account keeps its own default.
|
|
expect(useContactStore.getState().addressBooks.map((b) => b.isDefault)).toEqual([true, true]);
|
|
});
|
|
|
|
it('should target the owning account for a shared book', async () => {
|
|
const book = { id: 'ab-3', name: 'Team', isShared: true, accountId: 'account-2' };
|
|
useContactStore.setState({ addressBooks: [book] });
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().setDefaultAddressBook(client as unknown as IJMAPClient, book);
|
|
|
|
expect(client.setDefaultAddressBook).toHaveBeenCalledWith('ab-3', 'account-2');
|
|
});
|
|
|
|
it('should surface the error and keep the previous default on failure', async () => {
|
|
const books = [
|
|
{ id: 'ab-1', name: 'Personal', isDefault: true },
|
|
{ id: 'ab-2', name: 'Work', isDefault: false },
|
|
];
|
|
useContactStore.setState({ addressBooks: books });
|
|
const client = {
|
|
setDefaultAddressBook: vi.fn().mockRejectedValue(new Error('Not allowed')),
|
|
};
|
|
|
|
await expect(
|
|
useContactStore.getState().setDefaultAddressBook(client as unknown as IJMAPClient, books[1]),
|
|
).rejects.toThrow('Not allowed');
|
|
expect(useContactStore.getState().error).toBe('Not allowed');
|
|
expect(useContactStore.getState().addressBooks.map((b) => b.isDefault)).toEqual([true, false]);
|
|
});
|
|
});
|
|
|
|
describe('toggleContactSelection', () => {
|
|
it('should add id to selection', () => {
|
|
useContactStore.getState().toggleContactSelection('c1');
|
|
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(true);
|
|
});
|
|
|
|
it('should remove id when already selected', () => {
|
|
useContactStore.getState().toggleContactSelection('c1');
|
|
useContactStore.getState().toggleContactSelection('c1');
|
|
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
|
|
});
|
|
|
|
it('should handle multiple selections independently', () => {
|
|
useContactStore.getState().toggleContactSelection('c1');
|
|
useContactStore.getState().toggleContactSelection('c2');
|
|
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
|
|
useContactStore.getState().toggleContactSelection('c1');
|
|
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
|
|
expect(useContactStore.getState().selectedContactIds.has('c2')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('selectAllContacts', () => {
|
|
it('should set all provided ids', () => {
|
|
useContactStore.getState().selectAllContacts(['c1', 'c2', 'c3']);
|
|
expect(useContactStore.getState().selectedContactIds.size).toBe(3);
|
|
});
|
|
|
|
it('should replace previous selection', () => {
|
|
useContactStore.getState().toggleContactSelection('c0');
|
|
useContactStore.getState().selectAllContacts(['c1', 'c2']);
|
|
expect(useContactStore.getState().selectedContactIds.has('c0')).toBe(false);
|
|
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('clearSelection', () => {
|
|
it('should empty the selection set', () => {
|
|
useContactStore.getState().selectAllContacts(['c1', 'c2']);
|
|
useContactStore.getState().clearSelection();
|
|
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('getAutocomplete', () => {
|
|
beforeEach(() => {
|
|
useContactStore.setState({
|
|
contacts: [
|
|
makeContact({ id: 'c1' }),
|
|
makeContact({ id: 'c2', name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Smith' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
|
|
makeContact({ id: 'c3', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.org' } } }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should return empty for empty query', () => {
|
|
expect(useContactStore.getState().getAutocomplete('')).toEqual([]);
|
|
});
|
|
|
|
it('should match by name', () => {
|
|
const results = useContactStore.getState().getAutocomplete('john');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].email).toBe('john@example.com');
|
|
});
|
|
|
|
it('should match by email address', () => {
|
|
const results = useContactStore.getState().getAutocomplete('test.org');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].name).toBe('Bob');
|
|
});
|
|
|
|
it('should be case insensitive', () => {
|
|
const results = useContactStore.getState().getAutocomplete('JANE');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].email).toBe('jane@example.com');
|
|
});
|
|
|
|
it('should cap results at 10', () => {
|
|
const manyContacts = Array.from({ length: 15 }, (_, i) =>
|
|
makeContact({ id: `c${i}`, name: { components: [{ kind: 'given', value: `User${i}` }], isOrdered: true }, emails: { e0: { address: `user${i}@test.com` } } })
|
|
);
|
|
useContactStore.setState({ contacts: manyContacts });
|
|
const results = useContactStore.getState().getAutocomplete('user');
|
|
expect(results.length).toBeLessThanOrEqual(10);
|
|
});
|
|
|
|
it('should suggest a matching group as a single entry with member count', () => {
|
|
const member1 = makeContact({ id: 'm1', name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true }, emails: { e0: { address: 'alice@test.com' } } });
|
|
const member2 = makeContact({ id: 'm2', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.com' } } });
|
|
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
|
|
useContactStore.setState({ contacts: [member1, member2, group] });
|
|
|
|
const results = useContactStore.getState().getAutocomplete('Team');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]).toEqual({ name: 'Team', email: '', group: { id: 'g1', memberCount: 2 } });
|
|
});
|
|
|
|
it('should not suggest a group with no addressable members', () => {
|
|
const group = makeGroup({ id: 'g1' });
|
|
useContactStore.setState({ contacts: [group] });
|
|
const results = useContactStore.getState().getAutocomplete('Team');
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should return contacts with multiple emails as separate results', () => {
|
|
const multi = makeContact({
|
|
id: 'multi',
|
|
name: { components: [{ kind: 'given', value: 'Multi' }], isOrdered: true },
|
|
emails: { e0: { address: 'a@test.com' }, e1: { address: 'b@test.com' } },
|
|
});
|
|
useContactStore.setState({ contacts: [multi] });
|
|
const results = useContactStore.getState().getAutocomplete('Multi');
|
|
expect(results).toHaveLength(2);
|
|
});
|
|
|
|
it('should augment results with directory principals', () => {
|
|
useContactStore.setState({
|
|
contacts: [],
|
|
directoryPrincipals: [{ name: 'Dana Director', email: 'dana@example.com' }],
|
|
});
|
|
const results = useContactStore.getState().getAutocomplete('dana');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]).toEqual({ name: 'Dana Director', email: 'dana@example.com' });
|
|
});
|
|
|
|
it('should not duplicate a directory principal already matched as a contact', () => {
|
|
useContactStore.setState({
|
|
contacts: [
|
|
makeContact({ id: 'c1', name: { components: [{ kind: 'given', value: 'Jane' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
|
|
],
|
|
directoryPrincipals: [{ name: 'Jane From Directory', email: 'JANE@example.com' }],
|
|
});
|
|
const results = useContactStore.getState().getAutocomplete('jane');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].name).toBe('Jane');
|
|
});
|
|
|
|
// #672: a flattened vCard import leaves the whole mailbox in the name (and
|
|
// sometimes in the address too). Suggesting that raw string composes a
|
|
// recipient the receiving server rejects.
|
|
it('should split a display name that carries the address', () => {
|
|
useContactStore.setState({
|
|
contacts: [
|
|
makeContact({ id: 'raw', name: { full: 'Jane Doe <jane.doe@example.com>' }, emails: { e0: { address: 'jane.doe@example.com' } } }),
|
|
],
|
|
});
|
|
const results = useContactStore.getState().getAutocomplete('jane');
|
|
expect(results).toEqual([{ name: 'Jane Doe', email: 'jane.doe@example.com' }]);
|
|
});
|
|
|
|
it('should split a whole mailbox stored as the address', () => {
|
|
useContactStore.setState({
|
|
contacts: [
|
|
makeContact({ id: 'raw', name: { full: 'Jane Doe <jane.doe@example.com>' }, emails: { e0: { address: 'Jane Doe <jane.doe@example.com>' } } }),
|
|
],
|
|
});
|
|
const results = useContactStore.getState().getAutocomplete('jane');
|
|
expect(results).toEqual([{ name: 'Jane Doe', email: 'jane.doe@example.com' }]);
|
|
});
|
|
|
|
it('should not surface a raw duplicate of an already parsed contact', () => {
|
|
useContactStore.setState({
|
|
contacts: [
|
|
makeContact({ id: 'clean', name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }], isOrdered: true }, emails: { e0: { address: 'jane.doe@example.com' } } }),
|
|
makeContact({ id: 'raw', name: { full: 'Jane Doe <jane.doe@example.com>' }, emails: { e0: { address: 'jane.doe@example.com' } } }),
|
|
],
|
|
});
|
|
const results = useContactStore.getState().getAutocomplete('jane');
|
|
expect(results).toEqual([{ name: 'Jane Doe', email: 'jane.doe@example.com' }]);
|
|
});
|
|
|
|
it('should repair a recent recipient whose address lost its closing bracket', () => {
|
|
useContactStore.setState({
|
|
contacts: [],
|
|
recentRecipients: [{ name: '', email: 'janedoe<jane.doe@example.com' }],
|
|
});
|
|
const results = useContactStore.getState().getAutocomplete('jane');
|
|
expect(results).toEqual([{ name: 'janedoe', email: 'jane.doe@example.com' }]);
|
|
});
|
|
});
|
|
|
|
describe('fetchDirectory', () => {
|
|
it('keeps what a restricted listing offers on Stalwart 1.0 with directory queries off, without logging an error', async () => {
|
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const client = {
|
|
supportsPrincipals: () => true,
|
|
getPrincipals: vi.fn(),
|
|
listPrincipals: vi.fn().mockResolvedValue({
|
|
status: 'restricted',
|
|
principals: [{ id: 'grp', type: 'group', name: 'sales@example.com', description: 'Sales', email: 'sales@example.com' }],
|
|
}),
|
|
} as unknown as IJMAPClient;
|
|
|
|
await useContactStore.getState().fetchDirectory(client);
|
|
|
|
const state = useContactStore.getState();
|
|
// Browsing says the directory is closed, but shows who the user knows.
|
|
expect(state.directoryStatus).toBe('forbidden');
|
|
expect(state.directoryLoaded).toBe(true);
|
|
expect(state.directoryPrincipals).toEqual([{ id: 'grp', name: 'Sales', email: 'sales@example.com', description: 'Sales', type: 'group' }]);
|
|
expect(errors).not.toHaveBeenCalled();
|
|
errors.mockRestore();
|
|
});
|
|
|
|
it('counts a client without listPrincipals (demo) as a complete listing', async () => {
|
|
const client = {
|
|
supportsPrincipals: () => true,
|
|
getPrincipals: vi.fn().mockResolvedValue([{ id: 'a', type: 'individual', name: 'anna@example.com', email: 'anna@example.com' }]),
|
|
} as unknown as IJMAPClient;
|
|
|
|
await useContactStore.getState().fetchDirectory(client);
|
|
|
|
expect(useContactStore.getState().directoryStatus).toBe('ready');
|
|
expect(useContactStore.getState().directoryPrincipals).toEqual([{ id: 'a', name: 'anna@example.com', email: 'anna@example.com', description: undefined, type: 'individual' }]);
|
|
});
|
|
});
|
|
|
|
describe('getGroups', () => {
|
|
it('should return only group contacts', () => {
|
|
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
|
|
const groups = useContactStore.getState().getGroups();
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups[0].kind).toBe('group');
|
|
});
|
|
|
|
it('should return empty when no groups', () => {
|
|
useContactStore.setState({ contacts: [makeContact()] });
|
|
expect(useContactStore.getState().getGroups()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('getIndividuals', () => {
|
|
it('should return non-group contacts', () => {
|
|
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
|
|
const individuals = useContactStore.getState().getIndividuals();
|
|
expect(individuals).toHaveLength(1);
|
|
expect(individuals[0].kind).not.toBe('group');
|
|
});
|
|
|
|
it('should include org and undefined kind', () => {
|
|
useContactStore.setState({
|
|
contacts: [
|
|
makeContact({ id: 'c1' }),
|
|
makeContact({ id: 'c2', kind: 'org' }),
|
|
makeGroup(),
|
|
],
|
|
});
|
|
expect(useContactStore.getState().getIndividuals()).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('getGroupMembers', () => {
|
|
it('should return contacts whose ids are in group members', () => {
|
|
const m1 = makeContact({ id: 'm1' });
|
|
const m2 = makeContact({ id: 'm2' });
|
|
const nonMember = makeContact({ id: 'nm' });
|
|
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
|
|
useContactStore.setState({ contacts: [m1, m2, nonMember, group] });
|
|
|
|
const members = useContactStore.getState().getGroupMembers('g1');
|
|
expect(members).toHaveLength(2);
|
|
expect(members.map(m => m.id).sort()).toEqual(['m1', 'm2']);
|
|
});
|
|
|
|
it('should return empty for group with no members', () => {
|
|
useContactStore.setState({ contacts: [makeGroup({ id: 'g1', members: {} })] });
|
|
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(0);
|
|
});
|
|
|
|
it('should return empty for non-existent group', () => {
|
|
expect(useContactStore.getState().getGroupMembers('nonexistent')).toHaveLength(0);
|
|
});
|
|
|
|
it('should match by uid as well as id', () => {
|
|
const m1 = makeContact({ id: 'm1', uid: 'uid-m1' });
|
|
const group = makeGroup({ id: 'g1', members: { 'uid-m1': true } });
|
|
useContactStore.setState({ contacts: [m1, group] });
|
|
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
|
|
});
|
|
|
|
it('should exclude members with false value', () => {
|
|
const m1 = makeContact({ id: 'm1' });
|
|
const m2 = makeContact({ id: 'm2' });
|
|
const group = makeGroup({ id: 'g1', members: { m1: true, m2: false } });
|
|
useContactStore.setState({ contacts: [m1, m2, group] });
|
|
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('createGroup (local mode)', () => {
|
|
it('should create group with local- prefix id', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Friends', ['c1', 'c2']);
|
|
const contacts = useContactStore.getState().contacts;
|
|
expect(contacts).toHaveLength(1);
|
|
expect(contacts[0].id).toMatch(/^local-/);
|
|
expect(contacts[0].kind).toBe('group');
|
|
});
|
|
|
|
it('should set members from provided ids', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2']);
|
|
const group = useContactStore.getState().contacts[0];
|
|
expect(group.members).toEqual({ m1: true, m2: true });
|
|
});
|
|
|
|
it('should set group name', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Work', []);
|
|
const group = useContactStore.getState().contacts[0];
|
|
expect(group.name?.components?.[0]?.value).toBe('Work');
|
|
});
|
|
});
|
|
|
|
describe('updateGroup (local mode)', () => {
|
|
it('should update group name', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Old', []);
|
|
const groupId = useContactStore.getState().contacts[0].id;
|
|
await useContactStore.getState().updateGroup(null, groupId, 'New');
|
|
expect(useContactStore.getState().contacts[0].name?.components?.[0]?.value).toBe('New');
|
|
});
|
|
|
|
it('should preserve group members when renaming', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
|
|
const groupId = useContactStore.getState().contacts[0].id;
|
|
await useContactStore.getState().updateGroup(null, groupId, 'Renamed');
|
|
expect(useContactStore.getState().contacts[0].members).toEqual({ m1: true });
|
|
});
|
|
});
|
|
|
|
describe('addMembersToGroup (local mode)', () => {
|
|
it('should add new members to group', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
|
|
const groupId = useContactStore.getState().contacts[0].id;
|
|
await useContactStore.getState().addMembersToGroup(null, groupId, ['m2', 'm3']);
|
|
const members = useContactStore.getState().contacts[0].members;
|
|
expect(members).toEqual({ m1: true, m2: true, m3: true });
|
|
});
|
|
|
|
it('should no-op for non-existent group', async () => {
|
|
await useContactStore.getState().addMembersToGroup(null, 'nonexistent', ['m1']);
|
|
expect(useContactStore.getState().contacts).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('removeMembersFromGroup (local mode)', () => {
|
|
it('should remove members from group', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2', 'm3']);
|
|
const groupId = useContactStore.getState().contacts[0].id;
|
|
await useContactStore.getState().removeMembersFromGroup(null, groupId, ['m2']);
|
|
const members = useContactStore.getState().contacts[0].members;
|
|
expect(members).toEqual({ m1: true, m3: true });
|
|
});
|
|
|
|
it('should no-op for group without members', async () => {
|
|
const group = makeGroup({ id: 'g1', members: undefined });
|
|
useContactStore.setState({ contacts: [group] });
|
|
await useContactStore.getState().removeMembersFromGroup(null, 'g1', ['m1']);
|
|
expect(useContactStore.getState().contacts[0].members).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('deleteGroup (local mode)', () => {
|
|
it('should remove group from contacts', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Team', []);
|
|
const groupId = useContactStore.getState().contacts[0].id;
|
|
await useContactStore.getState().deleteGroup(null, groupId);
|
|
expect(useContactStore.getState().contacts).toHaveLength(0);
|
|
});
|
|
|
|
it('should clear selectedContactId when deleting selected group', async () => {
|
|
await useContactStore.getState().createGroup(null, 'Team', []);
|
|
const groupId = useContactStore.getState().contacts[0].id;
|
|
useContactStore.getState().setSelectedContact(groupId);
|
|
await useContactStore.getState().deleteGroup(null, groupId);
|
|
expect(useContactStore.getState().selectedContactId).toBeNull();
|
|
});
|
|
|
|
it('should preserve selectedContactId when deleting other group', async () => {
|
|
await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
|
useContactStore.getState().setSelectedContact('c1');
|
|
await useContactStore.getState().createGroup(null, 'Team', []);
|
|
const groupId = useContactStore.getState().contacts[1].id;
|
|
await useContactStore.getState().deleteGroup(null, groupId);
|
|
expect(useContactStore.getState().selectedContactId).toBe('c1');
|
|
});
|
|
});
|
|
|
|
describe('bulkDeleteContacts (local mode)', () => {
|
|
it('should remove multiple contacts', async () => {
|
|
useContactStore.setState({
|
|
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' }), makeContact({ id: 'c3' })],
|
|
});
|
|
await useContactStore.getState().bulkDeleteContacts(null, ['c1', 'c3']);
|
|
expect(useContactStore.getState().contacts).toHaveLength(1);
|
|
expect(useContactStore.getState().contacts[0].id).toBe('c2');
|
|
});
|
|
|
|
it('should clear selection after bulk delete', async () => {
|
|
useContactStore.setState({
|
|
contacts: [makeContact({ id: 'c1' })],
|
|
selectedContactIds: new Set(['c1']),
|
|
});
|
|
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
|
|
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
|
|
});
|
|
|
|
it('should clear selectedContactId if deleted', async () => {
|
|
useContactStore.setState({
|
|
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' })],
|
|
selectedContactId: 'c1',
|
|
});
|
|
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
|
|
expect(useContactStore.getState().selectedContactId).toBeNull();
|
|
});
|
|
});
|
|
|
|
// Multi-account Pro mode lists every login's cards together. Card ids
|
|
// repeat across logins (each account numbers its own), so a write sent
|
|
// through the active login with another login's card id hits the active
|
|
// login's card of that id.
|
|
describe('writes to another login\'s cards', () => {
|
|
const loginClient = () => ({
|
|
getContactsAccountId: vi.fn(() => 'acct-primary'),
|
|
updateContact: vi.fn().mockResolvedValue(undefined),
|
|
deleteContact: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
let active: ReturnType<typeof loginClient>;
|
|
let other: ReturnType<typeof loginClient>;
|
|
|
|
beforeEach(() => {
|
|
active = loginClient();
|
|
other = loginClient();
|
|
setClientLookup((localAccountId) => (localAccountId === 'b@host' ? other as unknown as IJMAPClient : undefined));
|
|
useContactStore.setState({
|
|
supportsSync: true,
|
|
contacts: [
|
|
makeContact({ id: 'a@host::c', originalId: 'c', localAccountId: 'a@host' }),
|
|
makeContact({ id: 'b@host::c', originalId: 'c', localAccountId: 'b@host' }),
|
|
makeGroup({ id: 'b@host::g', originalId: 'g', localAccountId: 'b@host', members: { 'urn:uuid:x': true } }),
|
|
],
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
setClientLookup(() => undefined);
|
|
});
|
|
|
|
it('deletes selected cards through the login they belong to', async () => {
|
|
await useContactStore.getState().bulkDeleteContacts(active as unknown as IJMAPClient, ['b@host::c']);
|
|
|
|
expect(other.deleteContact).toHaveBeenCalledWith('c', undefined);
|
|
expect(active.deleteContact).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('changes and deletes a group through the login it belongs to', async () => {
|
|
const client = active as unknown as IJMAPClient;
|
|
await useContactStore.getState().updateGroup(client, 'b@host::g', 'Renamed');
|
|
await useContactStore.getState().addMembersToGroup(client, 'b@host::g', ['b@host::c']);
|
|
await useContactStore.getState().removeMembersFromGroup(client, 'b@host::g', ['urn:uuid:x']);
|
|
await useContactStore.getState().deleteGroup(client, 'b@host::g');
|
|
|
|
expect(other.updateContact).toHaveBeenCalledTimes(3);
|
|
expect(other.updateContact.mock.calls.every(([id, , accountId]) => id === 'g' && accountId === undefined)).toBe(true);
|
|
expect(other.deleteContact).toHaveBeenCalledWith('g', undefined);
|
|
expect(active.updateContact).not.toHaveBeenCalled();
|
|
expect(active.deleteContact).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('moves a card between two books of its own login there', async () => {
|
|
// Each login's own books carry that login's primary account.
|
|
other.getContactsAccountId.mockReturnValue('acct-b');
|
|
const target = { id: 'b@host::ab-2', originalId: 'ab-2', localAccountId: 'b@host', accountId: 'acct-b', name: 'Work' };
|
|
|
|
await useContactStore.getState().moveContactToAddressBook(active as unknown as IJMAPClient, ['b@host::c'], target);
|
|
|
|
expect(other.updateContact).toHaveBeenCalledWith('c', { addressBookIds: { 'ab-2': true } }, undefined);
|
|
expect(active.updateContact).not.toHaveBeenCalled();
|
|
expect(active.deleteContact).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('moves a card into a shared book of its login by creating it and deleting the old one there', async () => {
|
|
other.getContactsAccountId.mockReturnValue('acct-b');
|
|
const created = makeContact({ id: 'n', addressBookIds: { 'ab-7': true } });
|
|
const otherCreate = vi.fn().mockResolvedValue(created);
|
|
Object.assign(other, { createContact: otherCreate });
|
|
const target = { id: 'b@host::acct-s:ab-7', originalId: 'ab-7', localAccountId: 'b@host', accountId: 'acct-s', accountName: 'Team', isShared: true, name: 'Team' };
|
|
|
|
await useContactStore.getState().moveContactToAddressBook(active as unknown as IJMAPClient, ['b@host::c'], target);
|
|
|
|
expect(otherCreate).toHaveBeenCalledWith(expect.objectContaining({ addressBookIds: { 'ab-7': true } }), 'acct-s');
|
|
expect(other.deleteContact).toHaveBeenCalledWith('c', undefined);
|
|
expect(active.deleteContact).not.toHaveBeenCalled();
|
|
expect(useContactStore.getState().contacts.find((c) => c.originalId === 'n')).toMatchObject({
|
|
id: 'b@host::acct-s:n', localAccountId: 'b@host', isShared: true, accountId: 'acct-s',
|
|
addressBookIds: { 'b@host::acct-s:ab-7': true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('bulkAddToGroup (local mode)', () => {
|
|
it('should add contacts to group and clear selection', async () => {
|
|
const m1 = makeContact({ id: 'm1' });
|
|
const group = makeGroup({ id: 'g1', members: {} });
|
|
useContactStore.setState({
|
|
contacts: [m1, group],
|
|
selectedContactIds: new Set(['m1']),
|
|
});
|
|
await useContactStore.getState().bulkAddToGroup(null, 'g1', ['m1']);
|
|
expect(useContactStore.getState().contacts.find(c => c.id === 'g1')?.members).toEqual({ m1: true });
|
|
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('importContacts (local mode)', () => {
|
|
it('should import contacts with local- prefix ids', async () => {
|
|
const toImport = [makeContact({ id: 'orig-1' }), makeContact({ id: 'orig-2' })];
|
|
const count = await useContactStore.getState().importContacts(null, toImport);
|
|
expect(count).toBe(2);
|
|
expect(useContactStore.getState().contacts).toHaveLength(2);
|
|
expect(useContactStore.getState().contacts[0].id).toMatch(/^local-/);
|
|
});
|
|
});
|
|
|
|
describe('normalizeContactPhotoUri', () => {
|
|
it('rewrites malformed data:base64,... URIs using the media mediaType', () => {
|
|
expect(normalizeContactPhotoUri('data:base64,AAAA', 'image/png'))
|
|
.toBe('data:image/png;base64,AAAA');
|
|
});
|
|
|
|
it('rewrites data:;base64,... URIs using the media mediaType', () => {
|
|
expect(normalizeContactPhotoUri('data:;base64,AAAA', 'image/gif'))
|
|
.toBe('data:image/gif;base64,AAAA');
|
|
});
|
|
|
|
it('defaults to image/jpeg when no mediaType is available', () => {
|
|
expect(normalizeContactPhotoUri('data:base64,AAAA'))
|
|
.toBe('data:image/jpeg;base64,AAAA');
|
|
});
|
|
|
|
it('leaves well-formed data URIs unchanged', () => {
|
|
const good = 'data:image/png;base64,AAAA';
|
|
expect(normalizeContactPhotoUri(good)).toBe(good);
|
|
});
|
|
|
|
it('leaves http(s) URIs unchanged', () => {
|
|
const url = 'https://example.com/photo.jpg';
|
|
expect(normalizeContactPhotoUri(url)).toBe(url);
|
|
});
|
|
});
|
|
|
|
describe('getContactPhotoUri', () => {
|
|
it('returns a normalized data URI for malformed Stalwart photos (#307)', () => {
|
|
const contact = makeContact({
|
|
media: { m0: { kind: 'photo', uri: 'data:base64,AAAA', mediaType: 'image/png' } },
|
|
});
|
|
expect(getContactPhotoUri(contact)).toBe('data:image/png;base64,AAAA');
|
|
});
|
|
|
|
it('returns undefined when no photo media is present', () => {
|
|
expect(getContactPhotoUri(makeContact())).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for a Stalwart 1.0 blob photo, which has no URI', () => {
|
|
expect(getContactPhotoUri(makeContact({
|
|
media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } },
|
|
}))).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getContactPhotoRef / hasContactPhoto', () => {
|
|
// The shapes ContactCard/get returns for the same PHOTO.
|
|
const photo0_16_23 = { kind: 'photo' as const, uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' };
|
|
const photo0_16_0 = { kind: 'photo' as const, uri: 'data:base64,/9j/' };
|
|
const photo1_0 = { kind: 'photo' as const, blobId: 'dbcX', mediaType: 'image/jpeg' };
|
|
|
|
it('returns the data URI on Stalwart 0.16.23', () => {
|
|
const contact = makeContact({ media: { m0: photo0_16_23 } });
|
|
expect(getContactPhotoRef(contact)).toEqual({ uri: 'data:image/jpeg;base64,/9j/' });
|
|
expect(hasContactPhoto(contact)).toBe(true);
|
|
});
|
|
|
|
it('returns a data URI with a media type for the Stalwart 0.16.0 shape', () => {
|
|
const contact = makeContact({ media: { m0: photo0_16_0 } });
|
|
expect(getContactPhotoRef(contact)).toEqual({ uri: 'data:image/jpeg;base64,/9j/' });
|
|
expect(hasContactPhoto(contact)).toBe(true);
|
|
});
|
|
|
|
it('returns the blob and where to download it on Stalwart 1.0', () => {
|
|
const contact = makeContact({
|
|
media: { m0: photo1_0 },
|
|
accountId: 'shared-1',
|
|
localAccountId: 'me@host',
|
|
});
|
|
expect(getContactPhotoRef(contact)).toEqual({
|
|
blobId: 'dbcX', mediaType: 'image/jpeg', accountId: 'shared-1', localAccountId: 'me@host',
|
|
});
|
|
expect(hasContactPhoto(contact)).toBe(true);
|
|
});
|
|
|
|
it('keeps an https photo as it is', () => {
|
|
const contact = makeContact({ media: { m0: { kind: 'photo', uri: 'https://x.test/p.jpg' } } });
|
|
expect(getContactPhotoRef(contact)).toEqual({ uri: 'https://x.test/p.jpg' });
|
|
});
|
|
|
|
it('finds no photo on a card with only a logo, an empty entry or a null entry', () => {
|
|
const contact = makeContact({
|
|
media: {
|
|
logo: { kind: 'logo', blobId: 'dbcLogo', mediaType: 'image/png' },
|
|
empty: { kind: 'photo' },
|
|
gone: null as unknown as ContactMedia,
|
|
},
|
|
});
|
|
expect(getContactPhotoRef(contact)).toBeUndefined();
|
|
expect(hasContactPhoto(contact)).toBe(false);
|
|
expect(hasContactPhoto(makeContact())).toBe(false);
|
|
});
|
|
|
|
it('prefers the photo with the lowest pref', () => {
|
|
const contact = makeContact({
|
|
media: {
|
|
a: { kind: 'photo', uri: 'https://x.test/a.jpg' },
|
|
b: { kind: 'photo', blobId: 'dbcB', pref: 1 },
|
|
},
|
|
});
|
|
expect(findContactPhotoEntry(contact)?.[0]).toBe('b');
|
|
});
|
|
});
|
|
|
|
describe('getContactPhotoIndex', () => {
|
|
it('maps each address to the photo of the first contact that has one', () => {
|
|
const contacts = [
|
|
makeContact({ id: 'c1', emails: { e0: { address: 'Ann@Example.com' } } }),
|
|
makeContact({
|
|
id: 'c2',
|
|
emails: { e0: { address: 'ann@example.com' }, e1: { address: 'boss@example.com' } },
|
|
media: { photo: { kind: 'photo', blobId: 'dbcAnn', mediaType: 'image/jpeg' } },
|
|
}),
|
|
makeContact({ id: 'c3', emails: { e0: { address: 'ann@example.com' } }, media: { photo: { kind: 'photo', uri: 'https://x.test/other.jpg' } } }),
|
|
];
|
|
|
|
const index = getContactPhotoIndex(contacts);
|
|
|
|
expect(index.get('ann@example.com')).toEqual(expect.objectContaining({ blobId: 'dbcAnn' }));
|
|
expect(index.get('boss@example.com')).toEqual(expect.objectContaining({ blobId: 'dbcAnn' }));
|
|
expect(getContactPhotoIndex(contacts)).toBe(index);
|
|
});
|
|
});
|
|
|
|
describe('saving a new photo (P0-3)', () => {
|
|
const NEW_PHOTO = 'data:image/jpeg;base64,/9j/4AAQ';
|
|
|
|
const makeClient = (blobMedia: boolean) => ({
|
|
getContactsAccountId: vi.fn(() => 'acct-primary'),
|
|
supportsContactBlobMedia: vi.fn(() => blobMedia),
|
|
rejectContactBlobMedia: vi.fn(),
|
|
uploadBlob: vi.fn(async (file: File) => ({ blobId: 'Gupload1', size: file.size, type: file.type })),
|
|
createContact: vi.fn(async (c: Partial<ContactCard>) => ({ ...makeContact(), ...c, id: 'created-id' })),
|
|
updateContact: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
it('uploads the photo and sends its blobId on Stalwart 1.0', async () => {
|
|
useContactStore.setState({ contacts: [makeContact({ id: 'c-1' })] });
|
|
const client = makeClient(true);
|
|
|
|
await useContactStore.getState().updateContact(
|
|
client as unknown as IJMAPClient,
|
|
'c-1',
|
|
{ media: { photo: { kind: 'photo', uri: NEW_PHOTO, mediaType: 'image/jpeg' } } },
|
|
{ photoKey: 'photo' },
|
|
);
|
|
|
|
expect(client.uploadBlob).toHaveBeenCalledWith(expect.any(File), 'acct-primary');
|
|
expect(client.updateContact).toHaveBeenCalledWith(
|
|
'c-1',
|
|
{ media: { photo: { kind: 'photo', mediaType: 'image/jpeg', blobId: 'Gupload1' } } },
|
|
undefined,
|
|
);
|
|
// The list shows the picked photo until the next fetch brings the blob.
|
|
expect(useContactStore.getState().contacts[0].media?.photo.uri).toBe(NEW_PHOTO);
|
|
});
|
|
|
|
it('sends the data: URI without uploading on Stalwart 0.16', async () => {
|
|
const client = makeClient(false);
|
|
|
|
await useContactStore.getState().createContact(
|
|
client as unknown as IJMAPClient,
|
|
{ media: { photo: { kind: 'photo', uri: NEW_PHOTO, mediaType: 'image/jpeg' } } },
|
|
{ photoKey: 'photo' },
|
|
);
|
|
|
|
expect(client.uploadBlob).not.toHaveBeenCalled();
|
|
expect(client.createContact).toHaveBeenCalledWith(
|
|
{ media: { photo: { kind: 'photo', uri: NEW_PHOTO, mediaType: 'image/jpeg' } } },
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('checks the capability of the shared account the card is written to', async () => {
|
|
useContactStore.setState({
|
|
contacts: [makeContact({ id: 'shared-1:c-9', originalId: 'c-9', isShared: true, accountId: 'shared-1' })],
|
|
});
|
|
const client = makeClient(true);
|
|
|
|
await useContactStore.getState().updateContact(
|
|
client as unknown as IJMAPClient,
|
|
'shared-1:c-9',
|
|
{ media: { photo: { kind: 'photo', uri: NEW_PHOTO, mediaType: 'image/jpeg' } } },
|
|
{ photoKey: 'photo' },
|
|
);
|
|
|
|
expect(client.supportsContactBlobMedia).toHaveBeenCalledWith('shared-1');
|
|
expect(client.uploadBlob).toHaveBeenCalledWith(expect.any(File), 'acct-primary');
|
|
expect(client.updateContact).toHaveBeenCalledWith('c-9', expect.anything(), 'shared-1');
|
|
});
|
|
});
|
|
|
|
describe('fetchContactFromServer', () => {
|
|
// The contact form rebuilds `media` on this when the photo changed: the
|
|
// list's copy may carry a blobId Stalwart 1.0 no longer resolves.
|
|
it('reads a shared card from its account by its server id', async () => {
|
|
useContactStore.setState({
|
|
contacts: [makeContact({ id: 'shared-1:c-9', originalId: 'c-9', isShared: true, accountId: 'shared-1' })],
|
|
});
|
|
const current = makeContact({ id: 'c-9', media: { logo: { kind: 'logo', blobId: 'dbcLogoNew' } } });
|
|
const client = { getContact: vi.fn(async () => current) };
|
|
|
|
const card = await useContactStore.getState().fetchContactFromServer(client as unknown as IJMAPClient, 'shared-1:c-9');
|
|
|
|
expect(client.getContact).toHaveBeenCalledWith('c-9', 'shared-1');
|
|
expect(card).toBe(current);
|
|
});
|
|
|
|
it('reads an own card from the default account', async () => {
|
|
useContactStore.setState({ contacts: [makeContact({ id: 'c-1' })] });
|
|
const client = { getContact: vi.fn(async () => null) };
|
|
|
|
expect(await useContactStore.getState().fetchContactFromServer(client as unknown as IJMAPClient, 'c-1')).toBeNull();
|
|
expect(client.getContact).toHaveBeenCalledWith('c-1', undefined);
|
|
});
|
|
});
|
|
|
|
describe('moving a contact to another account', () => {
|
|
beforeEach(() => {
|
|
clearContactPhotoCache();
|
|
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:x');
|
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearContactPhotoCache();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('sends the bytes of a Stalwart 1.0 blob photo, not the source blobId', async () => {
|
|
useContactStore.setState({
|
|
contacts: [makeContact({
|
|
id: 'c-1',
|
|
originalId: 'c-1',
|
|
accountId: 'acct-primary',
|
|
media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } },
|
|
})],
|
|
});
|
|
const client = {
|
|
getContactsAccountId: vi.fn(() => 'acct-primary'),
|
|
fetchBlob: vi.fn(async () => new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' })),
|
|
createContact: vi.fn(async (c: Partial<ContactCard>) => ({ ...makeContact(), ...c, id: 'moved-id' })),
|
|
deleteContact: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
await useContactStore.getState().moveContactToAddressBook(
|
|
client as unknown as IJMAPClient,
|
|
['c-1'],
|
|
{ id: 'shared-1:ab-7', originalId: 'ab-7', name: 'Team', isShared: true, accountId: 'shared-1' },
|
|
);
|
|
|
|
expect(client.fetchBlob).toHaveBeenCalledWith('dbcX', 'photo', 'image/jpeg', 'acct-primary');
|
|
expect(client.createContact).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
media: { photo: { kind: 'photo', mediaType: 'image/jpeg', uri: 'data:image/jpeg;base64,/9j/' } },
|
|
addressBookIds: { 'ab-7': true },
|
|
}),
|
|
'shared-1',
|
|
);
|
|
expect(client.deleteContact).toHaveBeenCalledWith('c-1', undefined);
|
|
});
|
|
});
|
|
|
|
describe('loadTrustedSendersBook (#730)', () => {
|
|
const trustedBook = (id: string) => ({ id, name: TRUSTED_SENDERS_BOOK_NAME });
|
|
|
|
const makeClient = (overrides: Record<string, unknown> = {}) => ({
|
|
getAddressBooks: vi.fn().mockResolvedValue([]),
|
|
createAddressBook: vi.fn(async (name: string) => ({ id: 'created-book', name })),
|
|
getContacts: vi.fn().mockResolvedValue([]),
|
|
createContact: vi.fn(),
|
|
updateContact: vi.fn().mockResolvedValue(undefined),
|
|
deleteContact: vi.fn().mockResolvedValue(undefined),
|
|
deleteAddressBook: vi.fn().mockResolvedValue(undefined),
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
useContactStore.setState({
|
|
trustedSendersBookId: null,
|
|
trustedSendersLoaded: false,
|
|
trustedSendersLoading: false,
|
|
trustedSenderEmails: [],
|
|
});
|
|
});
|
|
|
|
it('does not create a book when the address book fetch fails', async () => {
|
|
const client = makeClient({ getAddressBooks: vi.fn().mockRejectedValue(new Error('429 rate limited')) });
|
|
|
|
await useContactStore.getState().loadTrustedSendersBook(client as never);
|
|
|
|
expect(client.createAddressBook).not.toHaveBeenCalled();
|
|
expect(useContactStore.getState().trustedSendersBookId).toBeNull();
|
|
});
|
|
|
|
it('creates the book exactly once for a genuinely empty account', async () => {
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().loadTrustedSendersBook(client as never);
|
|
|
|
expect(client.createAddressBook).toHaveBeenCalledTimes(1);
|
|
expect(useContactStore.getState().trustedSendersBookId).toBe('created-book');
|
|
});
|
|
|
|
it('shares one in-flight load between concurrent callers', async () => {
|
|
const client = makeClient();
|
|
|
|
await Promise.all([
|
|
useContactStore.getState().loadTrustedSendersBook(client as never),
|
|
useContactStore.getState().loadTrustedSendersBook(client as never),
|
|
useContactStore.getState().loadTrustedSendersBook(client as never),
|
|
]);
|
|
|
|
expect(client.createAddressBook).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('picks the same book on every client when duplicates already exist', async () => {
|
|
const client = makeClient({
|
|
getAddressBooks: vi.fn().mockResolvedValue([trustedBook('book-c'), trustedBook('book-a'), trustedBook('book-b')]),
|
|
});
|
|
|
|
await useContactStore.getState().loadTrustedSendersBook(client as never);
|
|
|
|
expect(client.createAddressBook).not.toHaveBeenCalled();
|
|
expect(useContactStore.getState().trustedSendersBookId).toBe('book-a');
|
|
});
|
|
|
|
it('merges duplicate books into the canonical one and deletes the leftovers', async () => {
|
|
const contactsByBook: Record<string, ContactCard[]> = {
|
|
'book-a': [makeContact({ id: 'c1', addressBookIds: { 'book-a': true }, emails: { e0: { address: 'kept@example.com' } } })],
|
|
'book-b': [makeContact({ id: 'c2', addressBookIds: { 'book-b': true }, emails: { e0: { address: 'moved@example.com' } } })],
|
|
};
|
|
const client = makeClient({
|
|
getAddressBooks: vi.fn().mockResolvedValue([trustedBook('book-a'), trustedBook('book-b')]),
|
|
getContacts: vi.fn(async (bookId: string) => contactsByBook[bookId] ?? []),
|
|
});
|
|
|
|
await useContactStore.getState().loadTrustedSendersBook(client as never);
|
|
|
|
expect(client.updateContact).toHaveBeenCalledWith('c2', { addressBookIds: { 'book-a': true } });
|
|
expect(client.deleteAddressBook).toHaveBeenCalledWith('book-b');
|
|
expect(useContactStore.getState().trustedSenderEmails.sort()).toEqual(['kept@example.com', 'moved@example.com']);
|
|
});
|
|
|
|
it('drops a duplicate card instead of copying it when the address is already trusted', async () => {
|
|
const duplicate = makeContact({ id: 'c2', addressBookIds: { 'book-b': true }, emails: { e0: { address: 'kept@example.com' } } });
|
|
const contactsByBook: Record<string, ContactCard[]> = {
|
|
'book-a': [makeContact({ id: 'c1', addressBookIds: { 'book-a': true }, emails: { e0: { address: 'kept@example.com' } } })],
|
|
'book-b': [duplicate],
|
|
};
|
|
const client = makeClient({
|
|
getAddressBooks: vi.fn().mockResolvedValue([trustedBook('book-a'), trustedBook('book-b')]),
|
|
getContacts: vi.fn(async (bookId: string) => contactsByBook[bookId] ?? []),
|
|
});
|
|
|
|
await useContactStore.getState().loadTrustedSendersBook(client as never);
|
|
|
|
expect(client.deleteContact).toHaveBeenCalledWith('c2');
|
|
expect(useContactStore.getState().trustedSenderEmails).toEqual(['kept@example.com']);
|
|
});
|
|
|
|
it('unfiles rather than destroys a card that also lives in another book', async () => {
|
|
const contactsByBook: Record<string, ContactCard[]> = {
|
|
'book-a': [makeContact({ id: 'c1', addressBookIds: { 'book-a': true }, emails: { e0: { address: 'kept@example.com' } } })],
|
|
'book-b': [makeContact({ id: 'c2', addressBookIds: { 'book-b': true, personal: true }, emails: { e0: { address: 'kept@example.com' } } })],
|
|
};
|
|
const client = makeClient({
|
|
getAddressBooks: vi.fn().mockResolvedValue([trustedBook('book-a'), trustedBook('book-b')]),
|
|
getContacts: vi.fn(async (bookId: string) => contactsByBook[bookId] ?? []),
|
|
});
|
|
|
|
await useContactStore.getState().loadTrustedSendersBook(client as never);
|
|
|
|
expect(client.deleteContact).not.toHaveBeenCalled();
|
|
expect(client.updateContact).toHaveBeenCalledWith('c2', { addressBookIds: { personal: true } });
|
|
});
|
|
|
|
it('keeps a duplicate book when its contacts cannot be read', async () => {
|
|
const client = makeClient({
|
|
getAddressBooks: vi.fn().mockResolvedValue([trustedBook('book-a'), trustedBook('book-b')]),
|
|
getContacts: vi.fn(async (bookId: string) => {
|
|
if (bookId === 'book-b') throw new Error('Network error');
|
|
return [];
|
|
}),
|
|
});
|
|
|
|
await useContactStore.getState().loadTrustedSendersBook(client as never);
|
|
|
|
expect(client.deleteAddressBook).not.toHaveBeenCalled();
|
|
expect(useContactStore.getState().trustedSendersBookId).toBe('book-a');
|
|
});
|
|
});
|
|
|
|
describe('address-book id namespacing on mutations (#133, #1043)', () => {
|
|
const makeClient = () => ({
|
|
getContactsAccountId: vi.fn(() => 'acct-primary'),
|
|
createContact: vi.fn(async (c: Partial<ContactCard>) => ({ ...makeContact(), ...c, id: 'created-id' })),
|
|
updateContact: vi.fn().mockResolvedValue(undefined),
|
|
deleteContact: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
it('strips the local-account prefix from a personal book on update', async () => {
|
|
// Multi-account Pro mode namespaces EVERY book, including the active
|
|
// account's own default one, as `local::bookId`. Updating such a contact
|
|
// used to send the namespaced id and Stalwart rejected the card with
|
|
// "Contact has to belong to at least one address book".
|
|
useContactStore.setState({
|
|
addressBooks: [
|
|
{ id: 'me@host::ab-1', originalId: 'ab-1', localAccountId: 'me@host', name: 'Personal', isDefault: true },
|
|
],
|
|
contacts: [makeContact({
|
|
id: 'me@host::c-1',
|
|
originalId: 'c-1',
|
|
localAccountId: 'me@host',
|
|
addressBookIds: { 'me@host::ab-1': true },
|
|
})],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().updateContact(
|
|
client as unknown as IJMAPClient,
|
|
'me@host::c-1',
|
|
{ name: { components: [{ kind: 'given', value: 'Jane' }], isOrdered: true }, addressBookIds: { 'me@host::ab-1': true } },
|
|
);
|
|
|
|
expect(client.updateContact).toHaveBeenCalledWith(
|
|
'c-1',
|
|
expect.objectContaining({ addressBookIds: { 'ab-1': true } }),
|
|
undefined,
|
|
);
|
|
// Local state keeps the namespaced form the sidebar filters by.
|
|
expect(useContactStore.getState().contacts[0].addressBookIds).toEqual({ 'me@host::ab-1': true });
|
|
});
|
|
|
|
it('strips both local-account and shared-account prefixes on update', async () => {
|
|
useContactStore.setState({
|
|
addressBooks: [
|
|
{ id: 'me@host::group-1:ab-7', originalId: 'ab-7', localAccountId: 'me@host', name: 'Team', isShared: true, accountId: 'group-1' },
|
|
],
|
|
contacts: [makeContact({
|
|
id: 'me@host::group-1:c-9',
|
|
originalId: 'c-9',
|
|
localAccountId: 'me@host',
|
|
isShared: true,
|
|
accountId: 'group-1',
|
|
addressBookIds: { 'me@host::group-1:ab-7': true },
|
|
})],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().updateContact(
|
|
client as unknown as IJMAPClient,
|
|
'me@host::group-1:c-9',
|
|
{ addressBookIds: { 'me@host::group-1:ab-7': true } },
|
|
);
|
|
|
|
expect(client.updateContact).toHaveBeenCalledWith('c-9', { addressBookIds: { 'ab-7': true } }, 'group-1');
|
|
});
|
|
|
|
it('falls back to prefix stripping when the book is not in state', async () => {
|
|
useContactStore.setState({
|
|
addressBooks: [],
|
|
contacts: [makeContact({
|
|
id: 'me@host::group-1:c-9',
|
|
originalId: 'c-9',
|
|
localAccountId: 'me@host',
|
|
isShared: true,
|
|
accountId: 'group-1',
|
|
})],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().updateContact(
|
|
client as unknown as IJMAPClient,
|
|
'me@host::group-1:c-9',
|
|
{ addressBookIds: { 'me@host::group-1:ab-7': true } },
|
|
);
|
|
|
|
expect(client.updateContact).toHaveBeenCalledWith('c-9', { addressBookIds: { 'ab-7': true } }, 'group-1');
|
|
});
|
|
|
|
it('leaves raw ids untouched in single-account mode', async () => {
|
|
useContactStore.setState({
|
|
addressBooks: [{ id: 'ab-1', name: 'Personal', isDefault: true }],
|
|
contacts: [makeContact({ id: 'c-1' })],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().updateContact(
|
|
client as unknown as IJMAPClient,
|
|
'c-1',
|
|
{ addressBookIds: { 'ab-1': true } },
|
|
);
|
|
|
|
expect(client.updateContact).toHaveBeenCalledWith('c-1', { addressBookIds: { 'ab-1': true } }, undefined);
|
|
});
|
|
|
|
it('strips the local-account prefix on create too', async () => {
|
|
useContactStore.setState({
|
|
addressBooks: [
|
|
{ id: 'me@host::ab-1', originalId: 'ab-1', localAccountId: 'me@host', name: 'Personal', isDefault: true },
|
|
],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().createContact(
|
|
client as unknown as IJMAPClient,
|
|
{ addressBookIds: { 'me@host::ab-1': true } },
|
|
);
|
|
|
|
expect(client.createContact).toHaveBeenCalledWith(
|
|
expect.objectContaining({ addressBookIds: { 'ab-1': true } }),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
// Card ids repeat across accounts (live on 0.16 and 1.0: the sharee's own
|
|
// card and one it creates in the owner's shared book both got "c"). A card
|
|
// created in a shared book was kept under its bare id: deleting it right
|
|
// after deleted the own card with that id.
|
|
it('keeps a card created in a shared book apart from an own card with the same id', async () => {
|
|
useContactStore.setState({
|
|
addressBooks: [
|
|
{ id: 'ab-1', name: 'Personal', isDefault: true },
|
|
{ id: 'acct-a:ab-7', originalId: 'ab-7', name: 'Team', isShared: true, accountId: 'acct-a', accountName: 'Alice' },
|
|
],
|
|
contacts: [makeContact({ id: 'c', addressBookIds: { 'ab-1': true } })],
|
|
});
|
|
const client = makeClient();
|
|
client.createContact.mockImplementation(async (c: Partial<ContactCard>) => ({ ...makeContact(), ...c, id: 'c' }));
|
|
|
|
await useContactStore.getState().createContact(
|
|
client as unknown as IJMAPClient,
|
|
{ addressBookIds: { 'acct-a:ab-7': true } },
|
|
);
|
|
|
|
expect(client.createContact).toHaveBeenCalledWith(expect.objectContaining({ addressBookIds: { 'ab-7': true } }), 'acct-a');
|
|
const created = useContactStore.getState().contacts[1];
|
|
expect(created).toMatchObject({
|
|
id: 'acct-a:c', originalId: 'c', isShared: true, accountId: 'acct-a', accountName: 'Alice',
|
|
addressBookIds: { 'acct-a:ab-7': true },
|
|
});
|
|
|
|
await useContactStore.getState().deleteContact(client as unknown as IJMAPClient, 'acct-a:c');
|
|
|
|
expect(client.deleteContact).toHaveBeenCalledWith('c', 'acct-a');
|
|
expect(useContactStore.getState().contacts.map((c) => c.id)).toEqual(['c']);
|
|
});
|
|
|
|
it('namespaces a card created through another login like that login\'s listing', async () => {
|
|
useContactStore.setState({
|
|
addressBooks: [
|
|
{ id: 'me@host::ab-1', originalId: 'ab-1', localAccountId: 'me@host', name: 'Personal', isDefault: true },
|
|
],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().createContact(
|
|
client as unknown as IJMAPClient,
|
|
{ addressBookIds: { 'me@host::ab-1': true } },
|
|
);
|
|
|
|
expect(useContactStore.getState().contacts[0]).toMatchObject({
|
|
id: 'me@host::created-id', originalId: 'created-id', localAccountId: 'me@host',
|
|
addressBookIds: { 'me@host::ab-1': true },
|
|
});
|
|
});
|
|
|
|
it('moves to a namespaced personal book with the raw id and keeps the display id locally', async () => {
|
|
const target = { id: 'me@host::ab-2', originalId: 'ab-2', localAccountId: 'me@host', name: 'Work' };
|
|
useContactStore.setState({
|
|
addressBooks: [
|
|
{ id: 'me@host::ab-1', originalId: 'ab-1', localAccountId: 'me@host', name: 'Personal', isDefault: true },
|
|
target,
|
|
],
|
|
contacts: [makeContact({
|
|
id: 'me@host::c-1',
|
|
originalId: 'c-1',
|
|
localAccountId: 'me@host',
|
|
addressBookIds: { 'me@host::ab-1': true },
|
|
})],
|
|
});
|
|
const client = makeClient();
|
|
|
|
await useContactStore.getState().moveContactToAddressBook(
|
|
client as unknown as IJMAPClient,
|
|
['me@host::c-1'],
|
|
target,
|
|
);
|
|
|
|
expect(client.updateContact).toHaveBeenCalledWith('c-1', { addressBookIds: { 'ab-2': true } }, undefined);
|
|
expect(useContactStore.getState().contacts[0].addressBookIds).toEqual({ 'me@host::ab-2': true });
|
|
});
|
|
});
|
|
|
|
describe('persistence/partialize', () => {
|
|
it('should persist contacts when supportsSync is false', () => {
|
|
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
|
|
const state = { contacts: [makeContact()], supportsSync: false };
|
|
const persisted = partialize(state);
|
|
expect(persisted.contacts).toHaveLength(1);
|
|
expect(persisted.supportsSync).toBe(false);
|
|
});
|
|
|
|
it('should persist empty contacts array when supportsSync is true', () => {
|
|
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
|
|
const state = { contacts: [makeContact()], supportsSync: true };
|
|
const persisted = partialize(state);
|
|
expect(persisted.contacts).toEqual([]);
|
|
expect(persisted.supportsSync).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('fetchDirectory', () => {
|
|
const principal = (id: string, email: string | null, description?: string) =>
|
|
({ id, type: 'individual' as const, name: email ?? id, email, description });
|
|
|
|
it('maps principals with an address to entries named by their description', async () => {
|
|
const client = {
|
|
supportsPrincipals: () => true,
|
|
getPrincipals: vi.fn().mockResolvedValue([
|
|
principal('a', 'ana@example.org', 'Ana Silva'),
|
|
principal('b', null),
|
|
principal('c', 'ANA@example.org', 'Duplicate'),
|
|
]),
|
|
} as unknown as IJMAPClient;
|
|
await useContactStore.getState().fetchDirectory(client);
|
|
const state = useContactStore.getState();
|
|
expect(state.directoryStatus).toBe('ready');
|
|
expect(state.directoryPrincipals).toEqual([
|
|
{ id: 'a', name: 'Ana Silva', email: 'ana@example.org', description: 'Ana Silva', type: 'individual' },
|
|
]);
|
|
});
|
|
|
|
it('marks a refused query as forbidden, not as an empty directory', async () => {
|
|
// JMAPClient.listPrincipals reports Principal/query's `forbidden` so.
|
|
const client = {
|
|
supportsPrincipals: () => true,
|
|
getPrincipals: vi.fn(),
|
|
listPrincipals: vi.fn().mockResolvedValue({ status: 'restricted', principals: [] }),
|
|
} as unknown as IJMAPClient;
|
|
await useContactStore.getState().fetchDirectory(client);
|
|
expect(useContactStore.getState().directoryStatus).toBe('forbidden');
|
|
});
|
|
|
|
it('keeps the last list when a refresh fails', async () => {
|
|
useContactStore.setState({ directoryPrincipals: [{ name: 'Ana', email: 'ana@example.org' }], directoryStatus: 'ready' });
|
|
const client = {
|
|
supportsPrincipals: () => true,
|
|
getPrincipals: vi.fn().mockRejectedValue(new PrincipalsRequestError(null, 'network down')),
|
|
} as unknown as IJMAPClient;
|
|
await useContactStore.getState().fetchDirectory(client);
|
|
expect(useContactStore.getState().directoryStatus).toBe('error');
|
|
expect(useContactStore.getState().directoryPrincipals).toHaveLength(1);
|
|
});
|
|
|
|
it('reports a server without principals as unavailable', async () => {
|
|
const getPrincipals = vi.fn();
|
|
const client = { supportsPrincipals: () => false, getPrincipals } as unknown as IJMAPClient;
|
|
await useContactStore.getState().fetchDirectory(client);
|
|
expect(useContactStore.getState().directoryStatus).toBe('unavailable');
|
|
expect(getPrincipals).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|