# Task Recon: Implement list_numbers with status/type/city filters and pagination

**Task ID:** 19.1
**Status:** pending
**Generated:** 2026-01-29T16:15:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create the `list_numbers` method in `NumberService` following the `UserService.list_users` pattern with OData `$filter` support for `AssignmentStatus`, `NumberType`, and city parameters. This method will retrieve phone number assignments from the Graph API `/admin/teams/telephoneNumberManagement/numberAssignments` endpoint with pagination support.

### Parent Task Context

**Parent Task:** Implement NumberService Core Operations (ID: 19)
**Parent Goal:** Create the NumberService for listing, searching, and retrieving phone number inventory from Graph API, providing core CRUD-like operations for phone numbers.
**This Subtask's Role:** Implements the foundational `list_numbers` method that will be used by sibling subtasks (`get_number`, `search_numbers`) and provides the pagination/filtering infrastructure for the entire NumberService.

### Dependencies
- Depends on: Task 7 (GraphClient), Task 9 (Pydantic Models), Task 10 (Phone Utils) - all **completed**

### Acceptance Criteria
1. Implement `list_numbers` method that accepts optional filters: `status`, `number_type`, `city`, `page_size`, `max_items`
2. Build OData `$filter` string dynamically based on provided filters
3. Use `GraphClient.get_paginated()` with endpoint `/admin/teams/telephoneNumberManagement/numberAssignments`
4. Validate each response item against `NumberAssignment.model_validate()`
5. Follow the async iterator pattern from `UserService.list_users`
6. Return `list[NumberAssignment]`

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 7 | GraphClient with Retry Logic | `get_paginated()` returns `AsyncIterator[dict[str, Any]]`, yields individual items. Use `async for item in client.get_paginated(...)`. Params passed as dict with `$top`, `$filter` keys. |
| 9 | Pydantic Models | `NumberAssignment` model exists with all fields. Use `NumberAssignment.model_validate(item)` for each yielded dict. `extra="forbid"` catches API contract changes. |
| 10 | Phone Number Utilities | `normalize_phone_number()`, `format_for_update()`, `match_pattern()` exist in `utils.py`. City filter uses raw string (not phone number format). |

### Reusable Patterns from Dependencies

- **OData filter construction**: Implemented in `UserService.list_users` at `user_service.py:76-89`
- **Pagination pattern**: Async iteration with `get_paginated()` at `user_service.py:91-96`
- **Enum value access**: Use `enum.value` for OData filters, e.g., `status.value` gives string like `"userAssigned"`

### Warnings and Gotchas

**From Task 7.3:**
- **respx prefix matching**: For tests with pagination, use `respx.get(url__eq="...")` for exact URL matching to prevent infinite loops.
- **nextLink URL handling**: Don't use `_build_url()` for nextLink - handled internally by `get_paginated()`.

**From Task 9.2:**
- **datetime string input in tests**: When testing, use string values with `# type: ignore[arg-type]` since mypy expects datetime objects but Pydantic parses strings.

**From Graph API Contracts (2-phone-number-operations-verified.md:19):**
- **Cannot filter by `id` or `city`**: The Graph API explicitly states `$filter` cannot filter by `id` or `city`. City filtering must be done client-side.

### Open Items Inherited
- None directly relevant to this subtask.

---

## Architecture Context

### Relevant Design Decisions

- **Layered Architecture**: Services abstract Graph API access (repository pattern). NumberService belongs in `src/teams_phone/services/`.
- **Strict Pydantic Models**: Use `extra="forbid"` for fail-fast on API contract changes.
- **Async-first**: All service methods use async/await pattern with GraphClient.

### Module Structure

```
src/teams_phone/
├── services/
│   ├── __init__.py          # Export NumberService here
│   ├── user_service.py      # Reference pattern for list_users
│   ├── location_service.py  # Cache-based service (different pattern)
│   └── number_service.py    # CREATE THIS FILE
├── models/
│   ├── number.py            # NumberAssignment, AssignmentStatus, NumberType enums
│   └── __init__.py          # Already exports all number models
└── infrastructure/
    └── graph_client.py      # GraphClient.get_paginated()
```

