# Testing Strategy

> ArchiTools testing guide -- tools, conventions, coverage targets, and anti-patterns.

---

## Overview

ArchiTools follows a testing pyramid: many fast unit tests at the base, fewer integration tests in the middle, and a small number of E2E tests at the top. Tests are co-located with the source code they test, not in a separate directory tree.

```
         ┌─────────┐
         │  E2E    │   Playwright -- critical user flows
         │  (few)  │
        ┌┴─────────┴┐
        │Integration │   Vitest -- module services with real storage
        │ (moderate) │
       ┌┴────────────┴┐
       │   Component   │   Vitest + React Testing Library
       │  (moderate)   │
      ┌┴──────────────┴┐
      │     Unit        │   Vitest -- hooks, services, utilities
      │    (many)       │
      └────────────────┘
```

---

## Tools

| Layer | Tool | Why |
|---|---|---|
| Unit | Vitest | Fast, native ESM, TypeScript-first, compatible with Next.js |
| Component | Vitest + React Testing Library | Tests component behavior from the user's perspective |
| Integration | Vitest | Same runner, but tests span module service + storage layers |
| E2E | Playwright | Cross-browser, reliable, good Next.js support |

### Vitest Configuration

```typescript
// vitest.config.ts

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/*.test.{ts,tsx}',
        'src/**/*.d.ts',
        'src/test/**',
        'src/types/**',
      ],
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});
```

### Test Setup

```typescript
// src/test/setup.ts

import '@testing-library/jest-dom/vitest';

// Mock localStorage for all tests
const localStorageMock = (() => {
  let store: Record<string, string> = {};
  return {
    getItem: (key: string) => store[key] ?? null,
    setItem: (key: string, value: string) => { store[key] = value; },
    removeItem: (key: string) => { delete store[key]; },
    clear: () => { store = {}; },
    get length() { return Object.keys(store).length; },
    key: (index: number) => Object.keys(store)[index] ?? null,
  };
})();

Object.defineProperty(window, 'localStorage', { value: localStorageMock });

// Reset storage between tests
beforeEach(() => {
  localStorage.clear();
});
```

---

## File Organization

Tests are co-located with the source files they test. No separate `__tests__` directories.

```
src/modules/registratura/
  components/
    RegistryTable.tsx
    RegistryTable.test.tsx          # component test
    RegistryForm.tsx
    RegistryForm.test.tsx           # component test
  hooks/
    use-registry.ts
    use-registry.test.ts            # hook unit test
  services/
    registry-service.ts
    registry-service.test.ts        # service unit test
    registry-service.integration.test.ts  # integration test
  types.ts
  config.ts

src/lib/tags/
  tag-service.ts
  tag-service.test.ts               # unit test
  tag-service.integration.test.ts   # integration test

src/lib/storage/
  storage-service.ts
  storage-service.test.ts           # unit test

src/hooks/
  useFeatureFlag.ts
  useFeatureFlag.test.ts            # unit test
```

### Naming Convention

| File type | Pattern | Example |
|---|---|---|
| Unit test | `[name].test.ts` | `registry-service.test.ts` |
| Component test | `[Component].test.tsx` | `RegistryTable.test.tsx` |
| Integration test | `[name].integration.test.ts` | `registry-service.integration.test.ts` |
| E2E test | `[flow].spec.ts` (in `e2e/`) | `e2e/registratura.spec.ts` |
| Test utility | `[name].ts` (in `src/test/`) | `src/test/factories.ts` |

---

## Unit Tests

### What to Test

- **Services:** All CRUD operations, validation logic, error handling, storage key construction.
- **Hooks:** State transitions, side effects, return value shape, error states.
- **Utility functions:** Transformations, formatters, parsers, validators.
- **Storage adapters:** Read/write/delete operations, namespace isolation.
- **Feature flag evaluation:** Flag resolution logic, default values.

### What NOT to Test

- TypeScript types (they are compile-time only).
- Static configuration objects (they have no logic).
- Third-party library internals (test your usage, not their code).

### Service Test Example

