# Task ID: 13

**Title:** Implement TenantService

**Status:** pending

**Dependencies:** 4, 6

**Priority:** high

**Description:** Create the TenantService for managing tenant profiles including list, add, remove, switch, and connection testing.

**Details:**

Create `src/teams_phone/services/tenant_service.py`:
```python
class TenantService:
    def __init__(self, config_manager: ConfigManager, auth_service: AuthService): ...
    
    def list_tenants(self) -> list[TenantProfile]: ...
    def get_current_tenant(self) -> TenantProfile: ...
    def switch_tenant(self, name: str) -> TenantProfile: ...
    def add_tenant(self, profile: TenantProfile) -> TenantProfile: ...
    def remove_tenant(self, name: str) -> None: ...
    def test_connection(self, name: str) -> ConnectionTestResult: ...
```

switch_tenant: validate tenant exists, check if authenticated (auto-login if needed), update default tenant.
test_connection: attempt token acquisition, make test Graph API call, validate permissions.
ConnectionTestResult: includes success bool, latency, permissions list, error message.

**Test Strategy:**

Unit tests: tenant CRUD operations with mocked ConfigManager, switch behavior with token state, connection test with mocked Graph responses.

---

## Implementation Notes

### Session: 2026-01-28 12:15 - Agent: claude-sonnet-4-5-20250929

**Subtask:** 13.1 - Implement list/get/add/remove tenant operations with ConfigManager

**Status:** Implemented

#### What Was Done
- Created `src/teams_phone/services/tenant_service.py` with TenantService class
- Implemented `__init__` storing config_manager and auth_service references
- Implemented `list_tenants()` returning sorted list of all tenants by name
- Implemented `get_current_tenant()` returning default tenant with ConfigurationError if none
- Implemented `add_tenant()` with auto-set first tenant as default logic
- Implemented `remove_tenant()` with auth status check (informational only)
- Updated `src/teams_phone/services/__init__.py` to export TenantService
- Created `tests/unit/test_tenant_service.py` with 17 comprehensive unit tests

#### Key Decisions Made
- **Auth check is informational only**: `remove_tenant` checks `is_authenticated()` but does not block removal or clear tokens. Tokens expire naturally or can be cleared with `auth logout`. This follows the principle of least surprise.
- **Return profile from add_tenant**: Returns the same profile passed in for consistency with service patterns (allows chaining or immediate use).
- **TYPE_CHECKING import guards**: Used `if TYPE_CHECKING:` for AuthService and ConfigManager imports to avoid circular import issues.

#### Patterns Discovered
- **Service class pattern**: Followed `AuthService` pattern at `auth_service.py:42-63` for `__init__` structure and docstrings
- **ConfigManager delegation**: All CRUD operations delegate to ConfigManager methods which handle persistence and NotFoundError
- **Sorted list pattern**: `sorted(config.tenants.values(), key=lambda t: t.name)` for consistent ordering

#### Open Items for Next Agent
- [x] Subtask 13.2: Implement `switch_tenant()` with auto-login functionality
- [ ] Subtask 13.3: Implement `test_connection()` with ConnectionTestResult model

#### Gotchas and Warnings
- ConfigManager's `set_tenant()` requires profile.name to match the key - this is validated by ConfigManager
- `is_authenticated()` never throws, always safe to call even for non-existent tenants
- The `_is_authenticated` variable in `remove_tenant` is prefixed with `_` to indicate it's intentionally unused (ruff compliance)

---

### Session: 2026-01-28 23:30 - Agent: claude-sonnet-4-5-20250929

**Subtask:** 13.2 - Implement switch_tenant with auto-login logic

**Status:** Implemented

#### What Was Done
- Added `switch_tenant(name: str, *, auto_login: bool = True) -> TenantProfile` method to TenantService
- Method validates tenant exists via `config_manager.get_tenant(name)` (raises NotFoundError if not)
- Checks authentication status via `auth_service.is_authenticated(name)`
- If not authenticated and `auto_login=True`, attempts login via `auth_service.login(name)`
- Only updates default tenant after successful auth check/login (atomic behavior)
- Returns the TenantProfile for the switched tenant
- Added 8 comprehensive unit tests in `TestSwitchTenant` class covering all scenarios

#### Key Decisions Made
- **Atomic operation ordering**: Validate → Auth check → Login (if needed) → Update default. This ensures default_tenant is NOT updated if login fails.
- **Keyword-only auto_login parameter**: Used `*, auto_login: bool = True` to prevent positional argument confusion
- **Let AuthenticationError propagate naturally**: Don't catch and re-raise - cleaner code and preserves original stack trace

