use-event-rsvp.test.tsx3.8 KBView on GitHub /**
* `useEventRsvp` — the RSVP write lifted out of `EventDetailsPopover`.
*
* The rule worth a test: Google REPLACES the attendee array on every write, so answering
* an invitation has to re-send every other guest's `responseStatus` verbatim. Omitting
* them resets the whole meeting to "no response" — a silent failure the person who
* pressed the button never sees, because their own answer looks right afterwards.
*
* The rollback matters for the same reason: the optimistic hop makes the UI claim the
* RSVP landed, so a failed write that is not reverted leaves the screen lying.
*/
import { renderHook, waitFor } from '@testing-library/react';
const mockMutateAsync = jest.fn();
jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } }));
jest.mock('@/modules/calendar/hooks/use-calendar', () => ({
useUpdateCalendarEvent: () => ({ mutateAsync: mockMutateAsync, isPending: false }),
}));
import { useEventRsvp } from '@/modules/calendar/hooks/use-event-rsvp';
import type { CalendarEvent } from '@/modules/calendar/types/calendar-types';
const EVENT: CalendarEvent = {
id: 'evt-1',
summary: 'Cedar × Acme',
attendees: [
{ email: '<email>', self: true, responseStatus: 'needsAction' },
{ email: '<email>', responseStatus: 'accepted' },
// An open-string value Google may hand back that the update mutation will not take.
{ email: '<email>', responseStatus: 'unknown-to-us' },
],
};
beforeEach(() => {
mockMutateAsync.mockReset();
mockMutateAsync.mockResolvedValue({ attendees: EVENT.attendees });
});
describe('useEventRsvp', () => {
it('rewrites only the user, and re-sends everyone else unchanged', async () => {
const { result } = renderHook(() => useEventRsvp());
await result.current.setRsvp(EVENT, 'declined');
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledTimes(1));
expect(mockMutateAsync).toHaveBeenCalledWith({
eventId: 'evt-1',
sendUpdates: 'all',
requestBody: {
attendees: [
{
email: '<email>',
displayName: undefined,
responseStatus: 'declined',
},
{ email: '<email>', displayName: undefined, responseStatus: 'accepted' },
// Narrowed away rather than forwarded: an unrecognised value would be rejected
// for the whole request, taking the other guests' answers down with it.
{ email: '<email>', displayName: undefined, responseStatus: undefined },
],
},
});
});
it('applies optimistically then confirms with the server answer', async () => {
const seen: Array<string | null | undefined> = [];
const { result } = renderHook(() =>
useEventRsvp({
onEventChange: (e) => seen.push(e.attendees?.find((a) => a.self)?.responseStatus),
}),
);
await result.current.setRsvp(EVENT, 'tentative');
// Optimistic first, then whatever came back.
expect(seen).toEqual(['tentative', 'needsAction']);
});
it('rolls the event back when the write fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('403'));
const seen: Array<string | null | undefined> = [];
const { result } = renderHook(() =>
useEventRsvp({
onEventChange: (e) => seen.push(e.attendees?.find((a) => a.self)?.responseStatus),
}),
);
await expect(result.current.setRsvp(EVENT, 'accepted')).resolves.toBe(false);
expect(seen).toEqual(['accepted', 'needsAction']);
});
it('refuses an event with no id rather than writing to nothing', async () => {
await expect(
renderHook(() => useEventRsvp()).result.current.setRsvp({ ...EVENT, id: null }, 'accepted'),
).resolves.toBe(false);
expect(mockMutateAsync).not.toHaveBeenCalled();
});
});