feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
561
frontend/src/hooks/__tests__/useResourceLocation.test.ts
Normal file
561
frontend/src/hooks/__tests__/useResourceLocation.test.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useResourceLocation, useLiveResourceLocation } from '../useResourceLocation';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useResourceLocation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should transform snake_case to camelCase for basic location data', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
accuracy: 10,
|
||||
heading: 180,
|
||||
speed: 5.5,
|
||||
timestamp: '2025-12-07T12:00:00Z',
|
||||
is_tracking: true,
|
||||
message: 'Location updated',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual({
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
accuracy: 10,
|
||||
heading: 180,
|
||||
speed: 5.5,
|
||||
timestamp: '2025-12-07T12:00:00Z',
|
||||
isTracking: true,
|
||||
activeJob: null,
|
||||
message: 'Location updated',
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform activeJob with status_display to statusDisplay', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
is_tracking: true,
|
||||
active_job: {
|
||||
id: 456,
|
||||
title: 'Repair HVAC System',
|
||||
status: 'en_route',
|
||||
status_display: 'En Route',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data?.activeJob).toEqual({
|
||||
id: 456,
|
||||
title: 'Repair HVAC System',
|
||||
status: 'en_route',
|
||||
statusDisplay: 'En Route',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set activeJob to null when not provided', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: false,
|
||||
is_tracking: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data?.activeJob).toBeNull();
|
||||
});
|
||||
|
||||
it('should default isTracking to false when not provided', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
// is_tracking not provided
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data?.isTracking).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null active_job explicitly', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
is_tracking: true,
|
||||
active_job: null,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data?.activeJob).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
it('should call the correct API endpoint with resourceId', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: false,
|
||||
is_tracking: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('789'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/resources/789/location/');
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not fetch when resourceId is null', () => {
|
||||
const { result } = renderHook(() => useResourceLocation(null), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(result.current.fetchStatus).toBe('idle');
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not fetch when enabled is false', () => {
|
||||
const { result } = renderHook(
|
||||
() => useResourceLocation('123', { enabled: false }),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(result.current.fetchStatus).toBe('idle');
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch when enabled is true', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: true,
|
||||
is_tracking: true,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useResourceLocation('123', { enabled: true }),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/resources/123/location/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle API errors', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle 404 responses', async () => {
|
||||
const mockError = {
|
||||
response: {
|
||||
status: 404,
|
||||
data: { detail: 'Resource not found' },
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('999'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query configuration', () => {
|
||||
it('should use the correct query key', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: false,
|
||||
is_tracking: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const cachedData = queryClient.getQueryData(['resourceLocation', '123']);
|
||||
expect(cachedData).toBeDefined();
|
||||
expect(cachedData).toEqual(result.current.data);
|
||||
});
|
||||
|
||||
it('should not refetch automatically', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: true,
|
||||
is_tracking: true,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
// Wait a bit to ensure no automatic refetch
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Should only be called once (no refetchInterval)
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional fields', () => {
|
||||
it('should handle missing optional location fields', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
is_tracking: true,
|
||||
// accuracy, heading, speed, timestamp not provided
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual({
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
accuracy: undefined,
|
||||
heading: undefined,
|
||||
speed: undefined,
|
||||
timestamp: undefined,
|
||||
isTracking: true,
|
||||
activeJob: null,
|
||||
message: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle message field when provided', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
has_location: false,
|
||||
is_tracking: false,
|
||||
message: 'Resource has not started tracking yet',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data?.message).toBe('Resource has not started tracking yet');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLiveResourceLocation', () => {
|
||||
let mockWebSocket: {
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
removeEventListener: ReturnType<typeof vi.fn>;
|
||||
readyState: number;
|
||||
onopen: ((event: Event) => void) | null;
|
||||
onmessage: ((event: MessageEvent) => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
onclose: ((event: CloseEvent) => void) | null;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock WebSocket
|
||||
mockWebSocket = {
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
readyState: 1, // OPEN
|
||||
onopen: null,
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
onclose: null,
|
||||
};
|
||||
|
||||
// Mock WebSocket constructor properly
|
||||
global.WebSocket = vi.fn(function(this: any) {
|
||||
return mockWebSocket;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should not crash when rendered', () => {
|
||||
const { result } = renderHook(() => useLiveResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.refresh).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should create WebSocket connection with correct URL', () => {
|
||||
renderHook(() => useLiveResourceLocation('456'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(global.WebSocket).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/ws/resource-location/456/')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not connect when resourceId is null', () => {
|
||||
renderHook(() => useLiveResourceLocation(null), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(global.WebSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not connect when enabled is false', () => {
|
||||
renderHook(() => useLiveResourceLocation('123', { enabled: false }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(global.WebSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close WebSocket on unmount', () => {
|
||||
const { unmount } = renderHook(() => useLiveResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockWebSocket.close).toHaveBeenCalledWith(1000, 'Component unmounting');
|
||||
});
|
||||
|
||||
it('should return refresh function', () => {
|
||||
const { result } = renderHook(() => useLiveResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.refresh).toBeInstanceOf(Function);
|
||||
|
||||
// Should not throw when called
|
||||
expect(() => result.current.refresh()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle location_update message type', () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
renderHook(() => useLiveResourceLocation('123'), { wrapper });
|
||||
|
||||
// Simulate WebSocket message
|
||||
const mockMessage = {
|
||||
data: JSON.stringify({
|
||||
type: 'location_update',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
accuracy: 10,
|
||||
heading: 180,
|
||||
speed: 5.5,
|
||||
timestamp: '2025-12-07T12:00:00Z',
|
||||
}),
|
||||
};
|
||||
|
||||
if (mockWebSocket.onmessage) {
|
||||
mockWebSocket.onmessage(mockMessage as MessageEvent);
|
||||
}
|
||||
|
||||
// Verify query cache was updated
|
||||
const cachedData = queryClient.getQueryData(['resourceLocation', '123']);
|
||||
expect(cachedData).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle tracking_stopped message type', () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
// Set initial data
|
||||
queryClient.setQueryData(['resourceLocation', '123'], {
|
||||
hasLocation: true,
|
||||
isTracking: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
renderHook(() => useLiveResourceLocation('123'), { wrapper });
|
||||
|
||||
// Simulate tracking stopped message
|
||||
const mockMessage = {
|
||||
data: JSON.stringify({
|
||||
type: 'tracking_stopped',
|
||||
}),
|
||||
};
|
||||
|
||||
if (mockWebSocket.onmessage) {
|
||||
mockWebSocket.onmessage(mockMessage as MessageEvent);
|
||||
}
|
||||
|
||||
// Verify isTracking was set to false
|
||||
const cachedData = queryClient.getQueryData<any>(['resourceLocation', '123']);
|
||||
expect(cachedData?.isTracking).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle malformed WebSocket messages gracefully', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderHook(() => useLiveResourceLocation('123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Simulate malformed JSON
|
||||
const mockMessage = {
|
||||
data: 'invalid json{{{',
|
||||
};
|
||||
|
||||
if (mockWebSocket.onmessage) {
|
||||
mockWebSocket.onmessage(mockMessage as MessageEvent);
|
||||
}
|
||||
|
||||
// Should log error but not crash
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user