Skip to content

AI Prompt: Forma3D.Connect — Phase 5d: Frontend Test Coverage

Purpose: This prompt instructs an AI to add comprehensive unit test coverage to the React frontend
Estimated Effort: 8-12 days (~40-60 hours)
Prerequisites: Phase 5c completed (Webhook Idempotency)
Output: Vitest configured, 60%+ test coverage on frontend, all tests passing
Status: 🟡 PENDING


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 5c foundation. Your task is to implement Phase 5d: Frontend Test Coverage — specifically addressing TD-002 (Missing Frontend Test Coverage) from the technical debt register.

Why This Matters:

The React frontend application currently has zero unit tests. All testing relies on end-to-end acceptance tests, which creates several problems:

  1. Slow Feedback Loop: E2E tests take minutes; unit tests take seconds
  2. Fragile Test Suite: E2E tests are inherently flaky and maintenance-intensive
  3. Component Regression Risk: UI logic changes may introduce subtle bugs
  4. Refactoring Fear: Developers avoid refactoring without test safety net

Phase 5d delivers:

  • Vitest configured for React component testing
  • Tests for all custom hooks (6 hook files, ~15 hooks)
  • Tests for api-client.ts error handling
  • Tests for auth-context.tsx
  • Tests for key UI components
  • 60%+ test coverage target
  • All tests passing in CI

📋 Context: Technical Debt Item

TD-002: Missing Frontend Test Coverage

