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:
poduck
2025-12-08 02:36:46 -05:00
parent c220612214
commit 8dc2248f1f
145 changed files with 77947 additions and 1048 deletions

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