# Phase 5: Tests for Phases 2–4 (Data Layer and Static Analysis)

## Goal

Write unit and integration tests for all public functions from Phases 2, 3, and 4. After this phase, the data layer (models, connection, repositories), static analysis core (scoring, import graph, clustering, test detection), and TS/JS adapters and security scanning all have verified behavior with documented edge cases and failure modes. Specifically: confirm and document the `create_many()` duplicate path fragility noted in `decisions.md`.

## 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 2, 3, and 4 `plan.md` files — not from reading implementation code. This ensures tests catch implementation errors rather than mirroring them.

Test phases are separate from implementation phases by design. See `_planning/phases/02-data-layer/plan.md`, `_planning/phases/03-static-analysis-core/plan.md`, and `_planning/phases/04-static-analysis-adapters-security/plan.md` for the interface contracts to test against.

## Dependencies

- **Phase 2** — `db/connection.py`, `db/repositories/`, `models/` — everything tested in Tasks 2–4
- **Phase 3** — `analysis/scoring.py`, `analysis/graph/`, `analysis/testing.py`, `analysis/adapters/python_adapter.py` — tested in Tasks 5–7
- **Phase 4** — `analysis/adapters/_treesitter_utils.py`, `analysis/adapters/typescript_adapter.py`, `analysis/adapters/javascript_adapter.py`, `analysis/security/` — tested in Tasks 8–10
- **dev dependencies** — `pytest`, `pytest-cov`, `hypothesis` (all already in `pyproject.toml`)

## Test Rules (from `my-style/references/testing.md`)

These rules are 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

- **Business logic** (scoring, graph analysis, tree-sitter parsing, model validation): pure unit tests, zero IO
- **Repository layer**: use `sqlite3.connect(":memory:")` — no disk, no external state. `init_database()` is safe on `:memory:` (its `chmod` step is a no-op when PRAGMA database_list returns `""`)
- **`open_database` / `init_database` integration tests**: use `pytest`'s `tmp_path` fixture (isolated temp dir, cleaned up after test)
- **Security scanning**: mock `subprocess.run` and `detect_secrets` imports with `unittest.mock.patch`

## Research Notes

### Property-Based Testing (Hypothesis)

Hypothesis is already in `pyproject.toml` (`hypothesis>=6.0`). The `@given` / `st` strategy API is stable. Import: `from hypothesis import given, strategies as st`.

For strategies:
- `st.sampled_from(list(InvestigationCategory))` — generates enum values
- `st.lists(st.sampled_from(...))` — generates lists of enum values
- `st.integers(min_value=0)` — generates non-negative integers
- `st.text(alphabet=st.characters(whitelist_categories="L"))` — generates valid text
- `st.builds(...)` — generates model instances from strategies

Settings for CI/speed: `from hypothesis import settings; @settings(max_examples=100)`.

### Tree-sitter in Tests

Tests for tree-sitter utilities need to parse real JS/TS code. Use the same Language objects the adapters use:
- `from tree_sitter import Parser, Language`
- `import tree_sitter_javascript as tsjs; JS_LANG = Language(tsjs.language())`
- `import tree_sitter_typescript as tsts; TS_LANG = Language(tsts.language_typescript())`

Parse source snippets as bytes: `parser.parse(source.encode())`. Access the root node via `tree.root_node`.

### Mocking detect-secrets

The `scan_for_secrets` function imports `detect_secrets` inside the module. To test graceful degradation:
```python
with patch.dict("sys.modules", {"detect_secrets": None}):
    ...
```

For happy-path tests, mock `SecretsCollection` and its `scan_file` / `json()` methods.

### create_many() Fragility (from decisions.md)

`ModuleRepository.create_many()` re-fetches inserted modules by `path` to assign ids. If two modules in the same campaign share the same `path`, the id assignment silently maps wrong results. This must be tested and documented:

```python
def test_create_many_duplicate_path_failure_mode():
    # Two modules with same path — documents the fragility
    # Assert the known failure: which module gets which id
```