Attribute Value
Type Test Debt
Priority Critical
Location apps/web/src/**
Interest Rate High (bugs reach production)
Principal (Effort) 8-12 days

Current State

Category Files Test Files Coverage
Hooks 6 0 0%
Contexts 2 0 0%
API Client 1 0 0%
UI Components 10 0 0%
Pages 9 0 0%
Total 28 0 0%

Testing Priority

Priority Category Files Rationale
P1 Custom Hooks 6 Core business logic, reused everywhere
P1 API Client 1 Central data fetching, error handling
P1 Auth Context 1 Authentication state management
P2 Socket Context 1 Real-time connection management
P2 UI Components 10 Reusable, visual regression risk
P3 Pages 9 Integration of hooks/components

🛠️ Implementation Phases

Phase 1: Vitest Configuration (2 hours)

Priority: Critical | Impact: Critical | Dependencies: None

1. Install Testing Dependencies

pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react msw

2. Create Vitest Configuration

Create apps/web/vitest.config.ts:

/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/*.{test,spec}.{ts,tsx}',
        'src/test/**',
        'src/main.tsx',
        'src/router.tsx',
        'src/vite-env.d.ts',
      ],
      thresholds: {
        statements: 60,
        branches: 60,
        functions: 60,
        lines: 60,
      },
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
      '@forma3d/domain': resolve(__dirname, '../../libs/domain/src'),
    },
  },
});

3. Create Test Setup File

Create apps/web/src/test/setup.ts:

import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
});

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// Mock localStorage
const localStorageMock = (() => {
  let store: Record<string, string> = {};
  return {
    getItem: vi.fn((key: string) => store[key] || null),
    setItem: vi.fn((key: string, value: string) => {
      store[key] = value;
    }),
    removeItem: vi.fn((key: string) => {
      delete store[key];
    }),
    clear: vi.fn(() => {
      store = {};
    }),
  };
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });

// Mock import.meta.env
vi.stubGlobal('import', {
  meta: {
    env: {
      VITE_API_URL: 'http://localhost:3000',
    },
  },
});

4. Create Test Utilities

Create apps/web/src/test/test-utils.tsx:

import { ReactElement, ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from '../contexts/auth-context';

// Create a fresh QueryClient for each test
function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: 0,
        staleTime: 0,
      },
      mutations: {
        retry: false,
      },
    },
  });
}

interface WrapperProps {
  children: ReactNode;
}

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  queryClient?: QueryClient;
  withRouter?: boolean;
  withAuth?: boolean;
}

function createWrapper(options: CustomRenderOptions = {}) {
  const {
    queryClient = createTestQueryClient(),
    withRouter = true,
    withAuth = true,
  } = options;

  return function Wrapper({ children }: WrapperProps) {
    let wrapped = children;

    if (withAuth) {
      wrapped = <AuthProvider>{wrapped}</AuthProvider>;
    }

    wrapped = (
      <QueryClientProvider client={queryClient}>{wrapped}</QueryClientProvider>
    );

    if (withRouter) {
      wrapped = <BrowserRouter>{wrapped}</BrowserRouter>;
    }

    return <>{wrapped}</>;
  };
}

function customRender(ui: ReactElement, options: CustomRenderOptions = {}) {
  const { queryClient, withRouter, withAuth, ...renderOptions } = options;

  return render(ui, {
    wrapper: createWrapper({ queryClient, withRouter, withAuth }),
    ...renderOptions,
  });
}

// Re-export everything from testing-library
export * from '@testing-library/react';
export { userEvent } from '@testing-library/user-event';
export { customRender as render, createTestQueryClient };

5. Create MSW Handlers

Create apps/web/src/test/mocks/handlers.ts:

import { http, HttpResponse } from 'msw';

const API_BASE = 'http://localhost:3000';

// Mock data factories
export const mockOrder = (overrides = {}) => ({
  id: 'order-1',
  shopifyOrderId: '123456789',
  shopifyOrderNumber: '#1001',
  status: 'PENDING',
  customerName: 'John Doe',
  customerEmail: 'john@example.com',
  shippingAddress: {
    address1: '123 Main St',
    city: 'Amsterdam',
    country: 'Netherlands',
    zip: '1012',
  },
  totalPrice: '99.99',
  currency: 'EUR',
  totalParts: 3,
  completedParts: 1,
  createdAt: '2026-01-17T10:00:00Z',
  updatedAt: '2026-01-17T10:00:00Z',
  completedAt: null,
  lineItems: [],
  ...overrides,
});

export const mockPrintJob = (overrides = {}) => ({
  id: 'job-1',
  lineItemId: 'line-1',
  assemblyPartId: 'part-1',
  simplyPrintJobId: 'sp-123',
  status: 'PRINTING',
  copyNumber: 1,
  printerId: 'printer-1',
  printerName: 'Prusa MK4',
  fileId: 'file-1',
  fileName: 'model.gcode',
  queuedAt: '2026-01-17T10:00:00Z',
  startedAt: '2026-01-17T10:05:00Z',
  completedAt: null,
  estimatedDuration: 3600,
  actualDuration: null,
  errorMessage: null,
  retryCount: 0,
  maxRetries: 3,
  createdAt: '2026-01-17T10:00:00Z',
  updatedAt: '2026-01-17T10:05:00Z',
  orderId: 'order-1',
  shopifyOrderNumber: '#1001',
  productSku: 'TEST-SKU',
  productName: 'Test Product',
  ...overrides,
});

export const mockDashboardStats = (overrides = {}) => ({
  pendingOrders: 5,
  processingOrders: 3,
  completedToday: 10,
  failedOrders: 1,
  activePrintJobs: 8,
  completedPrintJobsToday: 15,
  ...overrides,
});

export const mockProductMapping = (overrides = {}) => ({
  id: 'mapping-1',
  shopifyProductId: 'sp-123',
  shopifyVariantId: null,
  sku: 'TEST-SKU',
  productName: 'Test Product',
  description: 'A test product',
  isAssembly: false,
  isActive: true,
  createdAt: '2026-01-17T10:00:00Z',
  updatedAt: '2026-01-17T10:00:00Z',
  assemblyParts: [],
  ...overrides,
});

// Request handlers
export const handlers = [
  // Health endpoints
  http.get(`${API_BASE}/health`, () => {
    return HttpResponse.json({ status: 'ok', database: 'connected' });
  }),

  http.get(`${API_BASE}/health/live`, () => {
    return HttpResponse.json({ status: 'ok' });
  }),

  http.get(`${API_BASE}/health/ready`, () => {
    return HttpResponse.json({ status: 'ok', database: 'connected' });
  }),

  // Orders endpoints
  http.get(`${API_BASE}/api/v1/orders`, ({ request }) => {
    const url = new URL(request.url);
    const page = parseInt(url.searchParams.get('page') || '1');
    const pageSize = parseInt(url.searchParams.get('pageSize') || '10');

    return HttpResponse.json({
      orders: [mockOrder(), mockOrder({ id: 'order-2', shopifyOrderNumber: '#1002' })],
      total: 2,
      page,
      pageSize,
    });
  }),

  http.get(`${API_BASE}/api/v1/orders/:id`, ({ params }) => {
    const { id } = params;
    if (id === 'not-found') {
      return new HttpResponse(null, { status: 404 });
    }
    return HttpResponse.json(mockOrder({ id: id as string }));
  }),

  http.put(`${API_BASE}/api/v1/orders/:id/status`, async ({ params, request }) => {
    const { id } = params;
    const body = await request.json() as { status: string };
    return HttpResponse.json(mockOrder({ id: id as string, status: body.status }));
  }),

  http.put(`${API_BASE}/api/v1/orders/:id/cancel`, ({ params }) => {
    const { id } = params;
    return HttpResponse.json(mockOrder({ id: id as string, status: 'CANCELLED' }));
  }),

  // Print jobs endpoints
  http.get(`${API_BASE}/api/v1/print-jobs`, () => {
    return HttpResponse.json({
      data: [mockPrintJob()],
      total: 1,
      page: 1,
      pageSize: 10,
    });
  }),

  http.get(`${API_BASE}/api/v1/print-jobs/order/:orderId`, () => {
    return HttpResponse.json({
      data: [mockPrintJob()],
      total: 1,
      page: 1,
      pageSize: 10,
    });
  }),

  http.get(`${API_BASE}/api/v1/print-jobs/active`, () => {
    return HttpResponse.json([mockPrintJob()]);
  }),

  http.post(`${API_BASE}/api/v1/print-jobs/:id/retry`, ({ params }) => {
    const { id } = params;
    return HttpResponse.json(mockPrintJob({ id: id as string, status: 'QUEUED' }));
  }),

  http.post(`${API_BASE}/api/v1/print-jobs/:id/cancel`, ({ params }) => {
    const { id } = params;
    return HttpResponse.json(mockPrintJob({ id: id as string, status: 'CANCELLED' }));
  }),

  // Dashboard endpoints
  http.get(`${API_BASE}/api/v1/dashboard/stats`, () => {
    return HttpResponse.json(mockDashboardStats());
  }),

  // Product mappings endpoints
  http.get(`${API_BASE}/api/v1/product-mappings`, () => {
    return HttpResponse.json({
      mappings: [mockProductMapping()],
      total: 1,
      page: 1,
      pageSize: 10,
    });
  }),

  http.get(`${API_BASE}/api/v1/product-mappings/:id`, ({ params }) => {
    const { id } = params;
    return HttpResponse.json(mockProductMapping({ id: id as string }));
  }),

  // Logs endpoints
  http.get(`${API_BASE}/api/v1/logs`, () => {
    return HttpResponse.json({
      logs: [
        {
          id: 'log-1',
          orderId: 'order-1',
          printJobId: null,
          eventType: 'order.created',
          severity: 'INFO',
          message: 'Order created',
          metadata: {},
          createdAt: '2026-01-17T10:00:00Z',
        },
      ],
      total: 1,
      page: 1,
      pageSize: 10,
    });
  }),

  // Shipping endpoints
  http.get(`${API_BASE}/api/v1/shipments/order/:orderId`, () => {
    return HttpResponse.json({
      id: 'shipment-1',
      orderId: 'order-1',
      status: 'PENDING',
      trackingNumber: null,
      trackingUrl: null,
      labelUrl: null,
    });
  }),

  http.get(`${API_BASE}/api/v1/shipping/status`, () => {
    return HttpResponse.json({ enabled: true });
  }),
];

Create apps/web/src/test/mocks/server.ts:

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

6. Update Test Setup for MSW

Update apps/web/src/test/setup.ts to include MSW:

import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from './mocks/server';

// Start MSW server before tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

// Reset handlers after each test
afterEach(() => {
  cleanup();
  server.resetHandlers();
});

// Close server after all tests
afterAll(() => server.close());

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// Mock localStorage
const localStorageMock = (() => {
  let store: Record<string, string> = {};
  return {
    getItem: vi.fn((key: string) => store[key] || null),
    setItem: vi.fn((key: string, value: string) => {
      store[key] = value;
    }),
    removeItem: vi.fn((key: string) => {
      delete store[key];
    }),
    clear: vi.fn(() => {
      store = {};
    }),
  };
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });

7. Update Project Configuration

Update apps/web/project.json:

{
  "name": "web",
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "sourceRoot": "apps/web/src",
  "projectType": "application",
  "tags": [],
  "targets": {
    "test": {
      "executor": "@nx/vite:test",
      "options": {
        "configFile": "apps/web/vitest.config.ts"
      }
    },
    "test:coverage": {
      "executor": "@nx/vite:test",
      "options": {
        "configFile": "apps/web/vitest.config.ts",
        "coverage": true
      }
    }
  }
}

Phase 2: Hook Tests (2-3 days)

Priority: P1 | Impact: High | Dependencies: Phase 1

1. Test use-orders.ts

Create apps/web/src/hooks/__tests__/use-orders.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import {
  useOrders,
  useOrder,
  useOrderPrintJobs,
  useRetryPrintJob,
  useCancelPrintJob,
  useCancelOrder,
  useUpdateOrderStatus,
} from '../use-orders';
import { AuthProvider } from '../../contexts/auth-context';
import { mockOrder, mockPrintJob } from '../../test/mocks/handlers';

// Test wrapper
function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider>{children}</AuthProvider>
      </QueryClientProvider>
    );
  };
}

describe('useOrders', () => {
  it('should fetch orders list', async () => {
    const { result } = renderHook(() => useOrders(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data?.orders).toHaveLength(2);
    expect(result.current.data?.orders[0]).toMatchObject({
      id: 'order-1',
      shopifyOrderNumber: '#1001',
    });
  });

  it('should pass query parameters', async () => {
    const { result } = renderHook(
      () => useOrders({ page: 2, pageSize: 20, status: 'PENDING' }),
      { wrapper: createWrapper() },
    );

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data?.page).toBe(2);
    expect(result.current.data?.pageSize).toBe(20);
  });
});

describe('useOrder', () => {
  it('should fetch single order by id', async () => {
    const { result } = renderHook(() => useOrder('order-1'), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      id: 'order-1',
      shopifyOrderNumber: '#1001',
    });
  });

  it('should not fetch when id is empty', () => {
    const { result } = renderHook(() => useOrder(''), {
      wrapper: createWrapper(),
    });

    expect(result.current.fetchStatus).toBe('idle');
  });
});

describe('useOrderPrintJobs', () => {
  it('should fetch print jobs for order', async () => {
    const { result } = renderHook(() => useOrderPrintJobs('order-1'), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toBeInstanceOf(Array);
  });

  it('should not fetch when orderId is empty', () => {
    const { result } = renderHook(() => useOrderPrintJobs(''), {
      wrapper: createWrapper(),
    });

    expect(result.current.fetchStatus).toBe('idle');
  });
});

describe('useRetryPrintJob', () => {
  it('should retry print job', async () => {
    const { result } = renderHook(() => useRetryPrintJob(), {
      wrapper: createWrapper(),
    });

    result.current.mutate('job-1');

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      id: 'job-1',
      status: 'QUEUED',
    });
  });
});

describe('useCancelPrintJob', () => {
  it('should cancel print job', async () => {
    const { result } = renderHook(() => useCancelPrintJob(), {
      wrapper: createWrapper(),
    });

    result.current.mutate({ jobId: 'job-1', reason: 'Test cancellation' });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      id: 'job-1',
      status: 'CANCELLED',
    });
  });
});

describe('useCancelOrder', () => {
  it('should cancel order', async () => {
    const { result } = renderHook(() => useCancelOrder(), {
      wrapper: createWrapper(),
    });

    result.current.mutate('order-1');

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      id: 'order-1',
      status: 'CANCELLED',
    });
  });
});

describe('useUpdateOrderStatus', () => {
  it('should update order status', async () => {
    const { result } = renderHook(() => useUpdateOrderStatus(), {
      wrapper: createWrapper(),
    });

    result.current.mutate({ orderId: 'order-1', status: 'PROCESSING' });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      id: 'order-1',
      status: 'PROCESSING',
    });
  });
});

2. Test use-dashboard.ts

Create apps/web/src/hooks/__tests__/use-dashboard.test.tsx:

import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useDashboardStats, useActivePrintJobs } from '../use-dashboard';
import { mockDashboardStats } from '../../test/mocks/handlers';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });

  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    );
  };
}

describe('useDashboardStats', () => {
  it('should fetch dashboard statistics', async () => {
    const { result } = renderHook(() => useDashboardStats(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      pendingOrders: expect.any(Number),
      processingOrders: expect.any(Number),
      completedToday: expect.any(Number),
      failedOrders: expect.any(Number),
      activePrintJobs: expect.any(Number),
      completedPrintJobsToday: expect.any(Number),
    });
  });

  it('should have refetch interval of 30 seconds', async () => {
    const { result } = renderHook(() => useDashboardStats(), {
      wrapper: createWrapper(),
    });

    // The refetchInterval is set in the hook, we just verify the query works
    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
  });
});

describe('useActivePrintJobs', () => {
  it('should fetch active print jobs', async () => {
    const { result } = renderHook(() => useActivePrintJobs(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toBeInstanceOf(Array);
  });
});

3. Test use-mappings.ts

Create apps/web/src/hooks/__tests__/use-mappings.test.tsx:

import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useMappings, useMapping, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../use-mappings';
import { AuthProvider } from '../../contexts/auth-context';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider>{children}</AuthProvider>
      </QueryClientProvider>
    );
  };
}

describe('useMappings', () => {
  it('should fetch mappings list', async () => {
    const { result } = renderHook(() => useMappings(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data?.mappings).toBeDefined();
    expect(result.current.data?.total).toBeDefined();
  });

  it('should pass query parameters', async () => {
    const { result } = renderHook(
      () => useMappings({ page: 1, pageSize: 20, isActive: true }),
      { wrapper: createWrapper() },
    );

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
  });
});

describe('useMapping', () => {
  it('should fetch single mapping by id', async () => {
    const { result } = renderHook(() => useMapping('mapping-1'), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      id: 'mapping-1',
    });
  });
});

4. Test use-logs.ts

Create apps/web/src/hooks/__tests__/use-logs.test.tsx:

import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useLogs } from '../use-logs';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });

  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    );
  };
}

describe('useLogs', () => {
  it('should fetch logs list', async () => {
    const { result } = renderHook(() => useLogs(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data?.logs).toBeDefined();
    expect(result.current.data?.total).toBeDefined();
  });

  it('should pass filter parameters', async () => {
    const { result } = renderHook(
      () => useLogs({ severity: 'ERROR', eventType: 'order.created' }),
      { wrapper: createWrapper() },
    );

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
  });
});

5. Test use-health.ts

Create apps/web/src/hooks/__tests__/use-health.test.tsx:

import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useHealth, useLiveHealth, useReadyHealth } from '../use-health';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });

  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    );
  };
}

describe('useHealth', () => {
  it('should fetch health status', async () => {
    const { result } = renderHook(() => useHealth(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      status: 'ok',
    });
  });
});

describe('useLiveHealth', () => {
  it('should fetch liveness probe', async () => {
    const { result } = renderHook(() => useLiveHealth(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      status: 'ok',
    });
  });
});

describe('useReadyHealth', () => {
  it('should fetch readiness probe', async () => {
    const { result } = renderHook(() => useReadyHealth(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      status: 'ok',
      database: 'connected',
    });
  });
});

6. Test use-shipments.ts

Create apps/web/src/hooks/__tests__/use-shipments.test.tsx:

import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useOrderShipment, useShippingStatus, useCreateShippingLabel } from '../use-shipments';
import { AuthProvider } from '../../contexts/auth-context';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider>{children}</AuthProvider>
      </QueryClientProvider>
    );
  };
}

describe('useOrderShipment', () => {
  it('should fetch shipment for order', async () => {
    const { result } = renderHook(() => useOrderShipment('order-1'), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      orderId: 'order-1',
    });
  });

  it('should not fetch when orderId is empty', () => {
    const { result } = renderHook(() => useOrderShipment(''), {
      wrapper: createWrapper(),
    });

    expect(result.current.fetchStatus).toBe('idle');
  });
});

describe('useShippingStatus', () => {
  it('should fetch shipping enabled status', async () => {
    const { result } = renderHook(() => useShippingStatus(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toMatchObject({
      enabled: true,
    });
  });
});

Phase 3: Context Tests (1 day)

Priority: P1 | Impact: High | Dependencies: Phase 1

1. Test auth-context.tsx

Create apps/web/src/contexts/__tests__/auth-context.test.tsx:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from '../auth-context';

describe('AuthContext', () => {
  beforeEach(() => {
    // Clear localStorage before each test
    window.localStorage.clear();
    vi.clearAllMocks();
  });

  describe('useAuth hook', () => {
    it('should throw error when used outside AuthProvider', () => {
      expect(() => {
        renderHook(() => useAuth());
      }).toThrow('useAuth must be used within an AuthProvider');
    });

    it('should return initial unauthenticated state', () => {
      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      expect(result.current.isAuthenticated).toBe(false);
      expect(result.current.apiKey).toBeNull();
    });

    it('should load apiKey from localStorage on mount', () => {
      window.localStorage.setItem('forma3d_api_key', 'stored-key');

      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      expect(result.current.isAuthenticated).toBe(true);
      expect(result.current.apiKey).toBe('stored-key');
    });
  });

  describe('login', () => {
    it('should set apiKey and update isAuthenticated', () => {
      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      act(() => {
        result.current.login('test-api-key');
      });

      expect(result.current.isAuthenticated).toBe(true);
      expect(result.current.apiKey).toBe('test-api-key');
    });

    it('should store apiKey in localStorage', () => {
      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      act(() => {
        result.current.login('test-api-key');
      });

      expect(window.localStorage.setItem).toHaveBeenCalledWith(
        'forma3d_api_key',
        'test-api-key',
      );
    });
  });

  describe('logout', () => {
    it('should clear apiKey and update isAuthenticated', () => {
      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      // First login
      act(() => {
        result.current.login('test-api-key');
      });

      // Then logout
      act(() => {
        result.current.logout();
      });

      expect(result.current.isAuthenticated).toBe(false);
      expect(result.current.apiKey).toBeNull();
    });

    it('should remove apiKey from localStorage', () => {
      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      act(() => {
        result.current.login('test-api-key');
      });

      act(() => {
        result.current.logout();
      });

      expect(window.localStorage.removeItem).toHaveBeenCalledWith(
        'forma3d_api_key',
      );
    });
  });

  describe('error handling', () => {
    it('should handle localStorage errors gracefully on getItem', () => {
      const originalGetItem = window.localStorage.getItem;
      (window.localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation(() => {
        throw new Error('Storage error');
      });

      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      expect(result.current.apiKey).toBeNull();

      // Restore
      window.localStorage.getItem = originalGetItem;
    });

    it('should handle localStorage errors gracefully on setItem', () => {
      (window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {
        throw new Error('Storage error');
      });

      const { result } = renderHook(() => useAuth(), {
        wrapper: AuthProvider,
      });

      // Should not throw
      act(() => {
        result.current.login('test-key');
      });

      // State should still update
      expect(result.current.apiKey).toBe('test-key');
    });
  });
});

Phase 4: API Client Tests (1 day)

Priority: P1 | Impact: High | Dependencies: Phase 1

1. Test api-client.ts

Create apps/web/src/lib/__tests__/api-client.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/mocks/server';
import { apiClient } from '../api-client';

const API_BASE = 'http://localhost:3000';

describe('apiClient', () => {
  describe('request function', () => {
    it('should handle successful responses', async () => {
      const result = await apiClient.health.check();
      expect(result).toMatchObject({ status: 'ok' });
    });

    it('should handle 404 errors', async () => {
      server.use(
        http.get(`${API_BASE}/api/v1/orders/:id`, () => {
          return new HttpResponse(
            JSON.stringify({ message: 'Order not found' }),
            { status: 404 },
          );
        }),
      );

      await expect(apiClient.orders.get('not-found')).rejects.toThrow(
        'Order not found',
      );
    });

    it('should handle 500 errors', async () => {
      server.use(
        http.get(`${API_BASE}/health`, () => {
          return new HttpResponse(
            JSON.stringify({ message: 'Internal server error' }),
            { status: 500 },
          );
        }),
      );

      await expect(apiClient.health.check()).rejects.toThrow(
        'Internal server error',
      );
    });

    it('should handle network errors', async () => {
      server.use(
        http.get(`${API_BASE}/health`, () => {
          return HttpResponse.error();
        }),
      );

      await expect(apiClient.health.check()).rejects.toThrow();
    });

    it('should include API key header when provided', async () => {
      let capturedHeaders: Headers | undefined;

      server.use(
        http.put(`${API_BASE}/api/v1/orders/:id/status`, ({ request }) => {
          capturedHeaders = new Headers(request.headers);
          return HttpResponse.json({ id: 'order-1', status: 'PROCESSING' });
        }),
      );

      await apiClient.orders.updateStatus('order-1', 'PROCESSING' as any, 'test-api-key');

      expect(capturedHeaders?.get('X-API-Key')).toBe('test-api-key');
    });

    it('should handle 204 No Content responses', async () => {
      server.use(
        http.delete(`${API_BASE}/api/v1/product-mappings/:id`, () => {
          return new HttpResponse(null, { status: 204 });
        }),
      );

      const result = await apiClient.mappings.delete('mapping-1', 'api-key');
      expect(result).toBeUndefined();
    });
  });

  describe('orders', () => {
    it('should list orders with pagination', async () => {
      const result = await apiClient.orders.list({ page: 1, pageSize: 10 });

      expect(result).toMatchObject({
        orders: expect.any(Array),
        total: expect.any(Number),
        page: 1,
        pageSize: 10,
      });
    });

    it('should get order by id', async () => {
      const result = await apiClient.orders.get('order-1');

      expect(result).toMatchObject({
        id: 'order-1',
        shopifyOrderNumber: '#1001',
      });
    });

    it('should update order status', async () => {
      const result = await apiClient.orders.updateStatus(
        'order-1',
        'PROCESSING' as any,
        'api-key',
      );

      expect(result).toMatchObject({
        id: 'order-1',
        status: 'PROCESSING',
      });
    });

    it('should cancel order', async () => {
      const result = await apiClient.orders.cancel('order-1', 'api-key');

      expect(result).toMatchObject({
        id: 'order-1',
        status: 'CANCELLED',
      });
    });
  });

  describe('printJobs', () => {
    it('should list print jobs', async () => {
      const result = await apiClient.printJobs.list();

      expect(result).toMatchObject({
        data: expect.any(Array),
        total: expect.any(Number),
      });
    });

    it('should get print jobs by order id', async () => {
      const result = await apiClient.printJobs.getByOrderId('order-1');

      expect(result).toBeInstanceOf(Array);
    });

    it('should get active print jobs', async () => {
      const result = await apiClient.printJobs.getActive();

      expect(result).toBeInstanceOf(Array);
    });

    it('should retry print job', async () => {
      const result = await apiClient.printJobs.retry('job-1', 'api-key');

      expect(result).toMatchObject({
        id: 'job-1',
        status: 'QUEUED',
      });
    });

    it('should cancel print job', async () => {
      const result = await apiClient.printJobs.cancel('job-1', 'reason', 'api-key');

      expect(result).toMatchObject({
        id: 'job-1',
        status: 'CANCELLED',
      });
    });
  });

  describe('dashboard', () => {
    it('should get dashboard stats', async () => {
      const result = await apiClient.dashboard.getStats();

      expect(result).toMatchObject({
        pendingOrders: expect.any(Number),
        processingOrders: expect.any(Number),
        completedToday: expect.any(Number),
        failedOrders: expect.any(Number),
        activePrintJobs: expect.any(Number),
        completedPrintJobsToday: expect.any(Number),
      });
    });
  });

  describe('mappings', () => {
    it('should list mappings', async () => {
      const result = await apiClient.mappings.list();

      expect(result).toMatchObject({
        mappings: expect.any(Array),
        total: expect.any(Number),
      });
    });

    it('should get mapping by id', async () => {
      const result = await apiClient.mappings.get('mapping-1');

      expect(result).toMatchObject({
        id: 'mapping-1',
      });
    });
  });

  describe('logs', () => {
    it('should list logs with filters', async () => {
      const result = await apiClient.logs.list({
        severity: 'ERROR',
        page: 1,
        pageSize: 20,
      });

      expect(result).toMatchObject({
        logs: expect.any(Array),
        total: expect.any(Number),
      });
    });
  });

  describe('shipping', () => {
    it('should get shipment by order id', async () => {
      const result = await apiClient.shipping.getByOrderId('order-1');

      expect(result).toMatchObject({
        orderId: 'order-1',
      });
    });

    it('should return null for 404 on shipment', async () => {
      server.use(
        http.get(`${API_BASE}/api/v1/shipments/order/:orderId`, () => {
          return new HttpResponse(
            JSON.stringify({ message: 'Not found' }),
            { status: 404 },
          );
        }),
      );

      const result = await apiClient.shipping.getByOrderId('not-found');
      expect(result).toBeNull();
    });

    it('should get shipping status', async () => {
      const result = await apiClient.shipping.getStatus();

      expect(result).toMatchObject({
        enabled: true,
      });
    });
  });
});

Phase 5: UI Component Tests (2-3 days)

Priority: P2 | Impact: Medium | Dependencies: Phase 1

1. Test Badge Component

Create apps/web/src/components/ui/__tests__/badge.test.tsx:

import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import { Badge, StatusBadge } from '../badge';

describe('Badge', () => {
  it('should render children', () => {
    render(<Badge>Test Badge</Badge>);
    expect(screen.getByText('Test Badge')).toBeInTheDocument();
  });

  it('should apply default variant styles', () => {
    render(<Badge>Default</Badge>);
    const badge = screen.getByText('Default');
    expect(badge).toHaveClass('bg-gray-100');
  });

  it('should apply success variant styles', () => {
    render(<Badge variant="success">Success</Badge>);
    const badge = screen.getByText('Success');
    expect(badge).toHaveClass('bg-green-100');
  });

  it('should apply warning variant styles', () => {
    render(<Badge variant="warning">Warning</Badge>);
    const badge = screen.getByText('Warning');
    expect(badge).toHaveClass('bg-yellow-100');
  });

  it('should apply error variant styles', () => {
    render(<Badge variant="error">Error</Badge>);
    const badge = screen.getByText('Error');
    expect(badge).toHaveClass('bg-red-100');
  });

  it('should apply additional className', () => {
    render(<Badge className="custom-class">Custom</Badge>);
    const badge = screen.getByText('Custom');
    expect(badge).toHaveClass('custom-class');
  });
});

describe('StatusBadge', () => {
  it.each([
    ['PENDING', 'warning'],
    ['PROCESSING', 'info'],
    ['COMPLETED', 'success'],
    ['FAILED', 'error'],
    ['CANCELLED', 'default'],
  ])('should render %s status with correct variant', (status, expectedVariant) => {
    render(<StatusBadge status={status} />);
    expect(screen.getByText(status)).toBeInTheDocument();
  });
});

2. Test Button Component

Create apps/web/src/components/ui/__tests__/button.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import userEvent from '@testing-library/user-event';
import { Button } from '../button';

describe('Button', () => {
  it('should render children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });

  it('should handle click events', async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    await userEvent.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('should be disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('should not call onClick when disabled', async () => {
    const handleClick = vi.fn();
    render(<Button disabled onClick={handleClick}>Disabled</Button>);

    await userEvent.click(screen.getByRole('button'));

    expect(handleClick).not.toHaveBeenCalled();
  });

  it('should show loading state', () => {
    render(<Button loading>Loading</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
    // Check for loading spinner or text
  });

  it('should apply variant styles', () => {
    const { rerender } = render(<Button variant="primary">Primary</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-blue-600');

    rerender(<Button variant="secondary">Secondary</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-gray-200');

    rerender(<Button variant="danger">Danger</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-red-600');
  });

  it('should apply size styles', () => {
    const { rerender } = render(<Button size="sm">Small</Button>);
    expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5');

    rerender(<Button size="md">Medium</Button>);
    expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');

    rerender(<Button size="lg">Large</Button>);
    expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
  });
});

3. Test Card Component

Create apps/web/src/components/ui/__tests__/card.test.tsx:

import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import { Card, CardHeader, CardTitle, CardContent } from '../card';

describe('Card', () => {
  it('should render children', () => {
    render(<Card>Card content</Card>);
    expect(screen.getByText('Card content')).toBeInTheDocument();
  });

  it('should apply additional className', () => {
    render(<Card className="custom-class">Content</Card>);
    expect(screen.getByText('Content').closest('div')).toHaveClass('custom-class');
  });
});

describe('CardHeader', () => {
  it('should render header content', () => {
    render(
      <Card>
        <CardHeader>
          <CardTitle>Title</CardTitle>
        </CardHeader>
      </Card>,
    );
    expect(screen.getByText('Title')).toBeInTheDocument();
  });
});

describe('CardContent', () => {
  it('should render content', () => {
    render(
      <Card>
        <CardContent>Body content</CardContent>
      </Card>,
    );
    expect(screen.getByText('Body content')).toBeInTheDocument();
  });
});

4. Test Loading Component

Create apps/web/src/components/ui/__tests__/loading.test.tsx:

import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import { Loading, LoadingSpinner, LoadingPage } from '../loading';

describe('Loading', () => {
  it('should render loading text', () => {
    render(<Loading />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('should render custom text', () => {
    render(<Loading text="Please wait..." />);
    expect(screen.getByText('Please wait...')).toBeInTheDocument();
  });
});

describe('LoadingSpinner', () => {
  it('should render spinner', () => {
    render(<LoadingSpinner />);
    // Check for spinner element (usually an SVG or animated div)
    expect(document.querySelector('[class*="animate-spin"]')).toBeInTheDocument();
  });

  it('should apply size classes', () => {
    const { container } = render(<LoadingSpinner size="lg" />);
    expect(container.querySelector('[class*="w-8"]')).toBeInTheDocument();
  });
});

describe('LoadingPage', () => {
  it('should render full page loading', () => {
    render(<LoadingPage />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });
});

5. Test Pagination Component

Create apps/web/src/components/ui/__tests__/pagination.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import userEvent from '@testing-library/user-event';
import { Pagination } from '../pagination';

describe('Pagination', () => {
  it('should render current page and total', () => {
    render(
      <Pagination
        currentPage={1}
        totalPages={10}
        onPageChange={() => {}}
      />,
    );

    expect(screen.getByText(/1/)).toBeInTheDocument();
    expect(screen.getByText(/10/)).toBeInTheDocument();
  });

  it('should call onPageChange when clicking next', async () => {
    const handlePageChange = vi.fn();
    render(
      <Pagination
        currentPage={1}
        totalPages={10}
        onPageChange={handlePageChange}
      />,
    );

    await userEvent.click(screen.getByRole('button', { name: /next/i }));

    expect(handlePageChange).toHaveBeenCalledWith(2);
  });

  it('should call onPageChange when clicking previous', async () => {
    const handlePageChange = vi.fn();
    render(
      <Pagination
        currentPage={5}
        totalPages={10}
        onPageChange={handlePageChange}
      />,
    );

    await userEvent.click(screen.getByRole('button', { name: /previous/i }));

    expect(handlePageChange).toHaveBeenCalledWith(4);
  });

  it('should disable previous button on first page', () => {
    render(
      <Pagination
        currentPage={1}
        totalPages={10}
        onPageChange={() => {}}
      />,
    );

    expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
  });

  it('should disable next button on last page', () => {
    render(
      <Pagination
        currentPage={10}
        totalPages={10}
        onPageChange={() => {}}
      />,
    );

    expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
  });
});

Phase 6: Documentation Updates (30 minutes)

Priority: Medium | Impact: Medium | Dependencies: Phase 5

1. Update Technical Debt Register

Update docs/04-development/techdebt/technical-debt-register.md:

In the Summary Dashboard section:

## Summary Dashboard

| Category | Critical | High | Medium | Low | Total |
|----------|----------|------|--------|-----|-------|
| Architecture Debt | 0 | 3 | 2 | 1 | 6 |
| Code Debt | 0 | 4 | 5 | 3 | 12 |
| Test Debt | 0 | 2 | 1 | 0 | 3 |
| Documentation Debt | 0 | 1 | 2 | 1 | 4 |
| Infrastructure Debt | 0 | 1 | 2 | 1 | 4 |
| **Total** | **0** | **11** | **12** | **6** | **29** |

Update the TD-002 section:

### ~~TD-002: Missing Frontend Test Coverage~~ ✅ RESOLVED

**Type:** Test Debt  
**Status:****Resolved in Phase 5d**  
**Resolution Date:** 2026-XX-XX

#### Resolution

Implemented comprehensive frontend test coverage using Vitest:

- **Test Framework:** Vitest with React Testing Library
- **Mocking:** MSW (Mock Service Worker) for API mocking
- **Coverage:** 60%+ across hooks, contexts, and components

**Files Created:**
- `apps/web/vitest.config.ts` - Vitest configuration
- `apps/web/src/test/setup.ts` - Test setup with MSW
- `apps/web/src/test/test-utils.tsx` - Custom render utilities
- `apps/web/src/test/mocks/handlers.ts` - MSW request handlers
- `apps/web/src/test/mocks/server.ts` - MSW server setup
- `apps/web/src/hooks/__tests__/*.test.tsx` - Hook tests
- `apps/web/src/contexts/__tests__/*.test.tsx` - Context tests
- `apps/web/src/lib/__tests__/*.test.ts` - API client tests
- `apps/web/src/components/ui/__tests__/*.test.tsx` - Component tests

---

Phase 7: CI/CD Pipeline Integration (1 hour)

Priority: High | Impact: High | Dependencies: Phase 1

1. Update Vitest Configuration for CI

Update apps/web/vitest.config.ts to output JUnit reports:

/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    // CI-specific reporters
    reporters: process.env.CI 
      ? ['default', 'junit'] 
      : ['default'],
    outputFile: {
      junit: '../../test-results/web/junit.xml',
    },
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'cobertura'],
      reportsDirectory: '../../coverage/web',
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/*.{test,spec}.{ts,tsx}',
        'src/test/**',
        'src/main.tsx',
        'src/router.tsx',
        'src/vite-env.d.ts',
      ],
      thresholds: {
        statements: 60,
        branches: 60,
        functions: 60,
        lines: 60,
      },
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
      '@forma3d/domain': resolve(__dirname, '../../libs/domain/src'),
    },
  },
});

2. Update Azure DevOps Pipeline

Update .azuredevops/pipelines/ci.yml to add test reporting:

Replace the existing Test job with:

# -----------------------------------------------------------------------
# Test Job (Updated with Test Reports)
# -----------------------------------------------------------------------
- job: Test
  displayName: 'Test'
  services:
    postgres:
      image: postgres:16
      ports:
        - 5432:5432
      env:
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: postgres
        POSTGRES_DB: forma3d_connect_test
  steps:
    - task: NodeTool@0
      inputs:
        versionSpec: '$(nodeVersion)'
      displayName: 'Install Node.js'

    - script: |
        corepack enable
        corepack prepare pnpm@$(pnpmVersion) --activate
      displayName: 'Install pnpm'

    - script: pnpm install --frozen-lockfile
      displayName: 'Install Dependencies'

    - script: pnpm prisma generate
      displayName: 'Generate Prisma Client'

    - script: |
        for i in {1..30}; do
          pg_isready -h localhost -p 5432 -U postgres && break
          sleep 1
        done
      displayName: 'Wait for PostgreSQL'

    - script: pnpm prisma migrate deploy
      displayName: 'Run Migrations'
      env:
        DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/forma3d_connect_test?schema=public'

    # Create test results directories
    - script: mkdir -p test-results/api test-results/web coverage/api coverage/web
      displayName: 'Create Results Directories'

    # Run API tests with JUnit output
    - script: pnpm nx test api --coverage
      displayName: 'Run API Tests'
      env:
        DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/forma3d_connect_test?schema=public'
        CI: 'true'
      continueOnError: true

    # Run Frontend tests with JUnit output  
    - script: pnpm nx test web --coverage
      displayName: 'Run Frontend Tests'
      env:
        CI: 'true'
      continueOnError: true

    # Publish Test Results (JUnit format)
    - task: PublishTestResults@2
      displayName: 'Publish Test Results'
      inputs:
        testResultsFormat: 'JUnit'
        testResultsFiles: '**/test-results/**/junit.xml'
        mergeTestResults: true
        failTaskOnFailedTests: true
        testRunTitle: 'Unit Tests - $(Build.BuildNumber)'
      condition: succeededOrFailed()

    # Publish Code Coverage (Cobertura format)
    - task: PublishCodeCoverageResults@2
      displayName: 'Publish Code Coverage'
      inputs:
        summaryFileLocation: '$(System.DefaultWorkingDirectory)/coverage/**/cobertura-coverage.xml'
        pathToSources: '$(System.DefaultWorkingDirectory)'
      condition: succeededOrFailed()

