176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
"""Учебные материалы: создание, назначение, изучение и безопасная загрузка."""
|
||
|
||
import base64
|
||
from uuid import UUID
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.config import get_settings
|
||
from app.main import app
|
||
from app.session.hub import hub
|
||
|
||
|
||
@pytest.fixture
|
||
def client(monkeypatch):
|
||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||
get_settings.cache_clear()
|
||
try:
|
||
with TestClient(app) as test_client:
|
||
yield test_client
|
||
finally:
|
||
get_settings.cache_clear()
|
||
|
||
|
||
def _instructor(client: TestClient) -> None:
|
||
response = client.post("/api/auth/dev-token")
|
||
assert response.status_code == 200
|
||
|
||
|
||
def _trainee(client: TestClient) -> None:
|
||
client.post("/api/auth/logout")
|
||
response = client.post(
|
||
"/api/auth/login", json={"login": "demo-trainee", "password": "demo"}
|
||
)
|
||
assert response.status_code == 200
|
||
|
||
|
||
def test_teacher_creates_assigns_and_trainee_completes_text_material(client):
|
||
_instructor(client)
|
||
created = client.post("/api/materials", json={
|
||
"title": "Порядок доклада старшему",
|
||
"description": "Перед практическим занятием",
|
||
"level": "L2",
|
||
"kind": "text",
|
||
"body": "Передайте адрес, тип события, задачу и подтвердите выезд.",
|
||
"scenario_id": "fire-apartment-l2",
|
||
})
|
||
assert created.status_code == 201, created.text
|
||
material_id = created.json()["id"]
|
||
assert created.json()["assignment_count"] == 0
|
||
|
||
assigned = client.put(
|
||
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
|
||
)
|
||
assert assigned.status_code == 200, assigned.text
|
||
assert assigned.json()["assigned_at"]
|
||
|
||
_trainee(client)
|
||
listing = client.get("/api/materials")
|
||
assert listing.status_code == 200
|
||
item = next(item for item in listing.json() if item["id"] == material_id)
|
||
assert item["body"].startswith("Передайте адрес")
|
||
assert item["completed_at"] is None
|
||
|
||
completed = client.post(f"/api/materials/{material_id}/complete")
|
||
assert completed.status_code == 200
|
||
assert completed.json()["completed_at"]
|
||
assert client.post("/api/materials", json={
|
||
"title": "Нельзя создать", "kind": "text", "body": "запрещено",
|
||
}).status_code == 403
|
||
|
||
|
||
def test_teacher_cannot_edit_archive_or_assign_another_teachers_material(client, monkeypatch):
|
||
from app.api.auth import Principal
|
||
from app.api.http import materials as materials_api
|
||
from app.domain.roles import Role
|
||
|
||
identity = {"login": "teacher-one"}
|
||
|
||
def instructor(_request, *_roles):
|
||
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||
|
||
monkeypatch.setattr(materials_api, "require", instructor)
|
||
created = client.post("/api/materials", json={
|
||
"title": "Личный материал",
|
||
"level": "L1",
|
||
"kind": "text",
|
||
"body": "Учебный текст.",
|
||
})
|
||
assert created.status_code == 201, created.text
|
||
material_id = created.json()["id"]
|
||
|
||
identity["login"] = "teacher-two"
|
||
assert client.patch(f"/api/materials/{material_id}", json={"title": "Подмена"}).status_code == 404
|
||
assert client.delete(f"/api/materials/{material_id}").status_code == 404
|
||
assert client.put(
|
||
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
|
||
).status_code == 404
|
||
assert client.delete(
|
||
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
|
||
).status_code == 404
|
||
|
||
|
||
def test_uploaded_file_is_limited_sanitized_and_downloaded_as_attachment(client):
|
||
_instructor(client)
|
||
content = b"local training resource\n"
|
||
created = client.post("/api/materials", json={
|
||
"title": "Локальная памятка PDF",
|
||
"kind": "file",
|
||
"file_name": "C:\\Users\\teacher\\guide.txt",
|
||
"media_type": "text/plain",
|
||
"content_base64": base64.b64encode(content).decode(),
|
||
})
|
||
assert created.status_code == 201, created.text
|
||
data = created.json()
|
||
assert data["file_name"] == "guide.txt"
|
||
assert data["file_size"] == len(content)
|
||
assert len(data["file_sha256"]) == 64
|
||
|
||
downloaded = client.get(f"/api/materials/{data['id']}/download")
|
||
assert downloaded.status_code == 200
|
||
assert downloaded.content == content
|
||
assert downloaded.headers["x-content-type-options"] == "nosniff"
|
||
assert downloaded.headers["content-disposition"].startswith("attachment")
|
||
|
||
|
||
def test_unassigned_trainee_cannot_download_resource(client):
|
||
_instructor(client)
|
||
created = client.post("/api/materials", json={
|
||
"title": "Закрытый ресурс",
|
||
"kind": "file",
|
||
"file_name": "private.pdf",
|
||
"media_type": "application/pdf",
|
||
"content_base64": base64.b64encode(b"%PDF-demo").decode(),
|
||
})
|
||
material_id = created.json()["id"]
|
||
_trainee(client)
|
||
assert client.get(f"/api/materials/{material_id}/download").status_code == 403
|
||
|
||
|
||
def test_archive_hides_material_from_trainee_but_keeps_record(client):
|
||
_instructor(client)
|
||
created = client.post("/api/materials", json={
|
||
"title": "Архивируемая памятка",
|
||
"kind": "text",
|
||
"body": "Уникальный тестовый материал для проверки архивации.",
|
||
})
|
||
assert created.status_code == 201, created.text
|
||
material_id = created.json()["id"]
|
||
|
||
archived = client.delete(f"/api/materials/{material_id}")
|
||
assert archived.status_code == 200
|
||
assert archived.json()["active"] is False
|
||
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
|
||
archived_list = client.get("/api/materials?include_archived=true").json()
|
||
archived_item = next(item for item in archived_list if item["id"] == material_id)
|
||
assert archived_item["active"] is False
|
||
|
||
_trainee(client)
|
||
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
|
||
|
||
|
||
def test_trainee_starts_assigned_practice_in_self_mode(client):
|
||
_trainee(client)
|
||
seeded = client.get("/api/materials").json()[0]
|
||
started = client.post(f"/api/materials/{seeded['id']}/start")
|
||
assert started.status_code == 200, started.text
|
||
payload = started.json()
|
||
assert payload["mode"] == "self"
|
||
assert payload["exercise"] == "card"
|
||
assert payload["path"].startswith("/trainee?session=")
|
||
state = hub.get(UUID(payload["session_id"]))
|
||
assert state is not None
|
||
assert state.trainee_id.hex == "00000000000040008000000000000112"
|
||
hub.drop(state.session_id)
|