# Phase 8: Tests for Phases 6–7 (LLM Pipeline)

<!-- STATUS: COMPLETE -->

## Phase Complete

**All 11 tasks completed successfully.**

### Test Files Created
- `tests/conftest.py` - 6 new helper functions added
- `tests/test_conftest_helpers.py` - 26 tests for helpers
- `tests/test_llm_exceptions.py` - 29 tests
- `tests/test_llm_retry.py` - 56 tests
- `tests/test_llm_context.py` - 48 tests
- `tests/test_llm_provider.py` - 20 tests
- `tests/test_llm_briefing.py` - 29 tests
- `tests/test_mission_schemas.py` - 20 tests
- `tests/test_mission_context.py` - 27 tests
- `tests/test_mission_validation_filerefs.py` - 16 tests
- `tests/test_mission_validation_prereqs.py` - 8 tests
- `tests/test_mission_generator.py` - 8 tests
- `tests/test_mission_pipeline.py` - 11 tests

**Total: ~298 new tests**

### Summary of Tests by Module
- **Exception hierarchy**: 29 tests covering inheritance, attributes, message formatting, polymorphic catching
- **Retry mechanism**: 56 tests covering public function and private helpers, property-based backoff timing
- **Context builder**: 48 tests covering all 7 sections, token budget enforcement, property-based budget invariant
- **Provider factory**: 20 tests covering happy path, case insensitivity, unknown providers, missing API keys
- **Briefing schemas/prompt/generator**: 29 tests covering schema validation, prompt rendering, generator happy/error paths
- **Mission schemas**: 20 tests covering all 4 schema types
- **Mission context builder**: 27 tests covering category-specific context, module descriptions
- **File reference validation**: 16 tests covering path validation, line clamping, property-based clamping invariant
- **Prerequisite validation**: 8 tests covering cycle detection, depth enforcement, root guarantee
- **Mission generator**: 8 tests covering happy path, auth errors, transient errors
- **Mission pipeline**: 11 tests covering full pipeline, partial results, auth stop, category ordering

### Verification
- Ruff linting passes on all test files
- No implementation bugs found during test generation

## Goal

Write unit and property-based tests for all public functions from Phases 6 and 7. After this phase, the LLM integration layer (provider factory, retry mechanism, context builders, briefing generation) and mission generation pipeline (schemas, context, validation, generator, pipeline orchestrator) all have verified behavior with documented edge cases and failure modes. All LLM calls are mocked — no real API calls.

## This Is a Test Phase

**Important:** This phase must be executed in a **new session** with context isolation from the implementation phases. Tests are written from interface contracts in Phase 6 and 7 `plan.md` files — not from reading implementation code. This ensures tests catch implementation errors rather than mirroring them.

## Dependencies

- **Phase 6** — `llm/exceptions.py`, `llm/retry.py`, `llm/context.py`, `llm/providers/factory.py`, `llm/briefing/schemas.py`, `llm/briefing/prompts.py`, `llm/briefing/generator.py`
- **Phase 7** — `llm/missions/schemas.py`, `llm/missions/context.py`, `llm/missions/prompts.py`, `llm/missions/generator.py`, `llm/missions/validation.py`, `llm/missions/pipeline.py`
- **dev dependencies** — `pytest`, `pytest-cov`, `hypothesis` (all already in `pyproject.toml`)
- **Phase 5** — Existing test infrastructure in `tests/conftest.py` (fixtures: `db_conn`, `all_repos`, `sample_campaign`, helpers: `make_file_info`, `make_parsed_file`)

## Test Rules (from Phase 5, mandatory)

1. **All fields asserted** — for any model/object returned, assert ALL fields, not just the ones relevant to the scenario
2. **Exact count assertions** — use `== N` not `>= N` or `len(result) > 0`
3. **Negative test ratio** — minimum 1 error/edge case test per 2 happy path tests
4. **"Would This Fail?" self-check** — after each test, ask: if the implementation returned wrong data, would this test catch it?
5. **No mirror tests** — assert against independently-determined expected values, never re-implement the production calculation
6. **No generic assertions** — `assert result is not None` alone is never acceptable

