"""Model-level tests for chat: conversations, messages, message reports. app/models/chat.py / alembic/versions/0008_add_chat_tables.py (docs/planning/social.md US-4). This is the models+migration task only -- there are no API endpoints yet (sibling tasks own filtering, rate limiting, and the routers), so these tests exercise the ORM models and the real Postgres constraints directly rather than going through HTTP. """ import pytest from fastapi.testclient import TestClient from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import settings from app.main import app from app.models.chat import ( Conversation, ConversationParticipant, Message, MessageReport, ReportStatus, ) from app.models.user import User from app.models.world import World def _register(client: TestClient, username: str) -> int: resp = client.post( "/api/auth/register", json={ "username": username, "email": f"{username}@example.com", "password": "secret123", "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201, resp.text headers = {"Authorization": f"Bearer {resp.json()['access_token']}"} me = client.get("/api/users/me", headers=headers) assert me.status_code == 200, me.text return me.json()["id"] def _session_factory(): engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) return engine, async_sessionmaker(engine, expire_on_commit=False) class TestConversationCreation: async def test_create_conversation_with_participants_and_messages(self): client = TestClient(app) a = _register(client, "chat_conv_a") b = _register(client, "chat_conv_b") engine, factory = _session_factory() try: lo, hi = sorted((a, b)) async with factory() as session: convo = Conversation(user_a_id=lo, user_b_id=hi) session.add(convo) await session.flush() session.add_all( [ ConversationParticipant(conversation_id=convo.id, user_id=a), ConversationParticipant(conversation_id=convo.id, user_id=b), ] ) session.add( Message(sender_id=a, conversation_id=convo.id, body="hi there") ) await session.commit() convo_id = convo.id async with factory() as session: participants = ( await session.execute( select(ConversationParticipant).where( ConversationParticipant.conversation_id == convo_id ) ) ).scalars().all() messages = ( await session.execute( select(Message).where(Message.conversation_id == convo_id) ) ).scalars().all() assert len(participants) == 2 assert len(messages) == 1 assert messages[0].sender_id == a assert messages[0].filtered is False finally: await engine.dispose() async def test_duplicate_pair_rejected(self): client = TestClient(app) a = _register(client, "chat_uq_a") b = _register(client, "chat_uq_b") lo, hi = sorted((a, b)) engine, factory = _session_factory() try: async with factory() as session: session.add(Conversation(user_a_id=lo, user_b_id=hi)) await session.commit() async with factory() as session: session.add(Conversation(user_a_id=lo, user_b_id=hi)) with pytest.raises(IntegrityError): await session.commit() finally: await engine.dispose() async def test_unordered_pair_rejected_by_check_constraint(self): client = TestClient(app) a = _register(client, "chat_ck_a") b = _register(client, "chat_ck_b") lo, hi = sorted((a, b)) engine, factory = _session_factory() try: async with factory() as session: # Reversed order violates ck_conversations_ordered_pair. session.add(Conversation(user_a_id=hi, user_b_id=lo)) with pytest.raises(IntegrityError): await session.commit() finally: await engine.dispose() class TestWorldRoomChat: async def test_world_message_has_null_conversation(self): client = TestClient(app) owner = _register(client, "chat_world_owner") engine, factory = _session_factory() try: async with factory() as session: world = World(name="Chat Test World", owner_id=owner) session.add(world) await session.flush() msg = Message(sender_id=owner, world_id=world.id, body="hello world") session.add(msg) await session.commit() world_id = world.id async with factory() as session: rows = ( await session.execute( select(Message).where(Message.world_id == world_id) ) ).scalars().all() assert len(rows) == 1 assert rows[0].conversation_id is None assert rows[0].room_id is None finally: await engine.dispose() class TestMessageReports: async def test_create_report_defaults_to_open(self): client = TestClient(app) sender = _register(client, "chat_rpt_sender") reporter = _register(client, "chat_rpt_reporter") engine, factory = _session_factory() try: async with factory() as session: world = World(name="Report World", owner_id=sender) session.add(world) await session.flush() msg = Message(sender_id=sender, world_id=world.id, body="bad words") session.add(msg) await session.flush() report = MessageReport( message_id=msg.id, reporter_id=reporter, reason="spam" ) session.add(report) await session.commit() report_id = report.id async with factory() as session: report = await session.get(MessageReport, report_id) assert report.status == ReportStatus.OPEN assert report.moderator_id is None assert report.resolved_at is None finally: await engine.dispose() class TestCascadeDelete: async def test_deleting_conversation_deletes_participants_and_messages(self): client = TestClient(app) a = _register(client, "chat_cd_conv_a") b = _register(client, "chat_cd_conv_b") lo, hi = sorted((a, b)) engine, factory = _session_factory() try: async with factory() as session: convo = Conversation(user_a_id=lo, user_b_id=hi) session.add(convo) await session.flush() session.add_all( [ ConversationParticipant(conversation_id=convo.id, user_id=a), ConversationParticipant(conversation_id=convo.id, user_id=b), ] ) session.add(Message(sender_id=a, conversation_id=convo.id, body="bye")) await session.commit() convo_id = convo.id async with factory() as session: # Core delete against the conversations table directly, so # the real ON DELETE CASCADE does the work (not the ORM). await session.execute( delete(Conversation).where(Conversation.id == convo_id) ) await session.commit() async with factory() as session: participants = ( await session.execute( select(ConversationParticipant).where( ConversationParticipant.conversation_id == convo_id ) ) ).scalars().all() messages = ( await session.execute( select(Message).where(Message.conversation_id == convo_id) ) ).scalars().all() assert participants == [] assert messages == [] finally: await engine.dispose() async def test_deleting_user_removes_conversation_but_keeps_report_and_message_id_null(self): """Deleting a conversation participant cascades away the whole DM (conversation + participants + messages -- there's no third party left to care about the history). Deleting the sender of a world-chat message, by contrast, must NOT delete the message or its report -- sender_id is SET NULL and message_reports.message_id follows the message (also SET NULL, never CASCADE) so the audit trail survives account deletion.""" client = TestClient(app) a = _register(client, "chat_cd_user_a") b = _register(client, "chat_cd_user_b") reporter = _register(client, "chat_cd_user_reporter") lo, hi = sorted((a, b)) engine, factory = _session_factory() try: async with factory() as session: convo = Conversation(user_a_id=lo, user_b_id=hi) session.add(convo) await session.flush() session.add_all( [ ConversationParticipant(conversation_id=convo.id, user_id=a), ConversationParticipant(conversation_id=convo.id, user_id=b), ] ) session.add(Message(sender_id=a, conversation_id=convo.id, body="dm")) world = World(name="Sender Delete World", owner_id=b) session.add(world) await session.flush() world_msg = Message(sender_id=a, world_id=world.id, body="world chat") session.add(world_msg) await session.flush() report = MessageReport( message_id=world_msg.id, reporter_id=reporter, reason="rude" ) session.add(report) await session.commit() convo_id = convo.id world_msg_id = world_msg.id report_id = report.id async with factory() as session: # Delete `a`: participant in the DM (cascades the whole # conversation) and sender of the world message (SET NULL). await session.execute(delete(User).where(User.id == a)) await session.commit() async with factory() as session: convo_row = await session.get(Conversation, convo_id) dm_messages = ( await session.execute( select(Message).where(Message.conversation_id == convo_id) ) ).scalars().all() world_msg = await session.get(Message, world_msg_id) report = await session.get(MessageReport, report_id) assert convo_row is None assert dm_messages == [] # World message survives with sender_id nulled out. assert world_msg is not None assert world_msg.sender_id is None assert world_msg.body == "world chat" # Report still points at the surviving message. assert report is not None assert report.message_id == world_msg_id finally: await engine.dispose() async def test_deleting_message_nulls_report_message_id(self): client = TestClient(app) sender = _register(client, "chat_cd_msg_sender") reporter = _register(client, "chat_cd_msg_reporter") engine, factory = _session_factory() try: async with factory() as session: world = World(name="Msg Delete World", owner_id=sender) session.add(world) await session.flush() msg = Message(sender_id=sender, world_id=world.id, body="to be removed") session.add(msg) await session.flush() report = MessageReport( message_id=msg.id, reporter_id=reporter, reason="spam" ) session.add(report) await session.commit() msg_id = msg.id report_id = report.id async with factory() as session: await session.execute(delete(Message).where(Message.id == msg_id)) await session.commit() async with factory() as session: report = await session.get(MessageReport, report_id) assert report is not None assert report.message_id is None finally: await engine.dispose()