diff --git a/apps/todo-app/app/lib/__tests__/todo-context.test.tsx b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx index 06403f0..70c919f 100644 --- a/apps/todo-app/app/lib/__tests__/todo-context.test.tsx +++ b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, waitFor } from '@testing-library/react'; import { TodoProvider, useTodoStore, getFilteredTodos } from '../todo-context'; import type { Todo, TodoFilter } from '@todo-starter/utils'; import { removeFromStorage, saveToStorage } from '@todo-starter/utils'; @@ -86,6 +86,33 @@ vi.mock('@todo-starter/utils', async importOriginal => { }); describe('todo-context', () => { + const STORAGE_KEY = 'todo-app/state@v1'; + const ORIGINAL_ENV = process.env.NODE_ENV; + + beforeEach(() => { + // Opt-in to using real localStorage inside tests for this suite + Object.defineProperty(globalThis, '__ALLOW_STORAGE_IN_TESTS__', { value: true, configurable: true }); + // allow storage helpers to operate by switching env off 'test' for these tests + process.env.NODE_ENV = 'development'; + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } + }); + + afterEach(() => { + // restore jsdom localStorage cleanliness and env + process.env.NODE_ENV = ORIGINAL_ENV; + // Remove opt-in flag after each test to avoid cross-suite leakage + Object.defineProperty(globalThis, '__ALLOW_STORAGE_IN_TESTS__', { value: undefined, configurable: true }); + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } + }); + describe('TodoProvider and useTodoStore', () => { beforeEach(() => { // Ensure no persisted state bleeds across tests @@ -253,4 +280,66 @@ describe('todo-context', () => { expect(filtered[0].completed).toBe(true); }); }); + + it('hydrates and revives date instances on mount when persisted state exists', () => { + const seeded = { + todos: [ + { + id: 'x', + text: 'seed', + completed: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + filter: 'all' as const + }; + // Use storage helper (mocked in this suite) to seed persisted state + saveToStorage(STORAGE_KEY, seeded); + + renderWithProvider(); + + // Access via UI to ensure hydration occurred + expect(screen.getByTestId('todos-count')).toHaveTextContent('1'); + }); + + it('persists on addTodo, toggleTodo, setFilter', async () => { + const utils = await import('@todo-starter/utils'); + const spy = vi.spyOn(utils, 'saveToStorage'); + + renderWithProvider(); + + act(() => { + screen.getByTestId('add-todo').click(); + }); + act(() => { + screen.getByTestId('toggle-todo').click(); + }); + act(() => { + screen.getByTestId('set-filter').click(); + }); + + // Called via utils wrapper (effects may be scheduled) + await waitFor(() => expect(spy).toHaveBeenCalled()); + + spy.mockRestore(); + }); + + it('no SSR errors when window/localStorage not available (guarded in utils)', () => { + // Simulate storage access throwing + const original = window.localStorage; + // @ts-ignore - override for test + Object.defineProperty(window, 'localStorage', { + get() { + throw new Error('unavailable'); + }, + configurable: true + }); + + // Should not throw during render/mount due to guard + expect(() => renderWithProvider()).not.toThrow(); + + // restore + Object.defineProperty(window, 'localStorage', { value: original, configurable: true }); + }); }); diff --git a/apps/todo-app/app/lib/todo-context.tsx b/apps/todo-app/app/lib/todo-context.tsx index 79e7439..6cdd4b3 100644 --- a/apps/todo-app/app/lib/todo-context.tsx +++ b/apps/todo-app/app/lib/todo-context.tsx @@ -66,9 +66,7 @@ function todoReducer(state: TodoState, action: TodoAction): TodoState { return { ...state, todos: state.todos.map(todo => - todo.id === action.payload - ? { ...todo, completed: !todo.completed, updatedAt: new Date() } - : todo + todo.id === action.payload ? { ...todo, completed: !todo.completed, updatedAt: new Date() } : todo ) }; case 'DELETE_TODO': @@ -80,9 +78,7 @@ function todoReducer(state: TodoState, action: TodoAction): TodoState { return { ...state, todos: state.todos.map(todo => - todo.id === action.payload.id - ? { ...todo, text: action.payload.text.trim(), updatedAt: new Date() } - : todo + todo.id === action.payload.id ? { ...todo, text: action.payload.text.trim(), updatedAt: new Date() } : todo ) }; case 'SET_FILTER': @@ -151,17 +147,12 @@ export function TodoProvider({ children }: { children: ReactNode }) { addTodo: (text: string) => dispatch({ type: 'ADD_TODO', payload: text }), toggleTodo: (id: string) => dispatch({ type: 'TOGGLE_TODO', payload: id }), deleteTodo: (id: string) => dispatch({ type: 'DELETE_TODO', payload: id }), - updateTodo: (id: string, text: string) => - dispatch({ type: 'UPDATE_TODO', payload: { id, text } }), + updateTodo: (id: string, text: string) => dispatch({ type: 'UPDATE_TODO', payload: { id, text } }), setFilter: (filter: TodoFilter) => dispatch({ type: 'SET_FILTER', payload: filter }), clearCompleted: () => dispatch({ type: 'CLEAR_COMPLETED' }) }; - return ( - - {children} - - ); + return {children}; } // Custom hook to use the todo context diff --git a/apps/todo-app/tsconfig.json b/apps/todo-app/tsconfig.json index 2c1ade1..83c49b9 100644 --- a/apps/todo-app/tsconfig.json +++ b/apps/todo-app/tsconfig.json @@ -2,6 +2,11 @@ "extends": "../../tsconfig.json", "compilerOptions": { "baseUrl": ".", + "types": [ + "vitest/globals", + "@testing-library/jest-dom", + "vite/client" + ], "paths": { "~/*": ["./app/*"], "@todo-starter/ui": ["../../packages/ui/src"], @@ -21,8 +26,6 @@ "exclude": [ "node_modules", "build", - "dist", - "**/*.test.ts", - "**/*.test.tsx" + "dist" ] } diff --git a/packages/utils/src/__tests__/storage.test.ts b/packages/utils/src/__tests__/storage.test.ts new file mode 100644 index 0000000..2ab74a9 --- /dev/null +++ b/packages/utils/src/__tests__/storage.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { loadFromStorage, saveToStorage, removeFromStorage } from '@todo-starter/utils'; + +const KEY = 'test/storage@v1'; + +// Save original env to restore between tests +const ORIGINAL_ENV = process.env.NODE_ENV; + +describe('storage utils', () => { + function ensureWindowWithLocalStorage() { + // Ensure a Window-like global for Node environment + if (typeof window === 'undefined') { + Object.defineProperty(globalThis, 'window', { + // unknown avoids explicit any; cast to Window shape for tests + value: {} as unknown as Window & typeof globalThis, + configurable: true + }); + } + // Polyfill localStorage if missing + if (!('localStorage' in window)) { + const store = new Map(); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + } + }, + configurable: true + }); + } + } + + beforeEach(() => { + // Ensure clean slate + try { + window.localStorage.removeItem(KEY); + } catch { + // ignore + } + }); + + afterEach(() => { + process.env.NODE_ENV = ORIGINAL_ENV; + try { + window.localStorage.removeItem(KEY); + } catch { + // ignore + } + }); + + it('SSR/test guard disables storage (returns fallback in test env)', () => { + // In vitest, NODE_ENV is "test" by default. Verify guard path returns fallback. + window.localStorage.setItem(KEY, JSON.stringify({ value: 123 })); + const result = loadFromStorage(KEY, { value: 999 }); + expect(result).toEqual({ value: 999 }); + }); + + it('Malformed JSON returns fallback', () => { + // Enable storage access by switching to a non-test env for this test + process.env.NODE_ENV = 'development'; + ensureWindowWithLocalStorage(); + window.localStorage.setItem(KEY, '{not json'); + const result = loadFromStorage(KEY, { good: true }); + expect(result).toEqual({ good: true }); + }); + + it('save/remove round-trip behavior works', () => { + process.env.NODE_ENV = 'development'; + ensureWindowWithLocalStorage(); + + const value = { a: 1, b: 'two' }; + saveToStorage(KEY, value); + + const loaded = loadFromStorage(KEY, null); + expect(loaded).toEqual(value); + + removeFromStorage(KEY); + const afterRemove = loadFromStorage(KEY, null); + expect(afterRemove).toBeNull(); + }); + + it('validate guard: rejects invalid shape and returns fallback', () => { + process.env.NODE_ENV = 'development'; + ensureWindowWithLocalStorage(); + + window.localStorage.setItem(KEY, JSON.stringify({ nope: true })); + + const fallback = { ok: true }; + const result = loadFromStorage( + KEY, + fallback, + (v): v is typeof fallback => + typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean' + ); + expect(result).toEqual(fallback); + }); + + it('validate guard: accepts valid shape', () => { + process.env.NODE_ENV = 'development'; + ensureWindowWithLocalStorage(); + + const value = { ok: true }; + window.localStorage.setItem(KEY, JSON.stringify(value)); + + const result = loadFromStorage( + KEY, + { ok: false }, + (v): v is typeof value => + typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean' + ); + expect(result).toEqual(value); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 176d12f..e3de15e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,3 +2,5 @@ export { cn } from './cn'; export type { Todo, TodoFilter, TodoStore } from './types'; export { loadFromStorage, saveToStorage, removeFromStorage } from './storage'; export type { StorageLike } from './storage'; +// Re-export type for validator usage in tests and apps +export type { } from './storage'; diff --git a/packages/utils/src/storage.ts b/packages/utils/src/storage.ts index 0829cef..9778326 100644 --- a/packages/utils/src/storage.ts +++ b/packages/utils/src/storage.ts @@ -3,8 +3,13 @@ export type StorageLike = Pick; function getStorage(): StorageLike | null { - // Disable in test environments to keep tests deterministic - if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') return null; + // Allow tests to opt-in to real storage by setting a runtime flag + const allowInTests = + typeof globalThis !== 'undefined' && + // Use `unknown` and index signature to avoid `any` + (globalThis as unknown as Record).__ALLOW_STORAGE_IN_TESTS__ === true; + // Disable in test environments unless explicitly allowed + if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test' && !allowInTests) return null; if (typeof window === 'undefined') return null; try { return window.localStorage; @@ -13,13 +18,17 @@ function getStorage(): StorageLike | null { } } -export function loadFromStorage(key: string, fallback: T): T { +export function loadFromStorage(key: string, fallback: T): T; +export function loadFromStorage(key: string, fallback: T, validate: (value: unknown) => value is T | boolean): T; +export function loadFromStorage(key: string, fallback: T, validate?: (value: unknown) => value is T | boolean): T { const storage = getStorage(); if (!storage) return fallback; try { const raw = storage.getItem(key); if (!raw) return fallback; - return JSON.parse(raw) as T; + const parsed = JSON.parse(raw) as unknown; + if (validate && !validate(parsed)) return fallback; // Add optional validation guard + return parsed as T; } catch { return fallback; } @@ -44,4 +53,3 @@ export function removeFromStorage(key: string): void { // ignore } } - diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts index 414d975..d88b63c 100644 --- a/packages/utils/vitest.config.ts +++ b/packages/utils/vitest.config.ts @@ -2,7 +2,15 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - globals: true, - environment: 'node' - } -}); + // Default to jsdom for React + DOM/localStorage tests + environment: 'jsdom', + + // Optional: run Node env for server-only utils tests + environmentMatchGlobs: [ + ['packages/utils/**', 'node'], + ], + + // Optional (we already import from 'vitest') + // globals: true, + }, +}); \ No newline at end of file