## Isolation Strategy

- **Exception hierarchy, retry logic, context builders, validation**: Pure unit tests, zero IO
- **Provider factory**: Mock `init_chat_model` from langchain — no real API keys or network calls
- **Briefing/mission generators**: Mock `BaseChatModel` and `with_structured_output()` chain — no LLM calls
- **Pipeline**: Mock `generate_category_missions` and `validate_category_missions` — test orchestration logic, not LLM
- **File validation**: Use `pytest`'s `tmp_path` fixture for real filesystem checks (line counting, path existence)
- **All LangChain imports**: Lazy imports may fail in test environment — mock at the module level where needed

## Research Notes

### Mocking LangChain Models

The LLM layer uses `BaseChatModel` from `langchain_core`. For tests, create a mock model:

```python
from unittest.mock import MagicMock, patch

mock_model = MagicMock()
mock_structured = MagicMock()
mock_model.with_structured_output.return_value = mock_structured
mock_structured.invoke.return_value = BriefingResponse(...)
```

For `create_chat_model`, mock the `init_chat_model` import:
```python
with patch("unfamiliar.llm.providers.factory.init_chat_model") as mock_init:
    mock_init.return_value = mock_model
    result = create_chat_model(config)
```

### Mocking the Retry Wrapper

`with_llm_retry(fn, *args)` calls `fn(*args)` with retry logic. Tests can pass a regular function that raises on first N calls:

```python
call_count = 0
def flaky_fn():
    nonlocal call_count
    call_count += 1
    if call_count < 3:
        raise SomeTransientError()
    return "success"
```

For timing tests, mock `time.sleep` to avoid real delays.

### Building AnalysisResult for Tests

Reuse the existing `make_file_info` and `make_parsed_file` helpers from `conftest.py`. For a minimal `AnalysisResult`:

```python
from unfamiliar.analysis.models import (
    AnalysisResult, ImportGraph, DetectedModule, TestInfo,
    ScoredFile, EntryPoint, SecurityResult
)

def make_analysis_result(**overrides) -> AnalysisResult:
    defaults = {
        "files": [], "parsed_files": {}, "import_graph": ImportGraph(...),
        "modules": [], "test_info": TestInfo(...), "scored_files": [],
        "entry_points": [], "languages": {}, "repo_path": "/tmp/repo",
        "security": SecurityResult(...)
    }
    defaults.update(overrides)
    return AnalysisResult(**defaults)
```

### Property-Based Testing Strategy

Use Hypothesis `@given` decorator with custom strategies for:
- Mission sets with random prerequisite graphs (for cycle/depth/root/reachability)
- File references with random line numbers (for clamping validation)
- Retry backoff timing verification

Settings: `@settings(max_examples=100)` for CI speed.

### Interface Contracts Being Tested

From Phase 6 plan:
- `create_chat_model(config) -> BaseChatModel`
- `build_briefing_context(analysis, token_budget) -> str`
- `generate_briefing(analysis, model, token_budget) -> BriefingResult`
- `with_llm_retry(fn, *args, max_attempts, base_delay, **kwargs) -> T`
- Exception hierarchy: `LLMError`, `ProviderError`, `AuthenticationError`, `BriefingGenerationError`, `TokenBudgetExceededError`

From Phase 7 plan:
- `generate_category_missions(analysis, briefing_result, category, model, previous_missions, token_budget) -> CategoryMissionsResponse`
- `validate_file_refs(file_refs, repo_path) -> tuple[list[MissionFileRefResponse], list[str]]`
- `validate_prerequisites(missions, category) -> tuple[list[MissionItemResponse], list[str], list[str]]`
- `validate_category_missions(response, repo_path, category, chains) -> ValidationResult`
- `run_mission_pipeline(analysis, briefing_result, model, campaign_dir, token_budget) -> PipelineResult`
- `build_mission_context(analysis, briefing_result, category, previous_missions, token_budget) -> str`
- `build_module_descriptions(briefing_result) -> str`

### Property-Based Testing Candidates

