Skip to content
Merged
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
320 changes: 320 additions & 0 deletions frontend/src/pages/Items/__tests__/Items.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('Items Page', () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
vi.useRealTimers();
});

it('shows a loading spinner initially', () => {
Expand Down Expand Up @@ -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<typeof vi.fn>).mockResolvedValue(mockItems);

render(<Items />);

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<typeof vi.fn>).mockResolvedValue(mockItems);

render(<Items />);

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<typeof vi.fn>).mockResolvedValue(mockItems);

render(<Items />);

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<typeof vi.fn>).mockResolvedValue([]);
Expand All @@ -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<typeof vi.fn>)
.mockResolvedValueOnce(mockItems)
.mockRejectedValueOnce(new Error('Network error'));

render(<Items />);

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<typeof vi.fn>)
.mockResolvedValueOnce(mockItems)
.mockRejectedValueOnce(new Error('Network error'));

render(<Items />);

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<typeof vi.fn>)
.mockResolvedValueOnce(mockItems)
.mockRejectedValueOnce(new Error('Network error'));

render(<Items />);

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<typeof vi.fn>)
.mockResolvedValueOnce(mockItems)
.mockResolvedValueOnce(updatedItems);

render(<Items />);

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<typeof vi.fn>)
.mockResolvedValueOnce(mockItems)
.mockResolvedValueOnce(updatedItems);

render(<Items />);

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<typeof vi.fn>)
.mockResolvedValueOnce(mockItems)
.mockResolvedValueOnce(updatedItems);

render(<Items />);

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<typeof vi.fn>)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(mockItems);

render(<Items />);

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<typeof vi.fn>).mockResolvedValue(mockItems);

render(<Items />);

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();
});
});
Loading