# Task Recon: Implement unassign_number method and integrate both methods with poll_operation

**Task ID:** 21.2
**Status:** pending
**Generated:** 2026-01-29T18:00:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Add the `unassign_number` async method to NumberService that removes phone number assignments from users. The method follows the same pattern as `assign_number` but with a simpler request body (no user_id or location_id required). Both assign and unassign must properly integrate with the existing `poll_operation` infrastructure for wait/no-wait modes.

### Parent Task Context

**Parent Task:** Implement Number Assign/Unassign Operations (ID: 21)
**Parent Goal:** Add assign and unassign methods to NumberService with E911 location enforcement and async polling.
**This Subtask's Role:** Completes the parent task by implementing unassign_number and verifying both methods handle all error scenarios consistently.

### Dependencies
- Depends on: 21.1 (assign_number - DONE)
- Related: Task 20 (poll_operation and _extract_operation_id - DONE)
- Related: Task 15 (LocationService - DONE)

### Acceptance Criteria
1. Implement `unassign_number(phone_number, number_type, wait, timeout)` method
2. Request body contains only `telephoneNumber` and `numberType` (no userId/locationId)
3. POST to `/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber`
4. Handle 202 Accepted response, extract operation_id from Location header
5. If wait=True, poll using `poll_operation()` and return completed AsyncOperation
6. If wait=False, return AsyncOperation with status=NOT_STARTED immediately
7. Consistent error handling with assign_number for GraphClient errors (401, 403, 404, 429)
8. Handle operation failures (poll returns failed status)
9. Timeout handling passes through correctly

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 21.1 | assign_number method | Used `post_with_response()` for header access, `model_validate()` for AsyncOperation construction |
| 20.1 | poll_operation method | Terminal states: succeeded, failed, skipped. Uses `graph_client` (public), not `_graph_client` |
| 20.2 | _extract_operation_id | Pattern `r"operations\('([^']+)'\)"` - Location header uses OData format, polling endpoint uses REST format |

### Reusable Patterns from Dependencies
- **post_with_response pattern**: Implemented in `graph_client.py:426-455` during Task 21.1
- **AsyncOperation construction**: See `number_service.py:340-347` - use `model_validate()` with camelCase dict keys
- **Operation ID extraction**: See `number_service.py:251-280` - handles base64-encoded IDs with special characters

### Warnings and Gotchas
- **GraphClient uses public `graph_client` attribute**, NOT `_graph_client` (from Task 20.1)
- **Location header uses OData format**: `operations('id')` with single quotes and parentheses (from Task 20.2)
- **Polling endpoint uses REST format**: `operations/{id}` - different from Location header (from Task 20.2)
- **AsyncOperation requires camelCase keys when using model_validate**: Use `createdDateTime`, not `created_date_time` (from Task 21.1)
- **Request body field is `assignmentTargetId` for assign**, but unassign doesn't need it

### Open Items Inherited
- [x] Implement `unassign_number` method (this task)

---

## Architecture Context

### Relevant Design Decisions
- **Async operations return 202**: Graph API assign/unassign return 202 Accepted with Location header for polling
- **Repository pattern**: Services abstract Graph API access from CLI commands
- **Strict Pydantic models**: `extra="forbid"` for fail-fast on API contract changes

### Module Structure
```
src/teams_phone/
  services/
    number_service.py  <- Implement unassign_number here (alongside existing assign_number)
  infrastructure/
    graph_client.py    <- post_with_response() already available
  models/
    number.py          <- AsyncOperation, OperationStatus already defined
```

### Conventions to Follow
- Use `format_for_assign()` for phone number normalization (digits only, no + prefix)
- Use `post_with_response()` to access Location header from 202 response
- Use `_extract_operation_id()` to parse operation ID from Location header
- Use `poll_operation()` for wait=True scenarios
- Use `AsyncOperation.model_validate()` with dict for wait=False immediate return
- All async methods use `async def`
- Keyword-only arguments after `*` for clarity (see `assign_number` signature)

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/number_service.py` | 282-348 | assign_number implementation - **follow this exact pattern** |
| `src/teams_phone/services/number_service.py` | 198-249 | poll_operation implementation - understand polling interface |
| `src/teams_phone/services/number_service.py` | 251-280 | _extract_operation_id - operation ID extraction |
| `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md` | 145-183 | unassignNumber API contract |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/utils.py` | 44-64 | format_for_assign - phone number normalization |
| `src/teams_phone/models/number.py` | 76-93 | OperationStatus enum values |
| `src/teams_phone/models/number.py` | 110-129 | AsyncOperation model schema |
| `src/teams_phone/infrastructure/graph_client.py` | 426-455 | post_with_response method |
| `tests/unit/test_number_service.py` | 1285-1696 | TestNumberServiceAssignNumber - test patterns to follow |

### Code Patterns to Follow

```python
# Example pattern from assign_number (number_service.py:282-348)
async def assign_number(
    self,
    user_id: str,
    phone_number: str,
    number_type: NumberType,
    *,
    location_id: str | None = None,
    wait: bool = True,
    timeout: int = 60,
) -> AsyncOperation:
    # ... validation (E911 for assign only, NOT needed for unassign) ...

    # Build request body
    body: dict[str, Any] = {
        "telephoneNumber": format_for_assign(phone_number),
        "numberType": number_type.value,
        "assignmentTargetId": user_id,  # NOT NEEDED FOR UNASSIGN
    }
    if location_id:
        body["locationId"] = location_id  # NOT NEEDED FOR UNASSIGN

    # Make POST request
    response = await self.graph_client.post_with_response(
        "/admin/teams/telephoneNumberManagement/numberAssignments/assignNumber",
        body,
    )

    # Extract operation ID from Location header
    location_header = response.headers.get("location", "")
    operation_id = self._extract_operation_id(location_header)

    if wait:
        return await self.poll_operation(operation_id, timeout=timeout)

    # Return immediately with notStarted status
    return AsyncOperation.model_validate(
        {
            "id": operation_id,
            "createdDateTime": datetime.now(timezone.utc).isoformat(),
            "status": OperationStatus.NOT_STARTED.value,
            "numbers": [],
        }
    )
```

