Files
webmail/components/contacts/__tests__/contact-export.test.ts
T
Julien Béti a3fdd72763 fix: export contacts as valid vCard 4.0 #1407
The export declared VERSION:3.0 but wrote vCard 4.0 syntax, and wrote the JSContact phone feature \"mobile\" as TYPE=MOBILE, which is no vCard type, so importers dropped such numbers.

- Declare VERSION:4.0 and write 4.0 throughout: basic-format dates and timestamps, data: URIs for inline media, VALUE=text for a UID or RELATED that is no URI, geo: URIs, lines folded at 75 octets, CRLF after every line, and the webmail's own PRODID.
- Map "mobile" to CELL and back; MAIN-NUMBER and x- types are kept.
- Write N before FN: some importers ignore the name otherwise.
- The contact form stores mobile phones as "mobile" (RFC 9553) and still reads the "cell" it used to write.
2026-10-28 17:27:07 +01:00

136 lines
5.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { exportContact, exportContacts, useContactExportBusy } from '../contact-export';
import { useAuthStore } from '@/stores/auth-store';
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
import type { BlobBatch } from '@/lib/jmap/client-interface';
import type { ContactCard } from '@/lib/jmap/types';
const card = (id: string, media: ContactCard['media']): ContactCard => ({
id,
addressBookIds: {},
accountId: 'a',
name: { full: `Contact ${id}` },
media,
});
describe('vCard export of contact photos', () => {
const fetchBlob = vi.fn();
const getContactsAccountId = () => 'a';
let exported: Blob | undefined;
beforeEach(() => {
clearContactPhotoCache();
fetchBlob.mockReset();
exported = undefined;
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
exported = blob as Blob;
return 'blob:vcf';
});
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
useAuthStore.setState({ client: { fetchBlob, getContactsAccountId } as never });
});
afterEach(() => {
useAuthStore.setState({ client: null });
clearContactPhotoCache();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('fetches the Stalwart 1.0 photos of all cards in one Blob/get batch and writes them inline', async () => {
const getBlobsAsBase64 = vi.fn(async (): Promise<BlobBatch> => ({
data: new Map([['dbcX', '/9j/'], ['dbcLogo', 'iVBO']]),
failed: [],
}));
useAuthStore.setState({ client: { fetchBlob, getContactsAccountId, getBlobsAsBase64 } as never });
expect(await exportContacts([
card('1', { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } }),
card('2', { logo: { kind: 'logo', blobId: 'dbcLogo', mediaType: 'image/png' } }),
])).toBe(true);
const vcf = await exported!.text();
expect(getBlobsAsBase64).toHaveBeenCalledWith(['dbcX', 'dbcLogo'], 'a');
expect(fetchBlob).not.toHaveBeenCalled();
expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/');
expect(vcf).toContain('LOGO:data:image/png;base64,iVBO');
});
it('downloads Stalwart 1.0 blob photos one by one when the client cannot batch them', async () => {
fetchBlob.mockImplementation(async (_blobId: string, _name?: string, type?: string) =>
new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type }));
await exportContacts([
card('1', { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } }),
card('2', { logo: { kind: 'logo', blobId: 'dbcLogo', mediaType: 'image/png' } }),
]);
const vcf = await exported!.text();
expect(fetchBlob).toHaveBeenCalledWith('dbcX', 'photo', 'image/jpeg', 'a');
expect(fetchBlob).toHaveBeenCalledWith('dbcLogo', 'logo', 'image/png', 'a');
expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/');
expect(vcf).toContain('LOGO:data:image/png;base64,/9j/');
});
it('writes Stalwart 0.16 photos without a media type too', async () => {
await exportContacts([
card('1', { m0: { kind: 'photo', uri: 'data:;base64,/9j/', mediaType: 'image/jpeg' } }),
card('2', { m0: { kind: 'photo', uri: 'data:base64,/9j/' } }),
]);
const vcf = await exported!.text();
expect(fetchBlob).not.toHaveBeenCalled();
expect(vcf).toContain('PHOTO:data:image/jpeg;base64,/9j/');
expect(vcf).toContain('PHOTO:data:;base64,/9j/');
});
it('leaves out a photo that cannot be downloaded and exports the rest', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404'));
const done = exportContact(card('1', { photo: { kind: 'photo', blobId: 'dbcGone', mediaType: 'image/jpeg' } }));
await vi.advanceTimersByTimeAsync(1000);
expect(await done).toBe(true);
const vcf = await exported!.text();
expect(vcf).toContain('FN:Contact 1');
expect(vcf).not.toContain('PHOTO');
});
it('says it is busy and ignores another export until the first is done', async () => {
let deliver!: (batch: BlobBatch) => void;
const getBlobsAsBase64 = vi.fn(() => new Promise<BlobBatch>((resolve) => { deliver = resolve; }));
useAuthStore.setState({ client: { fetchBlob, getContactsAccountId, getBlobsAsBase64 } as never });
const { result } = renderHook(() => useContactExportBusy());
expect(result.current).toBe(false);
let first!: Promise<boolean>;
act(() => { first = exportContacts([card('1', { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } })]); });
expect(result.current).toBe(true);
// A second click, from any export button, while the photos download.
expect(await exportContact(card('2', undefined))).toBe(false);
expect(await exportContacts([card('3', undefined)])).toBe(false);
await act(async () => {
deliver({ data: new Map([['dbcX', '/9j/']]), failed: [] });
expect(await first).toBe(true);
});
expect(result.current).toBe(false);
expect(getBlobsAsBase64).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(await exported!.text()).toContain('FN:Contact 1');
});
});
describe('vCard file names', () => {
it('keep letters of any script instead of turning them into underscores', async () => {
const { sanitizeFilename } = await import('../contact-export');
expect(sanitizeFilename('Jürgen Müller')).toBe('Jürgen_Müller');
expect(sanitizeFilename('李 雷')).toBe('李_雷');
expect(sanitizeFilename('a/b:c')).toBe('a_b_c');
});
});