- 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>
562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|