9661b9a81c
Models + Alembic migration (0007, head) only, per docs/planning/social.md US-1 -- the friends/follow API router is a separate sibling task. - friendships: requester_id/addressee_id (FK users, CASCADE), status (pending/accepted native enum), unique pair constraint, indexed FKs. - follows: follower_id/followed_id (FK users, CASCADE), unique pair. - blocks: blocker_id/blocked_id (FK users, CASCADE), unique pair. - Relationships wired onto User for both directions of each table. - tests/test_social_models.py exercises request/accept, the unique-pair constraint, follow, block, and DB-level CASCADE delete against real Postgres. Full backend suite (152 tests) still green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
241 lines
8.8 KiB
Python
241 lines
8.8 KiB
Python
"""Model-level tests for the social graph: friendships, follows, blocks.
|
|
|
|
app/models/social.py / alembic/versions/0007_add_social_graph_tables.py
|
|
(docs/planning/social.md US-1). This is the models+migration task only --
|
|
there are no API endpoints yet (a sibling task owns the router), 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.social import Friendship, Follow, Block, FriendshipStatus
|
|
from app.models.user import User
|
|
|
|
|
|
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 TestFriendshipRequestAccept:
|
|
async def test_request_defaults_to_pending(self):
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_fr_a1")
|
|
b = _register(client, "sg_fr_b1")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
fr = Friendship(requester_id=a, addressee_id=b)
|
|
session.add(fr)
|
|
await session.commit()
|
|
await session.refresh(fr)
|
|
assert fr.status == FriendshipStatus.PENDING
|
|
assert fr.requester_id == a
|
|
assert fr.addressee_id == b
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
async def test_accept_updates_status(self):
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_fr_a2")
|
|
b = _register(client, "sg_fr_b2")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
fr = Friendship(requester_id=a, addressee_id=b)
|
|
session.add(fr)
|
|
await session.commit()
|
|
fr_id = fr.id
|
|
|
|
async with factory() as session:
|
|
fr = await session.get(Friendship, fr_id)
|
|
fr.status = FriendshipStatus.ACCEPTED
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
fr = await session.get(Friendship, fr_id)
|
|
assert fr.status == FriendshipStatus.ACCEPTED
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
class TestUniquePairConstraints:
|
|
async def test_duplicate_friendship_pair_rejected(self):
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_uq_fr_a")
|
|
b = _register(client, "sg_uq_fr_b")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
session.add(Friendship(requester_id=a, addressee_id=b))
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
session.add(Friendship(requester_id=a, addressee_id=b))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
async def test_duplicate_follow_pair_rejected(self):
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_uq_fw_a")
|
|
b = _register(client, "sg_uq_fw_b")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
session.add(Follow(follower_id=a, followed_id=b))
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
session.add(Follow(follower_id=a, followed_id=b))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
async def test_duplicate_block_pair_rejected(self):
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_uq_bl_a")
|
|
b = _register(client, "sg_uq_bl_b")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
session.add(Block(blocker_id=a, blocked_id=b))
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
session.add(Block(blocker_id=a, blocked_id=b))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
class TestFollow:
|
|
async def test_create_follow(self):
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_follow_a")
|
|
b = _register(client, "sg_follow_b")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
session.add(Follow(follower_id=a, followed_id=b))
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
rows = (await session.execute(
|
|
select(Follow).where(Follow.follower_id == a, Follow.followed_id == b)
|
|
)).scalars().all()
|
|
assert len(rows) == 1
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
class TestBlock:
|
|
async def test_create_block(self):
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_block_a")
|
|
b = _register(client, "sg_block_b")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
session.add(Block(blocker_id=a, blocked_id=b))
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
rows = (await session.execute(
|
|
select(Block).where(Block.blocker_id == a, Block.blocked_id == b)
|
|
)).scalars().all()
|
|
assert len(rows) == 1
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
class TestCascadeDelete:
|
|
async def test_deleting_user_removes_rows_in_all_three_tables(self):
|
|
"""Deleting a user removes friendships/follows/blocks referencing them
|
|
in *either* FK column, via the real DB ON DELETE CASCADE (deleted with
|
|
a Core statement, bypassing any ORM-level cascade, to prove the
|
|
database constraint itself does the job)."""
|
|
client = TestClient(app)
|
|
a = _register(client, "sg_cascade_a")
|
|
b = _register(client, "sg_cascade_b")
|
|
c = _register(client, "sg_cascade_c")
|
|
|
|
engine, factory = _session_factory()
|
|
try:
|
|
async with factory() as session:
|
|
# a as requester/follower/blocker, and also as addressee (c -> a)
|
|
session.add(Friendship(requester_id=a, addressee_id=b))
|
|
session.add(Friendship(requester_id=c, addressee_id=a))
|
|
session.add(Follow(follower_id=a, followed_id=b))
|
|
session.add(Follow(follower_id=c, followed_id=a))
|
|
session.add(Block(blocker_id=a, blocked_id=b))
|
|
session.add(Block(blocker_id=c, blocked_id=a))
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
await session.execute(delete(User).where(User.id == a))
|
|
await session.commit()
|
|
|
|
async with factory() as session:
|
|
friendships = (await session.execute(
|
|
select(Friendship).where(
|
|
(Friendship.requester_id == a) | (Friendship.addressee_id == a)
|
|
)
|
|
)).scalars().all()
|
|
follows = (await session.execute(
|
|
select(Follow).where(
|
|
(Follow.follower_id == a) | (Follow.followed_id == a)
|
|
)
|
|
)).scalars().all()
|
|
blocks = (await session.execute(
|
|
select(Block).where(
|
|
(Block.blocker_id == a) | (Block.blocked_id == a)
|
|
)
|
|
)).scalars().all()
|
|
assert friendships == []
|
|
assert follows == []
|
|
assert blocks == []
|
|
|
|
# Unrelated rows (b, c only) survive.
|
|
remaining_users = (await session.execute(
|
|
select(User.id).where(User.id.in_([b, c]))
|
|
)).scalars().all()
|
|
assert set(remaining_users) == {b, c}
|
|
finally:
|
|
await engine.dispose()
|