Files
native/src/lib/__tests__/contact-utils.test.ts
T
Linus Rath f52ef7db7e fix: show and keep contact photos in the app on Stalwart 1.0
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including photos saved as data: URIs. The app read
only `uri`, so sender avatars, the contact list, the detail screen, the
form preview and vCard export showed nothing. Worse, the form rebuilt
`media` from the card on every save and sent it without the photo, or
as `null`, so editing a phone number deleted the photo on the server.

- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`, and
  `uri` is optional. The photo shown is the photo entry with the lowest
  `pref`, as a URI (0.16) or a blob (1.0).
- Blob photos load from the download URL with the Authorization
  header, like file thumbnails. At most half of the server's
  `maxConcurrentRequests` load at once, a failed one is tried once more
  and then left alone for two minutes, and one that loaded skips the
  queue from then on.
- The form leaves `media` out of the save unless the photo changed. A
  changed photo is built on the card as the server has it now, so a
  stale blobId can't make 1.0 refuse the update, and a new photo keeps
  the key, `pref`, `label` and `contexts` of the one it replaces.
- A new photo is uploaded into the user's own account and sent as a
  blobId where the blob capability lists ContactCard (1.0). Otherwise
  it goes as a data: URI, and a refusal like 0.16's "blobIds in media
  is not supported." retries once with the data: URI and is remembered
  for the account.
- vCard export fetches blob photos with batched Blob/get requests and
  writes them inline. It also writes photos whose data: URI names no
  media type, which it dropped before. Duplicate and moving a card to
  another account send the bytes instead of the source's blobIds.
- An update never clears `id`, `uid` or `addressBookIds`, and vCard
  export writes MEMBER values that are already URIs as they are instead
  of `urn:uuid:urn:uuid:…`.

The form's state and patch building moved to `src/lib/contact-form.ts`
so the save path can be tested.
2026-09-25 20:32:13 +02:00

216 lines
8.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
deriveFullName,
findContactPhotoEntry,
getContactDisplayName,
getContactSortName,
getCustomFullName,
getContactPhotoRef,
getContactPhotoUri,
hasContactPhoto,
normalizeContactPhotoUri,
partialDateToString,
stringToPartialDate,
} from '../contact-utils';
import type { ContactCard } from '../../api/types';
describe('getContactDisplayName', () => {
it('joins given + surname only, like the webmail', () => {
const c: ContactCard = {
id: 'c',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'middle', value: 'Q' }, { kind: 'surname', value: 'Doe' }] },
};
expect(getContactDisplayName(c)).toBe('Jane Doe');
});
});
describe('normalizeContactPhotoUri (#307)', () => {
it('adds a media type to data URIs that lack one', () => {
expect(normalizeContactPhotoUri('data:base64,AAAA')).toBe('data:image/jpeg;base64,AAAA');
expect(normalizeContactPhotoUri('data:;base64,AAAA', 'image/png')).toBe('data:image/png;base64,AAAA');
});
it('leaves well-formed URIs alone', () => {
expect(normalizeContactPhotoUri('data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA');
expect(normalizeContactPhotoUri('https://x/y.jpg')).toBe('https://x/y.jpg');
});
it('is applied by getContactPhotoUri', () => {
const c: ContactCard = { id: 'c', addressBookIds: {}, media: { m: { kind: 'photo', uri: 'data:base64,AAAA' } } };
expect(getContactPhotoUri(c)).toBe('data:image/jpeg;base64,AAAA');
});
});
describe('contact photo refs', () => {
const card = (media: ContactCard['media'], extra: Partial<ContactCard> = {}): ContactCard =>
({ id: 'c', addressBookIds: {}, media, ...extra });
it('reads the data: URI Stalwart 0.16 returns', () => {
const c = card({ photo: { kind: 'photo', uri: 'data:image/jpeg;base64,AAAA', mediaType: 'image/jpeg' } });
expect(getContactPhotoRef(c)).toEqual({ uri: 'data:image/jpeg;base64,AAAA' });
expect(hasContactPhoto(c)).toBe(true);
});
it('reads the blob Stalwart 1.0 returns instead of a uri', () => {
const c = card({ photo: { blobId: 'iavlkor1', kind: 'photo', mediaType: 'image/jpeg' } });
expect(getContactPhotoRef(c)).toEqual({ blobId: 'iavlkor1', mediaType: 'image/jpeg' });
expect(hasContactPhoto(c)).toBe(true);
// No uri to hand out for a blob.
expect(getContactPhotoUri(c)).toBeUndefined();
});
it('picks the photo with the lowest pref, the first one on a tie', () => {
const c = card({
logo: { kind: 'logo', blobId: 'b-logo', pref: 1 },
p1: { kind: 'photo', blobId: 'b-1' },
p2: { kind: 'photo', blobId: 'b-2', pref: 5 },
p3: { kind: 'photo', blobId: 'b-3', pref: 2 },
p4: { kind: 'photo', blobId: 'b-4', pref: 2 },
});
expect(findContactPhotoEntry(c)?.[0]).toBe('p3');
expect(findContactPhotoEntry(card({ a: { kind: 'photo', uri: 'https://x/a.jpg' }, b: { kind: 'photo', uri: 'https://x/b.jpg' } }))?.[0])
.toBe('a');
});
it('has no photo without a photo entry that carries a uri or blobId', () => {
expect(hasContactPhoto(card({ logo: { kind: 'logo', blobId: 'b' }, p: { kind: 'photo' } }))).toBe(false);
expect(hasContactPhoto(card(undefined))).toBe(false);
});
it('names the owner account for a card of a shared address book', () => {
const c = card({ photo: { blobId: 'b', kind: 'photo' } }, { isShared: true, accountId: 'team' });
expect(getContactPhotoRef(c)).toEqual({ blobId: 'b', accountId: 'team' });
});
});
describe('partialDateToString', () => {
it.each([
[{ year: 1990, month: 5, day: 4 }, '1990-05-04'],
[{ year: 1990, month: 5 }, '1990-05'],
[{ year: 1990 }, '1990'],
[{ month: 5, day: 4 }, '--05-04'],
[{ month: 5 }, '--05'],
[{ day: 4 }, '---04'],
])('is lossless for %j', (pd, expected) => {
expect(partialDateToString(pd)).toBe(expected);
});
it('handles timestamps and strings', () => {
expect(partialDateToString({ '@type': 'Timestamp', utc: '2020-01-02T03:04:05Z' })).toBe('2020-01-02');
expect(partialDateToString('1999-12-31')).toBe('1999-12-31');
expect(partialDateToString(undefined)).toBe('');
});
});
describe('stringToPartialDate', () => {
it.each([
['1990-05-04', { year: 1990, month: 5, day: 4 }],
['19900504', { year: 1990, month: 5, day: 4 }],
['1990-05-04T00:00:00Z', { year: 1990, month: 5, day: 4 }],
['1990-05', { year: 1990, month: 5 }],
['1990', { year: 1990 }],
['--05-04', { month: 5, day: 4 }],
['--0504', { month: 5, day: 4 }],
['--05', { month: 5 }],
['---04', { day: 4 }],
])('parses %s', (input, expected) => {
expect(stringToPartialDate(input)).toEqual(expected);
});
it.each(['May 5', '1990-13-01', 'circa 1800', '', ' '])('rejects %s', (input) => {
expect(stringToPartialDate(input)).toBeNull();
});
it('round-trips through partialDateToString', () => {
for (const s of ['1990-05-04', '1990-05', '1990', '--05-04', '--05', '---04']) {
expect(partialDateToString(stringToPartialDate(s)!)).toBe(s);
}
});
});
describe('name.full for the vCard FN (#430)', () => {
const person = (full: string | undefined, components: Array<{ kind: string; value: string }>): ContactCard => ({
id: 'c',
addressBookIds: {},
name: { components, isOrdered: true, ...(full !== undefined ? { full } : {}) },
});
it('joins the name components in display order, like the webmail form', () => {
expect(deriveFullName([
{ kind: 'surname', value: 'Doe' },
{ kind: 'generation', value: 'Jr.' },
{ kind: 'given', value: ' John ' },
{ kind: 'title', value: 'Dr.' },
{ kind: 'given2', value: 'Q' },
])).toBe('Dr. John Q Doe Jr.');
// Legacy vCard-style kinds count too.
expect(deriveFullName([{ kind: 'prefix', value: 'Ms.' }, { kind: 'given', value: 'Ann' }])).toBe('Ms. Ann');
expect(deriveFullName(undefined)).toBe('');
expect(deriveFullName([])).toBe('');
});
it('treats a full that repeats the components as derived, so an edit cannot leave it stale', () => {
const card = person('John Doe', [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }]);
expect(getCustomFullName(card)).toBe('');
});
it('keeps a display name of its own', () => {
const card = person('Johnny D', [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }]);
expect(getCustomFullName(card)).toBe('Johnny D');
expect(getCustomFullName(person('Solo', []))).toBe('Solo');
expect(getCustomFullName(person(undefined, [{ kind: 'given', value: 'A' }]))).toBe('');
});
it('treats an organization card whose full is the organization name as derived', () => {
const org = person('Acme Corp', []);
expect(getCustomFullName(org, 'Acme Corp')).toBe('');
expect(getCustomFullName(org, 'Acme Inc')).toBe('Acme Corp');
});
});
describe('getContactSortName (#963)', () => {
const make = (overrides: Partial<ContactCard>): ContactCard => ({ id: 'c1', addressBookIds: {}, ...overrides });
const structured = make({
name: {
components: [
{ kind: 'given', value: 'Alice' },
{ kind: 'middle', value: 'Jane' },
{ kind: 'surname', value: 'Smith' },
],
isOrdered: true,
},
});
it('returns the display name when not sorting by last name', () => {
expect(getContactSortName(structured, false)).toBe('Alice Smith');
});
it('leads with the surname when sorting by last name', () => {
expect(getContactSortName(structured, true)).toBe('Smith, Alice Jane');
});
it('returns just the surname when no given name exists', () => {
const c = make({ name: { components: [{ kind: 'surname', value: 'Smith' }], isOrdered: true } });
expect(getContactSortName(c, true)).toBe('Smith');
});
it('uses the last word of name.full when there are no components', () => {
expect(getContactSortName(make({ name: { full: 'Jean Pierre Dupont' } }), true)).toBe('Dupont, Jean Pierre');
});
it('keeps a single-word name.full as-is', () => {
expect(getContactSortName(make({ name: { full: 'Madonna' } }), true)).toBe('Madonna');
});
it('does not split organization or email fallbacks into a surname', () => {
expect(getContactSortName(make({ organizations: { o1: { name: 'Acme Corp' } } }), true)).toBe('Acme Corp');
expect(getContactSortName(make({ emails: { e0: { address: 'someone@example.com' } } }), true)).toBe('someone@example.com');
});
it('falls back to the display name for a given-only name (e.g. a group)', () => {
const c = make({ kind: 'group', name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true } });
expect(getContactSortName(c, true)).toBe('Team');
});
});