# Task Recon: Implement Async Validation Logic

**Task ID:** 26.2
**Status:** pending
**Generated:** 2026-01-29
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Implement the `validate_assign_csv` async method that validates each `BulkAssignRow` by checking user existence, number availability, and location validity using injected services (UserService, NumberService, LocationService).

### Parent Task Context

**Parent Task:** Implement Bulk CSV Parsing and Validation (ID: 26)
**Parent Goal:** Create utilities for parsing and validating bulk operation CSV files with dry-run support. This includes CSV parsing, async validation against services, and results file generation.
**This Subtask's Role:** Core validation logic - takes parsed CSV rows from subtask 26.1, validates each row against live services (user existence, number availability, location validity), and returns a `BulkValidationResult` with error/warning counts. This enables the dry-run functionality for bulk operations.

### Dependencies
- Depends on: Subtask 26.1 (CSV Parsing with BulkAssignRow Dataclass) - **COMPLETED**
- Parent task dependencies: Task 10 (Phone Number Utilities), Task 15 (LocationService), Task 17 (UserService) - all completed

### Acceptance Criteria
From task details:
1. Define `BulkOperations` class with `__init__` accepting UserService, NumberService, LocationService
2. Implement `async validate_assign_csv(rows: list[BulkAssignRow]) -> BulkValidationResult` that:
   - For each row, validates in sequence:
     a. User existence: Call `user_service.resolve_user(row.user)`, catch NotFoundError and ValidationError (ambiguous match), add to row.errors
     b. Number availability: Call `number_service.get_number(row.phone_number)`, verify status is 'unassigned' using AssignmentStatus enum, add error if assigned or not found
     c. Location validity (if provided): Call `location_service.validate_location(row.location)`, catch NotFoundError, add to row.errors
   - Generate warnings for: stale location cache (`location_service.is_cache_stale()`), Calling Plan numbers without location
   - Count valid rows (no errors), error rows, and warning rows
   - Return BulkValidationResult with all rows and counts
3. Use asyncio.gather or sequential processing (consider rate limits) for batch validation
4. Import required models: AssignmentStatus from models, NotFoundError and ValidationError from exceptions

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 26.1 | CSV Parsing with BulkAssignRow | `parse_assign_csv` is a standalone function. BulkAssignRow uses dataclass with `field(default_factory=list)` for errors/warnings. Row numbers are 1-indexed starting at 2 (after header). |
| 10 | Phone Number Utilities | ValidationError accepts `remediation` kwarg for user guidance |
| 15 | Implement LocationService | `validate_location()` raises NotFoundError for unknown locations. `is_cache_stale()` returns bool. Location cache patterns established. |
| 17 | Implement UserService | `resolve_user()` raises NotFoundError if no match, ValidationError if multiple matches (ambiguous). Uses async patterns. |

### Reusable Patterns from Dependencies
- User resolution: `user_service.resolve_user()` at `src/teams_phone/services/user_service.py:191-265`
- Number lookup: `number_service.get_number()` at `src/teams_phone/services/number_service.py:116-150`
- Location validation: `location_service.validate_location()` at `src/teams_phone/services/location_service.py:101-127`
- Cache staleness: `location_service.is_cache_stale()` at `src/teams_phone/services/location_service.py:166-172`

### Warnings and Gotchas
- **From Task 26.1**: When running `tests/unit/test_bulk_operations.py` alone, a circular import error may occur. Tests pass when run via `pytest tests/unit/` (alphabetical loading resolves the cycle).
- **From Task 26.1**: Row numbers start at 2 (row 1 is the header).
- **From Task 17**: UserService and NumberService are NOT exported from `services/__init__.py` to avoid circular imports. Import directly from module.
- **From Task 15**: LocationService.validate_location() raises NotFoundError - must catch and add to row.errors.

### Open Items Inherited
- [ ] Subtask 26.1 implemented `parse_assign_csv` as standalone function, needs integration into BulkOperations class