3. Update Jest Configuration for API (JUnit output)

Install jest-junit:

pnpm add -D jest-junit

Update jest.config.ts in the root or apps/api/jest.config.ts:

import { getJestProjectsAsync } from '@nx/jest';

export default async () => ({
  projects: await getJestProjectsAsync(),
  // Global reporters for all projects
  reporters: [
    'default',
    [
      'jest-junit',
      {
        outputDirectory: './test-results/api',
        outputName: 'junit.xml',
        classNameTemplate: '{classname}',
        titleTemplate: '{title}',
      },
    ],
  ],
});

4. Update Project Configuration for CI

Update apps/web/project.json:

{
  "name": "web",
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "sourceRoot": "apps/web/src",
  "projectType": "application",
  "tags": [],
  "targets": {
    "test": {
      "executor": "@nx/vite:test",
      "options": {
        "configFile": "apps/web/vitest.config.ts"
      }
    },
    "test:coverage": {
      "executor": "@nx/vite:test",
      "options": {
        "configFile": "apps/web/vitest.config.ts",
        "coverage": true
      }
    }
  }
}

5. Update .gitignore

Ensure these directories are ignored:

# Test results and coverage
test-results/
coverage/

📁 Files to Create/Modify

New Files

apps/web/
  vitest.config.ts
  src/
    test/
      setup.ts
      test-utils.tsx
      mocks/
        handlers.ts
        server.ts
    hooks/__tests__/
      use-orders.test.tsx
      use-dashboard.test.tsx
      use-mappings.test.tsx
      use-logs.test.tsx
      use-health.test.tsx
      use-shipments.test.tsx
    contexts/__tests__/
      auth-context.test.tsx
    lib/__tests__/
      api-client.test.ts
    components/ui/__tests__/
      badge.test.tsx
      button.test.tsx
      card.test.tsx
      loading.test.tsx
      pagination.test.tsx

