/** * 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() }) })