mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: list shared address books, gate first-touch contact loads and add recent-recipient autocomplete data (#907)
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
request: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { jmapClient } from '../jmap-client';
|
||||||
|
import { queryRecentRecipients, searchSentRecipients } from '../recent-recipients';
|
||||||
|
|
||||||
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('queryRecentRecipients', () => {
|
||||||
|
it('scans Sent To/Cc newest-first and keeps the first name per address', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/query', { ids: ['m1', 'm2'] }, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
list: [
|
||||||
|
{ id: 'm1', to: [{ name: 'Jane Doe', email: 'jane@x.com' }], cc: [{ email: 'cc@x.com' }] },
|
||||||
|
{ id: 'm2', to: [{ name: 'Old Jane', email: 'JANE@x.com' }, { name: 'Bob', email: 'bob@x.com' }] },
|
||||||
|
],
|
||||||
|
}, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await queryRecentRecipients('sent-1');
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ name: 'Jane Doe', email: 'jane@x.com' },
|
||||||
|
{ name: '', email: 'cc@x.com' },
|
||||||
|
{ name: 'Bob', email: 'bob@x.com' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [query, get] = mockRequest.mock.calls[0][0];
|
||||||
|
expect(query[1]).toMatchObject({
|
||||||
|
accountId: 'acc-1',
|
||||||
|
filter: { inMailbox: 'sent-1' },
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit: 300,
|
||||||
|
});
|
||||||
|
expect(get[1].properties).toEqual(['to', 'cc']);
|
||||||
|
expect(get[1]['#ids']).toEqual({ resultOf: '0', name: 'Email/query', path: '/ids' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing without a Sent mailbox', async () => {
|
||||||
|
expect(await queryRecentRecipients('')).toEqual([]);
|
||||||
|
expect(mockRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchSentRecipients', () => {
|
||||||
|
it('only keeps the recipients that match the query', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/query', { ids: ['m1'] }, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
list: [{ id: 'm1', to: [{ name: 'Jane', email: 'jane@x.com' }, { name: 'Bob', email: 'bob@x.com' }] }],
|
||||||
|
}, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(await searchSentRecipients('jan', 'sent-1')).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
|
||||||
|
const query = mockRequest.mock.calls[0][0][0];
|
||||||
|
expect(query[1].filter).toEqual({
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [{ inMailbox: 'sent-1' }, { operator: 'OR', conditions: [{ to: 'jan' }, { cc: 'jan' }] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { jmapClient } from './jmap-client';
|
||||||
|
import { CAPABILITIES } from './types';
|
||||||
|
import type { EmailAddress } from './types';
|
||||||
|
|
||||||
|
export interface RecentRecipient {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||||
|
|
||||||
|
function collect(
|
||||||
|
emails: Array<{ to?: EmailAddress[] | null; cc?: EmailAddress[] | null }>,
|
||||||
|
keep: (key: string, name: string) => boolean = () => true,
|
||||||
|
): RecentRecipient[] {
|
||||||
|
// Messages arrive newest-first, so the first occurrence per address is the
|
||||||
|
// most recent one and carries the freshest display name.
|
||||||
|
const byEmail = new Map<string, RecentRecipient>();
|
||||||
|
for (const email of emails) {
|
||||||
|
for (const r of [...(email.to || []), ...(email.cc || [])]) {
|
||||||
|
if (!r?.email) continue;
|
||||||
|
const key = r.email.toLowerCase().trim();
|
||||||
|
if (!key || byEmail.has(key)) continue;
|
||||||
|
const name = (r.name || '').trim();
|
||||||
|
if (!keep(key, name)) continue;
|
||||||
|
byEmail.set(key, { name, email: r.email });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byEmail.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people the user has written to: scans the newest `limit` messages of the
|
||||||
|
* Sent mailbox and returns each To/Cc address once (OWA-style autocomplete
|
||||||
|
* cache). Only the recipient headers are fetched - no subject, preview or body.
|
||||||
|
*/
|
||||||
|
export async function queryRecentRecipients(
|
||||||
|
sentMailboxId: string,
|
||||||
|
limit = 300,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<RecentRecipient[]> {
|
||||||
|
if (!sentMailboxId) return [];
|
||||||
|
const account = accountId || jmapClient.accountId;
|
||||||
|
const res = await jmapClient.request([
|
||||||
|
['Email/query', {
|
||||||
|
accountId: account,
|
||||||
|
filter: { inMailbox: sentMailboxId },
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit,
|
||||||
|
}, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
accountId: account,
|
||||||
|
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||||
|
properties: ['to', 'cc'],
|
||||||
|
}, '1'],
|
||||||
|
], USING);
|
||||||
|
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
|
||||||
|
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
|
||||||
|
to?: EmailAddress[] | null;
|
||||||
|
cc?: EmailAddress[] | null;
|
||||||
|
}>;
|
||||||
|
return collect(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-demand server search of the Sent mailbox for recipients matching `query`
|
||||||
|
* (used when the local caches have no hit). Keeps only the addresses that
|
||||||
|
* actually match, not every co-recipient of a matching message.
|
||||||
|
*/
|
||||||
|
export async function searchSentRecipients(
|
||||||
|
query: string,
|
||||||
|
sentMailboxId: string,
|
||||||
|
limit = 60,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<RecentRecipient[]> {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q || !sentMailboxId) return [];
|
||||||
|
const account = accountId || jmapClient.accountId;
|
||||||
|
const res = await jmapClient.request([
|
||||||
|
['Email/query', {
|
||||||
|
accountId: account,
|
||||||
|
filter: {
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [
|
||||||
|
{ inMailbox: sentMailboxId },
|
||||||
|
{ operator: 'OR', conditions: [{ to: q }, { cc: q }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit,
|
||||||
|
}, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
accountId: account,
|
||||||
|
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||||
|
properties: ['to', 'cc'],
|
||||||
|
}, '1'],
|
||||||
|
], USING);
|
||||||
|
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
|
||||||
|
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
|
||||||
|
to?: EmailAddress[] | null;
|
||||||
|
cc?: EmailAddress[] | null;
|
||||||
|
}>;
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
return collect(list, (key, name) => key.includes(lower) || name.toLowerCase().includes(lower));
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export default function ContactImportSheet({
|
|||||||
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
|
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [importing, setImporting] = React.useState(false);
|
const [importing, setImporting] = React.useState(false);
|
||||||
const [result, setResult] = React.useState<number | null>(null);
|
const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
|
||||||
|
|
||||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
@@ -144,9 +144,9 @@ export default function ContactImportSheet({
|
|||||||
if (toImport.length === 0) return;
|
if (toImport.length === 0) return;
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const count = await importContacts(toImport, targetBookId);
|
const outcome = await importContacts(toImport, targetBookId);
|
||||||
setResult(count);
|
setResult(outcome);
|
||||||
onImported?.(count);
|
onImported?.(outcome.imported);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Import failed.');
|
setError(e instanceof Error ? e.message : 'Import failed.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -174,8 +174,13 @@ export default function ContactImportSheet({
|
|||||||
<Check size={26} color={c.primaryForeground} />
|
<Check size={26} color={c.primaryForeground} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.successText}>
|
<Text style={styles.successText}>
|
||||||
Imported {result} contact{result === 1 ? '' : 's'}.
|
Imported {result.imported} contact{result.imported === 1 ? '' : 's'}.
|
||||||
</Text>
|
</Text>
|
||||||
|
{result.failed > 0 && (
|
||||||
|
<Text style={[styles.successText, { color: c.error }]}>
|
||||||
|
{result.failed} contact{result.failed === 1 ? '' : 's'} could not be imported.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
|
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
|
||||||
</View>
|
</View>
|
||||||
) : parsed.length === 0 ? (
|
) : parsed.length === 0 ? (
|
||||||
|
|||||||
@@ -2,11 +2,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
vi.mock('../../api/contacts', () => ({
|
vi.mock('../../api/contacts', () => ({
|
||||||
getAddressBooks: vi.fn(),
|
getAddressBooks: vi.fn(),
|
||||||
|
getAllAddressBooks: vi.fn(),
|
||||||
queryContacts: vi.fn(),
|
queryContacts: vi.fn(),
|
||||||
getContacts: vi.fn(),
|
getContacts: vi.fn(),
|
||||||
|
getAllContacts: vi.fn(),
|
||||||
createContact: vi.fn(),
|
createContact: vi.fn(),
|
||||||
updateContact: vi.fn(),
|
updateContact: vi.fn(),
|
||||||
deleteContacts: vi.fn(),
|
deleteContacts: vi.fn(),
|
||||||
|
createAddressBook: vi.fn(),
|
||||||
|
updateAddressBook: vi.fn(),
|
||||||
|
deleteAddressBook: vi.fn(),
|
||||||
|
setDefaultAddressBook: vi.fn(),
|
||||||
|
getContactsInBook: vi.fn(),
|
||||||
|
getContactsAccountId: () => 'acc-1',
|
||||||
|
getContactCapableAccountIds: () => ['acc-1', 'acc-team'],
|
||||||
|
namespaceId: (accountId: string, id: string) => `${accountId}:${id}`,
|
||||||
|
stripClientFields: (c: Record<string, unknown>) => {
|
||||||
|
const { originalId: _o, accountId: _a, accountName: _n, isShared: _s, ...rest } = c;
|
||||||
|
return rest;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api/recent-recipients', () => ({
|
||||||
|
queryRecentRecipients: vi.fn(),
|
||||||
|
searchSentRecipients: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
@@ -25,14 +44,32 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import * as contactsApi from '../../api/contacts';
|
import * as contactsApi from '../../api/contacts';
|
||||||
import { useContactsStore } from '../contacts-store';
|
import * as recentApi from '../../api/recent-recipients';
|
||||||
|
import {
|
||||||
|
useContactsStore,
|
||||||
|
cleanGroupMembers,
|
||||||
|
normalizeSuggestions,
|
||||||
|
selectVisibleContacts,
|
||||||
|
selectGroupMembers,
|
||||||
|
} from '../contacts-store';
|
||||||
|
import type { ContactCard } from '../../api/types';
|
||||||
|
|
||||||
const mockGetAddressBooks = contactsApi.getAddressBooks as ReturnType<typeof vi.fn>;
|
const mockGetAddressBooks = contactsApi.getAddressBooks as ReturnType<typeof vi.fn>;
|
||||||
|
const mockGetAllAddressBooks = contactsApi.getAllAddressBooks as ReturnType<typeof vi.fn>;
|
||||||
const mockQueryContacts = contactsApi.queryContacts as ReturnType<typeof vi.fn>;
|
const mockQueryContacts = contactsApi.queryContacts as ReturnType<typeof vi.fn>;
|
||||||
const mockGetContacts = contactsApi.getContacts as ReturnType<typeof vi.fn>;
|
const mockGetContacts = contactsApi.getContacts as ReturnType<typeof vi.fn>;
|
||||||
|
const mockGetAllContacts = contactsApi.getAllContacts as ReturnType<typeof vi.fn>;
|
||||||
const mockCreateContact = contactsApi.createContact as ReturnType<typeof vi.fn>;
|
const mockCreateContact = contactsApi.createContact as ReturnType<typeof vi.fn>;
|
||||||
const mockUpdateContact = contactsApi.updateContact as ReturnType<typeof vi.fn>;
|
const mockUpdateContact = contactsApi.updateContact as ReturnType<typeof vi.fn>;
|
||||||
const mockDeleteContacts = contactsApi.deleteContacts as ReturnType<typeof vi.fn>;
|
const mockDeleteContacts = contactsApi.deleteContacts as ReturnType<typeof vi.fn>;
|
||||||
|
const mockCreateAddressBook = contactsApi.createAddressBook as ReturnType<typeof vi.fn>;
|
||||||
|
const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType<typeof vi.fn>;
|
||||||
|
const mockSetDefaultAddressBook = contactsApi.setDefaultAddressBook as ReturnType<typeof vi.fn>;
|
||||||
|
const mockGetContactsInBook = contactsApi.getContactsInBook as ReturnType<typeof vi.fn>;
|
||||||
|
const mockQueryRecent = recentApi.queryRecentRecipients as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const card = (id: string, extra: Partial<ContactCard> = {}): ContactCard =>
|
||||||
|
({ id, addressBookIds: { 'ab-1': true }, ...extra }) as ContactCard;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -41,18 +78,29 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('contacts-store', () => {
|
describe('contacts-store', () => {
|
||||||
describe('fetchAddressBooks', () => {
|
describe('fetchAddressBooks', () => {
|
||||||
it('should load address books', async () => {
|
it('should load address books from every contacts-capable account', async () => {
|
||||||
const books = [{ id: 'ab-1', name: 'Personal' }];
|
const books = [{ id: 'ab-1', name: 'Personal' }, { id: 'acc-team:ab', name: 'Team', isShared: true }];
|
||||||
mockGetAddressBooks.mockResolvedValue(books);
|
mockGetAllAddressBooks.mockResolvedValue(books);
|
||||||
|
|
||||||
await useContactsStore.getState().fetchAddressBooks();
|
await useContactsStore.getState().fetchAddressBooks();
|
||||||
|
|
||||||
expect(useContactsStore.getState().addressBooks).toEqual(books);
|
expect(useContactsStore.getState().addressBooks).toEqual(books);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shares one in-flight request between concurrent callers', async () => {
|
||||||
|
let resolve: (v: unknown) => void = () => {};
|
||||||
|
mockGetAllAddressBooks.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||||
|
const a = useContactsStore.getState().fetchAddressBooks();
|
||||||
|
const b = useContactsStore.getState().fetchAddressBooks();
|
||||||
|
resolve([{ id: 'ab-1', name: 'P' }]);
|
||||||
|
await Promise.all([a, b]);
|
||||||
|
expect(mockGetAllAddressBooks).toHaveBeenCalledTimes(1);
|
||||||
|
expect(useContactsStore.getState().addressBooks).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchContacts', () => {
|
describe('fetchContacts', () => {
|
||||||
it('should query and fetch contacts', async () => {
|
it('should query and fetch contacts with a filter', async () => {
|
||||||
mockQueryContacts.mockResolvedValue(['c1', 'c2']);
|
mockQueryContacts.mockResolvedValue(['c1', 'c2']);
|
||||||
const contacts = [{ id: 'c1' }, { id: 'c2' }];
|
const contacts = [{ id: 'c1' }, { id: 'c2' }];
|
||||||
mockGetContacts.mockResolvedValue(contacts);
|
mockGetContacts.mockResolvedValue(contacts);
|
||||||
@@ -66,50 +114,379 @@ describe('contacts-store', () => {
|
|||||||
it('should handle empty results', async () => {
|
it('should handle empty results', async () => {
|
||||||
mockQueryContacts.mockResolvedValue([]);
|
mockQueryContacts.mockResolvedValue([]);
|
||||||
|
|
||||||
await useContactsStore.getState().fetchContacts();
|
await useContactsStore.getState().fetchContacts({ text: 'nobody' });
|
||||||
|
|
||||||
expect(useContactsStore.getState().contacts).toEqual([]);
|
expect(useContactsStore.getState().contacts).toEqual([]);
|
||||||
expect(mockGetContacts).not.toHaveBeenCalled();
|
expect(mockGetContacts).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads every account without a filter', async () => {
|
||||||
|
mockGetAllContacts.mockResolvedValue([{ id: 'c1' }, { id: 'acc-team:c2', isShared: true }]);
|
||||||
|
await useContactsStore.getState().fetchContacts();
|
||||||
|
expect(useContactsStore.getState().contacts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for an in-flight address book load before querying', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
let resolveBooks: (v: unknown) => void = () => {};
|
||||||
|
mockGetAllAddressBooks.mockReturnValue(new Promise((r) => { resolveBooks = r; }));
|
||||||
|
mockGetAllContacts.mockImplementation(async () => { order.push('contacts'); return []; });
|
||||||
|
|
||||||
|
const books = useContactsStore.getState().fetchAddressBooks();
|
||||||
|
const contacts = useContactsStore.getState().fetchContacts();
|
||||||
|
expect(order).toEqual([]);
|
||||||
|
order.push('books-resolved');
|
||||||
|
resolveBooks([]);
|
||||||
|
await Promise.all([books, contacts]);
|
||||||
|
expect(order).toEqual(['books-resolved', 'contacts']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createContact', () => {
|
describe('createContact', () => {
|
||||||
it('should create and append to list', async () => {
|
it('should create and append to list', async () => {
|
||||||
useContactsStore.setState({ contacts: [{ id: 'c1' } as any] });
|
useContactsStore.setState({ contacts: [card('c1')], addressBooks: [{ id: 'ab-1', name: 'P' }] });
|
||||||
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } };
|
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } };
|
||||||
mockCreateContact.mockResolvedValue(created);
|
mockCreateContact.mockResolvedValue(created);
|
||||||
|
|
||||||
const result = await useContactsStore.getState().createContact({}, 'ab-1');
|
const result = await useContactsStore.getState().createContact({}, 'ab-1');
|
||||||
|
|
||||||
expect(result).toEqual(created);
|
expect(result).toEqual(created);
|
||||||
|
expect(mockCreateContact).toHaveBeenCalledWith({}, 'ab-1', undefined);
|
||||||
expect(useContactsStore.getState().contacts).toHaveLength(2);
|
expect(useContactsStore.getState().contacts).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes a shared book to its account and namespaces the result', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
addressBooks: [
|
||||||
|
{ id: 'ab-1', name: 'P' },
|
||||||
|
{ id: 'acc-team:ab', originalId: 'ab', name: 'Team', accountId: 'acc-team', accountName: 'Team', isShared: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockCreateContact.mockResolvedValue({ id: 'c9', addressBookIds: { ab: true } });
|
||||||
|
|
||||||
|
const result = await useContactsStore.getState().createContact({ name: { full: 'X' } }, 'acc-team:ab');
|
||||||
|
|
||||||
|
expect(mockCreateContact).toHaveBeenCalledWith({ name: { full: 'X' } }, 'ab', 'acc-team');
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
id: 'acc-team:c9', originalId: 'c9', accountId: 'acc-team', isShared: true, addressBookIds: { 'acc-team:ab': true },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateContact', () => {
|
describe('updateContact', () => {
|
||||||
it('should update and merge changes in state', async () => {
|
it('should update and merge changes in state', async () => {
|
||||||
useContactsStore.setState({
|
useContactsStore.setState({ contacts: [card('c1', { kind: 'individual' })] });
|
||||||
contacts: [{ id: 'c1', kind: 'individual' } as any],
|
|
||||||
});
|
|
||||||
mockUpdateContact.mockResolvedValue(undefined);
|
mockUpdateContact.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await useContactsStore.getState().updateContact('c1', { kind: 'org' } as any);
|
await useContactsStore.getState().updateContact('c1', { kind: 'org' });
|
||||||
|
|
||||||
expect(useContactsStore.getState().contacts[0].kind).toBe('org');
|
expect(useContactsStore.getState().contacts[0].kind).toBe('org');
|
||||||
|
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { kind: 'org' }, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops properties patched to null locally', async () => {
|
||||||
|
useContactsStore.setState({ contacts: [card('c1', { phones: { p1: { number: '1' } } })] });
|
||||||
|
mockUpdateContact.mockResolvedValue(undefined);
|
||||||
|
await useContactsStore.getState().updateContact('c1', { phones: null } as never);
|
||||||
|
expect(useContactsStore.getState().contacts[0].phones).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply changes the server refused', async () => {
|
||||||
|
useContactsStore.setState({ contacts: [card('c1', { kind: 'individual' })] });
|
||||||
|
mockUpdateContact.mockRejectedValue(new Error('read-only'));
|
||||||
|
await expect(useContactsStore.getState().updateContact('c1', { kind: 'org' })).rejects.toThrow('read-only');
|
||||||
|
expect(useContactsStore.getState().contacts[0].kind).toBe('individual');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-namespaces book ids and routes shared cards to their account', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
contacts: [card('acc-team:c1', { originalId: 'c1', accountId: 'acc-team', isShared: true })],
|
||||||
|
});
|
||||||
|
mockUpdateContact.mockResolvedValue(undefined);
|
||||||
|
await useContactsStore.getState().updateContact('acc-team:c1', { addressBookIds: { 'acc-team:ab': true } });
|
||||||
|
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { addressBookIds: { ab: true } }, 'acc-team');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteContact', () => {
|
describe('deleteContact', () => {
|
||||||
it('should remove from list', async () => {
|
it('should remove from list and clean group members', async () => {
|
||||||
useContactsStore.setState({
|
useContactsStore.setState({
|
||||||
contacts: [{ id: 'c1' } as any, { id: 'c2' } as any],
|
contacts: [
|
||||||
|
card('c1', { uid: 'urn:uuid:u1' }),
|
||||||
|
card('c2'),
|
||||||
|
card('g1', { kind: 'group', members: { u1: true, c2: true } }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
mockDeleteContacts.mockResolvedValue(undefined);
|
mockDeleteContacts.mockResolvedValue(['c1']);
|
||||||
|
|
||||||
await useContactsStore.getState().deleteContact('c1');
|
await useContactsStore.getState().deleteContact('c1');
|
||||||
|
|
||||||
|
const contacts = useContactsStore.getState().contacts;
|
||||||
|
expect(contacts.map((c) => c.id)).toEqual(['c2', 'g1']);
|
||||||
|
expect(contacts[1].members).toEqual({ c2: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkDelete', () => {
|
||||||
|
it('only drops the ids the server destroyed', async () => {
|
||||||
|
useContactsStore.setState({ contacts: [card('c1'), card('c2'), card('c3')] });
|
||||||
|
mockDeleteContacts.mockResolvedValue(['c1', 'c3']);
|
||||||
|
|
||||||
|
await useContactsStore.getState().bulkDelete(['c1', 'c2', 'c3']);
|
||||||
|
|
||||||
|
expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c2']);
|
||||||
|
expect(useContactsStore.getState().error).toMatch(/1 contact/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups cards by account', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
contacts: [card('c1'), card('acc-team:c2', { originalId: 'c2', accountId: 'acc-team', isShared: true })],
|
||||||
|
});
|
||||||
|
mockDeleteContacts.mockImplementation(async (ids: string[]) => ids);
|
||||||
|
await useContactsStore.getState().bulkDelete(['c1', 'acc-team:c2']);
|
||||||
|
expect(mockDeleteContacts).toHaveBeenCalledWith(['c1'], undefined);
|
||||||
|
expect(mockDeleteContacts).toHaveBeenCalledWith(['c2'], 'acc-team');
|
||||||
|
expect(useContactsStore.getState().contacts).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importContacts', () => {
|
||||||
|
it('reports imported and failed counts', async () => {
|
||||||
|
useContactsStore.setState({ addressBooks: [{ id: 'ab-1', name: 'P' }] });
|
||||||
|
mockCreateContact
|
||||||
|
.mockResolvedValueOnce({ id: 'n1', addressBookIds: { 'ab-1': true } })
|
||||||
|
.mockRejectedValueOnce(new Error('invalid'));
|
||||||
|
const result = await useContactsStore.getState().importContacts(
|
||||||
|
[{ id: 'import-1', name: { full: 'A' } }, { id: 'import-2', name: { full: 'B' } }],
|
||||||
|
'ab-1',
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ imported: 1, failed: 1 });
|
||||||
expect(useContactsStore.getState().contacts).toHaveLength(1);
|
expect(useContactsStore.getState().contacts).toHaveLength(1);
|
||||||
expect(useContactsStore.getState().contacts[0].id).toBe('c2');
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('address books', () => {
|
||||||
|
it('deleting a book drops its cards from the cache', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
addressBooks: [{ id: 'ab-1', name: 'P' }, { id: 'ab-2', name: 'Q' }],
|
||||||
|
contacts: [card('c1'), card('c2', { addressBookIds: { 'ab-2': true } })],
|
||||||
|
});
|
||||||
|
mockDeleteAddressBook.mockResolvedValue(undefined);
|
||||||
|
await useContactsStore.getState().deleteAddressBook('ab-2');
|
||||||
|
expect(useContactsStore.getState().addressBooks.map((b) => b.id)).toEqual(['ab-1']);
|
||||||
|
expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setDefaultAddressBook flips the flag on siblings', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
addressBooks: [{ id: 'ab-1', name: 'P', isDefault: true }, { id: 'ab-2', name: 'Q' }],
|
||||||
|
});
|
||||||
|
mockSetDefaultAddressBook.mockResolvedValue(undefined);
|
||||||
|
await useContactsStore.getState().setDefaultAddressBook('ab-2');
|
||||||
|
expect(mockSetDefaultAddressBook).toHaveBeenCalledWith('ab-2', undefined);
|
||||||
|
expect(useContactsStore.getState().addressBooks.map((b) => !!b.isDefault)).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDefaultAddressBookId prefers the default own book', () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
addressBooks: [
|
||||||
|
{ id: 'acc-team:ab', name: 'Team', isShared: true, isDefault: true },
|
||||||
|
{ id: 'ab-1', name: 'P' },
|
||||||
|
{ id: 'ab-2', name: 'Q', isDefault: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(useContactsStore.getState().getDefaultAddressBookId()).toBe('ab-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groups', () => {
|
||||||
|
it('addContactsToGroup references members by uid', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
contacts: [
|
||||||
|
card('g1', { kind: 'group', members: { existing: true } }),
|
||||||
|
card('c1', { uid: 'urn:uuid:u1' }),
|
||||||
|
card('c2'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockUpdateContact.mockResolvedValue(undefined);
|
||||||
|
await useContactsStore.getState().addContactsToGroup('g1', ['c1', 'c2']);
|
||||||
|
expect(mockUpdateContact).toHaveBeenCalledWith(
|
||||||
|
'g1', { members: { existing: true, 'urn:uuid:u1': true, c2: true } }, undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createGroup creates a kind=group card in the default book', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
addressBooks: [{ id: 'ab-1', name: 'P', isDefault: true }],
|
||||||
|
contacts: [card('c1', { uid: 'u1' })],
|
||||||
|
});
|
||||||
|
mockCreateContact.mockResolvedValue({ id: 'g1', kind: 'group', addressBookIds: { 'ab-1': true } });
|
||||||
|
await useContactsStore.getState().createGroup('Sales', ['c1']);
|
||||||
|
expect(mockCreateContact).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ kind: 'group', members: { u1: true }, name: expect.objectContaining({ full: 'Sales' }) }),
|
||||||
|
'ab-1',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getGroupRecipients dedupes addresses case-insensitively', () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
contacts: [
|
||||||
|
card('g1', { kind: 'group', members: { c1: true, c2: true, c3: true } }),
|
||||||
|
card('c1', { name: { full: 'A' }, emails: { e: { address: 'a@x.com' } } }),
|
||||||
|
card('c2', { name: { full: 'A2' }, emails: { e: { address: 'A@X.COM' } } }),
|
||||||
|
card('c3', { name: { full: 'B <b@x.com>' }, emails: { e: { address: 'b@x.com' } } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(useContactsStore.getState().getGroupRecipients('g1')).toEqual([
|
||||||
|
{ name: 'A', email: 'a@x.com' },
|
||||||
|
{ name: 'B', email: 'b@x.com' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renameKeyword', () => {
|
||||||
|
it('rewrites the keyword on every affected card', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
contacts: [card('c1', { keywords: { Old: true, Keep: true } }), card('c2', { keywords: { Other: true } })],
|
||||||
|
selectedCategory: { type: 'keyword', keyword: 'Old' },
|
||||||
|
});
|
||||||
|
mockUpdateContact.mockResolvedValue(undefined);
|
||||||
|
await useContactsStore.getState().renameKeyword('Old', 'New');
|
||||||
|
expect(mockUpdateContact).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true, New: true } }, undefined);
|
||||||
|
expect(useContactsStore.getState().selectedCategory).toEqual({ type: 'keyword', keyword: 'New' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autocomplete', () => {
|
||||||
|
it('merges contacts, groups and recent recipients without duplicates', async () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
contacts: [
|
||||||
|
card('c1', { name: { full: 'Jane Doe' }, emails: { e: { address: 'jane@x.com' } } }),
|
||||||
|
card('g1', { kind: 'group', name: { full: 'Jane fans' }, members: { c1: true } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockQueryRecent.mockResolvedValue([
|
||||||
|
{ name: 'Jane Doe', email: 'JANE@x.com' },
|
||||||
|
{ name: 'Janet', email: 'janet@x.com' },
|
||||||
|
]);
|
||||||
|
await useContactsStore.getState().loadRecentRecipients('sent-1');
|
||||||
|
expect(mockQueryRecent).toHaveBeenCalledWith('sent-1', 300);
|
||||||
|
|
||||||
|
const results = useContactsStore.getState().getAutocomplete('jan');
|
||||||
|
expect(results).toEqual([
|
||||||
|
{ name: 'Jane Doe', email: 'jane@x.com' },
|
||||||
|
{ name: 'Jane fans', email: '', group: { id: 'g1', memberCount: 1 } },
|
||||||
|
{ name: 'Janet', email: 'janet@x.com' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadRecentRecipients is a no-op once loaded', async () => {
|
||||||
|
mockQueryRecent.mockResolvedValue([]);
|
||||||
|
await useContactsStore.getState().loadRecentRecipients('sent-1');
|
||||||
|
await useContactsStore.getState().loadRecentRecipients('sent-1');
|
||||||
|
expect(mockQueryRecent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findContactByEmail matches case-insensitively and accepts a mailbox', () => {
|
||||||
|
useContactsStore.setState({
|
||||||
|
contacts: [card('c1', { emails: { e: { address: 'Jane@X.com' } } })],
|
||||||
|
});
|
||||||
|
expect(useContactsStore.getState().findContactByEmail('jane@x.com')?.id).toBe('c1');
|
||||||
|
expect(useContactsStore.getState().findContactByEmail('Jane <JANE@x.com>')?.id).toBe('c1');
|
||||||
|
expect(useContactsStore.getState().findContactByEmail('nobody@x.com')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trusted senders', () => {
|
||||||
|
it('add-after-load waits for the in-flight passive load', async () => {
|
||||||
|
let resolveBooks: (v: unknown) => void = () => {};
|
||||||
|
mockGetAddressBooks.mockReturnValue(new Promise((r) => { resolveBooks = r; }));
|
||||||
|
mockGetContactsInBook.mockResolvedValue([]);
|
||||||
|
mockCreateContact.mockResolvedValue({ id: 't1' });
|
||||||
|
|
||||||
|
const passive = useContactsStore.getState().loadTrustedSendersBook();
|
||||||
|
const add = useContactsStore.getState().addToTrustedSendersBook('Jane <Jane@X.com>');
|
||||||
|
resolveBooks([{ id: 'ab-ts-b', name: 'Trusted Senders' }, { id: 'ab-ts-a', name: 'Trusted Senders' }]);
|
||||||
|
await Promise.all([passive, add]);
|
||||||
|
|
||||||
|
expect(mockGetAddressBooks).toHaveBeenCalledTimes(1);
|
||||||
|
// Duplicate books: the lowest id wins on every client (#730).
|
||||||
|
expect(useContactsStore.getState().trustedSendersBookId).toBe('ab-ts-a');
|
||||||
|
expect(mockCreateContact).toHaveBeenCalledWith(
|
||||||
|
{ name: { full: 'Jane' }, emails: { email: { address: 'jane@x.com' } } },
|
||||||
|
'ab-ts-a',
|
||||||
|
);
|
||||||
|
expect(useContactsStore.getState().trustedSenderEmails).toEqual(['jane@x.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the book on first add when the passive load found none', async () => {
|
||||||
|
mockGetAddressBooks.mockResolvedValue([]);
|
||||||
|
mockCreateAddressBook.mockResolvedValue({ id: 'ab-new', name: 'Trusted Senders' });
|
||||||
|
mockGetContactsInBook.mockResolvedValue([]);
|
||||||
|
mockCreateContact.mockResolvedValue({ id: 't1' });
|
||||||
|
|
||||||
|
await useContactsStore.getState().loadTrustedSendersBook();
|
||||||
|
expect(useContactsStore.getState().trustedSendersBookId).toBeNull();
|
||||||
|
expect(mockCreateAddressBook).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await useContactsStore.getState().addToTrustedSendersBook('x@y.com');
|
||||||
|
expect(mockCreateAddressBook).toHaveBeenCalledWith('Trusted Senders');
|
||||||
|
expect(useContactsStore.getState().trustedSendersBookId).toBe('ab-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeFromTrustedSendersBook deletes the matching card', async () => {
|
||||||
|
useContactsStore.setState({ trustedSendersBookId: 'ab-ts', trustedSenderEmails: ['x@y.com', 'z@y.com'] });
|
||||||
|
mockGetContactsInBook.mockResolvedValue([
|
||||||
|
card('t1', { emails: { e: { address: 'X@y.com' } } }),
|
||||||
|
card('t2', { emails: { e: { address: 'z@y.com' } } }),
|
||||||
|
]);
|
||||||
|
mockDeleteContacts.mockResolvedValue(['t1']);
|
||||||
|
await useContactsStore.getState().removeFromTrustedSendersBook('Someone <x@y.com>');
|
||||||
|
expect(mockDeleteContacts).toHaveBeenCalledWith(['t1']);
|
||||||
|
expect(useContactsStore.getState().trustedSenderEmails).toEqual(['z@y.com']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('helpers', () => {
|
||||||
|
it('cleanGroupMembers strips id, uid and urn:uuid variants', () => {
|
||||||
|
const contacts = [
|
||||||
|
card('c1', { uid: 'urn:uuid:u1' }),
|
||||||
|
card('g1', { kind: 'group', members: { 'urn:uuid:u1': true, u1: true, c1: true, other: true } }),
|
||||||
|
];
|
||||||
|
const cleaned = cleanGroupMembers(contacts, new Set(['c1']));
|
||||||
|
expect(cleaned[1].members).toEqual({ other: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizeSuggestions collapses mailbox-shaped names (#672)', () => {
|
||||||
|
expect(normalizeSuggestions([
|
||||||
|
{ name: 'Jane <j@x.com>', email: 'j@x.com' },
|
||||||
|
{ name: '', email: 'J@x.com' },
|
||||||
|
{ name: '', email: 'Ann <a@x.com>' },
|
||||||
|
{ name: 'Grp', email: '', group: { id: 'g', memberCount: 2 } },
|
||||||
|
])).toEqual([
|
||||||
|
{ name: 'Jane', email: 'j@x.com' },
|
||||||
|
{ name: 'Ann', email: 'a@x.com' },
|
||||||
|
{ name: 'Grp', email: '', group: { id: 'g', memberCount: 2 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uncategorized means "no keywords"', () => {
|
||||||
|
const state = {
|
||||||
|
...useContactsStore.getState(),
|
||||||
|
contacts: [card('c1', { keywords: { A: true } }), card('c2'), card('g', { kind: 'group' })],
|
||||||
|
selectedCategory: { type: 'uncategorized' as const },
|
||||||
|
};
|
||||||
|
expect(selectVisibleContacts(state, '').map((c) => c.id)).toEqual(['c2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selectGroupMembers resolves shared cards by originalId', () => {
|
||||||
|
const contacts = [
|
||||||
|
card('g1', { kind: 'group', members: { c9: true } }),
|
||||||
|
card('acc-team:c9', { originalId: 'c9', isShared: true }),
|
||||||
|
];
|
||||||
|
expect(selectGroupMembers({ contacts }, 'g1').map((c) => c.id)).toEqual(['acc-team:c9']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+727
-255
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user