Newer
Older
gnexus-tasks / backend / tests / test_multiuser.py
"""Тесты мультиюзерности (ТЗ 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()