Files
webmail/components/contacts/__tests__/contact-export.test.ts
T
Linus Rath 6bd55c9501 fix: export contact photos in batches on Stalwart 1.0
On 1.0 a vCard export sent every blob photo through the avatar download
queue, a burst of 40 and then 6 a second, two at a time: about 160 s for
1000 photos. Avatars and print (which gives up after 5 s) waited behind
it, the Export button showed nothing, and a second click started the
whole export again.

- Export fetches the blob media of all cards with Blob/get
  (`data:asBase64`), asking for the sizes first and packing the requests
  to about 4 MiB. `urn:ietf:params:jmap:blob` goes into `using` only
  when the session advertises it; without it, or when a request is
  refused, the rest is downloaded one by one behind avatars and print,
  which keep half of the download burst for themselves.
- The export buttons in the settings, the bulk bar, the context menu and
  the detail menu show that an export is running and ignore clicks until
  it is done.
- When the photo changes, the form rebuilds `media` on the card as the
  server has it now. A logo or sound blobId from the list's older copy
  made 1.0 refuse the whole update ("The following blobIds do not exist
  or are not accessible").
- A new photo takes the key, place, `pref`, `label` and `contexts` of the
  one it replaces, so it stays the photo shown on cards with several.
  Removing the photo removes every photo the card had instead of only the
  shown one, which brought up the next; logos and sounds stay.
- A card whose login has no client downloads nothing, instead of asking
  the active login's server and remembering the refusal for two minutes.
- Photo downloads run at half the server's `maxConcurrentRequests`
  instead of a fixed two.
- `invalidProperties` naming `media/...` also counts as a refused blobId
  in media, not only `media` itself.
2026-09-25 20:05:19 +02:00

127 lines
5.3 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;ENCODING=b;TYPE=image/jpeg:/9j/');
expect(vcf).toContain('LOGO;ENCODING=b;TYPE=image/png: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;ENCODING=b;TYPE=image/jpeg:/9j/');
expect(vcf).toContain('LOGO;ENCODING=b;TYPE=image/png:/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;ENCODING=b;TYPE=image/jpeg:/9j/');
expect(vcf).toContain('PHOTO;ENCODING=b:/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');
});
});