```typescript
// src/modules/registratura/services/registry-service.test.ts

import { describe, it, expect, beforeEach } from 'vitest';
import { RegistryService } from './registry-service';
import { createMockStorageService } from '@/test/mocks/storage';

describe('RegistryService', () => {
  let service: RegistryService;
  let storage: ReturnType<typeof createMockStorageService>;

  beforeEach(() => {
    storage = createMockStorageService();
    service = new RegistryService(storage);
  });

  describe('createEntry', () => {
    it('assigns an ID and timestamps on creation', async () => {
      const entry = await service.createEntry({
        title: 'Cerere CU',
        type: 'incoming',
        tagIds: [],
      });

      expect(entry.id).toBeDefined();
      expect(entry.createdAt).toBeDefined();
      expect(entry.updatedAt).toBeDefined();
    });

    it('rejects entries with empty title', async () => {
      await expect(
        service.createEntry({ title: '', type: 'incoming', tagIds: [] })
      ).rejects.toThrow('Title is required');
    });

    it('stores the entry under the correct namespace', async () => {
      await service.createEntry({
        title: 'Cerere CU',
        type: 'incoming',
        tagIds: [],
      });

      expect(storage.setItem).toHaveBeenCalledWith(
        expect.stringContaining('architools.registratura'),
        expect.any(String)
      );
    });
  });

  describe('getEntries', () => {
    it('returns an empty array when no entries exist', async () => {
      const entries = await service.getEntries();
      expect(entries).toEqual([]);
    });

    it('returns entries sorted by creation date descending', async () => {
      await service.createEntry({ title: 'First', type: 'incoming', tagIds: [] });
      await service.createEntry({ title: 'Second', type: 'incoming', tagIds: [] });

      const entries = await service.getEntries();
      expect(entries[0].title).toBe('Second');
      expect(entries[1].title).toBe('First');
    });
  });
});
```

### Hook Test Example

```typescript
// src/modules/registratura/hooks/use-registry.test.ts

import { describe, it, expect } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRegistry } from './use-registry';
import { TestProviders } from '@/test/providers';

describe('useRegistry', () => {
  it('loads entries on mount', async () => {
    const { result } = renderHook(() => useRegistry(), {
      wrapper: TestProviders,
    });

    expect(result.current.isLoading).toBe(true);

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.entries).toEqual([]);
  });

  it('adds an entry and updates the list', async () => {
    const { result } = renderHook(() => useRegistry(), {
      wrapper: TestProviders,
    });

    await waitFor(() => expect(result.current.isLoading).toBe(false));

    await act(async () => {
      await result.current.addEntry({
        title: 'Test Entry',
        type: 'incoming',
        tagIds: [],
      });
    });

    expect(result.current.entries).toHaveLength(1);
    expect(result.current.entries[0].title).toBe('Test Entry');
  });
});
```

---

## Component Tests

### Principles

- Test **behavior**, not implementation. Click buttons, fill inputs, assert on visible output.
- Use `screen.getByRole`, `screen.getByText`, `screen.getByLabelText` -- prefer accessible queries.
- Do not test CSS classes or DOM structure. Test what the user sees and can interact with.
- Mock services at the hook level, not at the component level. Components should receive data through hooks.

### Component Test Example

```typescript
// src/modules/registratura/components/RegistryTable.test.tsx

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegistryTable } from './RegistryTable';
import { createTestEntry } from '@/test/factories';

describe('RegistryTable', () => {
  const mockEntries = [
    createTestEntry({ title: 'Cerere CU - Casa Popescu', type: 'incoming' }),
    createTestEntry({ title: 'Autorizatie construire', type: 'outgoing' }),
  ];

  it('renders all entries', () => {
    render(<RegistryTable entries={mockEntries} onEdit={vi.fn()} onDelete={vi.fn()} />);

    expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
    expect(screen.getByText('Autorizatie construire')).toBeInTheDocument();
  });

  it('calls onEdit when edit button is clicked', async () => {
    const onEdit = vi.fn();
    render(<RegistryTable entries={mockEntries} onEdit={onEdit} onDelete={vi.fn()} />);

    const editButtons = screen.getAllByRole('button', { name: /editeaza/i });
    await userEvent.click(editButtons[0]);

    expect(onEdit).toHaveBeenCalledWith(mockEntries[0].id);
  });

  it('shows empty state when no entries exist', () => {
    render(<RegistryTable entries={[]} onEdit={vi.fn()} onDelete={vi.fn()} />);

    expect(screen.getByText(/nu exista inregistrari/i)).toBeInTheDocument();
  });

  it('filters entries when tag filter is applied', async () => {
    render(
      <RegistryTable
        entries={mockEntries}
        onEdit={vi.fn()}
        onDelete={vi.fn()}
        activeTagIds={[mockEntries[0].tagIds[0]]}
      />
    );

    expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
    expect(screen.queryByText('Autorizatie construire')).not.toBeInTheDocument();
  });
});
```

---

## Integration Tests

Integration tests verify that module services work correctly with the real storage adapter (localStorage mock from the test setup, not a hand-written mock).

### Module-Level Integration