This test documents behavior, not a bug to fix. See `_planning/decisions.md` for context.

---

## Tasks

### Task 1: Test infrastructure and fixtures

**Files:** `tests/__init__.py` (already exists), `tests/conftest.py`

**Action:** Create the shared fixtures file. Provide:

- `db_conn` fixture — `sqlite3.connect(":memory:")`, sets `PRAGMA foreign_keys=ON`, `row_factory=sqlite3.Row`, calls `init_database(conn)`. Yields connection, closes on teardown.
- `all_repos` fixture — depends on `db_conn`, returns a named tuple or dict with all 5 repository instances
- `sample_campaign` fixture — depends on `all_repos`, calls `campaign_repo.create(...)` with a minimal valid Campaign, returns the created Campaign with id
- `ts_parser` fixture — creates a tree-sitter Parser with `Language(tree_sitter_typescript.language_typescript())`, yields it
- `js_parser` fixture — creates a tree-sitter Parser with `Language(tree_sitter_javascript.language())`, yields it
- Helper: `parse_ts(source: str) -> Node` — parses TypeScript source string, returns root node (for use in tree-sitter tests)
- Helper: `parse_js(source: str) -> Node` — parses JavaScript source string, returns root node

**Verify:** `python -m pytest tests/conftest.py --collect-only` — fixtures visible to pytest

**Done when:** All fixtures importable and usable. No lint errors. The `db_conn` fixture creates a fully initialized in-memory database.

---

### Task 2: Pydantic model validation tests

**Files:** `tests/test_models.py`

**Action:** Test all Pydantic model validation from `src/unfamiliar/models/`. Cover every model from Phase 2's interface contracts.

Test cases:
- `Campaign`: valid construction with all defaults; `ValidationError` on invalid `status` enum; `ValidationError` on missing `repo_path`; all fields present on constructed model
- `Briefing`: valid construction; `ValidationError` on missing `campaign_id`; `tech_stack` as empty list; `tech_stack` with multiple entries
- `Module`: valid with empty `category_relevance`; valid with all 7 categories; `ValidationError` on invalid category enum value
- `Mission`: valid construction; all fields asserted including `time_estimate_minutes`, `status=LOCKED` by default; `ValidationError` on invalid `category`
- `MissionFileRef`: whole-file ref (both line fields None); single-line ref (`line_start == line_end`); `ValidationError` on missing `file_path`
- `MissionChain`, `FileSnapshot`: valid construction, all fields asserted
- Junction models: `ModuleMissionLink`, `ChainMissionLink`, `MissionPrerequisite` — valid construction

**Property-based test:**
```python
@given(categories=st.lists(st.sampled_from(list(InvestigationCategory))))
def test_category_relevance_json_roundtrip(categories):
    serialized = json.dumps([str(c) for c in categories])
    deserialized = [InvestigationCategory(v) for v in json.loads(serialized)]
    assert deserialized == categories
```

**Verify:** `ruff check tests/test_models.py`; `pytest tests/test_models.py -v`

**Done when:** All model validation and JSON round-trip behaviors are tested. Minimum ratio of 1 error test per 2 happy path tests.

---

### Task 3: Database connection tests

**Files:** `tests/test_db_connection.py`

**Action:** Test `open_database`, `init_database`, and `get_schema_version` from `src/unfamiliar/db/connection.py`. Uses `tmp_path` for file-based tests (these functions have inherent file IO).

Test cases:
- `open_database(tmp_path / "test.db")`: file created; connection has WAL mode; foreign keys enabled (`PRAGMA foreign_keys` returns 1); `row_factory` is `sqlite3.Row`
- `init_database(conn)`: after init, `get_schema_version(conn) == "1.0"`; calling twice raises no error (idempotent); meta table has exactly 1 schema_version row after double-init
- `get_schema_version(conn)` on fresh `:memory:` connection (no init): returns `None`
- `get_schema_version(conn)` after init: returns `"1.0"`
- `open_database(path_in_nonexistent_parent)` where parent can't be created: verify `DatabaseError` is raised (can simulate by using a read-only directory if available on CI, or mock `sqlite3.connect` to raise)

