Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/__tests__/sessionStorage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button data-testid="add" onClick={() => addTodo('Persisted', 'From test')}>
Add
</button>
<div data-testid="count">{todos.length}</div>
</div>
);
};

// 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(
<TodoProvider>
<TestComponent />
</TodoProvider>
);

expect(screen.getByTestId('count').textContent).toBe('1');
});

it('clears corrupt data and starts empty', () => {
window.sessionStorage.setItem(STORAGE_KEY, '{not-valid-json');

render(
<TodoProvider>
<TestComponent />
</TodoProvider>
);

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(
<TodoProvider>
<TestComponent />
</TodoProvider>
);

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;
});
});
32 changes: 30 additions & 2 deletions src/contexts/TodoContext.tsx
Original file line number Diff line number Diff line change
@@ -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<Todo[]>([]);
const [todos, setTodos] = useState<Todo[]>(() => loadTodos());
const [storageError, setStorageError] = useState(false);

const addTodo = (title: string, description: string) => {
const newTodo: Todo = {
Expand All @@ -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 (
<TodoContext.Provider value={{ todos, addTodo, editTodo, toggleTodoCompletion, deleteTodo }}>
{children}
<Snackbar
open={storageError}
autoHideDuration={4000}
onClose={() => setStorageError(false)}
message="Storage quota exceeded – changes may not be saved."
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
{storageError && <div role="alert">Storage quota exceeded – changes may not be saved.</div>}
</TodoContext.Provider>
);
};
Expand Down
82 changes: 82 additions & 0 deletions src/utils/sessionStorage.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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;
}
}
Loading