diff --git a/frontend/src/pages/Items/__tests__/Items.test.tsx b/frontend/src/pages/Items/__tests__/Items.test.tsx index ef27217..3a433d4 100644 --- a/frontend/src/pages/Items/__tests__/Items.test.tsx +++ b/frontend/src/pages/Items/__tests__/Items.test.tsx @@ -52,6 +52,7 @@ describe('Items Page', () => { afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); + vi.useRealTimers(); }); it('shows a loading spinner initially', () => { @@ -187,6 +188,87 @@ describe('Items Page', () => { expect(screen.getByText('—')).toBeInTheDocument(); }); + it('re-fetches items when item.created event arrives', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(1); + + vi.useFakeTimers(); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'New', price: 1.0, created_at: '', updated_at: '' }, + }); + }); + + await act(async () => { + vi.runAllTimers(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + it('re-fetches items when item.updated event arrives', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(1); + + vi.useFakeTimers(); + + act(() => { + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget Pro', price: 29.99, created_at: '', updated_at: '' }, + }); + }); + + await act(async () => { + vi.runAllTimers(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + it('re-fetches items when item.deleted event arrives', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(1); + + vi.useFakeTimers(); + + act(() => { + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); + }); + + await act(async () => { + vi.runAllTimers(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + it('unsubscribes on unmount', async () => { const { mockUnsubscribe } = setupSubscribeMock(); (itemService.list as ReturnType).mockResolvedValue([]); @@ -201,4 +283,242 @@ describe('Items Page', () => { expect(mockUnsubscribe).toHaveBeenCalledTimes(3); }); + + // --------------------------------------------------------------------------- + // Silent-fail: background refresh errors must NOT surface an error alert + // --------------------------------------------------------------------------- + + it('keeps existing items and shows no error when background refresh fails on item.created', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockRejectedValueOnce(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'NewWidget', price: 5.0, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + + it('keeps existing items and shows no error when background refresh fails on item.updated', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockRejectedValueOnce(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget Pro', price: 29.99, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + + it('keeps existing items and shows no error when background refresh fails on item.deleted', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockRejectedValueOnce(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // UI update: DOM must reflect the fresh data returned by the background fetch + // --------------------------------------------------------------------------- + + it('renders new item in table after item.created triggers re-fetch', async () => { + const { handlers } = setupSubscribeMock(); + const updatedItems = [ + ...mockItems, + { id: 3, name: 'NewWidget', price: 5.0, created_at: '2026-01-03T00:00:00Z', updated_at: '2026-01-03T00:00:00Z' }, + ]; + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockResolvedValueOnce(updatedItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + expect(screen.queryByText('NewWidget')).not.toBeInTheDocument(); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'NewWidget', price: 5.0, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(screen.getByText('NewWidget')).toBeInTheDocument(); + }); + expect(screen.getByText('$5.00')).toBeInTheDocument(); + }); + + it('renders updated item data in table after item.updated triggers re-fetch', async () => { + const { handlers } = setupSubscribeMock(); + const updatedItems = [ + { ...mockItems[0], name: 'Widget Pro', price: 29.99 }, + mockItems[1], + ]; + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockResolvedValueOnce(updatedItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget Pro', price: 29.99, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(screen.getByText('Widget Pro')).toBeInTheDocument(); + }); + expect(screen.queryByText('Widget')).not.toBeInTheDocument(); + expect(screen.getByText('$29.99')).toBeInTheDocument(); + }); + + it('removes deleted item from table after item.deleted triggers re-fetch', async () => { + const { handlers } = setupSubscribeMock(); + const updatedItems = [mockItems[1]]; // Widget (id:1) removed by server + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockResolvedValueOnce(updatedItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); + }); + + await waitFor(() => { + expect(screen.queryByText('Widget')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // Rapid successive events: no race condition / state corruption + // --------------------------------------------------------------------------- + + it('clears error state and shows items when background refresh succeeds after initial load failure', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to load items')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 1, name: 'Widget', price: 9.99, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); + + it('handles rapid successive WebSocket events without state corruption', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + vi.useFakeTimers(); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'ItemA', price: 1.0, created_at: '', updated_at: '' }, + }); + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget', price: 9.99, created_at: '', updated_at: '' }, + }); + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 2 } }); + }); + + // All 3 events are coalesced into a single debounced fetch + await act(async () => { + vi.runAllTimers(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(2); // 1 initial + 1 debounced + + // Component must not crash; latest mocked data (mockItems) should be shown + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/Items/index.tsx b/frontend/src/pages/Items/index.tsx index 88329a1..3caf86a 100644 --- a/frontend/src/pages/Items/index.tsx +++ b/frontend/src/pages/Items/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Typography, Box, @@ -32,42 +32,61 @@ const Items = () => { const [error, setError] = useState(null); const [toast, setToast] = useState({ open: false, message: '', severity: 'success' }); const { subscribe } = useWebSocketContext(); + const fetchCounterRef = useRef(0); + const debounceRef = useRef | null>(null); - useEffect(() => { - const fetchItems = async () => { - try { - const data = await itemService.list(); + const fetchItems = useCallback(async (silent = false) => { + fetchCounterRef.current += 1; + const myCounter = fetchCounterRef.current; + try { + const data = await itemService.list(); + if (myCounter === fetchCounterRef.current) { setItems(data); - } catch { + setError(null); + } + } catch { + if (!silent && myCounter === fetchCounterRef.current) { setError('Failed to load items'); - } finally { - setLoading(false); } - }; - fetchItems(); + } }, []); + useEffect(() => { + const load = async () => { + await fetchItems(); + setLoading(false); + }; + void load(); + }, [fetchItems]); + useEffect(() => { const unsubCreated = subscribe('item.created', (msg: WebSocketMessage) => { const item = msg.payload as Item; setToast({ open: true, message: `Item '${item.name}' created`, severity: 'success' }); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { void fetchItems(true); }, 300); }); const unsubUpdated = subscribe('item.updated', (msg: WebSocketMessage) => { const item = msg.payload as Item; setToast({ open: true, message: `Item '${item.name}' updated`, severity: 'info' }); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { void fetchItems(true); }, 300); }); const unsubDeleted = subscribe('item.deleted', () => { setToast({ open: true, message: 'Item deleted', severity: 'warning' }); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { void fetchItems(true); }, 300); }); return () => { unsubCreated(); unsubUpdated(); unsubDeleted(); + if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [subscribe]); + }, [subscribe, fetchItems]); const handleToastClose = () => { setToast((prev) => ({ ...prev, open: false }));