#### Patterns Discovered
- **is_authenticated never-throw pattern**: Safe to use in conditionals without try/except (see `auth_service.py:419-441`)
- **login() signature**: Takes optional `tenant_name` and `force` parameter, returns CachedToken (see `auth_service.py:485-511`)
- **Test mocking pattern**: Use `MagicMock(spec=AuthService)` with `.return_value` and `.side_effect` for controlled test behavior

#### Test Coverage Added
1. `test_switch_to_valid_tenant_updates_default` - Happy path
2. `test_switch_to_nonexistent_tenant_raises_not_found` - NotFoundError case
3. `test_auto_login_triggers_when_not_authenticated` - Auto-login behavior
4. `test_auto_login_failure_propagates_exception` - Atomic behavior (no default change on failure)
5. `test_skips_login_when_already_authenticated` - Skip login when authenticated
6. `test_auto_login_false_skips_login` - Explicit auto_login=False
7. `test_returns_tenant_profile` - Return value validation
8. `test_switch_to_current_tenant_is_idempotent` - Idempotent behavior

#### Open Items for Next Agent
- [x] Subtask 13.3: Implement `test_connection()` with ConnectionTestResult model

#### Gotchas and Warnings
- `set_default_tenant()` internally validates tenant exists, but we call `get_tenant()` first anyway for early validation and to return the profile
- No logging added per recon recommendation - logging infrastructure not established yet, defer to future task

---

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

**Subtask:** 13.3 - Implement test_connection with permission validation

**Status:** Implemented

#### What Was Done
- Created `ConnectionTestResult` Pydantic model in `src/teams_phone/models/auth.py`
  - Fields: `success: bool`, `tenant_name: str`, `latency_ms: Optional[int]`, `permissions: list[str]`, `error_message: Optional[str]`
  - Uses `ConfigDict(extra="forbid")` for strict mode
- Exported `ConnectionTestResult` from `src/teams_phone/models/__init__.py`
- Added `test_connection(name: str) -> ConnectionTestResult` method to TenantService
  - Uses `time.perf_counter()` for high-precision latency measurement
  - Validates tenant exists via `config_manager.get_tenant(name)`
  - Acquires fresh token via `auth_service.login(name, force=True)`
  - Makes sync HTTP call to `GET /v1.0/organization` using `httpx.Client`
  - Returns token scopes as permissions on success
  - Catches ALL exceptions and returns safe result (never raises)
- Added 9 comprehensive unit tests in `TestTestConnection` class

#### Key Decisions Made
- **Sync httpx.Client instead of async GraphClient**: Since TenantService methods are synchronous and GraphClient is async, used sync `httpx.Client` directly to avoid `asyncio.run()` complexity
- **Return token scopes as permissions**: Task mentioned parsing JWT claims or calling `/oauth2PermissionGrants`, but returning `cached_token.scopes` is simpler and provides meaningful info about granted scopes
- **Use /v1.0/organization endpoint**: Stable V1.0 endpoint that requires `Directory.Read.All` permission, suitable for service principal auth (no user context needed)
- **Latency includes full round-trip**: Timer starts before tenant validation and ends after API response, measuring total operation time

#### Patterns Discovered
- **Exception swallowing pattern**: See `auth_service.py:419-441` - catch all exceptions, return safe default value
- **perf_counter for latency**: Standard Python approach for high-precision timing
- **httpx context manager**: `with httpx.Client(timeout=30.0) as client:` for proper resource cleanup

#### Test Coverage Added
1. `test_successful_connection_returns_success_true` - Happy path with success, latency, no error
2. `test_successful_connection_includes_permissions` - Validates scopes returned as permissions
3. `test_auth_failure_returns_success_false` - AuthenticationError captured in result
4. `test_tenant_not_found_returns_success_false` - NotFoundError captured in result
5. `test_network_error_returns_success_false` - httpx.ConnectError captured
6. `test_http_error_returns_success_false` - HTTP status error captured
7. `test_latency_is_positive_on_success` - Latency >= 0
8. `test_never_raises_exception` - RuntimeError captured (never throws)
9. `test_calls_login_with_force_true` - Verifies force=True for fresh token

#### Open Items for Next Agent
- [ ] Parent task 13 should be marked done after this subtask completes
- [ ] Future enhancement: Parse JWT token claims for actual permission names instead of scopes

#### Gotchas and Warnings
- `httpx.Client` must be mocked properly in tests using context manager pattern
- Latency is computed even on failure, providing diagnostic info about where failure occurred
- The `BLE001` noqa comment suppresses ruff warning for broad exception catch (intentional for never-raise pattern)

---
