mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
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.
216 lines
8.6 KiB
TypeScript
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');
|
|
});
|
|
});
|