546678a30d
docs/planning/social.md US-4, task 6: conversations, conversation_participants, messages (DM + world/room chat), and message_reports, following app/models/social.py's conventions (native enums, explicit index/constraint names, CASCADE FKs where appropriate). Delete behavior, chosen per-relationship: - conversations.user_a_id/user_b_id -> CASCADE: a DM is meaningless with one of its two participants gone, so it cascades away entirely (participants + messages with it). - messages.sender_id -> SET NULL: world/room chat history and reported messages must survive the sender's account being deleted, matching the existing actor-reference convention (WorldChunk.updated_by_id, WorldChunkRevision.actor_id). - messages.world_id/room_id -> CASCADE: chat scoped to a world/room has no meaning once that world/room is gone (mirrors WorldChunk). - message_reports.message_id -> SET NULL (not CASCADE): a moderation report must outlive the message it's about, mirroring WorldChunkRevision's audit-trail-survives-deletion precedent. - message_reports.reporter_id/moderator_id -> SET NULL: audit-actor references, same reasoning. Models + migration only, no endpoints, no text filtering (sibling tasks). Verified: migration upgrade/downgrade/upgrade round-trip against a real Postgres with schema inspected via \d against spec; new model tests cover DM creation, pair uniqueness + ordering check constraint, world/room messages, report defaults, and cascade behavior for both conversation-deletion and sender-deletion; full backend suite (160 tests) passes with no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""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()
|