diff --git a/frontend/e2e/items.spec.ts b/frontend/e2e/items.spec.ts new file mode 100644 index 0000000..227b1ae --- /dev/null +++ b/frontend/e2e/items.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Items Page', () => { + test('renders the page heading', async ({ page }) => { + await page.goto('/items'); + + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Items', { + timeout: 10_000, + }); + }); + + test('displays the items table with data from the API', async ({ page }) => { + await page.goto('/items'); + + // Wait for loading spinner to disappear, indicating data has loaded + await expect(page.getByRole('progressbar')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('progressbar')).not.toBeVisible({ timeout: 10_000 }); + + // Table should be visible with expected column headers + await expect(page.getByRole('table')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Price' })).toBeVisible(); + }); + + test('navigates to Items page via nav link and back', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Welcome to the Full Stack Application', + { timeout: 10_000 } + ); + + await page.getByRole('link', { name: /items/i }).click(); + await expect(page).toHaveURL('/items'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Items', { + timeout: 10_000, + }); + + await page.getByRole('link', { name: /home/i }).click(); + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Welcome to the Full Stack Application' + ); + }); + + test('renders table column headers', async ({ page }) => { + await page.goto('/items'); + + await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Price' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Description' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Created At' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Updated At' })).toBeVisible(); + }); +}); diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts index 2ea047c..a1ab499 100644 --- a/frontend/e2e/navigation.spec.ts +++ b/frontend/e2e/navigation.spec.ts @@ -32,6 +32,20 @@ test.describe('Navigation', () => { await expect(page.getByText('Full Stack App', { exact: true })).toBeVisible(); await expect(page.getByRole('link', { name: /home/i })).toBeVisible(); await expect(page.getByRole('link', { name: /health/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /items/i })).toBeVisible(); + }); + + test('navigates from Home to Items via nav link', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Welcome to the Full Stack Application' + ); + + await page.getByRole('link', { name: /items/i }).click(); + await expect(page).toHaveURL('/items'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Items', { + timeout: 10_000, + }); }); test('renders the footer with current year', async ({ page }) => { diff --git a/frontend/e2e/websocket.spec.ts b/frontend/e2e/websocket.spec.ts new file mode 100644 index 0000000..59d5ab8 --- /dev/null +++ b/frontend/e2e/websocket.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; + +/** + * WebSocket integration e2e tests. + * + * These tests verify that the app remains fully functional regardless of the + * WebSocket connection state. The WebSocket provider connects in the background, + * so we assert on observable UI behaviour rather than on the socket itself. + */ +test.describe('WebSocket Integration', () => { + test('app loads and renders correctly with WebSocket running in background', async ({ page }) => { + const wsErrors: string[] = []; + page.on('pageerror', (error) => { + if ( + error.message.toLowerCase().includes('websocket') || + error.message.includes('ws://') + ) { + wsErrors.push(error.message); + } + }); + + await page.goto('/'); + + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Welcome to the Full Stack Application', + { timeout: 10_000 } + ); + + // No uncaught WebSocket-related JS errors should surface to the page + expect(wsErrors).toHaveLength(0); + }); + + test('navigation works correctly while WebSocket is active', async ({ page }) => { + await page.goto('/'); + await expect( + page.getByRole('heading', { level: 1 }) + ).toHaveText('Welcome to the Full Stack Application', { timeout: 10_000 }); + + // Navigate away and back — the WebSocket provider should survive route changes + await page.getByRole('link', { name: /health/i }).click(); + await expect(page).toHaveURL('/health'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'System Health', + { timeout: 10_000 } + ); + + await page.getByRole('link', { name: /home/i }).click(); + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Welcome to the Full Stack Application' + ); + }); + + test('app recovers gracefully when WebSocket connection is interrupted', async ({ + page, + context, + }) => { + await page.goto('/'); + await expect( + page.getByRole('heading', { level: 1 }) + ).toHaveText('Welcome to the Full Stack Application', { timeout: 10_000 }); + + // Simulate a network interruption by going offline then online + await context.setOffline(true); + await context.setOffline(false); + + // App UI must still be functional after the interruption + await expect( + page.getByRole('link', { name: /health/i }) + ).toBeVisible({ timeout: 10_000 }); + + await page.getByRole('link', { name: /health/i }).click(); + await expect(page).toHaveURL('/health'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'System Health', + { timeout: 10_000 } + ); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 603e151..1a1d44c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.1", + "reconnecting-websocket": "^4.4.0", "typescript": "^5.8.3", "vite": "^6.3.5" }, @@ -3126,6 +3127,12 @@ "react-dom": ">=16.6.0" } }, + "node_modules/reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==", + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 04f311d..7c34fc4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.1", + "reconnecting-websocket": "^4.4.0", "typescript": "^5.8.3", "vite": "^6.3.5" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d7e827..968674e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'; import { BrowserRouter } from 'react-router-dom'; import AppRoutes from './routes'; import Layout from './components/Layout'; +import { WebSocketProvider } from './context/WebSocketContext'; const theme = createTheme({ palette: { @@ -20,9 +21,11 @@ function App() { - - - + + + + + ); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 8d6ed60..acdc5bc 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -2,12 +2,22 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import App from '../App'; -// Mock the health service to prevent actual API calls during render +// Prevent WebSocketProvider from opening a real connection in jsdom +vi.mock('reconnecting-websocket', () => ({ + default: class MockRWS extends EventTarget { + close() {} + }, +})); + +// Mock the health and item services to prevent actual API calls during render vi.mock('../api/client', () => ({ healthService: { checkLiveness: vi.fn().mockReturnValue(new Promise(() => {})), checkReadiness: vi.fn().mockReturnValue(new Promise(() => {})), }, + itemService: { + list: vi.fn().mockReturnValue(new Promise(() => {})), + }, })); describe('App', () => { @@ -16,6 +26,7 @@ describe('App', () => { expect(screen.getByText('Full Stack App')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /health/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /items/i })).toBeInTheDocument(); }); it('renders the Home page by default', () => { diff --git a/frontend/src/__tests__/WebSocketContext.test.tsx b/frontend/src/__tests__/WebSocketContext.test.tsx new file mode 100644 index 0000000..80a5b19 --- /dev/null +++ b/frontend/src/__tests__/WebSocketContext.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; + +interface MockWSInstance extends EventTarget { + open: () => void; + close: () => void; + receive: (data: string) => void; +} + +// vi.hoisted ensures the class is defined before vi.mock hoists the factory +const { MockRWS, getInstance } = vi.hoisted(() => { + let _instance: MockWSInstance | null = null; + + class MockRWS extends EventTarget implements MockWSInstance { + constructor() { + super(); + _instance = this; + } + close() { this.dispatchEvent(new CloseEvent('close')); } + open() { this.dispatchEvent(new Event('open')); } + receive(data: string) { this.dispatchEvent(new MessageEvent('message', { data })); } + } + + return { MockRWS, getInstance: (): MockWSInstance => _instance! }; +}); + +vi.mock('reconnecting-websocket', () => ({ default: MockRWS })); + +import { WebSocketProvider, useWebSocketContext } from '../context/WebSocketContext'; + +function wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(WebSocketProvider, null, children); +} + +describe('WebSocketContext', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('throws when used outside provider', () => { + // Suppress expected console.error from React + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useWebSocketContext())).toThrow( + 'useWebSocketContext must be used within ' + ); + consoleSpy.mockRestore(); + }); + + it('starts in connecting status', () => { + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + expect(result.current.connectionStatus).toBe('connecting'); + }); + + it('updates connectionStatus to open when socket opens', () => { + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + act(() => getInstance().open()); + expect(result.current.connectionStatus).toBe('open'); + }); + + it('exposes lastMessage after a message is received', () => { + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + act(() => + getInstance().receive('{"type":"item.created","payload":{"id":42}}') + ); + expect(result.current.lastMessage).toEqual({ + type: 'item.created', + payload: { id: 42 }, + }); + }); + + it('subscribe receives events of the subscribed type', () => { + const handler = vi.fn(); + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + + act(() => { + result.current.subscribe('item.created', handler); + }); + + act(() => + getInstance().receive('{"type":"item.created","payload":{"id":1}}') + ); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ type: 'item.created', payload: { id: 1 } }); + }); + + it('subscribe does not fire for other message types', () => { + const handler = vi.fn(); + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + + act(() => { + result.current.subscribe('item.created', handler); + }); + + act(() => + getInstance().receive('{"type":"item.deleted","payload":{"id":1}}') + ); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('unsubscribe returned from subscribe deregisters the handler', () => { + const handler = vi.fn(); + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + + let unsubscribe!: () => void; + act(() => { + unsubscribe = result.current.subscribe('item.updated', handler); + }); + + act(() => unsubscribe()); + + act(() => + getInstance().receive('{"type":"item.updated","payload":{}}') + ); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('updates connectionStatus to closed when socket closes', () => { + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + act(() => getInstance().open()); + expect(result.current.connectionStatus).toBe('open'); + + act(() => getInstance().close()); + expect(result.current.connectionStatus).toBe('closed'); + }); + + it('ignores non-JSON frames without updating lastMessage', () => { + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + act(() => getInstance().receive('not valid json at all')); + expect(result.current.lastMessage).toBeNull(); + }); + + it('calls all subscribers when multiple handlers are registered for the same type', () => { + const handlerA = vi.fn(); + const handlerB = vi.fn(); + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + + act(() => { + result.current.subscribe('item.created', handlerA); + result.current.subscribe('item.created', handlerB); + }); + + act(() => + getInstance().receive('{"type":"item.created","payload":{"id":2}}') + ); + + expect(handlerA).toHaveBeenCalledTimes(1); + expect(handlerB).toHaveBeenCalledTimes(1); + expect(handlerA).toHaveBeenCalledWith({ type: 'item.created', payload: { id: 2 } }); + expect(handlerB).toHaveBeenCalledWith({ type: 'item.created', payload: { id: 2 } }); + }); + + it('unsubscribe only removes the specific handler leaving others intact', () => { + const handlerA = vi.fn(); + const handlerB = vi.fn(); + const { result } = renderHook(() => useWebSocketContext(), { wrapper }); + + let unsubscribeA!: () => void; + act(() => { + unsubscribeA = result.current.subscribe('item.deleted', handlerA); + result.current.subscribe('item.deleted', handlerB); + }); + + act(() => unsubscribeA()); + + act(() => + getInstance().receive('{"type":"item.deleted","payload":{"id":5}}') + ); + + expect(handlerA).not.toHaveBeenCalled(); + expect(handlerB).toHaveBeenCalledTimes(1); + expect(handlerB).toHaveBeenCalledWith({ type: 'item.deleted', payload: { id: 5 } }); + }); +}); diff --git a/frontend/src/__tests__/useWebSocket.test.ts b/frontend/src/__tests__/useWebSocket.test.ts new file mode 100644 index 0000000..1740a05 --- /dev/null +++ b/frontend/src/__tests__/useWebSocket.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +interface MockWSInstance extends EventTarget { + url: string; + sent: string[]; + open: () => void; + close: () => void; + receive: (data: string) => void; + send: (data: string) => void; +} + +// vi.hoisted ensures the class is defined before vi.mock hoists the factory +const { MockRWS, getInstance } = vi.hoisted(() => { + let _instance: MockWSInstance | null = null; + + class MockRWS extends EventTarget implements MockWSInstance { + url: string; + sent: string[] = []; + constructor(url: string) { + super(); + _instance = this; + this.url = url; + } + send(data: string) { this.sent.push(data); } + close() { this.dispatchEvent(new CloseEvent('close')); } + open() { this.dispatchEvent(new Event('open')); } + receive(data: string) { this.dispatchEvent(new MessageEvent('message', { data })); } + } + + return { MockRWS, getInstance: (): MockWSInstance => _instance! }; +}); + +vi.mock('reconnecting-websocket', () => ({ default: MockRWS })); + +import { useWebSocket } from '../hooks/useWebSocket'; + +describe('useWebSocket', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('starts in connecting status', () => { + const { result } = renderHook(() => useWebSocket()); + expect(result.current.connectionStatus).toBe('connecting'); + }); + + it('updates status to open when socket opens', () => { + const { result } = renderHook(() => useWebSocket()); + act(() => getInstance().open()); + expect(result.current.connectionStatus).toBe('open'); + }); + + it('updates status to closed when socket closes', () => { + const { result } = renderHook(() => useWebSocket()); + act(() => getInstance().open()); + act(() => getInstance().close()); + expect(result.current.connectionStatus).toBe('closed'); + }); + + it('parses and exposes lastMessage', () => { + const { result } = renderHook(() => useWebSocket()); + act(() => getInstance().receive('{"type":"item.created","payload":{"id":1}}')); + expect(result.current.lastMessage).toEqual({ type: 'item.created', payload: { id: 1 } }); + }); + + it('calls onMessage callback', () => { + const onMessage = vi.fn(); + renderHook(() => useWebSocket({ onMessage })); + act(() => getInstance().receive('{"type":"item.updated","payload":{}}')); + expect(onMessage).toHaveBeenCalledWith({ type: 'item.updated', payload: {} }); + }); + + it('ignores non-JSON frames', () => { + const onMessage = vi.fn(); + const { result } = renderHook(() => useWebSocket({ onMessage })); + act(() => getInstance().receive('not-valid-json')); + expect(result.current.lastMessage).toBeNull(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('sendMessage serialises and sends data', () => { + const { result } = renderHook(() => useWebSocket()); + act(() => result.current.sendMessage({ hello: 'world' })); + expect(getInstance().sent).toContain('{"hello":"world"}'); + }); + + it('closes the socket on unmount', () => { + const { unmount } = renderHook(() => useWebSocket()); + const closeSpy = vi.spyOn(getInstance(), 'close'); + unmount(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('uses the custom path option in the WebSocket URL', () => { + renderHook(() => useWebSocket({ path: '/ws/events' })); + expect(getInstance().url).toContain('/ws/events'); + }); + + it('does not recreate the socket when onMessage callback changes between renders', () => { + const onMessage1 = vi.fn(); + const onMessage2 = vi.fn(); + const { rerender } = renderHook( + ({ cb }: { cb: (msg: { type: string; payload: unknown }) => void }) => + useWebSocket({ onMessage: cb }), + { initialProps: { cb: onMessage1 } } + ); + const firstInstance = getInstance(); + const closeSpy = vi.spyOn(firstInstance, 'close'); + + rerender({ cb: onMessage2 }); + + // Socket must not be closed and re-created + expect(closeSpy).not.toHaveBeenCalled(); + expect(getInstance()).toBe(firstInstance); + + // And the NEW callback should be invoked (ref is up-to-date) + act(() => getInstance().receive('{"type":"item.updated","payload":{}}')); + expect(onMessage2).toHaveBeenCalledTimes(1); + expect(onMessage1).not.toHaveBeenCalled(); + }); + + it('returns connectionStatus to open after a reconnect', () => { + const { result } = renderHook(() => useWebSocket()); + act(() => getInstance().open()); + expect(result.current.connectionStatus).toBe('open'); + + act(() => getInstance().close()); + expect(result.current.connectionStatus).toBe('closed'); + + // Simulate ReconnectingWebSocket successfully re-establishing the connection + act(() => getInstance().open()); + expect(result.current.connectionStatus).toBe('open'); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1362ed1..d55b4cd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,6 +12,70 @@ api.interceptors.response.use( } ); +export interface Item { + id: number; + name: string; + price: number; + description?: string; + created_at: string; + updated_at: string; +} + +export const itemService = { + list: async (limit?: number, offset?: number): Promise => { + try { + const params = new URLSearchParams(); + if (limit !== undefined) params.set('limit', String(limit)); + if (offset !== undefined) params.set('offset', String(offset)); + const query = params.toString() ? `?${params.toString()}` : ''; + const response = await api.get(`/api/v1/items${query}`); + return response.data; + } catch (error) { + console.error('Failed to fetch items:', error); + throw error; + } + }, + + get: async (id: number): Promise => { + try { + const response = await api.get(`/api/v1/items/${id}`); + return response.data; + } catch (error) { + console.error('Failed to fetch item:', error); + throw error; + } + }, + + create: async (item: Omit): Promise => { + try { + const response = await api.post('/api/v1/items', item); + return response.data; + } catch (error) { + console.error('Failed to create item:', error); + throw error; + } + }, + + update: async (id: number, item: Partial>): Promise => { + try { + const response = await api.put(`/api/v1/items/${id}`, item); + return response.data; + } catch (error) { + console.error('Failed to update item:', error); + throw error; + } + }, + + delete: async (id: number): Promise => { + try { + await api.delete(`/api/v1/items/${id}`); + } catch (error) { + console.error('Failed to delete item:', error); + throw error; + } + }, +}; + export const healthService = { checkLiveness: async () => { try { diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 8692020..e87b2ae 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -13,3 +13,10 @@ export const axiosConfig = { 'Content-Type': 'application/json', }, }; + +// WebSocket base URL. In production, derived from the current page origin; +// in development, points directly at the backend dev server. +export const WS_BASE_URL = + process.env.NODE_ENV === 'production' + ? `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}` + : 'ws://localhost:8081'; diff --git a/frontend/src/components/Layout/__tests__/Layout.test.tsx b/frontend/src/components/Layout/__tests__/Layout.test.tsx index 048b97a..f559394 100644 --- a/frontend/src/components/Layout/__tests__/Layout.test.tsx +++ b/frontend/src/components/Layout/__tests__/Layout.test.tsx @@ -17,10 +17,11 @@ describe('Layout', () => { expect(screen.getByText('Full Stack App')).toBeInTheDocument(); }); - it('renders Home and Health nav links', () => { + it('renders Home, Health, and Items nav links', () => { renderLayout(); expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/'); expect(screen.getByRole('link', { name: /health/i })).toHaveAttribute('href', '/health'); + expect(screen.getByRole('link', { name: /items/i })).toHaveAttribute('href', '/items'); }); it('renders children content', () => { diff --git a/frontend/src/components/Layout/index.tsx b/frontend/src/components/Layout/index.tsx index c6dc6d0..c89e36d 100644 --- a/frontend/src/components/Layout/index.tsx +++ b/frontend/src/components/Layout/index.tsx @@ -20,6 +20,9 @@ const Layout: React.FC = ({ children }) => { + diff --git a/frontend/src/context/WebSocketContext.tsx b/frontend/src/context/WebSocketContext.tsx new file mode 100644 index 0000000..f170c29 --- /dev/null +++ b/frontend/src/context/WebSocketContext.tsx @@ -0,0 +1,45 @@ +import React, { createContext, useContext, useCallback, useRef } from 'react'; +import { useWebSocket } from '../hooks/useWebSocket'; +import type { WebSocketMessage, ConnectionStatus } from '../hooks/useWebSocket'; + +interface WebSocketContextValue { + lastMessage: WebSocketMessage | null; + connectionStatus: ConnectionStatus; + subscribe: (type: string, handler: (msg: WebSocketMessage) => void) => () => void; +} + +const WebSocketContext = createContext(null); + +export function WebSocketProvider({ children }: { children: React.ReactNode }) { + const handlersRef = useRef void>>>(new Map()); + + const handleMessage = useCallback((msg: WebSocketMessage) => { + handlersRef.current.get(msg.type)?.forEach((h) => h(msg)); + }, []); + + const { lastMessage, connectionStatus } = useWebSocket({ onMessage: handleMessage }); + + const subscribe = useCallback( + (type: string, handler: (msg: WebSocketMessage) => void) => { + const map = handlersRef.current; + if (!map.has(type)) map.set(type, new Set()); + map.get(type)!.add(handler); + return () => { + map.get(type)?.delete(handler); + }; + }, + [] + ); + + return ( + + {children} + + ); +} + +export function useWebSocketContext(): WebSocketContextValue { + const ctx = useContext(WebSocketContext); + if (!ctx) throw new Error('useWebSocketContext must be used within '); + return ctx; +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..fa4e993 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import ReconnectingWebSocket from 'reconnecting-websocket'; +import { WS_BASE_URL } from '../api/config'; + +export type ConnectionStatus = 'connecting' | 'open' | 'closing' | 'closed'; + +/** Shape of every message the backend sends over the WebSocket. */ +export interface WebSocketMessage { + type: string; + payload: unknown; +} + +export interface UseWebSocketOptions { + /** Called whenever a message arrives, after JSON parsing. */ + onMessage?: (message: WebSocketMessage) => void; + /** URL path, e.g. '/ws'. Defaults to '/ws'. */ + path?: string; +} + +export interface UseWebSocketResult { + lastMessage: WebSocketMessage | null; + connectionStatus: ConnectionStatus; + sendMessage: (data: unknown) => void; +} + +export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketResult { + const { onMessage, path = '/ws' } = options; + const wsRef = useRef(null); + const [lastMessage, setLastMessage] = useState(null); + const [connectionStatus, setConnectionStatus] = useState('connecting'); + const onMessageRef = useRef(onMessage); + + // Keep the callback ref up-to-date without re-creating the socket + useEffect(() => { + onMessageRef.current = onMessage; + }, [onMessage]); + + useEffect(() => { + const url = `${WS_BASE_URL}${path}`; + const rws = new ReconnectingWebSocket(url); + wsRef.current = rws; + + const handleOpen = () => setConnectionStatus('open'); + const handleClose = () => setConnectionStatus('closed'); + const handleMessage = (event: MessageEvent) => { + try { + const parsed: WebSocketMessage = JSON.parse(event.data as string); + setLastMessage(parsed); + onMessageRef.current?.(parsed); + } catch { + // Ignore non-JSON frames + } + }; + + rws.addEventListener('open', handleOpen); + rws.addEventListener('close', handleClose); + rws.addEventListener('message', handleMessage); + + return () => { + rws.removeEventListener('open', handleOpen); + rws.removeEventListener('close', handleClose); + rws.removeEventListener('message', handleMessage); + rws.close(); + }; + }, [path]); + + const sendMessage = useCallback((data: unknown) => { + wsRef.current?.send(JSON.stringify(data)); + }, []); + + return { lastMessage, connectionStatus, sendMessage }; +} diff --git a/frontend/src/pages/Items/__tests__/Items.test.tsx b/frontend/src/pages/Items/__tests__/Items.test.tsx new file mode 100644 index 0000000..ef27217 --- /dev/null +++ b/frontend/src/pages/Items/__tests__/Items.test.tsx @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import Items from '../index'; +import { itemService } from '../../../api/client'; +import { useWebSocketContext } from '../../../context/WebSocketContext'; +import type { WebSocketMessage } from '../../../hooks/useWebSocket'; + +vi.mock('../../../context/WebSocketContext', () => ({ + useWebSocketContext: vi.fn(), +})); + +vi.mock('../../../api/client', () => ({ + itemService: { + list: vi.fn(), + }, +})); + +const mockItems = [ + { + id: 1, + name: 'Widget', + price: 9.99, + description: 'A test widget', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + id: 2, + name: 'Gadget', + price: 19.99, + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + }, +]; + +function setupSubscribeMock() { + const handlers: Record void> = {}; + const mockUnsubscribe = vi.fn(); + const mockSubscribe = vi + .fn() + .mockImplementation((type: string, handler: (msg: WebSocketMessage) => void) => { + handlers[type] = handler; + return mockUnsubscribe; + }); + (useWebSocketContext as ReturnType).mockReturnValue({ + subscribe: mockSubscribe, + }); + return { handlers, mockUnsubscribe, mockSubscribe }; +} + +describe('Items Page', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('shows a loading spinner initially', () => { + setupSubscribeMock(); + (itemService.list as ReturnType).mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders items table when data loads', async () => { + setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Items'); + }); + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('$9.99')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + expect(screen.getByText('$19.99')).toBeInTheDocument(); + expect(screen.getByText('A test widget')).toBeInTheDocument(); + }); + + it('shows error alert when fetch fails', async () => { + setupSubscribeMock(); + (itemService.list as ReturnType).mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByText('Failed to load items')).toBeInTheDocument(); + }); + + it('shows item.created toast with correct item name', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Items'); + }); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'NewWidget', price: 5.0, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(screen.getByText("Item 'NewWidget' created")).toBeInTheDocument(); + }); + }); + + it('shows item.updated toast with correct item name', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Items'); + }); + + 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("Item 'Widget Pro' updated")).toBeInTheDocument(); + }); + }); + + it('shows item.deleted toast', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Items'); + }); + + act(() => { + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); + }); + + await waitFor(() => { + expect(screen.getByText('Item deleted')).toBeInTheDocument(); + }); + }); + + it('shows no-items empty state alert when list is empty', async () => { + setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByText('No items found.')).toBeInTheDocument(); + }); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('renders em-dash for items without a description', async () => { + setupSubscribeMock(); + const itemWithoutDescription = [ + { + id: 1, + name: 'Widget', + price: 9.99, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]; + (itemService.list as ReturnType).mockResolvedValue(itemWithoutDescription); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('unsubscribes on unmount', async () => { + const { mockUnsubscribe } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue([]); + + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Items'); + }); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(3); + }); +}); diff --git a/frontend/src/pages/Items/index.tsx b/frontend/src/pages/Items/index.tsx new file mode 100644 index 0000000..88329a1 --- /dev/null +++ b/frontend/src/pages/Items/index.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react'; +import { + Typography, + Box, + CircularProgress, + Alert, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Snackbar, +} from '@mui/material'; +import { itemService } from '../../api/client'; +import type { Item } from '../../api/client'; +import { useWebSocketContext } from '../../context/WebSocketContext'; +import type { WebSocketMessage } from '../../hooks/useWebSocket'; + +type ToastSeverity = 'success' | 'info' | 'warning'; + +interface Toast { + open: boolean; + message: string; + severity: ToastSeverity; +} + +const Items = () => { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [toast, setToast] = useState({ open: false, message: '', severity: 'success' }); + const { subscribe } = useWebSocketContext(); + + useEffect(() => { + const fetchItems = async () => { + try { + const data = await itemService.list(); + setItems(data); + } catch { + setError('Failed to load items'); + } finally { + setLoading(false); + } + }; + 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' }); + }); + + const unsubUpdated = subscribe('item.updated', (msg: WebSocketMessage) => { + const item = msg.payload as Item; + setToast({ open: true, message: `Item '${item.name}' updated`, severity: 'info' }); + }); + + const unsubDeleted = subscribe('item.deleted', () => { + setToast({ open: true, message: 'Item deleted', severity: 'warning' }); + }); + + return () => { + unsubCreated(); + unsubUpdated(); + unsubDeleted(); + }; + }, [subscribe]); + + const handleToastClose = () => { + setToast((prev) => ({ ...prev, open: false })); + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return {error}; + } + + return ( + + + Items + + + {items.length === 0 ? ( + No items found. + ) : ( + + + + + ID + Name + Price + Description + Created At + Updated At + + + + {items.map((item) => ( + + {item.id} + {item.name} + ${item.price.toFixed(2)} + {item.description ?? '—'} + {new Date(item.created_at).toLocaleString()} + {new Date(item.updated_at).toLocaleString()} + + ))} + +
+
+ )} + + + + {toast.message} + + +
+ ); +}; + +export default Items; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 15571e8..c1018a4 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,12 +1,14 @@ import { Routes, Route } from 'react-router-dom'; import Home from './pages/Home'; import Health from './pages/Health'; +import Items from './pages/Items'; const AppRoutes = () => { return ( } /> } /> + } /> ); };