**Phase 6 candidates:**
1. `build_briefing_context` token budget: estimated tokens of included key file contents never exceed `token_budget`
2. `build_briefing_context` completeness: every file in `scored_files` appears as full content or signature
3. Retry backoff timing: delay between attempt N and N+1 is always `>= base_delay * 2^N` and `<= base_delay * 2^N + 1.0`

**Phase 7 candidates:**
1. `validate_prerequisites` cycle detection: output never contains cycles (random DAGs with injected cycles)
2. `validate_prerequisites` depth enforcement: max depth in output <= `MAX_PREREQUISITE_DEPTH`
3. `validate_prerequisites` root guarantee: at least `MIN_ROOT_MISSIONS_PER_CATEGORY` roots in output
4. `validate_prerequisites` reachability: every mission reachable from at least one root
5. `validate_file_refs` line clamping: output always `1 <= line_start <= line_end <= file_line_count`
6. `validate_file_refs` idempotence: re-validating validated refs produces identical output

---

## Tasks

### Task 1: LLM test fixtures and helpers

**Files:** `tests/conftest.py` (add to existing)
**Action:** Add shared fixtures and helpers for LLM testing:
- `make_analysis_result(**overrides) -> AnalysisResult` — factory for minimal valid AnalysisResult with sensible defaults (empty lists, stub ImportGraph/TestInfo/SecurityResult)
- `make_briefing_result(**overrides) -> BriefingResult` — factory for minimal valid BriefingResult with BriefingResponse and ModuleDescription list
- `make_mission_item(**overrides) -> MissionItemResponse` — factory for a single mission with defaults (title, title_plain, difficulty="quick", empty prerequisite_titles, etc.)
- `make_category_response(missions, chains=None) -> CategoryMissionsResponse` — wrapper
- `mock_chat_model(response) -> MagicMock` — creates a mock BaseChatModel where `with_structured_output().invoke()` returns the given response
- `sample_config() -> UnfamiliarConfig` — minimal config with `provider="anthropic"` and dummy API key
**Verify:** `pytest --collect-only` sees the new fixtures. Ruff passes.
**Done when:** All helpers are importable and produce valid typed objects.

---

### Task 2: Exception hierarchy tests

**Files:** `tests/test_llm_exceptions.py`
**Action:** Test all 5 exception classes from `llm/exceptions.py`:
- All 5 exceptions (`ProviderError`, `AuthenticationError`, `BriefingGenerationError`, `MissionGenerationError`, `TokenBudgetExceededError`) inherit from `LLMError`
- `LLMError` inherits from `Exception`
- `BriefingGenerationError` stores `attempts` attribute, accessible after construction
- `MissionGenerationError` stores `category` and `attempts` attributes, accessible after construction
- Each exception can be caught via `except LLMError` (polymorphic catch)
- Exception `str()` includes the message string passed to constructor
- Default attribute values: `MissionGenerationError(msg)` has `category=""` and `attempts=1`
- Attribute values when provided: `MissionGenerationError(msg, category="architecture", attempts=3)` stores both

**Verify:** `pytest tests/test_llm_exceptions.py -v` — all pass. Ruff passes.
**Done when:** Full hierarchy verified. Attribute storage confirmed for both generation errors.

---

### Task 3: Retry mechanism tests

**Files:** `tests/test_llm_retry.py`
**Action:** Test `with_llm_retry()` and its private helpers from `llm/retry.py`. Mock `time.sleep` to avoid real delays.

**Public function tests:**
- Happy path: function succeeds on first call → immediate return, `time.sleep` never called
- Transient then success: function raises 429 twice, succeeds third → returns result, `time.sleep` called twice
- Auth error (401) → raises `AuthenticationError` immediately, no retry, no sleep
- Auth error (403) → same immediate-raise behavior
- Max attempts exhausted (all transient) → raises the last caught exception
- `max_attempts=1` → single attempt, no retries, no sleep
- Non-HTTP errors (e.g., `ValueError`, JSON parse error) → retried as transient
- Connection errors (e.g., `ConnectionError`, timeout) → retried as transient

