diff --git a/backend/alembic/versions/0008_add_chat_tables.py b/backend/alembic/versions/0008_add_chat_tables.py new file mode 100644 index 0000000..7a26184 --- /dev/null +++ b/backend/alembic/versions/0008_add_chat_tables.py @@ -0,0 +1,178 @@ +"""add chat tables (conversations, messages, message_reports) + +Revision ID: 0008_add_chat_tables +Revises: 0007_add_social_graph_tables +Create Date: 2026-09-15 00:00:00.000000 + +docs/planning/social.md US-4 / app/models/chat.py. Models + migration only +-- text filtering, rate limiting, and send/report/moderation endpoints live +in sibling tasks that depend on this one. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0008_add_chat_tables" +down_revision: Union[str, None] = "0007_add_social_graph_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + message_report_status = postgresql.ENUM( + "open", "resolved", "dismissed", name="message_report_status" + ) + message_report_status.create(op.get_bind(), checkfirst=True) + + op.create_table( + "conversations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_a_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_b_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("user_a_id", "user_b_id", name="uq_conversations_pair"), + sa.CheckConstraint("user_a_id < user_b_id", name="ck_conversations_ordered_pair"), + ) + op.create_index("ix_conversations_user_a_id", "conversations", ["user_a_id"]) + op.create_index("ix_conversations_user_b_id", "conversations", ["user_b_id"]) + + op.create_table( + "conversation_participants", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "conversation_id", + sa.Integer(), + sa.ForeignKey("conversations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint( + "conversation_id", "user_id", name="uq_conversation_participants_pair" + ), + ) + op.create_index( + "ix_conversation_participants_conversation_id", + "conversation_participants", + ["conversation_id"], + ) + op.create_index( + "ix_conversation_participants_user_id", "conversation_participants", ["user_id"] + ) + + op.create_table( + "messages", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "sender_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "conversation_id", + sa.Integer(), + sa.ForeignKey("conversations.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column( + "world_id", + sa.Integer(), + sa.ForeignKey("worlds.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column( + "room_id", + sa.Integer(), + sa.ForeignKey("world_rooms.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("filtered", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_messages_sender_id", "messages", ["sender_id"]) + op.create_index( + "ix_messages_conversation_created", "messages", ["conversation_id", "created_at"] + ) + op.create_index("ix_messages_world_created", "messages", ["world_id", "created_at"]) + + op.create_table( + "message_reports", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "message_id", + sa.Integer(), + sa.ForeignKey("messages.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "reporter_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column( + "status", + postgresql.ENUM( + "open", "resolved", "dismissed", name="message_report_status", create_type=False + ), + nullable=False, + server_default="open", + ), + sa.Column( + "moderator_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_message_reports_message_id", "message_reports", ["message_id"]) + op.create_index("ix_message_reports_reporter_id", "message_reports", ["reporter_id"]) + op.create_index("ix_message_reports_moderator_id", "message_reports", ["moderator_id"]) + + +def downgrade() -> None: + op.drop_index("ix_message_reports_moderator_id", table_name="message_reports") + op.drop_index("ix_message_reports_reporter_id", table_name="message_reports") + op.drop_index("ix_message_reports_message_id", table_name="message_reports") + op.drop_table("message_reports") + + op.drop_index("ix_messages_world_created", table_name="messages") + op.drop_index("ix_messages_conversation_created", table_name="messages") + op.drop_index("ix_messages_sender_id", table_name="messages") + op.drop_table("messages") + + op.drop_index("ix_conversation_participants_user_id", table_name="conversation_participants") + op.drop_index( + "ix_conversation_participants_conversation_id", table_name="conversation_participants" + ) + op.drop_table("conversation_participants") + + op.drop_index("ix_conversations_user_b_id", table_name="conversations") + op.drop_index("ix_conversations_user_a_id", table_name="conversations") + op.drop_table("conversations") + + postgresql.ENUM(name="message_report_status").drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2293fe0..2398a03 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,9 +5,13 @@ from app.models.asset import Asset from app.models.avatar_loadout import AvatarLoadout from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision from app.models.social import Friendship, Follow, Block, FriendshipStatus +from app.models.chat import ( + Conversation, ConversationParticipant, Message, MessageReport, ReportStatus, +) __all__ = [ "User", "Session", "World", "Asset", "AvatarLoadout", "WorldRoom", "WorldChunk", "WorldChunkRevision", "Friendship", "Follow", "Block", "FriendshipStatus", + "Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus", ] diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py new file mode 100644 index 0000000..c9499c4 --- /dev/null +++ b/backend/app/models/chat.py @@ -0,0 +1,217 @@ +"""Chat: DM conversations and world/room chat, plus message reports. + +docs/planning/social.md §4/§5 (US-4). This module is models + schema only -- +text filtering, rate limiting, and the send/list/report/moderation endpoints +belong to sibling tasks that depend on this one. + +Delete-behavior choices (mirrors ``app/models/world_content.py``'s +established convention of preserving audit/history data over cascading it +away): + +- ``conversations.user_a_id`` / ``user_b_id`` -> CASCADE. A DM conversation + is meaningless with one of its two (and only) participants gone, so + deleting either user removes the conversation outright, which in turn + cascades to its ``conversation_participants`` rows and its ``messages``. +- ``conversation_participants.user_id`` -> CASCADE, ``.conversation_id`` -> + CASCADE. Pure join-table rows; they only make sense alongside both sides. +- ``messages.conversation_id`` -> CASCADE. Deleting a conversation deletes + its message history with it. +- ``messages.sender_id`` -> SET NULL (nullable). Unlike a DM conversation + (which dies with its participants above), world/room chat history and any + message under moderation review should survive the sender's account being + deleted -- the row just becomes attributed to no one, matching how + ``WorldChunk.updated_by_id`` / ``WorldChunkRevision.actor_id`` treat + actor references as SET NULL rather than cascading away history. +- ``messages.world_id`` / ``room_id`` -> CASCADE. World/room chat has no + meaning once the world or room it was said in is gone; consistent with + ``WorldChunk``'s own CASCADE from both. +- ``message_reports.message_id`` -> SET NULL (nullable). Same audit-trail + reasoning as ``WorldChunkRevision`` (world_content.py): a moderation + report is a record that something *was* said and reported, which must + outlive the underlying message being removed (e.g. as the moderation + action taken in response to the report itself). +- ``message_reports.reporter_id`` / ``moderator_id`` -> SET NULL (nullable). + Actor references for an audit record; the report itself must survive + either account being deleted later, matching the actor-reference + convention above. +""" +import enum + +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + DateTime, + ForeignKey, + Index, + Integer, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class ReportStatus(str, enum.Enum): + OPEN = "open" + RESOLVED = "resolved" + DISMISSED = "dismissed" + + +class Conversation(Base): + """A DM conversation between exactly two users. + + ``conversation_participants`` is the real roster (modeled as a join + table so a future group-DM schema change can widen it beyond two rows + without touching this table's shape), but a 2-party DM also needs a + cheap, DB-enforced "only one conversation per pair" guarantee, which a + plain join table can't express as a simple unique constraint. So the + pair is additionally denormalized here as ``user_a_id``/``user_b_id``, + always stored with the lower user id first (enforced by the check + constraint below); the API layer is responsible for normalizing the + pair before insert/lookup. + """ + + __tablename__ = "conversations" + + id = Column(Integer, primary_key=True, index=True) + user_a_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_b_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + participants = relationship( + "ConversationParticipant", back_populates="conversation", cascade="all, delete-orphan" + ) + messages = relationship( + "Message", back_populates="conversation", cascade="all, delete-orphan" + ) + + __table_args__ = ( + UniqueConstraint("user_a_id", "user_b_id", name="uq_conversations_pair"), + CheckConstraint("user_a_id < user_b_id", name="ck_conversations_ordered_pair"), + ) + + +class ConversationParticipant(Base): + """Join table of a conversation's members. + + DMs are strictly 2-party today (enforced by ``Conversation``'s pair + columns above), but this is modeled as a real join table -- not folded + into ``Conversation`` -- so a later group-conversation feature can add + rows here without a schema change to this table. + """ + + __tablename__ = "conversation_participants" + + id = Column(Integer, primary_key=True, index=True) + conversation_id = Column( + Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + conversation = relationship("Conversation", back_populates="participants") + user = relationship("User") + + __table_args__ = ( + UniqueConstraint( + "conversation_id", "user_id", name="uq_conversation_participants_pair" + ), + ) + + +class Message(Base): + """A chat message: either a DM (``conversation_id`` set) or a + world/room chat message (``conversation_id`` null, ``world_id`` set and + optionally ``room_id``). + + ``world_id`` is denormalized onto every world/room message (mirroring + ``WorldChunk.world_id``) so world-scoped history queries don't need a + join through rooms; ``room_id`` narrows further to a specific room when + the world subdivides chat by room, and is null for world-wide chat. + """ + + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + sender_id = Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + conversation_id = Column( + Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=True + ) + world_id = Column( + Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=True + ) + room_id = Column( + Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"), nullable=True + ) + body = Column(Text, nullable=False) + filtered = Column(Boolean, nullable=False, default=False, server_default="false") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + sender = relationship("User") + conversation = relationship("Conversation", back_populates="messages") + world = relationship("World") + room = relationship("WorldRoom") + reports = relationship( + "MessageReport", back_populates="message", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("ix_messages_conversation_created", "conversation_id", "created_at"), + Index("ix_messages_world_created", "world_id", "created_at"), + ) + + +class MessageReport(Base): + """A moderation report filed against a message. + + ``message_id`` is nullable with ``ON DELETE SET NULL`` (rather than + CASCADE) so the report -- the audit record that something was said and + flagged -- outlives the message row, including when the message's own + removal is the moderation action taken in response to this report. + """ + + __tablename__ = "message_reports" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column( + Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True + ) + reporter_id = Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + reason = Column(Text, nullable=False) + status = Column( + ENUM( + ReportStatus, + name="message_report_status", + create_type=False, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + ), + nullable=False, + default=ReportStatus.OPEN, + server_default=ReportStatus.OPEN.value, + ) + moderator_id = Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + resolved_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + message = relationship("Message", back_populates="reports") + reporter = relationship("User", foreign_keys=[reporter_id]) + moderator = relationship("User", foreign_keys=[moderator_id]) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index df65806..64612e2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -45,6 +45,11 @@ TEST_TABLES = ( # Social graph (app/models/social.py) -- all three FK users with # ON DELETE CASCADE, but list explicitly for clarity/consistency. "friendships", "follows", "blocks", + # Chat (app/models/chat.py). message_reports.message_id is ON DELETE SET + # NULL (not CASCADE, deliberately -- see chat.py), so it wouldn't be + # reached by cascading a truncate of messages/conversations; list it + # explicitly along with the rest for clarity/consistency. + "message_reports", "messages", "conversation_participants", "conversations", "sessions", "refresh_tokens", "users", ) diff --git a/backend/tests/test_chat_models.py b/backend/tests/test_chat_models.py new file mode 100644 index 0000000..4be5393 --- /dev/null +++ b/backend/tests/test_chat_models.py @@ -0,0 +1,344 @@ +"""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()