### Conventions to Follow

- **Type hints**: Use strict typing with `TYPE_CHECKING` import guard for GraphClient
- **Docstrings**: Google-style docstrings with Args, Returns, Raises sections
- **Enum usage**: Import enums from `teams_phone.models`, use `.value` in filters
- **Filter pattern**: Build filter list, join with ` and `, only add `$filter` if non-empty

---

## Related Code References

### Primary References (Must Read)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/user_service.py` | 37-98 | **Primary pattern**: `list_users` method with OData filters and pagination |
| `src/teams_phone/infrastructure/graph_client.py` | 426-490 | `get_paginated()` method - understand params and return type |
| `src/teams_phone/models/number.py` | 46-59, 132-186 | `AssignmentStatus`, `NumberType` enums and `NumberAssignment` model |

### Secondary References (Helpful Context)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/exceptions.py` | 72-83 | `NotFoundError` pattern (will be used in get_number) |
| `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md` | 1-55 | API contract for numberAssignments endpoint |
| `tests/unit/test_user_service.py` | 70-345 | Test patterns for service list methods |

### Code Patterns to Follow

```python
# Pattern from user_service.py:37-98 - list method with filters
async def list_numbers(
    self,
    *,
    status: AssignmentStatus | None = None,
    number_type: NumberType | None = None,
    city: str | None = None,
    page_size: int = 100,
    max_items: int | None = None,
) -> list[NumberAssignment]:
    """List phone numbers with optional filtering and pagination."""
    numbers: list[NumberAssignment] = []
    params: dict[str, Any] = {"$top": str(min(page_size, 999))}

    # Build OData $filter string based on parameters
    filters: list[str] = []
    if status is not None:
        filters.append(f"assignmentStatus eq '{status.value}'")
    if number_type is not None:
        filters.append(f"numberType eq '{number_type.value}'")
    # NOTE: city cannot be filtered via OData - see warnings section

    if filters:
        params["$filter"] = " and ".join(filters)

    async for item in self.graph_client.get_paginated(
        "/admin/teams/telephoneNumberManagement/numberAssignments",
        params=params,
        max_items=max_items,
    ):
        number = NumberAssignment.model_validate(item)
        # Client-side city filter (OData doesn't support city filter)
        if city is not None and number.city != city:
            continue
        numbers.append(number)

    return numbers
```

---

## Dependencies

### Internal Modules
- `teams_phone.infrastructure.graph_client.GraphClient` - HTTP client with pagination
- `teams_phone.models.number.NumberAssignment` - Pydantic model for response
- `teams_phone.models.number.AssignmentStatus` - Enum for status filter
- `teams_phone.models.number.NumberType` - Enum for number type filter

### External Packages
- `pydantic` - Already in project, used for model validation
- No new dependencies needed

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_number_service.py` (CREATE NEW FILE)

### Test Patterns in Use

```python
# Pattern from tests/unit/test_user_service.py:70-92
@pytest.fixture
def mock_graph_client() -> MagicMock:
    """Create a mock GraphClient for testing."""
    return MagicMock()


