test: reorganize test infrastructure with colocated tests in subdirectories

- Move tests into dedicated tests/ subdirectories within each feature
- Create centralized test utilities in src/features/testing/
- Update all import paths to match new structure
- Configure tsconfig.prod.json to exclude test files
- Remove legacy test files from old test/ directory
- All 32 tests passing with proper provider wrapping
This commit is contained in:
Rasmus Widing
2025-09-05 00:12:36 +03:00
parent 79839b23f5
commit 832e587a01
29 changed files with 942 additions and 3612 deletions

View File

@@ -43,6 +43,16 @@ docker-compose.yml
# Tests
coverage
test-results
tests/
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
**/__tests__
**/*.e2e.test.ts
**/*.integration.test.ts
vitest.config.ts
tsconfig.prod.json
# Documentation
README.md

View File

@@ -1,296 +0,0 @@
import React from 'react'
import { vi } from 'vitest'
const createMockIcon = (name: string) => {
const MockIcon = React.forwardRef(({ className, ...props }: any, ref: any) => (
<span
ref={ref}
className={className}
data-testid={`${name.toLowerCase()}-icon`}
data-lucide={name}
{...props}
>
{name}
</span>
))
MockIcon.displayName = name
return MockIcon
}
// Export all icons used in the app
export const Settings = createMockIcon('Settings')
export const Check = createMockIcon('Check')
export const CheckCircle = createMockIcon('CheckCircle')
export const X = createMockIcon('X')
export const XCircle = createMockIcon('XCircle')
export const Eye = createMockIcon('Eye')
export const EyeOff = createMockIcon('EyeOff')
export const Save = createMockIcon('Save')
export const Loader = createMockIcon('Loader')
export const Loader2 = createMockIcon('Loader2')
export const RefreshCw = createMockIcon('RefreshCw')
export const Play = createMockIcon('Play')
export const Pause = createMockIcon('Pause')
export const Square = createMockIcon('Square')
export const FileText = createMockIcon('FileText')
export const Download = createMockIcon('Download')
export const Upload = createMockIcon('Upload')
export const ChevronDown = createMockIcon('ChevronDown')
export const ChevronUp = createMockIcon('ChevronUp')
export const ChevronLeft = createMockIcon('ChevronLeft')
export const ChevronRight = createMockIcon('ChevronRight')
export const Plus = createMockIcon('Plus')
export const Minus = createMockIcon('Minus')
export const Edit = createMockIcon('Edit')
export const Edit2 = createMockIcon('Edit2')
export const Edit3 = createMockIcon('Edit3')
export const Trash = createMockIcon('Trash')
export const Trash2 = createMockIcon('Trash2')
export const User = createMockIcon('User')
export const Users = createMockIcon('Users')
export const Bot = createMockIcon('Bot')
export const Database = createMockIcon('Database')
export const Server = createMockIcon('Server')
export const Globe = createMockIcon('Globe')
export const Search = createMockIcon('Search')
export const Filter = createMockIcon('Filter')
export const Copy = createMockIcon('Copy')
export const ExternalLink = createMockIcon('ExternalLink')
export const Info = createMockIcon('Info')
export const AlertCircle = createMockIcon('AlertCircle')
export const AlertTriangle = createMockIcon('AlertTriangle')
export const Zap = createMockIcon('Zap')
export const Code = createMockIcon('Code')
export const Terminal = createMockIcon('Terminal')
export const Book = createMockIcon('Book')
export const BookOpen = createMockIcon('BookOpen')
export const Folder = createMockIcon('Folder')
export const FolderOpen = createMockIcon('FolderOpen')
export const File = createMockIcon('File')
export const Hash = createMockIcon('Hash')
export const Tag = createMockIcon('Tag')
export const Clock = createMockIcon('Clock')
export const Calendar = createMockIcon('Calendar')
export const MapPin = createMockIcon('MapPin')
export const Link = createMockIcon('Link')
export const Mail = createMockIcon('Mail')
export const Phone = createMockIcon('Phone')
export const Home = createMockIcon('Home')
export const Menu = createMockIcon('Menu')
export const MoreHorizontal = createMockIcon('MoreHorizontal')
export const MoreVertical = createMockIcon('MoreVertical')
export const Refresh = createMockIcon('Refresh')
export const RotateCcw = createMockIcon('RotateCcw')
export const RotateCw = createMockIcon('RotateCw')
export const Sun = createMockIcon('Sun')
export const Moon = createMockIcon('Moon')
export const Monitor = createMockIcon('Monitor')
export const Wifi = createMockIcon('Wifi')
export const WifiOff = createMockIcon('WifiOff')
export const Volume2 = createMockIcon('Volume2')
export const VolumeX = createMockIcon('VolumeX')
export const BarChart = createMockIcon('BarChart')
export const PieChart = createMockIcon('PieChart')
export const TrendingUp = createMockIcon('TrendingUp')
export const TrendingDown = createMockIcon('TrendingDown')
export const ArrowUp = createMockIcon('ArrowUp')
export const ArrowDown = createMockIcon('ArrowDown')
export const ArrowLeft = createMockIcon('ArrowLeft')
export const ArrowRight = createMockIcon('ArrowRight')
export const Send = createMockIcon('Send')
export const MessageSquare = createMockIcon('MessageSquare')
export const MessageCircle = createMockIcon('MessageCircle')
export const Heart = createMockIcon('Heart')
export const Star = createMockIcon('Star')
export const Bookmark = createMockIcon('Bookmark')
export const Share = createMockIcon('Share')
export const Share2 = createMockIcon('Share2')
export const Maximize = createMockIcon('Maximize')
export const Minimize = createMockIcon('Minimize')
export const Expand = createMockIcon('Expand')
export const Shrink = createMockIcon('Shrink')
export const Move = createMockIcon('Move')
export const Shuffle = createMockIcon('Shuffle')
export const Repeat = createMockIcon('Repeat')
export const StopCircle = createMockIcon('StopCircle')
export const SkipBack = createMockIcon('SkipBack')
export const SkipForward = createMockIcon('SkipForward')
export const FastForward = createMockIcon('FastForward')
export const Rewind = createMockIcon('Rewind')
export const Camera = createMockIcon('Camera')
export const Image = createMockIcon('Image')
export const Video = createMockIcon('Video')
export const Mic = createMockIcon('Mic')
export const MicOff = createMockIcon('MicOff')
export const Headphones = createMockIcon('Headphones')
export const Speaker = createMockIcon('Speaker')
export const Bell = createMockIcon('Bell')
export const BellOff = createMockIcon('BellOff')
export const Shield = createMockIcon('Shield')
export const ShieldCheck = createMockIcon('ShieldCheck')
export const ShieldAlert = createMockIcon('ShieldAlert')
export const Key = createMockIcon('Key')
export const Lock = createMockIcon('Lock')
export const Unlock = createMockIcon('Unlock')
export const LogIn = createMockIcon('LogIn')
export const LogOut = createMockIcon('LogOut')
export const UserPlus = createMockIcon('UserPlus')
export const UserMinus = createMockIcon('UserMinus')
export const UserCheck = createMockIcon('UserCheck')
export const UserX = createMockIcon('UserX')
export const Package = createMockIcon('Package')
export const Package2 = createMockIcon('Package2')
export const ShoppingCart = createMockIcon('ShoppingCart')
export const ShoppingBag = createMockIcon('ShoppingBag')
export const CreditCard = createMockIcon('CreditCard')
export const DollarSign = createMockIcon('DollarSign')
export const Percent = createMockIcon('Percent')
export const Activity = createMockIcon('Activity')
export const Cpu = createMockIcon('Cpu')
export const HardDrive = createMockIcon('HardDrive')
export const MemoryStick = createMockIcon('MemoryStick')
export const Smartphone = createMockIcon('Smartphone')
export const Tablet = createMockIcon('Tablet')
export const Laptop = createMockIcon('Laptop')
export const Monitor2 = createMockIcon('Monitor2')
export const Tv = createMockIcon('Tv')
export const Watch = createMockIcon('Watch')
export const Gamepad2 = createMockIcon('Gamepad2')
export const Mouse = createMockIcon('Mouse')
export const Keyboard = createMockIcon('Keyboard')
export const Printer = createMockIcon('Printer')
export const Scanner = createMockIcon('Scanner')
export const Webcam = createMockIcon('Webcam')
export const Bluetooth = createMockIcon('Bluetooth')
export const Usb = createMockIcon('Usb')
export const Zap2 = createMockIcon('Zap2')
export const Battery = createMockIcon('Battery')
export const BatteryCharging = createMockIcon('BatteryCharging')
export const Plug = createMockIcon('Plug')
export const Power = createMockIcon('Power')
export const PowerOff = createMockIcon('PowerOff')
export const BarChart2 = createMockIcon('BarChart2')
export const BarChart3 = createMockIcon('BarChart3')
export const BarChart4 = createMockIcon('BarChart4')
export const LineChart = createMockIcon('LineChart')
export const PieChart2 = createMockIcon('PieChart2')
export const Layers = createMockIcon('Layers')
export const Layers2 = createMockIcon('Layers2')
export const Layers3 = createMockIcon('Layers3')
export const Grid = createMockIcon('Grid')
export const Grid2x2 = createMockIcon('Grid2x2')
export const Grid3x3 = createMockIcon('Grid3x3')
export const List = createMockIcon('List')
export const ListChecks = createMockIcon('ListChecks')
export const ListTodo = createMockIcon('ListTodo')
export const CheckSquare = createMockIcon('CheckSquare')
export const Square2 = createMockIcon('Square2')
export const Circle = createMockIcon('Circle')
export const CircleCheck = createMockIcon('CircleCheck')
export const CircleX = createMockIcon('CircleX')
export const CircleDot = createMockIcon('CircleDot')
export const Target = createMockIcon('Target')
export const Focus = createMockIcon('Focus')
export const Crosshair = createMockIcon('Crosshair')
export const Locate = createMockIcon('Locate')
export const LocateFixed = createMockIcon('LocateFixed')
export const Navigation = createMockIcon('Navigation')
export const Navigation2 = createMockIcon('Navigation2')
export const Compass = createMockIcon('Compass')
export const Map = createMockIcon('Map')
export const TestTube = createMockIcon('TestTube')
export const FlaskConical = createMockIcon('FlaskConical')
export const Bug = createMockIcon('Bug')
export const GitBranch = createMockIcon('GitBranch')
export const GitCommit = createMockIcon('GitCommit')
export const GitMerge = createMockIcon('GitMerge')
export const GitPullRequest = createMockIcon('GitPullRequest')
export const Github = createMockIcon('Github')
export const Gitlab = createMockIcon('Gitlab')
export const Bitbucket = createMockIcon('Bitbucket')
export const Network = createMockIcon('Network')
export const GitGraph = createMockIcon('GitGraph')
export const ListFilter = createMockIcon('ListFilter')
export const CheckSquare2 = createMockIcon('CheckSquare2')
export const CircleSlash2 = createMockIcon('CircleSlash2')
export const Clock3 = createMockIcon('Clock3')
export const GitCommitHorizontal = createMockIcon('GitCommitHorizontal')
export const CalendarDays = createMockIcon('CalendarDays')
export const Sparkles = createMockIcon('Sparkles')
export const Layout = createMockIcon('Layout')
export const Table = createMockIcon('Table')
export const Columns = createMockIcon('Columns')
export const GitPullRequestDraft = createMockIcon('GitPullRequestDraft')
export const BrainCircuit = createMockIcon('BrainCircuit')
export const Wrench = createMockIcon('Wrench')
export const PlugZap = createMockIcon('PlugZap')
export const BoxIcon = createMockIcon('BoxIcon')
export const Box = createMockIcon('Box')
export const Brain = createMockIcon('Brain')
export const LinkIcon = createMockIcon('LinkIcon')
export const Sparkle = createMockIcon('Sparkle')
export const FolderTree = createMockIcon('FolderTree')
export const Lightbulb = createMockIcon('Lightbulb')
export const Rocket = createMockIcon('Rocket')
export const Building = createMockIcon('Building')
export const FileCode = createMockIcon('FileCode')
export const FileJson = createMockIcon('FileJson')
export const Braces = createMockIcon('Braces')
export const Binary = createMockIcon('Binary')
export const Palette = createMockIcon('Palette')
export const Paintbrush = createMockIcon('Paintbrush')
export const Type = createMockIcon('Type')
export const Heading = createMockIcon('Heading')
export const AlignLeft = createMockIcon('AlignLeft')
export const AlignCenter = createMockIcon('AlignCenter')
export const AlignRight = createMockIcon('AlignRight')
export const Bold = createMockIcon('Bold')
export const Italic = createMockIcon('Italic')
export const Underline = createMockIcon('Underline')
export const Strikethrough = createMockIcon('Strikethrough')
export const FileCheck = createMockIcon('FileCheck')
export const FileX = createMockIcon('FileX')
export const FilePlus = createMockIcon('FilePlus')
export const FileMinus = createMockIcon('FileMinus')
export const FolderPlus = createMockIcon('FolderPlus')
export const FolderMinus = createMockIcon('FolderMinus')
export const FolderCheck = createMockIcon('FolderCheck')
export const FolderX = createMockIcon('FolderX')
export const startMCPServer = createMockIcon('startMCPServer')
export const Pin = createMockIcon('Pin')
export const CheckCircle2 = createMockIcon('CheckCircle2')
export const Clipboard = createMockIcon('Clipboard')
export const LayoutGrid = createMockIcon('LayoutGrid')
export const Pencil = createMockIcon('Pencil')
export const MousePointer = createMockIcon('MousePointer')
export const GripVertical = createMockIcon('GripVertical')
export const History = createMockIcon('History')
export const PlusCircle = createMockIcon('PlusCircle')
export const MinusCircle = createMockIcon('MinusCircle')
export const ChevronDownIcon = createMockIcon('ChevronDownIcon')
export const FileIcon = createMockIcon('FileIcon')
export const AlertCircleIcon = createMockIcon('AlertCircleIcon')
export const Clock4 = createMockIcon('Clock4')
export const XIcon = createMockIcon('XIcon')
export const CheckIcon = createMockIcon('CheckIcon')
export const TrashIcon = createMockIcon('TrashIcon')
export const EyeIcon = createMockIcon('EyeIcon')
export const EditIcon = createMockIcon('EditIcon')
export const DownloadIcon = createMockIcon('DownloadIcon')
export const RefreshIcon = createMockIcon('RefreshIcon')
export const SearchIcon = createMockIcon('SearchIcon')
export const FilterIcon = createMockIcon('FilterIcon')
export const PlusIcon = createMockIcon('PlusIcon')
export const FolderIcon = createMockIcon('FolderIcon')
export const FileTextIcon = createMockIcon('FileTextIcon')
export const BookOpenIcon = createMockIcon('BookOpenIcon')
export const DatabaseIcon = createMockIcon('DatabaseIcon')
export const GlobeIcon = createMockIcon('GlobeIcon')
export const TagIcon = createMockIcon('TagIcon')
export const CalendarIcon = createMockIcon('CalendarIcon')
export const ClockIcon = createMockIcon('ClockIcon')
export const UserIcon = createMockIcon('UserIcon')
export const SettingsIcon = createMockIcon('SettingsIcon')
export const InfoIcon = createMockIcon('InfoIcon')
export const WarningIcon = createMockIcon('WarningIcon')
export const ErrorIcon = createMockIcon('ErrorIcon')

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '../../../testing/test-utils';
import { ProjectCard } from '../ProjectCard';
import type { Project } from '../../types';
describe('ProjectCard', () => {
const mockProject: Project = {
id: 'project-1',
title: 'Test Project',
description: 'Test Description',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
pinned: false,
features: [],
docs: [],
};
const mockTaskCounts = {
todo: 5,
doing: 3,
review: 2,
done: 10,
};
const mockHandlers = {
onSelect: vi.fn(),
onPin: vi.fn(),
onDelete: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render project title', () => {
render(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
expect(screen.getByText('Test Project')).toBeInTheDocument();
});
it('should display task counts', () => {
render(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
// Task count badges should be visible
// Note: Component only shows todo, doing, and done (not review)
const fives = screen.getAllByText('5');
expect(fives.length).toBeGreaterThan(0); // todo count
expect(screen.getByText('10')).toBeInTheDocument(); // done
// Doing count might be displayed as 3 or duplicated - implementation detail
});
it('should call onSelect when clicked', () => {
render(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
const card = screen.getByRole('listitem');
fireEvent.click(card);
expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockProject);
expect(mockHandlers.onSelect).toHaveBeenCalledTimes(1);
});
it('should apply selected styles when isSelected is true', () => {
const { container } = render(
<ProjectCard
project={mockProject}
isSelected={true}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
const card = container.querySelector('[role="listitem"]');
// Check for selected-specific classes
expect(card?.className).toContain('scale-[1.02]');
expect(card?.className).toContain('border-purple');
});
it('should apply pinned styles when project is pinned', () => {
const pinnedProject = { ...mockProject, pinned: true };
const { container } = render(
<ProjectCard
project={pinnedProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
const card = container.querySelector('[role="listitem"]');
// Check for pinned-specific classes
expect(card?.className).toContain('from-purple');
expect(card?.className).toContain('border-purple-500');
});
it('should render aurora glow effect when selected', () => {
const { container } = render(
<ProjectCard
project={mockProject}
isSelected={true}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
// Aurora glow div should exist when selected
const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
expect(glowEffect).toBeInTheDocument();
});
it('should not render aurora glow effect when not selected', () => {
const { container } = render(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
// Aurora glow div should not exist when not selected
const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
expect(glowEffect).not.toBeInTheDocument();
});
it('should show zero task counts correctly', () => {
const zeroTaskCounts = {
todo: 0,
doing: 0,
review: 0,
done: 0,
};
render(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={zeroTaskCounts}
{...mockHandlers}
/>
);
// All counts should show 0 (ProjectCard may not show review count)
const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThanOrEqual(3); // At least todo, doing, done
});
it('should handle very long project titles', () => {
const longTitleProject = {
...mockProject,
title: 'This is an extremely long project title that should be truncated properly to avoid breaking the layout of the card component',
};
render(
<ProjectCard
project={longTitleProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
const title = screen.getByText(/This is an extremely long project title/);
expect(title).toBeInTheDocument();
// Title should have line-clamp-2 class
expect(title.className).toContain('line-clamp-2');
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { projectKeys, useProjects, useCreateProject, useUpdateProject, useDeleteProject } from '../useProjectQueries';
import type { Project } from '../../types';
import React from 'react';
// Mock the services
vi.mock('../../services', () => ({
projectService: {
listProjects: vi.fn(),
createProject: vi.fn(),
updateProject: vi.fn(),
deleteProject: vi.fn(),
getProjectFeatures: vi.fn(),
},
taskService: {
getTaskCountsForAllProjects: vi.fn(),
},
}));
// Mock the toast hook
vi.mock('../../../ui/hooks/useToast', () => ({
useToast: () => ({
showToast: vi.fn(),
}),
}));
// Mock smart polling
vi.mock('../../../ui/hooks', () => ({
useSmartPolling: () => ({
refetchInterval: 5000,
isPaused: false,
}),
}));
// Test wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe('useProjectQueries', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('projectKeys', () => {
it('should generate correct query keys', () => {
expect(projectKeys.all).toEqual(['projects']);
expect(projectKeys.lists()).toEqual(['projects', 'list']);
expect(projectKeys.detail('123')).toEqual(['projects', 'detail', '123']);
expect(projectKeys.tasks('123')).toEqual(['projects', 'detail', '123', 'tasks']);
expect(projectKeys.features('123')).toEqual(['projects', 'detail', '123', 'features']);
expect(projectKeys.documents('123')).toEqual(['projects', 'detail', '123', 'documents']);
});
});
describe('useProjects', () => {
it('should fetch projects list', async () => {
const mockProjects: Project[] = [
{
id: '1',
title: 'Test Project',
description: 'Test Description',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
pinned: false,
features: [],
docs: [],
},
];
const { projectService } = await import('../../services');
vi.mocked(projectService.listProjects).mockResolvedValue(mockProjects);
const { result } = renderHook(() => useProjects(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).toEqual(mockProjects);
});
expect(projectService.listProjects).toHaveBeenCalledTimes(1);
});
});
describe('useCreateProject', () => {
it('should optimistically add project and replace with server response', async () => {
const newProject: Project = {
id: 'real-id',
title: 'New Project',
description: 'New Description',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
pinned: false,
features: [],
docs: [],
};
const { projectService } = await import('../../services');
vi.mocked(projectService.createProject).mockResolvedValue({
project: newProject,
message: 'Created',
});
const wrapper = createWrapper();
const { result } = renderHook(() => useCreateProject(), { wrapper });
await result.current.mutateAsync({
title: 'New Project',
description: 'New Description',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(projectService.createProject).toHaveBeenCalledWith({
title: 'New Project',
description: 'New Description',
});
});
});
it('should rollback on error', async () => {
const { projectService } = await import('../../services');
vi.mocked(projectService.createProject).mockRejectedValue(new Error('Network error'));
const wrapper = createWrapper();
const { result } = renderHook(() => useCreateProject(), { wrapper });
await expect(
result.current.mutateAsync({
title: 'New Project',
description: 'New Description',
})
).rejects.toThrow('Network error');
});
});
describe('useUpdateProject', () => {
it('should handle pinning a project', async () => {
const updatedProject: Project = {
id: '1',
title: 'Test Project',
description: 'Test Description',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
pinned: true,
features: [],
docs: [],
};
const { projectService } = await import('../../services');
vi.mocked(projectService.updateProject).mockResolvedValue(updatedProject);
const wrapper = createWrapper();
const { result } = renderHook(() => useUpdateProject(), { wrapper });
await result.current.mutateAsync({
projectId: '1',
updates: { pinned: true },
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(projectService.updateProject).toHaveBeenCalledWith('1', { pinned: true });
});
});
});
describe('useDeleteProject', () => {
it('should optimistically remove project', async () => {
const { projectService } = await import('../../services');
vi.mocked(projectService.deleteProject).mockResolvedValue(undefined);
const wrapper = createWrapper();
const { result } = renderHook(() => useDeleteProject(), { wrapper });
await result.current.mutateAsync('project-to-delete');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(projectService.deleteProject).toHaveBeenCalledWith('project-to-delete');
});
});
it('should rollback on delete error', async () => {
const { projectService } = await import('../../services');
vi.mocked(projectService.deleteProject).mockRejectedValue(new Error('Permission denied'));
const wrapper = createWrapper();
const { result } = renderHook(() => useDeleteProject(), { wrapper });
await expect(
result.current.mutateAsync('project-to-delete')
).rejects.toThrow('Permission denied');
});
});
});

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { taskKeys, useProjectTasks, useCreateTask } from '../useTaskQueries';
import type { Task } from '../../types';
import React from 'react';
// Mock the services
vi.mock('../../services', () => ({
taskService: {
getTasksByProject: vi.fn(),
createTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
},
}));
// Mock the toast hook
vi.mock('../../../../ui/hooks/useToast', () => ({
useToast: () => ({
showToast: vi.fn(),
}),
}));
// Mock smart polling
vi.mock('../../../../ui/hooks', () => ({
useSmartPolling: () => ({
refetchInterval: 5000,
isPaused: false,
}),
}));
// Test wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe('useTaskQueries', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('taskKeys', () => {
it('should generate correct query keys', () => {
expect(taskKeys.all('project-123')).toEqual(['projects', 'project-123', 'tasks']);
});
});
describe('useProjectTasks', () => {
it('should fetch tasks for a project', async () => {
const mockTasks: Task[] = [
{
id: 'task-1',
project_id: 'project-123',
title: 'Test Task',
description: 'Test Description',
status: 'todo',
assignee: 'User',
task_order: 100,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
const { taskService } = await import('../../services');
vi.mocked(taskService.getTasksByProject).mockResolvedValue(mockTasks);
const { result } = renderHook(() => useProjectTasks('project-123'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).toEqual(mockTasks);
});
expect(taskService.getTasksByProject).toHaveBeenCalledWith('project-123');
});
it('should not fetch tasks when projectId is undefined', () => {
const { result } = renderHook(() => useProjectTasks(undefined), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
expect(result.current.data).toBeUndefined();
});
it('should respect enabled flag', () => {
const { result } = renderHook(() => useProjectTasks('project-123', false), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
expect(result.current.data).toBeUndefined();
});
});
describe('useCreateTask', () => {
it('should optimistically add task and replace with server response', async () => {
const newTask: Task = {
id: 'real-task-id',
project_id: 'project-123',
title: 'New Task',
description: 'New Description',
status: 'todo',
assignee: 'User',
task_order: 100,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const { taskService } = await import('../../services');
vi.mocked(taskService.createTask).mockResolvedValue(newTask);
const wrapper = createWrapper();
const { result } = renderHook(() => useCreateTask(), { wrapper });
await result.current.mutateAsync({
project_id: 'project-123',
title: 'New Task',
description: 'New Description',
status: 'todo',
assignee: 'User',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(taskService.createTask).toHaveBeenCalledWith({
project_id: 'project-123',
title: 'New Task',
description: 'New Description',
status: 'todo',
assignee: 'User',
});
});
});
it('should provide default values for optional fields', async () => {
const newTask: Task = {
id: 'real-task-id',
project_id: 'project-123',
title: 'Minimal Task',
description: '',
status: 'todo',
assignee: 'User',
task_order: 100,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const { taskService } = await import('../../services');
vi.mocked(taskService.createTask).mockResolvedValue(newTask);
const wrapper = createWrapper();
const { result } = renderHook(() => useCreateTask(), { wrapper });
await result.current.mutateAsync({
project_id: 'project-123',
title: 'Minimal Task',
description: '',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('should rollback on error', async () => {
const { taskService } = await import('../../services');
vi.mocked(taskService.createTask).mockRejectedValue(new Error('Network error'));
const wrapper = createWrapper();
const { result } = renderHook(() => useCreateTask(), { wrapper });
await expect(
result.current.mutateAsync({
project_id: 'project-123',
title: 'Failed Task',
description: 'This will fail',
})
).rejects.toThrow('Network error');
});
});
});

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { render as rtlRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '../ui/components/ToastProvider';
import { TooltipProvider } from '../ui/primitives/tooltip';
/**
* Custom render function that wraps components with all necessary providers
* This follows the best practice of having a centralized test render utility
*/
export function renderWithProviders(
ui: React.ReactElement,
{
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
}),
...renderOptions
} = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<ToastProvider>
{children}
</ToastProvider>
</TooltipProvider>
</QueryClientProvider>
);
}
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}
// Re-export everything from React Testing Library
export * from '@testing-library/react';
// Override the default render with our custom one
export { renderWithProviders as render };

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useSmartPolling } from '../useSmartPolling';
describe('useSmartPolling', () => {
beforeEach(() => {
// Reset document visibility state
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true,
});
Object.defineProperty(document, 'hidden', {
value: false,
writable: true,
configurable: true,
});
// Mock document.hasFocus
document.hasFocus = vi.fn(() => true);
});
afterEach(() => {
vi.clearAllTimers();
vi.clearAllMocks();
});
it('should return the base interval when document is visible and focused', () => {
const { result } = renderHook(() => useSmartPolling(5000));
expect(result.current.refetchInterval).toBe(5000);
expect(result.current.isActive).toBe(true);
expect(result.current.isVisible).toBe(true);
expect(result.current.hasFocus).toBe(true);
});
it('should disable polling when document is hidden', () => {
const { result } = renderHook(() => useSmartPolling(5000));
// Initially should be active
expect(result.current.isActive).toBe(true);
expect(result.current.refetchInterval).toBe(5000);
// Simulate tab becoming hidden
act(() => {
Object.defineProperty(document, 'hidden', {
value: true,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
// Should be disabled (returns false)
expect(result.current.isVisible).toBe(false);
expect(result.current.isActive).toBe(false);
expect(result.current.refetchInterval).toBe(false);
});
it('should resume polling when document becomes visible again', () => {
const { result } = renderHook(() => useSmartPolling(5000));
// Make hidden
act(() => {
Object.defineProperty(document, 'hidden', {
value: true,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current.refetchInterval).toBe(false);
// Make visible again
act(() => {
Object.defineProperty(document, 'hidden', {
value: false,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current.isVisible).toBe(true);
expect(result.current.isActive).toBe(true);
expect(result.current.refetchInterval).toBe(5000);
});
it('should slow down to 60 seconds when window loses focus', () => {
const { result } = renderHook(() => useSmartPolling(5000));
// Initially focused
expect(result.current.refetchInterval).toBe(5000);
expect(result.current.hasFocus).toBe(true);
// Simulate window blur
act(() => {
window.dispatchEvent(new Event('blur'));
});
// Should be slowed down to 60 seconds
expect(result.current.hasFocus).toBe(false);
expect(result.current.isActive).toBe(false);
expect(result.current.refetchInterval).toBe(60000);
});
it('should resume normal speed when window regains focus', () => {
const { result } = renderHook(() => useSmartPolling(5000));
// Blur window
act(() => {
window.dispatchEvent(new Event('blur'));
});
expect(result.current.refetchInterval).toBe(60000);
// Focus window again
act(() => {
window.dispatchEvent(new Event('focus'));
});
expect(result.current.hasFocus).toBe(true);
expect(result.current.isActive).toBe(true);
expect(result.current.refetchInterval).toBe(5000);
});
it('should handle different base intervals', () => {
const { result: result1 } = renderHook(() => useSmartPolling(1000));
const { result: result2 } = renderHook(() => useSmartPolling(10000));
expect(result1.current.refetchInterval).toBe(1000);
expect(result2.current.refetchInterval).toBe(10000);
// When blurred, both should be 60 seconds
act(() => {
window.dispatchEvent(new Event('blur'));
});
expect(result1.current.refetchInterval).toBe(60000);
expect(result2.current.refetchInterval).toBe(60000);
});
it('should use default interval of 10000ms when not specified', () => {
const { result } = renderHook(() => useSmartPolling());
expect(result.current.refetchInterval).toBe(10000);
});
it('should cleanup event listeners on unmount', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const windowRemoveEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSmartPolling(5000));
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('blur', expect.any(Function));
removeEventListenerSpy.mockRestore();
windowRemoveEventListenerSpy.mockRestore();
});
it('should correctly report isActive state', () => {
const { result } = renderHook(() => useSmartPolling(5000));
// Active when both visible and focused
expect(result.current.isActive).toBe(true);
// Not active when not focused
act(() => {
window.dispatchEvent(new Event('blur'));
});
expect(result.current.isActive).toBe(false);
// Not active when hidden
act(() => {
window.dispatchEvent(new Event('focus')); // Focus first
Object.defineProperty(document, 'hidden', {
value: true,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current.isActive).toBe(false);
});
});

View File

@@ -1,294 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import React from 'react'
describe('Component Tests', () => {
test('button component works', () => {
const onClick = vi.fn()
const MockButton = ({ children, ...props }: any) => (
<button {...props}>{children}</button>
)
render(<MockButton onClick={onClick}>Click me</MockButton>)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(onClick).toHaveBeenCalledTimes(1)
})
test('input component works', () => {
const MockInput = () => {
const [value, setValue] = React.useState('')
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Test input"
/>
)
}
render(<MockInput />)
const input = screen.getByPlaceholderText('Test input')
fireEvent.change(input, { target: { value: 'test' } })
expect((input as HTMLInputElement).value).toBe('test')
})
test('modal component works', () => {
const MockModal = () => {
const [isOpen, setIsOpen] = React.useState(false)
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && (
<div role="dialog">
<h2>Modal Title</h2>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</div>
)
}
render(<MockModal />)
// Modal not visible initially
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// Open modal
fireEvent.click(screen.getByText('Open Modal'))
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Close modal
fireEvent.click(screen.getByText('Close'))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
test('progress bar component works', () => {
const MockProgressBar = ({ value, max }: { value: number; max: number }) => (
<div>
<div>Progress: {Math.round((value / max) * 100)}%</div>
<div style={{ width: `${(value / max) * 100}%` }}>Bar</div>
</div>
)
const { rerender } = render(<MockProgressBar value={0} max={100} />)
expect(screen.getByText('Progress: 0%')).toBeInTheDocument()
rerender(<MockProgressBar value={50} max={100} />)
expect(screen.getByText('Progress: 50%')).toBeInTheDocument()
rerender(<MockProgressBar value={100} max={100} />)
expect(screen.getByText('Progress: 100%')).toBeInTheDocument()
})
test('tooltip component works', () => {
const MockTooltip = ({ children, tooltip }: any) => {
const [show, setShow] = React.useState(false)
return (
<div>
<button
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
</button>
{show && <div role="tooltip">{tooltip}</div>}
</div>
)
}
render(<MockTooltip tooltip="This is a tooltip">Hover me</MockTooltip>)
const button = screen.getByText('Hover me')
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.mouseEnter(button)
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.mouseLeave(button)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
test('accordion component works', () => {
const MockAccordion = () => {
const [expanded, setExpanded] = React.useState(false)
return (
<div>
<button onClick={() => setExpanded(!expanded)}>
Section 1 {expanded ? '' : '+'}
</button>
{expanded && <div>Section content</div>}
</div>
)
}
render(<MockAccordion />)
expect(screen.queryByText('Section content')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Section 1 +'))
expect(screen.getByText('Section content')).toBeInTheDocument()
fireEvent.click(screen.getByText('Section 1 '))
expect(screen.queryByText('Section content')).not.toBeInTheDocument()
})
test('table sorting works', () => {
const MockTable = () => {
const [data, setData] = React.useState([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
{ name: 'Charlie', age: 35 }
])
const sortByName = () => {
setData([...data].sort((a, b) => a.name.localeCompare(b.name)))
}
return (
<table>
<thead>
<tr>
<th onClick={sortByName} style={{ cursor: 'pointer' }}>
Name
</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr key={index}>
<td>{row.name}</td>
<td>{row.age}</td>
</tr>
))}
</tbody>
</table>
)
}
render(<MockTable />)
const cells = screen.getAllByRole('cell')
expect(cells[0]).toHaveTextContent('Alice')
fireEvent.click(screen.getByText('Name'))
// After sorting, Alice should still be first (already sorted)
const sortedCells = screen.getAllByRole('cell')
expect(sortedCells[0]).toHaveTextContent('Alice')
})
test('pagination works', () => {
const MockPagination = () => {
const [page, setPage] = React.useState(1)
return (
<div>
<div>Page {page}</div>
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
Previous
</button>
<button onClick={() => setPage(page + 1)}>
Next
</button>
</div>
)
}
render(<MockPagination />)
expect(screen.getByText('Page 1')).toBeInTheDocument()
fireEvent.click(screen.getByText('Next'))
expect(screen.getByText('Page 2')).toBeInTheDocument()
fireEvent.click(screen.getByText('Previous'))
expect(screen.getByText('Page 1')).toBeInTheDocument()
})
test('form validation works', () => {
const MockForm = () => {
const [email, setEmail] = React.useState('')
const [error, setError] = React.useState('')
const validate = (value: string) => {
if (!value) {
setError('Email is required')
} else if (!value.includes('@')) {
setError('Invalid email format')
} else {
setError('')
}
}
return (
<div>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
validate(e.target.value)
}}
/>
{error && <div role="alert">{error}</div>}
</div>
)
}
render(<MockForm />)
const input = screen.getByPlaceholderText('Email')
fireEvent.change(input, { target: { value: 'invalid' } })
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email format')
fireEvent.change(input, { target: { value: 'valid@email.com' } })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
test('search filtering works', () => {
const MockSearch = () => {
const [query, setQuery] = React.useState('')
const items = ['Apple', 'Banana', 'Cherry', 'Date']
const filtered = items.filter(item =>
item.toLowerCase().includes(query.toLowerCase())
)
return (
<div>
<input
placeholder="Search items"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{filtered.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)
}
render(<MockSearch />)
// All items visible initially
expect(screen.getByText('Apple')).toBeInTheDocument()
expect(screen.getByText('Banana')).toBeInTheDocument()
// Filter items
const input = screen.getByPlaceholderText('Search items')
fireEvent.change(input, { target: { value: 'a' } })
expect(screen.getByText('Apple')).toBeInTheDocument()
expect(screen.getByText('Banana')).toBeInTheDocument()
expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
})
})

View File

@@ -1,107 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { DeleteConfirmModal } from '../../../src/components/common/DeleteConfirmModal';
describe('DeleteConfirmModal', () => {
const defaultProps = {
itemName: 'Test Item',
onConfirm: vi.fn(),
onCancel: vi.fn(),
type: 'task' as const,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with correct title and message for task type', () => {
render(<DeleteConfirmModal {...defaultProps} />);
expect(screen.getByText('Delete Task')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete the "Test Item" task/)).toBeInTheDocument();
});
it('renders with correct title and message for project type', () => {
render(<DeleteConfirmModal {...defaultProps} type="project" />);
expect(screen.getByText('Delete Project')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete the "Test Item" project/)).toBeInTheDocument();
});
it('renders with correct title and message for client type', () => {
render(<DeleteConfirmModal {...defaultProps} type="client" />);
expect(screen.getByText('Delete MCP Client')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete the "Test Item" client/)).toBeInTheDocument();
});
it('calls onConfirm when Delete button is clicked', () => {
render(<DeleteConfirmModal {...defaultProps} />);
fireEvent.click(screen.getByText('Delete'));
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1);
});
it('calls onCancel when Cancel button is clicked', () => {
render(<DeleteConfirmModal {...defaultProps} />);
fireEvent.click(screen.getByText('Cancel'));
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
});
it('calls onCancel when Escape key is pressed', () => {
render(<DeleteConfirmModal {...defaultProps} />);
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
});
it('calls onCancel when backdrop is clicked', () => {
render(<DeleteConfirmModal {...defaultProps} />);
// Click the backdrop
const backdrop = screen.getByTestId('modal-backdrop');
fireEvent.click(backdrop);
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
});
it('does not call onCancel when modal content is clicked', () => {
render(<DeleteConfirmModal {...defaultProps} />);
// Click the modal dialog itself
fireEvent.click(screen.getByRole('dialog'));
expect(defaultProps.onCancel).not.toHaveBeenCalled();
});
it('has proper accessibility attributes', () => {
render(<DeleteConfirmModal {...defaultProps} />);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby');
expect(dialog).toHaveAttribute('aria-describedby');
});
it('focuses Cancel button by default', () => {
render(<DeleteConfirmModal {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toHaveFocus();
});
it('has proper button types', () => {
render(<DeleteConfirmModal {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
const deleteButton = screen.getByText('Delete');
expect(cancelButton).toHaveAttribute('type', 'button');
expect(deleteButton).toHaveAttribute('type', 'button');
});
});

View File

@@ -1,407 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, test, expect, vi, beforeEach } from 'vitest'
import React from 'react'
// Mock the dependencies
vi.mock('../../../src/contexts/ToastContext', () => ({
useToast: () => ({
showToast: vi.fn()
})
}))
vi.mock('../../../src/services/projectService', () => ({
projectService: {
getProjectDocuments: vi.fn().mockResolvedValue([]),
deleteDocument: vi.fn().mockResolvedValue(undefined),
updateDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Updated' }),
getDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Document 1' })
}
}))
vi.mock('../../../src/services/knowledgeBaseService', () => ({
knowledgeBaseService: {
getItems: vi.fn().mockResolvedValue([])
}
}))
// Create a minimal DocsTab component for testing
const DocsTabTest = () => {
const [documents, setDocuments] = React.useState([
{
id: 'doc-1',
title: 'Document 1',
content: { type: 'prp' },
document_type: 'prp',
updated_at: '2025-07-30T12:00:00Z'
},
{
id: 'doc-2',
title: 'Document 2',
content: { type: 'technical' },
document_type: 'technical',
updated_at: '2025-07-30T13:00:00Z'
},
{
id: 'doc-3',
title: 'Document 3',
content: { type: 'business' },
document_type: 'business',
updated_at: '2025-07-30T14:00:00Z'
}
])
const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
const { showToast } = { showToast: vi.fn() }
return (
<div>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700">
{documents.map(doc => (
<div
key={doc.id}
data-testid={`document-card-${doc.id}`}
className={`flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer ${
selectedDocument?.id === doc.id ? 'border-2 border-blue-500' : 'border border-gray-200'
}`}
onClick={() => setSelectedDocument(doc)}
>
<div className={`text-xs ${doc.document_type}`}>{doc.document_type}</div>
<h4>{doc.title}</h4>
{selectedDocument?.id !== doc.id && (
<button
data-testid={`delete-${doc.id}`}
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete "${doc.title}"?`)) {
setDocuments(prev => prev.filter(d => d.id !== doc.id))
if (selectedDocument?.id === doc.id) {
setSelectedDocument(documents.find(d => d.id !== doc.id) || null)
}
showToast('Document deleted', 'success')
}
}}
>
Delete
</button>
)}
</div>
))}
<div
data-testid="new-document-card"
className="flex-shrink-0 w-48 h-32 rounded-lg border-2 border-dashed"
onClick={() => console.log('New document')}
>
New Document
</div>
</div>
{selectedDocument && (
<div data-testid="selected-document">
Selected: {selectedDocument.title}
</div>
)}
</div>
)
}
describe('DocsTab Document Cards Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('renders all document cards', () => {
render(<DocsTabTest />)
expect(screen.getByTestId('document-card-doc-1')).toBeInTheDocument()
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
expect(screen.getByTestId('document-card-doc-3')).toBeInTheDocument()
expect(screen.getByTestId('new-document-card')).toBeInTheDocument()
})
test('shows active state on selected document', () => {
render(<DocsTabTest />)
const doc1 = screen.getByTestId('document-card-doc-1')
expect(doc1.className).toContain('border-blue-500')
const doc2 = screen.getByTestId('document-card-doc-2')
expect(doc2.className).not.toContain('border-blue-500')
})
test('switches between documents', () => {
render(<DocsTabTest />)
// Initially doc-1 is selected
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
// Click on doc-2
fireEvent.click(screen.getByTestId('document-card-doc-2'))
// Now doc-2 should be selected
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
// Check active states
expect(screen.getByTestId('document-card-doc-1').className).not.toContain('border-blue-500')
expect(screen.getByTestId('document-card-doc-2').className).toContain('border-blue-500')
})
test('deletes document with confirmation', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<DocsTabTest />)
// Click delete on doc-2
const deleteButton = screen.getByTestId('delete-doc-2')
fireEvent.click(deleteButton)
expect(confirmSpy).toHaveBeenCalledWith('Delete "Document 2"?')
// Document should be removed
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
confirmSpy.mockRestore()
})
test('cancels delete when user declines', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<DocsTabTest />)
// Click delete on doc-2
const deleteButton = screen.getByTestId('delete-doc-2')
fireEvent.click(deleteButton)
// Document should still be there
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
confirmSpy.mockRestore()
})
test('selects next document when deleting active document', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<DocsTabTest />)
// doc-1 is initially selected
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
// Switch to doc-2
fireEvent.click(screen.getByTestId('document-card-doc-2'))
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
// Switch to doc-1 to delete a non-selected document
fireEvent.click(screen.getByTestId('document-card-doc-1'))
// Delete doc-2 (not currently selected - it should have delete button)
const deleteButton = screen.getByTestId('delete-doc-2')
fireEvent.click(deleteButton)
// Should automatically select another document
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document')
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
confirmSpy.mockRestore()
})
test('does not show delete button on active card', () => {
render(<DocsTabTest />)
// doc-1 is active, should not have delete button
expect(screen.queryByTestId('delete-doc-1')).not.toBeInTheDocument()
// doc-2 is not active, should have delete button
expect(screen.getByTestId('delete-doc-2')).toBeInTheDocument()
})
test('horizontal scroll container has correct classes', () => {
const { container } = render(<DocsTabTest />)
const scrollContainer = container.querySelector('.overflow-x-auto')
expect(scrollContainer).toBeInTheDocument()
expect(scrollContainer?.className).toContain('scrollbar-thin')
expect(scrollContainer?.className).toContain('scrollbar-thumb-gray-300')
})
test('document cards maintain fixed width', () => {
render(<DocsTabTest />)
const cards = screen.getAllByTestId(/document-card-doc-/)
cards.forEach(card => {
expect(card.className).toContain('flex-shrink-0')
expect(card.className).toContain('w-48')
})
})
})
describe('DocsTab Document API Integration', () => {
test('calls deleteDocument API when deleting a document', async () => {
const { projectService } = await import('../../../src/services/projectService')
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
// Create a test component that uses the actual deletion logic
const DocsTabWithAPI = () => {
const [documents, setDocuments] = React.useState([
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
{ id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
])
const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
const project = { id: 'proj-123', title: 'Test Project' }
const { showToast } = { showToast: vi.fn() }
const handleDelete = async (docId: string) => {
try {
// This mirrors the actual DocsTab deletion logic
await projectService.deleteDocument(project.id, docId)
setDocuments(prev => prev.filter(d => d.id !== docId))
if (selectedDocument?.id === docId) {
setSelectedDocument(documents.find(d => d.id !== docId) || null)
}
showToast('Document deleted', 'success')
} catch (error) {
console.error('Failed to delete document:', error)
showToast('Failed to delete document', 'error')
}
}
return (
<div>
{documents.map(doc => (
<div key={doc.id} data-testid={`doc-${doc.id}`}>
<span>{doc.title}</span>
<button
data-testid={`delete-${doc.id}`}
onClick={() => {
if (confirm(`Delete "${doc.title}"?`)) {
handleDelete(doc.id)
}
}}
>
Delete
</button>
</div>
))}
</div>
)
}
render(<DocsTabWithAPI />)
// Click delete button
fireEvent.click(screen.getByTestId('delete-doc-2'))
// Wait for async operations
await waitFor(() => {
expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-2')
})
// Verify document is removed from UI
expect(screen.queryByTestId('doc-doc-2')).not.toBeInTheDocument()
confirmSpy.mockRestore()
})
test('handles deletion API errors gracefully', async () => {
const { projectService } = await import('../../../src/services/projectService')
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Make deleteDocument reject
projectService.deleteDocument = vi.fn().mockRejectedValue(new Error('API Error'))
const DocsTabWithError = () => {
const [documents, setDocuments] = React.useState([
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' }
])
const project = { id: 'proj-123', title: 'Test Project' }
const showToast = vi.fn()
const handleDelete = async (docId: string) => {
try {
await projectService.deleteDocument(project.id, docId)
setDocuments(prev => prev.filter(d => d.id !== docId))
showToast('Document deleted', 'success')
} catch (error) {
console.error('Failed to delete document:', error)
showToast('Failed to delete document', 'error')
}
}
return (
<div>
{documents.map(doc => (
<div key={doc.id} data-testid={`doc-${doc.id}`}>
<button
data-testid={`delete-${doc.id}`}
onClick={() => {
if (confirm(`Delete "${doc.title}"?`)) {
handleDelete(doc.id)
}
}}
>
Delete
</button>
</div>
))}
<div data-testid="toast-container" />
</div>
)
}
render(<DocsTabWithError />)
// Click delete button
fireEvent.click(screen.getByTestId('delete-doc-1'))
// Wait for async operations
await waitFor(() => {
expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-1')
})
// Document should still be in UI due to error
expect(screen.getByTestId('doc-doc-1')).toBeInTheDocument()
// Error should be logged
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete document:', expect.any(Error))
confirmSpy.mockRestore()
consoleSpy.mockRestore()
})
test('deletion persists after page refresh', async () => {
const { projectService } = await import('../../../src/services/projectService')
// Simulate documents before deletion
let mockDocuments = [
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
{ id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
]
// First render - before deletion
const { rerender } = render(<div data-testid="docs-count">{mockDocuments.length}</div>)
expect(screen.getByTestId('docs-count')).toHaveTextContent('2')
// Mock deleteDocument to also update the mock data
projectService.deleteDocument = vi.fn().mockImplementation(async (projectId, docId) => {
mockDocuments = mockDocuments.filter(d => d.id !== docId)
return Promise.resolve()
})
// Mock the list function to return current state
projectService.listProjectDocuments = vi.fn().mockImplementation(async () => {
return mockDocuments
})
// Perform deletion
await projectService.deleteDocument('proj-123', 'doc-2')
// Simulate page refresh by re-fetching documents
const refreshedDocs = await projectService.listProjectDocuments('proj-123')
// Re-render with refreshed data
rerender(<div data-testid="docs-count">{refreshedDocs.length}</div>)
// Should only have 1 document after refresh
expect(screen.getByTestId('docs-count')).toHaveTextContent('1')
expect(refreshedDocs).toHaveLength(1)
expect(refreshedDocs[0].id).toBe('doc-1')
})
})

View File

@@ -1,227 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import React from 'react'
import { DocumentCard, NewDocumentCard } from '../../../src/components/project-tasks/DocumentCard'
import type { ProjectDoc } from '../../../src/components/project-tasks/DocumentCard'
describe('DocumentCard', () => {
const mockDocument: ProjectDoc = {
id: 'doc-1',
title: 'Test Document',
content: { test: 'content' },
document_type: 'prp',
updated_at: '2025-07-30T12:00:00Z',
}
const mockHandlers = {
onSelect: vi.fn(),
onDelete: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
test('renders document card with correct content', () => {
render(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
expect(screen.getByText('Test Document')).toBeInTheDocument()
expect(screen.getByText('prp')).toBeInTheDocument()
expect(screen.getByText('7/30/2025')).toBeInTheDocument()
})
test('shows correct icon and color for different document types', () => {
const documentTypes = [
{ type: 'prp', expectedClass: 'text-blue-600' },
{ type: 'technical', expectedClass: 'text-green-600' },
{ type: 'business', expectedClass: 'text-purple-600' },
{ type: 'meeting_notes', expectedClass: 'text-orange-600' },
]
documentTypes.forEach(({ type, expectedClass }) => {
const { container, rerender } = render(
<DocumentCard
document={{ ...mockDocument, document_type: type }}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const badge = container.querySelector(`.${expectedClass}`)
expect(badge).toBeInTheDocument()
})
})
test('applies active styles when selected', () => {
const { container } = render(
<DocumentCard
document={mockDocument}
isActive={true}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const card = container.firstChild as HTMLElement
expect(card.className).toContain('border-blue-500')
expect(card.className).toContain('scale-105')
})
test('calls onSelect when clicked', () => {
render(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const card = screen.getByText('Test Document').closest('div')
fireEvent.click(card!)
expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockDocument)
})
test('shows delete button on hover', () => {
const { container } = render(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const card = container.firstChild as HTMLElement
// Delete button should not be visible initially
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
// Hover over the card
fireEvent.mouseEnter(card)
// Delete button should now be visible
expect(screen.getByLabelText('Delete Test Document')).toBeInTheDocument()
// Mouse leave
fireEvent.mouseLeave(card)
// Delete button should be hidden again
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
})
test('does not show delete button on active card', () => {
const { container } = render(
<DocumentCard
document={mockDocument}
isActive={true}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const card = container.firstChild as HTMLElement
fireEvent.mouseEnter(card)
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
})
test('confirms before deleting', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const { container } = render(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const card = container.firstChild as HTMLElement
fireEvent.mouseEnter(card)
const deleteButton = screen.getByLabelText('Delete Test Document')
fireEvent.click(deleteButton)
expect(confirmSpy).toHaveBeenCalledWith('Delete "Test Document"?')
expect(mockHandlers.onDelete).toHaveBeenCalledWith('doc-1')
confirmSpy.mockRestore()
})
test('cancels delete when user declines', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { container } = render(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const card = container.firstChild as HTMLElement
fireEvent.mouseEnter(card)
const deleteButton = screen.getByLabelText('Delete Test Document')
fireEvent.click(deleteButton)
expect(confirmSpy).toHaveBeenCalled()
expect(mockHandlers.onDelete).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
test('applies dark mode styles correctly', () => {
const { container } = render(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={true}
/>
)
const card = container.firstChild as HTMLElement
expect(card.className).toContain('dark:')
})
})
describe('NewDocumentCard', () => {
test('renders new document card', () => {
const onClick = vi.fn()
render(<NewDocumentCard onClick={onClick} />)
expect(screen.getByText('New Document')).toBeInTheDocument()
})
test('calls onClick when clicked', () => {
const onClick = vi.fn()
render(<NewDocumentCard onClick={onClick} />)
const card = screen.getByText('New Document').closest('div')
fireEvent.click(card!)
expect(onClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,272 +0,0 @@
import { describe, test, expect } from 'vitest'
// Test the PRP to Markdown conversion logic
describe('MilkdownEditor PRP Conversion', () => {
// Helper function to format values (extracted from component)
const formatValue = (value: any, indent = ''): string => {
if (Array.isArray(value)) {
return value.map(item => `${indent}- ${formatValue(item, indent + ' ')}`).join('\n') + '\n'
}
if (typeof value === 'object' && value !== null) {
let result = ''
Object.entries(value).forEach(([key, val]) => {
const formattedKey = key.replace(/_/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
if (typeof val === 'string' || typeof val === 'number') {
result += `${indent}**${formattedKey}:** ${val}\n\n`
} else {
result += `${indent}### ${formattedKey}\n\n${formatValue(val, indent)}`
}
})
return result
}
return String(value)
}
// Simplified version of convertPRPToMarkdown for testing
const convertPRPToMarkdown = (content: any, docTitle = 'Test Doc'): string => {
let markdown = `# ${content.title || docTitle}\n\n`
// Metadata section
if (content.version || content.author || content.date || content.status) {
markdown += `## Metadata\n\n`
if (content.version) markdown += `- **Version:** ${content.version}\n`
if (content.author) markdown += `- **Author:** ${content.author}\n`
if (content.date) markdown += `- **Date:** ${content.date}\n`
if (content.status) markdown += `- **Status:** ${content.status}\n`
markdown += '\n'
}
// Goal section
if (content.goal) {
markdown += `## Goal\n\n${content.goal}\n\n`
}
// Why section
if (content.why) {
markdown += `## Why\n\n`
if (Array.isArray(content.why)) {
content.why.forEach(item => markdown += `- ${item}\n`)
} else {
markdown += `${content.why}\n`
}
markdown += '\n'
}
// What section
if (content.what) {
markdown += `## What\n\n`
if (typeof content.what === 'string') {
markdown += `${content.what}\n\n`
} else if (content.what.description) {
markdown += `${content.what.description}\n\n`
if (content.what.success_criteria) {
markdown += `### Success Criteria\n\n`
content.what.success_criteria.forEach((criterion: string) => {
markdown += `- [ ] ${criterion}\n`
})
markdown += '\n'
}
}
}
// Handle all other sections dynamically
const handledKeys = [
'title', 'version', 'author', 'date', 'status', 'goal', 'why', 'what',
'document_type'
]
Object.entries(content).forEach(([key, value]) => {
if (!handledKeys.includes(key) && value) {
const sectionTitle = key.replace(/_/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
markdown += `## ${sectionTitle}\n\n`
markdown += formatValue(value)
markdown += '\n'
}
})
return markdown
}
test('converts basic PRP structure to markdown', () => {
const prp = {
title: 'Test PRP',
version: '1.0',
author: 'Test Author',
date: '2025-07-30',
status: 'draft',
goal: 'Test goal'
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).toContain('# Test PRP')
expect(markdown).toContain('## Metadata')
expect(markdown).toContain('- **Version:** 1.0')
expect(markdown).toContain('- **Author:** Test Author')
expect(markdown).toContain('- **Date:** 2025-07-30')
expect(markdown).toContain('- **Status:** draft')
expect(markdown).toContain('## Goal\n\nTest goal')
})
test('handles array why section', () => {
const prp = {
title: 'Test PRP',
why: ['Reason 1', 'Reason 2', 'Reason 3']
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).toContain('## Why')
expect(markdown).toContain('- Reason 1')
expect(markdown).toContain('- Reason 2')
expect(markdown).toContain('- Reason 3')
})
test('handles string why section', () => {
const prp = {
title: 'Test PRP',
why: 'Single reason for the change'
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).toContain('## Why')
expect(markdown).toContain('Single reason for the change')
})
test('handles complex what section with success criteria', () => {
const prp = {
title: 'Test PRP',
what: {
description: 'Main description of what we are building',
success_criteria: [
'Criterion 1',
'Criterion 2',
'Criterion 3'
]
}
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).toContain('## What')
expect(markdown).toContain('Main description of what we are building')
expect(markdown).toContain('### Success Criteria')
expect(markdown).toContain('- [ ] Criterion 1')
expect(markdown).toContain('- [ ] Criterion 2')
expect(markdown).toContain('- [ ] Criterion 3')
})
test('handles dynamic sections', () => {
const prp = {
title: 'Test PRP',
user_personas: {
developer: {
name: 'Developer Dan',
goals: ['Write clean code', 'Ship features fast']
}
},
technical_requirements: {
frontend: 'React 18',
backend: 'FastAPI',
database: 'PostgreSQL'
}
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).toContain('## User Personas')
expect(markdown).toContain('### Developer')
expect(markdown).toContain('**Name:** Developer Dan')
expect(markdown).toContain('## Technical Requirements')
expect(markdown).toContain('**Frontend:** React 18')
expect(markdown).toContain('**Backend:** FastAPI')
})
test('formats nested objects correctly', () => {
const value = {
level1: {
level2: {
level3: 'Deep value'
}
}
}
const formatted = formatValue(value)
expect(formatted).toContain('### Level1')
expect(formatted).toContain('### Level2')
expect(formatted).toContain('**Level3:** Deep value')
})
test('formats arrays correctly', () => {
const value = ['Item 1', 'Item 2', { nested: 'Nested item' }]
const formatted = formatValue(value)
expect(formatted).toContain('- Item 1')
expect(formatted).toContain('- Item 2')
expect(formatted).toContain('**Nested:** Nested item')
})
test('handles empty content', () => {
const prp = {}
const markdown = convertPRPToMarkdown(prp, 'Default Title')
expect(markdown).toBe('# Default Title\n\n')
})
test('skips null and undefined values', () => {
const prp = {
title: 'Test PRP',
null_field: null,
undefined_field: undefined,
empty_string: '',
valid_field: 'Valid content'
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).not.toContain('Null Field')
expect(markdown).not.toContain('Undefined Field')
expect(markdown).not.toContain('Empty String')
expect(markdown).toContain('## Valid Field')
expect(markdown).toContain('Valid content')
})
test('converts snake_case to Title Case', () => {
const prp = {
title: 'Test PRP',
user_journey_mapping: 'Content',
api_endpoint_design: 'More content'
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).toContain('## User Journey Mapping')
expect(markdown).toContain('## Api Endpoint Design')
})
test('preserves markdown formatting in content', () => {
const prp = {
title: 'Test PRP',
description: '**Bold text** and *italic text* with `code`'
}
const markdown = convertPRPToMarkdown(prp)
expect(markdown).toContain('**Bold text** and *italic text* with `code`')
})
})

View File

@@ -1,60 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
describe('TasksTab Drag and Drop Integration', () => {
it('should properly manage movingTaskIds during drag operations', () => {
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
const fileContent = readFileSync(tasksTabPath, 'utf-8');
// Check that moveTask adds task to movingTaskIds
expect(fileContent).toContain('setMovingTaskIds(prev => new Set([...prev, taskId]))');
// Check that moveTask removes task from movingTaskIds in finally block
expect(fileContent).toContain('finally {');
expect(fileContent).toMatch(/finally\s*{\s*\/\/\s*Remove from loading set\s*setMovingTaskIds/);
// Check that the cleanup happens even on error
const moveTaskMatch = fileContent.match(/const moveTask[\s\S]*?\n{2}\};/);
expect(moveTaskMatch).toBeTruthy();
if (moveTaskMatch) {
const moveTaskFunction = moveTaskMatch[0];
expect(moveTaskFunction).toContain('try {');
expect(moveTaskFunction).toContain('catch (error)');
expect(moveTaskFunction).toContain('finally {');
}
});
it('should pass movingTaskIds to TaskBoardView', () => {
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
const fileContent = readFileSync(tasksTabPath, 'utf-8');
// Check that movingTaskIds is passed to TaskBoardView
expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
});
it('should handle task completion through moveTask', () => {
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
const fileContent = readFileSync(tasksTabPath, 'utf-8');
// Check that completeTask calls moveTask
expect(fileContent).toMatch(/completeTask.*moveTask\(taskId, 'done'\)/s);
});
it('should have optimistic updates in moveTask', () => {
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
const fileContent = readFileSync(tasksTabPath, 'utf-8');
// Check for optimistic update comment and implementation
expect(fileContent).toContain('// Optimistically update UI for immediate feedback');
expect(fileContent).toContain('setTasks(prev => prev.map(task =>');
});
it('should revert on error as indicated by comment', () => {
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
const fileContent = readFileSync(tasksTabPath, 'utf-8');
// Check for revert comment
expect(fileContent).toContain('// Revert optimistic update - polling will sync correct state');
});
});

View File

@@ -1,186 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import React from 'react'
import { PRPViewer } from '../../../src/components/prp/PRPViewer'
import type { PRPContent } from '../../../src/components/prp/types/prp.types'
describe('PRPViewer', () => {
const mockContent: PRPContent = {
title: 'Test PRP',
version: '1.0',
author: 'Test Author',
date: '2025-07-30',
status: 'draft',
goal: 'Test goal with [Image #1] placeholder',
why: 'Test reason with [Image #2] reference',
what: {
description: 'Test description with [Image #3] and [Image #4]',
success_criteria: ['Criterion 1', 'Criterion 2 with [Image #5]']
},
context: {
background: 'Background with [Image #6]',
objectives: ['Objective 1', 'Objective 2']
}
}
test('renders without [Image #N] placeholders', () => {
render(<PRPViewer content={mockContent} />)
// Check that [Image #N] placeholders are replaced
expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
// Check that content is present
expect(screen.getByText(/Test goal/)).toBeInTheDocument()
expect(screen.getByText(/Test reason/)).toBeInTheDocument()
expect(screen.getByText(/Test description/)).toBeInTheDocument()
})
test('processes nested content with image placeholders', () => {
const { container } = render(<PRPViewer content={mockContent} />)
// Check that the content has been processed
const htmlContent = container.innerHTML
// Should not contain raw [Image #N] text
expect(htmlContent).not.toMatch(/\[Image #\d+\]/)
// Should contain processed markdown image syntax
expect(htmlContent).toContain('Image 1')
expect(htmlContent).toContain('Image 2')
})
test('renders metadata section correctly', () => {
render(<PRPViewer content={mockContent} />)
expect(screen.getByText('Test PRP')).toBeInTheDocument()
expect(screen.getByText('1.0')).toBeInTheDocument()
expect(screen.getByText('Test Author')).toBeInTheDocument()
expect(screen.getByText('draft')).toBeInTheDocument()
})
test('handles empty content gracefully', () => {
render(<PRPViewer content={{} as PRPContent} />)
// Should render without errors
expect(screen.getByText(/Metadata/)).toBeInTheDocument()
})
test('handles null content', () => {
render(<PRPViewer content={null as any} />)
expect(screen.getByText('No PRP content available')).toBeInTheDocument()
})
test('handles string content in objects', () => {
const stringContent = {
title: 'String Test',
description: 'This has [Image #1] in it'
}
render(<PRPViewer content={stringContent as any} />)
// Should process the image placeholder
expect(screen.queryByText(/\[Image #1\]/)).not.toBeInTheDocument()
expect(screen.getByText(/This has/)).toBeInTheDocument()
})
test('handles array content with image placeholders', () => {
const arrayContent = {
title: 'Array Test',
items: [
'Item 1 with [Image #1]',
'Item 2 with [Image #2]',
{ nested: 'Nested with [Image #3]' }
]
}
render(<PRPViewer content={arrayContent as any} />)
// Should process all image placeholders
expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
})
test('renders collapsible sections', () => {
render(<PRPViewer content={mockContent} />)
// Find collapsible sections
const contextSection = screen.getByText('Context').closest('div')
expect(contextSection).toBeInTheDocument()
// Should have chevron icon for collapsible sections
const chevrons = screen.getAllByTestId('chevron-icon')
expect(chevrons.length).toBeGreaterThan(0)
})
test('toggles section visibility', () => {
render(<PRPViewer content={mockContent} />)
// Find a collapsible section header
const contextHeader = screen.getByText('Context').closest('button')
// The section should be visible initially (defaultOpen for first 5 sections)
expect(screen.getByText(/Background with/)).toBeInTheDocument()
// Click to collapse
fireEvent.click(contextHeader!)
// Content should be hidden
expect(screen.queryByText(/Background with/)).not.toBeInTheDocument()
// Click to expand
fireEvent.click(contextHeader!)
// Content should be visible again
expect(screen.getByText(/Background with/)).toBeInTheDocument()
})
test('applies dark mode styles', () => {
const { container } = render(<PRPViewer content={mockContent} isDarkMode={true} />)
const viewer = container.querySelector('.prp-viewer')
expect(viewer?.className).toContain('dark')
})
test('uses section overrides when provided', () => {
const CustomSection = ({ data, title }: any) => (
<div data-testid="custom-section">
<h3>{title}</h3>
<p>Custom rendering of: {JSON.stringify(data)}</p>
</div>
)
const overrides = {
context: CustomSection
}
render(<PRPViewer content={mockContent} sectionOverrides={overrides} />)
expect(screen.getByTestId('custom-section')).toBeInTheDocument()
expect(screen.getByText(/Custom rendering of/)).toBeInTheDocument()
})
test('sorts sections by group', () => {
const complexContent = {
title: 'Complex PRP',
// These should be sorted in a specific order
validation_gates: { test: 'validation' },
user_personas: { test: 'personas' },
context: { test: 'context' },
user_flows: { test: 'flows' },
success_metrics: { test: 'metrics' }
}
const { container } = render(<PRPViewer content={complexContent as any} />)
// Get all section titles in order
const sectionTitles = Array.from(
container.querySelectorAll('h3')
).map(el => el.textContent)
// Context should come before personas
const contextIndex = sectionTitles.findIndex(t => t?.includes('Context'))
const personasIndex = sectionTitles.findIndex(t => t?.includes('Personas'))
expect(contextIndex).toBeLessThan(personasIndex)
})
})

View File

@@ -1,206 +0,0 @@
/**
* Tests for API configuration port requirements
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('API Configuration', () => {
let originalEnv: any;
beforeEach(() => {
// Save original environment
originalEnv = { ...import.meta.env };
// Clear the module cache to ensure fresh imports
vi.resetModules();
});
afterEach(() => {
// Restore original environment
Object.keys(import.meta.env).forEach(key => {
delete (import.meta.env as any)[key];
});
Object.assign(import.meta.env, originalEnv);
});
describe('getApiUrl', () => {
it('should use VITE_API_URL when provided', async () => {
// Set VITE_API_URL
(import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
const { getApiUrl } = await import('../../src/config/api');
expect(getApiUrl()).toBe('http://custom-api:9999');
});
it('should return empty string in production mode', async () => {
// Set production mode
(import.meta.env as any).PROD = true;
// It should not use VITE_API_URL
(import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
const { getApiUrl } = await import('../../src/config/api');
expect(getApiUrl()).toBe('');
});
it('should use default port 8181 when no port environment variables are set in development', async () => {
// Development mode without any port variables
delete (import.meta.env as any).PROD;
delete (import.meta.env as any).VITE_API_URL;
delete (import.meta.env as any).VITE_ARCHON_SERVER_PORT;
delete (import.meta.env as any).VITE_PORT;
delete (import.meta.env as any).ARCHON_SERVER_PORT;
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
protocol: 'http:',
hostname: 'localhost'
},
writable: true
});
const { getApiUrl } = await import('../../src/config/api');
expect(getApiUrl()).toBe('http://localhost:8181');
});
it('should use VITE_ARCHON_SERVER_PORT when set in development', async () => {
// Development mode with custom port via VITE_ prefix
delete (import.meta.env as any).PROD;
delete (import.meta.env as any).VITE_API_URL;
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = '9191';
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
protocol: 'http:',
hostname: 'localhost'
},
writable: true
});
const { getApiUrl } = await import('../../src/config/api');
expect(getApiUrl()).toBe('http://localhost:9191');
});
it('should use custom port with https protocol', async () => {
// Development mode with custom port and https via VITE_ prefix
delete (import.meta.env as any).PROD;
delete (import.meta.env as any).VITE_API_URL;
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = '8443';
// Mock window.location with https
Object.defineProperty(window, 'location', {
value: {
protocol: 'https:',
hostname: 'example.com'
},
writable: true
});
const { getApiUrl } = await import('../../src/config/api');
expect(getApiUrl()).toBe('https://example.com:8443');
});
});
describe('Port validation', () => {
it('should handle various port formats', async () => {
const testCases = [
{ port: '80', expected: 'http://localhost:80' },
{ port: '443', expected: 'http://localhost:443' },
{ port: '3000', expected: 'http://localhost:3000' },
{ port: '8080', expected: 'http://localhost:8080' },
{ port: '65535', expected: 'http://localhost:65535' },
];
for (const { port, expected } of testCases) {
vi.resetModules();
delete (import.meta.env as any).PROD;
delete (import.meta.env as any).VITE_API_URL;
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = port;
Object.defineProperty(window, 'location', {
value: {
protocol: 'http:',
hostname: 'localhost'
},
writable: true
});
const { getApiUrl } = await import('../../src/config/api');
expect(getApiUrl()).toBe(expected);
}
});
});
});
describe('MCP Client Service Configuration', () => {
let originalEnv: any;
beforeEach(() => {
originalEnv = { ...import.meta.env };
vi.resetModules();
});
afterEach(() => {
Object.keys(import.meta.env).forEach(key => {
delete (import.meta.env as any)[key];
});
Object.assign(import.meta.env, originalEnv);
});
it('should throw error when ARCHON_MCP_PORT is not set', async () => {
delete (import.meta.env as any).ARCHON_MCP_PORT;
const { mcpClientService } = await import('../../src/services/mcpClientService');
await expect(mcpClientService.createArchonClient()).rejects.toThrow('ARCHON_MCP_PORT environment variable is required');
await expect(mcpClientService.createArchonClient()).rejects.toThrow('Default value: 8051');
});
it('should use ARCHON_MCP_PORT when set', async () => {
(import.meta.env as any).ARCHON_MCP_PORT = '9051';
(import.meta.env as any).ARCHON_SERVER_PORT = '8181';
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
protocol: 'http:',
hostname: 'localhost'
},
writable: true
});
// Mock the API call
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 'test-id',
name: 'Archon',
transport_type: 'http',
connection_status: 'connected'
})
});
const { mcpClientService } = await import('../../src/services/mcpClientService');
try {
await mcpClientService.createArchonClient();
// Verify the fetch was called with the correct URL
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/mcp/clients'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('9051')
})
);
} catch (error) {
// If it fails due to actual API call, that's okay for this test
// We're mainly testing that it constructs the URL correctly
expect(error).toBeDefined();
}
});
});

View File

@@ -1,236 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
import React from 'react'
import { credentialsService } from '../src/services/credentialsService'
describe('Error Handling Tests', () => {
test('api error simulation', () => {
const MockApiComponent = () => {
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(false)
const fetchData = async () => {
setLoading(true)
try {
// Simulate API error
throw new Error('Network error')
} catch (err) {
setError('Failed to load data')
} finally {
setLoading(false)
}
}
return (
<div>
<button onClick={fetchData}>Load Data</button>
{loading && <div>Loading...</div>}
{error && <div role="alert">{error}</div>}
</div>
)
}
render(<MockApiComponent />)
fireEvent.click(screen.getByText('Load Data'))
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load data')
})
test('timeout error simulation', () => {
const MockTimeoutComponent = () => {
const [status, setStatus] = React.useState('idle')
const handleTimeout = () => {
setStatus('loading')
setTimeout(() => {
setStatus('timeout')
}, 100)
}
return (
<div>
<button onClick={handleTimeout}>Start Request</button>
{status === 'loading' && <div>Loading...</div>}
{status === 'timeout' && <div role="alert">Request timed out</div>}
</div>
)
}
render(<MockTimeoutComponent />)
fireEvent.click(screen.getByText('Start Request'))
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for timeout
setTimeout(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Request timed out')
}, 150)
})
test('form validation errors', () => {
const MockFormErrors = () => {
const [values, setValues] = React.useState({ name: '', email: '' })
const [errors, setErrors] = React.useState<string[]>([])
const validate = () => {
const newErrors: string[] = []
if (!values.name) newErrors.push('Name is required')
if (!values.email) newErrors.push('Email is required')
if (values.email && !values.email.includes('@')) {
newErrors.push('Invalid email format')
}
setErrors(newErrors)
}
return (
<div>
<input
placeholder="Name"
value={values.name}
onChange={(e) => setValues({ ...values, name: e.target.value })}
/>
<input
placeholder="Email"
value={values.email}
onChange={(e) => setValues({ ...values, email: e.target.value })}
/>
<button onClick={validate}>Submit</button>
{errors.length > 0 && (
<div role="alert">
{errors.map((error, index) => (
<div key={index}>{error}</div>
))}
</div>
)}
</div>
)
}
render(<MockFormErrors />)
// Submit empty form
fireEvent.click(screen.getByText('Submit'))
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent('Name is required')
expect(alert).toHaveTextContent('Email is required')
})
test('connection error recovery', () => {
const MockConnection = () => {
const [connected, setConnected] = React.useState(true)
const [error, setError] = React.useState('')
const handleDisconnect = () => {
setConnected(false)
setError('Connection lost')
}
const handleReconnect = () => {
setConnected(true)
setError('')
}
return (
<div>
<div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
{error && <div role="alert">{error}</div>}
<button onClick={handleDisconnect}>Simulate Disconnect</button>
<button onClick={handleReconnect}>Reconnect</button>
</div>
)
}
render(<MockConnection />)
expect(screen.getByText('Status: Connected')).toBeInTheDocument()
fireEvent.click(screen.getByText('Simulate Disconnect'))
expect(screen.getByText('Status: Disconnected')).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('Connection lost')
fireEvent.click(screen.getByText('Reconnect'))
expect(screen.getByText('Status: Connected')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
test('user friendly error messages', () => {
const MockErrorMessages = () => {
const [errorType, setErrorType] = React.useState('')
const getErrorMessage = (type: string) => {
switch (type) {
case '401':
return 'Please log in to continue'
case '403':
return "You don't have permission to access this"
case '404':
return "We couldn't find what you're looking for"
case '500':
return 'Something went wrong on our end'
default:
return ''
}
}
return (
<div>
<button onClick={() => setErrorType('401')}>401 Error</button>
<button onClick={() => setErrorType('403')}>403 Error</button>
<button onClick={() => setErrorType('404')}>404 Error</button>
<button onClick={() => setErrorType('500')}>500 Error</button>
{errorType && (
<div role="alert">{getErrorMessage(errorType)}</div>
)}
</div>
)
}
render(<MockErrorMessages />)
fireEvent.click(screen.getByText('401 Error'))
expect(screen.getByRole('alert')).toHaveTextContent('Please log in to continue')
fireEvent.click(screen.getByText('404 Error'))
expect(screen.getByRole('alert')).toHaveTextContent("We couldn't find what you're looking for")
fireEvent.click(screen.getByText('500 Error'))
expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong on our end')
})
})
describe('CredentialsService Error Handling', () => {
const originalFetch = global.fetch
beforeEach(() => {
global.fetch = vi.fn() as any
})
afterEach(() => {
global.fetch = originalFetch
})
test('should handle network errors with context', async () => {
const mockError = new Error('Network request failed')
;(global.fetch as any).mockRejectedValueOnce(mockError)
await expect(credentialsService.createCredential({
key: 'TEST_KEY',
value: 'test',
is_encrypted: false,
category: 'test'
})).rejects.toThrow(/Network error while creating credential 'test_key'/)
})
test('should preserve context in error messages', async () => {
const mockError = new Error('database error')
;(global.fetch as any).mockRejectedValueOnce(mockError)
await expect(credentialsService.updateCredential({
key: 'OPENAI_API_KEY',
value: 'sk-test',
is_encrypted: true,
category: 'api_keys'
})).rejects.toThrow(/Updating credential 'OPENAI_API_KEY' failed/)
})
})

View File

@@ -1,246 +0,0 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { usePolling } from '../../src/hooks/usePolling';
describe('usePolling Hook - REAL Tests', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
// Mock fetch globally
global.fetch = vi.fn();
// Reset document visibility state
Object.defineProperty(document, 'hidden', {
value: false,
writable: true,
configurable: true
});
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true
});
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('should poll the endpoint at specified intervals', async () => {
const mockResponse = { data: 'test' };
(global.fetch as any).mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
headers: new Headers({ 'etag': '"v1"' })
});
const { result } = renderHook(() =>
usePolling('/api/test', { interval: 1000 })
);
// Initially loading
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
// Wait for first fetch to complete
await waitFor(() => {
expect(result.current.data).toEqual(mockResponse);
expect(result.current.isLoading).toBe(false);
}, { timeout: 5000 });
expect(global.fetch).toHaveBeenCalledTimes(1);
// Advance timer to trigger second poll
await act(async () => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(2);
}, { timeout: 5000 });
// Check ETag header was sent on second request
const secondCall = (global.fetch as any).mock.calls[1];
expect(secondCall[1].headers['If-None-Match']).toBe('"v1"');
}, 15000);
it('should handle 304 Not Modified responses correctly', async () => {
const initialData = { value: 'initial' };
// First call returns data
(global.fetch as any).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => initialData,
headers: new Headers({ 'etag': '"v1"' })
});
const { result } = renderHook(() =>
usePolling('/api/test', { interval: 1000 })
);
await waitFor(() => {
expect(result.current.data).toEqual(initialData);
expect(result.current.isLoading).toBe(false);
}, { timeout: 5000 });
// Second call returns 304 Not Modified
(global.fetch as any).mockResolvedValueOnce({
ok: true,
status: 304,
json: async () => null,
headers: new Headers({ 'etag': '"v1"' })
});
await act(async () => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(2);
}, { timeout: 5000 });
// Data should remain unchanged after 304
expect(result.current.data).toEqual(initialData);
}, 15000);
it('should pause polling when tab becomes inactive', async () => {
// This test verifies that polling stops when the tab is hidden
// The hook behavior is complex due to multiple useEffect hooks
// so we'll just verify the key behavior: no excessive polling when hidden
(global.fetch as any).mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: 'test' }),
headers: new Headers()
});
const { result } = renderHook(() => usePolling('/api/test', { interval: 1000 }));
// Wait for initial fetch
await waitFor(() => {
expect(result.current.data).toEqual({ data: 'test' });
expect(result.current.isLoading).toBe(false);
}, { timeout: 5000 });
// Clear the mock to start fresh
vi.clearAllMocks();
// Simulate tab becoming hidden
await act(async () => {
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
configurable: true
});
Object.defineProperty(document, 'hidden', {
value: true,
writable: true,
configurable: true
});
document.dispatchEvent(new Event('visibilitychange'));
});
// Advance timers significantly while hidden
await act(async () => {
vi.advanceTimersByTime(5000);
});
// Should have minimal or no calls while hidden (allowing for edge cases)
const hiddenCallCount = (global.fetch as any).mock.calls.length;
expect(hiddenCallCount).toBeLessThanOrEqual(1);
// Simulate tab becoming visible again
await act(async () => {
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true
});
Object.defineProperty(document, 'hidden', {
value: false,
writable: true,
configurable: true
});
document.dispatchEvent(new Event('visibilitychange'));
});
// Should trigger immediate refetch when becoming visible
await waitFor(() => {
expect((global.fetch as any).mock.calls.length).toBeGreaterThan(hiddenCallCount);
}, { timeout: 5000 });
}, 15000);
it('should handle errors and retry with backoff', async () => {
// First call fails
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() =>
usePolling('/api/test', { interval: 1000 })
);
await waitFor(() => {
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe('Network error');
expect(result.current.isLoading).toBe(false);
}, { timeout: 5000 });
expect(global.fetch).toHaveBeenCalledTimes(1);
// Second call succeeds
(global.fetch as any).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: 'recovered' }),
headers: new Headers()
});
// Advance timer for retry
await act(async () => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(result.current.data).toEqual({ data: 'recovered' });
expect(result.current.error).toBeNull();
}, { timeout: 5000 });
}, 15000);
it('should cleanup on unmount', async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: 'test' }),
headers: new Headers()
});
const { unmount, result } = renderHook(() =>
usePolling('/api/test', { interval: 1000 })
);
// Wait for initial fetch to complete
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
}, { timeout: 5000 });
// Clear any pending timers before unmount
vi.clearAllTimers();
unmount();
// Reset mocks to clear call count
const callCountBeforeAdvance = (global.fetch as any).mock.calls.length;
// Advance timers after unmount
await act(async () => {
vi.advanceTimersByTime(5000);
});
// No additional calls should be made after unmount
expect((global.fetch as any).mock.calls.length).toBe(callCountBeforeAdvance);
}, 15000);
});

View File

@@ -1,116 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import React from 'react'
import { isLmConfigured } from '../src/utils/onboarding'
import type { NormalizedCredential } from '../src/utils/onboarding'
// Mock useNavigate for onboarding page test
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn()
}))
describe('Page Load Tests', () => {
test('simple page component renders', () => {
const MockPage = () => <h1>Projects</h1>
render(<MockPage />)
expect(screen.getByText('Projects')).toBeInTheDocument()
})
test('knowledge base mock renders', () => {
const MockKnowledgePage = () => <h1>Knowledge Base</h1>
render(<MockKnowledgePage />)
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
})
test('settings mock renders', () => {
const MockSettingsPage = () => <h1>Settings</h1>
render(<MockSettingsPage />)
expect(screen.getByText('Settings')).toBeInTheDocument()
})
test('mcp mock renders', () => {
const MockMCPPage = () => <h1>MCP Servers</h1>
render(<MockMCPPage />)
expect(screen.getByText('MCP Servers')).toBeInTheDocument()
})
test('tasks mock renders', () => {
const MockTasksPage = () => (
<div>
<h1>Tasks</h1>
<div>TODO</div>
<div>In Progress</div>
<div>Done</div>
</div>
)
render(<MockTasksPage />)
expect(screen.getByText('Tasks')).toBeInTheDocument()
expect(screen.getByText('TODO')).toBeInTheDocument()
expect(screen.getByText('In Progress')).toBeInTheDocument()
expect(screen.getByText('Done')).toBeInTheDocument()
})
test('onboarding page renders', () => {
const MockOnboardingPage = () => <h1>Welcome to Archon</h1>
render(<MockOnboardingPage />)
expect(screen.getByText('Welcome to Archon')).toBeInTheDocument()
})
})
describe('Onboarding Detection Tests', () => {
test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY exists', () => {
const ragCreds: NormalizedCredential[] = [
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
]
const apiKeyCreds: NormalizedCredential[] = [
{ key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
]
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
})
test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY is encrypted', () => {
const ragCreds: NormalizedCredential[] = [
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
]
const apiKeyCreds: NormalizedCredential[] = [
{ key: 'OPENAI_API_KEY', is_encrypted: true, encrypted_value: 'encrypted_sk-test123', category: 'api_keys' }
]
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
})
test('isLmConfigured returns false when provider is openai and no OPENAI_API_KEY', () => {
const ragCreds: NormalizedCredential[] = [
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
]
const apiKeyCreds: NormalizedCredential[] = []
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
})
test('isLmConfigured returns true when provider is ollama regardless of API keys', () => {
const ragCreds: NormalizedCredential[] = [
{ key: 'LLM_PROVIDER', value: 'ollama', category: 'rag_strategy' }
]
const apiKeyCreds: NormalizedCredential[] = []
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
})
test('isLmConfigured returns true when no provider but OPENAI_API_KEY exists', () => {
const ragCreds: NormalizedCredential[] = []
const apiKeyCreds: NormalizedCredential[] = [
{ key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
]
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
})
test('isLmConfigured returns false when no provider and no OPENAI_API_KEY', () => {
const ragCreds: NormalizedCredential[] = []
const apiKeyCreds: NormalizedCredential[] = []
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
})
})

View File

@@ -1,124 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
describe('ProjectPage Performance Optimizations', () => {
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
const projectServicePath = join(process.cwd(), 'src/services/projectService.ts');
it('should use batch API call for task counts instead of N+1 queries', () => {
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Verify batch endpoint is being used
expect(fileContent).toContain('getTaskCountsForAllProjects');
// Verify we're NOT using Promise.allSettled for parallel fetching
expect(fileContent).not.toContain('Promise.allSettled');
// Verify single batch API call pattern
expect(fileContent).toContain('await projectService.getTaskCountsForAllProjects()');
});
it('should have memoized handleProjectSelect to prevent duplicate calls', () => {
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check that handleProjectSelect is wrapped with useCallback
expect(fileContent).toMatch(/const handleProjectSelect = useCallback\(/);
// Check for early return if same project
expect(fileContent).toContain('if (selectedProject?.id === project.id) return');
// Check dependency array includes selectedProject?.id
expect(fileContent).toMatch(/\}, \[.*selectedProject\?\.id.*\]\)/);
});
it('should implement task counts cache with TTL', () => {
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check cache ref is defined
expect(fileContent).toContain('const taskCountsCache = useRef');
// Check cache structure includes timestamp
expect(fileContent).toContain('timestamp: number');
// Check cache is checked before API call (5-minute TTL = 300000ms)
expect(fileContent).toContain('(now - taskCountsCache.current.timestamp) < 300000');
// Check cache is updated after successful API call
expect(fileContent).toContain('taskCountsCache.current = {');
});
it('should disable polling during project switching and drag operations', () => {
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check useTaskPolling enabled parameter includes conditions
expect(fileContent).toMatch(/enabled:.*!isSwitchingProject.*movingTaskIds\.size === 0/);
// Verify isSwitchingProject state exists
expect(fileContent).toContain('const [isSwitchingProject, setIsSwitchingProject]');
});
it('should have debounce utility implemented', () => {
const debouncePath = join(process.cwd(), 'src/utils/debounce.ts');
const fileContent = readFileSync(debouncePath, 'utf-8');
// Check debounce function exists
expect(fileContent).toContain('export function debounce');
// Check it has proper TypeScript types
expect(fileContent).toContain('T extends (...args: any[]) => any');
// Check timeout clearing logic
expect(fileContent).toContain('clearTimeout(timeoutId)');
});
it('should apply debouncing to loadTaskCountsForAllProjects', () => {
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check debounce is imported
expect(fileContent).toContain('import { debounce } from "../utils/debounce"');
// Check debounced version is created
expect(fileContent).toContain('const debouncedLoadTaskCounts = useMemo');
expect(fileContent).toContain('debounce((projectIds: string[])');
// Check debounced version is used instead of direct calls
expect(fileContent).toContain('debouncedLoadTaskCounts(projectIds)');
// Verify 1000ms delay
expect(fileContent).toContain('}, 1000)');
});
it('should have batch task counts endpoint in backend service', () => {
const serviceContent = readFileSync(projectServicePath, 'utf-8');
// Check the service method exists
expect(serviceContent).toContain('async getTaskCountsForAllProjects()');
// Check it calls the correct endpoint
expect(serviceContent).toContain('/api/projects/task-counts');
// Check return type
expect(serviceContent).toContain('Promise<Record<string, TaskCounts>>');
});
it('should not make duplicate API calls on project switch', () => {
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check that tasks are cleared immediately on switch
expect(fileContent).toContain('setTasks([]); // Clear stale tasks immediately');
// Check loading state is managed properly
expect(fileContent).toContain('setIsSwitchingProject(true)');
expect(fileContent).toContain('setIsSwitchingProject(false)');
});
it('should have correct import statements for performance utilities', () => {
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check all necessary React hooks are imported
expect(fileContent).toContain('useCallback');
expect(fileContent).toContain('useMemo');
expect(fileContent).toContain('useRef');
});
});

View File

@@ -1,42 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
describe('ProjectPage Polling Conflict Prevention', () => {
it('should have movingTaskIds check in polling useEffect', () => {
// Read the actual source file to verify the implementation
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check that movingTaskIds state is declared
expect(fileContent).toContain('const [movingTaskIds, setMovingTaskIds] = useState<Set<string>>(new Set())');
// Check that movingTaskIds is checked before updating tasks
expect(fileContent).toContain('if (movingTaskIds.size === 0)');
// Check that merge logic is present for non-moving tasks
expect(fileContent).toContain('if (movingTaskIds.has(task.id))');
expect(fileContent).toContain('return task; // Preserve local state for moving tasks');
// Check that movingTaskIds is in the dependency array
expect(fileContent).toMatch(/\}, \[.*movingTaskIds.*\]\)/);
});
it('should pass movingTaskIds props to TasksTab', () => {
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
const fileContent = readFileSync(projectPagePath, 'utf-8');
// Check that movingTaskIds is passed as prop
expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
expect(fileContent).toContain('setMovingTaskIds={setMovingTaskIds}');
});
it('should have TasksTab accept movingTaskIds props', () => {
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
const fileContent = readFileSync(tasksTabPath, 'utf-8');
// Check that TasksTab accepts the props
expect(fileContent).toContain('movingTaskIds: Set<string>');
expect(fileContent).toContain('setMovingTaskIds: (ids: Set<string>) => void');
});
});

View File

@@ -1,393 +0,0 @@
/**
* Unit tests for projectService document CRUD operations
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Document } from '../../src/services/projectService';
// Mock fetch globally
global.fetch = vi.fn();
describe('projectService Document Operations', () => {
let projectService: any;
beforeEach(async () => {
// Reset all mocks
vi.resetAllMocks();
vi.resetModules();
// Import fresh instance of projectService
const module = await import('../../src/services/projectService');
projectService = module.projectService;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('getDocument', () => {
const mockDocument: Document = {
id: 'doc-123',
project_id: 'proj-456',
title: 'Test Document',
content: { type: 'markdown', text: 'Test content' },
document_type: 'prp',
metadata: { version: '1.0' },
tags: ['test', 'sample'],
author: 'test-user',
created_at: '2025-08-18T10:00:00Z',
updated_at: '2025-08-18T10:00:00Z'
};
it('should successfully fetch a document', async () => {
// Mock successful response
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ document: mockDocument })
});
const result = await projectService.getDocument('proj-456', 'doc-123');
expect(result).toEqual(mockDocument);
expect(global.fetch).toHaveBeenCalledWith(
'/api/projects/proj-456/docs/doc-123',
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json'
})
})
);
});
it('should include projectId in error message when fetch fails', async () => {
// Mock failed response
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '{"error": "Document not found"}'
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to get document doc-123 from project proj-456:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should handle network errors', async () => {
// Mock network error
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow('Network error');
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to get document doc-123 from project proj-456:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
describe('updateDocument', () => {
const mockUpdatedDocument: Document = {
id: 'doc-123',
project_id: 'proj-456',
title: 'Updated Document',
content: { type: 'markdown', text: 'Updated content' },
document_type: 'prp',
metadata: { version: '2.0' },
tags: ['updated', 'test'],
author: 'test-user',
created_at: '2025-08-18T10:00:00Z',
updated_at: '2025-08-18T11:00:00Z'
};
const updates = {
title: 'Updated Document',
content: { type: 'markdown', text: 'Updated content' },
tags: ['updated', 'test']
};
it('should successfully update a document', async () => {
// Mock successful response
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ document: mockUpdatedDocument })
});
const result = await projectService.updateDocument('proj-456', 'doc-123', updates);
expect(result).toEqual(mockUpdatedDocument);
expect(global.fetch).toHaveBeenCalledWith(
'/api/projects/proj-456/docs/doc-123',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
'Content-Type': 'application/json'
}),
body: JSON.stringify(updates)
})
);
});
it('should include projectId in error message when update fails', async () => {
// Mock failed response
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 400,
text: async () => '{"error": "Invalid update data"}'
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(projectService.updateDocument('proj-456', 'doc-123', updates)).rejects.toThrow();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to update document doc-123 in project proj-456:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should handle partial updates', async () => {
const partialUpdate = { title: 'Only Title Updated' };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ document: { ...mockUpdatedDocument, title: 'Only Title Updated' } })
});
const result = await projectService.updateDocument('proj-456', 'doc-123', partialUpdate);
expect(result.title).toBe('Only Title Updated');
expect(global.fetch).toHaveBeenCalledWith(
'/api/projects/proj-456/docs/doc-123',
expect.objectContaining({
body: JSON.stringify(partialUpdate)
})
);
});
});
describe('deleteDocument', () => {
it('should successfully delete a document', async () => {
// Mock successful response
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({})
});
await expect(projectService.deleteDocument('proj-456', 'doc-123')).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledWith(
'/api/projects/proj-456/docs/doc-123',
expect.objectContaining({
method: 'DELETE',
headers: expect.objectContaining({
'Content-Type': 'application/json'
})
})
);
});
it('should include projectId in error message when deletion fails', async () => {
// Mock failed response
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => '{"error": "Permission denied"}'
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to delete document doc-123 from project proj-456:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should handle 404 errors appropriately', async () => {
// Mock 404 response
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '{"error": "Document not found"}'
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
// Verify the error is logged with project context
expect(consoleSpy).toHaveBeenCalled();
const errorLog = consoleSpy.mock.calls[0];
expect(errorLog[0]).toContain('proj-456');
expect(errorLog[0]).toContain('doc-123');
consoleSpy.mockRestore();
});
it('should handle network timeouts', async () => {
// Mock timeout error
const timeoutError = new Error('Request timeout');
(global.fetch as any).mockRejectedValueOnce(timeoutError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow('Failed to call API');
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to delete document doc-123 from project proj-456:',
expect.objectContaining({
message: expect.stringContaining('Request timeout')
})
);
consoleSpy.mockRestore();
});
});
describe('listProjectDocuments', () => {
const mockDocuments: Document[] = [
{
id: 'doc-1',
project_id: 'proj-456',
title: 'Document 1',
content: { type: 'markdown', text: 'Content 1' },
document_type: 'prp',
created_at: '2025-08-18T10:00:00Z',
updated_at: '2025-08-18T10:00:00Z'
},
{
id: 'doc-2',
project_id: 'proj-456',
title: 'Document 2',
content: { type: 'markdown', text: 'Content 2' },
document_type: 'spec',
created_at: '2025-08-18T11:00:00Z',
updated_at: '2025-08-18T11:00:00Z'
}
];
it('should successfully list all project documents', async () => {
// Mock successful response
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ documents: mockDocuments })
});
const result = await projectService.listProjectDocuments('proj-456');
expect(result).toEqual(mockDocuments);
expect(result).toHaveLength(2);
expect(global.fetch).toHaveBeenCalledWith(
'/api/projects/proj-456/docs',
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json'
})
})
);
});
it('should return empty array when no documents exist', async () => {
// Mock response with no documents
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ documents: [] })
});
const result = await projectService.listProjectDocuments('proj-456');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should handle null documents field gracefully', async () => {
// Mock response with null documents
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ documents: null })
});
const result = await projectService.listProjectDocuments('proj-456');
expect(result).toEqual([]);
});
});
describe('createDocument', () => {
const newDocumentData = {
title: 'New Document',
content: { type: 'markdown', text: 'New content' },
document_type: 'prp',
tags: ['new', 'test']
};
const mockCreatedDocument: Document = {
id: 'doc-new',
project_id: 'proj-456',
...newDocumentData,
author: 'test-user',
created_at: '2025-08-18T12:00:00Z',
updated_at: '2025-08-18T12:00:00Z'
};
it('should successfully create a new document', async () => {
// Mock successful response
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ document: mockCreatedDocument })
});
const result = await projectService.createDocument('proj-456', newDocumentData);
expect(result).toEqual(mockCreatedDocument);
expect(result.id).toBeDefined();
expect(global.fetch).toHaveBeenCalledWith(
'/api/projects/proj-456/docs',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json'
}),
body: JSON.stringify(newDocumentData)
})
);
});
it('should handle validation errors', async () => {
// Mock validation error response
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 422,
text: async () => '{"error": "Title is required"}'
});
const invalidData = { content: 'Missing title' };
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(projectService.createDocument('proj-456', invalidData)).rejects.toThrow();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to create document for project proj-456:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
});

View File

@@ -1,243 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import React from 'react'
describe('User Flow Tests', () => {
test('create project flow mock', () => {
const MockCreateProject = () => {
const [project, setProject] = React.useState('')
return (
<div>
<h1>Create Project</h1>
<input
placeholder="Project title"
value={project}
onChange={(e) => setProject(e.target.value)}
/>
<button>Create</button>
</div>
)
}
render(<MockCreateProject />)
expect(screen.getByText('Create Project')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Project title')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
})
test('search functionality mock', () => {
const MockSearch = () => {
const [query, setQuery] = React.useState('')
return (
<div>
<h1>Search</h1>
<input
placeholder="Search knowledge base"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query && <div>Results for: {query}</div>}
</div>
)
}
render(<MockSearch />)
const input = screen.getByPlaceholderText('Search knowledge base')
fireEvent.change(input, { target: { value: 'test query' } })
expect(screen.getByText('Results for: test query')).toBeInTheDocument()
})
test('settings toggle mock', () => {
const MockSettings = () => {
const [theme, setTheme] = React.useState('light')
return (
<div>
<h1>Settings</h1>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Theme: {theme}
</button>
</div>
)
}
render(<MockSettings />)
const button = screen.getByText('Theme: light')
fireEvent.click(button)
expect(screen.getByText('Theme: dark')).toBeInTheDocument()
})
test('file upload mock', () => {
const MockUpload = () => {
const [uploaded, setUploaded] = React.useState(false)
return (
<div>
<h1>Upload Documents</h1>
<input type="file" onChange={() => setUploaded(true)} data-testid="file-input" />
{uploaded && <div>File uploaded successfully</div>}
</div>
)
}
render(<MockUpload />)
const input = screen.getByTestId('file-input')
fireEvent.change(input)
expect(screen.getByText('File uploaded successfully')).toBeInTheDocument()
})
test('connection status mock', () => {
const MockConnection = () => {
const [connected, setConnected] = React.useState(true)
return (
<div>
<h1>Connection Status</h1>
<div>{connected ? 'Connected' : 'Disconnected'}</div>
<button onClick={() => setConnected(!connected)}>
Toggle Connection
</button>
</div>
)
}
render(<MockConnection />)
expect(screen.getByText('Connected')).toBeInTheDocument()
fireEvent.click(screen.getByText('Toggle Connection'))
expect(screen.getByText('Disconnected')).toBeInTheDocument()
})
test('task management mock', () => {
const MockTasks = () => {
const [tasks, setTasks] = React.useState(['Task 1', 'Task 2'])
const addTask = () => setTasks([...tasks, `Task ${tasks.length + 1}`])
return (
<div>
<h1>Task Management</h1>
<button onClick={addTask}>Add Task</button>
<ul>
{tasks.map((task, index) => (
<li key={index}>{task}</li>
))}
</ul>
</div>
)
}
render(<MockTasks />)
expect(screen.getByText('Task 1')).toBeInTheDocument()
fireEvent.click(screen.getByText('Add Task'))
expect(screen.getByText('Task 3')).toBeInTheDocument()
})
test('navigation mock', () => {
const MockNav = () => {
const [currentPage, setCurrentPage] = React.useState('home')
return (
<div>
<nav>
<button onClick={() => setCurrentPage('projects')}>Projects</button>
<button onClick={() => setCurrentPage('settings')}>Settings</button>
</nav>
<main>
<h1>Current page: {currentPage}</h1>
</main>
</div>
)
}
render(<MockNav />)
expect(screen.getByText('Current page: home')).toBeInTheDocument()
fireEvent.click(screen.getByText('Projects'))
expect(screen.getByText('Current page: projects')).toBeInTheDocument()
})
test('form validation mock', () => {
const MockForm = () => {
const [email, setEmail] = React.useState('')
const [error, setError] = React.useState('')
const handleSubmit = () => {
if (!email.includes('@')) {
setError('Invalid email')
} else {
setError('')
}
}
return (
<div>
<h1>Form Validation</h1>
<input
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button onClick={handleSubmit}>Submit</button>
{error && <div role="alert">{error}</div>}
</div>
)
}
render(<MockForm />)
const input = screen.getByPlaceholderText('Email')
fireEvent.change(input, { target: { value: 'invalid' } })
fireEvent.click(screen.getByText('Submit'))
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email')
})
test('theme switching mock', () => {
const MockTheme = () => {
const [isDark, setIsDark] = React.useState(false)
return (
<div className={isDark ? 'dark' : 'light'}>
<h1>Theme Test</h1>
<button onClick={() => setIsDark(!isDark)}>
Switch to {isDark ? 'Light' : 'Dark'}
</button>
</div>
)
}
render(<MockTheme />)
const button = screen.getByText('Switch to Dark')
fireEvent.click(button)
expect(screen.getByText('Switch to Light')).toBeInTheDocument()
})
test('data filtering mock', () => {
const MockFilter = () => {
const [filter, setFilter] = React.useState('')
const items = ['Apple', 'Banana', 'Cherry']
const filtered = items.filter(item =>
item.toLowerCase().includes(filter.toLowerCase())
)
return (
<div>
<h1>Filter Test</h1>
<input
placeholder="Filter items"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul>
{filtered.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)
}
render(<MockFilter />)
const input = screen.getByPlaceholderText('Filter items')
fireEvent.change(input, { target: { value: 'a' } })
expect(screen.getByText('Apple')).toBeInTheDocument()
expect(screen.getByText('Banana')).toBeInTheDocument()
expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
})
})

View File

@@ -1,138 +0,0 @@
import { describe, it, expect } from 'vitest';
import { calculateTaskOrder, calculateReorderPosition, getDefaultTaskOrder } from '../../src/utils/taskOrdering';
import { Task } from '../../src/types/project';
// Mock task factory
const createMockTask = (id: string, task_order: number): Task => ({
id,
title: `Task ${id}`,
description: '',
status: 'todo',
assignee: { name: 'Test User', avatar: '' },
feature: '',
featureColor: '#3b82f6',
task_order,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
project_id: 'test-project'
});
describe('taskOrdering utilities', () => {
describe('calculateTaskOrder', () => {
it('does not mutate existingTasks', () => {
const existingTasks = [createMockTask('1', 200), createMockTask('2', 100)];
const snapshot = existingTasks.map(t => t.task_order);
calculateTaskOrder({ position: 'first', existingTasks });
expect(existingTasks.map(t => t.task_order)).toEqual(snapshot);
});
it('should return seed value for first task when no existing tasks', () => {
const result = calculateTaskOrder({
position: 'first',
existingTasks: []
});
expect(result).toBe(65536);
});
it('should calculate first position correctly', () => {
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
const result = calculateTaskOrder({
position: 'first',
existingTasks
});
expect(result).toBe(50); // Math.floor(100 / 2)
});
it('should calculate last position correctly', () => {
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
const result = calculateTaskOrder({
position: 'last',
existingTasks
});
expect(result).toBe(1224); // 200 + 1024
});
it('should calculate between position correctly', () => {
const result = calculateTaskOrder({
position: 'between',
existingTasks: [],
beforeTaskOrder: 100,
afterTaskOrder: 200
});
expect(result).toBe(150); // Math.floor((100 + 200) / 2)
});
});
describe('getDefaultTaskOrder', () => {
it('should return seed value when no existing tasks', () => {
const result = getDefaultTaskOrder([]);
expect(result).toBe(65536);
});
it('should return first position when existing tasks present', () => {
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
const result = getDefaultTaskOrder(existingTasks);
expect(result).toBe(50); // Math.floor(100 / 2)
});
});
describe('calculateReorderPosition', () => {
const statusTasks = [
createMockTask('1', 100),
createMockTask('2', 200),
createMockTask('3', 300)
];
it('should calculate position for moving to first', () => {
const result = calculateReorderPosition(statusTasks, 1, 0);
expect(result).toBeLessThan(statusTasks[0].task_order);
});
it('should calculate position for moving to last', () => {
const result = calculateReorderPosition(statusTasks, 0, 2);
expect(result).toBeGreaterThan(statusTasks[2].task_order);
});
it('should calculate position for moving down within middle (1 -> 2)', () => {
const result = calculateReorderPosition(statusTasks, 1, 2);
// After excluding moving index 1, insert between 300 and end => should be >300 (or handled by "last" path)
expect(result).toBeGreaterThan(statusTasks[2].task_order);
});
it('should calculate position for moving up within middle (2 -> 1)', () => {
const result = calculateReorderPosition(statusTasks, 2, 1);
// With fixed neighbor calculation, this should work correctly
expect(result).toBeGreaterThan(statusTasks[0].task_order); // > 100
expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
});
it('should calculate position for moving between items', () => {
const result = calculateReorderPosition(statusTasks, 0, 1);
// Moving task 0 (order 100) to position 1 should place it before task 1 (order 200)
// Since we removed the moving task, it should be between start and 200
expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
expect(result).toBeGreaterThan(0); // > 0
});
it('should return integer values only', () => {
const result1 = calculateReorderPosition(statusTasks, 1, 0);
const result2 = calculateReorderPosition(statusTasks, 0, 2);
const result3 = calculateReorderPosition(statusTasks, 2, 1);
expect(Number.isInteger(result1)).toBe(true);
expect(Number.isInteger(result2)).toBe(true);
expect(Number.isInteger(result3)).toBe(true);
});
it('should handle bounds checking correctly', () => {
// Test with tasks that have equal order values (edge case)
const equalTasks = [
createMockTask('1', 100),
createMockTask('2', 100)
];
const result = calculateReorderPosition(equalTasks, 0, 1);
expect(Number.isInteger(result)).toBe(true);
expect(result).toBeGreaterThan(100);
});
});
});

View File

@@ -0,0 +1,58 @@
# Test Structure
## Test Organization
We follow a hybrid testing strategy:
### Unit Tests (Colocated)
Unit tests live next to the code they test in the `src/features` directory:
```
src/features/projects/
├── components/
│ ├── ProjectCard.tsx
│ └── ProjectCard.test.tsx
```
### Integration Tests
Tests that cross multiple features/systems:
```
tests/integration/
└── api.integration.test.ts
```
### E2E Tests
Full user flow tests:
```
tests/e2e/
└── user-flows.e2e.test.ts
```
## Running Tests
```bash
# Run all tests
npm run test
# Run tests in watch mode
npm run test:watch
# Run with coverage
npm run test:coverage
# Run specific test file
npx vitest run src/features/ui/hooks/useSmartPolling.test.ts
```
## Test Naming Conventions
- **Unit tests**: `ComponentName.test.tsx` or `hookName.test.ts`
- **Integration tests**: `feature.integration.test.ts`
- **E2E tests**: `flow-name.e2e.test.ts`
## Test Setup
Global test setup is in `tests/setup.ts` which:
- Sets environment variables
- Mocks fetch and localStorage
- Mocks DOM APIs
- Mocks external libraries (lucide-react)

View File

@@ -35,17 +35,28 @@ Object.defineProperty(window, 'localStorage', {
Element.prototype.scrollIntoView = vi.fn()
window.HTMLElement.prototype.scrollIntoView = vi.fn()
// Mock lucide-react icons - create a proxy that returns icon name for any icon
vi.mock('lucide-react', () => {
return new Proxy({}, {
get: (target, prop) => {
if (typeof prop === 'string') {
return () => prop
}
return undefined
}
})
})
// Mock lucide-react icons - simple implementation
vi.mock('lucide-react', () => ({
Trash2: () => 'Trash2',
X: () => 'X',
AlertCircle: () => 'AlertCircle',
Loader2: () => 'Loader2',
BookOpen: () => 'BookOpen',
Settings: () => 'Settings',
WifiOff: () => 'WifiOff',
ChevronDown: () => 'ChevronDown',
ChevronRight: () => 'ChevronRight',
Plus: () => 'Plus',
Search: () => 'Search',
Activity: () => 'Activity',
CheckCircle2: () => 'CheckCircle2',
ListTodo: () => 'ListTodo',
MoreHorizontal: () => 'MoreHorizontal',
Pin: () => 'Pin',
PinOff: () => 'PinOff',
Clipboard: () => 'Clipboard',
// Add more icons as needed
}))
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({

View File

@@ -23,6 +23,6 @@
/* Path mapping */
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "test"],
"include": ["src", "tests"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"exclude": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/__tests__/**",
"**/tests/**",
"src/features/testing/**",
"test/**",
"tests/**",
"coverage/**",
"**/*.d.ts"
]
}

View File

@@ -321,15 +321,18 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
test: {
globals: true,
environment: 'jsdom',
setupFiles: './test/setup.ts',
setupFiles: './tests/setup.ts',
css: true,
include: [
'src/**/*.{test,spec}.{ts,tsx}', // Tests colocated in features
'tests/**/*.{test,spec}.{ts,tsx}' // Tests in tests directory
],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
'**/*.test.{ts,tsx}',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*'
],
env: {
VITE_HOST: host,

View File

@@ -8,10 +8,12 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './test/setup.ts',
setupFiles: './tests/setup.ts',
include: [
'test/**/*.test.{ts,tsx}',
'test/**/*.spec.{ts,tsx}'
'src/**/*.test.{ts,tsx}', // Colocated tests in features
'src/**/*.spec.{ts,tsx}',
'tests/**/*.test.{ts,tsx}', // Tests in tests directory
'tests/**/*.spec.{ts,tsx}'
],
exclude: ['node_modules', 'dist', '.git', '.cache', 'test.backup', '*.backup/**', 'test-backups'],
reporters: ['dot', 'json'],
@@ -35,7 +37,7 @@ export default defineConfig({
reportOnFailure: true, // Generate coverage reports even when tests fail
exclude: [
'node_modules/',
'test/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData.ts',