feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- Contact groups/lists, vCard import/export (RFC 6350), bulk operations - Advanced search with JMAP filter panel, search chips, cross-mailbox queries - Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator - TOTP two-factor authentication support - Docker multi-stage build with standalone output and docker-compose - CSP Report-Only headers and security headers via proxy middleware - Virtual scrolling for large email lists - Structured server-side logger (text/JSON, configurable level) - 450+ tests (contacts, vCard, threads, headers, identity, components) - Playwright E2E framework setup - Updated README and ROADMAP with all new features
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAuthenticationResults,
|
||||
parseSpamScore,
|
||||
parseReceivedHeaders,
|
||||
formatBytes,
|
||||
getSecurityStatus,
|
||||
parseSpamLLM,
|
||||
extractListHeaders,
|
||||
} from '../email-headers';
|
||||
|
||||
describe('parseAuthenticationResults', () => {
|
||||
it('parses SPF pass with domain', () => {
|
||||
const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com');
|
||||
expect(result.spf).toEqual({ result: 'pass', domain: 'example.com' });
|
||||
});
|
||||
|
||||
it('parses DKIM pass with domain and selector', () => {
|
||||
const result = parseAuthenticationResults('dkim=pass header.d=example.com header.s=selector1');
|
||||
expect(result.dkim).toEqual({ result: 'pass', domain: 'example.com', selector: 'selector1' });
|
||||
});
|
||||
|
||||
it('parses DMARC pass with domain', () => {
|
||||
const result = parseAuthenticationResults('dmarc=pass header.from=example.com');
|
||||
expect(result.dmarc).toEqual({ result: 'pass', domain: 'example.com', policy: undefined });
|
||||
});
|
||||
|
||||
it('parses all three together separated by semicolons', () => {
|
||||
const header = 'spf=pass smtp.mailfrom=example.com; dkim=pass header.d=example.com; dmarc=pass header.from=example.com';
|
||||
const result = parseAuthenticationResults(header);
|
||||
expect(result.spf?.result).toBe('pass');
|
||||
expect(result.dkim?.result).toBe('pass');
|
||||
expect(result.dmarc?.result).toBe('pass');
|
||||
});
|
||||
|
||||
it('parses failure results', () => {
|
||||
expect(parseAuthenticationResults('spf=fail smtp.mailfrom=bad.com').spf?.result).toBe('fail');
|
||||
expect(parseAuthenticationResults('dkim=fail header.d=bad.com').dkim?.result).toBe('fail');
|
||||
expect(parseAuthenticationResults('dmarc=fail header.from=bad.com').dmarc?.result).toBe('fail');
|
||||
});
|
||||
|
||||
it('returns empty object for unrecognized header', () => {
|
||||
expect(parseAuthenticationResults('garbage header value')).toEqual({});
|
||||
});
|
||||
|
||||
it('parses iprev with IP address', () => {
|
||||
const result = parseAuthenticationResults('iprev=pass policy.iprev=192.168.1.1');
|
||||
expect(result.iprev).toEqual({ result: 'pass', ip: '192.168.1.1' });
|
||||
});
|
||||
|
||||
it('parses SPF softfail', () => {
|
||||
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
|
||||
expect(result.spf?.result).toBe('softfail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSpamScore', () => {
|
||||
it('parses X-Spam-Status "No" format', () => {
|
||||
expect(parseSpamScore('No, score=-0.25')).toEqual({ status: 'no', score: -0.25 });
|
||||
});
|
||||
|
||||
it('parses X-Spam-Status "Yes" format', () => {
|
||||
expect(parseSpamScore('Yes, score=8.5')).toEqual({ status: 'yes', score: 8.5 });
|
||||
});
|
||||
|
||||
it('extracts plain score and classifies as ham', () => {
|
||||
expect(parseSpamScore('score=3.2')).toEqual({ score: 3.2, status: 'ham' });
|
||||
});
|
||||
|
||||
it('extracts plain score and classifies as spam when above threshold', () => {
|
||||
expect(parseSpamScore('score=6.0')).toEqual({ score: 6.0, status: 'spam' });
|
||||
});
|
||||
|
||||
it('returns null for unrecognized format', () => {
|
||||
expect(parseSpamScore('nothing useful here')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReceivedHeaders', () => {
|
||||
it('parses a single received header', () => {
|
||||
const headers = ['from mail.example.com by mx.example.com with SMTP id abc123; Mon, 15 Jan 2024 10:00:00 +0000'];
|
||||
const result = parseReceivedHeaders(headers);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].from).toBe('mail.example.com');
|
||||
expect(result[0].by).toBe('mx.example.com');
|
||||
expect(result[0].protocol).toBe('SMTP');
|
||||
expect(result[0].id).toBe('abc123');
|
||||
expect(result[0].timestamp).toBe('Mon, 15 Jan 2024 10:00:00 +0000');
|
||||
});
|
||||
|
||||
it('handles missing fields gracefully', () => {
|
||||
const result = parseReceivedHeaders(['from sender.example.com']);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].from).toBe('sender.example.com');
|
||||
expect(result[0].by).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(parseReceivedHeaders([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips headers with no from or by', () => {
|
||||
expect(parseReceivedHeaders(['random text without routing info'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatBytes(512)).toBe('512.0 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatBytes(1048576)).toBe('1.0 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatBytes(1073741824)).toBe('1.0 GB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecurityStatus', () => {
|
||||
it('returns green for pass', () => {
|
||||
const status = getSecurityStatus('pass');
|
||||
expect(status.icon).toBe('check');
|
||||
expect(status.color).toContain('green');
|
||||
expect(status.borderColor).toContain('green');
|
||||
});
|
||||
|
||||
it('returns red for fail', () => {
|
||||
const status = getSecurityStatus('fail');
|
||||
expect(status.icon).toBe('x');
|
||||
expect(status.color).toContain('red');
|
||||
});
|
||||
|
||||
it('returns red for permerror', () => {
|
||||
const status = getSecurityStatus('permerror');
|
||||
expect(status.icon).toBe('x');
|
||||
expect(status.color).toContain('red');
|
||||
});
|
||||
|
||||
it('returns amber for softfail', () => {
|
||||
const status = getSecurityStatus('softfail');
|
||||
expect(status.icon).toBe('alert');
|
||||
expect(status.color).toContain('amber');
|
||||
});
|
||||
|
||||
it('returns amber for neutral and temperror', () => {
|
||||
expect(getSecurityStatus('neutral').icon).toBe('alert');
|
||||
expect(getSecurityStatus('temperror').icon).toBe('alert');
|
||||
});
|
||||
|
||||
it('returns gray for undefined', () => {
|
||||
const status = getSecurityStatus(undefined);
|
||||
expect(status.icon).toBe('minus');
|
||||
expect(status.color).toContain('gray');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSpamLLM', () => {
|
||||
it('parses LEGITIMATE verdict', () => {
|
||||
expect(parseSpamLLM('LEGITIMATE (This is a normal email)')).toEqual({
|
||||
verdict: 'LEGITIMATE',
|
||||
explanation: 'This is a normal email',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses SPAM verdict', () => {
|
||||
expect(parseSpamLLM('SPAM (Unsolicited bulk message)')).toEqual({
|
||||
verdict: 'SPAM',
|
||||
explanation: 'Unsolicited bulk message',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses SUSPICIOUS verdict', () => {
|
||||
expect(parseSpamLLM('SUSPICIOUS (Possible phishing attempt)')).toEqual({
|
||||
verdict: 'SUSPICIOUS',
|
||||
explanation: 'Possible phishing attempt',
|
||||
});
|
||||
});
|
||||
|
||||
it('is case-insensitive for verdict keyword', () => {
|
||||
expect(parseSpamLLM('legitimate (test)')).toEqual({
|
||||
verdict: 'LEGITIMATE',
|
||||
explanation: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unrecognized format', () => {
|
||||
expect(parseSpamLLM('some random header')).toBeNull();
|
||||
expect(parseSpamLLM('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractListHeaders', () => {
|
||||
it('extracts List-Id', () => {
|
||||
const result = extractListHeaders({ 'List-Id': 'My Newsletter <list.example.com>' });
|
||||
expect(result.listId).toBe('My Newsletter <list.example.com>');
|
||||
});
|
||||
|
||||
it('extracts List-Unsubscribe with HTTP URL', () => {
|
||||
const result = extractListHeaders({
|
||||
'List-Unsubscribe': '<https://example.com/unsubscribe?id=123>',
|
||||
});
|
||||
expect(result.listUnsubscribe?.http).toBe('https://example.com/unsubscribe?id=123');
|
||||
expect(result.listUnsubscribe?.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('extracts List-Unsubscribe with both HTTP and mailto', () => {
|
||||
const result = extractListHeaders({
|
||||
'List-Unsubscribe': '<https://example.com/unsub>, <mailto:unsub@example.com>',
|
||||
});
|
||||
expect(result.listUnsubscribe?.http).toBe('https://example.com/unsub');
|
||||
expect(result.listUnsubscribe?.mailto).toBe('mailto:unsub@example.com');
|
||||
expect(result.listUnsubscribe?.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('handles array header values', () => {
|
||||
const result = extractListHeaders({
|
||||
'List-Id': ['Newsletter <list.example.com>', 'fallback'],
|
||||
});
|
||||
expect(result.listId).toBe('Newsletter <list.example.com>');
|
||||
});
|
||||
|
||||
it('returns empty object when no list headers present', () => {
|
||||
expect(extractListHeaders({})).toEqual({});
|
||||
expect(extractListHeaders({ 'Subject': 'hello' })).toEqual({});
|
||||
});
|
||||
|
||||
it('extracts List-Help and List-Post', () => {
|
||||
const result = extractListHeaders({
|
||||
'List-Help': '<mailto:help@example.com>',
|
||||
'List-Post': '<mailto:post@example.com>',
|
||||
});
|
||||
expect(result.listHelp).toBe('<mailto:help@example.com>');
|
||||
expect(result.listPost).toBe('<mailto:post@example.com>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
const mockContact = {
|
||||
id: 'contact-1',
|
||||
addressBookIds: { 'ab-1': true },
|
||||
name: { components: [{ kind: 'given' as const, value: 'John' }, { kind: 'surname' as const, value: 'Doe' }], isOrdered: true },
|
||||
emails: { e0: { address: 'john@example.com' } },
|
||||
};
|
||||
|
||||
const mockAddressBook = {
|
||||
id: 'ab-1',
|
||||
name: 'Default',
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
function createClient(): JMAPClient {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
|
||||
Object.assign(client, {
|
||||
apiUrl: 'https://jmap.example.com/api',
|
||||
accountId: 'account-1',
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function mockFetch(response: object, ok = true, status = 200) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
text: () => Promise.resolve(JSON.stringify(response)),
|
||||
json: () => Promise.resolve(response),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
function mockFetchOnce(spy: ReturnType<typeof vi.spyOn>, response: object) {
|
||||
spy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify(response)),
|
||||
json: () => Promise.resolve(response),
|
||||
} as Response);
|
||||
return spy;
|
||||
}
|
||||
|
||||
describe('JMAPClient contact methods', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('supportsContacts', () => {
|
||||
it('should return true when contacts capability exists', () => {
|
||||
const client = createClient();
|
||||
Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:contacts': {} } });
|
||||
expect(client.supportsContacts()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when contacts capability is missing', () => {
|
||||
const client = createClient();
|
||||
Object.assign(client, { capabilities: {} });
|
||||
expect(client.supportsContacts()).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw when capabilities is undefined', () => {
|
||||
const client = createClient();
|
||||
Object.assign(client, { capabilities: undefined });
|
||||
expect(() => client.supportsContacts()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAddressBooks', () => {
|
||||
it('should return address books from server', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getAddressBooks();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('ab-1');
|
||||
expect(result[0].name).toBe('Default');
|
||||
});
|
||||
|
||||
it('should return empty array when no address books', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['AddressBook/get', { list: [] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getAddressBooks();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on network error', async () => {
|
||||
const client = createClient();
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await client.getAddressBooks();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for unexpected response method', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getAddressBooks();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when list is missing', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['AddressBook/get', {}, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getAddressBooks();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContacts', () => {
|
||||
it('should return contacts from server', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: ['contact-1'] }, '0'],
|
||||
['ContactCard/get', { list: [mockContact] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await client.getContacts();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('contact-1');
|
||||
});
|
||||
|
||||
it('should filter by addressBookId when provided', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: ['contact-1'] }, '0'],
|
||||
['ContactCard/get', { list: [mockContact] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
await client.getContacts('ab-1');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body.methodCalls[0][1].filter).toEqual({ inAddressBook: 'ab-1' });
|
||||
});
|
||||
|
||||
it('should not include filter when no addressBookId', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: [] }, '0'],
|
||||
['ContactCard/get', { list: [] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
await client.getContacts();
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body.methodCalls[0][1].filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty array when no contacts', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: [] }, '0'],
|
||||
['ContactCard/get', { list: [] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await client.getContacts();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on network error', async () => {
|
||||
const client = createClient();
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await client.getContacts();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for unexpected response at index 1', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: [] }, '0'],
|
||||
['SomethingElse', {}, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await client.getContacts();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContact', () => {
|
||||
it('should return a single contact', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getContact('contact-1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('contact-1');
|
||||
});
|
||||
|
||||
it('should pass contact id in the request', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = mockFetch({
|
||||
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
|
||||
});
|
||||
|
||||
await client.getContact('contact-1');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body.methodCalls[0][1].ids).toEqual(['contact-1']);
|
||||
});
|
||||
|
||||
it('should return null when contact not found', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/get', { list: [] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getContact('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
const client = createClient();
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await client.getContact('contact-1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unexpected response method', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getContact('contact-1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContact', () => {
|
||||
it('should create contact and refetch full object', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
// 1: getAddressBooks
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
|
||||
});
|
||||
// 2: ContactCard/set
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
|
||||
});
|
||||
// 3: getContact refetch
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.createContact({ name: mockContact.name, emails: mockContact.emails });
|
||||
expect(result.id).toBe('new-id');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should skip getAddressBooks when addressBookIds provided', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
// 1: ContactCard/set (no getAddressBooks needed)
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
|
||||
});
|
||||
// 2: getContact refetch
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.createContact({
|
||||
name: mockContact.name,
|
||||
addressBookIds: { 'ab-1': true },
|
||||
});
|
||||
expect(result.id).toBe('new-id');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw on notCreated error with description', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
|
||||
});
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/set', {
|
||||
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'Missing required fields' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createContact({ name: mockContact.name }))
|
||||
.rejects.toThrow('Missing required fields');
|
||||
});
|
||||
|
||||
it('should throw generic error when notCreated has no description', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
|
||||
});
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/set', {
|
||||
notCreated: { 'new-contact': { type: 'forbidden' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createContact({ name: mockContact.name }))
|
||||
.rejects.toThrow('Failed to create contact');
|
||||
});
|
||||
|
||||
it('should throw on unexpected response method', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
|
||||
});
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createContact({ name: mockContact.name }))
|
||||
.rejects.toThrow('Failed to create contact');
|
||||
});
|
||||
|
||||
it('should throw when created id is missing', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
|
||||
});
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/set', { created: {} }, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createContact({ name: mockContact.name }))
|
||||
.rejects.toThrow('Failed to create contact');
|
||||
});
|
||||
|
||||
it('should throw when refetch returns null', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
|
||||
});
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
|
||||
});
|
||||
mockFetchOnce(fetchSpy, {
|
||||
methodResponses: [['ContactCard/get', { list: [] }, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createContact({ name: mockContact.name }))
|
||||
.rejects.toThrow('Failed to create contact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateContact', () => {
|
||||
it('should update contact successfully', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateContact('contact-1', { name: mockContact.name })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass updates in the request body', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = mockFetch({
|
||||
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
|
||||
});
|
||||
|
||||
const updates = { name: { components: [{ kind: 'given' as const, value: 'Jane' }], isOrdered: true } };
|
||||
await client.updateContact('contact-1', updates);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body.methodCalls[0][1].update['contact-1']).toEqual(updates);
|
||||
});
|
||||
|
||||
it('should throw on notUpdated error with description', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/set', {
|
||||
notUpdated: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateContact('contact-1', { name: mockContact.name }))
|
||||
.rejects.toThrow('Contact not found');
|
||||
});
|
||||
|
||||
it('should throw generic error when notUpdated has no description', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/set', {
|
||||
notUpdated: { 'contact-1': { type: 'forbidden' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateContact('contact-1', { name: mockContact.name }))
|
||||
.rejects.toThrow('Failed to update contact');
|
||||
});
|
||||
|
||||
it('should throw on unexpected response method', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateContact('contact-1', { name: mockContact.name }))
|
||||
.rejects.toThrow('Failed to update contact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteContact', () => {
|
||||
it('should delete contact successfully', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteContact('contact-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass contact id in destroy array', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = mockFetch({
|
||||
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
|
||||
});
|
||||
|
||||
await client.deleteContact('contact-1');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body.methodCalls[0][1].destroy).toEqual(['contact-1']);
|
||||
});
|
||||
|
||||
it('should throw on notDestroyed error with description', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/set', {
|
||||
notDestroyed: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteContact('contact-1'))
|
||||
.rejects.toThrow('Contact not found');
|
||||
});
|
||||
|
||||
it('should throw generic error when notDestroyed has no description', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['ContactCard/set', {
|
||||
notDestroyed: { 'contact-1': { type: 'forbidden' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteContact('contact-1'))
|
||||
.rejects.toThrow('Failed to delete contact');
|
||||
});
|
||||
|
||||
it('should throw on unexpected response method', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteContact('contact-1'))
|
||||
.rejects.toThrow('Failed to delete contact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchContacts', () => {
|
||||
it('should return matching contacts', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: ['contact-1'] }, '0'],
|
||||
['ContactCard/get', { list: [mockContact] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await client.searchContacts('John');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('contact-1');
|
||||
});
|
||||
|
||||
it('should pass query as text filter', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: [] }, '0'],
|
||||
['ContactCard/get', { list: [] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
await client.searchContacts('Jane');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body.methodCalls[0][1].filter).toEqual({ text: 'Jane' });
|
||||
});
|
||||
|
||||
it('should return empty array when no results', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: [] }, '0'],
|
||||
['ContactCard/get', { list: [] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await client.searchContacts('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on network error', async () => {
|
||||
const client = createClient();
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await client.searchContacts('John');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for unexpected response at index 1', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: [] }, '0'],
|
||||
['SomethingElse', {}, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await client.searchContacts('John');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
const mockIdentity = {
|
||||
id: 'id-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
mayDelete: true,
|
||||
};
|
||||
|
||||
function createClient(): JMAPClient {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
|
||||
// Set internal state so request() doesn't throw "Not connected"
|
||||
Object.assign(client, {
|
||||
apiUrl: 'https://jmap.example.com/api',
|
||||
accountId: 'account-1',
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function mockFetch(response: object, ok = true, status = 200) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
text: () => Promise.resolve(JSON.stringify(response)),
|
||||
json: () => Promise.resolve(response),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe('JMAPClient identity methods', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getIdentities', () => {
|
||||
it('should return identities from server', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/get', { list: [mockIdentity] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getIdentities();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('id-1');
|
||||
expect(result[0].email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should return empty array when no identities', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/get', { list: [] }, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getIdentities();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on network error', async () => {
|
||||
const client = createClient();
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await client.getIdentities();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for unexpected response', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getIdentities();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing list property', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/get', {}, '0']],
|
||||
});
|
||||
|
||||
const result = await client.getIdentities();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIdentity', () => {
|
||||
it('should create identity and return full object', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
// First call: Identity/set returns created id
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
|
||||
})),
|
||||
json: () => Promise.resolve({
|
||||
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
// Second call: getIdentities fetches full object
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
|
||||
})),
|
||||
json: () => Promise.resolve({
|
||||
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await client.createIdentity('Test User', 'test@example.com');
|
||||
expect(result.id).toBe('new-id');
|
||||
});
|
||||
|
||||
it('should throw on forbidden error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notCreated: { 'new-identity': { type: 'forbidden' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createIdentity('Test', 'test@example.com'))
|
||||
.rejects.toThrow('not authorized');
|
||||
});
|
||||
|
||||
it('should throw on generic creation error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notCreated: { 'new-identity': { type: 'invalidProperties', description: 'Bad input' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createIdentity('Test', 'test@example.com'))
|
||||
.rejects.toThrow('Bad input');
|
||||
});
|
||||
|
||||
it('should throw on unexpected response', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.createIdentity('Test', 'test@example.com'))
|
||||
.rejects.toThrow('unexpected');
|
||||
});
|
||||
|
||||
it('should pass all parameters to the request', async () => {
|
||||
const client = createClient();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
|
||||
})),
|
||||
json: () => Promise.resolve({
|
||||
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
|
||||
})),
|
||||
json: () => Promise.resolve({
|
||||
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const replyTo = [{ name: 'Reply', email: 'reply@example.com' }];
|
||||
const bcc = [{ email: 'bcc@example.com' }];
|
||||
|
||||
await client.createIdentity('Test', 'test@example.com', replyTo, bcc, 'text sig', '<b>html sig</b>');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
const createArgs = body.methodCalls[0][1].create['new-identity'];
|
||||
expect(createArgs.name).toBe('Test');
|
||||
expect(createArgs.email).toBe('test@example.com');
|
||||
expect(createArgs.replyTo).toEqual(replyTo);
|
||||
expect(createArgs.bcc).toEqual(bcc);
|
||||
expect(createArgs.textSignature).toBe('text sig');
|
||||
expect(createArgs.htmlSignature).toBe('<b>html sig</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIdentity', () => {
|
||||
it('should update identity successfully', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', { updated: { 'id-1': null } }, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateIdentity('id-1', { name: 'New Name' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw on notFound error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notUpdated: { 'id-1': { type: 'notFound' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateIdentity('id-1', { name: 'X' }))
|
||||
.rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
it('should throw on forbidden error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notUpdated: { 'id-1': { type: 'forbidden' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateIdentity('id-1', { name: 'X' }))
|
||||
.rejects.toThrow('not authorized');
|
||||
});
|
||||
|
||||
it('should throw on generic update error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notUpdated: { 'id-1': { type: 'other', description: 'Server error' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateIdentity('id-1', { name: 'X' }))
|
||||
.rejects.toThrow('Server error');
|
||||
});
|
||||
|
||||
it('should throw on unexpected response', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.updateIdentity('id-1', { name: 'X' }))
|
||||
.rejects.toThrow('unexpected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIdentity', () => {
|
||||
it('should delete identity successfully', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', { destroyed: ['id-1'] }, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteIdentity('id-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw on forbidden error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notDestroyed: { 'id-1': { type: 'forbidden' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteIdentity('id-1'))
|
||||
.rejects.toThrow('cannot be deleted');
|
||||
});
|
||||
|
||||
it('should throw on notFound error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notDestroyed: { 'id-1': { type: 'notFound' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteIdentity('id-1'))
|
||||
.rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
it('should throw on generic delete error', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['Identity/set', {
|
||||
notDestroyed: { 'id-1': { type: 'other', description: 'Cannot remove' } },
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteIdentity('id-1'))
|
||||
.rejects.toThrow('Cannot remove');
|
||||
});
|
||||
|
||||
it('should throw on unexpected response', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
methodResponses: [['SomethingElse', {}, '0']],
|
||||
});
|
||||
|
||||
await expect(client.deleteIdentity('id-1'))
|
||||
.rejects.toThrow('unexpected');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseSubAddress,
|
||||
generateSubAddress,
|
||||
extractDomain,
|
||||
suggestTagsForDomain,
|
||||
isValidTag,
|
||||
getTagValidationError,
|
||||
MAX_TAG_LENGTH,
|
||||
} from '../sub-addressing';
|
||||
|
||||
describe('parseSubAddress', () => {
|
||||
describe('standard addresses', () => {
|
||||
it('should parse email with tag', () => {
|
||||
const result = parseSubAddress('user+shopping@example.com');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBe('shopping');
|
||||
expect(result.domain).toBe('example.com');
|
||||
expect(result.localPart).toBe('user+shopping');
|
||||
expect(result.fullAddress).toBe('user+shopping@example.com');
|
||||
});
|
||||
|
||||
it('should parse email with alphanumeric tag', () => {
|
||||
const result = parseSubAddress('john+news2024@domain.co.uk');
|
||||
expect(result.baseUser).toBe('john');
|
||||
expect(result.tag).toBe('news2024');
|
||||
expect(result.domain).toBe('domain.co.uk');
|
||||
});
|
||||
|
||||
it('should parse email with dash in tag', () => {
|
||||
const result = parseSubAddress('alice+my-orders@shop.com');
|
||||
expect(result.tag).toBe('my-orders');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no tag', () => {
|
||||
it('should handle email without plus sign', () => {
|
||||
const result = parseSubAddress('user@example.com');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBeNull();
|
||||
expect(result.domain).toBe('example.com');
|
||||
expect(result.localPart).toBe('user');
|
||||
});
|
||||
|
||||
it('should handle dotted local part without tag', () => {
|
||||
const result = parseSubAddress('first.last@example.com');
|
||||
expect(result.baseUser).toBe('first.last');
|
||||
expect(result.tag).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple plus signs', () => {
|
||||
it('should use first plus as separator', () => {
|
||||
const result = parseSubAddress('user+tag1+tag2@example.com');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBe('tag1+tag2');
|
||||
expect(result.localPart).toBe('user+tag1+tag2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty tag', () => {
|
||||
it('should return null tag for trailing plus', () => {
|
||||
const result = parseSubAddress('user+@example.com');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing domain', () => {
|
||||
const result = parseSubAddress('user');
|
||||
expect(result.localPart).toBe('user');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBeNull();
|
||||
expect(result.domain).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing local part', () => {
|
||||
const result = parseSubAddress('@example.com');
|
||||
expect(result.localPart).toBe('');
|
||||
expect(result.baseUser).toBe('');
|
||||
expect(result.tag).toBeNull();
|
||||
expect(result.domain).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = parseSubAddress('');
|
||||
expect(result.localPart).toBe('');
|
||||
expect(result.baseUser).toBe('');
|
||||
expect(result.tag).toBeNull();
|
||||
expect(result.domain).toBe('');
|
||||
});
|
||||
|
||||
it('should preserve full address', () => {
|
||||
const email = 'test+dev@mail.example.org';
|
||||
const result = parseSubAddress(email);
|
||||
expect(result.fullAddress).toBe(email);
|
||||
});
|
||||
|
||||
it('should handle plus at start of local part', () => {
|
||||
const result = parseSubAddress('+tag@example.com');
|
||||
expect(result.baseUser).toBe('');
|
||||
expect(result.tag).toBe('tag');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSubAddress', () => {
|
||||
describe('basic generation', () => {
|
||||
it('should generate tagged address', () => {
|
||||
expect(generateSubAddress('user@example.com', 'shopping')).toBe('user+shopping@example.com');
|
||||
});
|
||||
|
||||
it('should lowercase the tag', () => {
|
||||
expect(generateSubAddress('user@example.com', 'Shopping')).toBe('user+shopping@example.com');
|
||||
});
|
||||
|
||||
it('should allow dashes in tag', () => {
|
||||
expect(generateSubAddress('user@example.com', 'my-orders')).toBe('user+my-orders@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace existing tag', () => {
|
||||
it('should replace existing tag with new one', () => {
|
||||
expect(generateSubAddress('user+old@example.com', 'new')).toBe('user+new@example.com');
|
||||
});
|
||||
|
||||
it('should replace complex existing tag', () => {
|
||||
expect(generateSubAddress('user+tag1+tag2@example.com', 'fresh')).toBe('user+fresh@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty or invalid tag', () => {
|
||||
it('should return original email for empty tag', () => {
|
||||
expect(generateSubAddress('user@example.com', '')).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('should return original email for tag with only invalid chars', () => {
|
||||
expect(generateSubAddress('user@example.com', '!@#$%')).toBe('user@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag sanitization', () => {
|
||||
it('should strip special characters from tag', () => {
|
||||
expect(generateSubAddress('user@example.com', 'my_tag!')).toBe('user+mytag@example.com');
|
||||
});
|
||||
|
||||
it('should strip spaces from tag', () => {
|
||||
expect(generateSubAddress('user@example.com', 'my tag')).toBe('user+mytag@example.com');
|
||||
});
|
||||
|
||||
it('should keep alphanumeric and dash', () => {
|
||||
expect(generateSubAddress('user@example.com', 'valid-tag-123')).toBe('user+valid-tag-123@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing domain', () => {
|
||||
it('should return original for email without domain', () => {
|
||||
expect(generateSubAddress('user', 'tag')).toBe('user');
|
||||
});
|
||||
|
||||
it('should return original for email without local part', () => {
|
||||
expect(generateSubAddress('@example.com', 'tag')).toBe('@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractDomain', () => {
|
||||
it('should extract domain from standard email', () => {
|
||||
expect(extractDomain('user@example.com')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should extract domain from sub-addressed email', () => {
|
||||
expect(extractDomain('user+tag@mail.example.org')).toBe('mail.example.org');
|
||||
});
|
||||
|
||||
it('should normalize domain to lowercase', () => {
|
||||
expect(extractDomain('user@EXAMPLE.COM')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should return null for email without @', () => {
|
||||
expect(extractDomain('nodomain')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
expect(extractDomain('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple @ symbols', () => {
|
||||
expect(extractDomain('user@host@example.com')).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestTagsForDomain', () => {
|
||||
describe('known domains', () => {
|
||||
it('should return suggestions for amazon.com', () => {
|
||||
const tags = suggestTagsForDomain('amazon.com');
|
||||
expect(tags).toContain('amazon');
|
||||
expect(tags).toContain('shopping');
|
||||
expect(tags).toContain('orders');
|
||||
});
|
||||
|
||||
it('should return suggestions for github.com', () => {
|
||||
const tags = suggestTagsForDomain('github.com');
|
||||
expect(tags).toContain('github');
|
||||
expect(tags).toContain('dev');
|
||||
expect(tags).toContain('notifications');
|
||||
});
|
||||
|
||||
it('should return suggestions for paypal.com', () => {
|
||||
const tags = suggestTagsForDomain('paypal.com');
|
||||
expect(tags).toContain('paypal');
|
||||
expect(tags).toContain('payments');
|
||||
});
|
||||
|
||||
it('should return suggestions for netflix.com', () => {
|
||||
const tags = suggestTagsForDomain('netflix.com');
|
||||
expect(tags).toContain('netflix');
|
||||
expect(tags).toContain('entertainment');
|
||||
});
|
||||
|
||||
it('should return suggestions for regional Amazon domains', () => {
|
||||
expect(suggestTagsForDomain('amazon.fr')).toContain('amazon');
|
||||
expect(suggestTagsForDomain('amazon.de')).toContain('shopping');
|
||||
expect(suggestTagsForDomain('amazon.co.uk')).toContain('orders');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown domains', () => {
|
||||
it('should return generic suggestions with domain name', () => {
|
||||
const tags = suggestTagsForDomain('randomsite.com');
|
||||
expect(tags).toContain('randomsite');
|
||||
expect(tags).toContain('newsletter');
|
||||
expect(tags).toContain('registration');
|
||||
});
|
||||
|
||||
it('should extract main domain from multi-part TLD', () => {
|
||||
const tags = suggestTagsForDomain('unknown.co.uk');
|
||||
expect(tags[0]).toBe('co');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subdomains', () => {
|
||||
it('should extract main domain from subdomain', () => {
|
||||
const tags = suggestTagsForDomain('mail.google.com');
|
||||
expect(tags[0]).toBe('google');
|
||||
});
|
||||
|
||||
it('should extract main domain from deep subdomain', () => {
|
||||
const tags = suggestTagsForDomain('smtp.mail.provider.com');
|
||||
expect(tags[0]).toBe('provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('case-insensitive matching', () => {
|
||||
it('should match known domains case-insensitively', () => {
|
||||
expect(suggestTagsForDomain('GITHUB.COM')).toContain('github');
|
||||
expect(suggestTagsForDomain('GitHub.com')).toContain('github');
|
||||
});
|
||||
|
||||
it('should match regional domains case-insensitively', () => {
|
||||
expect(suggestTagsForDomain('AMAZON.FR')).toContain('amazon');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidTag', () => {
|
||||
describe('valid tags', () => {
|
||||
it('should accept lowercase letters', () => {
|
||||
expect(isValidTag('shopping')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept uppercase letters', () => {
|
||||
expect(isValidTag('Shopping')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept numbers', () => {
|
||||
expect(isValidTag('tag123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept dashes', () => {
|
||||
expect(isValidTag('my-tag')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept single character', () => {
|
||||
expect(isValidTag('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept max length tag', () => {
|
||||
expect(isValidTag('a'.repeat(MAX_TAG_LENGTH))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid tags', () => {
|
||||
it('should reject empty string', () => {
|
||||
expect(isValidTag('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject underscores', () => {
|
||||
expect(isValidTag('my_tag')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject dots', () => {
|
||||
expect(isValidTag('my.tag')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject spaces', () => {
|
||||
expect(isValidTag('my tag')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject special characters', () => {
|
||||
expect(isValidTag('tag!')).toBe(false);
|
||||
expect(isValidTag('tag@')).toBe(false);
|
||||
expect(isValidTag('tag#')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject tag exceeding max length', () => {
|
||||
expect(isValidTag('a'.repeat(MAX_TAG_LENGTH + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTagValidationError', () => {
|
||||
it('should return null for valid tag', () => {
|
||||
expect(getTagValidationError('shopping')).toBeNull();
|
||||
expect(getTagValidationError('my-tag-123')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return EMPTY for empty string', () => {
|
||||
expect(getTagValidationError('')).toBe('EMPTY');
|
||||
});
|
||||
|
||||
it('should return TOO_LONG for oversized tag', () => {
|
||||
expect(getTagValidationError('a'.repeat(MAX_TAG_LENGTH + 1))).toBe('TOO_LONG');
|
||||
});
|
||||
|
||||
it('should return INVALID_CHARS for special characters', () => {
|
||||
expect(getTagValidationError('tag!')).toBe('INVALID_CHARS');
|
||||
expect(getTagValidationError('tag with spaces')).toBe('INVALID_CHARS');
|
||||
expect(getTagValidationError('tag_underscore')).toBe('INVALID_CHARS');
|
||||
});
|
||||
|
||||
it('should check length before characters', () => {
|
||||
const longInvalid = '!'.repeat(MAX_TAG_LENGTH + 1);
|
||||
expect(getTagValidationError(longInvalid)).toBe('TOO_LONG');
|
||||
});
|
||||
|
||||
it('should return null for boundary-length valid tag', () => {
|
||||
expect(getTagValidationError('a'.repeat(MAX_TAG_LENGTH))).toBeNull();
|
||||
});
|
||||
|
||||
it('should return INVALID_CHARS for unicode characters', () => {
|
||||
expect(getTagValidationError('café')).toBe('INVALID_CHARS');
|
||||
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
groupEmailsByThread,
|
||||
sortThreadGroups,
|
||||
getThreadParticipants,
|
||||
mergeThreadEmails,
|
||||
getEmailColorTag,
|
||||
getThreadColorTag,
|
||||
} from '../thread-utils';
|
||||
import type { Email, ThreadGroup } from '../jmap/types';
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('groupEmailsByThread', () => {
|
||||
it('groups emails by threadId', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', threadId: 'thread-1' }),
|
||||
makeEmail({ id: 'e2', threadId: 'thread-1' }),
|
||||
makeEmail({ id: 'e3', threadId: 'thread-2' }),
|
||||
];
|
||||
const groups = groupEmailsByThread(emails);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.find(g => g.threadId === 'thread-1')!.emailCount).toBe(2);
|
||||
expect(groups.find(g => g.threadId === 'thread-2')!.emailCount).toBe(1);
|
||||
});
|
||||
|
||||
it('sorts emails within group by receivedAt descending', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', threadId: 'thread-1', receivedAt: '2024-01-10T10:00:00Z' }),
|
||||
makeEmail({ id: 'e2', threadId: 'thread-1', receivedAt: '2024-01-15T10:00:00Z' }),
|
||||
makeEmail({ id: 'e3', threadId: 'thread-1', receivedAt: '2024-01-12T10:00:00Z' }),
|
||||
];
|
||||
const group = groupEmailsByThread(emails)[0];
|
||||
expect(group.emails[0].id).toBe('e2');
|
||||
expect(group.emails[1].id).toBe('e3');
|
||||
expect(group.emails[2].id).toBe('e1');
|
||||
});
|
||||
|
||||
it('sets latestEmail to the newest email', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'old', threadId: 'thread-1', receivedAt: '2024-01-01T00:00:00Z' }),
|
||||
makeEmail({ id: 'new', threadId: 'thread-1', receivedAt: '2024-06-01T00:00:00Z' }),
|
||||
];
|
||||
expect(groupEmailsByThread(emails)[0].latestEmail.id).toBe('new');
|
||||
});
|
||||
|
||||
it('calculates participantNames from unique senders', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
||||
makeEmail({ id: 'e2', from: [{ name: 'Bob', email: 'bob@example.com' }] }),
|
||||
makeEmail({ id: 'e3', from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
||||
];
|
||||
const group = groupEmailsByThread(emails)[0];
|
||||
expect(group.participantNames).toEqual(['Alice', 'Bob']);
|
||||
});
|
||||
|
||||
it('detects hasUnread when an email lacks $seen', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: {} }),
|
||||
];
|
||||
expect(groupEmailsByThread(emails)[0].hasUnread).toBe(true);
|
||||
});
|
||||
|
||||
it('detects hasStarred when an email has $flagged', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { $seen: true, $flagged: true } }),
|
||||
];
|
||||
expect(groupEmailsByThread(emails)[0].hasStarred).toBe(true);
|
||||
});
|
||||
|
||||
it('detects hasAttachment', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', hasAttachment: false }),
|
||||
makeEmail({ id: 'e2', hasAttachment: true }),
|
||||
];
|
||||
expect(groupEmailsByThread(emails)[0].hasAttachment).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(groupEmailsByThread([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for null/undefined input', () => {
|
||||
expect(groupEmailsByThread(null as unknown as Email[])).toEqual([]);
|
||||
expect(groupEmailsByThread(undefined as unknown as Email[])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortThreadGroups', () => {
|
||||
it('sorts groups by latestEmail.receivedAt descending', () => {
|
||||
const groups: ThreadGroup[] = [
|
||||
{
|
||||
threadId: 'old',
|
||||
emails: [makeEmail({ receivedAt: '2024-01-01T00:00:00Z' })],
|
||||
latestEmail: makeEmail({ receivedAt: '2024-01-01T00:00:00Z' }),
|
||||
participantNames: ['A'],
|
||||
hasUnread: false,
|
||||
hasStarred: false,
|
||||
hasAttachment: false,
|
||||
emailCount: 1,
|
||||
},
|
||||
{
|
||||
threadId: 'new',
|
||||
emails: [makeEmail({ receivedAt: '2024-06-01T00:00:00Z' })],
|
||||
latestEmail: makeEmail({ receivedAt: '2024-06-01T00:00:00Z' }),
|
||||
participantNames: ['B'],
|
||||
hasUnread: false,
|
||||
hasStarred: false,
|
||||
hasAttachment: false,
|
||||
emailCount: 1,
|
||||
},
|
||||
];
|
||||
const sorted = sortThreadGroups(groups);
|
||||
expect(sorted[0].threadId).toBe('new');
|
||||
expect(sorted[1].threadId).toBe('old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadParticipants', () => {
|
||||
it('extracts unique sender names', () => {
|
||||
const emails = [
|
||||
makeEmail({ from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
||||
makeEmail({ from: [{ name: 'Bob', email: 'bob@example.com' }] }),
|
||||
makeEmail({ from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
||||
];
|
||||
expect(getThreadParticipants(emails)).toEqual(['Alice', 'Bob']);
|
||||
});
|
||||
|
||||
it('respects maxNames limit', () => {
|
||||
const emails = [
|
||||
makeEmail({ from: [{ name: 'A', email: 'a@x.com' }] }),
|
||||
makeEmail({ from: [{ name: 'B', email: 'b@x.com' }] }),
|
||||
makeEmail({ from: [{ name: 'C', email: 'c@x.com' }] }),
|
||||
];
|
||||
expect(getThreadParticipants(emails, 2)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('uses email prefix when name is empty', () => {
|
||||
const emails = [
|
||||
makeEmail({ from: [{ name: '', email: 'charlie@example.com' }] }),
|
||||
];
|
||||
expect(getThreadParticipants(emails)).toEqual(['charlie']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeThreadEmails', () => {
|
||||
it('merges new emails without duplicating existing ones', () => {
|
||||
const existing: ThreadGroup = {
|
||||
threadId: 'thread-1',
|
||||
emails: [
|
||||
makeEmail({ id: 'e1', receivedAt: '2024-01-10T00:00:00Z' }),
|
||||
makeEmail({ id: 'e2', receivedAt: '2024-01-09T00:00:00Z' }),
|
||||
],
|
||||
latestEmail: makeEmail({ id: 'e1', receivedAt: '2024-01-10T00:00:00Z' }),
|
||||
participantNames: ['Alice'],
|
||||
hasUnread: false,
|
||||
hasStarred: false,
|
||||
hasAttachment: false,
|
||||
emailCount: 2,
|
||||
};
|
||||
const fetched = [
|
||||
makeEmail({ id: 'e2', receivedAt: '2024-01-09T00:00:00Z' }),
|
||||
makeEmail({ id: 'e3', receivedAt: '2024-01-11T00:00:00Z', from: [{ name: 'Bob', email: 'bob@example.com' }] }),
|
||||
];
|
||||
const merged = mergeThreadEmails(existing, fetched);
|
||||
expect(merged.emailCount).toBe(3);
|
||||
expect(merged.emails.map(e => e.id)).toEqual(['e3', 'e1', 'e2']);
|
||||
});
|
||||
|
||||
it('updates thread metadata after merge', () => {
|
||||
const existing: ThreadGroup = {
|
||||
threadId: 'thread-1',
|
||||
emails: [makeEmail({ id: 'e1', keywords: { $seen: true }, hasAttachment: false })],
|
||||
latestEmail: makeEmail({ id: 'e1' }),
|
||||
participantNames: ['Alice'],
|
||||
hasUnread: false,
|
||||
hasStarred: false,
|
||||
hasAttachment: false,
|
||||
emailCount: 1,
|
||||
};
|
||||
const fetched = [
|
||||
makeEmail({
|
||||
id: 'e2',
|
||||
receivedAt: '2024-06-01T00:00:00Z',
|
||||
keywords: { $flagged: true },
|
||||
hasAttachment: true,
|
||||
from: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
}),
|
||||
];
|
||||
const merged = mergeThreadEmails(existing, fetched);
|
||||
expect(merged.latestEmail.id).toBe('e2');
|
||||
expect(merged.hasUnread).toBe(true);
|
||||
expect(merged.hasStarred).toBe(true);
|
||||
expect(merged.hasAttachment).toBe(true);
|
||||
expect(merged.participantNames).toContain('Bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailColorTag', () => {
|
||||
it('returns color from $color: keyword', () => {
|
||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailColorTag(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadColorTag', () => {
|
||||
it('returns first color found across thread emails', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$color:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('blue');
|
||||
});
|
||||
|
||||
it('returns null when no emails have color tags', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { parseVCard, generateVCard, detectDuplicates } from "../vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("crypto", { randomUUID: () => "test-uuid" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("parseVCard", () => {
|
||||
it("parses single vCard with FN (full name)", () => {
|
||||
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEMAIL:john@example.com\r\nEND:VCARD`;
|
||||
const result = parseVCard(vcf);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const card = result[0];
|
||||
expect(card.id).toBe("import-test-uuid");
|
||||
expect(card.name?.components).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "given", value: "John" },
|
||||
{ kind: "surname", value: "Doe" },
|
||||
])
|
||||
);
|
||||
expect(card.emails?.e0?.address).toBe("john@example.com");
|
||||
});
|
||||
|
||||
it("parses vCard with N field (structured name with all components)", () => {
|
||||
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;Michael;Mr.;Jr.\r\nEMAIL:john@example.com\r\nEND:VCARD`;
|
||||
const result = parseVCard(vcf);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const components = result[0].name?.components || [];
|
||||
expect(components).toEqual([
|
||||
{ kind: "prefix", value: "Mr." },
|
||||
{ kind: "given", value: "John" },
|
||||
{ kind: "additional", value: "Michael" },
|
||||
{ kind: "surname", value: "Doe" },
|
||||
{ kind: "suffix", value: "Jr." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("N field overrides FN when both present", () => {
|
||||
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nN:Doe;John;;;\r\nEMAIL:john@example.com\r\nEND:VCARD`;
|
||||
const result = parseVCard(vcf);
|
||||
|
||||
// N comes after FN in raw lines, and N always sets card.name (overwrites FN)
|
||||
const components = result[0].name?.components || [];
|
||||
expect(components.find((c) => c.kind === "given")?.value).toBe("John");
|
||||
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
|
||||
});
|
||||
|
||||
it("parses vCard with phone, org, and address", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:Jane Smith",
|
||||
"EMAIL;TYPE=WORK:jane@work.com",
|
||||
"TEL;TYPE=CELL:+1234567890",
|
||||
"ORG:Acme Corp;Engineering",
|
||||
"ADR;TYPE=WORK:;;123 Main St;City;State;12345;US",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
const card = result[0];
|
||||
|
||||
expect(card.emails?.e0?.address).toBe("jane@work.com");
|
||||
expect(card.emails?.e0?.contexts).toEqual({ work: true });
|
||||
|
||||
expect(card.phones?.p0?.number).toBe("+1234567890");
|
||||
|
||||
expect(card.organizations?.o0?.name).toBe("Acme Corp");
|
||||
expect(card.organizations?.o0?.units).toEqual([{ name: "Engineering" }]);
|
||||
|
||||
expect(card.addresses?.a0).toMatchObject({
|
||||
street: "123 Main St",
|
||||
locality: "City",
|
||||
region: "State",
|
||||
postcode: "12345",
|
||||
country: "US",
|
||||
contexts: { work: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses vCard with nickname, notes, and UID", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:Bob Builder",
|
||||
"NICKNAME:Bobby",
|
||||
"NOTE:Important person",
|
||||
"UID:abc-123",
|
||||
"EMAIL:bob@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
const card = result[0];
|
||||
|
||||
expect(card.nicknames?.n0?.name).toBe("Bobby");
|
||||
expect(card.notes?.n0?.note).toBe("Important person");
|
||||
expect(card.uid).toBe("abc-123");
|
||||
});
|
||||
|
||||
it("parses multi-contact vCard file", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:Alice",
|
||||
"EMAIL:alice@example.com",
|
||||
"END:VCARD",
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:Bob",
|
||||
"EMAIL:bob@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].emails?.e0?.address).toBe("alice@example.com");
|
||||
expect(result[1].emails?.e0?.address).toBe("bob@example.com");
|
||||
});
|
||||
|
||||
it("skips malformed vCards without name or email", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"NOTE:Just a note",
|
||||
"END:VCARD",
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:Valid Contact",
|
||||
"EMAIL:valid@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].emails?.e0?.address).toBe("valid@example.com");
|
||||
});
|
||||
|
||||
it("parses vCard with group kind and members", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:Team Alpha",
|
||||
"KIND:group",
|
||||
"MEMBER:urn:uuid:member-1",
|
||||
"MEMBER:urn:uuid:member-2",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
const card = result[0];
|
||||
|
||||
expect(card.kind).toBe("group");
|
||||
expect(card.members).toEqual({ "member-1": true, "member-2": true });
|
||||
});
|
||||
|
||||
it("handles folded lines (continuation with leading space)", () => {
|
||||
const vcf =
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John\r\n Doe\r\nEMAIL:john@example.com\r\nEND:VCARD";
|
||||
const result = parseVCard(vcf);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name?.components).toEqual(
|
||||
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
|
||||
);
|
||||
});
|
||||
|
||||
it("handles escaped characters", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:Test User",
|
||||
"NOTE:Line one\\nLine two\\, with comma\\; and semicolon\\\\backslash",
|
||||
"EMAIL:test@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result[0].notes?.n0?.note).toBe(
|
||||
"Line one\nLine two, with comma; and semicolon\\backslash"
|
||||
);
|
||||
});
|
||||
|
||||
it("allows group kind without name or email", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"KIND:group",
|
||||
"MEMBER:urn:uuid:m1",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].kind).toBe("group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateVCard", () => {
|
||||
it("exports single contact with all fields", () => {
|
||||
const contact: ContactCard = {
|
||||
id: "c1",
|
||||
uid: "uid-1",
|
||||
addressBookIds: { ab1: true },
|
||||
kind: "individual",
|
||||
name: {
|
||||
components: [
|
||||
{ kind: "prefix", value: "Dr." },
|
||||
{ kind: "given", value: "Jane" },
|
||||
{ kind: "additional", value: "Marie" },
|
||||
{ kind: "surname", value: "Smith" },
|
||||
{ kind: "suffix", value: "PhD" },
|
||||
],
|
||||
isOrdered: true,
|
||||
},
|
||||
emails: {
|
||||
e0: { address: "jane@work.com", contexts: { work: true } },
|
||||
e1: { address: "jane@home.com", contexts: { private: true } },
|
||||
},
|
||||
phones: { p0: { number: "+1234567890", contexts: { work: true } } },
|
||||
organizations: {
|
||||
o0: { name: "Acme Corp", units: [{ name: "Engineering" }] },
|
||||
},
|
||||
addresses: {
|
||||
a0: {
|
||||
street: "123 Main St",
|
||||
locality: "City",
|
||||
region: "State",
|
||||
postcode: "12345",
|
||||
country: "US",
|
||||
contexts: { work: true },
|
||||
},
|
||||
},
|
||||
nicknames: { n0: { name: "JJ" } },
|
||||
notes: { n0: { note: "VIP client" } },
|
||||
};
|
||||
|
||||
const vcf = generateVCard([contact]);
|
||||
|
||||
expect(vcf).toContain("BEGIN:VCARD");
|
||||
expect(vcf).toContain("END:VCARD");
|
||||
expect(vcf).toContain("VERSION:3.0");
|
||||
expect(vcf).toContain("UID:uid-1");
|
||||
expect(vcf).toContain("KIND:individual");
|
||||
expect(vcf).toContain("FN:Jane Smith");
|
||||
expect(vcf).toContain("N:Smith;Jane;Marie;Dr.;PhD");
|
||||
expect(vcf).toContain("NICKNAME:JJ");
|
||||
expect(vcf).toContain("EMAIL;TYPE=WORK:jane@work.com");
|
||||
expect(vcf).toContain("EMAIL;TYPE=HOME:jane@home.com");
|
||||
expect(vcf).toContain("TEL;TYPE=WORK:+1234567890");
|
||||
expect(vcf).toContain("ORG:Acme Corp;Engineering");
|
||||
expect(vcf).toContain("ADR;TYPE=WORK:;;123 Main St;City;State;12345;US");
|
||||
expect(vcf).toContain("NOTE:VIP client");
|
||||
});
|
||||
|
||||
it("produces valid structure for minimal contact", () => {
|
||||
const contact: ContactCard = {
|
||||
id: "c2",
|
||||
addressBookIds: {},
|
||||
name: {
|
||||
components: [{ kind: "given", value: "Solo" }],
|
||||
isOrdered: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vcf = generateVCard([contact]);
|
||||
const lines = vcf.split("\r\n");
|
||||
|
||||
expect(lines[0]).toBe("BEGIN:VCARD");
|
||||
expect(lines[1]).toBe("VERSION:3.0");
|
||||
expect(lines).toContain("FN:Solo");
|
||||
expect(lines).toContain("N:;Solo;;;");
|
||||
expect(lines[lines.length - 1]).toBe("END:VCARD");
|
||||
});
|
||||
|
||||
it("encodes special characters in values", () => {
|
||||
const contact: ContactCard = {
|
||||
id: "c3",
|
||||
addressBookIds: {},
|
||||
name: {
|
||||
components: [{ kind: "given", value: "Test" }],
|
||||
isOrdered: true,
|
||||
},
|
||||
notes: { n0: { note: "Has comma, semicolon; and newline\nhere" } },
|
||||
};
|
||||
|
||||
const vcf = generateVCard([contact]);
|
||||
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip: parse → generate → parse", () => {
|
||||
it("produces structurally equivalent data", () => {
|
||||
const original = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"FN:John Doe",
|
||||
"N:Doe;John;;;",
|
||||
"EMAIL;TYPE=WORK:john@work.com",
|
||||
"TEL:+1234567890",
|
||||
"ORG:Acme Corp",
|
||||
"NICKNAME:JD",
|
||||
"NOTE:A note",
|
||||
"UID:round-trip-1",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const parsed = parseVCard(original);
|
||||
const exported = generateVCard(parsed);
|
||||
const reparsed = parseVCard(exported);
|
||||
|
||||
expect(reparsed).toHaveLength(1);
|
||||
const a = parsed[0];
|
||||
const b = reparsed[0];
|
||||
|
||||
expect(b.name?.components).toEqual(a.name?.components);
|
||||
expect(b.emails?.e0?.address).toBe(a.emails?.e0?.address);
|
||||
expect(b.phones?.p0?.number).toBe(a.phones?.p0?.number);
|
||||
expect(b.organizations?.o0?.name).toBe(a.organizations?.o0?.name);
|
||||
expect(b.nicknames?.n0?.name).toBe(a.nicknames?.n0?.name);
|
||||
expect(b.notes?.n0?.note).toBe(a.notes?.n0?.note);
|
||||
expect(b.uid).toBe(a.uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDuplicates", () => {
|
||||
it("detects duplicates by matching email (case-insensitive)", () => {
|
||||
const existing: ContactCard[] = [
|
||||
{
|
||||
id: "existing-1",
|
||||
addressBookIds: {},
|
||||
emails: { e0: { address: "Alice@Example.com" } },
|
||||
},
|
||||
];
|
||||
const incoming: ContactCard[] = [
|
||||
{
|
||||
id: "new-1",
|
||||
addressBookIds: {},
|
||||
emails: { e0: { address: "alice@example.com" } },
|
||||
},
|
||||
];
|
||||
|
||||
const dupes = detectDuplicates(existing, incoming);
|
||||
expect(dupes.size).toBe(1);
|
||||
expect(dupes.get(0)).toBe("existing-1");
|
||||
});
|
||||
|
||||
it("returns empty map when no duplicates", () => {
|
||||
const existing: ContactCard[] = [
|
||||
{
|
||||
id: "existing-1",
|
||||
addressBookIds: {},
|
||||
emails: { e0: { address: "alice@example.com" } },
|
||||
},
|
||||
];
|
||||
const incoming: ContactCard[] = [
|
||||
{
|
||||
id: "new-1",
|
||||
addressBookIds: {},
|
||||
emails: { e0: { address: "bob@example.com" } },
|
||||
},
|
||||
];
|
||||
|
||||
const dupes = detectDuplicates(existing, incoming);
|
||||
expect(dupes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles contacts without emails", () => {
|
||||
const existing: ContactCard[] = [
|
||||
{ id: "existing-1", addressBookIds: {} },
|
||||
];
|
||||
const incoming: ContactCard[] = [
|
||||
{ id: "new-1", addressBookIds: {} },
|
||||
{
|
||||
id: "new-2",
|
||||
addressBookIds: {},
|
||||
emails: { e0: { address: "a@b.com" } },
|
||||
},
|
||||
];
|
||||
|
||||
const dupes = detectDuplicates(existing, incoming);
|
||||
expect(dupes.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user