class TestNumberServiceListNumbers:
    """Tests for NumberService.list_numbers method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_returns_empty_list(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers returns empty list when no numbers 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 = NumberService(mock_graph_client)
        numbers = await service.list_numbers()

        assert numbers == []
```

### Fixtures Available
- No specific fixtures needed - create mock GraphClient inline (see pattern above)

### Test Utilities
- `tests/conftest.py` - Registers `@pytest.mark.unit`, `@pytest.mark.asyncio` markers
- Use `typing.AsyncIterator` for mock paginated responses
- Use `unittest.mock.MagicMock` and `unittest.mock.AsyncMock`

### Tests to Write
1. `test_list_numbers_returns_empty_list` - Empty paginated response
2. `test_list_numbers_returns_number_assignments` - Multiple items returned
3. `test_list_numbers_uses_correct_endpoint` - Verify `/admin/teams/telephoneNumberManagement/numberAssignments`
4. `test_list_numbers_with_status_filter` - AssignmentStatus filter in $filter
5. `test_list_numbers_with_number_type_filter` - NumberType filter in $filter
6. `test_list_numbers_with_city_filter` - Client-side city filtering
7. `test_list_numbers_with_multiple_filters` - Combined filters joined with " and "
8. `test_list_numbers_with_page_size` - `$top` parameter
9. `test_list_numbers_caps_page_size_at_999` - Max page size enforcement
10. `test_list_numbers_with_max_items` - `max_items` passed to get_paginated

---

## Risks and Blockers

### Technical Risks
- [x] **City filter is client-side**: Graph API cannot filter by city (documented in API contracts). This means all numbers are fetched and filtered locally, which may impact performance for large inventories. Consider warning in docstring.

### Dependency Risks
- None - all dependency tasks (7, 9, 10) are marked as done

### Open Questions
- [x] **Clarified**: Task details specify city filter should use OData, but Graph API docs say city cannot be filtered. Decision: Use client-side filtering for city (per API limitation).

---

## Implementation Recommendations

### Suggested Approach

1. Create `src/teams_phone/services/number_service.py` with NumberService class
2. Add `__init__` accepting `GraphClient` (follow UserService pattern)
3. Implement `list_numbers` method with:
   - Optional filter parameters: `status`, `number_type`, `city`, `page_size`, `max_items`
   - OData `$filter` construction for `assignmentStatus` and `numberType`
   - Client-side filtering for `city` (API limitation)
   - `get_paginated()` call with async iteration
   - `NumberAssignment.model_validate()` for each item
4. Update `src/teams_phone/services/__init__.py` to export NumberService
5. Create comprehensive unit tests in `tests/unit/test_number_service.py`

### Files to Create/Modify

| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/services/number_service.py` | Main service implementation |
| Create | `tests/unit/test_number_service.py` | Unit tests |
| Modify | `src/teams_phone/services/__init__.py` | Export NumberService |

### Quick Wins
- Copy the `UserService.list_users` method structure directly and adapt for NumberService
- The endpoint and filter field names are the only significant changes needed

---

## Cheat Sheet

### Key Imports

```python
from __future__ import annotations

from typing import TYPE_CHECKING, Any

from teams_phone.models import AssignmentStatus, NumberAssignment, NumberType

if TYPE_CHECKING:
    from teams_phone.infrastructure.graph_client import GraphClient
```

### Key Functions to Use
- `GraphClient.get_paginated(endpoint, params=dict, max_items=int|None)` - Async iterator yielding dicts
- `NumberAssignment.model_validate(dict)` - Parse and validate API response
- `AssignmentStatus.value` / `NumberType.value` - Get string value for OData filter

### Common Patterns
- **Filter construction**: `user_service.py:76-89`
- **Pagination iteration**: `user_service.py:91-96`
- **Enum filter format**: `f"assignmentStatus eq '{status.value}'"` (note: lowercase field name per API)

### API Endpoint
```
GET /admin/teams/telephoneNumberManagement/numberAssignments
```

### OData Filter Examples
```
$filter=assignmentStatus eq 'unassigned'
$filter=numberType eq 'directRouting'
$filter=assignmentStatus eq 'userAssigned' and numberType eq 'callingPlan'
```

### NumberAssignment Model Fields (for reference)
- `telephone_number` (alias: telephoneNumber)
- `number_type` (alias: numberType) - NumberType enum
- `assignment_status` (alias: assignmentStatus) - AssignmentStatus enum
- `city` - Optional[str], used for client-side filtering
- `location_id` (alias: locationId) - Optional[str]
- `assignment_target_id` (alias: assignmentTargetId) - Optional[str], user's Entra ID

---

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