# Task Recon: Implement UserService

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

---

## Task Summary

Create the UserService for listing, searching, and retrieving user voice configurations from Microsoft Graph API. This service abstracts Graph API operations for user data, providing:
- `list_users()` - List users with filtering (licensed, assigned, account_type) and pagination
- `get_user()` - Get a single user by ID
- `search_users()` - Search by display name, UPN, or phone number
- `resolve_user()` - Resolve user by UPN, display name, or object ID with disambiguation

### Dependencies
- Depends on: 7 (GraphClient with Retry Logic - done), 9 (Pydantic Models - done), 39 (Validation Checkpoint 1 - done)

### Acceptance Criteria
- UserService class with all four public methods implemented
- Uses `/admin/teams/userConfigurations` endpoint for user operations
- Pydantic model validation with strict mode (extra='forbid')
- Proper error handling with NotFoundError and ValidationError for ambiguous results
- Unit tests with respx mocking for all methods

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 7 | GraphClient with Retry Logic | GraphClient is async-only, must be used as async context manager. Token acquisition is sync. Uses respx for HTTP mocking in tests. |
| 9 | Pydantic Models | UserConfiguration model exists with all fields. Use `model_validate()` for dict-based construction. `extra="forbid"` ensures contract validation. |

### Reusable Patterns from Dependencies
- **Async context manager pattern**: See `graph_client.py:491-518` - `async with GraphClient(...) as client:`
- **Pagination with get_paginated()**: See `graph_client.py:426-489` - yields individual items from paginated responses
- **UserConfiguration model**: See `models/user.py:80-118` - ready to use for response parsing
- **GraphListResponse generic**: See `models/api_responses.py:47-69` - `GraphListResponse[UserConfiguration].model_validate()`

### Warnings and Gotchas
- **mypy strict mode + aliases**: When constructing UserConfiguration in tests, use camelCase aliases or `model_validate()` with dicts
- **Token refresh is sync**: `auth_service.refresh_token()` is synchronous, called from async `_request()`
- **respx url__eq for pagination**: Use `respx.get(url__eq="...")` for exact URL matching to prevent infinite loops

### Open Items Inherited
- None directly applicable

---

## Architecture Context

### Relevant Design Decisions
- **Services layer abstracts Graph API**: Services provide business logic, using GraphClient for HTTP and Pydantic for validation
- **Repository pattern**: Services encapsulate data access (Graph API + CSV cache) - see CLAUDE.md
- **Async-only design**: All Graph API operations are async

### Module Structure
```
src/teams_phone/
├── services/
│   ├── __init__.py          # Export UserService
│   ├── auth_service.py      # Auth operations (pattern reference)
│   ├── tenant_service.py    # Tenant operations (pattern reference)
│   └── user_service.py      # NEW: User operations
```

### Conventions to Follow
- Use `ConfigDict(extra="forbid", populate_by_name=True)` for strict models
- Raise specific exceptions from `teams_phone.exceptions` hierarchy
- Use type hints for all function signatures (mypy strict mode)
- Document with docstrings following existing patterns

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/infrastructure/graph_client.py` | 426-489 | `get_paginated()` method for list operations with pagination |
| `src/teams_phone/infrastructure/graph_client.py` | 367-395 | `get()` method for single resource retrieval |
| `src/teams_phone/models/user.py` | 80-118 | `UserConfiguration` model with all fields |
| `src/teams_phone/services/auth_service.py` | 42-64 | Service class pattern with dependency injection |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/exceptions.py` | 72-82 | NotFoundError for user not found |
| `src/teams_phone/exceptions.py` | 85-95 | ValidationError for ambiguous results |
| `src/teams_phone/models/api_responses.py` | 47-69 | GraphListResponse[T] generic wrapper |
| `.taskmaster/docs/graph-api-contracts/1-user-operations-verified.md` | 1-107 | Graph API endpoint documentation |

### Code Patterns to Follow

```python
# Service class pattern (auth_service.py:42)
class UserService:
    """Service for user voice configuration operations."""

    def __init__(self, graph_client: GraphClient) -> None:
        """Initialize the UserService.

        Args:
            graph_client: GraphClient instance for API communication.
        """
        self.graph_client = graph_client
```

```python
# Pagination pattern (graph_client.py:426)
async def list_users(
    self,
    licensed: bool | None = None,
    assigned: bool | None = None,
    account_type: AccountType | None = None,
    page_size: int = 100,
    max_items: int | None = None,
) -> list[UserConfiguration]:
    users: list[UserConfiguration] = []
    params: dict[str, Any] = {"$top": str(page_size)}

    # Build $filter string based on parameters
    filters: list[str] = []
    if licensed is not None:
        filters.append(f"isEnterpriseVoiceEnabled eq {str(licensed).lower()}")
    if assigned is not None:
        # Filter by telephoneNumbers being empty or not
        if assigned:
            filters.append("telephoneNumbers/$count gt 0")
        else:
            filters.append("telephoneNumbers/$count eq 0")
    if account_type is not None:
        filters.append(f"accountType eq '{account_type.value}'")

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

    async for item in self.graph_client.get_paginated(
        "/admin/teams/userConfigurations",
        params=params,
        max_items=max_items,
    ):
        users.append(UserConfiguration.model_validate(item))

    return users
```