---

## Architecture Context

### Relevant Design Decisions
- Services abstract Graph API + CSV cache access (repository pattern)
- UserService.resolve_user() handles UPN, display name, or object ID detection
- NumberService.get_number() normalizes phone number format and queries by telephoneNumber filter
- LocationService uses CSV cache (Graph API limitation) - cache may be stale
- Async operations use Python asyncio

### Module Structure
`src/teams_phone/services/bulk_operations.py` already exists with:
- `BulkAssignRow` dataclass (row_number, user, phone_number, location, errors, warnings)
- `BulkValidationResult` dataclass (rows, valid_count, error_count, warning_count)
- `parse_assign_csv()` standalone function

Need to add:
- `BulkOperations` class with dependency injection
- `validate_assign_csv()` async method

### Conventions to Follow
- Google-style docstrings with Args, Returns, Raises sections
- Type hints with PEP 604 union syntax (`str | None`)
- `@pytest.mark.unit` and `@pytest.mark.asyncio` decorators on async tests
- Test class organization: `TestClassName` for each method/feature
- Use `from __future__ import annotations` for forward references
- Use `TYPE_CHECKING` import guard to avoid circular imports

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/bulk_operations.py` | 1-151 | Existing dataclasses and parse_assign_csv function to extend |
| `src/teams_phone/services/user_service.py` | 191-265 | resolve_user() method - catches NotFoundError and ValidationError |
| `src/teams_phone/services/number_service.py` | 116-150 | get_number() method - returns NumberAssignment with assignment_status |
| `src/teams_phone/services/location_service.py` | 101-127, 166-172 | validate_location() and is_cache_stale() methods |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/number.py` | 46-59 | AssignmentStatus enum with UNASSIGNED, USER_ASSIGNED, etc. |
| `src/teams_phone/models/number.py` | 14-26 | NumberType enum with CALLING_PLAN value |
| `src/teams_phone/exceptions.py` | 72-82, 85-95 | NotFoundError and ValidationError definitions |
| `tests/unit/test_user_service.py` | 609-756 | Async test patterns for resolve_user with mock services |

### Code Patterns to Follow

```python
# User resolution pattern from user_service.py:191-265
async def resolve_user(self, identifier: str) -> UserConfiguration:
    """Resolve a user by UPN, display name, or object ID."""
    # ...
    raise NotFoundError(f"User '{identifier}' not found", remediation="...")
    # or
    raise ValidationError(f"Multiple users match '{identifier}'", remediation="...")
```

```python
# Number lookup pattern from number_service.py:116-150
async def get_number(self, phone_number: str) -> NumberAssignment:
    """Get a single phone number assignment by phone number."""
    # ...
    raise NotFoundError(f"Phone number '{normalized}' not found", remediation="...")
```

```python
# Location validation pattern from location_service.py:101-127
def validate_location(self, identifier: str) -> EmergencyLocation:
    """Validate and retrieve a location by ID or description."""
    # ...
    raise NotFoundError(f"Location '{identifier}' not found", remediation="...")
```

```python
# Async test pattern from test_user_service.py:609-650
@pytest.mark.unit
@pytest.mark.asyncio
async def test_resolve_user_by_guid(self, mock_graph_client: MagicMock) -> None:
    """resolve_user looks up user by GUID directly."""
    user_data = make_user_dict("12345678-1234-1234-1234-123456789abc", "test@contoso.com")
    mock_graph_client.get = AsyncMock(return_value=user_data)

    service = UserService(mock_graph_client)
    user = await service.resolve_user("12345678-1234-1234-1234-123456789abc")

    assert user.id == "12345678-1234-1234-1234-123456789abc"
```

---

## Dependencies