**Verify:** `ruff check tests/test_db_connection.py`; `pytest tests/test_db_connection.py -v`

**Done when:** Connection lifecycle is verified. Idempotency is confirmed by exact row count assertion, not a generic pass.

---

### Task 4: Repository tests

**Files:** `tests/test_repositories.py`

**Action:** Test all 5 repositories using the `db_conn` and `all_repos` fixtures from conftest (in-memory SQLite). Uses `sample_campaign` fixture for a pre-inserted Campaign with id.

**CampaignRepository:**
- `create`: all fields on returned Campaign match input + id is set + timestamps are non-None
- `get(existing_id)`: returns Campaign; `get(999)`: returns None
- `get_by_repo_path(existing_path)`: returns Campaign; `get_by_repo_path("nonexistent")`: returns None
- `update_status`: status changes to new value; re-fetch confirms change
- `create_briefing` + `get_briefing`: briefing returned with id; re-fetch matches all fields
- `update_briefing`: after update, re-fetch returns changed summary

**ModuleRepository:**
- `create`: id set, `category_relevance` preserved after round-trip
- `create_many`: correct number of modules returned with ids
- **`create_many` duplicate path failure mode** (from `decisions.md`): create two modules in the same campaign with identical `path` values; assert the failure behavior (second module overwrites first in the path-to-id mapping, or one is missing from results). Document with a comment: `# Known fragility: documented in decisions.md`
- `get_by_campaign`: returns all modules for campaign ordered by path
- `get_by_path`: returns correct module or None

**MissionRepository:**
- `create` + `get`: all fields
- `create_many` + `get_by_campaign`: exact count
- `get_by_status`: returns only missions with matching status
- `get_by_category`: returns only missions with matching category
- `update_status`: status + completion_method changes
- `add_file_refs` + `get_file_refs`: exact refs returned
- `add_prerequisites` + `get_prerequisites` / `get_dependents`: graph relationships
- `link_modules` + `get_linked_modules` + `get_missions_for_module`

**ChainRepository:**
- `create` + `get`; `create_many`
- `add_missions` + `get_chain_missions`: verify position ordering (not insertion order)
- `get_chains_for_mission`

**FileSnapshotRepository:**
- `create_many` + `get_by_campaign`: exact count
- `upsert`: insert then upsert with new hash, confirm hash changed; exact 1 row for same (campaign_id, file_path)

**Verify:** `ruff check tests/test_repositories.py`; `pytest tests/test_repositories.py -v`

**Done when:** All repository methods tested. `create_many` fragility confirmed and documented. All field assertions complete (no partial checks).

---

### Task 5: File scoring tests

**Files:** `tests/test_scoring.py`

**Action:** Test `score_files` and `select_key_files` from `src/unfamiliar/analysis/scoring.py`. Both are pure functions — zero IO.

**`score_files()`:**
- Empty `files` list: returns `[]`
- File with no scoring signals: `ScoredFile.score == 0`, no reasons
- Entry point file (in `entry_points`): score `== 100`, reasons include "entry point"
- Hub file (in_degree `== 3`): score `== 50`, reasons include "hub (imported by 3 files)"
- High complexity file (complexity `== 15`): score `== 30`, reasons include "complex"
- Test file (path in `test_info.test_files`): score `== 20`, reasons include "test file"
- File with all 4 signals: score `== 200` (additive), all 4 reason strings present
- Sort order: higher scores first; tie-break by path alphabetically
- All `ScoredFile` fields asserted: `path`, `score`, `reasons` (exact list contents)

**`select_key_files()`:**
- Empty `scored_files`: returns `[]`
- All files fit within budget: returns all
- First file alone exceeds budget: returns just that first file (at least 1 always selected)
- Budget exhausted after 2 files: returns exactly 2
- File with missing metadata (not in `files` dict): skipped without error

