diff --git a/backend/alembic/versions/0007_add_social_graph_tables.py b/backend/alembic/versions/0007_add_social_graph_tables.py new file mode 100644 index 0000000..c663958 --- /dev/null +++ b/backend/alembic/versions/0007_add_social_graph_tables.py @@ -0,0 +1,118 @@ +"""add social graph tables (friendships, follows, blocks) + +Revision ID: 0007_add_social_graph_tables +Revises: 0006_add_presence_columns +Create Date: 2026-09-15 00:00:00.000000 + +docs/planning/social.md US-1 / app/models/social.py. Models + migration +only -- the request/accept/block business logic lives in the sibling +"Friends & follow API router" task. +""" +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 = "0007_add_social_graph_tables" +down_revision: Union[str, None] = "0006_add_presence_columns" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + friendship_status = postgresql.ENUM( + "pending", "accepted", name="friendship_status" + ) + friendship_status.create(op.get_bind(), checkfirst=True) + + op.create_table( + "friendships", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "requester_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "addressee_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "status", + postgresql.ENUM( + "pending", "accepted", name="friendship_status", create_type=False + ), + nullable=False, + server_default="pending", + ), + 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("requester_id", "addressee_id", name="uq_friendships_pair"), + ) + op.create_index("ix_friendships_requester_id", "friendships", ["requester_id"]) + op.create_index("ix_friendships_addressee_id", "friendships", ["addressee_id"]) + + op.create_table( + "follows", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "follower_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "followed_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("follower_id", "followed_id", name="uq_follows_pair"), + ) + op.create_index("ix_follows_follower_id", "follows", ["follower_id"]) + op.create_index("ix_follows_followed_id", "follows", ["followed_id"]) + + op.create_table( + "blocks", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "blocker_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "blocked_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("blocker_id", "blocked_id", name="uq_blocks_pair"), + ) + op.create_index("ix_blocks_blocker_id", "blocks", ["blocker_id"]) + op.create_index("ix_blocks_blocked_id", "blocks", ["blocked_id"]) + + +def downgrade() -> None: + op.drop_index("ix_blocks_blocked_id", table_name="blocks") + op.drop_index("ix_blocks_blocker_id", table_name="blocks") + op.drop_table("blocks") + + op.drop_index("ix_follows_followed_id", table_name="follows") + op.drop_index("ix_follows_follower_id", table_name="follows") + op.drop_table("follows") + + op.drop_index("ix_friendships_addressee_id", table_name="friendships") + op.drop_index("ix_friendships_requester_id", table_name="friendships") + op.drop_table("friendships") + + postgresql.ENUM(name="friendship_status").drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 20aca4a..2293fe0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,8 +4,10 @@ from app.models.world import World 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 __all__ = [ "User", "Session", "World", "Asset", "AvatarLoadout", "WorldRoom", "WorldChunk", "WorldChunkRevision", + "Friendship", "Follow", "Block", "FriendshipStatus", ] diff --git a/backend/app/models/social.py b/backend/app/models/social.py new file mode 100644 index 0000000..28bfe1e --- /dev/null +++ b/backend/app/models/social.py @@ -0,0 +1,106 @@ +"""Social graph: friendships, follows, blocks. + +docs/planning/social.md §4/§5 (US-1). Friends = mutual (request -> accept), +Follow = one-way (no approval), Block = hard deny. This module is models + +schema only -- the request/accept/block business logic (idempotent +duplicate/self handling, removing an existing friendship/follow on block, +etc.) belongs to the API router task that depends on this one. +""" +import enum + +from sqlalchemy import Column, Integer, ForeignKey, DateTime, UniqueConstraint +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class FriendshipStatus(str, enum.Enum): + PENDING = "pending" + ACCEPTED = "accepted" + + +class Friendship(Base): + """A friend request, directional until accepted. + + ``requester_id`` sent the request to ``addressee_id``. Once accepted the + friendship is mutual, but the row stays directional (who asked whom) for + audit purposes; the API layer treats it as symmetric once accepted. + """ + + __tablename__ = "friendships" + + id = Column(Integer, primary_key=True, index=True) + requester_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + addressee_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + status = Column( + ENUM( + FriendshipStatus, + name="friendship_status", + create_type=False, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + ), + nullable=False, + default=FriendshipStatus.PENDING, + server_default=FriendshipStatus.PENDING.value, + ) + 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) + + requester = relationship("User", foreign_keys=[requester_id], back_populates="sent_friend_requests") + addressee = relationship("User", foreign_keys=[addressee_id], back_populates="received_friend_requests") + + __table_args__ = ( + UniqueConstraint("requester_id", "addressee_id", name="uq_friendships_pair"), + ) + + +class Follow(Base): + """A one-way follow relationship: ``follower_id`` follows ``followed_id``.""" + + __tablename__ = "follows" + + id = Column(Integer, primary_key=True, index=True) + follower_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + followed_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) + + follower = relationship("User", foreign_keys=[follower_id], back_populates="following") + followed = relationship("User", foreign_keys=[followed_id], back_populates="followers") + + __table_args__ = ( + UniqueConstraint("follower_id", "followed_id", name="uq_follows_pair"), + ) + + +class Block(Base): + """A hard deny: ``blocker_id`` has blocked ``blocked_id``.""" + + __tablename__ = "blocks" + + id = Column(Integer, primary_key=True, index=True) + blocker_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + blocked_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) + + blocker = relationship("User", foreign_keys=[blocker_id], back_populates="blocks_initiated") + blocked = relationship("User", foreign_keys=[blocked_id], back_populates="blocked_by") + + __table_args__ = ( + UniqueConstraint("blocker_id", "blocked_id", name="uq_blocks_pair"), + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 02257b5..7559276 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -35,6 +35,44 @@ class User(Base): cascade="all, delete-orphan", ) + # Social graph (docs/planning/social.md US-1; app/models/social.py) + sent_friend_requests = relationship( + "Friendship", + foreign_keys="Friendship.requester_id", + back_populates="requester", + cascade="all, delete-orphan", + ) + received_friend_requests = relationship( + "Friendship", + foreign_keys="Friendship.addressee_id", + back_populates="addressee", + cascade="all, delete-orphan", + ) + following = relationship( + "Follow", + foreign_keys="Follow.follower_id", + back_populates="follower", + cascade="all, delete-orphan", + ) + followers = relationship( + "Follow", + foreign_keys="Follow.followed_id", + back_populates="followed", + cascade="all, delete-orphan", + ) + blocks_initiated = relationship( + "Block", + foreign_keys="Block.blocker_id", + back_populates="blocker", + cascade="all, delete-orphan", + ) + blocked_by = relationship( + "Block", + foreign_keys="Block.blocked_id", + back_populates="blocked", + cascade="all, delete-orphan", + ) + @property def is_adult(self) -> bool: """Return True if the user is 18 or older based on date_of_birth.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fff67a8..df65806 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -41,7 +41,11 @@ TEST_TABLES = ( # `worlds` the way world_rooms/world_chunks would be. List all three # explicitly rather than rely on cascade for some of them. "world_chunk_revisions", "world_chunks", "world_rooms", - "worlds", "sessions", "refresh_tokens", "users", + "worlds", + # Social graph (app/models/social.py) -- all three FK users with + # ON DELETE CASCADE, but list explicitly for clarity/consistency. + "friendships", "follows", "blocks", + "sessions", "refresh_tokens", "users", ) BACKEND_DIR = Path(__file__).resolve().parents[1] diff --git a/backend/tests/test_social_models.py b/backend/tests/test_social_models.py new file mode 100644 index 0000000..899ddca --- /dev/null +++ b/backend/tests/test_social_models.py @@ -0,0 +1,240 @@ +"""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()