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();
  });
});