Compare commits

...

10 Commits

Author SHA1 Message Date
daddy f6c55d888f Add Groups API router: create/join/leave, roles, transfer, group page
Implements docs/planning/social.md US-5's API layer on top of the
already-merged Group/GroupMember models (app/models/groups.py):

- POST/GET /api/groups, GET/PATCH/DELETE /api/groups/{id}
- POST .../join (idempotent), .../leave (owner must transfer first)
- POST .../members/{user_id}/role (owner-only, admin/member),
  .../transfer (owner-only, demotes prior owner to admin)

Group page member list is a dedicated schema (user_id/username/role/
joined_at) so no private User fields leak. 27 new tests cover
authorization boundaries, idempotency, and exactly-one-owner invariants;
full backend suite (220 tests) passes with zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFSPt6rX1gwZmcr2HEfvzg
2026-09-15 01:09:29 +00:00
daddy eac6cf81ef Text filtering service + profanity config
checkpoint / checkpoint (push) Successful in 2m56s
TextFilter protocol + ListBasedTextFilter: normalized (case-fold +
leetspeak/lookalike substitution, non-alnum stripped) substring matching
against a configurable JSON term list, with an index map so matched spans
in normalized text translate back to the original text for redaction.
Supports both redact and reject modes. No external service dependency;
the protocol itself is the extension point for a future third-party
provider. Standalone service module, not yet wired into any endpoint
(sibling tasks own that).

Independently re-verified: 193/193 backend tests, no regressions.
2026-09-15 01:02:00 +00:00
daddy 10a431e7a1 Merge branch 'main' into verify-4daac3f1cfba 2026-09-15 00:37:58 +00:00
daddy 0b3622d6b3 Group + membership models + migration
checkpoint / checkpoint (push) Successful in 2m55s
groups (owner SET NULL -- a group is a durable community, not
single-owner content, survives owner deletion) and group_members
(owner/admin/member role, unique group_id+user_id, CASCADE both FKs).
Models/migration only, no API endpoints (sibling task).

Independently re-verified: migration round-trip, schema inspected
directly (FKs/constraints/indexes match spec), 170/170 backend tests.
2026-09-15 00:37:47 +00:00
daddy eb2328b427 Merge branch 'main' into verify-ff065522ba55 2026-09-15 00:35:40 +00:00
daddy 55f849f221 Reusable named rate-limiting dependency (extend existing in-process limiter)
checkpoint / checkpoint (push) Successful in 2m48s
Generalizes app/rate_limit.py's SlidingWindowLimiter pattern (deliberately
in-process, no Redis -- this backend runs single-worker per that file's
own documented reasoning) into a named-limit factory: rate_limit(name,
max_hits=, window_seconds=) returns a FastAPI dependency, reusing the
existing 429+Retry-After behavior and RATE_LIMIT_ENABLED flag. Used by
chat-send/report endpoints (sibling tasks). auth_rate_limit itself
untouched; reset_all() generalized to cover every registered limiter.

Independently re-verified: 164/164 backend tests, no regressions.
2026-09-15 00:35:28 +00:00
daddy 600261dba2 Merge branch 'main' into verify-9eaf37ab424c 2026-09-15 00:33:52 +00:00
daddy c256fb2334 tests/conftest.py: derive the hermetic-bootstrap compose project/ports per checkout
checkpoint / checkpoint (push) Successful in 2m47s
Hardcoded _COMPOSE_PROJECT="agp-pytest" + fixed ports meant every
concurrent checkout running pytest (e.g. multiple Agent-tool subagents,
each in their own workspace clone) fought over the same Docker Compose
project and host ports -- one run's pytest_unconfigure teardown could
yank the ephemeral Postgres out from under a completely unrelated
concurrent run. Confirmed live running 3 parallel subagents: one hit
"connection was closed in the middle of operation", another independently
hit a transient DeadlockDetectedError from concurrent table truncation on
the same shared container. Same root cause and fix pattern as the
agent_core workspace/Docker-Compose collision documented in project_agp
memory -- derive both the project name and the ports from a hash of the
checkout directory so concurrent checkouts land on different
projects/ports while a single checkout stays stable across repeated runs.
2026-09-15 00:33:46 +00:00
daddy 9334caf533 Add pluggable server-side TextFilter service (social.md §4 US-4)
Deterministic default (ListBasedTextFilter) backed by a configurable JSON
term list, with normalization (case-fold, leetspeak substitutions,
stripped punctuation/spacing between letters) so trivial evasion doesn't
slip through. Supports both reject (blocking) and redact (asterisk
replacement) modes. TextFilter is a Protocol, leaving an explicit
extension point for a future third-party moderation provider.

Standalone module, not wired into any router/endpoint -- sibling chat
tasks depend on this one. No DB/migration work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:32:17 +00:00
daddy 712d21e746 Add groups + group_members models and migration
docs/planning/social.md US-5 (task 13). Models + Alembic migration only,
per the sibling-dependency boundary with the Groups API task.

- groups: id, name, description, owner_id (FK users, ON DELETE SET NULL --
  a group is a durable multi-member community, so it and its full roster
  survive the owner deleting their account; the API task handles surfacing
  an ownerless group for reassignment).
- group_members: group_id/user_id (both FK, ON DELETE CASCADE -- pure
  join-table rows with no meaning without either side), role
  (owner/admin/member native enum, default member), joined_at, unique
  constraint on (group_id, user_id).
- User gets owned_groups / group_memberships relationships.
- tests/conftest.py TEST_TABLES updated for truncation between tests.
- New tests/test_group_models.py: role assignment, unique-membership
  rejection, group-delete cascade, member-delete cascade, and owner-delete
  SET NULL survival -- all verified against real Postgres.

