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.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
getParticipantCount,
|
||||
buildParticipantMap,
|
||||
} from '@/lib/calendar-participants';
|
||||
|
||||
function makeEvent(participants: Record<string, Partial<CalendarParticipant>> | null = null): CalendarEvent {
|
||||
return {
|
||||
'@type': 'Event',
|
||||
id: 'ev1',
|
||||
uid: 'uid-ev1',
|
||||
calendarIds: { cal1: true },
|
||||
title: 'Test Event',
|
||||
description: '',
|
||||
descriptionContentType: 'text/plain',
|
||||
start: '2026-03-01T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'UTC',
|
||||
showWithoutTime: false,
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'busy',
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
recurrenceId: null,
|
||||
recurrenceIdTimeZone: null,
|
||||
recurrenceRules: null,
|
||||
recurrenceOverrides: null,
|
||||
excludedRecurrenceRules: null,
|
||||
useDefaultAlerts: false,
|
||||
alerts: null,
|
||||
locations: null,
|
||||
virtualLocations: null,
|
||||
links: null,
|
||||
relatedTo: null,
|
||||
utcStart: null,
|
||||
utcEnd: null,
|
||||
isDraft: false,
|
||||
isOrigin: true,
|
||||
sequence: 0,
|
||||
created: '2026-03-01T09:00:00Z',
|
||||
updated: '2026-03-01T09:00:00Z',
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
participants: participants as Record<string, CalendarParticipant> | null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
hideAttendees: false,
|
||||
};
|
||||
}
|
||||
|
||||
const orgParticipant: Partial<CalendarParticipant> = {
|
||||
'@type': 'Participant',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
roles: { owner: true, attendee: true },
|
||||
participationStatus: 'accepted',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: 'mailto:alice@example.com' },
|
||||
expectReply: false,
|
||||
kind: 'individual',
|
||||
};
|
||||
|
||||
const attendeeParticipant: Partial<CalendarParticipant> = {
|
||||
'@type': 'Participant',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: 'mailto:bob@example.com' },
|
||||
expectReply: true,
|
||||
kind: 'individual',
|
||||
};
|
||||
|
||||
const acceptedAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Carol',
|
||||
email: 'carol@example.com',
|
||||
participationStatus: 'accepted',
|
||||
};
|
||||
|
||||
const declinedAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Dave',
|
||||
email: 'dave@example.com',
|
||||
participationStatus: 'declined',
|
||||
};
|
||||
|
||||
const tentativeAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Eve',
|
||||
email: 'eve@example.com',
|
||||
participationStatus: 'tentative',
|
||||
};
|
||||
|
||||
describe('isOrganizer', () => {
|
||||
it('returns true when user email matches organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with case-insensitive match', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['ALICE@EXAMPLE.COM'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when user is not organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['bob@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when user has multiple emails and one matches', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['other@example.com', 'alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when empty user emails', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserParticipantId', () => {
|
||||
it('returns the participant ID for the user', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
expect(getUserParticipantId(event, ['bob@example.com'])).toBe('att1');
|
||||
});
|
||||
|
||||
it('returns organizer ID when user is organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserParticipantId(event, ['alice@example.com'])).toBe('org');
|
||||
});
|
||||
|
||||
it('returns null when user not found', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserParticipantId(event, ['unknown@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getUserParticipantId(event, ['alice@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
expect(getUserParticipantId(event, ['BOB@EXAMPLE.COM'])).toBe('att1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserStatus', () => {
|
||||
it('returns the participation status', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
expect(getUserStatus(event, ['bob@example.com'])).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('returns accepted for organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserStatus(event, ['alice@example.com'])).toBe('accepted');
|
||||
});
|
||||
|
||||
it('returns null when user not found', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserStatus(event, ['unknown@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getUserStatus(event, ['alice@example.com'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParticipantList', () => {
|
||||
it('returns all participants as info objects', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
const list = getParticipantList(event);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.find(p => p.id === 'org')).toEqual({
|
||||
id: 'org',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
status: 'accepted',
|
||||
isOrganizer: true,
|
||||
});
|
||||
expect(list.find(p => p.id === 'att1')).toEqual({
|
||||
id: 'att1',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
status: 'needs-action',
|
||||
isOrganizer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getParticipantList(event)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults status to needs-action for missing status', () => {
|
||||
const event = makeEvent({
|
||||
att1: { ...attendeeParticipant, participationStatus: undefined },
|
||||
});
|
||||
const list = getParticipantList(event);
|
||||
expect(list[0].status).toBe('needs-action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusCounts', () => {
|
||||
it('counts statuses correctly', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: acceptedAttendee,
|
||||
att2: declinedAttendee,
|
||||
att3: tentativeAttendee,
|
||||
att4: attendeeParticipant,
|
||||
});
|
||||
const counts = getStatusCounts(event);
|
||||
expect(counts.accepted).toBe(2);
|
||||
expect(counts.declined).toBe(1);
|
||||
expect(counts.tentative).toBe(1);
|
||||
expect(counts['needs-action']).toBe(1);
|
||||
});
|
||||
|
||||
it('returns all zeros when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
const counts = getStatusCounts(event);
|
||||
expect(counts).toEqual({ accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParticipantCount', () => {
|
||||
it('returns correct count', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
expect(getParticipantCount(event)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getParticipantCount(event)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildParticipantMap', () => {
|
||||
it('creates organizer and attendees', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[
|
||||
{ name: 'Bob', email: 'bob@example.com' },
|
||||
{ name: 'Carol', email: 'carol@example.com' },
|
||||
]
|
||||
);
|
||||
|
||||
expect(Object.keys(map)).toHaveLength(3);
|
||||
|
||||
const org = map['organizer'];
|
||||
expect(org.name).toBe('Alice');
|
||||
expect(org.email).toBe('alice@example.com');
|
||||
expect(org.roles).toEqual({ owner: true, attendee: true });
|
||||
expect(org.participationStatus).toBe('accepted');
|
||||
expect(org.scheduleAgent).toBe('server');
|
||||
expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
|
||||
expect(org.expectReply).toBe(false);
|
||||
|
||||
const att0 = map['attendee-0'];
|
||||
expect(att0.name).toBe('Bob');
|
||||
expect(att0.email).toBe('bob@example.com');
|
||||
expect(att0.roles).toEqual({ attendee: true });
|
||||
expect(att0.participationStatus).toBe('needs-action');
|
||||
expect(att0.scheduleAgent).toBe('server');
|
||||
expect(att0.expectReply).toBe(true);
|
||||
|
||||
const att1 = map['attendee-1'];
|
||||
expect(att1.name).toBe('Carol');
|
||||
expect(att1.email).toBe('carol@example.com');
|
||||
});
|
||||
|
||||
it('creates only organizer when no attendees', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[]
|
||||
);
|
||||
expect(Object.keys(map)).toHaveLength(1);
|
||||
expect(map['organizer']).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets @type to Participant for all entries', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }]
|
||||
);
|
||||
Object.values(map).forEach(p => {
|
||||
expect(p['@type']).toBe('Participant');
|
||||
});
|
||||
});
|
||||
|
||||
it('sets kind to individual for all entries', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }]
|
||||
);
|
||||
Object.values(map).forEach(p => {
|
||||
expect(p.kind).toBe('individual');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user