205 lines
7.4 KiB
Python
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
|