592 lines
18 KiB
TypeScript
592 lines
18 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
|
|
import type { Email } from '@/lib/jmap/types';
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const pushMock = vi.fn();
|
|
const clientMock = {
|
|
parseCalendarEvents: vi.fn(),
|
|
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
|
|
getCalendarEvent: vi.fn(),
|
|
queryCalendarEvents: vi.fn(async () => []),
|
|
};
|
|
|
|
const authState = {
|
|
client: clientMock,
|
|
primaryIdentity: { email: 'user@example.com' },
|
|
};
|
|
|
|
const settingsState = {
|
|
calendarInvitationParsingEnabled: true,
|
|
};
|
|
|
|
const calendarState = {
|
|
calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }],
|
|
supportsCalendar: true,
|
|
importEvents: vi.fn(),
|
|
rsvpEvent: vi.fn(),
|
|
updateEvent: vi.fn(),
|
|
events: [] as Array<Record<string, unknown>>,
|
|
setSelectedDate: vi.fn(),
|
|
};
|
|
|
|
const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => (
|
|
typeof selector === 'function' ? selector(calendarState) : calendarState
|
|
)) as {
|
|
(selector?: (state: typeof calendarState) => unknown): unknown;
|
|
getState: () => typeof calendarState;
|
|
setState: (updater: Partial<typeof calendarState> | ((state: typeof calendarState) => Partial<typeof calendarState> | typeof calendarState)) => void;
|
|
};
|
|
|
|
useCalendarStoreMock.getState = () => calendarState;
|
|
useCalendarStoreMock.setState = (updater) => {
|
|
const nextState = typeof updater === 'function' ? updater(calendarState) : updater;
|
|
Object.assign(calendarState, nextState);
|
|
};
|
|
|
|
return {
|
|
pushMock,
|
|
clientMock,
|
|
authState,
|
|
settingsState,
|
|
calendarState,
|
|
useCalendarStoreMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('next-intl', () => ({
|
|
useTranslations: () => (key: string, values?: Record<string, string>) => {
|
|
const strings: Record<string, string> = {
|
|
loading: 'Loading event details…',
|
|
title: 'Calendar Invitation',
|
|
organizer: 'Organized by {name}',
|
|
attendees: 'attendees',
|
|
add_to_calendar: 'Add to calendar',
|
|
added: 'Added to calendar',
|
|
rsvp_sent: 'Response sent',
|
|
already_in_calendar: 'Already in your calendar',
|
|
your_response: 'Your response: {status}',
|
|
response_accepted: 'Accepted',
|
|
response_needed: 'Needs response',
|
|
actor_response_info: '{name} responded {status}.',
|
|
actor_sent_info: 'Sent by {name}.',
|
|
actor_counter_info: '{name} proposed changes to this event.',
|
|
actor_refresh_info: '{name} asked for the latest event details.',
|
|
actor_declined_counter_info: '{name} declined the counter proposal.',
|
|
actor_note: 'Note: {comment}',
|
|
actor_unknown: 'Someone',
|
|
action_failed: 'Could not complete that calendar action.',
|
|
proposal_applied: 'Proposed changes applied.',
|
|
proposed_changes: 'Proposed changes',
|
|
change_title: 'Title',
|
|
change_time: 'Time',
|
|
change_location: 'Location',
|
|
change_description: 'Description',
|
|
change_empty: 'None',
|
|
change_from_to: '{before} -> {after}',
|
|
apply_proposal: 'Apply proposed changes',
|
|
review_proposal: 'Review proposal',
|
|
review_request: 'Review request',
|
|
view_in_calendar: 'View in calendar',
|
|
organizer_role: 'You organize this event',
|
|
accept: 'Accept',
|
|
maybe: 'Maybe',
|
|
decline: 'Decline',
|
|
select_calendar: 'Select calendar',
|
|
};
|
|
|
|
let message = strings[key] ?? key;
|
|
if (values) {
|
|
for (const [name, value] of Object.entries(values)) {
|
|
message = message.replace(`{${name}}`, String(value));
|
|
}
|
|
}
|
|
return message;
|
|
},
|
|
useFormatter: () => ({
|
|
dateTime: (date: Date) => date.toISOString(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/i18n/navigation', () => ({
|
|
useRouter: () => ({ push: mocks.pushMock }),
|
|
}));
|
|
|
|
vi.mock('@/stores/auth-store', () => ({
|
|
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState),
|
|
}));
|
|
|
|
vi.mock('@/stores/settings-store', () => ({
|
|
useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState),
|
|
}));
|
|
|
|
vi.mock('@/stores/calendar-store', () => ({
|
|
useCalendarStore: mocks.useCalendarStoreMock,
|
|
}));
|
|
|
|
function makeEmail(overrides: Partial<Email> = {}): Email {
|
|
return {
|
|
id: 'email-1',
|
|
threadId: 'thread-1',
|
|
mailboxIds: { inbox: true },
|
|
keywords: {},
|
|
size: 512,
|
|
receivedAt: '2026-03-16T10:00:00Z',
|
|
hasAttachment: true,
|
|
attachments: [
|
|
{
|
|
partId: '1',
|
|
blobId: 'blob-1',
|
|
size: 256,
|
|
type: 'text/calendar; method=REQUEST',
|
|
name: 'invite.ics',
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('CalendarInvitationBanner', () => {
|
|
beforeEach(() => {
|
|
mocks.pushMock.mockReset();
|
|
mocks.settingsState.calendarInvitationParsingEnabled = true;
|
|
mocks.clientMock.parseCalendarEvents.mockReset();
|
|
mocks.clientMock.getCalendarEvent.mockReset();
|
|
mocks.clientMock.getCalendarEvent.mockResolvedValue(null);
|
|
mocks.clientMock.queryCalendarEvents.mockReset();
|
|
mocks.clientMock.queryCalendarEvents.mockResolvedValue([]);
|
|
mocks.calendarState.importEvents.mockReset();
|
|
mocks.calendarState.rsvpEvent.mockReset();
|
|
mocks.calendarState.updateEvent.mockReset();
|
|
mocks.calendarState.setSelectedDate.mockReset();
|
|
mocks.calendarState.events = [];
|
|
});
|
|
|
|
it('does not parse invitations when invitation parsing is disabled', () => {
|
|
mocks.settingsState.calendarInvitationParsingEnabled = false;
|
|
|
|
const { container } = render(<CalendarInvitationBanner email={makeEmail()} />);
|
|
|
|
expect(container.firstChild).toBeNull();
|
|
expect(mocks.clientMock.parseCalendarEvents).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows existing event status, current response, and view-in-calendar action', async () => {
|
|
mocks.calendarState.events = [
|
|
{
|
|
id: 'event-1',
|
|
uid: 'uid-1',
|
|
start: '2026-03-20T09:00:00Z',
|
|
participants: {
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { attendee: true },
|
|
participationStatus: 'accepted',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-1',
|
|
title: 'Team Sync',
|
|
start: '2026-03-20T09:00:00Z',
|
|
participants: {
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { attendee: true },
|
|
participationStatus: 'accepted',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Already in your calendar')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText('Your response: Accepted')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByText('View in calendar'));
|
|
|
|
expect(mocks.calendarState.setSelectedDate).toHaveBeenCalledTimes(1);
|
|
expect(mocks.pushMock).toHaveBeenCalledWith('/calendar');
|
|
});
|
|
|
|
it('keeps the invitation summary visible after importing', async () => {
|
|
mocks.calendarState.importEvents.mockResolvedValue(1);
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-2',
|
|
title: 'Planning Session',
|
|
start: '2026-03-22T13:00:00Z',
|
|
participants: {
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { attendee: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-2' })} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Add to calendar'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Added to calendar')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show RSVP actions when the current user is the organizer', async () => {
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-3',
|
|
title: 'Organizer Review',
|
|
start: '2026-03-25T11:00:00Z',
|
|
participants: {
|
|
organizer: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { owner: true },
|
|
participationStatus: 'accepted',
|
|
},
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
email: 'alice@example.com',
|
|
name: 'Alice',
|
|
roles: { attendee: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-3' })} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('You organize this event')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByText('Accept')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Maybe')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Decline')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the banner visible when an import action fails', async () => {
|
|
mocks.calendarState.importEvents.mockResolvedValue(0);
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-4',
|
|
title: 'Failed Import Event',
|
|
start: '2026-03-26T15:00:00Z',
|
|
participants: {
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { attendee: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-4' })} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Add to calendar'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Could not complete that calendar action.')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('hydrates sparse existing events before sending RSVP', async () => {
|
|
mocks.calendarState.events = [
|
|
{
|
|
id: 'event-6',
|
|
uid: 'uid-6',
|
|
start: '2026-03-28T09:00:00Z',
|
|
},
|
|
];
|
|
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-6',
|
|
title: 'Sparse Event',
|
|
start: '2026-03-28T09:00:00Z',
|
|
participants: {
|
|
parsedAttendee: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { required: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
mocks.clientMock.getCalendarEvent.mockResolvedValue({
|
|
id: 'event-6',
|
|
uid: 'uid-6',
|
|
start: '2026-03-28T09:00:00Z',
|
|
participants: {
|
|
canonicalAttendee: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { attendee: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
});
|
|
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Accept')).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Accept'));
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6');
|
|
});
|
|
|
|
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
|
|
mocks.clientMock,
|
|
'event-6',
|
|
'canonicalAttendee',
|
|
'accepted',
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('falls back to organizerCalendarAddress when replyTo is missing', async () => {
|
|
mocks.calendarState.events = [
|
|
{
|
|
id: 'event-7',
|
|
uid: 'uid-7',
|
|
start: '2026-03-29T09:00:00Z',
|
|
participants: {
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
calendarAddress: 'mailto:user@example.com',
|
|
name: 'User',
|
|
roles: { attendee: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-7',
|
|
title: 'Invite Without Reply-To',
|
|
start: '2026-03-29T09:00:00Z',
|
|
organizerCalendarAddress: 'mailto:organizer@example.com',
|
|
participants: {
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
calendarAddress: 'mailto:user@example.com',
|
|
name: 'User',
|
|
roles: { required: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-7' })} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Accept')).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Accept'));
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
|
|
mocks.clientMock,
|
|
'event-7',
|
|
'attendee',
|
|
'accepted',
|
|
{ imip: 'mailto:organizer@example.com' },
|
|
);
|
|
});
|
|
});
|
|
|
|
it('repairs sparse existing events with parsed participants before RSVPing', async () => {
|
|
mocks.calendarState.events = [
|
|
{
|
|
id: 'event-8',
|
|
uid: 'uid-8',
|
|
start: '2026-03-30T09:00:00Z',
|
|
},
|
|
];
|
|
|
|
mocks.clientMock.getCalendarEvent.mockResolvedValue({
|
|
id: 'event-8',
|
|
uid: 'uid-8',
|
|
start: '2026-03-30T09:00:00Z',
|
|
});
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-8',
|
|
title: 'Sparse Stored Event',
|
|
start: '2026-03-30T09:00:00Z',
|
|
organizerCalendarAddress: 'mailto:organizer@example.com',
|
|
participants: {
|
|
organizer: {
|
|
'@type': 'Participant',
|
|
calendarAddress: 'mailto:organizer@example.com',
|
|
name: 'Organizer',
|
|
roles: { required: true },
|
|
participationStatus: 'accepted',
|
|
},
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
calendarAddress: 'mailto:user@example.com',
|
|
name: 'User',
|
|
roles: { required: true },
|
|
participationStatus: 'needs-action',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
mocks.calendarState.updateEvent.mockResolvedValue(undefined);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-8' })} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Accept')).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Accept'));
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.calendarState.updateEvent).toHaveBeenCalledWith(
|
|
mocks.clientMock,
|
|
'event-8',
|
|
expect.objectContaining({
|
|
replyTo: { imip: 'mailto:organizer@example.com' },
|
|
participants: expect.objectContaining({
|
|
attendee: expect.objectContaining({
|
|
participationStatus: 'accepted',
|
|
}),
|
|
}),
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
expect(mocks.calendarState.rsvpEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows counter changes and lets organizers apply the proposal', async () => {
|
|
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined);
|
|
mocks.calendarState.events = [
|
|
{
|
|
id: 'event-5',
|
|
uid: 'uid-5',
|
|
title: 'Original Sync',
|
|
start: '2026-03-27T09:00:00Z',
|
|
duration: 'PT1H',
|
|
locations: {
|
|
room: { '@type': 'Location', name: 'Room A' },
|
|
},
|
|
participants: {
|
|
organizer: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { owner: true },
|
|
participationStatus: 'accepted',
|
|
},
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
email: 'alice@example.com',
|
|
name: 'Alice',
|
|
roles: { attendee: true },
|
|
participationStatus: 'tentative',
|
|
participationComment: 'Could we move this later?',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
|
{
|
|
uid: 'uid-5',
|
|
title: 'Original Sync',
|
|
start: '2026-03-27T10:00:00Z',
|
|
duration: 'PT1H',
|
|
locations: {
|
|
room: { '@type': 'Location', name: 'Room B' },
|
|
},
|
|
participants: {
|
|
organizer: {
|
|
'@type': 'Participant',
|
|
email: 'user@example.com',
|
|
name: 'User',
|
|
roles: { owner: true },
|
|
participationStatus: 'accepted',
|
|
},
|
|
attendee: {
|
|
'@type': 'Participant',
|
|
email: 'alice@example.com',
|
|
name: 'Alice',
|
|
roles: { attendee: true },
|
|
participationStatus: 'tentative',
|
|
participationComment: 'Could we move this later?',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-5', attachments: [{ partId: '1', blobId: 'blob-5', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }] })} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Proposed changes')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText(/Location/)).toBeInTheDocument();
|
|
expect(screen.getByText('Review proposal')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByText('Apply proposed changes'));
|
|
|
|
await waitFor(() => {
|
|
expect(mocks.calendarState.updateEvent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument();
|
|
});
|
|
}); |