# Task Recon: Implement API-Check Contract Validation Command

**Task ID:** 28
**Status:** pending
**Generated:** 2026-01-30T00:00:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create the `api-check` command for validating Pydantic models against live Graph API responses. This command will fetch sample data from Graph API endpoints and validate them against the strict Pydantic models (`extra="forbid"`) to detect API contract drift (new fields added, fields renamed/removed). On validation failures, exit with code 8 (CONTRACT_ERROR).

### Dependencies
- Task 7 (GraphClient with Retry Logic) - DONE
- Task 9 (Pydantic Models for Graph API Responses) - DONE
- Task 11 (Root CLI App with Global Options) - DONE
- Task 41 (Validation Checkpoint 3: Write Operations) - DONE

### Acceptance Criteria
- `api-check` command fetches sample data from Graph API endpoints
- Validates responses against `UserConfiguration` and `NumberAssignment` Pydantic models
- Uses strict mode (`extra="forbid"`) to detect extra fields
- Displays PASS/FAIL results table for each endpoint
- Exits with code 8 if any validation failures
- Supports `--endpoint` option to check specific endpoint
- Supports `--verbose` flag for detailed error output
- Works with `--json` global flag for automation

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 7 | GraphClient with Retry Logic | GraphClient is async-only; use `async with GraphClient(...) as client:` pattern. Token acquisition is synchronous via `auth_service.get_token()`. |
| 9 | Pydantic Models | All models use `ConfigDict(extra="forbid", populate_by_name=True)`. Need `_strip_odata_metadata()` helper to remove @odata.context etc. before validation. |
| 11 | Root CLI App | Use `get_context(ctx)` for typed CLIContext. OutputFormatter initialized in callback. Use `cli_ctx.get_config_manager()` for lazy init. |
| 41 | Validation Checkpoint 3 | OData metadata in single-item responses includes `@odata.context` which Pydantic rejects. Must strip @-prefixed fields before validation. |

### Reusable Patterns from Dependencies
- **OData stripping**: Implemented in `src/teams_phone/services/user_service.py:19-26` during Task 41
- **CLI command pattern**: See `src/teams_phone/cli/auth.py` for service instantiation and error handling pattern
- **Async operations in CLI**: Pattern established in `src/teams_phone/cli/numbers.py` using `asyncio.run()`

### Warnings and Gotchas
- **From Task 9**: mypy strict mode + aliases - When using `populate_by_name=True`, mypy doesn't recognize snake_case constructor args. Use `model_validate()` with dicts.
- **From Task 7**: GraphClient must be used as async context manager or raises RuntimeError
- **From Task 41**: Graph API single-item responses include `@odata.context` field which Pydantic rejects with `extra="forbid"`. Use `_strip_odata_metadata()` helper.

### Open Items Inherited
- None directly affecting this task

---

## Architecture Context

### Relevant Design Decisions
- **Pydantic strict mode**: All Graph API response models use `extra="forbid"` to fail-fast on API contract changes (see `.taskmaster/docs/architecture/index.md`)
- **Exit code 8**: Reserved for CONTRACT_ERROR - API contract violations that users cannot remediate directly

### Module Structure
```
src/teams_phone/
├── cli/
│   └── api_check.py    # NEW - api-check command
├── models/
│   ├── user.py         # UserConfiguration model
│   ├── number.py       # NumberAssignment, AsyncOperation models
│   └── api_responses.py # GraphListResponse[T] generic model
├── infrastructure/
│   └── graph_client.py # GraphClient for API calls
└── exceptions.py       # ContractError with exit_code=8
```

### Conventions to Follow
- CLI commands use `@app.command()` decorator with Typer
- Error handling: catch `TeamsPhoneError`, call `formatter.error()`, raise `typer.Exit(e.exit_code)`
- Global options accessed via `get_context(ctx)` returning `CLIContext`
- JSON output via `--json` flag: use `formatter.json()` for structured output
- Async operations: wrap in `asyncio.run()` since Typer commands are synchronous

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/auth.py` | 23-49 | CLI command pattern with service instantiation and error handling |
| `src/teams_phone/services/user_service.py` | 19-26 | `_strip_odata_metadata()` helper for OData field removal |
| `src/teams_phone/models/user.py` | 82-121 | UserConfiguration model with strict validation |
| `src/teams_phone/models/number.py` | 132-186 | NumberAssignment model with strict validation |
| `src/teams_phone/exceptions.py` | 124-138 | ContractError with exit_code=8 |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/main.py` | 1-85 | How to register new command group |
| `src/teams_phone/cli/context.py` | 1-94 | CLIContext and get_context() helper |
| `src/teams_phone/infrastructure/graph_client.py` | 379-407 | GraphClient.get() method |
| `src/teams_phone/constants.py` | 55-97 | ExitCode enum with CONTRACT_ERROR=8 |

### Code Patterns to Follow

```python
# CLI command pattern with async operation (from auth.py + numbers.py patterns)
import asyncio
from typing import Optional
import typer
from pydantic import ValidationError

from teams_phone.cli.context import get_context
from teams_phone.exceptions import TeamsPhoneError, ContractError
from teams_phone.infrastructure import CacheManager, GraphClient
from teams_phone.models import UserConfiguration, NumberAssignment
from teams_phone.services import AuthService

api_check_app = typer.Typer(name="api-check", help="Validate API contracts.")

@api_check_app.command("run")  # or just make it a standalone command
def api_check(
    ctx: typer.Context,
    endpoint: Optional[str] = typer.Option(None, help="Check specific endpoint"),
    verbose: bool = typer.Option(False, "--verbose", "-V", help="Show detailed errors"),
) -> None:
    """Validate API contracts against live responses."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        # Service setup
        config_manager = cli_ctx.get_config_manager()
        cache_manager = CacheManager()
        auth_service = AuthService(config_manager, cache_manager, cli_ctx.tenant)

        # Run async validation
        results = asyncio.run(_run_validation(auth_service, endpoint))

        # Display results and handle exit code
    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)
```

