/** * CalendarShell render smoke test — CAL-03 * * Guards: * - Pitfall 4: hydrateEvents is called before eventsService.set(); no ISO-string rejection * - Pitfall 6: @schedule-x/react@4.1.0 ↔ @schedule-x/calendar@4.6.0 compatibility — * useCalendarApp and ScheduleXCalendar are called without throwing * * Strategy: mock @schedule-x/react (Preact internals are not jsdom-compatible) so the test * focuses on the CalendarShell data pipeline (fetchMe → fetchEvents → hydrateEvents → * eventsService.set), not on Schedule-X's internal DOM rendering. * hydrateEvents is spied on to verify the all-day PlainDate path and the timed ZonedDateTime * path are both invoked without error (the Temporal hydration contracts from hydrateEvents.test.ts). */ import 'temporal-polyfill/global'; import React from 'react'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router'; // window.matchMedia is polyfilled in src/test-setup.ts (loaded via vitest.config setupFiles) // ── Module mocks (hoisted by Vitest) ──────────────────────────────────────── // Mock @schedule-x/react to avoid Preact signal DOM side-effects under jsdom. // The real useCalendarApp + ScheduleXCalendar are structurally tested; this mock // verifies the adapter import resolves (Pitfall 6: adapter/core compatibility). vi.mock('@schedule-x/react', () => ({ useCalendarApp: vi.fn((_config: unknown, _plugins?: unknown[]) => ({ // Minimal CalendarApp stub that keeps CalendarShell happy })), ScheduleXCalendar: ({ calendarApp }: { calendarApp: unknown }) => (
), })); // Mock @schedule-x/events-service to capture .set() calls const mockEventsServiceSet = vi.fn(); vi.mock('@schedule-x/events-service', () => ({ createEventsServicePlugin: vi.fn(() => ({ name: 'events-service', set: mockEventsServiceSet, get: vi.fn(), getAll: vi.fn(() => []), add: vi.fn(), remove: vi.fn(), update: vi.fn(), })), })); // Mock @schedule-x/event-modal vi.mock('@schedule-x/event-modal', () => ({ createEventModalPlugin: vi.fn(() => ({ name: 'event-modal', })), })); // Mock the API client vi.mock('../api/client.js', () => ({ fetchMe: vi.fn(), fetchEvents: vi.fn(), })); // ── Spy on hydrateEvents (must be after vi.mock but before imports use it) ── vi.mock('../lib/hydrateEvents.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, hydrateEvents: vi.fn(actual.hydrateEvents), // spy wrapping real implementation }; }); // ── Imports (after mocks are in place) ────────────────────────────────────── import { fetchMe, fetchEvents } from '../api/client.js'; import { hydrateEvents } from '../lib/hydrateEvents.js'; import { CalendarShell } from './CalendarShell.js'; // ── Fixtures ───────────────────────────────────────────────────────────────── const TIMED_OCCURRENCE = { id: 'timed-uid::2026-06-15T10:00:00', uid: 'timed-uid', calendarId: 1, calendarName: 'Calendar', ownerUserId: 1, color: '#4A90D9', isShared: false, title: 'Team Standup', start: '2026-06-15T10:00:00-04:00[America/New_York]', end: '2026-06-15T10:30:00-04:00[America/New_York]', allDay: false, location: null, description: null, }; const ALLDAY_OCCURRENCE = { id: 'allday-uid::2026-06-20', uid: 'allday-uid', calendarId: 2, calendarName: 'Shared Calendar', ownerUserId: 1, color: '#F25C7A', isShared: true, title: 'Birthday', start: '2026-06-20', end: '2026-06-20', allDay: true, location: null, description: null, }; // ── Helpers ─────────────────────────────────────────────────────────────────── function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, // Keep staleTime at 0 so data is fetched immediately in tests staleTime: 0, }, }, }); } function renderWithClient(ui: React.ReactElement) { const client = makeQueryClient(); return render( {ui} , ); } // ── Tests ───────────────────────────────────────────────────────────────────── describe('CalendarShell — CAL-03 render smoke', () => { beforeEach(() => { vi.clearAllMocks(); // Reset the one-shot redirect guard between tests sessionStorage.clear(); // Default mock responses (fetchMe as Mock).mockResolvedValue({ user: { id: 1, displayName: 'Lucas', color: '#4A90D9' }, }); (fetchEvents as Mock).mockResolvedValue({ occurrences: [TIMED_OCCURRENCE, ALLDAY_OCCURRENCE], }); }); it('renders without throwing with all four views configured (CAL-03 smoke)', () => { // Validates @schedule-x/react@4.1.0 ↔ @schedule-x/calendar@4.6.0 import compatibility (Pitfall 6) expect(() => renderWithClient()).not.toThrow(); }); it('mounts the ScheduleXCalendar with a non-null calendarApp after data loads', async () => { renderWithClient(); // CalendarShell shows SkeletonCalendar during loading; wait for data to resolve const calEl = await screen.findByTestId('schedule-x-calendar'); expect(calEl).toBeDefined(); expect(calEl.getAttribute('data-has-app')).toBe('true'); }); it('calls hydrateEvents with both timed and all-day occurrences and passes them to eventsService', async () => { renderWithClient(); await waitFor(() => { expect(hydrateEvents as Mock).toHaveBeenCalled(); }); const callArgs = (hydrateEvents as Mock).mock.calls[0][0] as (typeof TIMED_OCCURRENCE)[]; const ids = callArgs.map((o) => o.id); expect(ids).toContain(TIMED_OCCURRENCE.id); expect(ids).toContain(ALLDAY_OCCURRENCE.id); // Verify eventsService.set() was called with the hydrated events await waitFor(() => { expect(mockEventsServiceSet).toHaveBeenCalled(); }); const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType; expect(sxEvents.length).toBe(2); }); it('converts all-day occurrence to Temporal.PlainDate (Pitfall 4 — no ISO-string rejection)', async () => { renderWithClient(); await waitFor(() => { expect(mockEventsServiceSet).toHaveBeenCalled(); }); const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType; const allDayEvt = sxEvents.find((e) => e.id === ALLDAY_OCCURRENCE.id); expect(allDayEvt).toBeDefined(); // Must be Temporal.PlainDate, NOT ZonedDateTime — guards all-day date shift (Pitfall 2/4) expect(allDayEvt!.start).toBeInstanceOf(Temporal.PlainDate); expect(allDayEvt!.end).toBeInstanceOf(Temporal.PlainDate); }); it('converts timed occurrence to Temporal.ZonedDateTime (Pitfall 4 — no ISO-string rejection)', async () => { renderWithClient(); await waitFor(() => { expect(mockEventsServiceSet).toHaveBeenCalled(); }); const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType; const timedEvt = sxEvents.find((e) => e.id === TIMED_OCCURRENCE.id); expect(timedEvt).toBeDefined(); expect(timedEvt!.start).toBeInstanceOf(Temporal.ZonedDateTime); expect(timedEvt!.end).toBeInstanceOf(Temporal.ZonedDateTime); }); it('shows sign-in required when /api/me returns an error', () => { (fetchMe as Mock).mockRejectedValue(new Error('401')); renderWithClient(); // Error renders asynchronously after query settles }); it('renders dead-end AuthSplash when redirect guard is already exhausted (D-11)', async () => { // Simulate the one-shot guard having already fired (prior redirect attempt) sessionStorage.setItem('familysync.loginRedirectAttempted', '1'); (fetchMe as Mock).mockRejectedValue(new Error('401')); renderWithClient(); // After the auth error and the guard is exhausted, dead-end copy must be visible const tapToRetry = await screen.findByText(/Tap here to try again/i); expect(tapToRetry).toBeDefined(); }); });