```typescript
// src/modules/registratura/services/registry-service.integration.test.ts

import { describe, it, expect, beforeEach } from 'vitest';
import { RegistryService } from './registry-service';
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';

describe('RegistryService (integration)', () => {
  let service: RegistryService;

  beforeEach(() => {
    localStorage.clear();
    const storage = new LocalStorageAdapter('architools.registratura');
    service = new RegistryService(storage);
  });

  it('persists and retrieves entries across service instances', async () => {
    await service.createEntry({
      title: 'Persistent Entry',
      type: 'incoming',
      tagIds: [],
    });

    // Create a new service instance pointing to the same storage
    const storage2 = new LocalStorageAdapter('architools.registratura');
    const service2 = new RegistryService(storage2);

    const entries = await service2.getEntries();
    expect(entries).toHaveLength(1);
    expect(entries[0].title).toBe('Persistent Entry');
  });

  it('does not leak data across storage namespaces', async () => {
    await service.createEntry({
      title: 'Registry Entry',
      type: 'incoming',
      tagIds: [],
    });

    const otherStorage = new LocalStorageAdapter('architools.other-module');
    const keys = Object.keys(localStorage).filter((k) =>
      k.startsWith('architools.other-module')
    );
    expect(keys).toHaveLength(0);
  });
});
```

### Cross-Module Integration

Test that the tagging system works correctly when used by a module.

```typescript
// src/test/integration/tags-cross-module.integration.test.ts

import { describe, it, expect, beforeEach } from 'vitest';
import { TagService } from '@/lib/tags/tag-service';
import { RegistryService } from '@/modules/registratura/services/registry-service';
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';

describe('Tags cross-module integration', () => {
  let tagService: TagService;
  let registryService: RegistryService;

  beforeEach(() => {
    localStorage.clear();
    tagService = new TagService(new LocalStorageAdapter('architools.tags'));
    registryService = new RegistryService(
      new LocalStorageAdapter('architools.registratura')
    );
  });

  it('registry entries can reference tags from the tag service', async () => {
    const tag = await tagService.createTag({
      label: 'DTAC',
      category: 'phase',
      scope: 'global',
      color: '#14b8a6',
    });

    const entry = await registryService.createEntry({
      title: 'Documentatie DTAC',
      type: 'incoming',
      tagIds: [tag.id],
    });

    const resolvedTags = await tagService.getAllTags();
    const entryTags = resolvedTags.filter((t) => entry.tagIds.includes(t.id));

    expect(entryTags).toHaveLength(1);
    expect(entryTags[0].label).toBe('DTAC');
  });

  it('handles deleted tags gracefully in entity tag lists', async () => {
    const tag = await tagService.createTag({
      label: 'Temporary',
      category: 'custom',
      scope: 'global',
    });

    await registryService.createEntry({
      title: 'Entry with temp tag',
      type: 'incoming',
      tagIds: [tag.id],
    });

    await tagService.deleteTag(tag.id);

    const allTags = await tagService.getAllTags();
    const entries = await registryService.getEntries();

    // The entry still references the deleted tag ID
    expect(entries[0].tagIds).toContain(tag.id);
    // But the tag no longer exists
    expect(allTags.find((t) => t.id === tag.id)).toBeUndefined();
  });
});
```

---

## E2E Tests

### Setup

```typescript
// playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
```

### Critical Paths

E2E tests cover the flows that, if broken, would block daily use of ArchiTools.

| Flow | File | What it tests |
|---|---|---|
| Navigation | `e2e/navigation.spec.ts` | Sidebar renders enabled modules, disabled modules are absent, clicking navigates |
| Registratura CRUD | `e2e/registratura.spec.ts` | Create, read, update, delete registry entries |
| Tag management | `e2e/tag-manager.spec.ts` | Create tag, apply to entity, filter by tag |
| Feature flags | `e2e/feature-flags.spec.ts` | Toggle flag off, verify module disappears from sidebar and route returns 404 |
| Email Signature | `e2e/email-signature.spec.ts` | Generate signature, preview renders, copy to clipboard |
| Data export/import | `e2e/data-export.spec.ts` | Export data as JSON, clear storage, import, verify data restored |

### E2E Test Example

