import os import unittest from datetime import datetime, timedelta, timezone from uuid import UUID from unittest.mock import patch from botocore.exceptions import ClientError from fastapi.testclient import TestClient from sqlalchemy import create_engine, delete, text from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") os.environ.setdefault("S3_ACCESS_KEY", "test") os.environ.setdefault("S3_SECRET_KEY", "test") os.environ.setdefault("S3_BUCKET", "test") from app.core.config import settings from app.core.security import create_jwt from app.db.session import get_db from app.chat_main import app as chat_app from app.main import app as main_app from app.models.attachment import Attachment from app.models.message import Message from app.models.notification import Notification from app.models.request import Request from app.models.request_data_requirement import RequestDataRequirement from app.models.status_history import StatusHistory from app.services.chat_presence import clear_presence_for_tests, set_typing_presence class _FakeBody: def __init__(self, payload: bytes): self.payload = payload def iter_chunks(self, chunk_size=65536): for i in range(0, len(self.payload), chunk_size): yield self.payload[i : i + chunk_size] class _FakeS3Storage: def __init__(self): self.objects = {} def create_presigned_put_url(self, key: str, mime_type: str): return f"http://s3.local/{key}?mime={mime_type}" def head_object(self, key: str) -> dict: row = self.objects.get(key) if row is None: raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject") return {"ContentLength": row["size"]} def get_object(self, key: str) -> dict: row = self.objects.get(key) if row is None: raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject") return {"Body": _FakeBody(row["content"]), "ContentType": row["mime"], "ContentLength": row["size"]} class PublicCabinetTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) Request.__table__.create(bind=cls.engine) Notification.__table__.create(bind=cls.engine) Message.__table__.create(bind=cls.engine) Attachment.__table__.create(bind=cls.engine) RequestDataRequirement.__table__.create(bind=cls.engine) StatusHistory.__table__.create(bind=cls.engine) @classmethod def tearDownClass(cls): RequestDataRequirement.__table__.drop(bind=cls.engine) StatusHistory.__table__.drop(bind=cls.engine) Attachment.__table__.drop(bind=cls.engine) Message.__table__.drop(bind=cls.engine) Notification.__table__.drop(bind=cls.engine) Request.__table__.drop(bind=cls.engine) cls.engine.dispose() def setUp(self): clear_presence_for_tests() with self.SessionLocal() as db: db.execute(delete(Notification)) db.execute(delete(StatusHistory)) db.execute(delete(Attachment)) db.execute(delete(RequestDataRequirement)) db.execute(delete(Message)) db.execute(delete(Request)) db.commit() def override_get_db(): db = self.SessionLocal() try: yield db finally: db.close() main_app.dependency_overrides[get_db] = override_get_db chat_app.dependency_overrides[get_db] = override_get_db self.client = TestClient(main_app) self.chat_client = TestClient(chat_app) def tearDown(self): self.chat_client.close() self.client.close() chat_app.dependency_overrides.clear() main_app.dependency_overrides.clear() clear_presence_for_tests() @staticmethod def _public_cookies(track_number: str) -> dict[str, str]: token = create_jwt({"sub": track_number, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1)) return {settings.PUBLIC_COOKIE_NAME: token} def test_cabinet_lists_messages_attachments_history_and_timeline(self): with self.SessionLocal() as db: req = Request( track_number="TRK-CAB-001", client_name="Тест Клиент", client_phone="+79991110000", topic_code="consulting", status_code="IN_PROGRESS", description="Проверка кабинета", extra_fields={}, ) db.add(req) db.commit() db.refresh(req) db.add( Message( request_id=req.id, author_type="LAWYER", author_name="Юрист", body="Принял в работу.", ) ) db.add( Attachment( request_id=req.id, file_name="doc.pdf", mime_type="application/pdf", size_bytes=1234, s3_key="requests/key/doc.pdf", ) ) db.add( StatusHistory( request_id=req.id, from_status="NEW", to_status="IN_PROGRESS", comment="Юрист взял заявку", ) ) db.commit() cookies = self._public_cookies("TRK-CAB-001") messages = self.chat_client.get("/api/public/chat/requests/TRK-CAB-001/messages", cookies=cookies) self.assertEqual(messages.status_code, 200) self.assertEqual(len(messages.json()), 1) self.assertEqual(messages.json()[0]["author_type"], "LAWYER") attachments = self.client.get("/api/public/requests/TRK-CAB-001/attachments", cookies=cookies) self.assertEqual(attachments.status_code, 200) self.assertEqual(len(attachments.json()), 1) self.assertIn("/api/public/uploads/object/", attachments.json()[0]["download_url"]) history = self.client.get("/api/public/requests/TRK-CAB-001/history", cookies=cookies) self.assertEqual(history.status_code, 200) self.assertEqual(len(history.json()), 1) self.assertEqual(history.json()[0]["to_status"], "IN_PROGRESS") timeline = self.client.get("/api/public/requests/TRK-CAB-001/timeline", cookies=cookies) self.assertEqual(timeline.status_code, 200) events = timeline.json() self.assertEqual(len(events), 3) self.assertEqual({event["type"] for event in events}, {"status_change", "message", "attachment"}) def test_client_can_create_message_in_public_cabinet(self): with self.SessionLocal() as db: req = Request( track_number="TRK-CAB-MSG", client_name="Клиент Сообщение", client_phone="+79992220000", topic_code="consulting", status_code="NEW", description="Проверка отправки", extra_fields={}, ) db.add(req) db.commit() request_id = req.id cookies = self._public_cookies("TRK-CAB-MSG") created = self.chat_client.post( "/api/public/chat/requests/TRK-CAB-MSG/messages", cookies=cookies, json={"body": "Добрый день, есть вопрос по документам."}, ) self.assertEqual(created.status_code, 201) self.assertEqual(created.json().get("message_kind"), "TEXT") self.assertEqual(created.json().get("request_data_items"), []) self.assertFalse(bool(created.json().get("request_data_all_filled"))) message_id = UUID(created.json()["id"]) with self.SessionLocal() as db: row = db.get(Message, message_id) self.assertIsNotNone(row) self.assertEqual(row.request_id, request_id) self.assertEqual(row.author_type, "CLIENT") self.assertEqual(row.body, "Добрый день, есть вопрос по документам.") req = db.get(Request, request_id) self.assertIsNotNone(req) self.assertEqual(req.responsible, "Клиент") self.assertTrue(req.lawyer_has_unread_updates) self.assertEqual(req.lawyer_unread_event_type, "MESSAGE") def test_legacy_public_request_messages_routes_are_not_exposed(self): with self.SessionLocal() as db: req = Request( track_number="TRK-CAB-LEGACY", client_name="Клиент Legacy Route", client_phone="+79992220001", topic_code="consulting", status_code="NEW", description="Проверка отсутствия legacy chat route", extra_fields={}, ) db.add(req) db.commit() cookies = self._public_cookies("TRK-CAB-LEGACY") listed = self.client.get("/api/public/requests/TRK-CAB-LEGACY/messages", cookies=cookies) self.assertEqual(listed.status_code, 404) created = self.client.post( "/api/public/requests/TRK-CAB-LEGACY/messages", cookies=cookies, json={"body": "Legacy endpoint"}, ) self.assertEqual(created.status_code, 404) def test_public_chat_service_endpoints_work_for_authorized_client(self): with self.SessionLocal() as db: req = Request( track_number="TRK-CHAT-001", client_name="Клиент Чат", client_phone="+79997770000", topic_code="consulting", status_code="NEW", description="Проверка chat service", extra_fields={}, ) db.add(req) db.commit() cookies = self._public_cookies("TRK-CHAT-001") created = self.chat_client.post( "/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies, json={"body": "Сообщение через выделенный сервис"}, ) self.assertEqual(created.status_code, 201) self.assertEqual(created.json()["author_type"], "CLIENT") listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies) self.assertEqual(listed.status_code, 200) self.assertEqual(len(listed.json()), 1) self.assertIn("выделенный сервис", listed.json()[0]["body"]) for index in range(4): created_extra = self.chat_client.post( "/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies, json={"body": f"Сообщение {index}"}, ) self.assertEqual(created_extra.status_code, 201) listed_window = self.chat_client.get( "/api/public/chat/requests/TRK-CHAT-001/messages-window", cookies=cookies, params={"limit": 2}, ) self.assertEqual(listed_window.status_code, 200) window_payload = listed_window.json() self.assertEqual(len(window_payload.get("rows") or []), 2) self.assertTrue(bool(window_payload.get("has_more"))) self.assertEqual(int(window_payload.get("loaded_count") or 0), 2) self.assertEqual(int(window_payload.get("total") or 0), 5) denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER")) self.assertEqual(denied.status_code, 404) def test_public_chat_marks_delivery_and_read_receipts_for_staff_messages(self): with self.SessionLocal() as db: req = Request( track_number="TRK-CHAT-RECEIPTS-CLIENT", client_name="Клиент Чат Receipt", client_phone="+79997774411", topic_code="consulting", status_code="IN_PROGRESS", description="Проверка delivered/read для клиента", extra_fields={}, ) db.add(req) db.flush() msg = Message( request_id=req.id, author_type="LAWYER", author_name="Юрист", body="Проверка receipt", ) db.add(msg) db.commit() message_id = msg.id cookies = self._public_cookies("TRK-CHAT-RECEIPTS-CLIENT") live = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-RECEIPTS-CLIENT/live", cookies=cookies) self.assertEqual(live.status_code, 200) with self.SessionLocal() as db: delivered_row = db.get(Message, message_id) self.assertIsNotNone(delivered_row) self.assertIsNotNone(delivered_row.delivered_to_client_at) self.assertIsNone(delivered_row.read_by_client_at) listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-RECEIPTS-CLIENT/messages", cookies=cookies) self.assertEqual(listed.status_code, 200) self.assertEqual(len(listed.json()), 1) self.assertTrue(bool(listed.json()[0].get("delivered_to_client_at"))) self.assertTrue(bool(listed.json()[0].get("read_by_client_at"))) with self.SessionLocal() as db: read_row = db.get(Message, message_id) self.assertIsNotNone(read_row) self.assertIsNotNone(read_row.read_by_client_at) def test_chat_message_is_encrypted_at_rest(self): with self.SessionLocal() as db: req = Request( track_number="TRK-CHAT-ENC", client_name="Клиент Шифрование", client_phone="+79997779999", topic_code="consulting", status_code="NEW", description="Проверка шифрования чата", extra_fields={}, ) db.add(req) db.commit() payload_body = "Секретное сообщение клиента" cookies = self._public_cookies("TRK-CHAT-ENC") created = self.chat_client.post( "/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies, json={"body": payload_body}, ) self.assertEqual(created.status_code, 201) with self.SessionLocal() as db: raw_encrypted = db.execute(text("SELECT body FROM messages ORDER BY created_at DESC LIMIT 1")).scalar_one() self.assertTrue(str(raw_encrypted).startswith("chatenc:")) self.assertNotEqual(str(raw_encrypted), payload_body) listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies) self.assertEqual(listed.status_code, 200) self.assertEqual(listed.json()[0]["body"], payload_body) def test_chat_supports_legacy_plaintext_rows(self): with self.SessionLocal() as db: req = Request( track_number="TRK-CHAT-LEGACY", client_name="Клиент Legacy", client_phone="+79997778888", topic_code="consulting", status_code="NEW", description="Проверка legacy формата", extra_fields={}, ) db.add(req) db.flush() message = Message( request_id=req.id, author_type="LAWYER", author_name="Юрист", body="legacy placeholder", ) db.add(message) db.flush() db.execute( text("UPDATE messages SET body = :body WHERE rowid = (SELECT rowid FROM messages ORDER BY created_at DESC LIMIT 1)"), {"body": "LEGACY_PLAINTEXT_MESSAGE"}, ) db.commit() cookies = self._public_cookies("TRK-CHAT-LEGACY") listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-LEGACY/messages", cookies=cookies) self.assertEqual(listed.status_code, 200) self.assertEqual(len(listed.json()), 1) self.assertEqual(listed.json()[0]["body"], "LEGACY_PLAINTEXT_MESSAGE") def test_public_live_endpoint_and_typing_state(self): with self.SessionLocal() as db: req = Request( track_number="TRK-LIVE-001", client_name="Клиент Live", client_phone="+79997771234", topic_code="consulting", status_code="NEW", description="Проверка live", extra_fields={}, ) db.add(req) db.flush() db.add( Message( request_id=req.id, author_type="LAWYER", author_name="Юрист", body="Первое сообщение", ) ) db.commit() request_id = str(req.id) cookies = self._public_cookies("TRK-LIVE-001") live_initial = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies) self.assertEqual(live_initial.status_code, 200) live_body = live_initial.json() self.assertTrue(bool(live_body.get("has_updates"))) self.assertTrue(bool(live_body.get("cursor"))) set_typing_presence( request_key=request_id, actor_key="LAWYER:test", actor_label="Юрист Тест", actor_role="LAWYER", typing=True, ) live_with_typing = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies) self.assertEqual(live_with_typing.status_code, 200) typing_rows = live_with_typing.json().get("typing") or [] self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows)) current_cursor = str(live_with_typing.json().get("cursor") or "") live_no_delta = self.chat_client.get( "/api/public/chat/requests/TRK-LIVE-001/live", params={"cursor": current_cursor}, cookies=cookies, ) self.assertEqual(live_no_delta.status_code, 200) self.assertFalse(bool(live_no_delta.json().get("has_updates"))) with self.SessionLocal() as db: req = db.query(Request).filter(Request.track_number == "TRK-LIVE-001").first() self.assertIsNotNone(req) live_message = Message( request_id=req.id, author_type="LAWYER", author_name="Юрист", body="Новое сообщение live", ) db.add(live_message) db.flush() db.add( Attachment( request_id=req.id, message_id=live_message.id, file_name="live-public.pdf", mime_type="application/pdf", size_bytes=512, s3_key=f"requests/{req.id}/live-public.pdf", ) ) db.commit() live_delta = self.chat_client.get( "/api/public/chat/requests/TRK-LIVE-001/live", params={"cursor": current_cursor}, cookies=cookies, ) self.assertEqual(live_delta.status_code, 200) self.assertTrue(bool(live_delta.json().get("has_updates"))) self.assertEqual(len(live_delta.json().get("messages") or []), 1) self.assertEqual(len(live_delta.json().get("attachments") or []), 1) typing_on = self.chat_client.post( "/api/public/chat/requests/TRK-LIVE-001/typing", cookies=cookies, json={"typing": True}, ) self.assertEqual(typing_on.status_code, 200) self.assertTrue(bool(typing_on.json().get("typing"))) def test_public_live_detects_filled_request_data_updates(self): with self.SessionLocal() as db: now = datetime.now(timezone.utc) req = Request( track_number="TRK-LIVE-DATA-001", client_name="Клиент Live Data", client_phone="+79997771235", topic_code="consulting", status_code="IN_PROGRESS", description="Проверка live по допданным", extra_fields={}, ) db.add(req) db.flush() msg = Message( request_id=req.id, author_type="LAWYER", author_name="Юрист", body="Запрос", created_at=now - timedelta(minutes=2), updated_at=now - timedelta(minutes=2), ) db.add(msg) db.flush() row = RequestDataRequirement( request_id=req.id, request_message_id=msg.id, key="passport_series", label="Серия паспорта", field_type="text", required=True, sort_order=0, ) db.add(row) db.commit() message_id = str(msg.id) row_id = str(row.id) cookies = self._public_cookies("TRK-LIVE-DATA-001") live_initial = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-DATA-001/live", cookies=cookies) self.assertEqual(live_initial.status_code, 200) cursor = str(live_initial.json().get("cursor") or "") self.assertTrue(bool(cursor)) live_no_delta = self.chat_client.get( "/api/public/chat/requests/TRK-LIVE-DATA-001/live", params={"cursor": cursor}, cookies=cookies, ) self.assertEqual(live_no_delta.status_code, 200) self.assertFalse(bool(live_no_delta.json().get("has_updates"))) save_values = self.chat_client.post( f"/api/public/chat/requests/TRK-LIVE-DATA-001/data-requests/{message_id}", cookies=cookies, json={"items": [{"id": row_id, "value_text": "1234"}]}, ) self.assertEqual(save_values.status_code, 200) self.assertEqual(int(save_values.json().get("updated") or 0), 1) live_after_fill = self.chat_client.get( "/api/public/chat/requests/TRK-LIVE-DATA-001/live", params={"cursor": cursor}, cookies=cookies, ) self.assertEqual(live_after_fill.status_code, 200) self.assertTrue(bool(live_after_fill.json().get("has_updates"))) def test_public_cabinet_respects_track_access(self): with self.SessionLocal() as db: req = Request( track_number="TRK-REAL", client_name="Клиент Ограничение", client_phone="+79993330000", topic_code="consulting", status_code="NEW", description="Проверка доступа", extra_fields={}, ) db.add(req) db.commit() cookies = self._public_cookies("TRK-OTHER") denied = self.chat_client.get("/api/public/chat/requests/TRK-REAL/messages", cookies=cookies) self.assertEqual(denied.status_code, 404) def test_public_attachment_download_requires_access(self): fake_s3 = _FakeS3Storage() with self.SessionLocal() as db: req = Request( track_number="TRK-FILE-1", client_name="Клиент Файл", client_phone="+79994440000", topic_code="consulting", status_code="NEW", description="Файл", extra_fields={}, ) db.add(req) db.commit() db.refresh(req) att = Attachment( request_id=req.id, file_name="act.pdf", mime_type="application/pdf", size_bytes=4, s3_key="requests/a/act.pdf", ) db.add(att) db.commit() attachment_id = str(att.id) fake_s3.objects["requests/a/act.pdf"] = { "content": b"test", "mime": "application/pdf", "size": 4, } with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3): allowed = self.client.get( f"/api/public/uploads/object/{attachment_id}", cookies=self._public_cookies("TRK-FILE-1"), ) self.assertEqual(allowed.status_code, 200) self.assertEqual(allowed.content, b"test") denied = self.client.get( f"/api/public/uploads/object/{attachment_id}", cookies=self._public_cookies("TRK-OTHER"), ) self.assertEqual(denied.status_code, 404) def test_public_upload_complete_links_attachment_to_message_when_message_id_provided(self): fake_s3 = _FakeS3Storage() with self.SessionLocal() as db: req = Request( track_number="TRK-PUBLIC-UPL-1", client_name="Клиент Файл Сообщение", client_phone="+79995550000", topic_code="consulting", status_code="NEW", description="Проверка привязки файла к сообщению", extra_fields={}, ) db.add(req) db.flush() msg = Message( request_id=req.id, author_type="CLIENT", author_name=req.client_name, body="Сообщение клиента", ) db.add(msg) db.commit() db.refresh(req) db.refresh(msg) request_id = str(req.id) message_id = str(msg.id) key = f"requests/{request_id}/chat/file.txt" fake_s3.objects[key] = { "content": b"hello", "mime": "text/plain", "size": 5, } with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3): response = self.client.post( "/api/public/uploads/complete", cookies=self._public_cookies("TRK-PUBLIC-UPL-1"), json={ "key": key, "file_name": "file.txt", "mime_type": "text/plain", "size_bytes": 5, "scope": "REQUEST_ATTACHMENT", "request_id": request_id, "message_id": message_id, }, ) self.assertEqual(response.status_code, 200) attachment_id = response.json().get("attachment_id") self.assertIsNotNone(attachment_id) with self.SessionLocal() as db: row = db.get(Attachment, UUID(attachment_id)) self.assertIsNotNone(row) self.assertEqual(str(row.message_id), message_id) def test_public_status_route_endpoint_is_available_for_client(self): with self.SessionLocal() as db: req = Request( track_number="TRK-ROUTE-001", client_name="Клиент Маршрут", client_phone="+79996660000", topic_code="consulting", status_code="IN_PROGRESS", description="Проверка маршрута", extra_fields={}, ) db.add(req) db.commit() response = self.client.get("/api/public/requests/TRK-ROUTE-001/status-route", cookies=self._public_cookies("TRK-ROUTE-001")) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload.get("track_number"), "TRK-ROUTE-001") self.assertEqual(payload.get("current_status"), "IN_PROGRESS") self.assertTrue(isinstance(payload.get("nodes"), list))