calendarSlice.test.ts6.2 KBView on GitHub
/**
 * Tests for CalendarSlice.setCalendars — reconciliation of the persisted
 * visible-calendar set against the calendars the account can actually access.
 *
 * Regression: a calendar that's been unshared/removed disappears from Google's
 * calendarList but lingers in localStorage, causing an endless 404 loop on
 * listEvents. setCalendars must prune stale ids while leaving valid ones intact.
 */

import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cal = (id: string, primary = false): any => ({ id, primary, summary: id });

const reset = () => {
  localStorage.clear();
  useCedarStore.setState((s) => ({
    ...s,
    calendars: [],
    visibleCalendarIds: new Set<string>(),
  }));
};

beforeEach(reset);
afterEach(reset);

const setCalendars = (calendars: unknown[]) =>
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  act(() => useCedarStore.getState().setCalendars(calendars as any));

const visible = () => useCedarStore.getState().visibleCalendarIds;

describe('setCalendars reconciliation', () => {
  it('prunes a visible calendar that is no longer accessible', () => {
    useCedarStore.setState({
      visibleCalendarIds: new Set(['<email>', '<email>']),
    });

    setCalendars([cal('<email>', true), cal('<email>')]);

    expect(visible().has('<email>')).toBe(false);
    expect(visible().has('<email>')).toBe(true);
    expect(JSON.parse(localStorage.getItem('calendar-visible-ids') ?? '[]')).not.toContain(
      '<email>',
    );
  });

  it('keeps visible calendars that are still accessible', () => {
    useCedarStore.setState({
      visibleCalendarIds: new Set(['<email>', '<email>']),
    });

    setCalendars([cal('<email>', true), cal('<email>'), cal('<email>')]);

    expect(visible()).toEqual(new Set(['<email>', '<email>']));
  });

  it('falls back to the primary calendar when pruning empties the set', () => {
    useCedarStore.setState({
      visibleCalendarIds: new Set(['<email>', '<email>']),
    });

    setCalendars([cal('<email>', true), cal('<email>')]);

    expect(visible()).toEqual(new Set(['<email>']));
  });

  it('does not touch the selection when the calendar list is empty (transient fetch)', () => {
    useCedarStore.setState({
      visibleCalendarIds: new Set(['<email>', '<email>']),
    });

    setCalendars([]);

    expect(visible()).toEqual(new Set(['<email>', '<email>']));
  });

  it('defaults to the primary calendar on first load (empty selection)', () => {
    setCalendars([cal('<email>', true), cal('<email>')]);

    expect(visible()).toEqual(new Set(['<email>']));
  });
});

/**
 * Tests for CalendarSlice.mergeCalendarEvents eviction.
 *
 * Regression: the merge was upsert-only, so an event that a refetch no longer returns —
 * rescheduled out of view, deleted, or declined — had nothing to overwrite it and lingered
 * at its stale slot indefinitely. Eviction must be scoped to the window the fetch actually
 * covered, so overlapping callers on other date ranges don't wipe each other.
 */

const ev = (
  id: string,
  startIso: string,
  calendarId = 'primary',
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
): any => ({ id, calendarId, summary: id, start: { dateTime: startIso } });

const WINDOW = {
  calendarIds: ['primary'],
  timeMin: '2026-08-10T00:00:00.000Z',
  timeMax: '2026-08-31T23:59:59.000Z',
};

const merge = (events: unknown[], window?: unknown) =>
  act(() =>
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    useCedarStore.getState().mergeCalendarEvents(events as any, window as any),
  );

const stored = () => useCedarStore.getState().calendarEvents;

describe('mergeCalendarEvents eviction', () => {
  beforeEach(() => {
    useCedarStore.setState({ calendarEvents: [] });
  });

  // NB: the store always handled this one — a reschedule keeps the same event id, so the
  // upsert overwrote it. The bug that surfaced it lived in useAllCalendarEvents, whose memo
  // was keyed on event COUNT and so never recomputed. Kept as a guard on the store half.
  it('updates a rescheduled event in place', () => {
    merge([ev('jake', '2026-08-19T18:00:00-07:00'), ev('gym', '2026-08-19T08:30:00-07:00')], WINDOW);
    expect(stored()).toHaveLength(2);

    // Jake moved to Aug 26 — same window, so the response still covers it, at a new time.
    merge([ev('jake', '2026-08-26T18:00:00-07:00'), ev('gym', '2026-08-19T08:30:00-07:00')], WINDOW);

    const jake = stored().filter((e) => e.id === 'jake');
    expect(jake).toHaveLength(1);
    expect(jake[0]?.start?.dateTime).toBe('2026-08-26T18:00:00-07:00');
  });

  it('evicts a deleted in-window event', () => {
    merge([ev('gone', '2026-08-19T18:00:00-07:00'), ev('gym', '2026-08-19T08:30:00-07:00')], WINDOW);

    merge([ev('gym', '2026-08-19T08:30:00-07:00')], WINDOW);

    expect(stored().map((e) => e.id)).toEqual(['gym']);
  });

  it('leaves events outside the synced window untouched', () => {
    merge([ev('october', '2026-10-05T18:00:00-07:00')], undefined);
    merge([ev('gym', '2026-08-19T08:30:00-07:00')], WINDOW);

    expect(stored().map((e) => e.id).sort()).toEqual(['gym', 'october']);
  });

  it('leaves calendars that did not answer untouched', () => {
    merge([ev('theirs', '2026-08-19T18:00:00-07:00', '<email>')], undefined);

    // Only 'primary' answered this round; the other calendar's events must survive.
    merge([ev('gym', '2026-08-19T08:30:00-07:00')], WINDOW);

    expect(stored().map((e) => e.id).sort()).toEqual(['gym', 'theirs']);
  });

  it('upserts without evicting when no window is supplied', () => {
    merge([ev('a', '2026-08-19T18:00:00-07:00'), ev('b', '2026-08-19T19:00:00-07:00')], WINDOW);

    // Single-event upsert path (RSVP / inline edit) must not be read as an empty window.
    merge([ev('a', '2026-08-19T20:00:00-07:00')], undefined);

    expect(stored().map((e) => e.id).sort()).toEqual(['a', 'b']);
    expect(stored().find((e) => e.id === 'a')?.start?.dateTime).toBe('2026-08-19T20:00:00-07:00');
  });
});