```typescript
// e2e/registratura.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Registratura', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/registratura');
  });

  test('creates a new registry entry', async ({ page }) => {
    await page.getByRole('button', { name: /adauga/i }).click();

    await page.getByLabel(/titlu/i).fill('Cerere CU - Casa Test');
    await page.getByLabel(/tip/i).selectOption('incoming');
    await page.getByRole('button', { name: /salveaza/i }).click();

    await expect(page.getByText('Cerere CU - Casa Test')).toBeVisible();
  });

  test('edits an existing entry', async ({ page }) => {
    // Create an entry first
    await page.getByRole('button', { name: /adauga/i }).click();
    await page.getByLabel(/titlu/i).fill('Original Title');
    await page.getByLabel(/tip/i).selectOption('incoming');
    await page.getByRole('button', { name: /salveaza/i }).click();

    // Edit it
    await page.getByRole('button', { name: /editeaza/i }).first().click();
    await page.getByLabel(/titlu/i).clear();
    await page.getByLabel(/titlu/i).fill('Updated Title');
    await page.getByRole('button', { name: /salveaza/i }).click();

    await expect(page.getByText('Updated Title')).toBeVisible();
    await expect(page.getByText('Original Title')).not.toBeVisible();
  });

  test('deletes an entry', async ({ page }) => {
    // Create an entry first
    await page.getByRole('button', { name: /adauga/i }).click();
    await page.getByLabel(/titlu/i).fill('To Be Deleted');
    await page.getByLabel(/tip/i).selectOption('incoming');
    await page.getByRole('button', { name: /salveaza/i }).click();

    // Delete it
    await page.getByRole('button', { name: /sterge/i }).first().click();
    await page.getByRole('button', { name: /confirma/i }).click();

    await expect(page.getByText('To Be Deleted')).not.toBeVisible();
  });
});
```

### Feature Flag E2E Test

```typescript
// e2e/feature-flags.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Feature Flags', () => {
  test('disabled module is absent from sidebar', async ({ page }) => {
    // Set feature flag to disabled via localStorage before navigation
    await page.goto('/');
    await page.evaluate(() => {
      localStorage.setItem(
        'architools.flags',
        JSON.stringify({ 'module.registratura': false })
      );
    });
    await page.reload();

    const sidebar = page.getByRole('navigation');
    await expect(sidebar.getByText('Registratura')).not.toBeVisible();
  });

  test('disabled module route returns 404 or redirects', async ({ page }) => {
    await page.goto('/');
    await page.evaluate(() => {
      localStorage.setItem(
        'architools.flags',
        JSON.stringify({ 'module.registratura': false })
      );
    });

    await page.goto('/registratura');

    // Should show a "module not found" or redirect to dashboard
    await expect(page.getByText(/nu a fost gasit/i)).toBeVisible();
  });
});
```

---

## Test Utilities

### Mock Storage Service

```typescript
// src/test/mocks/storage.ts

import { vi } from 'vitest';

export function createMockStorageService() {
  const store = new Map<string, string>();

  return {
    getItem: vi.fn((key: string) => store.get(key) ?? null),
    setItem: vi.fn((key: string, value: string) => { store.set(key, value); }),
    removeItem: vi.fn((key: string) => { store.delete(key); }),
    clear: vi.fn(() => store.clear()),
    getAllKeys: vi.fn(() => Array.from(store.keys())),
  };
}
```

### Mock Feature Flag Provider

```typescript
// src/test/mocks/feature-flags.ts

import React from 'react';
import { FeatureFlagContext } from '@/lib/feature-flags/context';

interface MockFlagProviderProps {
  flags?: Record<string, boolean>;
  children: React.ReactNode;
}

export function MockFeatureFlagProvider({
  flags = {},
  children,
}: MockFlagProviderProps) {
  const allEnabled = new Proxy(flags, {
    get: (target, prop: string) => target[prop] ?? true,
  });

  return (
    <FeatureFlagContext.Provider value={allEnabled}>
      {children}
    </FeatureFlagContext.Provider>
  );
}
```

### Test Data Factories

```typescript
// src/test/factories.ts

import { v4 as uuid } from 'uuid';
import type { Tag } from '@/types/tags';

export function createTestEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
  return {
    id: uuid(),
    title: 'Test Entry',
    type: 'incoming',
    tagIds: [],
    visibility: 'all',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    ...overrides,
  };
}

export function createTestTag(overrides: Partial<Tag> = {}): Tag {
  return {
    id: uuid(),
    label: 'Test Tag',
    category: 'custom',
    scope: 'global',
    color: '#6366f1',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    ...overrides,
  };
}

export function createTestContact(overrides: Partial<Contact> = {}): Contact {
  return {
    id: uuid(),
    name: 'Ion Popescu',
    email: 'ion@example.com',
    phone: '+40 712 345 678',
    tagIds: [],
    visibility: 'all',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    ...overrides,
  };
}
```

### Test Providers Wrapper