Verified: upgrade/downgrade/upgrade round-trip, \d-inspected schema matches
spec (FKs, cascade behavior, unique constraint, indexes), full backend
suite 166 passed / 0 regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:28:46 +00:00
12 changed files with 1892 additions and 4 deletions
@@ -0,0 +1,80 @@
"""add group tables (groups, group_members)
Revision ID: 0009_add_group_tables
Revises: 0008_add_chat_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-5 / app/models/groups.py. Models + migration only
-- create/join/leave, role authorization, and the group page endpoint live
in a sibling task ("Groups API") that depends 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 = "0009_add_group_tables"
down_revision: Union[str, None] = "0008_add_chat_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
group_role = postgresql.ENUM("owner", "admin", "member", name="group_role")
group_role.create(op.get_bind(), checkfirst=True)
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"owner_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
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),
)
op.create_index("ix_groups_owner_id", "groups", ["owner_id"])
op.create_table(
"group_members",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False),
nullable=False,
server_default="member",
),
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
op.create_index("ix_group_members_user_id", "group_members", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_group_members_user_id", table_name="group_members")
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_owner_id", table_name="groups")
op.drop_table("groups")
postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True)
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups
from app.services.game_servers import supervisor from app.services.game_servers import supervisor
@@ -45,6 +45,7 @@ app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"]) app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"]) app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"]) app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
app.include_router(groups.router, prefix="/api/groups", tags=["groups"])
@app.get("/api/health") @app.get("/api/health")
+2
View File
@@ -8,10 +8,12 @@ from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.chat import ( from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus, Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
) )
from app.models.groups import Group, GroupMember, GroupRole
__all__ = [ __all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout", "User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision", "WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus", "Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus", "Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole",
] ]
+97
View File
@@ -0,0 +1,97 @@
"""Groups: named groups with owner/admin/member roles.
docs/planning/social.md §4/§5 (US-5). This module is models + schema only --
create/join/leave, role authorization (only the owner may transfer ownership
or promote/demote admins), and the group page aggregate endpoint belong to
the API router task ("Groups API") that depends on this one.
Delete-behavior choices:
- ``groups.owner_id`` -> ``SET NULL`` (nullable). A group is a durable,
multi-member community, not content solely attributable to one person like
a ``World``/``Asset``. Cascading the group away (or hard-blocking the
owner's account deletion) would punish every other member for the owner's
choice to leave the platform. Instead the group and its full membership
list survive with ``owner_id`` nulled out; the sibling API task is
responsible for surfacing an ownerless group (e.g. prompting an admin to
claim ownership) the same way ``Message.sender_id`` / ``WorldChunk``'s
actor columns already treat "the actor is gone" as a survivable state
elsewhere in this schema.
- ``group_members.group_id`` -> ``CASCADE``. A membership row is meaningless
without the group it belongs to; deleting a group deletes its roster,
mirroring ``ConversationParticipant.conversation_id`` in chat.py.
- ``group_members.user_id`` -> ``CASCADE``. Deleting a user removes their own
membership rows wherever they appear, matching the join-table convention
used by ``ConversationParticipant.user_id`` and the social-graph tables
(``Friendship``/``Follow``/``Block``) for plain per-user references.
"""
import enum
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class GroupRole(str, enum.Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
class Group(Base):
"""A named group. ``owner_id`` is nullable -- see module docstring for
why the group survives its owner's account deletion."""
__tablename__ = "groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, 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
)
owner = relationship("User", back_populates="owned_groups")
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
"""A group's roster: one row per (group, user) with a role."""
__tablename__ = "group_members"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
role = Column(
ENUM(
GroupRole,
name="group_role",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=GroupRole.MEMBER,
server_default=GroupRole.MEMBER.value,
)
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
group = relationship("Group", back_populates="members")
user = relationship("User", back_populates="group_memberships")
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
+11
View File
@@ -73,6 +73,17 @@ class User(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Groups (docs/planning/social.md US-5; app/models/groups.py)
owned_groups = relationship(
"Group",
back_populates="owner",
)
group_memberships = relationship(
"GroupMember",
back_populates="user",
cascade="all, delete-orphan",
)
@property @property
def is_adult(self) -> bool: def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth.""" """Return True if the user is 18 or older based on date_of_birth."""
+465
View File
@@ -0,0 +1,465 @@
"""Groups API router.
Endpoints:
- POST /api/groups -- create (creator becomes owner)
- GET /api/groups -- list groups (paginated), optional mine=true
- GET /api/groups/{id} -- group page (name/description/members)
- PATCH /api/groups/{id} -- update name/description (owner/admin)
- DELETE /api/groups/{id} -- delete (owner only)
- POST /api/groups/{id}/join -- join (idempotent)
- POST /api/groups/{id}/leave -- leave (owner must transfer first)
- POST /api/groups/{id}/members/{user_id}/role -- set role admin/member (owner only)
- POST /api/groups/{id}/transfer -- transfer ownership (owner only)
See app/models/groups.py for the schema and delete-behavior rationale
(docs/planning/social.md US-5). This module owns create/join/leave, role
authorization, and the group page aggregate.
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.groups import Group, GroupMember, GroupRole
from app.models.user import User
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_NAME_MAX_LEN = 100
_DESCRIPTION_MAX_LEN = 2000
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class GroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=_NAME_MAX_LEN)
description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN)
@field_validator("name")
@classmethod
def strip_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v
@field_validator("description")
@classmethod
def strip_description(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip() or None
return v
class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=_NAME_MAX_LEN)
description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN)
@field_validator("name")
@classmethod
def strip_name(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v
@field_validator("description")
@classmethod
def strip_description(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip() or None
return v
class GroupSummary(BaseModel):
id: int
name: str
description: str | None
owner_id: int | None
member_count: int
created_at: datetime
class GroupListResponse(BaseModel):
groups: list[GroupSummary]
total: int
class GroupMemberOut(BaseModel):
"""Public-safe member entry -- no email or other private User fields."""
user_id: int
username: str
role: GroupRole
joined_at: datetime
class GroupDetail(BaseModel):
id: int
name: str
description: str | None
owner_id: int | None
member_count: int
members: list[GroupMemberOut]
created_at: datetime
class RoleUpdateRequest(BaseModel):
role: GroupRole
@field_validator("role")
@classmethod
def only_admin_or_member(cls, v: GroupRole) -> GroupRole:
if v == GroupRole.OWNER:
raise ValueError(
"cannot set role to owner directly -- use POST /transfer"
)
return v
class TransferRequest(BaseModel):
new_owner_id: int
class MembershipOut(BaseModel):
group_id: int
user_id: int
role: GroupRole
joined_at: datetime
# ──────────────────────────────── Helpers ─────────────────────────────────────
async def _get_group_or_404(db: AsyncSession, group_id: int) -> Group:
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
return group
async def _get_membership(db: AsyncSession, group_id: int, user_id: int) -> GroupMember | None:
result = await db.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == user_id
)
)
return result.scalar_one_or_none()
async def _member_count(db: AsyncSession, group_id: int) -> int:
result = await db.execute(
select(func.count()).select_from(GroupMember).where(GroupMember.group_id == group_id)
)
return int(result.scalar_one())
def _require_role(membership: GroupMember | None, *allowed: GroupRole) -> None:
if membership is None or membership.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to perform this action",
)
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.post("", response_model=GroupDetail, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=GroupDetail, status_code=status.HTTP_201_CREATED, include_in_schema=False)
async def create_group(
body: GroupCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a group. The creator becomes its owner."""
group = Group(name=body.name, description=body.description, owner_id=current_user.id)
db.add(group)
await db.flush()
db.add(GroupMember(group_id=group.id, user_id=current_user.id, role=GroupRole.OWNER))
await db.commit()
await db.refresh(group)
return GroupDetail(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=1,
members=[
GroupMemberOut(
user_id=current_user.id,
username=current_user.username,
role=GroupRole.OWNER,
joined_at=group.created_at,
)
],
created_at=group.created_at,
)
@router.get("", response_model=GroupListResponse)
@router.get("/", response_model=GroupListResponse, include_in_schema=False)
async def list_groups(
current_user: CurrentUser,
db: DbDep,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
mine: bool = Query(False, description="If true, only groups the caller is a member of"),
):
"""List groups, paginated. With ``mine=true``, restrict to the caller's own groups."""
base_query = select(Group)
count_query = select(func.count()).select_from(Group)
if mine:
base_query = base_query.join(
GroupMember, GroupMember.group_id == Group.id
).where(GroupMember.user_id == current_user.id)
count_query = count_query.join(
GroupMember, GroupMember.group_id == Group.id
).where(GroupMember.user_id == current_user.id)
total = (await db.execute(count_query)).scalar_one()
result = await db.execute(
base_query.order_by(Group.created_at.desc()).offset(skip).limit(limit)
)
groups = result.scalars().all()
summaries = []
for group in groups:
summaries.append(
GroupSummary(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=await _member_count(db, group.id),
created_at=group.created_at,
)
)
return GroupListResponse(groups=summaries, total=total)
@router.get("/{group_id}", response_model=GroupDetail)
async def get_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Group page: name, description, member count, and member list with roles."""
group = await _get_group_or_404(db, group_id)
result = await db.execute(
select(GroupMember, User.username)
.join(User, User.id == GroupMember.user_id)
.where(GroupMember.group_id == group_id)
.order_by(GroupMember.joined_at.asc())
)
rows = result.all()
members = [
GroupMemberOut(
user_id=gm.user_id,
username=username,
role=gm.role,
joined_at=gm.joined_at,
)
for gm, username in rows
]
return GroupDetail(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=len(members),
members=members,
created_at=group.created_at,
)
@router.patch("/{group_id}", response_model=GroupDetail)
async def update_group(
group_id: int,
body: GroupUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update name/description. Owner/admin only."""
group = await _get_group_or_404(db, group_id)
membership = await _get_membership(db, group_id, current_user.id)
_require_role(membership, GroupRole.OWNER, GroupRole.ADMIN)
if body.name is not None:
group.name = body.name
if body.description is not None:
group.description = body.description if body.description else None
await db.commit()
await db.refresh(group)
return await get_group(group_id, current_user, db)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Delete a group. Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can delete this group",
)
await db.delete(group)
await db.commit()
return None
@router.post("/{group_id}/join", response_model=MembershipOut)
async def join_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Join a group. Idempotent -- joining an already-joined group is a no-op."""
await _get_group_or_404(db, group_id)
existing = await _get_membership(db, group_id, current_user.id)
if existing is not None:
return MembershipOut(
group_id=group_id,
user_id=current_user.id,
role=existing.role,
joined_at=existing.joined_at,
)
membership = GroupMember(group_id=group_id, user_id=current_user.id, role=GroupRole.MEMBER)
db.add(membership)
await db.commit()
await db.refresh(membership)
return MembershipOut(
group_id=group_id,
user_id=current_user.id,
role=membership.role,
joined_at=membership.joined_at,
)
@router.post("/{group_id}/leave", response_model=dict)
async def leave_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Leave a group. The owner cannot leave without transferring ownership first."""
await _get_group_or_404(db, group_id)
membership = await _get_membership(db, group_id, current_user.id)
if membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="You are not a member of this group",
)
if membership.role == GroupRole.OWNER:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Transfer ownership before leaving this group",
)
await db.delete(membership)
await db.commit()
return {"status": "ok"}
@router.post("/{group_id}/members/{user_id}/role", response_model=MembershipOut)
async def set_member_role(
group_id: int,
user_id: int,
body: RoleUpdateRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Set a member's role (admin/member). Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can change member roles",
)
target = await _get_membership(db, group_id, user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
if target.role == GroupRole.OWNER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Use POST /transfer to change ownership",
)
target.role = body.role
await db.commit()
await db.refresh(target)
return MembershipOut(
group_id=group_id, user_id=user_id, role=target.role, joined_at=target.joined_at
)
@router.post("/{group_id}/transfer", response_model=GroupDetail)
async def transfer_ownership(
group_id: int,
body: TransferRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Transfer group ownership to another existing member. Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can transfer ownership",
)
if body.new_owner_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You already own this group",
)
new_owner_membership = await _get_membership(db, group_id, body.new_owner_id)
if new_owner_membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="New owner must already be a member of this group",
)
current_owner_membership = await _get_membership(db, group_id, current_user.id)
new_owner_membership.role = GroupRole.OWNER
if current_owner_membership is not None:
current_owner_membership.role = GroupRole.ADMIN
group.owner_id = body.new_owner_id
await db.commit()
return await get_group(group_id, current_user, db)
+304
View File
@@ -0,0 +1,304 @@
"""Pluggable server-side text filtering, `docs/planning/social.md` §4/§5 (US-4).
social.md §4 calls for a "Pluggable `TextFilter` interface with a
deterministic default (profanity list + normalization) so it runs without
external services; third-party hook later". This module is that interface
plus the default implementation:
- `TextFilter` is a `Protocol` -- the extension point. A future third-party
moderation provider (a paid API, an ML classifier, etc.) just needs to
implement `check(text) -> FilterResult`; nothing else in this module
needs to change, and callers depend on the protocol, not a concrete
class.
- `ListBasedTextFilter` is the deterministic default: a configurable
term list (JSON file, see `text_filter_terms.json` next to this module)
matched against a normalized form of the input text so that trivial
evasion (case changes, leetspeak substitutions, and punctuation/spacing
inserted between letters) doesn't slip through.
Nothing here talks to a database, an HTTP client, or the FastAPI app -- it
is a pure, side-effect-free (aside from reading the term list file once, at
construction) service so it can be unit tested in isolation. It is NOT
wired into any router/endpoint by this task; that's for the chat feature
tasks that depend on it (social.md §6) to do.
Normalization approach
-----------------------
Matching naively against raw text misses "sh1t", "@ss", or "s.h.i.t". To
catch these without a real tokenizer, every character of the input is
independently normalized to at most one lowercase ASCII alphanumeric
character (case-folded, then run through a small leetspeak substitution
table); characters that don't reduce to `[a-z0-9]` (punctuation, spacing,
symbols, non-ASCII) are dropped entirely rather than kept as separators.
This collapses "s.h.i.t", "s h i t", and "SHIT" to the same normalized
string ("shit") that a plain lowercase term list entry matches, while a
side table (`index_map`) remembers which original character produced each
surviving normalized character -- so a match found in the normalized
string can still be translated back into a span of the *original* text for
redaction.
This is a deliberate precision/recall tradeoff common to list-based
filters: stripping separators and folding leetspeak increases recall
against evasion at the cost of occasionally matching a term inside an
unrelated longer word (e.g. a 3-letter term inside a longer benign word).
That tradeoff is exactly why this is pluggable -- a deployment that needs
tighter precision can swap in a smarter `TextFilter` implementation behind
the same protocol.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Iterable, Protocol, runtime_checkable
# ---------------------------------------------------------------------------
# Public result / protocol
# ---------------------------------------------------------------------------
class FilterAction(str, Enum):
"""What the caller should do with a checked message."""
ALLOW = "allow"
REDACT = "redact"
REJECT = "reject"
@dataclass(frozen=True)
class FilterResult:
"""Outcome of running a piece of text through a `TextFilter`.
- `action`: ALLOW (no matches), REDACT (matches replaced in
`filtered_text`), or REJECT (matches found; the message should be
blocked -- callers should not persist/deliver it even though
`filtered_text` is populated for logging/audit purposes).
- `filtered_text`: the text to actually store/deliver. Equal to the
input for ALLOW and REJECT; has matched spans replaced for REDACT.
- `matched_terms`: the distinct matched terms (original, un-normalized
list entries), in first-occurrence order. Empty for ALLOW.
"""
action: FilterAction
filtered_text: str
matched_terms: tuple[str, ...] = ()
@runtime_checkable
class TextFilter(Protocol):
"""Extension point for text filtering/moderation providers.
Any implementation -- the deterministic default below, or a future
third-party service call -- just needs this one method.
"""
def check(self, text: str) -> FilterResult:
"""Check `text` and return the action to take plus filtered text."""
...
class FilterMode(str, Enum):
"""Configures what `ListBasedTextFilter` does when it finds a match."""
REDACT = "redact"
REJECT = "reject"
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
# Common leetspeak / lookalike substitutions, applied after case-folding.
# Keys are already-lowercased single characters.
_LEET_MAP: dict[str, str] = {
"0": "o",
"1": "i",
"!": "i",
"3": "e",
"4": "a",
"@": "a",
"5": "s",
"$": "s",
"7": "t",
"+": "t",
"8": "b",
"|": "l",
}
_ALNUM_RE = re.compile(r"[a-z0-9]")
def _normalize(text: str) -> tuple[str, list[int]]:
"""Fold `text` to a lowercase-alnum-only string plus an index map.
Returns `(normalized, index_map)` where `index_map[i]` is the index
into the *original* `text` that produced `normalized[i]`. Characters
that don't reduce to a single `[a-z0-9]` character (punctuation,
whitespace, symbols, most non-ASCII) are dropped from both outputs.
"""
normalized_chars: list[str] = []
index_map: list[int] = []
for i, ch in enumerate(text):
folded = ch.casefold()
# case-fold can expand to multiple characters for some unicode
# (e.g. German "ß" -> "ss"); such characters are not part of the
# leetspeak table and are not plain ascii alnum, so just skip them
# like any other non-matching character rather than guessing.
if len(folded) != 1:
continue
folded = _LEET_MAP.get(folded, folded)
if _ALNUM_RE.fullmatch(folded):
normalized_chars.append(folded)
index_map.append(i)
return "".join(normalized_chars), index_map
def _normalize_term(term: str) -> str:
normalized, _ = _normalize(term)
return normalized
# ---------------------------------------------------------------------------
# Default term list loading
# ---------------------------------------------------------------------------
DEFAULT_TERMS_PATH = Path(__file__).with_name("text_filter_terms.json")
def load_terms(path: Path | str | None = None) -> list[str]:
"""Load a term list from a JSON file shaped `{"terms": [...]}`.
Any other top-level keys (e.g. `_readme`) are ignored. Defaults to the
bundled `text_filter_terms.json` next to this module.
"""
resolved = Path(path) if path is not None else DEFAULT_TERMS_PATH
with resolved.open("r", encoding="utf-8") as f:
data = json.load(f)
terms = data.get("terms", [])
if not isinstance(terms, list):
raise ValueError(f"{resolved}: 'terms' must be a list")
return [str(t) for t in terms]
# ---------------------------------------------------------------------------
# Span merging / redaction helpers
# ---------------------------------------------------------------------------
def _merge_spans(spans: list[tuple[int, int, str]]) -> list[tuple[int, int]]:
"""Merge overlapping/adjacent (start, end) spans, sorted by start."""
if not spans:
return []
ordered = sorted((s, e) for s, e, _ in spans)
merged: list[tuple[int, int]] = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def _apply_redactions(text: str, merged_spans: list[tuple[int, int]], redaction_char: str) -> str:
if not merged_spans:
return text
pieces: list[str] = []
cursor = 0
for start, end in merged_spans:
pieces.append(text[cursor:start])
pieces.append(redaction_char * (end - start))
cursor = end
pieces.append(text[cursor:])
return "".join(pieces)
# ---------------------------------------------------------------------------
# Default implementation
# ---------------------------------------------------------------------------
class ListBasedTextFilter:
"""Deterministic `TextFilter`: normalized substring match against a term list.
`mode` controls what happens on a match:
- `FilterMode.REDACT` (default): matched spans in the *original* text
are replaced with `redaction_char` repeated to the matched span's
original length; `action` is REDACT if anything matched, else ALLOW.
- `FilterMode.REJECT`: `filtered_text` is returned unchanged but
`action` is REJECT if anything matched (callers should treat this as
"do not store/deliver"), else ALLOW.
An empty or whitespace-only string always returns ALLOW with no
matches -- there is nothing to filter.
"""
def __init__(
self,
terms: Iterable[str] | None = None,
*,
mode: FilterMode = FilterMode.REDACT,
terms_path: Path | str | None = None,
redaction_char: str = "*",
) -> None:
if terms is None:
terms = load_terms(terms_path)
# De-dupe while preserving order; drop blanks.
seen: dict[str, None] = {}
for t in terms:
t = t.strip()
if t:
seen.setdefault(t, None)
self._terms: tuple[str, ...] = tuple(seen)
# Pre-normalize once at construction; skip terms that normalize to
# nothing (e.g. a term that's pure punctuation).
self._normalized_terms: list[tuple[str, str]] = [
(normalized, original)
for original in self._terms
if (normalized := _normalize_term(original))
]
self.mode = mode
self.redaction_char = redaction_char
def check(self, text: str) -> FilterResult:
if not text or not text.strip():
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
normalized, index_map = _normalize(text)
if not normalized:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
spans: list[tuple[int, int, str]] = []
for normalized_term, original_term in self._normalized_terms:
start = 0
term_len = len(normalized_term)
while True:
idx = normalized.find(normalized_term, start)
if idx == -1:
break
end = idx + term_len
orig_start = index_map[idx]
orig_end = index_map[end - 1] + 1
spans.append((orig_start, orig_end, original_term))
start = end # non-overlapping: resume after this match
if not spans:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
spans.sort(key=lambda s: s[0])
matched_terms = tuple(dict.fromkeys(term for _, _, term in spans))
if self.mode is FilterMode.REJECT:
return FilterResult(action=FilterAction.REJECT, filtered_text=text, matched_terms=matched_terms)
merged = _merge_spans(spans)
filtered_text = _apply_redactions(text, merged, self.redaction_char)
return FilterResult(action=FilterAction.REDACT, filtered_text=filtered_text, matched_terms=matched_terms)
def default_text_filter(mode: FilterMode = FilterMode.REDACT) -> ListBasedTextFilter:
"""Convenience factory for the bundled default term list."""
return ListBasedTextFilter(mode=mode)
@@ -0,0 +1,20 @@
{
"_readme": "Default deterministic term list for app.services.text_filter.ListBasedTextFilter. This is a minimal, illustrative starter list (common mild-to-strong English profanity, no slurs) meant to be replaced/extended per-deployment -- swap this file, or pass an explicit `terms`/`terms_path` to the filter, rather than editing this list in place for production moderation policy.",
"terms": [
"fuck",
"shit",
"ass",
"asshole",
"bitch",
"bastard",
"damn",
"crap",
"dick",
"piss",
"slut",
"whore",
"cunt",
"douche",
"bullshit"
]
}
+22 -3
View File
@@ -16,6 +16,7 @@ out dozens of times.
""" """
import asyncio import asyncio
import hashlib
import os import os
import socket import socket
import subprocess import subprocess
@@ -50,13 +51,31 @@ TEST_TABLES = (
# reached by cascading a truncate of messages/conversations; list it # reached by cascading a truncate of messages/conversations; list it
# explicitly along with the rest for clarity/consistency. # explicitly along with the rest for clarity/consistency.
"message_reports", "messages", "conversation_participants", "conversations", "message_reports", "messages", "conversation_participants", "conversations",
# Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
# by cascading a truncate of users; list it explicitly for clarity too.
"group_members", "groups",
"sessions", "refresh_tokens", "users", "sessions", "refresh_tokens", "users",
) )
BACKEND_DIR = Path(__file__).resolve().parents[1] BACKEND_DIR = Path(__file__).resolve().parents[1]
_COMPOSE_PROJECT = "agp-pytest"
_EPHEMERAL_PG_PORT = 25432 # A hardcoded project name/port here would mean every concurrent checkout
_EPHEMERAL_REDIS_PORT = 26379 # running `pytest` (e.g. several Claude Agent-tool subagents, each in their
# own ~/agp/workspace/task_<id> clone) fights over the exact same Docker
# Compose project and host ports -- one run's pytest_unconfigure teardown
# can yank the ephemeral Postgres out from under a completely unrelated
# concurrent run, surfacing as "connection was closed in the middle of
# operation" with no obvious cause. Confirmed live 2026-09-15 running 3
# parallel subagents. Same root cause and fix pattern as the agent_core
# workspace/Docker-Compose collision documented in project_agp memory --
# derive both the project name and the port from a hash of the checkout
# directory, so different concurrent checkouts land on different projects/
# ports while the same checkout is stable across repeated runs.
_WORKSPACE_HASH = hashlib.sha256(str(BACKEND_DIR.parent).encode()).hexdigest()[:10]
_COMPOSE_PROJECT = f"agp-pytest-{_WORKSPACE_HASH}"
_EPHEMERAL_PG_PORT = 25400 + (int(_WORKSPACE_HASH, 16) % 100)
_EPHEMERAL_REDIS_PORT = 26300 + (int(_WORKSPACE_HASH, 16) % 100)
# Set by pytest_configure() when it starts container(s) it owns; torn down in # Set by pytest_configure() when it starts container(s) it owns; torn down in
# pytest_unconfigure. # pytest_unconfigure.
+258
View File
@@ -0,0 +1,258 @@
"""Model-level tests for groups: groups and group_members.
app/models/groups.py / alembic/versions/0009_add_group_tables.py
(docs/planning/social.md US-5). This is the models+migration task only --
there are no API endpoints yet (a sibling task, "Groups API", owns
create/join/leave/role authorization and the group page endpoint), 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.groups import Group, GroupMember, GroupRole
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 TestGroupCreationAndRoles:
async def test_create_group_with_members_of_different_roles(self):
client = TestClient(app)
owner = _register(client, "grp_role_owner")
admin = _register(client, "grp_role_admin")
member = _register(client, "grp_role_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Explorers", description="We explore worlds", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=admin, role=GroupRole.ADMIN),
GroupMember(group_id=group.id, user_id=member, role=GroupRole.MEMBER),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
fetched = await session.get(Group, group_id)
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
by_user = {m.user_id: m.role for m in members}
assert fetched.owner_id == owner
assert len(members) == 3
assert by_user[owner] == GroupRole.OWNER
assert by_user[admin] == GroupRole.ADMIN
assert by_user[member] == GroupRole.MEMBER
finally:
await engine.dispose()
async def test_membership_role_defaults_to_member(self):
client = TestClient(app)
owner = _register(client, "grp_default_owner")
joiner = _register(client, "grp_default_joiner")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Defaults", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=joiner))
await session.commit()
group_id = group.id
async with factory() as session:
gm = (
await session.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == joiner
)
)
).scalar_one()
assert gm.role == GroupRole.MEMBER
finally:
await engine.dispose()
class TestUniqueMembershipConstraint:
async def test_duplicate_membership_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_uq_owner")
member = _register(client, "grp_uq_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Uniqueness", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=member))
await session.commit()
group_id = group.id
async with factory() as session:
session.add(GroupMember(group_id=group_id, user_id=member))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_group_deletes_members(self):
client = TestClient(app)
owner = _register(client, "grp_cd_group_owner")
member = _register(client, "grp_cd_group_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="To Delete", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
# Core delete against groups directly, so the real ON DELETE
# CASCADE does the work (not the ORM).
await session.execute(delete(Group).where(Group.id == group_id))
await session.commit()
async with factory() as session:
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert members == []
finally:
await engine.dispose()
async def test_deleting_member_user_removes_only_their_membership(self):
client = TestClient(app)
owner = _register(client, "grp_cd_user_owner")
member = _register(client, "grp_cd_user_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Membership Cascade", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == member))
await session.commit()
async with factory() as session:
remaining = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
# Only the deleted member's row is gone; the group and the
# owner's membership survive.
assert len(remaining) == 1
assert remaining[0].user_id == owner
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id == owner
finally:
await engine.dispose()
async def test_deleting_owner_survives_with_owner_id_null(self):
"""Deleting the owner's account must not delete the group or its
other members -- only ``owner_id`` is nulled out (SET NULL), per the
module docstring in app/models/groups.py."""
client = TestClient(app)
owner = _register(client, "grp_cd_owner_owner")
member = _register(client, "grp_cd_owner_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Survives Owner Deletion", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == owner))
await session.commit()
async with factory() as session:
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id is None
assert group.name == "Survives Owner Deletion"
# The owner's own membership row is gone (user_id CASCADE),
# but the other member's row survives untouched.
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert len(members) == 1
assert members[0].user_id == member
finally:
await engine.dispose()
+437
View File
@@ -0,0 +1,437 @@
"""Integration tests for the Groups API router (app/routers/groups.py).
app/models/groups.py owns the schema (see test_group_models.py); this
covers create/join/leave, role authorization, ownership transfer, and the
group page's member-list privacy.
"""
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
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
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
def _create_group(client: TestClient, headers: dict, name: str, description: str | None = None) -> dict:
resp = client.post(
"/api/groups",
json={"name": name, "description": description},
headers=headers,
)
assert resp.status_code == 201, resp.text
return resp.json()
class TestCreateGroup:
def test_create_makes_creator_owner(self):
client = TestClient(app)
owner = _register(client, "grp_api_create_owner")
group = _create_group(client, owner["headers"], "Explorers", "We explore worlds")
assert group["name"] == "Explorers"
assert group["description"] == "We explore worlds"
assert group["owner_id"] == owner["id"]
assert group["member_count"] == 1
assert len(group["members"]) == 1
assert group["members"][0]["user_id"] == owner["id"]
assert group["members"][0]["role"] == "owner"
def test_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/groups", json={"name": "No Auth"})
assert resp.status_code == 401
def test_blank_name_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_blank_name")
resp = client.post("/api/groups", json={"name": " "}, headers=owner["headers"])
assert resp.status_code == 422
def test_name_over_max_length_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_long_name")
resp = client.post(
"/api/groups", json={"name": "x" * 101}, headers=owner["headers"]
)
assert resp.status_code == 422
class TestGroupPage:
def test_get_group_page_shows_members_and_roles_no_private_fields(self):
client = TestClient(app)
owner = _register(client, "grp_api_page_owner")
member = _register(client, "grp_api_page_member")
group = _create_group(client, owner["headers"], "Page Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.get(f"/api/groups/{group['id']}", headers=member["headers"])
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == "Page Group"
assert body["member_count"] == 2
by_user = {m["user_id"]: m for m in body["members"]}
assert by_user[owner["id"]]["role"] == "owner"
assert by_user[member["id"]]["role"] == "member"
assert by_user[member["id"]]["username"] == member["username"]
# No private User fields (email, hashed_password, etc.) leak.
for m in body["members"]:
assert set(m.keys()) == {"user_id", "username", "role", "joined_at"}
def test_get_nonexistent_group_is_404(self):
client = TestClient(app)
user = _register(client, "grp_api_404")
resp = client.get("/api/groups/999999999", headers=user["headers"])
assert resp.status_code == 404
class TestJoinLeave:
def test_join_is_idempotent(self):
client = TestClient(app)
owner = _register(client, "grp_api_join_owner")
joiner = _register(client, "grp_api_join_joiner")
group = _create_group(client, owner["headers"], "Join Group")
r1 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"])
assert r1.status_code == 200, r1.text
r2 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"])
assert r2.status_code == 200, r2.text
assert r1.json()["role"] == r2.json()["role"] == "member"
page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json()
assert page["member_count"] == 2 # not 3 -- duplicate join didn't add a row
def test_leave_removes_membership(self):
client = TestClient(app)
owner = _register(client, "grp_api_leave_owner")
member = _register(client, "grp_api_leave_member")
group = _create_group(client, owner["headers"], "Leave Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"])
assert resp.status_code == 200, resp.text
page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json()
assert page["member_count"] == 1
assert all(m["user_id"] != member["id"] for m in page["members"])
def test_owner_cannot_leave_without_transfer(self):
client = TestClient(app)
owner = _register(client, "grp_api_owner_leave")
group = _create_group(client, owner["headers"], "Owner Leave Group")
resp = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"])
assert resp.status_code == 409, resp.text
def test_leave_when_not_a_member_is_404(self):
client = TestClient(app)
owner = _register(client, "grp_api_notmember_owner")
stranger = _register(client, "grp_api_notmember_stranger")
group = _create_group(client, owner["headers"], "Not A Member Group")
resp = client.post(f"/api/groups/{group['id']}/leave", headers=stranger["headers"])
assert resp.status_code == 404
class TestRoleAuthorization:
def test_owner_can_promote_and_demote(self):
client = TestClient(app)
owner = _register(client, "grp_api_role_owner")
member = _register(client, "grp_api_role_member")
group = _create_group(client, owner["headers"], "Role Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
promote = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
assert promote.status_code == 200, promote.text
assert promote.json()["role"] == "admin"
demote = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "member"},
headers=owner["headers"],
)
assert demote.status_code == 200, demote.text
assert demote.json()["role"] == "member"
def test_plain_member_cannot_promote(self):
client = TestClient(app)
owner = _register(client, "grp_api_noauth_owner")
member = _register(client, "grp_api_noauth_member")
other = _register(client, "grp_api_noauth_other")
group = _create_group(client, owner["headers"], "No Auth Promote Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
client.post(f"/api/groups/{group['id']}/join", headers=other["headers"])
resp = client.post(
f"/api/groups/{group['id']}/members/{other['id']}/role",
json={"role": "admin"},
headers=member["headers"],
)
assert resp.status_code == 403
def test_admin_cannot_promote_or_demote(self):
"""Only the owner may change roles -- admins cannot, per spec."""
client = TestClient(app)
owner = _register(client, "grp_api_adminauth_owner")
admin = _register(client, "grp_api_adminauth_admin")
other = _register(client, "grp_api_adminauth_other")
group = _create_group(client, owner["headers"], "Admin Cannot Promote Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(f"/api/groups/{group['id']}/join", headers=other["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.post(
f"/api/groups/{group['id']}/members/{other['id']}/role",
json={"role": "admin"},
headers=admin["headers"],
)
assert resp.status_code == 403
def test_plain_member_cannot_delete_group(self):
client = TestClient(app)
owner = _register(client, "grp_api_nodelete_owner")
member = _register(client, "grp_api_nodelete_member")
group = _create_group(client, owner["headers"], "No Delete Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.delete(f"/api/groups/{group['id']}", headers=member["headers"])
assert resp.status_code == 403
def test_admin_cannot_delete_group(self):
client = TestClient(app)
owner = _register(client, "grp_api_admindelete_owner")
admin = _register(client, "grp_api_admindelete_admin")
group = _create_group(client, owner["headers"], "Admin No Delete Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.delete(f"/api/groups/{group['id']}", headers=admin["headers"])
assert resp.status_code == 403
def test_plain_member_cannot_transfer_ownership(self):
client = TestClient(app)
owner = _register(client, "grp_api_notransfer_owner")
member = _register(client, "grp_api_notransfer_member")
group = _create_group(client, owner["headers"], "No Transfer Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": member["id"]},
headers=member["headers"],
)
assert resp.status_code == 403
def test_cannot_set_role_to_owner_via_role_endpoint(self):
client = TestClient(app)
owner = _register(client, "grp_api_noownerrole_owner")
member = _register(client, "grp_api_noownerrole_member")
group = _create_group(client, owner["headers"], "No Owner Role Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "owner"},
headers=owner["headers"],
)
assert resp.status_code == 422
class TestOwnershipTransfer:
def test_transfer_moves_ownership_and_demotes_old_owner(self):
client = TestClient(app)
owner = _register(client, "grp_api_xfer_owner")
member = _register(client, "grp_api_xfer_member")
group = _create_group(client, owner["headers"], "Transfer Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": member["id"]},
headers=owner["headers"],
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["owner_id"] == member["id"]
by_user = {m["user_id"]: m for m in body["members"]}
assert by_user[member["id"]]["role"] == "owner"
assert by_user[owner["id"]]["role"] == "admin"
# Exactly one owner remains.
owners = [m for m in body["members"] if m["role"] == "owner"]
assert len(owners) == 1
# Old owner can now leave (no longer the owner).
leave = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"])
assert leave.status_code == 200, leave.text
# New owner cannot leave without transferring again.
leave2 = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"])
assert leave2.status_code == 409
def test_transfer_target_must_be_a_member(self):
client = TestClient(app)
owner = _register(client, "grp_api_xfernonmember_owner")
stranger = _register(client, "grp_api_xfernonmember_stranger")
group = _create_group(client, owner["headers"], "Transfer Nonmember Group")
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": stranger["id"]},
headers=owner["headers"],
)
assert resp.status_code == 404
def test_transfer_to_self_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_xferself_owner")
group = _create_group(client, owner["headers"], "Transfer Self Group")
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": owner["id"]},
headers=owner["headers"],
)
assert resp.status_code == 400
class TestUpdateGroup:
def test_owner_can_update_name_and_description(self):
client = TestClient(app)
owner = _register(client, "grp_api_update_owner")
group = _create_group(client, owner["headers"], "Old Name", "Old desc")
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "New Name", "description": "New desc"},
headers=owner["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "New Name"
assert resp.json()["description"] == "New desc"
def test_admin_can_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_adminupdate_owner")
admin = _register(client, "grp_api_adminupdate_admin")
group = _create_group(client, owner["headers"], "Admin Update Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Updated By Admin"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "Updated By Admin"
def test_plain_member_cannot_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_noupdate_owner")
member = _register(client, "grp_api_noupdate_member")
group = _create_group(client, owner["headers"], "No Update Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Hacked Name"},
headers=member["headers"],
)
assert resp.status_code == 403
def test_non_member_cannot_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_strangernoupdate_owner")
stranger = _register(client, "grp_api_strangernoupdate_stranger")
group = _create_group(client, owner["headers"], "Stranger No Update Group")
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Hacked Name"},
headers=stranger["headers"],
)
assert resp.status_code == 403
class TestDeleteGroup:
def test_owner_can_delete(self):
client = TestClient(app)
owner = _register(client, "grp_api_delete_owner")
group = _create_group(client, owner["headers"], "Delete Group")
resp = client.delete(f"/api/groups/{group['id']}", headers=owner["headers"])
assert resp.status_code == 204, resp.text
after = client.get(f"/api/groups/{group['id']}", headers=owner["headers"])
assert after.status_code == 404
class TestListGroups:
def test_list_all_groups_paginated(self):
client = TestClient(app)
owner = _register(client, "grp_api_list_owner")
_create_group(client, owner["headers"], "List Group A")
_create_group(client, owner["headers"], "List Group B")
resp = client.get("/api/groups", params={"limit": 1}, headers=owner["headers"])
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body["groups"]) == 1
assert body["total"] >= 2
def test_list_mine_only_returns_own_groups(self):
client = TestClient(app)
owner = _register(client, "grp_api_listmine_owner")
other_owner = _register(client, "grp_api_listmine_other")
mine = _create_group(client, owner["headers"], "Mine Group")
_create_group(client, other_owner["headers"], "Not Mine Group")
resp = client.get("/api/groups", params={"mine": True}, headers=owner["headers"])
assert resp.status_code == 200, resp.text
ids = [g["id"] for g in resp.json()["groups"]]
assert mine["id"] in ids
assert all(
g["id"] != mine["id"] or g["owner_id"] == owner["id"]
for g in resp.json()["groups"]
)
# The other owner's group must not appear.
other_group_ids = [
g["id"] for g in client.get(
"/api/groups", params={"mine": True}, headers=other_owner["headers"]
).json()["groups"]
]
assert mine["id"] not in other_group_ids
+194
View File
@@ -0,0 +1,194 @@
"""Unit tests for app.services.text_filter (docs/planning/social.md §4/§5 US-4).
Pure, side-effect-free service -- no DB/Redis fixtures needed.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.services.text_filter import (
DEFAULT_TERMS_PATH,
FilterAction,
FilterMode,
FilterResult,
ListBasedTextFilter,
TextFilter,
default_text_filter,
load_terms,
)
@pytest.fixture
def redact_filter() -> ListBasedTextFilter:
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REDACT)
@pytest.fixture
def reject_filter() -> ListBasedTextFilter:
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REJECT)
class TestAllowPath:
def test_clean_text_is_allowed_unchanged(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("hello there, how are you today?")
assert result.action is FilterAction.ALLOW
assert result.filtered_text == "hello there, how are you today?"
assert result.matched_terms == ()
def test_message_with_no_matches(self, reject_filter: ListBasedTextFilter) -> None:
# Distinct from the clean-text case above: exercises the reject-mode
# filter's no-match path specifically (mode should not matter when
# nothing matches).
text = "let's meet at the plaza around noon"
result = reject_filter.check(text)
assert result.action is FilterAction.ALLOW
assert result.filtered_text == text
assert result.matched_terms == ()
class TestRedactPath:
def test_exact_match_is_redacted(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("don't be a badword about it")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
assert "badword" not in result.filtered_text
assert result.filtered_text == "don't be a ******* about it"
def test_multiple_distinct_matches(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("badword, don't shoot!")
assert result.action is FilterAction.REDACT
assert set(result.matched_terms) == {"badword", "shoot"}
assert "badword" not in result.filtered_text
assert "shoot" not in result.filtered_text
# Length is preserved (asterisks replace matched spans 1:1).
assert len(result.filtered_text) == len("badword, don't shoot!")
def test_redaction_char_is_configurable(self) -> None:
f = ListBasedTextFilter(terms=["badword"], mode=FilterMode.REDACT, redaction_char="#")
result = f.check("that's a badword thing to say")
assert result.filtered_text == "that's a ####### thing to say"
class TestRejectPath:
def test_match_is_rejected_and_text_left_intact(self, reject_filter: ListBasedTextFilter) -> None:
text = "stop, badword!"
result = reject_filter.check(text)
assert result.action is FilterAction.REJECT
assert result.matched_terms == ("badword",)
# Reject mode does not mutate the text -- callers are expected to
# not store/deliver it at all, based on `action`.
assert result.filtered_text == text
class TestNormalization:
def test_leetspeak_variant_is_caught(self, redact_filter: ListBasedTextFilter) -> None:
# "b4dw0rd" -> normalizes to "badword" via the leet map (4->a, 0->o).
result = redact_filter.check("you're being a b4dw0rd right now")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_separators_between_letters_are_stripped(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("that is b.a.d.w.o.r.d behavior")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_spacing_between_letters_is_stripped(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("b a d w o r d, seriously")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_case_folding(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("BADWORD in all caps")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_symbol_lookalikes(self) -> None:
# @ -> a, $ -> s: "@ss" should normalize to "ass".
f = ListBasedTextFilter(terms=["ass"], mode=FilterMode.REDACT)
result = f.check("what an @$$ move")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("ass",)
class TestEmptyAndWhitespace:
def test_empty_string(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("")
assert result.action is FilterAction.ALLOW
assert result.filtered_text == ""
assert result.matched_terms == ()
def test_whitespace_only_string(self, redact_filter: ListBasedTextFilter) -> None:
text = " \t\n "
result = redact_filter.check(text)
assert result.action is FilterAction.ALLOW
assert result.filtered_text == text
assert result.matched_terms == ()
def test_punctuation_only_string_has_nothing_to_normalize(self, redact_filter: ListBasedTextFilter) -> None:
# No alnum characters survive normalization at all -- must not
# crash on an empty normalized string / empty index_map.
result = redact_filter.check("...!!!???")
assert result.action is FilterAction.ALLOW
assert result.matched_terms == ()
class TestConfigurability:
def test_custom_term_list_is_used_instead_of_default(self) -> None:
f = ListBasedTextFilter(terms=["zzzcustomzzz"], mode=FilterMode.REDACT)
result = f.check("this contains zzzcustomzzz right here")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("zzzcustomzzz",)
# Default list terms should NOT match since we overrode the list.
result2 = f.check("this is fucking unrelated")
assert result2.action is FilterAction.ALLOW
def test_duplicate_and_blank_terms_are_deduped(self) -> None:
f = ListBasedTextFilter(terms=["badword", "badword", " ", ""], mode=FilterMode.REDACT)
assert f._terms == ("badword",)
def test_default_terms_file_loads_and_is_nonempty(self) -> None:
terms = load_terms()
assert isinstance(terms, list)
assert len(terms) > 0
assert all(isinstance(t, str) and t for t in terms)
def test_default_terms_path_exists(self) -> None:
assert DEFAULT_TERMS_PATH.exists()
assert DEFAULT_TERMS_PATH.suffix == ".json"
def test_load_terms_from_explicit_path(self, tmp_path: Path) -> None:
custom = tmp_path / "custom_terms.json"
custom.write_text('{"terms": ["foo", "bar"]}', encoding="utf-8")
assert load_terms(custom) == ["foo", "bar"]
def test_default_text_filter_factory_matches_default_list(self) -> None:
f = default_text_filter(mode=FilterMode.REJECT)
result = f.check("that's fucking ridiculous")
assert result.action is FilterAction.REJECT
assert "fuck" in result.matched_terms
class TestProtocol:
def test_list_based_filter_satisfies_text_filter_protocol(self, redact_filter: ListBasedTextFilter) -> None:
assert isinstance(redact_filter, TextFilter)
def test_check_returns_filter_result(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("hi")
assert isinstance(result, FilterResult)
def test_a_third_party_style_implementation_satisfies_the_protocol(self) -> None:
# Demonstrates the extension point: any object with a matching
# `check` method satisfies TextFilter without inheriting from
# anything -- this is how a future third-party provider would plug
# in without touching this module.
class AlwaysAllow:
def check(self, text: str) -> FilterResult:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
provider: TextFilter = AlwaysAllow()
assert isinstance(provider, TextFilter)
result = provider.check("anything at all, even fuck")
assert result.action is FilterAction.ALLOW