### Internal Modules
- `teams_phone.services.user_service.UserService` - for `resolve_user()` async method
- `teams_phone.services.number_service.NumberService` - for `get_number()` async method
- `teams_phone.services.location_service.LocationService` - for `validate_location()` and `is_cache_stale()` sync methods
- `teams_phone.models.AssignmentStatus` - enum for checking if number is unassigned
- `teams_phone.models.NumberType` - enum for checking Calling Plan numbers
- `teams_phone.exceptions.NotFoundError` - exception to catch for user/number/location not found
- `teams_phone.exceptions.ValidationError` - exception to catch for ambiguous user match

### External Packages
- No new external packages required

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_bulk_operations.py` (extend existing file)

### Test Patterns in Use
```python
# Async test with mocked services from test_user_service.py:73-91
@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_users_returns_empty_list(self, mock_graph_client: MagicMock) -> None:
    """list_users returns empty list when no users exist."""

    async def mock_paginated(*args: Any, **kwargs: Any) -> AsyncIterator[dict[str, Any]]:
        return
        yield  # pragma: no cover - makes this an async generator

    mock_graph_client.get_paginated = mock_paginated

    service = UserService(mock_graph_client)
    users = await service.list_users()

    assert users == []
```

```python
# Mock service pattern for BulkOperations testing
from unittest.mock import AsyncMock, MagicMock

@pytest.fixture
def mock_user_service() -> MagicMock:
    """Create a mock UserService for testing."""
    service = MagicMock()
    service.resolve_user = AsyncMock()
    return service

@pytest.fixture
def mock_number_service() -> MagicMock:
    """Create a mock NumberService for testing."""
    service = MagicMock()
    service.get_number = AsyncMock()
    return service

@pytest.fixture
def mock_location_service() -> MagicMock:
    """Create a mock LocationService for testing."""
    service = MagicMock()
    service.validate_location = MagicMock()  # sync method
    service.is_cache_stale = MagicMock(return_value=False)  # sync method
    return service
```

### Fixtures Available
- `tmp_path` - pytest built-in fixture for temporary directories
- Existing fixtures in `tests/unit/test_bulk_operations.py` for BulkAssignRow creation

### Test Utilities
- `tests/conftest.py` - defines markers: unit, integration, contract
- `pytest-asyncio` for async test support
- `unittest.mock.AsyncMock` for mocking async methods
- `unittest.mock.MagicMock` for mocking sync methods

### Test Cases to Implement (from task details)
1. **Valid row validation** - user exists, number unassigned, location valid
2. **User not found** - NotFoundError caught, added to row.errors
3. **Ambiguous user match** - ValidationError caught, added to row.errors
4. **Number already assigned** - assignment_status != UNASSIGNED, error added
5. **Number not in inventory** - NotFoundError caught, added to row.errors
6. **Invalid location** - NotFoundError caught, added to row.errors
7. **Stale cache warning** - is_cache_stale() returns True, warning added
8. **Calling Plan without location** - number type is callingPlan and no location, warning added
9. **Counts validation** - valid_count, error_count, warning_count computed correctly

---

## Risks and Blockers

### Technical Risks
- [ ] **Rate limiting concerns**: Task mentions "consider rate limits" for asyncio.gather vs sequential processing. Recommend: Sequential processing for now (simpler, safer), can optimize later with batching if needed.
- [ ] **Circular imports**: UserService and NumberService are not in `services/__init__.py` exports. Must import directly from modules using TYPE_CHECKING guard.
- [ ] **LocationService is sync, others are async**: `validate_location()` and `is_cache_stale()` are synchronous methods while `resolve_user()` and `get_number()` are async. Handle appropriately in async context.

### Dependency Risks
- [ ] None - subtask 26.1 is completed and provides the required BulkAssignRow/BulkValidationResult dataclasses

### Open Questions
- [ ] Should validation stop on first error per row, or collect all errors? Recommend: Collect all errors (current task spec implies this with "errors list").
- [ ] Should rows with parsing errors (from subtask 26.1) be re-validated? Recommend: Skip validation for rows that already have errors (user/phone_number missing).

---

## Implementation Recommendations

### Suggested Approach
1. Add `TYPE_CHECKING` imports for UserService, NumberService at top of `bulk_operations.py`
2. Define `BulkOperations` class with `__init__(self, user_service, number_service, location_service)`
3. Implement `async def validate_assign_csv(self, rows: list[BulkAssignRow]) -> BulkValidationResult`:
   - Check `is_cache_stale()` once at start, add global warning if stale
   - For each row:
     - Skip if row already has errors (missing user/phone_number from parsing)
     - Call `resolve_user(row.user)` - catch NotFoundError/ValidationError
     - Call `get_number(row.phone_number)` - catch NotFoundError, check assignment_status
     - If location provided, call `validate_location(row.location)` - catch NotFoundError
     - Check for Calling Plan without location warning
   - Compute counts: valid_count (rows with no errors), error_count, warning_count
   - Return BulkValidationResult
4. Add comprehensive unit tests with mocked services

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/services/bulk_operations.py` | Add BulkOperations class with validate_assign_csv method |
| Modify | `tests/unit/test_bulk_operations.py` | Add async tests for BulkOperations.validate_assign_csv |

