import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { renderHook, waitFor, act } from '@testing-library/react' import type { ReactNode } from 'react' import { WorkspaceProvider, useWorkspace } from './WorkspaceContext' import * as api from '@/services/api' import { useAuth } from '@/context/AuthContext' import type { Workspace } from '@/types' vi.mock('@/services/api') vi.mock('@/context/AuthContext', () => ({ useAuth: vi.fn(), })) function wrapper({ children }: { children: ReactNode }) { return {children} } // Node's own experimental `localStorage` global (active in newer Node runtimes // without a valid --localstorage-file) shadows jsdom's working implementation // and throws on every call. Stub a real in-memory Storage for these tests. function createLocalStorageMock(): Storage { let store: Record = {} return { getItem: (key: string) => (key in store ? store[key]! : null), setItem: (key: string, value: string) => { store[key] = value }, removeItem: (key: string) => { delete store[key] }, clear: () => { store = {} }, key: (index: number) => Object.keys(store)[index] ?? null, get length() { return Object.keys(store).length }, } } const authenticated = { accessToken: 'tok', isAuthenticated: true, isLoading: false, login: vi.fn(), register: vi.fn(), logout: vi.fn(), } const unauthenticated = { accessToken: null, isAuthenticated: false, isLoading: false, login: vi.fn(), register: vi.fn(), logout: vi.fn(), } const workspace: Workspace = { id: 'ws1', name: 'Our workspace', createdAt: '2026-01-01T00:00:00Z', } const userContext: api.UserContext = { personal_data_uuid: 'pd1', workspace_id: 'ws1', parent_name: 'Anna', children: [ { id: 'c1', name: 'Child One', birth_date: '2024-01-01' }, { id: 'c2', name: 'Child Two', birth_date: '2025-01-01' }, ], take_pl_on_red_days: false, } describe('WorkspaceContext', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('localStorage', createLocalStorageMock()) }) afterEach(() => { vi.unstubAllGlobals() }) it('does nothing when the user is not authenticated', async () => { vi.mocked(useAuth).mockReturnValue(unauthenticated) const { result } = renderHook(() => useWorkspace(), { wrapper }) expect(api.getUserContext).not.toHaveBeenCalled() expect(result.current.workspace).toBeNull() expect(result.current.isLoading).toBe(false) }) it('loads user context on mount when authenticated, with no follow-up workspace fetch', async () => { // H12 step 1: cold load used to be getUserContext() -> getWorkspace(id). // The workspace state this provider needs (id + takePlOnRedDays) now // comes straight from the context response, so getWorkspace must not // be called at all on this path. vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockResolvedValue(userContext) const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.workspace).not.toBeNull()) expect(api.getUserContext).toHaveBeenCalled() expect(api.getWorkspace).not.toHaveBeenCalled() expect(result.current.workspaceId).toBe('ws1') expect(result.current.personalDataUuid).toBe('pd1') expect(result.current.parentName).toBe('Anna') expect(result.current.children).toHaveLength(2) expect(result.current.selectedChildId).toBe('c1') expect(result.current.workspace).toEqual({ id: 'ws1', takePlOnRedDays: false }) expect(localStorage.getItem('workspaceId')).toBe('ws1') expect(result.current.isLoading).toBe(false) }) it('sets an error when loading the user context fails', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockRejectedValue(new Error('context boom')) const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.error).toBe('context boom') expect(result.current.workspace).toBeNull() }) it('sets a non-Error rejection to a generic message when loading the user context fails', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockRejectedValue('nope') const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.error).toBe('Failed to load user context') }) it('defaults to an empty children list when the context response omits children', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) const contextWithoutChildren = { personal_data_uuid: userContext.personal_data_uuid, workspace_id: userContext.workspace_id, parent_name: userContext.parent_name, take_pl_on_red_days: false, } as unknown as api.UserContext vi.mocked(api.getUserContext).mockResolvedValue(contextWithoutChildren) const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.workspace).not.toBeNull()) expect(result.current.children).toEqual([]) expect(result.current.selectedChildId).toBeNull() }) it('refetch sets a non-Error rejection to a generic message', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getWorkspace).mockResolvedValue(workspace) const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.workspace).not.toBeNull()) vi.mocked(api.getWorkspace).mockRejectedValue('nope') act(() => { result.current.refetch() }) await waitFor(() => expect(result.current.error).toBe('Failed to load workspace')) expect(result.current.workspace).toBeNull() }) it('selectChild updates the selected child id', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getWorkspace).mockResolvedValue(workspace) const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.selectedChildId).toBe('c1')) act(() => { result.current.selectChild('c2') }) expect(result.current.selectedChildId).toBe('c2') }) it('setWorkspaceId stores the id, updates state, and fetches that workspace directly', async () => { // P2 in the frontend audit: this used to rely on a reactive effect that // only fired while `workspace` was still null, so switching workspaces // after the initial load silently never fetched the new one. Fetching // explicitly here fixes that without reintroducing the cold-load // duplicate GET /workspace the same effect used to cause. vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getWorkspace).mockResolvedValue(workspace) const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.workspace).not.toBeNull()) const otherWorkspace: Workspace = { ...workspace, id: 'ws2', name: 'Other workspace' } vi.mocked(api.getWorkspace).mockResolvedValue(otherWorkspace) vi.mocked(api.getWorkspace).mockClear() act(() => { result.current.setWorkspaceId('ws2') }) expect(result.current.workspaceId).toBe('ws2') expect(localStorage.getItem('workspaceId')).toBe('ws2') await waitFor(() => expect(api.getWorkspace).toHaveBeenCalledWith('ws2')) await waitFor(() => expect(result.current.workspace).toEqual(otherWorkspace)) }) it('refetch re-fetches the current workspace', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getWorkspace).mockResolvedValue(workspace) const { result } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.workspace).not.toBeNull()) vi.mocked(api.getWorkspace).mockClear() act(() => { result.current.refetch() }) await waitFor(() => expect(api.getWorkspace).toHaveBeenCalledWith('ws1')) }) it('refetch is a no-op without a workspaceId', () => { vi.mocked(useAuth).mockReturnValue(unauthenticated) const { result } = renderHook(() => useWorkspace(), { wrapper }) act(() => { result.current.refetch() }) expect(api.getWorkspace).not.toHaveBeenCalled() }) it('clears identity/workspace state on logout so it cannot leak into the next session', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getWorkspace).mockResolvedValue(workspace) const { result, rerender } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.workspace).not.toBeNull()) expect(result.current.parentName).toBe('Anna') vi.mocked(useAuth).mockReturnValue(unauthenticated) rerender() await waitFor(() => expect(result.current.workspace).toBeNull()) expect(result.current.workspaceId).toBeNull() expect(result.current.parentName).toBeNull() expect(result.current.personalDataUuid).toBeNull() expect(result.current.children).toEqual([]) expect(result.current.selectedChildId).toBeNull() expect(localStorage.getItem('workspaceId')).toBeNull() }) it('refetches on a fresh login even when stale data from a previous session is already present', async () => { // Regression for a login-swap bug: a previous guard only refetched when // workspaceId/children looked empty, so logging in as a *different* // user after a previous session's data was already cached here never // refetched at all, and the new session silently rendered the old // account's workspace/name/children. vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getWorkspace).mockResolvedValue(workspace) const { result, rerender } = renderHook(() => useWorkspace(), { wrapper }) await waitFor(() => expect(result.current.parentName).toBe('Anna')) vi.mocked(useAuth).mockReturnValue(unauthenticated) rerender() await waitFor(() => expect(result.current.workspace).toBeNull()) const otherUserContext: api.UserContext = { personal_data_uuid: 'pd2', workspace_id: 'ws2', parent_name: 'Alex', children: [{ id: 'c3', name: 'Child Three', birth_date: '2026-01-01' }], take_pl_on_red_days: true, } vi.mocked(api.getUserContext).mockResolvedValue(otherUserContext) vi.mocked(api.getUserContext).mockClear() vi.mocked(useAuth).mockReturnValue(authenticated) rerender() await waitFor(() => expect(result.current.parentName).toBe('Alex')) expect(api.getUserContext).toHaveBeenCalled() expect(result.current.workspaceId).toBe('ws2') expect(result.current.personalDataUuid).toBe('pd2') expect(result.current.selectedChildId).toBe('c3') expect(result.current.workspace).toEqual({ id: 'ws2', takePlOnRedDays: true }) }) it('discards a refreshContext response that resolves after logout (S4)', async () => { vi.mocked(useAuth).mockReturnValue(authenticated) let resolveContext: (value: api.UserContext) => void = () => {} const pending = new Promise((resolve) => { resolveContext = resolve }) vi.mocked(api.getUserContext).mockReturnValue(pending) const { result, rerender } = renderHook(() => useWorkspace(), { wrapper }) // Logout happens while the initial refreshContext() call is still // in flight (e.g. a slow /auth/context response). vi.mocked(useAuth).mockReturnValue(unauthenticated) rerender() await waitFor(() => expect(result.current.workspace).toBeNull()) expect(result.current.workspaceId).toBeNull() // The stale response arrives after logout — it must be discarded // rather than repopulating this account's context. await act(async () => { resolveContext(userContext) await pending }) expect(result.current.workspaceId).toBeNull() expect(result.current.parentName).toBeNull() expect(result.current.personalDataUuid).toBeNull() expect(result.current.children).toEqual([]) expect(localStorage.getItem('workspaceId')).toBeNull() }) it('throws when useWorkspace is used outside a WorkspaceProvider', () => { vi.mocked(useAuth).mockReturnValue(unauthenticated) expect(() => renderHook(() => useWorkspace())).toThrow( 'useWorkspace must be used inside ', ) }) it('bumpPlanVersion increments planVersion, giving hooks watching it a reason to refetch', () => { vi.mocked(useAuth).mockReturnValue(unauthenticated) const { result } = renderHook(() => useWorkspace(), { wrapper }) expect(result.current.planVersion).toBe(0) act(() => { result.current.bumpPlanVersion() }) expect(result.current.planVersion).toBe(1) act(() => { result.current.bumpPlanVersion() }) expect(result.current.planVersion).toBe(2) }) })