```python
# OData metadata stripping pattern (from user_service.py:19-26)
def _strip_odata_metadata(data: dict[str, Any]) -> dict[str, Any]:
    """Strip OData metadata fields from a response dict."""
    return {k: v for k, v in data.items() if not k.startswith("@")}
```

---

## Dependencies

### Internal Modules
- `teams_phone.cli.context` - CLIContext, get_context()
- `teams_phone.exceptions` - TeamsPhoneError, ContractError
- `teams_phone.infrastructure` - CacheManager, GraphClient, OutputFormatter
- `teams_phone.models` - UserConfiguration, NumberAssignment, AsyncOperation
- `teams_phone.services` - AuthService

### External Packages
- `typer` - CLI framework (already in use)
- `pydantic` - ValidationError for catching strict mode failures (already in use)
- `asyncio` - For running async GraphClient operations (stdlib)

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_cli_api_check.py`

### Test Patterns in Use
```python
# Test pattern from test_cli_auth.py:27-52
from typer.testing import CliRunner
from unittest.mock import MagicMock, patch

runner = CliRunner()

class TestApiCheckCommand:
    """Tests for api-check command."""

    @pytest.mark.unit
    def test_api_check_shows_in_help(self) -> None:
        """api-check command appears in main help."""
        result = runner.invoke(app, ["--help"])
        assert result.exit_code == 0
        assert "api-check" in result.stdout

    @pytest.mark.unit
    def test_api_check_pass_all_endpoints(self) -> None:
        """api-check passes when all endpoints validate."""
        # Mock GraphClient.get() to return valid responses
        # Assert exit code 0 and PASS in output

    @pytest.mark.unit
    def test_api_check_fail_extra_field(self) -> None:
        """api-check fails when response has extra fields."""
        # Mock response with unknown field
        # Assert exit code 8 and FAIL in output

    @pytest.mark.unit
    def test_api_check_fail_missing_required_field(self) -> None:
        """api-check fails when required field is missing."""
        # Mock response missing required field
        # Assert exit code 8 and FAIL in output
```

### Fixtures Available
- Standard pytest fixtures
- `CliRunner` from `typer.testing`

### Test Utilities
- `unittest.mock.patch` for mocking services
- `unittest.mock.MagicMock` for mock objects

---

## Risks and Blockers

### Technical Risks
- [x] **Async in Typer**: Typer commands are synchronous but GraphClient is async. Mitigated by using `asyncio.run()` pattern already established in `numbers.py`.

### Dependency Risks
- None - all dependency tasks (7, 9, 11, 41) are completed

### Open Questions
- [ ] Should api-check be a standalone command or under a command group? Task details show standalone `@app.command("api-check")` pattern.
- [ ] What endpoints beyond userConfigurations and numberAssignments should be validated? Task details only specify these two.
- [ ] Should AsyncOperation model validation be included? It's mentioned in task details imports but not in the checks list.

---

## Implementation Recommendations

### Suggested Approach
1. Create `src/teams_phone/cli/api_check.py` with api-check command
2. Implement async `_run_validation()` helper that:
   - Uses GraphClient to fetch sample data from each endpoint
   - Strips OData metadata before validation
   - Validates against Pydantic models with `model_validate()`
   - Collects results (endpoint, status, error details)
3. Display results as table (PASS/FAIL for each endpoint)
4. Exit with code 8 if any failures, 0 if all pass
5. Register api-check in `src/teams_phone/cli/main.py`
6. Write unit tests with mocked API responses

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/cli/api_check.py` | New api-check command implementation |
| Modify | `src/teams_phone/cli/main.py` | Register api-check command |
| Create | `tests/unit/test_cli_api_check.py` | Unit tests for the command |

### Quick Wins
- Start by implementing the happy path (all validations pass)
- Use existing `_strip_odata_metadata()` pattern from user_service.py
- Reuse auth.py command structure for consistency

---

## Cheat Sheet

### Key Imports
```python
from __future__ import annotations

import asyncio
from typing import Any, Optional

import typer
from pydantic import ValidationError

from teams_phone.cli.context import get_context
from teams_phone.constants import ExitCode
from teams_phone.exceptions import ContractError, TeamsPhoneError
from teams_phone.infrastructure import CacheManager, GraphClient
from teams_phone.models import NumberAssignment, UserConfiguration
from teams_phone.services import AuthService
```

### Key Functions to Use
- `get_context(ctx: typer.Context) -> CLIContext` - Get typed context
- `cli_ctx.get_output_formatter() -> OutputFormatter` - Get formatter for output
- `cli_ctx.get_config_manager() -> ConfigManager` - Lazy config loading
- `UserConfiguration.model_validate(data)` - Validate dict against model
- `NumberAssignment.model_validate(data)` - Validate dict against model

### Common Patterns
- **CLI error handling**: See `src/teams_phone/cli/auth.py:47-49`
- **OData stripping**: See `src/teams_phone/services/user_service.py:19-26`
- **Async in CLI**: See pattern in numbers.py commands using `asyncio.run()`

### Graph API Endpoints to Validate
| Name | Endpoint | Model |
|------|----------|-------|
| userConfigurations | `/admin/teams/userConfigurations?$top=1` | UserConfiguration |
| numberAssignments | `/admin/teams/telephoneNumberManagement/numberAssignments?$top=1` | NumberAssignment |

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