### Quick Wins
- Start by adding the BulkOperations class skeleton with dependency injection
- Implement the simplest validation path first (all valid row)
- Add error handling one service at a time (user, then number, then location)
- Add warning logic last (stale cache, Calling Plan location)

---

## Cheat Sheet

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

from typing import TYPE_CHECKING

from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.models import AssignmentStatus, NumberType

if TYPE_CHECKING:
    from teams_phone.services.location_service import LocationService
    from teams_phone.services.number_service import NumberService
    from teams_phone.services.user_service import UserService
```

### Key Functions to Use
- `user_service.resolve_user(identifier)` - async, raises NotFoundError or ValidationError
- `number_service.get_number(phone_number)` - async, raises NotFoundError, returns NumberAssignment
- `location_service.validate_location(identifier)` - sync, raises NotFoundError
- `location_service.is_cache_stale()` - sync, returns bool
- `number.assignment_status == AssignmentStatus.UNASSIGNED` - check if number available
- `number.number_type == NumberType.CALLING_PLAN` - check if Calling Plan number

### Common Patterns
- Error handling: Wrap service calls in try/except, append message to `row.errors`
- Warning handling: Check conditions, append message to `row.warnings`
- Counting: `valid_count = sum(1 for r in rows if not r.errors)`

### BulkOperations Class Pattern
```python
class BulkOperations:
    """Bulk operations for phone number management."""

    def __init__(
        self,
        user_service: UserService,
        number_service: NumberService,
        location_service: LocationService,
    ) -> None:
        self.user_service = user_service
        self.number_service = number_service
        self.location_service = location_service

    async def validate_assign_csv(
        self, rows: list[BulkAssignRow]
    ) -> BulkValidationResult:
        """Validate all rows: user exists, number available, location valid."""
        # Implementation here
        pass
```

### Validation Logic Pattern
```python
# User validation
try:
    user = await self.user_service.resolve_user(row.user)
except NotFoundError:
    row.errors.append(f"User '{row.user}' not found")
except ValidationError as e:
    row.errors.append(f"Ambiguous user: {e.message}")

# Number validation
try:
    number = await self.number_service.get_number(row.phone_number)
    if number.assignment_status != AssignmentStatus.UNASSIGNED:
        row.errors.append(f"Number '{row.phone_number}' is already assigned")
except NotFoundError:
    row.errors.append(f"Number '{row.phone_number}' not in inventory")

# Location validation (if provided)
if row.location:
    try:
        self.location_service.validate_location(row.location)
    except NotFoundError:
        row.errors.append(f"Location '{row.location}' not found")

# Calling Plan warning (no location)
if number.number_type == NumberType.CALLING_PLAN and not row.location:
    row.warnings.append("Calling Plan number without emergency location")
```

---

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