**Backoff timing tests (mock `time.sleep`):**
- Verify first delay is `base_delay * 2^0` + jitter (jitter in [0, 1))
- Verify second delay is `base_delay * 2^1` + jitter
- Verify custom `base_delay=2.0` produces correspondingly larger delays

**Private helper tests:**
- `_is_auth_error`: returns True for exceptions with status_code 401 or 403, False for 429/500
- `_is_auth_error`: returns True for exceptions with "unauthorized" or "invalid api key" in message
- `_is_transient_error`: returns True for 429, 500, 502, 503, 504 status codes
- `_is_transient_error`: returns True for `ConnectionError`, `TimeoutError`
- `_extract_status_code`: extracts from `.status_code`, `.response.status_code`, `.code` attributes
- `_extract_status_code`: returns None for exceptions without status code
- `_calculate_delay`: returns `base_delay * 2^(attempt-1)` + jitter in [0, 1)

**Property-based:**
- For any `max_attempts` (1–10) and `base_delay` (0.1–5.0): delay between attempt N and N+1 is `>= base_delay * 2^N` and `<= base_delay * 2^N + 1.0`

**Verify:** `pytest tests/test_llm_retry.py -v` — all pass. Ruff passes.
**Done when:** All retry paths tested. Backoff formula verified. Auth/transient classification complete.

---

### Task 4: Briefing context builder tests

**Files:** `tests/test_llm_context.py`
**Action:** Test `build_briefing_context()` from `llm/context.py`:

- Empty analysis (no files, no modules) → returns valid string with section headers
- Analysis with files → includes repository overview with language counts
- Analysis with entry points → entry points section lists files and line numbers
- Analysis with modules → module structure section with file lists and category hints
- Analysis with scored files and file contents → key file contents section included
- Token budget enforcement: scored files with large content → stays within budget (estimated tokens ≤ budget)
- Token budget of 0 → no file contents, only structural summary
- Large single file exceeding budget → file is truncated
- Files not included as content appear as signatures (path + definition names)
- Security findings present → security section populated with findings
- No security findings → section notes "no issues" or equivalent
- Test coverage data present → test coverage section populated
- Import graph with hub files → import graph section lists hub files with in-degree

**Property-based:**
- For any AnalysisResult and positive token_budget: estimated token count of included key file contents ≤ token_budget
- For any AnalysisResult: every file in scored_files either appears as full content or as a signature — no files silently dropped

**Verify:** `pytest tests/test_llm_context.py -v` — all pass. Ruff passes.
**Done when:** All 7 sections tested. Budget enforcement verified with property-based tests.

---

### Task 5: Provider factory tests

**Files:** `tests/test_llm_provider.py`
**Action:** Test `create_chat_model()` from `llm/providers/factory.py`. Mock `init_chat_model` and `resolve_api_key`.

- Valid "anthropic" provider → returns model, `init_chat_model` called with `model_provider="anthropic"`, temperature=0.2, max_tokens=4096, max_retries=2
- Valid "glm" provider → returns model, `init_chat_model` called with correct `model_provider` for Zhipu
- Provider name case-insensitive: "Anthropic", "ANTHROPIC", "GLM" → all resolve correctly
- Unknown provider string (e.g., "openai") → raises `ProviderError` with provider name in message
- Missing API key (`resolve_api_key` returns None) → raises `ProviderError` with clear "No API key" message
- `init_chat_model` raises `ImportError` → raises `ProviderError` with "Failed to initialize" message
- API key passed to `init_chat_model` as `api_key=` kwarg — verify the mock received it

**Verify:** `pytest tests/test_llm_provider.py -v` — all pass. Ruff passes.
**Done when:** All provider paths tested. Error messages verified for each failure mode.

---

### Task 6: Briefing schemas, prompt, and generator tests

**Files:** `tests/test_llm_briefing.py`
**Action:**

**Schema tests (BriefingResponse, ModuleDescription):**
- Valid `BriefingResponse` construction — assert all fields: `summary`, `tech_stack`, `security_posture`, `modules`
- Valid `ModuleDescription` — assert all fields: `name`, `description`, `security_relevant`
- `BriefingResponse` with empty `tech_stack` → valid
- `BriefingResponse` with multiple `ModuleDescription` entries → all accessible

