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