From fdeb1c86946d45b5850e1302b0d99db15ede3054 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 02:45:57 +0000 Subject: [PATCH 1/6] test(storage): add storage utils tests and optional validation; extend todo-context tests for hydration + persistence Co-authored-by: Jake Ruesink --- .../app/lib/__tests__/todo-context.test.tsx | 62 +++++++++ packages/utils/src/__tests__/storage.test.ts | 126 ++++++++++++++++++ packages/utils/src/index.ts | 2 + packages/utils/src/storage.ts | 17 ++- packages/utils/vitest.config.ts | 8 ++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 packages/utils/src/__tests__/storage.test.ts create mode 100644 packages/utils/vitest.config.ts 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 deccf7a..584377c 100644 --- a/apps/todo-app/app/lib/__tests__/todo-context.test.tsx +++ b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { TodoProvider, useTodoStore, getFilteredTodos } from '../todo-context'; import type { Todo } from '@todo-starter/utils'; +import * as Utils from '@todo-starter/utils'; // Mock crypto.randomUUID for consistent testing Object.defineProperty(global, 'crypto', { @@ -75,6 +76,21 @@ function renderWithProvider() { } describe('todo-context', () => { + const STORAGE_KEY = 'todo-app/state@v1'; + const ORIGINAL_ENV = process.env.NODE_ENV; + + beforeEach(() => { + // 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 {} + }); + + afterEach(() => { + // restore jsdom localStorage cleanliness and env + process.env.NODE_ENV = ORIGINAL_ENV; + try { window.localStorage.removeItem(STORAGE_KEY); } catch {} + }); + describe('TodoProvider and useTodoStore', () => { it('provides initial todos', () => { renderWithProvider(); @@ -209,4 +225,50 @@ 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 + }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(seeded)); + + renderWithProvider(); + + // Access via UI to ensure hydration occurred + expect(screen.getByTestId('todos-count')).toHaveTextContent('1'); + }); + + it('persists on addTodo, toggleTodo, setFilter', () => { + 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 multiple times through effect + 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/packages/utils/src/__tests__/storage.test.ts b/packages/utils/src/__tests__/storage.test.ts new file mode 100644 index 0000000..4647da0 --- /dev/null +++ b/packages/utils/src/__tests__/storage.test.ts @@ -0,0 +1,126 @@ +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', () => { + 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'; + // Ensure localStorage exists in case test env didn't provide it + if (typeof window === 'undefined' || !('localStorage' in window)) { + // @ts-ignore + global.window = {} as any; + } + 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) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k) + }, + configurable: true + }); + } + 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'; + // Ensure localStorage exists (same polyfill as above) + if (typeof window === 'undefined' || !('localStorage' in window)) { + // @ts-ignore + global.window = {} as any; + } + 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) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k) + }, + configurable: true + }); + } + + 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'; + const store = new Map(); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k) + }, + configurable: true + }); + + window.localStorage.setItem(KEY, JSON.stringify({ nope: true })); + + const fallback = { ok: true }; + const result = loadFromStorage(KEY, fallback, (v): v is typeof fallback => typeof (v as any).ok === 'boolean'); + expect(result).toEqual(fallback); + }); + + it('validate guard: accepts valid shape', () => { + process.env.NODE_ENV = 'development'; + const store = new Map(); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k) + }, + configurable: true + }); + + const value = { ok: true }; + window.localStorage.setItem(KEY, JSON.stringify(value)); + + const result = loadFromStorage(KEY, { ok: false }, (v): v is typeof value => typeof (v as any).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..ea3194b 100644 --- a/packages/utils/src/storage.ts +++ b/packages/utils/src/storage.ts @@ -13,13 +13,25 @@ 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 +56,3 @@ export function removeFromStorage(key: string): void { // ignore } } - diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts new file mode 100644 index 0000000..0509597 --- /dev/null +++ b/packages/utils/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom' + } +}); + From 501a061f8d9897ffe9d3493ca9f0411464b22728 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Fri, 8 Aug 2025 22:31:01 -0500 Subject: [PATCH 2/6] refactor(tests): enhance todo-context tests with improved localStorage handling and cleanup This update refines the todo-context tests by introducing a consistent localStorage polyfill for Node environments, ensuring reliable test execution. It also improves the cleanup logic in the tests to maintain a clean state between runs. Additionally, minor formatting adjustments are made for better readability. --- .../app/lib/__tests__/todo-context.test.tsx | 59 +++++++----- packages/utils/src/__tests__/storage.test.ts | 95 +++++++++---------- 2 files changed, 79 insertions(+), 75 deletions(-) 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 9350414..0c9ab2a 100644 --- a/apps/todo-app/app/lib/__tests__/todo-context.test.tsx +++ b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx @@ -1,8 +1,7 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { TodoProvider, useTodoStore, getFilteredTodos } from '../todo-context'; import type { Todo } from '@todo-starter/utils'; -import * as Utils from '@todo-starter/utils'; // Mock crypto.randomUUID for consistent testing Object.defineProperty(global, 'crypto', { @@ -22,23 +21,15 @@ function TestComponent() { - - -