"""Integration tests: profile repository + GET/PATCH /api/v1/me/profile (002/003).""" from __future__ import annotations import asyncio import uuid import httpx import pytest from contract_check.core.db.repositories import UserProfileRepository from contract_check.core.db.session import create_session_factory from tests.integration.conftest import user_token pytestmark = pytest.mark.integration def _unique_tg() -> int: return abs(int(uuid.uuid4().int % 900000000000)) + 100000000000 async def _make_user(client: httpx.AsyncClient, infra: dict[str, str]) -> tuple[int, str]: tg = _unique_tg() token = await user_token(client, infra, tg) return tg, token class TestProfileRepository: async def test_first_read_creates_defaults(self, infra: dict[str, str]) -> None: factory = create_session_factory() async with factory() as session: user_id = uuid.uuid4() # The FK requires a real user row. await session.execute( __import__("sqlalchemy").text( "INSERT INTO users (id, telegram_id) VALUES (:id, :tg)" ), {"id": user_id, "tg": _unique_tg()}, ) await session.commit() repo = UserProfileRepository(session) row = await repo.get(user_id) await session.commit() assert row.language is None assert row.timezone is None assert row.notif_prefs == { "report_ready": True, "security": True, "marketing": False, } assert row.dashboard_prefs == { "severity_filter": "all", "per_page": 10, "density": "comfortable", } async def test_concurrent_first_read_does_not_raise(self, infra: dict[str, str]) -> None: factory = create_session_factory() async with factory() as session: user_id = uuid.uuid4() await session.execute( __import__("sqlalchemy").text( "INSERT INTO users (id, telegram_id) VALUES (:id, :tg)" ), {"id": user_id, "tg": _unique_tg()}, ) await session.commit() async def first_read() -> None: async with factory() as session: repo = UserProfileRepository(session) await repo.get(user_id) await session.commit() await asyncio.gather(first_read(), first_read()) async def test_partial_update_persists_touched_groups_only(self, infra: dict[str, str]) -> None: factory = create_session_factory() async with factory() as session: user_id = uuid.uuid4() await session.execute( __import__("sqlalchemy").text( "INSERT INTO users (id, telegram_id) VALUES (:id, :tg)" ), {"id": user_id, "tg": _unique_tg()}, ) await session.commit() async with factory() as session: repo = UserProfileRepository(session) await repo.get(user_id) await repo.update( user_id, language="ru", notif_prefs={"report_ready": False, "security": True, "marketing": True}, ) await session.commit() async with factory() as session: row = await UserProfileRepository(session).get(user_id) assert row.language == "ru" assert row.timezone is None # untouched group unchanged assert row.notif_prefs == { "report_ready": False, "security": True, "marketing": True, } assert row.dashboard_prefs["per_page"] == 10 # untouched group unchanged class TestProfileAPI: async def test_get_returns_defaults_without_404( self, client: httpx.AsyncClient, infra: dict[str, str] ) -> None: _, token = await _make_user(client, infra) r = await client.get("/api/v1/me/profile", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200, r.text body = r.json() assert body["language"] is None assert body["notif_prefs"] == { "report_ready": True, "security": True, "marketing": False, } async def test_patch_persists_and_returns_merged( self, client: httpx.AsyncClient, infra: dict[str, str] ) -> None: _, token = await _make_user(client, infra) headers = {"Authorization": f"Bearer {token}"} r = await client.patch( "/api/v1/me/profile", json={ "language": "be", "timezone": "Europe/Minsk", "dashboard_prefs": {"per_page": 50}, }, headers=headers, ) assert r.status_code == 200, r.text assert r.json()["language"] == "be" assert r.json()["timezone"] == "Europe/Minsk" assert r.json()["dashboard_prefs"]["per_page"] == 50 assert r.json()["dashboard_prefs"]["density"] == "comfortable" # merged default r = await client.get("/api/v1/me/profile", headers=headers) assert r.json()["language"] == "be" async def test_empty_patch_is_noop( self, client: httpx.AsyncClient, infra: dict[str, str] ) -> None: _, token = await _make_user(client, infra) headers = {"Authorization": f"Bearer {token}"} await client.patch("/api/v1/me/profile", json={"language": "en"}, headers=headers) r = await client.patch("/api/v1/me/profile", json={}, headers=headers) assert r.status_code == 200 assert r.json()["language"] == "en" # unchanged async def test_unknown_field_ignored( self, client: httpx.AsyncClient, infra: dict[str, str] ) -> None: _, token = await _make_user(client, infra) r = await client.patch( "/api/v1/me/profile", json={"language": "ru", "hobby": "chess"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 200 assert "hobby" not in r.json() async def test_invalid_timezone_422( self, client: httpx.AsyncClient, infra: dict[str, str] ) -> None: _, token = await _make_user(client, infra) r = await client.patch( "/api/v1/me/profile", json={"timezone": "Mars/Olympus_Mons"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 422 async def test_requires_auth(self, client: httpx.AsyncClient) -> None: r = await client.get("/api/v1/me/profile") assert r.status_code == 401 class TestOverviewAPI: async def test_overview_no_subscription( self, client: httpx.AsyncClient, infra: dict[str, str] ) -> None: _, token = await _make_user(client, infra) r = await client.get("/api/v1/me/overview", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200, r.text body = r.json() assert body["credits_left"] == 0 assert body["plan"] is None assert body["billing_hold"] is False assert body["docs"]["total"] == 0 async def test_overview_requires_auth(self, client: httpx.AsyncClient) -> None: r = await client.get("/api/v1/me/overview") assert r.status_code == 401