"""Tests for User Preferences API — CRUD, tenant isolation, RBAC enforcement.""" from __future__ import annotations import pytest from httpx import AsyncClient from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users async def _login_with_csrf(client: AsyncClient, email: str) -> str: """Login and return the CSRF token for subsequent unsafe requests.""" resp = await client.post( "/api/v1/auth/login", json={"email": email, "password": "TestPass123!"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}" return resp.json()["csrf_token"] def _csrf_headers(csrf_token: str) -> dict: """Return headers dict with Origin + X-CSRF-Token for unsafe methods.""" return {**ORIGIN_HEADER, "X-CSRF-Token": csrf_token} @pytest.mark.asyncio class TestUserPreferencesList: """GET /api/v1/user/preferences — list all preferences for current user.""" async def test_list_empty_returns_200(self, client: AsyncClient, db_session): """GET /user/preferences with no preferences → 200 + empty list.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert "preferences" in data assert data["preferences"] == [] async def test_list_returns_saved_preferences(self, client: AsyncClient, db_session): """GET /user/preferences after PUT → 200 + saved entries.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") # Save a preference first resp = await client.put( "/api/v1/user/preferences/theme", json={"value": "dark"}, headers=_csrf_headers(csrf), ) assert resp.status_code == 200 resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert len(data["preferences"]) >= 1 keys = [p["key"] for p in data["preferences"]] assert "theme" in keys async def test_list_unauthenticated_returns_401(self, client: AsyncClient): """GET /user/preferences without auth → 401.""" resp = await client.get("/api/v1/user/preferences") assert resp.status_code == 401 @pytest.mark.asyncio class TestUserPreferencesGet: """GET /api/v1/user/preferences/{key} — get single preference.""" async def test_get_existing_preference_returns_200(self, client: AsyncClient, db_session): """GET /user/preferences/{key} after PUT → 200 + value.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") await client.put( "/api/v1/user/preferences/sidebar_open", json={"value": False}, headers=_csrf_headers(csrf), ) resp = await client.get( "/api/v1/user/preferences/sidebar_open", headers=ORIGIN_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["key"] == "sidebar_open" assert data["value"] is False async def test_get_nonexistent_returns_404(self, client: AsyncClient, db_session): """GET /user/preferences/{key} for missing key → 404.""" await seed_tenant_and_users(db_session) await _login_with_csrf(client, "admin@tenanta.com") resp = await client.get( "/api/v1/user/preferences/nonexistent", headers=ORIGIN_HEADER ) assert resp.status_code == 404 @pytest.mark.asyncio class TestUserPreferencesUpsert: """PUT /api/v1/user/preferences/{key} — create or update preference.""" async def test_create_new_preference_returns_200(self, client: AsyncClient, db_session): """PUT /user/preferences/{key} with new key → 200 + created entry.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") resp = await client.put( "/api/v1/user/preferences/theme", json={"value": "dark"}, headers=_csrf_headers(csrf), ) assert resp.status_code == 200 data = resp.json() assert data["key"] == "theme" assert data["value"] == "dark" assert "updated_at" in data async def test_update_existing_preference_returns_200(self, client: AsyncClient, db_session): """PUT /user/preferences/{key} with existing key → 200 + updated value.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") # Create await client.put( "/api/v1/user/preferences/locale", json={"value": "de"}, headers=_csrf_headers(csrf), ) # Update resp = await client.put( "/api/v1/user/preferences/locale", json={"value": "en"}, headers=_csrf_headers(csrf), ) assert resp.status_code == 200 data = resp.json() assert data["key"] == "locale" assert data["value"] == "en" async def test_upsert_complex_json_value(self, client: AsyncClient, db_session): """PUT /user/preferences/{key} with complex JSON value → 200.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") complex_value = { "sort_by": "name", "sort_order": "asc", "filters": {"industry": "IT", "status": "active"}, } resp = await client.put( "/api/v1/user/preferences/contact_list_settings", json={"value": complex_value}, headers=_csrf_headers(csrf), ) assert resp.status_code == 200 data = resp.json() assert data["value"]["sort_by"] == "name" assert data["value"]["filters"]["industry"] == "IT" async def test_upsert_unauthenticated_returns_401(self, client: AsyncClient): """PUT /user/preferences/{key} without auth → 401 or 403 (CSRF block).""" resp = await client.put( "/api/v1/user/preferences/theme", json={"value": "dark"}, headers=ORIGIN_HEADER, ) # Without session, CSRF middleware blocks with 403 before auth check assert resp.status_code in (401, 403) @pytest.mark.asyncio class TestUserPreferencesDelete: """DELETE /api/v1/user/preferences/{key} — remove a preference.""" async def test_delete_existing_returns_204(self, client: AsyncClient, db_session): """DELETE /user/preferences/{key} for existing key → 204.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") # Create first await client.put( "/api/v1/user/preferences/theme", json={"value": "dark"}, headers=_csrf_headers(csrf), ) resp = await client.delete( "/api/v1/user/preferences/theme", headers=_csrf_headers(csrf) ) assert resp.status_code == 204 async def test_delete_nonexistent_returns_404(self, client: AsyncClient, db_session): """DELETE /user/preferences/{key} for missing key → 404.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") resp = await client.delete( "/api/v1/user/preferences/nonexistent", headers=_csrf_headers(csrf) ) assert resp.status_code == 404 async def test_delete_then_get_returns_404(self, client: AsyncClient, db_session): """After DELETE, GET /user/preferences/{key} → 404.""" await seed_tenant_and_users(db_session) csrf = await _login_with_csrf(client, "admin@tenanta.com") await client.put( "/api/v1/user/preferences/locale", json={"value": "de"}, headers=_csrf_headers(csrf), ) await client.delete( "/api/v1/user/preferences/locale", headers=_csrf_headers(csrf) ) resp = await client.get( "/api/v1/user/preferences/locale", headers=ORIGIN_HEADER ) assert resp.status_code == 404 @pytest.mark.asyncio class TestUserPreferencesTenantIsolation: """Preferences are tenant-scoped — users in different tenants can't see each other.""" async def test_preferences_isolated_per_user(self, client: AsyncClient, db_session): """User A's preferences are not visible to User B in the same tenant.""" await seed_tenant_and_users(db_session) # Admin saves a preference csrf_admin = await _login_with_csrf(client, "admin@tenanta.com") await client.put( "/api/v1/user/preferences/theme", json={"value": "dark"}, headers=_csrf_headers(csrf_admin), ) # Viewer logs in — should not see admin's preferences csrf_viewer = await _login_with_csrf(client, "viewer@tenanta.com") resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() keys = [p["key"] for p in data["preferences"]] assert "theme" not in keys