Modified Files

apps/web/project.json                         # Add test targets
apps/web/vitest.config.ts                     # Add CI reporters (JUnit, Cobertura)
.azuredevops/pipelines/ci.yml                 # Add test reporting tasks
jest.config.ts                                # Add jest-junit reporter
.gitignore                                    # Add test-results/ and coverage/
package.json                                  # Add testing dependencies
docs/04-development/techdebt/technical-debt-register.md  # Mark TD-002 resolved

🧪 Testing Requirements

Coverage Targets

Category Files Target Coverage
Hooks 6 80%
Contexts 2 90%
API Client 1 80%
UI Components 10 70%
Overall 19 60%

Test Verification

# Run all frontend tests
pnpm nx test web

# Run with coverage
pnpm nx test:coverage web

# Watch mode for development
pnpm nx test web --watch

✅ Validation Checklist

Phase 1: Configuration

  • Testing dependencies installed
  • Vitest configured with jsdom environment
  • Test setup file created with MSW
  • Test utilities created with wrapper
  • MSW handlers for all API endpoints
  • Project.json updated with test targets

Phase 2: Hook Tests

  • use-orders.test.tsx - All 7 hooks tested
  • use-dashboard.test.tsx - Both hooks tested
  • use-mappings.test.tsx - All hooks tested
  • use-logs.test.tsx - Hook tested
  • use-health.test.tsx - All 3 hooks tested
  • use-shipments.test.tsx - All hooks tested