---

## Dependencies

### Internal Modules
- `teams_phone.infrastructure.GraphClient` - HTTP client for Graph API
- `teams_phone.models.UserConfiguration` - User configuration Pydantic model
- `teams_phone.models.AccountType` - User account type enum
- `teams_phone.exceptions` - NotFoundError, ValidationError, ContractError

### External Packages
- `pydantic` (2.x) - Model validation via `model_validate()`
- No new external dependencies needed

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_user_service.py` (NEW)

### Test Patterns in Use
```python
# respx mocking pattern (test_graph_client.py:204-218)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_users_returns_users(
    mock_graph_client: MagicMock
) -> None:
    """list_users returns list of UserConfiguration objects."""
    response_data = {
        "value": [
            {
                "id": "user-1",
                "userPrincipalName": "user1@contoso.com",
                "tenantId": "tenant-id",
                "accountType": "user",
                "isEnterpriseVoiceEnabled": True,
                "featureTypes": ["Teams"],
                "telephoneNumbers": [],
                "effectivePolicyAssignments": [],
            }
        ]
    }
    # Mock get_paginated to yield items
    async def mock_paginated(*args, **kwargs):
        for item in response_data["value"]:
            yield item

    mock_graph_client.get_paginated = mock_paginated

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

    assert len(users) == 1
    assert users[0].id == "user-1"
    assert isinstance(users[0], UserConfiguration)
```

### Fixtures Available
- `mock_auth_service` (test_graph_client.py:29-34) - Mock AuthService returning test token
- Create new `mock_graph_client` fixture for UserService tests

### Test Utilities
- `pytest.mark.unit` - Unit test marker
- `pytest.mark.asyncio` - Async test marker
- `respx.mock` - HTTP mocking context manager

---

## Risks and Blockers

### Technical Risks
- [x] **Filter syntax for Graph API**: OData filter syntax may need verification. The telephoneNumbers count filter (`telephoneNumbers/$count gt 0`) needs testing against real API.
- [x] **Search endpoint selection**: Task mentions using userConfigurations for search by phone number. Need to verify $search vs $filter behavior.

### Dependency Risks
- None - all dependencies (GraphClient, UserConfiguration model) are complete

### Open Questions
- [ ] Should `resolve_user()` try all resolution methods (UPN, ID, display name) in sequence, or should it determine the identifier type first?
- [ ] For `search_users()`, should phone number search use exact match or partial match?
- [ ] What is the behavior when `list_users(assigned=True)` and a user has no phone numbers? Is `telephoneNumbers/$count gt 0` the correct filter?

---

## Implementation Recommendations

### Suggested Approach
1. Create `src/teams_phone/services/user_service.py` with class skeleton
2. Implement `list_users()` with pagination via `get_paginated()`
3. Implement `get_user()` with direct GET to `/admin/teams/userConfigurations/{id}`
4. Implement `search_users()` with $filter queries
5. Implement `resolve_user()` with identifier type detection and disambiguation
6. Update `src/teams_phone/services/__init__.py` to export UserService
7. Create comprehensive unit tests in `tests/unit/test_user_service.py`

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/services/user_service.py` | Main UserService implementation |
| Modify | `src/teams_phone/services/__init__.py` | Export UserService |
| Create | `tests/unit/test_user_service.py` | Unit tests for UserService |

### Quick Wins
- Start with `get_user()` - simplest method, validates GraphClient integration
- Then `list_users()` without filters - validates pagination
- Add filters incrementally

---

## Cheat Sheet

### Key Imports
```python
from typing import Any

from teams_phone.exceptions import NotFoundError, ValidationError, ContractError
from teams_phone.infrastructure import GraphClient
from teams_phone.models import AccountType, UserConfiguration
```

### Key Functions to Use
- `graph_client.get(endpoint, params)` - Single resource retrieval
- `graph_client.get_paginated(endpoint, params, max_items)` - Paginated iteration
- `UserConfiguration.model_validate(dict)` - Parse API response to model

### Common Patterns
- **Pagination**: `async for item in self.graph_client.get_paginated(...): yield item`
- **Model validation**: `UserConfiguration.model_validate(response)`
- **OData filter**: `params["$filter"] = "accountType eq 'user'"`
- **Error handling**: `raise NotFoundError(f"User '{user_id}' not found")`

### Graph API Endpoints
| Operation | Endpoint | Method |
|-----------|----------|--------|
| List users | `/admin/teams/userConfigurations` | GET |
| Get user | `/admin/teams/userConfigurations/{id}` | GET |
| Search users | `/admin/teams/userConfigurations?$filter=...` | GET |

### OData Filter Examples
```python
# Filter by enterprise voice enabled
"$filter": "isEnterpriseVoiceEnabled eq true"

# Filter by account type
"$filter": "accountType eq 'user'"

# Filter by UPN (exact match)
"$filter": "userPrincipalName eq 'user@contoso.com'"

# Filter by display name (starts with)
"$filter": "startsWith(displayName, 'John')"

# Search by phone number (in telephoneNumbers collection)
"$filter": "telephoneNumbers/any(t: t/telephoneNumber eq '+13235533696')"
```

---

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