"""Тесты мультиюзерности (ТЗ 1.2): изоляция данных двух пользователей.
Сессия-заглушка conftest мутируема: переключение пользователя — замена
AUTH_USER["user_id"] (см. switch_user). Сервер сам ставит user_id из сессии.
"""
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.models import AppSettingGlobal, Project, Task
from tests.conftest import AUTH_USER, _test_session_factory # type: ignore[attr-defined]
@contextmanager
def switch_user(user_id: str) -> Iterator[None]:
"""Сменить пользователя сессии-заглушки (вкладка другого человека)."""
old = AUTH_USER["user_id"]
AUTH_USER["user_id"] = user_id
try:
yield
finally:
AUTH_USER["user_id"] = old
def test_lists_are_isolated(client: TestClient) -> None:
client.post("/api/tasks", json={"title": "Моя задача"})
client.post("/api/projects", json={"name": "Мой проект"})
client.post("/api/tags", json={"name": "мойтег"})
with switch_user("other-user"):
assert client.get("/api/tasks").json() == []
assert client.get("/api/projects").json() == []
assert client.get("/api/tags").json() == []
# чужое не отдаётся и по id
assert client.get("/api/tasks/1").status_code == 404
assert client.get("/api/projects/1").status_code == 404
def test_writes_cannot_touch_foreign_rows(client: TestClient) -> None:
tid = client.post("/api/tasks", json={"title": "Чужая для второго"}).json()["id"]
pid = client.post("/api/projects", json={"name": "Проект А"}).json()["id"]
with switch_user("other-user"):
assert client.patch(f"/api/tasks/{tid}", json={"title": "взлом"}).status_code == 404
assert client.delete(f"/api/tasks/{tid}").status_code == 404
assert client.patch(f"/api/projects/{pid}", json={"name": "взлом"}).status_code == 404
assert client.delete(f"/api/projects/{pid}").status_code == 404
# данные не тронуты
assert client.get(f"/api/tasks/{tid}").json()["title"] == "Чужая для второго"
def test_same_names_allowed_for_different_users(client: TestClient) -> None:
assert client.post("/api/projects", json={"name": "Общее имя"}).status_code == 200
with switch_user("other-user"):
assert client.post("/api/projects", json={"name": "Общее имя"}).status_code == 200
# дубликат внутри одного пользователя по-прежнему 409
assert client.post("/api/projects", json={"name": "Общее имя"}).status_code == 409
assert client.post("/api/tags", json={"name": "dup"}).status_code == 200
with switch_user("other-user"):
assert client.post("/api/tags", json={"name": "dup"}).status_code == 200
def test_project_and_tag_refs_scoped(client: TestClient) -> None:
pid = client.post("/api/projects", json={"name": "Проект Б"}).json()["id"]
tid = client.post("/api/tasks", json={"title": "Задача 1"}).json()["id"]
with switch_user("other-user"):
# чужой проект нельзя прицепить к своей задаче
resp = client.patch(f"/api/tasks/{tid}", json={"project_id": pid})
assert resp.status_code == 404
def test_xp_and_garden_are_per_user(client: TestClient) -> None:
tid = client.post("/api/tasks", json={"title": "Закрыть меня"}).json()["id"]
client.patch(f"/api/tasks/{tid}", json={"priority": 8})
client.patch(f"/api/tasks/{tid}", json={"status": "done"})
my_xp = client.get("/api/xp").json()["total_xp"]
assert my_xp > 0
with switch_user("other-user"):
assert client.get("/api/xp").json()["total_xp"] == 0
garden = client.get("/api/garden").json()
assert garden["items"] == []
assert garden["balance"] == 0
assert garden["level"] == 1
# у первого растение из закрытой задачи есть
assert any(i["kind"] == "plant" for i in client.get("/api/garden").json()["items"])
def test_daily_reward_per_user(client: TestClient) -> None:
assert client.post("/api/xp/daily").json()["granted"] is True
with switch_user("other-user"):
# другому пользователю своя дейли
assert client.post("/api/xp/daily").json()["granted"] is True
# повтор в тот же день внутри своего пользователя — нет
assert client.post("/api/xp/daily").json()["granted"] is False
def test_settings_are_per_user(client: TestClient) -> None:
client.put("/api/settings", json={"currency": "USD"})
with switch_user("other-user"):
# другому — дефолт, свои настройки не перепутались
assert client.get("/api/settings").json()["currency"] == "UAH"
client.put("/api/settings", json={"currency": "EUR"})
assert client.get("/api/settings").json()["currency"] == "USD"
def test_claim_orphan_data_on_first_login(client: TestClient) -> None:
"""Бесхозные строки (user_id NULL из прежней эпохи) забирает вошедший."""
from app.services.users import claim_orphan_data
session = _test_session_factory()
try:
session.add(Task(title="Старая задача"))
session.add(Project(name="Старый проект"))
session.commit()
orphans = session.scalars(select(Task).where(Task.user_id.is_(None))).all()
assert len(orphans) == 1
assert claim_orphan_data(session, "new-user") >= 2
assert session.scalars(select(Task).where(Task.user_id.is_(None))).all() == []
mine = session.scalars(
select(Task).where(Task.user_id == "new-user", Task.title == "Старая задача")
).all()
assert len(mine) == 1
finally:
session.close()
def test_claim_global_settings_on_first_login(client: TestClient) -> None:
from app.models import AppSetting
from app.services.users import claim_global_settings
session = _test_session_factory()
try:
session.add(AppSettingGlobal(key="currency", value="EUR"))
session.commit()
claim_global_settings(session, "new-user")
row = session.get(AppSetting, ("new-user", "currency"))
assert row is not None and row.value == "EUR"
# идемпотентно: второй claim не задублирует
claim_global_settings(session, "new-user")
rows = session.scalars(
select(AppSetting).where(AppSetting.user_id == "new-user", AppSetting.key == "currency")
).all()
assert len(rows) == 1
finally:
session.close()
def test_settings_suggest_scoped_to_user(client: TestClient) -> None:
"""Режим «3 вариантов» показывает только свои задачи."""
from app.services.options import pick_options
tid = client.post("/api/tasks", json={"title": "Моё дело"}).json()["id"]
client.post(f"/api/tasks/{tid}/approve")
session = _test_session_factory()
try:
assert pick_options(session, 60, "1")[0].id == tid
assert pick_options(session, 60, "other-user") == []
finally:
session.close()
def test_user_profile_upsert(client: TestClient) -> None:
"""upsert_user: создание и обновление профиля из SSO."""
from app.services.users import upsert_user
session = _test_session_factory()
try:
user = upsert_user(session, "u1", "a@example.com", None, "ru")
session.commit()
assert user.email == "a@example.com"
updated = upsert_user(session, "u1", "b@example.com", "http://x/ava.png", "en")
session.commit()
assert updated.email == "b@example.com"
assert updated.avatar_url == "http://x/ava.png"
assert updated.locale == "en"
finally:
session.close()
def test_document_owner_follows_task(client: TestClient) -> None:
"""Документ (описание задачи) наследует user_id задачи."""
tid = client.post("/api/tasks", json={"title": "С описанием", "description": "текст"}).json()[
"id"
]
session = _test_session_factory()
try:
task = session.get(Task, tid)
assert task is not None
assert task.user_id == AUTH_USER["user_id"]
assert task.document is not None
assert task.document.user_id == AUTH_USER["user_id"]
finally:
session.close()
def test_mcp_tool_of_other_user_isolated(client: TestClient) -> None:
"""Тул MCP от имени другого пользователя не видит чужие задачи."""
from app import mcp_server
from tests.conftest import mcp_ctx # type: ignore[attr-defined]
client.post("/api/tasks", json={"title": "Только моя"})
other_ctx: Any = mcp_ctx("agent-user")
tasks = mcp_server.list_tasks(ctx=other_ctx)
assert tasks == []
created = mcp_server.create_task("От агента", ctx=other_ctx)
assert created["detail_state"] == "raw"
session = _test_session_factory()
try:
task = session.get(Task, created["id"])
assert task is not None
assert task.user_id == "agent-user"
finally:
session.close()