**Prompt template tests (BRIEFING_PROMPT):**
- `BRIEFING_PROMPT` renders without error when invoked with `context` and `module_count` variables
- Rendered messages include system and human message types
- System message contains role definition keywords (e.g., "code analyst" or "briefing")
- Human message contains the injected `{context}` value

**Generator tests (generate_briefing):**
- Happy path: mock model returns valid `BriefingResponse` → returns `BriefingResult` with all 3 fields
- `context_tokens_estimate` equals `len(context) // CHARS_PER_TOKEN` (verify against known context size)
- Module count in `BriefingResult.modules` matches number of `DetectedModule` entries in input
- Module count mismatch (LLM returns fewer modules) → padded with defaults (names from primary paths)
- Auth error from model → raises `AuthenticationError` (not wrapped in `BriefingGenerationError`)
- Transient error then success → retries and returns valid result
- Permanent failure after retries → raises `BriefingGenerationError` with `attempts` attribute set
- Very small codebase (< 5 files, 1 module) → still produces valid briefing
- Empty modules in analysis → `BriefingResult.modules` is empty list, still valid

**Verify:** `pytest tests/test_llm_briefing.py -v` — all pass. Ruff passes.
**Done when:** Schemas validated. Prompt renders. Generator covers happy/error/edge paths.

---

### Task 7: Mission schemas and prompt tests

**Files:** `tests/test_mission_schemas.py`
**Action:**

**Schema tests:**
- Valid `MissionFileRefResponse` — assert all fields: `file_path`, `line_start`, `line_end`, `description`
- `MissionFileRefResponse` with `line_start=None`, `line_end=None` → valid (whole-file reference)
- Valid `MissionItemResponse` — assert ALL fields: `title`, `title_plain`, `description`, `difficulty`, `completion_condition`, `security_tag`, `standard_tag`, `narrative_intro`, `narrative_outro`, `file_refs`, `module_index`, `prerequisite_titles`
- Difficulty values: "quick", "medium", "deep_dive" all accepted
- `standard_tag=None` → valid (optional field)
- Empty `prerequisite_titles` → valid (root mission)
- Empty `file_refs` → valid
- Valid `ChainResponse` — assert all fields: `name`, `name_plain`, `description`, `mission_titles`
- Valid `CategoryMissionsResponse` — assert `missions` list and `chains` list
- `CategoryMissionsResponse` with empty `chains` → valid

**Prompt template tests (MISSION_PROMPT):**
- `MISSION_PROMPT` renders without error with all expected variables: `category`, `briefing_summary`, `category_context`, `previous_missions`, `module_descriptions`
- Rendered system message contains Film Noir theming instructions
- Rendered system message contains difficulty guidelines (quick/medium/deep_dive)
- Rendered human message includes the injected `{category}` value

**Verify:** `pytest tests/test_mission_schemas.py -v` — all pass. Ruff passes.
**Done when:** All schema fields validated. Prompt template renders with all variables.

---

### Task 8: Mission context builder tests

**Files:** `tests/test_mission_context.py`
**Action:** Test `build_mission_context()` and `build_module_descriptions()` from `llm/missions/context.py`:

**`build_mission_context` tests:**
- ARCHITECTURE category → output includes architecture-relevant keywords/sections
- TESTING category → output includes test coverage information
- TECHNICAL_DEBT category → output includes debt-relevant content
- DEPENDENCIES category → output includes dependency-related content
- With `previous_missions=["Mission A", "Mission B"]` → output lists previous mission titles
- With `previous_missions=[]` (first category) → no previous missions section in output
- With modules having `category_hints` matching current category → those modules included in output
- With modules having no matching `category_hints` → still produces valid output (uses broader context)
- Token budget per-category → context stays within `budget / 7` allocation

**`build_module_descriptions` tests:**
- With 3 modules → output contains indexed list (0, 1, 2) with names and descriptions
- With empty modules list → returns empty or minimal string
- Module descriptions include `security_relevant` flag information

**Verify:** `pytest tests/test_mission_context.py -v` — all pass. Ruff passes.
**Done when:** All category-specific contexts verified. Module descriptions indexed correctly.

