Skip to content

Commit fdeb1c8

Browse files
test(storage): add storage utils tests and optional validation; extend todo-context tests for hydration + persistence
Co-authored-by: Jake Ruesink <jake@lambdacurry.dev>
1 parent d0927af commit fdeb1c8

File tree

5 files changed

+212
-3
lines changed

5 files changed

+212
-3
lines changed

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
22
import { render, screen, act } from '@testing-library/react';
33
import { TodoProvider, useTodoStore, getFilteredTodos } from '../todo-context';
44
import type { Todo } from '@todo-starter/utils';
5+
import * as Utils from '@todo-starter/utils';
56

67
// Mock crypto.randomUUID for consistent testing
78
Object.defineProperty(global, 'crypto', {
@@ -75,6 +76,21 @@ function renderWithProvider() {
7576
}
7677

7778
describe('todo-context', () => {
79+
const STORAGE_KEY = 'todo-app/state@v1';
80+
const ORIGINAL_ENV = process.env.NODE_ENV;
81+
82+
beforeEach(() => {
83+
// allow storage helpers to operate by switching env off 'test' for these tests
84+
process.env.NODE_ENV = 'development';
85+
try { window.localStorage.removeItem(STORAGE_KEY); } catch {}
86+
});
87+
88+
afterEach(() => {
89+
// restore jsdom localStorage cleanliness and env
90+
process.env.NODE_ENV = ORIGINAL_ENV;
91+
try { window.localStorage.removeItem(STORAGE_KEY); } catch {}
92+
});
93+
7894
describe('TodoProvider and useTodoStore', () => {
7995
it('provides initial todos', () => {
8096
renderWithProvider();
@@ -209,4 +225,50 @@ describe('todo-context', () => {
209225
expect(filtered[0].completed).toBe(true);
210226
});
211227
});
228+
229+
it('hydrates and revives date instances on mount when persisted state exists', () => {
230+
const seeded = {
231+
todos: [
232+
{ id: 'x', text: 'seed', completed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
233+
],
234+
filter: 'all' as const
235+
};
236+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(seeded));
237+
238+
renderWithProvider();
239+
240+
// Access via UI to ensure hydration occurred
241+
expect(screen.getByTestId('todos-count')).toHaveTextContent('1');
242+
});
243+
244+
it('persists on addTodo, toggleTodo, setFilter', () => {
245+
const spy = vi.spyOn(Utils, 'saveToStorage');
246+
247+
renderWithProvider();
248+
249+
act(() => { screen.getByTestId('add-todo').click(); });
250+
act(() => { screen.getByTestId('toggle-todo').click(); });
251+
act(() => { screen.getByTestId('set-filter').click(); });
252+
253+
// Called multiple times through effect
254+
expect(spy).toHaveBeenCalled();
255+
256+
spy.mockRestore();
257+
});
258+
259+
it('no SSR errors when window/localStorage not available (guarded in utils)', () => {
260+
// Simulate storage access throwing
261+
const original = window.localStorage;
262+
// @ts-ignore - override for test
263+
Object.defineProperty(window, 'localStorage', {
264+
get() { throw new Error('unavailable'); },
265+
configurable: true
266+
});
267+
268+
// Should not throw during render/mount due to guard
269+
expect(() => renderWithProvider()).not.toThrow();
270+
271+
// restore
272+
Object.defineProperty(window, 'localStorage', { value: original, configurable: true });
273+
});
212274
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
beforeEach(() => {
11+
// Ensure clean slate
12+
try {
13+
window.localStorage.removeItem(KEY);
14+
} catch {
15+
// ignore
16+
}
17+
});
18+
19+
afterEach(() => {
20+
process.env.NODE_ENV = ORIGINAL_ENV;
21+
try {
22+
window.localStorage.removeItem(KEY);
23+
} catch {
24+
// ignore
25+
}
26+
});
27+
28+
it('SSR/test guard disables storage (returns fallback in test env)', () => {
29+
// In vitest, NODE_ENV is "test" by default. Verify guard path returns fallback.
30+
window.localStorage.setItem(KEY, JSON.stringify({ value: 123 }));
31+
const result = loadFromStorage(KEY, { value: 999 });
32+
expect(result).toEqual({ value: 999 });
33+
});
34+
35+
it('Malformed JSON returns fallback', () => {
36+
// Enable storage access by switching to a non-test env for this test
37+
process.env.NODE_ENV = 'development';
38+
// Ensure localStorage exists in case test env didn't provide it
39+
if (typeof window === 'undefined' || !('localStorage' in window)) {
40+
// @ts-ignore
41+
global.window = {} as any;
42+
}
43+
if (!('localStorage' in window)) {
44+
const store = new Map<string, string>();
45+
Object.defineProperty(window, 'localStorage', {
46+
value: {
47+
getItem: (k: string) => store.get(k) ?? null,
48+
setItem: (k: string, v: string) => void store.set(k, v),
49+
removeItem: (k: string) => void store.delete(k)
50+
},
51+
configurable: true
52+
});
53+
}
54+
window.localStorage.setItem(KEY, '{not json');
55+
const result = loadFromStorage(KEY, { good: true });
56+
expect(result).toEqual({ good: true });
57+
});
58+
59+
it('save/remove round-trip behavior works', () => {
60+
process.env.NODE_ENV = 'development';
61+
// Ensure localStorage exists (same polyfill as above)
62+
if (typeof window === 'undefined' || !('localStorage' in window)) {
63+
// @ts-ignore
64+
global.window = {} as any;
65+
}
66+
if (!('localStorage' in window)) {
67+
const store = new Map<string, string>();
68+
Object.defineProperty(window, 'localStorage', {
69+
value: {
70+
getItem: (k: string) => store.get(k) ?? null,
71+
setItem: (k: string, v: string) => void store.set(k, v),
72+
removeItem: (k: string) => void store.delete(k)
73+
},
74+
configurable: true
75+
});
76+
}
77+
78+
const value = { a: 1, b: 'two' };
79+
saveToStorage(KEY, value);
80+
81+
const loaded = loadFromStorage<typeof value | null>(KEY, null);
82+
expect(loaded).toEqual(value);
83+
84+
removeFromStorage(KEY);
85+
const afterRemove = loadFromStorage<typeof value | null>(KEY, null);
86+
expect(afterRemove).toBeNull();
87+
});
88+
89+
it('validate guard: rejects invalid shape and returns fallback', () => {
90+
process.env.NODE_ENV = 'development';
91+
const store = new Map<string, string>();
92+
Object.defineProperty(window, 'localStorage', {
93+
value: {
94+
getItem: (k: string) => store.get(k) ?? null,
95+
setItem: (k: string, v: string) => void store.set(k, v),
96+
removeItem: (k: string) => void store.delete(k)
97+
},
98+
configurable: true
99+
});
100+
101+
window.localStorage.setItem(KEY, JSON.stringify({ nope: true }));
102+
103+
const fallback = { ok: true };
104+
const result = loadFromStorage(KEY, fallback, (v): v is typeof fallback => typeof (v as any).ok === 'boolean');
105+
expect(result).toEqual(fallback);
106+
});
107+
108+
it('validate guard: accepts valid shape', () => {
109+
process.env.NODE_ENV = 'development';
110+
const store = new Map<string, string>();
111+
Object.defineProperty(window, 'localStorage', {
112+
value: {
113+
getItem: (k: string) => store.get(k) ?? null,
114+
setItem: (k: string, v: string) => void store.set(k, v),
115+
removeItem: (k: string) => void store.delete(k)
116+
},
117+
configurable: true
118+
});
119+
120+
const value = { ok: true };
121+
window.localStorage.setItem(KEY, JSON.stringify(value));
122+
123+
const result = loadFromStorage(KEY, { ok: false }, (v): v is typeof value => typeof (v as any).ok === 'boolean');
124+
expect(result).toEqual(value);
125+
});
126+
});

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: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,25 @@ function getStorage(): StorageLike | null {
1313
}
1414
}
1515

16-
export function loadFromStorage<T>(key: string, fallback: T): T {
16+
export function loadFromStorage<T>(key: string, fallback: T): T;
17+
export function loadFromStorage<T>(
18+
key: string,
19+
fallback: T,
20+
validate: (value: unknown) => value is T | boolean
21+
): T;
22+
export function loadFromStorage<T>(
23+
key: string,
24+
fallback: T,
25+
validate?: (value: unknown) => value is T | boolean
26+
): T {
1727
const storage = getStorage();
1828
if (!storage) return fallback;
1929
try {
2030
const raw = storage.getItem(key);
2131
if (!raw) return fallback;
22-
return JSON.parse(raw) as T;
32+
const parsed = JSON.parse(raw) as unknown;
33+
if (validate && !validate(parsed)) return fallback; // Add optional validation guard
34+
return parsed as T;
2335
} catch {
2436
return fallback;
2537
}
@@ -44,4 +56,3 @@ export function removeFromStorage(key: string): void {
4456
// ignore
4557
}
4658
}
47-

packages/utils/vitest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
environment: 'jsdom'
6+
}
7+
});
8+

0 commit comments

Comments
 (0)