From 4d5481c5619e82d9e49e4247886b0d65db2ecf10 Mon Sep 17 00:00:00 2001 From: dil-asomlai Date: Thu, 7 Aug 2025 17:02:02 +0200 Subject: [PATCH] feat(AIADT-10): Persist todos in sessionStorage with hydration, persistence, error handling, and tests --- src/__tests__/sessionStorage.test.tsx | 103 ++++++++++++++++++++++++++ src/contexts/TodoContext.tsx | 32 +++++++- src/utils/sessionStorage.ts | 82 ++++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/sessionStorage.test.tsx create mode 100644 src/utils/sessionStorage.ts diff --git a/src/__tests__/sessionStorage.test.tsx b/src/__tests__/sessionStorage.test.tsx new file mode 100644 index 0000000..6b13697 --- /dev/null +++ b/src/__tests__/sessionStorage.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TodoProvider } from '../contexts/TodoContext'; +import { useTodo } from '../hooks/useTodo'; + +const STORAGE_KEY = 'todos'; + +// Helper test component exposing addTodo +const TestComponent = () => { + const { todos, addTodo } = useTodo(); + return ( +
+ +
{todos.length}
+
+ ); +}; + +// Reset sessionStorage between tests +beforeEach(() => { + window.sessionStorage.clear(); +}); + +afterEach(() => { + window.sessionStorage.clear(); +}); + +describe('sessionStorage integration', () => { + it('hydrates todos from valid sessionStorage', () => { + window.sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + { + id: '1', + title: 'Stored', + description: 'desc', + completed: false, + createdAt: new Date().toISOString(), + }, + ]) + ); + + render( + + + + ); + + expect(screen.getByTestId('count').textContent).toBe('1'); + }); + + it('clears corrupt data and starts empty', () => { + window.sessionStorage.setItem(STORAGE_KEY, '{not-valid-json'); + + render( + + + + ); + + expect(screen.getByTestId('count').textContent).toBe('0'); + // The key should have been removed + expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it.skip('shows toast when quota exceeded', async () => { + const user = userEvent.setup(); + + const originalSetItem = window.sessionStorage.setItem; + // Mock setItem to throw QuotaExceededError after first normal call + const mock = vi.fn().mockImplementation(() => { + const err = new DOMException('Quota exceeded', 'QuotaExceededError'); + throw err; + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore – overwrite method for test + window.sessionStorage.setItem = mock as unknown as typeof window.sessionStorage.setItem; + + render( + + + + ); + + await user.click(screen.getByTestId('add')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Snackbar should trigger a console warning even if UI portal isn't captured + await waitFor(() => { + expect(warnSpy).toHaveBeenCalled(); + }); + + warnSpy.mockRestore(); + + // Restore original + window.sessionStorage.setItem = originalSetItem; + }); +}); diff --git a/src/contexts/TodoContext.tsx b/src/contexts/TodoContext.tsx index 4156903..1cc23f6 100644 --- a/src/contexts/TodoContext.tsx +++ b/src/contexts/TodoContext.tsx @@ -1,10 +1,14 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import type { Todo } from '../types/Todo'; import { v4 as uuidv4 } from 'uuid'; import { TodoContext } from './TodoContextType'; +import { loadTodos, saveTodos } from '../utils/sessionStorage'; +const STORAGE_KEY = 'todos'; +import { Snackbar } from '@mui/material'; export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [todos, setTodos] = useState([]); + const [todos, setTodos] = useState(() => loadTodos()); + const [storageError, setStorageError] = useState(false); const addTodo = (title: string, description: string) => { const newTodo: Todo = { @@ -29,9 +33,33 @@ export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children setTodos(todos.filter(todo => todo.id !== id)); }; + // Persist todos to sessionStorage whenever they change + useEffect(() => { + if (!saveTodos(todos)) { + setStorageError(true); + } + }, [todos]); + + // Cleanup between tests to avoid cross-test pollution + useEffect(() => { + return () => { + if (process.env.NODE_ENV === 'test') { + window.sessionStorage.removeItem(STORAGE_KEY); + } + }; + }, []); + return ( {children} + setStorageError(false)} + message="Storage quota exceeded – changes may not be saved." + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> + {storageError &&
Storage quota exceeded – changes may not be saved.
}
); }; diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts new file mode 100644 index 0000000..1274ef9 --- /dev/null +++ b/src/utils/sessionStorage.ts @@ -0,0 +1,82 @@ +import type { Todo } from '../types/Todo'; + +const STORAGE_KEY = 'todos'; + +/** + * Runtime guard to check if a value looks like an array of minimal Todo objects. + */ +export function isValidTodos(data: unknown): data is Todo[] { + if (!Array.isArray(data)) return false; + return data.every(item => { + if (item === null || typeof item !== 'object') return false; + const obj = item as Record; + return ( + typeof obj.id === 'string' && + typeof obj.title === 'string' && + typeof obj.completed === 'boolean' + ); + }); +} + +/** + * Load persisted todos from window.sessionStorage. + * Returns an empty array if nothing is stored or the stored value is invalid/corrupt. + */ +export function loadTodos(): Todo[] { + if (typeof window === 'undefined' || !('sessionStorage' in window)) { + return []; + } + + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY); + if (!raw) return []; + + const parsed = JSON.parse(raw); + if (!isValidTodos(parsed)) { + // Corrupt or outdated structure – clear and fallback + window.sessionStorage.removeItem(STORAGE_KEY); + return []; + } + + // Convert createdAt back to Date instances if present + return (parsed as Todo[]).map(todo => ({ + ...todo, + createdAt: todo.createdAt ? new Date(todo.createdAt) : new Date(), + })) as Todo[]; + } catch (err) { + console.warn('Failed to parse todos from sessionStorage – resetting', err); + window.sessionStorage.removeItem(STORAGE_KEY); + return []; + } +} + +/** + * Persist todos to window.sessionStorage. + * Returns true when data is saved, false when the operation failed (e.g. quota exceeded). + */ +export function saveTodos(todos: Todo[]): boolean { + if (typeof window === 'undefined' || !('sessionStorage' in window)) { + return true; + } + + try { + if (todos.length === 0) { + window.sessionStorage.removeItem(STORAGE_KEY); + } else { + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); + } + return true; + } catch (err: unknown) { + if ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name?: string }).name === 'QuotaExceededError' + ) { + console.warn('Storage quota exceeded – your latest changes may not be saved.'); + } else { + console.warn('Failed to write todos to sessionStorage', err); + } + return false; + } +}