---

### Task 9: File reference validation tests

**Files:** `tests/test_mission_validation_filerefs.py`
**Action:** Test `validate_file_refs()` from `llm/missions/validation.py`. Use `tmp_path` fixture for filesystem.

**Happy path:**
- Empty `file_refs` → returns `([], [])` — empty list, no warnings
- Valid file paths (create real files in `tmp_path`) → returns refs unchanged, no warnings
- Multiple valid refs → all returned in order

**Error/edge cases:**
- Non-existent file path → removed from output, warning generated mentioning the path
- Path traversal attempt (`../../../etc/passwd`) → removed, warning about traversal
- `line_start > line_end` → swapped (auto-fix), warning logged about swap
- `line_start > file_line_count` → clamped to last line of file
- `line_start = 0` → set to 1 (1-indexed auto-fix)
- `line_end > file_line_count` → clamped to file_line_count
- `line_start=None, line_end=None` → passed through unchanged (whole-file ref)
- All file paths invalid → returns empty list with one warning per invalid path
- Binary/unreadable file → skipped with warning

**Property-based:**
- **Line clamping invariant:** For any `line_start` (0–1000), `line_end` (0–1000), and a file with N lines (1–100): output always satisfies `1 <= line_start <= line_end <= N`
- **Idempotence:** Validating an already-validated set of file refs produces identical output with no new warnings

**Verify:** `pytest tests/test_mission_validation_filerefs.py -v` — all pass. Ruff passes.
**Done when:** All file ref validation paths tested. Line clamping invariant proven by property test.

---

### Task 10: Prerequisite validation and orchestrator tests

**Files:** `tests/test_mission_validation_prereqs.py`
**Action:** Test `validate_prerequisites()` and `validate_category_missions()` from `llm/missions/validation.py`.

**`validate_prerequisites` tests:**
- No prerequisites (all root missions) → unchanged, no warnings
- Valid linear DAG (A→B→C, depth 3) → unchanged, valid
- Simple cycle (A→B→A) → one edge removed, warning about cycle break
- Complex cycle (A→B→C→A) → cycle broken, warning
- Self-referencing prerequisite (A→A) → edge removed
- Depth exceeds `MAX_PREREQUISITE_DEPTH` (4 levels deep) → trimmed to ≤ 3
- Depth exactly at limit (3) → unchanged
- No root missions (all have prerequisites) → quickest-difficulty mission promoted to root, warning
- Single mission → always valid (root by definition), no warnings
- Empty missions list → returns empty list, warning "no missions"
- Non-existent prerequisite title → removed, warning about missing reference
- Unreachable missions (disconnected from DAG) → made roots, warning
- Multiple issues combined (cycle + depth + no root) → all fixed in sequence

**Property-based (use `make_mission_item` helper to generate random sets):**
- **No cycles:** For any mission set with random `prerequisite_titles` drawn from the set's `title_plain` values, output never contains a cycle (verify via DFS)
- **Max depth:** For any mission set, max prerequisite chain depth in output ≤ `MAX_PREREQUISITE_DEPTH` (3)
- **Root guarantee:** For any non-empty mission set, at least `MIN_ROOT_MISSIONS_PER_CATEGORY` (1) missions have empty `prerequisite_titles`
- **Reachability:** For any non-empty mission set, every mission is reachable from at least one root via prerequisite chains

**`validate_category_missions` orchestrator tests:**
- Valid response (good files, good prereqs, good chains) → `valid=True`, empty warnings, empty fixes
- Response with invalid file refs → `valid=True` after fix, warnings present, missions kept with cleaned refs
- Response with cycles in prerequisites → `valid=True` after fix, `fixes_applied` lists the cycle break
- Empty mission list → `valid=False`, warning about no missions for category
- Chain references non-existent mission title → chain dropped, warning
- Chain with < 2 valid mission references → chain dropped, warning
- Full orchestration: mix of bad file refs + cycle + missing prereq ref → all fixed, comprehensive warnings and fixes lists
- `ValidationResult` fields: verify `missions`, `chains`, `warnings`, `fixes_applied`, `valid` are all populated correctly

