# Task ID: 20

**Title:** Implement Async Operation Polling

**Status:** pending

**Dependencies:** 19

**Priority:** high

**Description:** Add async operation polling to NumberService for assign/unassign operations that return 202 Accepted.

**Details:**

Extend `src/teams_phone/services/number_service.py`:
```python
async def poll_operation(
    self,
    operation_id: str,
    timeout: int = 60,
    on_progress: Callable[[AsyncOperation], None] | None = None,
) -> AsyncOperation:
    """Poll operation until complete or timeout."""
    start = time.time()
    while time.time() - start < timeout:
        result = await self._graph_client.get(
            f"/admin/teams/telephoneNumberManagement/operations/{operation_id}"
        )
        operation = AsyncOperation.model_validate(result)
        if on_progress:
            on_progress(operation)
        if operation.status in ("succeeded", "failed", "skipped"):
            return operation
        await asyncio.sleep(2 + random.uniform(0, 1))  # 2-3 seconds
    raise TimeoutError(f"Operation {operation_id} did not complete within {timeout}s")

def _extract_operation_id(self, location_header: str) -> str:
    """Extract operation ID from Location header."""
    # Format: .../operations('{operation_id}')
    ...
```

**Test Strategy:**

Unit tests: poll success after N iterations, poll timeout, operation ID extraction from various header formats, progress callback invocation.

---

## Subtask 20.1: Implement poll_operation method

### Implementation Notes

#### Session: 2026-01-29 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added required imports to `number_service.py`: `asyncio`, `random`, `time`, `Callable`
- Added model imports: `AsyncOperation`, `OperationStatus`
- Implemented `poll_operation` method with:
  - Terminal state detection using `OperationStatus` enum values
  - 2-3 second polling interval with jitter (`2 + random.uniform(0, 1)`)
  - Configurable timeout (default 60s)
  - Optional progress callback invoked on each poll iteration
  - Proper timeout handling with `TimeoutError`
- Created `make_operation_dict()` test helper in test file
- Added 9 unit tests covering all acceptance criteria

#### Key Decisions Made
- Used `OperationStatus` enum for terminal state comparison instead of raw strings for type safety
- Made `timeout` and `on_progress` keyword-only arguments with `*` separator for clarity
- Used `is not None` check for callback to properly handle falsy callbacks
- Access `graph_client` directly (not `_graph_client`) following established pattern in existing methods

#### Patterns Discovered
- Existing service methods use `self.graph_client` (public), not `self._graph_client` (private) - see `number_service.py:92`
- Test mocking pattern: Use `AsyncMock` for `graph_client.get`, patch `asyncio.sleep` and `time.time` for timeout tests

#### Open Items for Next Agent
- [ ] Subtask 20.2: Implement `_extract_operation_id` helper for parsing Location headers
- [ ] Subtask 20.3: Integrate polling with assign/unassign operations

#### Gotchas and Warnings
- The endpoint uses `/{operationId}` directly, NOT OData-style `('{operationId}')` format
- Terminal states are: `succeeded`, `failed`, `skipped` - do NOT return early on `notStarted` or `running`
- The task spec showed `_graph_client` but actual codebase uses `graph_client` (public attribute)

---

## Subtask 20.2: Implement operation ID extraction from Location header

### Implementation Notes

#### Session: 2026-01-29 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `import re` to `number_service.py` for regex pattern matching
- Added `ValidationError` to imports (alongside existing `NotFoundError`)
- Implemented `_extract_operation_id(self, location_header: str) -> str` method:
  - Uses regex pattern `r"operations\('([^']+)'\)"` to extract operation ID
  - Raises `ValidationError` with descriptive message and remediation on invalid format
  - Handles edge cases: full URLs, base64-encoded IDs, query params, special characters
- Created/modified: `src/teams_phone/services/number_service.py:252-281`
- Added 12 unit tests covering all acceptance criteria:
  - Full URL extraction, base64 IDs, minimal paths, query parameters, special chars
  - ValidationError cases: empty string, missing quotes, wrong parentheses, no operations keyword, empty ID
  - Error message verification including remediation guidance

#### Key Decisions Made
- Pattern `r"operations\('([^']+)'\)"` uses `[^']+` to require at least one character (prevents empty ID match)
- Return operation ID as-is without URL-decoding - polling endpoint should accept same format
- Include original header in error message for debugging
- Remediation suggests "API response format change" as this is typically an internal error

#### Patterns Discovered
- Location header format from Graph API: `https://graph.microsoft.com/beta/admin/teams/telephoneNumberManagement/operations('{base64_encoded_id}')`
- Important distinction: Location header uses OData format `operations('id')`, but polling endpoint uses direct path `operations/{id}`
- ValidationError import was already available in exceptions.py - just needed to add to service imports

#### Open Items for Next Agent
- [ ] Subtask 20.3: Integrate polling with assign operation (use `_extract_operation_id` on Location header from 202 response)
- [ ] Consider: Should empty operation ID in pattern `operations('')` be handled differently?

#### Gotchas and Warnings
- The Location header uses OData-style `operations('id')` but the polling endpoint uses REST-style `operations/{id}`
- Operation IDs may be base64-encoded with special characters like `|`, `=`, `-`
- Do NOT decode URL-encoded characters in operation ID - pass through as-is
- When testing, remember `exc_info.value.remediation` may be `None` - assert `is not None` before checking content
