Skip to content

Commit 073512b

Browse files
authored
Merge pull request #12 from lambda-curry/codegen/sta-10-testing-add-storage-hydration-tests-utils-todo-context
2 parents db102d2 + 8b2d28b commit 073512b

File tree

7 files changed

+245
-27
lines changed

7 files changed

+245
-27
lines changed

apps/todo-app/app/lib/__tests__/todo-context.test.tsx

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, it, expect, vi } from 'vitest';
2-
import { render, screen, act } from '@testing-library/react';
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen, act, waitFor } from '@testing-library/react';
33
import { TodoProvider, useTodoStore, getFilteredTodos } from '../todo-context';
44
import type { Todo, TodoFilter } from '@todo-starter/utils';
55
import { removeFromStorage, saveToStorage } from '@todo-starter/utils';
@@ -86,6 +86,33 @@ vi.mock('@todo-starter/utils', async importOriginal => {
8686
});
8787

8888
describe('todo-context', () => {
89+
const STORAGE_KEY = 'todo-app/state@v1';
90+
const ORIGINAL_ENV = process.env.NODE_ENV;
91+
92+
beforeEach(() => {
93+
// Opt-in to using real localStorage inside tests for this suite
94+
Object.defineProperty(globalThis, '__ALLOW_STORAGE_IN_TESTS__', { value: true, configurable: true });
95+
// allow storage helpers to operate by switching env off 'test' for these tests
96+
process.env.NODE_ENV = 'development';
97+
try {
98+
window.localStorage.removeItem(STORAGE_KEY);
99+
} catch {
100+
/* ignore */
101+
}
102+
});
103+
104+
afterEach(() => {
105+
// restore jsdom localStorage cleanliness and env
106+
process.env.NODE_ENV = ORIGINAL_ENV;
107+
// Remove opt-in flag after each test to avoid cross-suite leakage
108+
Object.defineProperty(globalThis, '__ALLOW_STORAGE_IN_TESTS__', { value: undefined, configurable: true });
109+
try {
110+
window.localStorage.removeItem(STORAGE_KEY);
111+
} catch {
112+
/* ignore */
113+
}
114+
});
115+
89116
describe('TodoProvider and useTodoStore', () => {
90117
beforeEach(() => {
91118
// Ensure no persisted state bleeds across tests
@@ -253,4 +280,66 @@ describe('todo-context', () => {
253280
expect(filtered[0].completed).toBe(true);
254281
});
255282
});
283+
284+
it('hydrates and revives date instances on mount when persisted state exists', () => {
285+
const seeded = {
286+
todos: [
287+
{
288+
id: 'x',
289+
text: 'seed',
290+
completed: false,
291+
createdAt: new Date().toISOString(),
292+
updatedAt: new Date().toISOString()
293+
}
294+
],
295+
filter: 'all' as const
296+
};
297+
// Use storage helper (mocked in this suite) to seed persisted state
298+
saveToStorage(STORAGE_KEY, seeded);
299+
300+
renderWithProvider();
301+
302+
// Access via UI to ensure hydration occurred
303+
expect(screen.getByTestId('todos-count')).toHaveTextContent('1');
304+
});
305+
306+
it('persists on addTodo, toggleTodo, setFilter', async () => {
307+
const utils = await import('@todo-starter/utils');
308+
const spy = vi.spyOn(utils, 'saveToStorage');
309+
310+
renderWithProvider();
311+
312+
act(() => {
313+
screen.getByTestId('add-todo').click();
314+
});
315+
act(() => {
316+
screen.getByTestId('toggle-todo').click();
317+
});
318+
act(() => {
319+
screen.getByTestId('set-filter').click();
320+
});
321+
322+
// Called via utils wrapper (effects may be scheduled)
323+
await waitFor(() => expect(spy).toHaveBeenCalled());
324+
325+
spy.mockRestore();
326+
});
327+
328+
it('no SSR errors when window/localStorage not available (guarded in utils)', () => {
329+
// Simulate storage access throwing
330+
const original = window.localStorage;
331+
// @ts-ignore - override for test
332+
Object.defineProperty(window, 'localStorage', {
333+
get() {
334+
throw new Error('unavailable');
335+
},
336+
configurable: true
337+
});
338+
339+
// Should not throw during render/mount due to guard
340+
expect(() => renderWithProvider()).not.toThrow();
341+
342+
// restore
343+
Object.defineProperty(window, 'localStorage', { value: original, configurable: true });
344+
});
256345
});

apps/todo-app/app/lib/todo-context.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@ function todoReducer(state: TodoState, action: TodoAction): TodoState {
6666
return {
6767
...state,
6868
todos: state.todos.map(todo =>
69-
todo.id === action.payload
70-
? { ...todo, completed: !todo.completed, updatedAt: new Date() }
71-
: todo
69+
todo.id === action.payload ? { ...todo, completed: !todo.completed, updatedAt: new Date() } : todo
7270
)
7371
};
7472
case 'DELETE_TODO':
@@ -80,9 +78,7 @@ function todoReducer(state: TodoState, action: TodoAction): TodoState {
8078
return {
8179
...state,
8280
todos: state.todos.map(todo =>
83-
todo.id === action.payload.id
84-
? { ...todo, text: action.payload.text.trim(), updatedAt: new Date() }
85-
: todo
81+
todo.id === action.payload.id ? { ...todo, text: action.payload.text.trim(), updatedAt: new Date() } : todo
8682
)
8783
};
8884
case 'SET_FILTER':
@@ -151,17 +147,12 @@ export function TodoProvider({ children }: { children: ReactNode }) {
151147
addTodo: (text: string) => dispatch({ type: 'ADD_TODO', payload: text }),
152148
toggleTodo: (id: string) => dispatch({ type: 'TOGGLE_TODO', payload: id }),
153149
deleteTodo: (id: string) => dispatch({ type: 'DELETE_TODO', payload: id }),
154-
updateTodo: (id: string, text: string) =>
155-
dispatch({ type: 'UPDATE_TODO', payload: { id, text } }),
150+
updateTodo: (id: string, text: string) => dispatch({ type: 'UPDATE_TODO', payload: { id, text } }),
156151
setFilter: (filter: TodoFilter) => dispatch({ type: 'SET_FILTER', payload: filter }),
157152
clearCompleted: () => dispatch({ type: 'CLEAR_COMPLETED' })
158153
};
159154

160-
return (
161-
<TodoContext.Provider value={contextValue}>
162-
{children}
163-
</TodoContext.Provider>
164-
);
155+
return <TodoContext.Provider value={contextValue}>{children}</TodoContext.Provider>;
165156
}
166157

167158
// Custom hook to use the todo context

apps/todo-app/tsconfig.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
44
"baseUrl": ".",
5+
"types": [
6+
"vitest/globals",
7+
"@testing-library/jest-dom",
8+
"vite/client"
9+
],
510
"paths": {
611
"~/*": ["./app/*"],
712
"@todo-starter/ui": ["../../packages/ui/src"],
@@ -21,8 +26,6 @@
2126
"exclude": [
2227
"node_modules",
2328
"build",
24-
"dist",
25-
"**/*.test.ts",
26-
"**/*.test.tsx"
29+
"dist"
2730
]
2831
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { loadFromStorage, saveToStorage, removeFromStorage } from '@todo-starter/utils';
3+
4+
const KEY = 'test/storage@v1';
5+
6+
// Save original env to restore between tests
7+
const ORIGINAL_ENV = process.env.NODE_ENV;
8+
9+
describe('storage utils', () => {
10+
function ensureWindowWithLocalStorage() {
11+
// Ensure a Window-like global for Node environment
12+
if (typeof window === 'undefined') {
13+
Object.defineProperty(globalThis, 'window', {
14+
// unknown avoids explicit any; cast to Window shape for tests
15+
value: {} as unknown as Window & typeof globalThis,
16+
configurable: true
17+
});
18+
}
19+
// Polyfill localStorage if missing
20+
if (!('localStorage' in window)) {
21+
const store = new Map<string, string>();
22+
Object.defineProperty(window, 'localStorage', {
23+
value: {
24+
getItem: (k: string) => store.get(k) ?? null,
25+
setItem: (k: string, v: string) => {
26+
store.set(k, v);
27+
},
28+
removeItem: (k: string) => {
29+
store.delete(k);
30+
}
31+
},
32+
configurable: true
33+
});
34+
}
35+
}
36+
37+
beforeEach(() => {
38+
// Ensure clean slate
39+
try {
40+
window.localStorage.removeItem(KEY);
41+
} catch {
42+
// ignore
43+
}
44+
});
45+
46+
afterEach(() => {
47+
process.env.NODE_ENV = ORIGINAL_ENV;
48+
try {
49+
window.localStorage.removeItem(KEY);
50+
} catch {
51+
// ignore
52+
}
53+
});
54+
55+
it('SSR/test guard disables storage (returns fallback in test env)', () => {
56+
// In vitest, NODE_ENV is "test" by default. Verify guard path returns fallback.
57+
window.localStorage.setItem(KEY, JSON.stringify({ value: 123 }));
58+
const result = loadFromStorage(KEY, { value: 999 });
59+
expect(result).toEqual({ value: 999 });
60+
});
61+
62+
it('Malformed JSON returns fallback', () => {
63+
// Enable storage access by switching to a non-test env for this test
64+
process.env.NODE_ENV = 'development';
65+
ensureWindowWithLocalStorage();
66+
window.localStorage.setItem(KEY, '{not json');
67+
const result = loadFromStorage(KEY, { good: true });
68+
expect(result).toEqual({ good: true });
69+
});
70+
71+
it('save/remove round-trip behavior works', () => {
72+
process.env.NODE_ENV = 'development';
73+
ensureWindowWithLocalStorage();
74+
75+
const value = { a: 1, b: 'two' };
76+
saveToStorage(KEY, value);
77+
78+
const loaded = loadFromStorage<typeof value | null>(KEY, null);
79+
expect(loaded).toEqual(value);
80+
81+
removeFromStorage(KEY);
82+
const afterRemove = loadFromStorage<typeof value | null>(KEY, null);
83+
expect(afterRemove).toBeNull();
84+
});
85+
86+
it('validate guard: rejects invalid shape and returns fallback', () => {
87+
process.env.NODE_ENV = 'development';
88+
ensureWindowWithLocalStorage();
89+
90+
window.localStorage.setItem(KEY, JSON.stringify({ nope: true }));
91+
92+
const fallback = { ok: true };
93+
const result = loadFromStorage(
94+
KEY,
95+
fallback,
96+
(v): v is typeof fallback =>
97+
typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean'
98+
);
99+
expect(result).toEqual(fallback);
100+
});
101+
102+
it('validate guard: accepts valid shape', () => {
103+
process.env.NODE_ENV = 'development';
104+
ensureWindowWithLocalStorage();
105+
106+
const value = { ok: true };
107+
window.localStorage.setItem(KEY, JSON.stringify(value));
108+
109+
const result = loadFromStorage(
110+
KEY,
111+
{ ok: false },
112+
(v): v is typeof value =>
113+
typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean'
114+
);
115+
expect(result).toEqual(value);
116+
});
117+
});

packages/utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export { cn } from './cn';
22
export type { Todo, TodoFilter, TodoStore } from './types';
33
export { loadFromStorage, saveToStorage, removeFromStorage } from './storage';
44
export type { StorageLike } from './storage';
5+
// Re-export type for validator usage in tests and apps
6+
export type { } from './storage';

packages/utils/src/storage.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33
export type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
44

55
function getStorage(): StorageLike | null {
6-
// Disable in test environments to keep tests deterministic
7-
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') return null;
6+
// Allow tests to opt-in to real storage by setting a runtime flag
7+
const allowInTests =
8+
typeof globalThis !== 'undefined' &&
9+
// Use `unknown` and index signature to avoid `any`
10+
(globalThis as unknown as Record<string, unknown>).__ALLOW_STORAGE_IN_TESTS__ === true;
11+
// Disable in test environments unless explicitly allowed
12+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test' && !allowInTests) return null;
813
if (typeof window === 'undefined') return null;
914
try {
1015
return window.localStorage;
@@ -13,13 +18,17 @@ function getStorage(): StorageLike | null {
1318
}
1419
}
1520

16-
export function loadFromStorage<T>(key: string, fallback: T): T {
21+
export function loadFromStorage<T>(key: string, fallback: T): T;
22+
export function loadFromStorage<T>(key: string, fallback: T, validate: (value: unknown) => value is T | boolean): T;
23+
export function loadFromStorage<T>(key: string, fallback: T, validate?: (value: unknown) => value is T | boolean): T {
1724
const storage = getStorage();
1825
if (!storage) return fallback;
1926
try {
2027
const raw = storage.getItem(key);
2128
if (!raw) return fallback;
22-
return JSON.parse(raw) as T;
29+
const parsed = JSON.parse(raw) as unknown;
30+
if (validate && !validate(parsed)) return fallback; // Add optional validation guard
31+
return parsed as T;
2332
} catch {
2433
return fallback;
2534
}
@@ -44,4 +53,3 @@ export function removeFromStorage(key: string): void {
4453
// ignore
4554
}
4655
}
47-

packages/utils/vitest.config.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import { defineConfig } from 'vitest/config';
22

33
export default defineConfig({
44
test: {
5-
globals: true,
6-
environment: 'node'
7-
}
8-
});
5+
// Default to jsdom for React + DOM/localStorage tests
6+
environment: 'jsdom',
7+
8+
// Optional: run Node env for server-only utils tests
9+
environmentMatchGlobs: [
10+
['packages/utils/**', 'node'],
11+
],
12+
13+
// Optional (we already import from 'vitest')
14+
// globals: true,
15+
},
16+
});

0 commit comments

Comments
 (0)