---

## Dependencies

### Internal Modules
- `teams_phone.utils.format_for_assign` - phone number normalization (digits only)
- `teams_phone.models.NumberType` - number type enum (directRouting, callingPlan, operatorConnect)
- `teams_phone.models.AsyncOperation` - return type for async operations
- `teams_phone.models.OperationStatus` - operation status enum values

### External Packages
- `datetime` - for timezone-aware timestamps in immediate return
- Already imported in number_service.py: `asyncio`, `random`, `re`, `time`, `typing.Any`

### New Dependencies Needed
- None required - all imports already available in number_service.py

---

## Testing Guidance

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

### Test Patterns in Use
```python
# Example test structure from TestNumberServiceAssignNumber (tests/unit/test_number_service.py:1348-1379)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_direct_routing_without_location_succeeds(
    self, mock_graph_client: MagicMock
) -> None:
    """assign_number succeeds for DirectRouting without location_id."""
    from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

    mock_response = SyncMock()
    mock_response.headers = {"location": "operations('op-123')"}
    mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
    mock_graph_client.get = AsyncMock(
        return_value=make_operation_dict(status="succeeded")
    )

    service = NumberService(mock_graph_client)

    with patch(
        "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
    ):
        result = await service.assign_number(
            user_id="user-123",
            phone_number="+12065551234",
            number_type=NumberType.DIRECT_ROUTING,
        )

    assert result.status == OperationStatus.SUCCEEDED
```

### Fixtures Available
- `mock_graph_client` - MagicMock for GraphClient (defined in test file line 93-96)
- `make_operation_dict()` - helper function to create async operation dicts (lines 22-52)
- `make_number_dict()` - helper function to create number assignment dicts (lines 55-90)

### Test Utilities
- `tests/conftest.py` - pytest markers: `unit`, `integration`, `contract`
- Use `@pytest.mark.unit` and `@pytest.mark.asyncio` decorators
- Mock patterns: `AsyncMock` for async methods, `MagicMock as SyncMock` for response objects
- Patch `asyncio.sleep` to avoid test delays

### Required Tests (from task details)
1. Successful unassign flow with mocked 202 response
2. Request body construction (only telephoneNumber and numberType - no userId/locationId)
3. wait=True triggers poll_operation integration
4. wait=False returns immediate AsyncOperation with status=NOT_STARTED
5. GraphClient error propagation (401, 403, 404, 429)
6. Operation failure handling (poll returns failed status)
7. Timeout handling passes through correctly

---

## Risks and Blockers

### Technical Risks
- [x] Low risk - Pattern well-established by assign_number implementation

### Dependency Risks
- [x] All dependencies complete (21.1, 20.1, 20.2)

### Open Questions
- None identified - API contract is clear and assign_number pattern is established

---

## Implementation Recommendations

### Suggested Approach
1. Add `unassign_number` method to NumberService after `assign_number` (line ~349)
2. Copy the structure from `assign_number` but simplify:
   - Remove E911 validation (not needed for unassign)
   - Remove `user_id` and `location_id` parameters
   - Simplify request body to just `telephoneNumber` and `numberType`
   - Change endpoint to `/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber`
3. Add test class `TestNumberServiceUnassignNumber` after existing test classes
4. Run `pytest tests/unit/test_number_service.py -v` to verify all tests pass
5. Run `mypy src/tests` to verify type checking
6. Run `ruff check src/ tests/` to verify linting

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/services/number_service.py` | Add unassign_number method |
| Modify | `tests/unit/test_number_service.py` | Add TestNumberServiceUnassignNumber test class |

### Quick Wins
- The method signature is simpler than assign_number (fewer parameters)
- No E911 validation logic needed
- Can directly reuse post_with_response, _extract_operation_id, poll_operation

---

## Cheat Sheet

### Key Imports
```python
# Already imported in number_service.py:
from datetime import datetime, timezone
from typing import Any
from teams_phone.models import AsyncOperation, NumberType, OperationStatus
from teams_phone.utils import format_for_assign
```

### Key Functions to Use
- `format_for_assign(phone_number)` - normalize phone number to digits only
- `self.graph_client.post_with_response(endpoint, body)` - POST with access to headers
- `self._extract_operation_id(location_header)` - parse operation ID from Location header
- `self.poll_operation(operation_id, timeout=timeout)` - poll until completion
- `AsyncOperation.model_validate({...})` - create immediate AsyncOperation for wait=False

### Common Patterns
- **Request body for unassign**: `{"telephoneNumber": "12065551234", "numberType": "directRouting"}`
- **Endpoint**: `/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber`
- **Wait=False return**: See `number_service.py:340-347`

### Method Signature
```python
async def unassign_number(
    self,
    phone_number: str,
    number_type: NumberType,
    *,
    wait: bool = True,
    timeout: int = 60,
) -> AsyncOperation:
```

---

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