Compare commits

..

2 Commits

Author SHA1 Message Date
daddy 9661b9a81c Add social graph models: friendships, follows, blocks
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
2026-09-15 00:10:15 +00:00
daddy 4eb4476fc3 Presence & status model + endpoints (Redis heartbeat) -- recovered
checkpoint / checkpoint (push) Successful in 2m25s
Real feature built in August, checkpointed mid-task by the old fleet's
graceful-shutdown mechanism, then orphaned when checkpoint_focus_
feature_ids scoped the fleet away from M8 and it was never resumed --
the PM task had shown done/reviewed for a month with zero actual code on
main. Ported forward from the 225-commits-stale branch onto current
main's conventions (DbDep pattern, migration rebased onto 0005's head),
fixing two real bugs found in the process: the original PresenceStore's
explicit connect() was never called from anywhere (would 500 on every
real request), and a naive fix hits 'event loop is closed' under this
repo's TestClient(app) pattern -- fixed with lazy per-event-loop client
caching, same class of issue already documented for asyncpg in
conftest.py. conftest.py extended to also bring up ephemeral Redis
symmetrically with its existing Postgres bootstrap.

144/144 backend tests independently re-run via the fully automated
hermetic path (zero manual setup, matching what a fresh contributor
gets), plus a manual end-to-end heartbeat/TTL-expiry/batch-query check
against real Postgres+Redis.
2026-09-15 00:01:24 +00:00
6 changed files with 509 additions and 1 deletions
@@ -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)
+2
View File
@@ -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",
]
+106
View File
@@ -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"),
)
+38
View File
@@ -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."""
+5 -1
View File
@@ -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]
+240
View File
@@ -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()