meraproject/services/user-reader/tests/test_project_members_service.py
keboss-m a7f3df859a Add service API for assigning employees to project teams.
Expose PUT /api/service/project-members for external apps that authenticate with only the API key, reusing portal validation while keeping acting-based writes unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 15:30:59 +03:00

425 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for service project-members write (API key only, no acting)."""
from __future__ import annotations
import os
from contextlib import contextmanager
from unittest.mock import MagicMock
import app.main # noqa: F401
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.project_members_write import ProjectMemberWrite, validate_member_write
class _FakeCursor:
def __init__(self, fetchone_results: list | None = None):
self._fetchone = list(fetchone_results or [])
self.executed: list[tuple] = []
def execute(self, sql, params=None):
self.executed.append((sql, params))
def fetchone(self):
if self._fetchone:
return self._fetchone.pop(0)
return None
def fetchall(self):
return []
def __enter__(self):
return self
def __exit__(self, *args):
return False
def _sample_item(**overrides):
base = {
"member_id": 10,
"emp_id": 46,
"emp_name": "Test Emp",
"project_id": 86,
"project_code": "P-86",
"project_name": "Demo",
"step_name": "РД",
"status": 1,
"status_name": "В работе",
"archive": 0,
"archive_date": None,
"section_id": 12,
"section_name": "ОВ",
"role": 22,
"active": 1,
}
base.update(overrides)
return base
@contextmanager
def _fake_conn(cur):
c = MagicMock()
c.cursor.return_value = cur
c.commit = MagicMock()
yield c
def test_validate_member_write_invalid_role():
cur = _FakeCursor()
body = ProjectMemberWrite(
project_id=1, emp_id=2, section_id=3, role=999, active=True
)
with pytest.raises(Exception) as ei:
validate_member_write(cur, "db", body)
assert getattr(ei.value, "status_code", None) == 400
assert ei.value.detail["code"] == "invalid_role"
def test_service_put_insert_without_acting(monkeypatch):
import app.project_members_service as pms
cur = _FakeCursor()
monkeypatch.setattr(pms, "get_conn", lambda: _fake_conn(cur))
monkeypatch.setattr(pms, "_db_name", lambda _c: "db")
monkeypatch.setattr(
pms,
"validate_member_write",
lambda *_a, **_k: ({"team": 7, "id": 86}, [12]),
)
monkeypatch.setattr(pms, "upsert_team_member", lambda *_a, **_k: 10)
monkeypatch.setattr(pms, "fetch_member_item", lambda *_a, **_k: _sample_item())
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 86,
"emp_id": 46,
"section_id": 12,
"role": 22,
"active": True,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["member_id"] == 10
assert data["emp_id"] == 46
assert data["section_id"] == 12
def test_service_put_update_without_acting(monkeypatch):
import app.project_members_service as pms
cur = _FakeCursor()
monkeypatch.setattr(pms, "get_conn", lambda: _fake_conn(cur))
monkeypatch.setattr(pms, "_db_name", lambda _c: "db")
monkeypatch.setattr(
pms,
"validate_member_write",
lambda *_a, **_k: ({"team": 7, "id": 86}, [12]),
)
monkeypatch.setattr(pms, "upsert_team_member", lambda *_a, **_k: 10)
monkeypatch.setattr(
pms,
"fetch_member_item",
lambda *_a, **_k: _sample_item(role=2, section_id=15, section_name="ТХ"),
)
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 86,
"emp_id": 46,
"section_id": 15,
"role": 2,
"active": True,
"text": "updated",
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["role"] == 2
assert data["section_id"] == 15
def test_service_put_validation_invalid_section(monkeypatch):
import app.project_members_service as pms
from fastapi import HTTPException
cur = _FakeCursor()
monkeypatch.setattr(pms, "get_conn", lambda: _fake_conn(cur))
monkeypatch.setattr(pms, "_db_name", lambda _c: "db")
def boom(*_a, **_k):
raise HTTPException(
status_code=400,
detail={"code": "invalid_section", "message": "Раздел не входит в состав проекта"},
)
monkeypatch.setattr(pms, "validate_member_write", boom)
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 86,
"emp_id": 46,
"section_id": 999,
"role": 22,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 400
assert r.json()["detail"]["code"] == "invalid_section"
def test_service_put_validation_archived_emp(monkeypatch):
import app.project_members_service as pms
from fastapi import HTTPException
cur = _FakeCursor()
monkeypatch.setattr(pms, "get_conn", lambda: _fake_conn(cur))
monkeypatch.setattr(pms, "_db_name", lambda _c: "db")
def boom(*_a, **_k):
raise HTTPException(
status_code=400,
detail={
"code": "invalid_employee",
"message": "Нельзя добавить архивного или удалённого сотрудника",
},
)
monkeypatch.setattr(pms, "validate_member_write", boom)
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 86,
"emp_id": 46,
"section_id": 12,
"role": 22,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 400
assert r.json()["detail"]["code"] == "invalid_employee"
def test_service_put_unknown_project(monkeypatch):
import app.project_members_service as pms
from fastapi import HTTPException
cur = _FakeCursor()
monkeypatch.setattr(pms, "get_conn", lambda: _fake_conn(cur))
monkeypatch.setattr(pms, "_db_name", lambda _c: "db")
def boom(*_a, **_k):
raise HTTPException(
status_code=404,
detail={"code": "project_not_found", "message": "Проект не найден"},
)
monkeypatch.setattr(pms, "validate_member_write", boom)
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 99999,
"emp_id": 46,
"section_id": 12,
"role": 22,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 404
assert r.json()["detail"]["code"] == "project_not_found"
def test_service_put_unknown_emp(monkeypatch):
import app.project_members_service as pms
from fastapi import HTTPException
cur = _FakeCursor()
monkeypatch.setattr(pms, "get_conn", lambda: _fake_conn(cur))
monkeypatch.setattr(pms, "_db_name", lambda _c: "db")
def boom(*_a, **_k):
raise HTTPException(
status_code=404,
detail={"code": "emp_not_found", "message": "Сотрудник 999 не найден"},
)
monkeypatch.setattr(pms, "validate_member_write", boom)
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 86,
"emp_id": 999,
"section_id": 12,
"role": 22,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 404
assert r.json()["detail"]["code"] == "emp_not_found"
def test_service_put_invalid_role_via_api(monkeypatch):
"""Pydantic rejects role < 1; invalid known-range role goes through validate."""
import app.project_members_service as pms
from fastapi import HTTPException
cur = _FakeCursor()
monkeypatch.setattr(pms, "get_conn", lambda: _fake_conn(cur))
monkeypatch.setattr(pms, "_db_name", lambda _c: "db")
def boom(*_a, **_k):
raise HTTPException(
status_code=400,
detail={"code": "invalid_role", "message": "Укажите роль в проекте"},
)
monkeypatch.setattr(pms, "validate_member_write", boom)
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 86,
"emp_id": 46,
"section_id": 12,
"role": 999,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 400
assert r.json()["detail"]["code"] == "invalid_role"
def test_service_put_requires_api_key():
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "secret-key"
try:
client = TestClient(app)
r = client.put(
"/api/service/project-members",
json={
"project_id": 86,
"emp_id": 46,
"section_id": 12,
"role": 22,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 401
def test_portal_put_still_requires_acting():
prev = os.environ.get("USER_READER_API_KEY")
os.environ["USER_READER_API_KEY"] = "test-key"
try:
client = TestClient(app)
r = client.put(
"/api/project-members",
headers={"X-Api-Key": "test-key"},
json={
"project_id": 86,
"emp_id": 46,
"section_id": 12,
"role": 22,
},
)
finally:
if prev is None:
os.environ.pop("USER_READER_API_KEY", None)
else:
os.environ["USER_READER_API_KEY"] = prev
assert r.status_code == 400
detail = r.json()["detail"]
if isinstance(detail, dict):
assert detail.get("code") == "missing_acting_emp"
else:
assert "Acting" in str(detail) or "acting" in str(detail).lower()