```typescript
// src/test/providers.tsx

import React from 'react';
import { MockFeatureFlagProvider } from './mocks/feature-flags';

interface TestProvidersProps {
  children: React.ReactNode;
  flags?: Record<string, boolean>;
}

export function TestProviders({ children, flags }: TestProvidersProps) {
  return (
    <MockFeatureFlagProvider flags={flags}>
      {children}
    </MockFeatureFlagProvider>
  );
}
```

---

## Coverage Targets

| Layer | Target | Rationale |
|---|---|---|
| Services | 90%+ | Services contain all business logic and data access. Bugs here corrupt data. |
| Hooks | 80%+ | Hooks orchestrate service calls and manage state. Most logic is delegated to services. |
| Components | 70%+ | Component tests cover interactive behavior. Layout and styling are not tested. |
| Utilities | 95%+ | Pure functions with clear inputs/outputs. Easy to test exhaustively. |
| Overall | 75%+ | Weighted average across all layers. |

Coverage is measured with V8 via Vitest and reported in CI. Coverage gates are advisory, not blocking, during the initial build-out phase. They become blocking once the codebase stabilizes.

---

## CI Integration

Tests run on every pull request. The pipeline:

```yaml
# .github/workflows/test.yml (or equivalent CI config)

steps:
  - name: Install dependencies
    run: npm ci

  - name: Type check
    run: npx tsc --noEmit

  - name: Lint
    run: npm run lint

  - name: Unit + Component + Integration tests
    run: npx vitest run --coverage

  - name: E2E tests
    run: npx playwright test

  - name: Upload coverage
    run: # upload lcov report to coverage service
```

**Rules:**
- All tests must pass before a PR can be merged.
- Coverage regressions (dropping below target) produce a warning, not a blocking failure.
- E2E tests run against a production build (`npm run build && npm start`) in CI, not against the dev server.
- Flaky tests are quarantined (moved to a `*.flaky.test.ts` suffix) and tracked for repair. They do not block the pipeline.

---

## Testing Anti-Patterns

These patterns are explicitly avoided in ArchiTools tests.

### 1. Testing Implementation Details

**Bad:** Asserting on internal state, private methods, or specific DOM structure.

```typescript
// BAD: testing internal state
expect(component.state.isOpen).toBe(true);

// BAD: testing CSS classes
expect(container.querySelector('.modal-active')).toBeTruthy();
```

**Good:** Assert on what the user sees or what the API returns.

```typescript
// GOOD: testing visible behavior
expect(screen.getByRole('dialog')).toBeVisible();
```

### 2. Snapshot Testing for Components

Snapshot tests are brittle and provide low signal. A single class name change breaks the snapshot, and reviewers rubber-stamp snapshot updates. Do not use `toMatchSnapshot()` or `toMatchInlineSnapshot()` for component output.

### 3. Mocking Everything

Over-mocking eliminates the value of the test. If a service test mocks the storage adapter, the formatter, and the validator, it is testing nothing but the function's wiring.

**Rule:** Mock at the boundary. For service tests, mock storage. For hook tests, mock the service. For component tests, mock the hook. One layer deep, no more.

### 4. Testing the Framework

Do not test that React renders a component, that `useState` works, or that `useEffect` fires. Test your logic, not React's.

```typescript
// BAD: testing that React works
it('renders without crashing', () => {
  render(<MyComponent />);
});
```

This test passes for an empty `<div>` and catches nothing useful. Test specific behavior instead.

### 5. Coupling Tests to Data Order

Tests that depend on array order without explicitly sorting are fragile. If the service returns entries sorted by date and the test asserts on `entries[0].title`, it will break when a second entry has the same timestamp.

**Rule:** Either sort explicitly in the test or use `expect.arrayContaining` / `toContainEqual`.

### 6. Not Cleaning Up State

Tests that write to localStorage (or any shared state) without clearing it in `beforeEach` will produce order-dependent failures. The test setup file clears localStorage globally, but custom state (e.g., module-level caches) must be reset in the test's own `beforeEach`.

### 7. Giant E2E Tests

A single E2E test that creates 10 entities, edits 5, deletes 3, and checks a report is slow, fragile, and hard to debug. Keep E2E tests focused on one flow. Use `test.beforeEach` to set up preconditions via API/localStorage rather than through the UI.

### 8. Ignoring Async Behavior

```typescript
// BAD: not waiting for async updates
const { result } = renderHook(() => useRegistry());
expect(result.current.entries).toHaveLength(1); // may be empty -- hook hasn't loaded yet

// GOOD: wait for the async operation
await waitFor(() => {
  expect(result.current.entries).toHaveLength(1);
});
```