Phase 3: Context Tests

  • auth-context.test.tsx - Login/logout/persistence tested

Phase 4: API Client Tests

  • api-client.test.ts - All endpoints and error handling tested

Phase 5: Component Tests

  • Badge component tested
  • Button component tested
  • Card component tested
  • Loading component tested
  • Pagination component tested

Phase 6: Documentation

  • Technical debt register updated (TD-002 marked resolved)
  • Summary dashboard counts updated

Phase 7: CI/CD Integration

  • Vitest configured with JUnit reporter for CI
  • Vitest configured with Cobertura coverage reporter
  • jest-junit installed for API tests
  • Azure DevOps pipeline updated with PublishTestResults task
  • Azure DevOps pipeline updated with PublishCodeCoverageResults task
  • test-results/ and coverage/ directories in .gitignore
  • Test results visible in Azure DevOps pipeline runs

Final Verification

# All tests pass
pnpm nx test web

# Coverage meets threshold (60%)
pnpm nx test:coverage web

# Build still works
pnpm nx build web

# Lint passes
pnpm nx lint web

# CI reporter generates JUnit (verify file exists after test run)
CI=true pnpm nx test web
ls -la test-results/web/junit.xml

# Verify coverage report generated
ls -la coverage/web/cobertura-coverage.xml

CI/CD Verification

After pushing changes, verify in Azure DevOps:

  1. Test Results Tab: Pipeline run should show "Tests" tab with pass/fail counts
  2. Code Coverage Tab: Pipeline run should show "Code Coverage" tab with percentage
  3. Test History: Individual test names should be visible and searchable
  4. Failure Details: Failed tests should show stack traces and error messages

🚫 Constraints and Rules

MUST DO

  • Use Vitest (not Jest) for frontend tests
  • Use React Testing Library for component testing
  • Use MSW for API mocking
  • Create test utilities with proper providers
  • Test error states and edge cases
  • Achieve 60% minimum coverage

MUST NOT

  • Use snapshot testing for components
  • Mock implementation details
  • Test private functions directly
  • Skip async/await error handling tests
  • Leave flaky tests in the suite

📊 Success Metrics

Before Phase 5d

Metric Value
Test files 0
Test coverage 0%
Critical tech debt items 1

After Phase 5d

Metric Value
Test files 15+
Test coverage 60%+
Critical tech debt items 0

END OF PROMPT


This prompt resolves TD-002 from the technical debt register by implementing comprehensive frontend test coverage. The testing setup uses modern tools (Vitest, MSW, React Testing Library) following React testing best practices.