**Property-based test:**
```python
@given(st.lists(st.builds(FileInfo, path=st.text(min_size=1), language=st.just("python"),
                           size_bytes=st.integers(min_value=0), content_hash=st.just("abc"))))
def test_score_files_paths_preserved(file_infos):
    result = score_files(file_infos, {}, ImportGraph(), TestInfo(), [])
    assert {sf.path for sf in result} == {f.path for f in file_infos}
```

**Verify:** `ruff check tests/test_scoring.py`; `pytest tests/test_scoring.py -v`

**Done when:** All scoring signals tested independently and in combination. Property test confirms output paths always equal input paths.

---

### Task 6: Import graph and clustering tests

**Files:** `tests/test_import_graph.py`

**Action:** Test all public functions from `analysis/graph/import_graph.py` and `analysis/graph/clustering.py`. Pure functions — zero IO. For `detect_modules`, seed Python's `random` module to get deterministic results: `random.seed(42)`.

**`build_import_graph()`:**
- Empty `parsed_files`: returns graph with empty `edges`, empty `in_degree`
- File with no imports: in_degree `== 0` for all files
- A → B import: `"a.py"` in `edges`, `B` has in_degree `== 1`
- Self-import (A imports A): not added as edge; A's in_degree not incremented by self
- External import (resolver returns None): not added to edges

**`get_hub_files()`:**
- Empty graph: returns `[]`
- No files meet threshold (all in_degree < 3): returns `[]`
- One hub with in_degree `== 5`, one with `== 3`: returned in descending in_degree order

**`get_connected_components()`:**
- Single isolated file: 1 component of size 1
- 3 files with no edges: 3 components each of size 1
- A → B → C (chain): all 3 in 1 component
- Two disconnected groups: 2 components, each with correct membership

**`detect_modules()`:**
- Empty graph: returns `[]`
- 3 well-connected files: grouped into 1 module with correct `file_count`
- Isolated file (no edges, count < `min_module_size=3`): merged or kept as small cluster
- `primary_path` is non-empty for all returned modules
- `confidence` is between 0.0 and 1.0 for all modules

**Private helpers (import directly):**
- `_compute_edge_weight("a/b.py", "a/c.py")`: returns `1.3` (same directory)
- `_compute_edge_weight("a/b.py", "c/d.py")`: returns `1.0` (different directory)
- `_compute_confidence` with fully internal cluster: `== 1.0`
- `_compute_confidence` with no edges: `== 0.0`

**Verify:** `ruff check tests/test_import_graph.py`; `pytest tests/test_import_graph.py -v`

**Done when:** All graph and clustering functions tested. Exact count assertions on component membership. Floating-point comparisons use `pytest.approx`.

---

### Task 7: Test detection tests

**Files:** `tests/test_test_detection.py`

**Action:** Test `detect_tests` and its helpers from `analysis/testing.py`. Pure function tests — zero IO except `_parse_coverage_xml` (reads a file; use `tmp_path` to create a fixture XML file).

**`_is_test_file()` (import directly from module):**
- `"tests/utils.py"` → `True`
- `"test_scoring.py"` → `True`
- `"utils_test.py"` → `True`
- `"scoring.spec.ts"` → `True`
- `"utils.py"` → `False`
- `"setup.py"` → `False`
- `"__init__.py"` → `False`

**`detect_tests()`:**
- No test files: `test_files == set()`, `untested_files` contains all non-config source paths, `tier == "basic"`
- Test file present by naming convention: `test_files` has exact count
- Test file with relative import to source: `source_to_tests` maps source path → [test path]
- All non-test, non-config files with no test coverage: in `untested_files`
- `tier == "basic"` when no `coverage.xml` exists
- `tier == "enhanced"` when valid `coverage.xml` exists (use `tmp_path` fixture)