**Verify:** `pytest tests/test_mission_validation_prereqs.py -v` — all pass including property-based. Ruff passes.
**Done when:** All prerequisite validation paths tested. 4 property-based invariants verified. Orchestrator covers compound scenarios.

---

### Task 11: Mission generator and pipeline tests

**Files:** `tests/test_mission_generator.py`, `tests/test_mission_pipeline.py`
**Action:**

**Generator tests (`test_mission_generator.py`):**
Mock `build_mission_context`, `build_module_descriptions`, and `with_llm_retry` (or mock the model chain).
- Happy path: mock model returns valid `CategoryMissionsResponse` → returned correctly with missions and chains
- `previous_missions` list passed through to `build_mission_context` call
- Auth error from model → raises `AuthenticationError` immediately (not wrapped)
- Transient error then success → retries via `with_llm_retry` and returns result
- Permanent failure → raises `MissionGenerationError` with `category` attribute set to category name
- `MissionGenerationError.attempts` reflects the number of attempts made
- LLM returns 0 missions (empty list) → raises `MissionGenerationError`
- Each `InvestigationCategory` value accepted → parameterized test across all 7 categories

**Pipeline tests (`test_mission_pipeline.py`):**
Mock `generate_category_missions` and `validate_category_missions` at the module level.
- Full pipeline: all 7 categories succeed → `PipelineResult` with `len(completed_categories) == 7`, `len(errors) == 0`
- `category_missions` dict has 7 keys matching `CATEGORY_ORDER` category values
- `category_validations` dict has same 7 keys as `category_missions`
- Single category fails (non-auth) → 6 completed, 1 error message in `errors`, pipeline continues
- First category fails → still tries remaining 6 categories
- Auth error on any category → pipeline stops immediately, returns partial results
- Categories processed in `CATEGORY_ORDER` (verify call order of mock)
- Previous mission titles accumulate: first category gets `[]`, second gets first category's mission titles, etc.
- Campaign dir used for checkpointing (verify file created in `tmp_path`)
- Checkpoint DB corrupted (bad file at checkpoint path) → falls back to in-memory, logs warning, still completes
- `PipelineResult` invariant: `len(completed_categories) + len(errors) <= 7`

**Verify:** `pytest tests/test_mission_generator.py tests/test_mission_pipeline.py -v` — all pass. Ruff passes.
**Done when:** Generator tested for all 7 categories. Pipeline orchestration covers full/partial/auth-stop scenarios.

---

## Test File Summary

| File | Approx Tests | Focus |
|------|-------------|-------|
| `tests/conftest.py` (additions) | — | LLM fixtures and helpers |
| `tests/test_llm_exceptions.py` | ~8 | Exception hierarchy and attributes |
| `tests/test_llm_retry.py` | ~15 | Retry mechanism, helpers, property-based backoff |
| `tests/test_llm_context.py` | ~15 | Briefing context builder sections + property-based budget |
| `tests/test_llm_provider.py` | ~7 | Provider factory |
| `tests/test_llm_briefing.py` | ~13 | Briefing schemas, prompt template, generator |
| `tests/test_mission_schemas.py` | ~12 | Mission schemas and prompt template |
| `tests/test_mission_context.py` | ~12 | Mission context builder per category |
| `tests/test_mission_validation_filerefs.py` | ~13 | File ref validation + property-based clamping |
| `tests/test_mission_validation_prereqs.py` | ~20 | Prereq validation, orchestrator, 4 property-based invariants |
| `tests/test_mission_generator.py` | ~8 | Per-category generator |
| `tests/test_mission_pipeline.py` | ~11 | Pipeline orchestrator |

**Estimated total: ~134 new tests** (+ ~260 existing = ~394 total)

---

## Security Checklist

### Test Safety
- [ ] No real API keys in test code — all mocked
- [ ] No network calls — all LLM interactions mocked
- [ ] No sensitive data in test fixtures
- [ ] Filesystem tests use `tmp_path` (isolated, auto-cleaned)
- [ ] Path traversal test verifies rejection, doesn't access real paths
