Files
SRCmail/lib/__tests__/calendar-invitation.test.ts
T
Matthieu MALVACHEandMatthieu MALVACHE b65b2f26df feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants,
scheduling messages, and inline calendar invitation banner in email viewer
with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
2026-02-17 01:09:57 +01:00

286 lines
8.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
findCalendarAttachment,
getInvitationMethod,
formatEventSummary,
findParticipantByEmail,
} from '../calendar-invitation';
import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'e1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
size: 1024,
receivedAt: '2026-02-17T10:00:00Z',
hasAttachment: false,
...overrides,
};
}
function makeParticipant(overrides: Partial<CalendarParticipant> = {}): CalendarParticipant {
return {
'@type': 'Participant',
name: 'Test',
email: 'test@example.com',
description: null,
sendTo: null,
kind: 'individual',
roles: { attendee: true },
participationStatus: 'needs-action',
participationComment: null,
expectReply: false,
scheduleAgent: 'server',
scheduleForceSend: false,
scheduleId: null,
scheduleSequence: 0,
scheduleStatus: null,
scheduleUpdated: null,
invitedBy: null,
delegatedTo: null,
delegatedFrom: null,
memberOf: null,
locationId: null,
language: null,
links: null,
...overrides,
};
}
describe('findCalendarAttachment', () => {
it('finds attachment by MIME type text/calendar', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b1', size: 500, type: 'text/calendar', name: 'invite.ics' },
],
hasAttachment: true,
});
const result = findCalendarAttachment(email);
expect(result).toBeTruthy();
expect(result!.blobId).toBe('b1');
});
it('finds attachment by application/ics type', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b2', size: 500, type: 'application/ics', name: 'event.ics' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('b2');
});
it('finds attachment by .ics file extension', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b3', size: 500, type: 'application/octet-stream', name: 'meeting.ics' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('b3');
});
it('finds attachment by .ical file extension', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b4', size: 500, type: 'application/octet-stream', name: 'meeting.ical' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('b4');
});
it('returns null when no calendar attachment exists', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b5', size: 500, type: 'application/pdf', name: 'doc.pdf' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)).toBeNull();
});
it('returns null when attachments is undefined', () => {
const email = makeEmail();
expect(findCalendarAttachment(email)).toBeNull();
});
it('detects text/calendar in textBody parts', () => {
const email = makeEmail({
textBody: [
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
],
});
const result = findCalendarAttachment(email);
expect(result).toBeTruthy();
expect(result!.blobId).toBe('tb1');
});
it('prioritizes attachments over textBody', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'att1', size: 500, type: 'text/calendar', name: 'invite.ics' },
],
textBody: [
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('att1');
});
});
describe('getInvitationMethod', () => {
it('detects cancel when status is cancelled', () => {
expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel');
});
it('detects request when participants have organizer role', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, name: 'Organizer' }),
att: makeParticipant({ roles: { attendee: true }, name: 'Attendee' }),
},
};
expect(getInvitationMethod(event)).toBe('request');
});
it('detects request with chair role', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { chair: true }, name: 'Chair' }),
},
};
expect(getInvitationMethod(event)).toBe('request');
});
it('returns unknown when no participants', () => {
expect(getInvitationMethod({})).toBe('unknown');
});
it('returns unknown when participants have no organizer', () => {
const event: Partial<CalendarEvent> = {
participants: {
att: makeParticipant({ roles: { attendee: true } }),
},
};
expect(getInvitationMethod(event)).toBe('unknown');
});
});
describe('formatEventSummary', () => {
it('extracts title from event', () => {
const summary = formatEventSummary({ title: 'Team Sync' });
expect(summary.title).toBe('Team Sync');
});
it('extracts location from event', () => {
const summary = formatEventSummary({
locations: { loc1: { '@type': 'Location', name: 'Room A', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
});
expect(summary.location).toBe('Room A');
});
it('extracts organizer info', () => {
const summary = formatEventSummary({
participants: {
org: makeParticipant({ roles: { owner: true }, name: 'Alice', email: 'alice@example.com' }),
att: makeParticipant({ roles: { attendee: true }, name: 'Bob' }),
},
});
expect(summary.organizer).toBe('Alice');
expect(summary.organizerEmail).toBe('alice@example.com');
expect(summary.attendeeCount).toBe(1);
});
it('handles missing data gracefully', () => {
const summary = formatEventSummary({});
expect(summary.title).toBe('');
expect(summary.start).toBeNull();
expect(summary.end).toBeNull();
expect(summary.location).toBeNull();
expect(summary.organizer).toBeNull();
expect(summary.attendeeCount).toBe(0);
});
it('computes end from start + duration', () => {
const summary = formatEventSummary({
start: '2026-02-17T10:00:00',
duration: 'PT1H30M',
});
expect(summary.start).toBe('2026-02-17T10:00:00');
expect(summary.end).toBeTruthy();
const endDate = new Date(summary.end!);
expect(endDate.getHours()).toBe(new Date('2026-02-17T10:00:00').getHours() + 1);
expect(endDate.getMinutes()).toBe(new Date('2026-02-17T10:00:00').getMinutes() + 30);
});
it('uses utcStart and utcEnd when available', () => {
const summary = formatEventSummary({
utcStart: '2026-02-17T15:00:00Z',
utcEnd: '2026-02-17T16:00:00Z',
start: '2026-02-17T10:00:00',
});
expect(summary.start).toBe('2026-02-17T15:00:00Z');
expect(summary.end).toBe('2026-02-17T16:00:00Z');
});
});
describe('findParticipantByEmail', () => {
it('finds participant by direct email match', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'alice@example.com', name: 'Alice' }),
},
};
const result = findParticipantByEmail(event, 'alice@example.com');
expect(result).toBeTruthy();
expect(result!.id).toBe('p1');
expect(result!.participant.name).toBe('Alice');
});
it('matches case-insensitively', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'Alice@Example.COM' }),
},
};
expect(findParticipantByEmail(event, 'alice@example.com')).toBeTruthy();
});
it('finds participant by sendTo mailto', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: '', sendTo: { imip: 'mailto:bob@example.com' } }),
},
};
const result = findParticipantByEmail(event, 'bob@example.com');
expect(result).toBeTruthy();
expect(result!.id).toBe('p1');
});
it('returns null when no match', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'alice@example.com' }),
},
};
expect(findParticipantByEmail(event, 'unknown@example.com')).toBeNull();
});
it('returns null with no participants', () => {
expect(findParticipantByEmail({}, 'test@example.com')).toBeNull();
});
it('returns null with empty email', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'alice@example.com' }),
},
};
expect(findParticipantByEmail(event, '')).toBeNull();
});
});