**`_parse_coverage_xml()` (import directly):**
- Valid Cobertura XML: returns dict with `filename → coverage` as float
- File with `line-rate="0.75"`: `== 0.75` (exact)
- File with `./` prefix in filename: normalized (leading `./` stripped)
- Invalid XML: raises `ET.ParseError` (tested to confirm the caller's `try/except` is needed)

**Verify:** `ruff check tests/test_test_detection.py`; `pytest tests/test_test_detection.py -v`

**Done when:** Test file detection verified for all naming patterns. Coverage XML parsing produces exact expected values.

---

### Task 8: Tree-sitter utilities tests

**Files:** `tests/test_treesitter_utils.py`

**Action:** Test all public functions in `analysis/adapters/_treesitter_utils.py`. Uses the `js_parser` fixture from conftest. These tests parse real JS code snippets to produce tree-sitter nodes.

**`traverse()`:**
- Parse a known JS snippet; collect all nodes into a list; verify no duplicate node ids (`id(node)` or `node.id` per tree-sitter API)
- Root node is the first yielded node
- Small single-node tree: yields exactly 1 node
- Deeply nested code: no `RecursionError` (cursor-based, not recursive)

**`extract_es_imports()`:**
- `"import foo from 'bar'"` → 1 import; `source_path` matches input; `target == "bar"`; `is_relative == False`; `names == ["foo"]`
- `"import { a, b } from 'mod'"` → `names == ["a", "b"]`
- `"import * as ns from 'mod'"` → `names == ["*"]`
- `"import './relative'"` → `is_relative == True`; `target == "./relative"`; `names == []`
- `"import 'polyfill'"` (side-effect) → 1 import; `names == []`; `is_relative == False`

**`extract_require_imports()`:**
- `"const x = require('./path')"` → 1 import; `is_relative == True`; `target == "./path"`
- `"const y = require('express')"` → 1 import; `is_relative == False`
- `"const z = require(variable)"` → 0 imports (dynamic require not captured)
- `"require.resolve('./mod')"` → 0 imports (not a bare `require()` call)

**`extract_definitions()`:**
- Named function declaration → 1 `FunctionDef` with `kind == "function"`, correct `name`, `line_start >= 1`
- Arrow function assigned to variable → `FunctionDef` with `name` from variable, `kind == "function"`
- Class with 2 methods → 3 `FunctionDef` objects: 1 class (`kind == "class"`) + 2 methods (`kind == "method"`) with names `"ClassName.methodName"`
- `async function foo()` → `is_async == True`
- Source with no definitions → returns `[]`

**`extract_entry_points()`:**
- `"const app = express()"` → 1 `EntryPoint` with `entry_type == EntryType.API_ROUTE`
- `"app.listen(3000)"` → 1 `EntryPoint` with `entry_type == EntryType.API_ROUTE`
- `"if (require.main === module) {}"` → 1 `EntryPoint` with `entry_type == EntryType.MAIN`
- Non-index file with default export → 0 entry points
- Index file (`file_path="src/index.js"`) with `export default` → `EntryType.MAIN` captured

**`calculate_complexity()`:**
- Empty JS: `== 0`
- Single `if`: `== 1`
- `if` + `for` + `while`: `== 3`
- `&&` in condition: counted; `||` in condition: counted
- Source with only type declarations (TS): `== 0`

**Verify:** `ruff check tests/test_treesitter_utils.py`; `pytest tests/test_treesitter_utils.py -v`

**Done when:** All utility functions covered. Each `FunctionDef` and `EntryPoint` assertion checks all fields, not just name.

---

### Task 9: Language adapter tests

**Files:** `tests/test_adapters.py`

**Action:** Test TypeScriptAdapter, JavaScriptAdapter, PythonAdapter, and AdapterRegistry from `analysis/adapters/`. Uses tree-sitter fixtures from conftest. No network, no subprocess.

**`TypeScriptAdapter`:**
- `can_handle("foo.ts")` → `True`; `can_handle("foo.tsx")` → `True`; `can_handle("foo.js")` → `False`
- `supported_extensions()` == `frozenset({".ts", ".tsx"})`
- `parse_file(Path("src/app.ts"), valid_ts_bytes)` → `ParsedFile.language == "typescript"`, `ParsedFile.path == "src/app.ts"`, at least the import/definition counts expected from the snippet
- `parse_file` with syntax error content → returns `ParsedFile` with empty imports/definitions (no exception raised)
- `parse_file` with `.tsx` extension → uses TSX language (test by including JSX syntax that would fail pure TS grammar)
- `resolve_import("src/app.ts", "./utils", {"src/utils.ts", "src/utils.js"})` → returns `"src/utils.ts"` (TS tried first)
- `resolve_import("src/app.ts", "express", {"src/utils.ts"})` → returns `None` (bare import)
- `resolve_import("src/app.ts", "@/components/Foo", {"src/components/Foo.ts"})` → returns `None` (path alias, known limitation)

**`JavaScriptAdapter`:**
- `can_handle("foo.js")` → `True`; `can_handle("foo.jsx")` → `True`; `can_handle("foo.ts")` → `False`
- `parse_file` with `require('./utils')` → `imports` contains the require import with `is_relative == True`
- `parse_file` with both ES module and CommonJS imports → both captured, combined in `imports`
- `resolve_import` extension order: `.js` tried before `.ts`
- Syntax error → empty ParsedFile, no exception

**`PythonAdapter`:**
- `can_handle("foo.py")` → `True`; `can_handle("foo.ts")` → `False`
- `parse_file` with valid Python → `ParsedFile.language == "python"`, `line_count > 0`
- `parse_file` with `SyntaxError` content → returns ParsedFile with `line_count` set but empty `imports`/`definitions`

**`AdapterRegistry`:**
- `create_default_registry()` → registry with 3 adapters registered
- `get_adapter("file.py")` → returns a `PythonAdapter` instance
- `get_adapter("file.ts")` → returns a `TypeScriptAdapter` instance
- `get_adapter("file.js")` → returns a `JavaScriptAdapter` instance
- `get_adapter("file.rb")` → returns `None`
- `supported_languages()` contains "PythonAdapter", "TypeScriptAdapter", "JavaScriptAdapter" keys

**Property-based test:**
```python
@given(st.sampled_from([
    ("file.py", "PythonAdapter"),
    ("file.ts", "TypeScriptAdapter"),
    ("file.tsx", "TypeScriptAdapter"),
    ("file.js", "JavaScriptAdapter"),
    ("file.jsx", "JavaScriptAdapter"),
]))
def test_registry_dispatch_always_returns_correct_adapter(path_and_type):
    path, expected_type = path_and_type
    registry = create_default_registry()
    adapter = registry.get_adapter(path)
    assert adapter is not None
    assert type(adapter).__name__ == expected_type
    assert adapter.can_handle(path)
```

**Import resolution property:**
```python
@given(
    repo_files=st.frozensets(st.text(min_size=1).map(lambda s: f"src/{s}.ts"), min_size=1),
    filename=st.text(min_size=1).map(lambda s: f"src/{s}.ts"),
)
def test_resolved_import_exists_in_repo_files(repo_files, filename):
    adapter = TypeScriptAdapter()
    for target in [f"./{f}" for f in list(repo_files)[:3]]:
        result = adapter.resolve_import(filename, target, set(repo_files))
        if result is not None:
            assert result in repo_files
```

**Verify:** `ruff check tests/test_adapters.py`; `pytest tests/test_adapters.py -v`

**Done when:** All 3 adapter types tested. Registry dispatch verified for all supported extensions. Property tests confirm no phantom paths returned by resolve_import.

---

### Task 10: Security module tests and full verification

**Files:** `tests/test_security.py`

**Action:** Test `scan_for_secrets` and `scan_vulnerabilities` from `analysis/security/`. All external calls (detect-secrets, subprocess) are mocked. Uses `tmp_path` for fake repo directory with manifest files.

**`scan_for_secrets()`:**
- Empty `file_paths` list: returns `[]`, no calls made to detect-secrets
- detect-secrets not available (mock `import detect_secrets` to `ImportError`): returns `[]`, no exception
- Mock `SecretsCollection.scan_file` and `json()` to return 1 finding: returns exactly 1 `SecretFinding` with all fields set (`file_path`, `line_number`, `secret_type`, `description`)
- File over 1MB (mock file size): skipped, not scanned
- Mock returns `is_secret: False` (confirmed false positive): not included in results
- All `SecretFinding` fields asserted on returned results

**`scan_vulnerabilities()`:**
- No manifest files in `tmp_path`: returns `([], [], [])` — exactly 0 findings, 0 manifests, 0 errors
- `requirements.txt` present: `pip-audit -r ... -f json` called (mock subprocess); `"requirements.txt"` in `manifests_scanned`
- `pyproject.toml` present: `pip-audit --locked -f json` called; `"pyproject.toml"` in `manifests_scanned`
- `package.json` + `package-lock.json` present: `npm audit --json` called; `"package.json"` in `manifests_scanned`
- `package.json` without lockfile: `npm audit` NOT called; `manifests_scanned` doesn't include `package.json`
- `pip-audit` not found (`FileNotFoundError` from subprocess): `scan_errors` contains error message; no exception raised
- Mock pip-audit JSON output with 1 CVE: returns 1 `VulnerabilityFinding` with all fields: `package_name`, `installed_version`, `vulnerability_id`, `severity`, `description`, `fix_version`, `ecosystem == "python"`
- Mock npm audit JSON with 1 vuln: returns 1 `VulnerabilityFinding` with `ecosystem == "javascript"`

**Full verification:**
After all test files are written:
1. `ruff check tests/` — no lint errors
2. `pytest tests/ -v --tb=short` — all tests pass
3. `pytest tests/ --cov=src/unfamiliar --cov-report=term-missing` — review coverage
4. `mypy tests/` (if tests use type hints) — no errors

**Verify:** All commands above exit 0. Pytest output shows no failures or errors.

**Done when:** Security scanning is tested with mocked external tools. Full test suite runs clean. Coverage report reviewed (does not need to hit a specific number — Phase 5 goal is correctness of behavior, not coverage percentage).

---

## Property-Based Testing Candidates Summary

| Function | Property | Strategy |
|----------|----------|----------|
| JSON round-trip for `category_relevance` | serialize → deserialize == original | `st.lists(st.sampled_from(InvestigationCategory))` |
| `score_files` output paths | `{sf.path for sf in result} == {f.path for f in files}` | `st.lists(st.builds(FileInfo, ...))` |
| `calculate_complexity` | always `>= 0` | example-based with diverse snippets (property via known invariant) |
| AdapterRegistry dispatch | `get_adapter(path)` returns adapter whose `can_handle(path)` is True | `st.sampled_from` over known extensions |
| `TypeScriptAdapter.resolve_import` | resolved path always in `repo_files` | `st.frozensets` of paths |

---

## Interface Contracts Referenced

This phase tests against the contracts documented in:

- `_planning/phases/02-data-layer/plan.md` — Models, Connection, all 5 Repositories
- `_planning/phases/03-static-analysis-core/plan.md` — ImportGraph, Scoring, TestInfo, Clustering
- `_planning/phases/04-static-analysis-adapters-security/plan.md` — TreesitterUtils, TypeScriptAdapter, JavaScriptAdapter, SecretFinding, VulnerabilityFinding

**Do not read the implementation files during the build phase for this plan.** Work from the contracts above and the actual function signatures only (for import paths and type annotations).

---

## Decisions to Revisit

From `_planning/decisions.md` (2026-03-02 — create_many() fragility):

> **Phase 5 action:** Test `create_many()` with duplicate match-field values to confirm and document the failure mode. Consider whether a UNIQUE constraint should be added to `module.path` within a campaign (likely yes), or whether the application layer guarantee is sufficient.

Task 4 (Repository tests) includes this test. After running it, add a note to `decisions.md` with the observed behavior and a recommendation. This does not require a code change in Phase 5 — document only.
