DealDocumentScreening/tests/integration/test_profile_api.py

205 lines
7.4 KiB
Python

"""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