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,579 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock the sandbox API
vi.mock('../../api/sandbox', () => ({
getSandboxStatus: vi.fn(),
toggleSandboxMode: vi.fn(),
resetSandboxData: vi.fn(),
}));
import {
useSandboxStatus,
useToggleSandbox,
useResetSandbox,
} from '../useSandbox';
import * as sandboxApi from '../../api/sandbox';
// Create wrapper
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('useSandbox hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useSandboxStatus', () => {
it('fetches sandbox status', async () => {
const mockStatus = {
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(sandboxApi.getSandboxStatus).toHaveBeenCalled();
expect(result.current.data).toEqual(mockStatus);
});
it('returns sandbox_mode as false when in live mode', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.sandbox_mode).toBe(false);
});
it('handles sandbox not being enabled for business', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: false,
sandbox_schema: null,
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.sandbox_enabled).toBe(false);
expect(result.current.data?.sandbox_schema).toBeNull();
});
it('handles API errors', async () => {
vi.mocked(sandboxApi.getSandboxStatus).mockRejectedValue(
new Error('Failed to fetch sandbox status')
);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe('Failed to fetch sandbox status');
});
it('configures staleTime to 30 seconds', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Data should be considered fresh for 30 seconds
// This is configured in the hook with staleTime: 30 * 1000
expect(result.current.isStale).toBe(false);
});
});
describe('useToggleSandbox', () => {
let originalLocation: Location;
let reloadMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Mock window.location.reload
originalLocation = window.location;
reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
value: { ...originalLocation, reload: reloadMock },
writable: true,
});
});
afterEach(() => {
// Restore window.location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
});
it('toggles sandbox mode to enabled', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(true);
});
expect(sandboxApi.toggleSandboxMode).toHaveBeenCalled();
expect(vi.mocked(sandboxApi.toggleSandboxMode).mock.calls[0][0]).toBe(true);
});
it('toggles sandbox mode to disabled', async () => {
const mockResponse = {
sandbox_mode: false,
message: 'Sandbox mode disabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(false);
});
expect(sandboxApi.toggleSandboxMode).toHaveBeenCalled();
expect(vi.mocked(sandboxApi.toggleSandboxMode).mock.calls[0][0]).toBe(false);
});
it('updates sandbox status in cache on success', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const wrapper = createWrapper();
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// Pre-populate cache with initial status
queryClient.setQueryData(['sandboxStatus'], {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
});
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync(true);
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['sandboxStatus']);
expect(cachedData).toEqual({
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
});
});
it('reloads window after successful toggle', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(true);
});
expect(reloadMock).toHaveBeenCalled();
});
it('handles toggle errors without reloading', async () => {
vi.mocked(sandboxApi.toggleSandboxMode).mockRejectedValue(
new Error('Failed to toggle sandbox mode')
);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync(true);
} catch (error) {
// Expected to throw
}
});
expect(reloadMock).not.toHaveBeenCalled();
});
it('updates cache even when old data is undefined', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const wrapper = createWrapper();
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync(true);
});
// Verify cache was set even with no prior data
const cachedData = queryClient.getQueryData(['sandboxStatus']);
expect(cachedData).toMatchObject({
sandbox_mode: true,
});
});
});
describe('useResetSandbox', () => {
it('resets sandbox data', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useResetSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(sandboxApi.resetSandboxData).toHaveBeenCalled();
});
it('invalidates resource queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['resources'] });
});
it('invalidates event queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['events'] });
});
it('invalidates service queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['services'] });
});
it('invalidates customer queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['customers'] });
});
it('invalidates payment queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['payments'] });
});
it('invalidates all required queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
// Verify all expected queries were invalidated
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['resources'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['events'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['services'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['customers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['payments'] });
expect(invalidateSpy).toHaveBeenCalledTimes(5);
});
it('handles reset errors', async () => {
vi.mocked(sandboxApi.resetSandboxData).mockRejectedValue(
new Error('Failed to reset sandbox data')
);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
try {
await result.current.mutateAsync();
} catch (error) {
// Expected to throw
}
});
// Verify queries were NOT invalidated on error
expect(invalidateSpy).not.toHaveBeenCalled();
});
it('returns response data on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useResetSandbox(), {
wrapper: createWrapper(),
});
let response;
await act(async () => {
response = await result.current.mutateAsync();
});
expect(response).toEqual(mockResponse);
});
});
});