Private
Public Access
Phase 2: private rooms, room roles, and invites (backend only)
Adds room_invites table + migration, owner/admin/member role enforcement (require_room_role), and endpoints for private room creation, room management (update/delete/leave/transfer-ownership/change-role/remove-member), and the invite lifecycle (create/list/accept/decline/revoke). Registration stays invite-only via the CLI from Phase 1 — this is a separate, room-level invite system for adding existing users to private rooms. Frontend is untouched: the UI redesign is happening separately, so this phase is backend + tests only (35 passing). Verified no regressions in the Phase 1 open-room/WebSocket flow via manual smoke test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,19 +4,22 @@ A web-based team chat service (Mattermost-style, no threaded conversations),
|
|||||||
invite-only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design
|
invite-only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design
|
||||||
and phased build plan.
|
and phased build plan.
|
||||||
|
|
||||||
**Phase 1** (this state of the repo): auth, open-room CRUD, and single-instance
|
**Phase 1**: auth, open-room CRUD, and single-instance WebSocket chat, backend
|
||||||
WebSocket chat, backend + a minimal frontend. Later phases (private rooms,
|
+ a minimal frontend. **Phase 2**: private rooms, room roles (owner/admin/
|
||||||
push notifications, Redis fan-out, the admin portal, the bot/extension
|
member), and room invites — backend only, see below. Later phases (push
|
||||||
system, and production deployment) are tracked as issues in the repo's issue
|
notifications, Redis fan-out, the admin portal, the bot/extension system, and
|
||||||
tracker, prioritized.
|
production deployment) are tracked as issues in the repo's issue tracker,
|
||||||
|
prioritized.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- [`backend/`](backend/) — FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. See
|
- [`backend/`](backend/) — FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. See
|
||||||
[`backend/README.md`](backend/README.md) for local setup, migrations, and
|
[`backend/README.md`](backend/README.md) for local setup, migrations, how to
|
||||||
how to create a user (registration is invite-only — there's no public
|
create a user (site registration is invite-only — no public sign-up
|
||||||
sign-up endpoint).
|
endpoint), and the Phase 2 room-roles/invites API.
|
||||||
- [`frontend/`](frontend/) — React + Vite PWA (login, room list, chat view).
|
- [`frontend/`](frontend/) — React + Vite PWA (login, room list, chat view).
|
||||||
|
Still Phase-1-only: it doesn't yet call any of the Phase 2 endpoints. A UI
|
||||||
|
redesign is happening separately; frontend work resumes once that lands.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
|
|||||||
+32
-12
@@ -1,7 +1,8 @@
|
|||||||
# KeepItTalking backend (Phase 1)
|
# KeepItTalking backend (Phase 1 + 2)
|
||||||
|
|
||||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, open-room CRUD,
|
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, room CRUD
|
||||||
and a single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the
|
(open and private), room roles (owner/admin/member) and invites, and a
|
||||||
|
single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the
|
||||||
full system design and the phased build plan.
|
full system design and the phased build plan.
|
||||||
|
|
||||||
This is an **invite-only site**: there is no public registration endpoint.
|
This is an **invite-only site**: there is no public registration endpoint.
|
||||||
@@ -79,29 +80,48 @@ app/
|
|||||||
main.py create_app(), session middleware, router/WS mounting
|
main.py create_app(), session middleware, router/WS mounting
|
||||||
config.py environment-driven settings (pydantic-settings)
|
config.py environment-driven settings (pydantic-settings)
|
||||||
database.py async engine/session, get_db() dependency
|
database.py async engine/session, get_db() dependency
|
||||||
dependencies.py get_current_user, require_room_member
|
dependencies.py get_current_user, require_room_member, require_room_role
|
||||||
security.py argon2 password hashing
|
security.py argon2 password hashing
|
||||||
cli.py `python -m app.cli create-user` (account provisioning)
|
cli.py `python -m app.cli create-user` (account provisioning)
|
||||||
models/ SQLAlchemy models (users, rooms, room_memberships, messages)
|
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||||
|
messages, room_invites)
|
||||||
schemas/ Pydantic request/response models
|
schemas/ Pydantic request/response models
|
||||||
routers/ auth, rooms, health
|
routers/ auth, rooms, invites, health
|
||||||
services/ business logic called by routers
|
services/ business logic called by routers
|
||||||
ws/ WebSocket connection manager + /ws/chat handler
|
ws/ WebSocket connection manager + /ws/chat handler
|
||||||
alembic/ migrations
|
alembic/ migrations
|
||||||
tests/ pytest + httpx/TestClient tests
|
tests/ pytest + httpx/TestClient tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Room roles and invites (Phase 2)
|
||||||
|
|
||||||
|
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
|
||||||
|
`private` (`is_private: true` at creation — joinable only via invite). Room
|
||||||
|
roles are `owner` > `admin` > `member`:
|
||||||
|
- **member**: post messages, leave the room
|
||||||
|
- **admin**: edit room settings, create/list/revoke invites, remove plain members
|
||||||
|
- **owner**: everything admin can, plus delete the room, remove admins, change
|
||||||
|
member roles, and transfer ownership
|
||||||
|
|
||||||
|
Invite flow: an admin+ calls `POST /api/rooms/{id}/invites` with an existing
|
||||||
|
`target_username`; the invited user sees it via `GET /api/invites/mine` and
|
||||||
|
calls `POST /api/invites/{id}/accept` (or `/decline`). `GET /api/rooms/mine`
|
||||||
|
lists every room (open + private) the current user belongs to, alongside
|
||||||
|
their role.
|
||||||
|
|
||||||
## Notes / scope decisions
|
## Notes / scope decisions
|
||||||
|
|
||||||
- Invite-only: no `POST /api/auth/register`. Accounts are provisioned with
|
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
|
||||||
`python -m app.cli create-user` (see step 4 above). A more self-service
|
provisioned with `python -m app.cli create-user` (see step 4 above). This is
|
||||||
invite flow (per-user tokens, or an admin-portal "generate invite" button)
|
separate from *room* invites above — site accounts vs. room membership.
|
||||||
is a natural phase-2/6 follow-up, not built now.
|
- Room invites are by **username only** — `room_invites.target_email` exists
|
||||||
|
in the schema (per `ARCHITECTURE.md`) but is unused, since there's no
|
||||||
|
email-delivery mechanism anywhere in the stack yet.
|
||||||
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
|
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
|
||||||
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
|
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
|
||||||
through a WebSocket handshake). This means there's currently no way to force-
|
through a WebSocket handshake). This means there's currently no way to force-
|
||||||
revoke a session server-side; that needs a real session table later.
|
revoke a session server-side; that needs a real session table later.
|
||||||
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
|
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
|
||||||
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
|
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
|
||||||
- `rooms.is_private` exists in the schema but the API never sets it `True` yet;
|
- Deleting a room explicitly deletes its messages/memberships/invites first
|
||||||
private rooms/invites are phase 2 (tracked as a Gitea issue).
|
(`room_service.delete_room`) rather than relying on DB-level cascades.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""room invites
|
||||||
|
|
||||||
|
Revision ID: 0699d20789b3
|
||||||
|
Revises: c7981d17890c
|
||||||
|
Create Date: 2026-08-13 20:14:22.346544
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0699d20789b3'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'c7981d17890c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('room_invites',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('room_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('invited_by', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('token', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('target_user_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('target_email', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('pending', 'accepted', 'revoked', name='invite_status'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.CheckConstraint('target_user_id IS NOT NULL OR target_email IS NOT NULL', name='room_invites_target_required'),
|
||||||
|
sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['target_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_room_invites_room_id'), 'room_invites', ['room_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_room_invites_target_user_id'), 'room_invites', ['target_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_room_invites_token'), 'room_invites', ['token'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_room_invites_token'), table_name='room_invites')
|
||||||
|
op.drop_index(op.f('ix_room_invites_target_user_id'), table_name='room_invites')
|
||||||
|
op.drop_index(op.f('ix_room_invites_room_id'), table_name='room_invites')
|
||||||
|
op.drop_table('room_invites')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -5,7 +5,9 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import RoomMembership, User
|
from app.models import RoomMembership, RoomRole, User
|
||||||
|
|
||||||
|
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
@@ -35,3 +37,12 @@ async def require_room_member(
|
|||||||
if membership is None:
|
if membership is None:
|
||||||
raise HTTPException(status_code=403, detail="Not a member of this room")
|
raise HTTPException(status_code=403, detail="Not a member of this room")
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
async def require_room_role(
|
||||||
|
room_id: uuid.UUID, user: User, db: AsyncSession, minimum: RoomRole
|
||||||
|
) -> RoomMembership:
|
||||||
|
membership = await require_room_member(room_id, user, db)
|
||||||
|
if _ROLE_RANK[membership.role] < _ROLE_RANK[minimum]:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient room role")
|
||||||
|
return membership
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import auth, health, rooms
|
from app.routers import auth, health, invites, rooms
|
||||||
from app.ws.chat import router as ws_router
|
from app.ws.chat import router as ws_router
|
||||||
from app.ws.connection_manager import ConnectionManager
|
from app.ws.connection_manager import ConnectionManager
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(rooms.router)
|
app.include_router(rooms.router)
|
||||||
|
app.include_router(invites.router)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
from app.models.invite import InviteStatus, RoomInvite
|
||||||
from app.models.membership import RoomMembership, RoomRole
|
from app.models.membership import RoomMembership, RoomRole
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.room import Room
|
from app.models.room import Room
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = ["Base", "User", "Room", "RoomMembership", "RoomRole", "Message"]
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"User",
|
||||||
|
"Room",
|
||||||
|
"RoomMembership",
|
||||||
|
"RoomRole",
|
||||||
|
"Message",
|
||||||
|
"RoomInvite",
|
||||||
|
"InviteStatus",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
DEFAULT_INVITE_LIFETIME = timedelta(days=7)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_expires_at() -> datetime:
|
||||||
|
return datetime.now(timezone.utc) + DEFAULT_INVITE_LIFETIME
|
||||||
|
|
||||||
|
|
||||||
|
class InviteStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
accepted = "accepted"
|
||||||
|
revoked = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class RoomInvite(Base):
|
||||||
|
__tablename__ = "room_invites"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"target_user_id IS NOT NULL OR target_email IS NOT NULL",
|
||||||
|
name="room_invites_target_required",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
|
||||||
|
invited_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
|
token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
target_user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
# Stored per the documented schema but not actionable yet: there's no
|
||||||
|
# email-delivery mechanism anywhere in the stack. Phase 2 only creates
|
||||||
|
# invites via target_user_id (existing users, looked up by username).
|
||||||
|
target_email: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=_default_expires_at, nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[InviteStatus] = mapped_column(
|
||||||
|
Enum(InviteStatus, name="invite_status"), default=InviteStatus.pending, nullable=False
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
room = relationship("Room")
|
||||||
|
inviter = relationship("User", foreign_keys=[invited_by])
|
||||||
|
target_user = relationship("User", foreign_keys=[target_user_id])
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.invite import InviteRead
|
||||||
|
from app.schemas.room import RoomMemberRead
|
||||||
|
from app.services.invite_service import (
|
||||||
|
InviteExpiredError,
|
||||||
|
InviteNotFoundError,
|
||||||
|
InviteNotPendingError,
|
||||||
|
WrongInviteTargetError,
|
||||||
|
accept_invite,
|
||||||
|
decline_invite,
|
||||||
|
list_my_invites,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/invites", tags=["invites"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mine", response_model=list[InviteRead])
|
||||||
|
async def list_my_invites_endpoint(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_my_invites(db, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{invite_id}/accept", response_model=RoomMemberRead)
|
||||||
|
async def accept_invite_endpoint(
|
||||||
|
invite_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
membership = await accept_invite(db, invite_id, current_user.id)
|
||||||
|
except InviteNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Invite not found")
|
||||||
|
except WrongInviteTargetError:
|
||||||
|
raise HTTPException(status_code=403, detail="This invite is not addressed to you")
|
||||||
|
except InviteNotPendingError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||||
|
except InviteExpiredError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invite has expired")
|
||||||
|
|
||||||
|
return RoomMemberRead(
|
||||||
|
user_id=membership.user_id,
|
||||||
|
username=current_user.username,
|
||||||
|
role=membership.role,
|
||||||
|
joined_at=membership.joined_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{invite_id}/decline", response_model=InviteRead)
|
||||||
|
async def decline_invite_endpoint(
|
||||||
|
invite_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await decline_invite(db, invite_id, current_user.id)
|
||||||
|
except InviteNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Invite not found")
|
||||||
|
except WrongInviteTargetError:
|
||||||
|
raise HTTPException(status_code=403, detail="This invite is not addressed to you")
|
||||||
|
except InviteNotPendingError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||||
@@ -4,19 +4,51 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import get_current_user, require_room_member
|
from app.dependencies import get_current_user, require_room_member, require_room_role
|
||||||
from app.models import User
|
from app.models import RoomRole, User
|
||||||
|
from app.schemas.invite import InviteCreate, InviteRead
|
||||||
from app.schemas.message import MessageRead
|
from app.schemas.message import MessageRead
|
||||||
from app.schemas.room import RoomCreate, RoomListItem, RoomRead
|
from app.schemas.room import (
|
||||||
|
MyRoomItem,
|
||||||
|
RoomCreate,
|
||||||
|
RoomListItem,
|
||||||
|
RoomMemberRead,
|
||||||
|
RoomMemberRoleUpdate,
|
||||||
|
RoomRead,
|
||||||
|
RoomUpdate,
|
||||||
|
TransferOwnershipRequest,
|
||||||
|
)
|
||||||
|
from app.services.invite_service import (
|
||||||
|
AlreadyMemberError,
|
||||||
|
DuplicateInviteError,
|
||||||
|
InviteNotFoundError,
|
||||||
|
InviteNotPendingError,
|
||||||
|
TargetUserNotFoundError,
|
||||||
|
create_invite,
|
||||||
|
list_room_invites,
|
||||||
|
revoke_invite,
|
||||||
|
)
|
||||||
from app.services.message_service import list_recent_messages
|
from app.services.message_service import list_recent_messages
|
||||||
from app.services.room_service import (
|
from app.services.room_service import (
|
||||||
|
CannotRemoveOwnerError,
|
||||||
DuplicateRoomError,
|
DuplicateRoomError,
|
||||||
|
InsufficientRoleError,
|
||||||
|
MembershipNotFoundError,
|
||||||
|
OwnerMustTransferError,
|
||||||
RoomIsPrivateError,
|
RoomIsPrivateError,
|
||||||
RoomNotFoundError,
|
RoomNotFoundError,
|
||||||
|
change_member_role,
|
||||||
create_room,
|
create_room,
|
||||||
|
delete_room,
|
||||||
get_room,
|
get_room,
|
||||||
join_room,
|
join_room,
|
||||||
|
leave_room,
|
||||||
|
list_member_rooms,
|
||||||
list_open_rooms,
|
list_open_rooms,
|
||||||
|
list_room_members,
|
||||||
|
remove_member,
|
||||||
|
transfer_ownership,
|
||||||
|
update_room,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
||||||
@@ -54,6 +86,57 @@ async def list_rooms_endpoint(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mine", response_model=list[MyRoomItem])
|
||||||
|
async def list_my_rooms_endpoint(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
rooms = await list_member_rooms(db, current_user.id)
|
||||||
|
return [
|
||||||
|
MyRoomItem(
|
||||||
|
id=room.id,
|
||||||
|
name=room.name,
|
||||||
|
description=room.description,
|
||||||
|
is_private=room.is_private,
|
||||||
|
owner_id=room.owner_id,
|
||||||
|
created_at=room.created_at,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
for room, role in rooms
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{room_id}", response_model=RoomRead)
|
||||||
|
async def update_room_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
data: RoomUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
room = await get_room(db, room_id)
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
return await update_room(db, room, data)
|
||||||
|
except RoomNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
except DuplicateRoomError:
|
||||||
|
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}", status_code=204)
|
||||||
|
async def delete_room_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
room = await get_room(db, room_id)
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.owner)
|
||||||
|
await delete_room(db, room)
|
||||||
|
except RoomNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{room_id}/join", response_model=RoomRead)
|
@router.post("/{room_id}/join", response_model=RoomRead)
|
||||||
async def join_room_endpoint(
|
async def join_room_endpoint(
|
||||||
room_id: uuid.UUID,
|
room_id: uuid.UUID,
|
||||||
@@ -69,6 +152,103 @@ async def join_room_endpoint(
|
|||||||
raise HTTPException(status_code=400, detail="Cannot join a private room directly")
|
raise HTTPException(status_code=400, detail="Cannot join a private room directly")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{room_id}/leave", status_code=204)
|
||||||
|
async def leave_room_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_member(room_id, current_user, db)
|
||||||
|
try:
|
||||||
|
await leave_room(db, room_id, current_user.id)
|
||||||
|
except OwnerMustTransferError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Transfer ownership before leaving this room"
|
||||||
|
)
|
||||||
|
except MembershipNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Not a member of this room")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_id}/members", response_model=list[RoomMemberRead])
|
||||||
|
async def list_room_members_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_member(room_id, current_user, db)
|
||||||
|
memberships = await list_room_members(db, room_id)
|
||||||
|
return [
|
||||||
|
RoomMemberRead(
|
||||||
|
user_id=m.user_id, username=m.user.username, role=m.role, joined_at=m.joined_at
|
||||||
|
)
|
||||||
|
for m in memberships
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}/members/{user_id}", status_code=204)
|
||||||
|
async def remove_member_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
membership = await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
try:
|
||||||
|
await remove_member(db, room_id, user_id, acting_role=membership.role)
|
||||||
|
except MembershipNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="That user is not a member of this room")
|
||||||
|
except CannotRemoveOwnerError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Room owner must transfer ownership before being removed"
|
||||||
|
)
|
||||||
|
except InsufficientRoleError:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the owner can remove an admin")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead)
|
||||||
|
async def change_member_role_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
data: RoomMemberRoleUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.owner)
|
||||||
|
try:
|
||||||
|
membership = await change_member_role(db, room_id, user_id, data.role)
|
||||||
|
except MembershipNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="That user is not a member of this room")
|
||||||
|
except InsufficientRoleError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Use transfer-ownership to change the room owner"
|
||||||
|
)
|
||||||
|
return RoomMemberRead(
|
||||||
|
user_id=membership.user_id,
|
||||||
|
username=membership.user.username,
|
||||||
|
role=membership.role,
|
||||||
|
joined_at=membership.joined_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{room_id}/transfer-ownership", response_model=RoomRead)
|
||||||
|
async def transfer_ownership_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
data: TransferOwnershipRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
room = await get_room(db, room_id)
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.owner)
|
||||||
|
return await transfer_ownership(db, room, current_user.id, data.new_owner_user_id)
|
||||||
|
except RoomNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
except MembershipNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="The new owner must already be a member of this room"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{room_id}/messages", response_model=list[MessageRead])
|
@router.get("/{room_id}/messages", response_model=list[MessageRead])
|
||||||
async def get_room_messages_endpoint(
|
async def get_room_messages_endpoint(
|
||||||
room_id: uuid.UUID,
|
room_id: uuid.UUID,
|
||||||
@@ -78,3 +258,47 @@ async def get_room_messages_endpoint(
|
|||||||
):
|
):
|
||||||
await require_room_member(room_id, current_user, db)
|
await require_room_member(room_id, current_user, db)
|
||||||
return await list_recent_messages(db, room_id, limit)
|
return await list_recent_messages(db, room_id, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{room_id}/invites", response_model=InviteRead, status_code=201)
|
||||||
|
async def create_invite_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
data: InviteCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
try:
|
||||||
|
return await create_invite(db, room_id, current_user.id, data.target_username)
|
||||||
|
except TargetUserNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="No user with that username")
|
||||||
|
except AlreadyMemberError:
|
||||||
|
raise HTTPException(status_code=409, detail="That user is already a member")
|
||||||
|
except DuplicateInviteError:
|
||||||
|
raise HTTPException(status_code=409, detail="That user already has a pending invite")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_id}/invites", response_model=list[InviteRead])
|
||||||
|
async def list_room_invites_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
return await list_room_invites(db, room_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}/invites/{invite_id}", status_code=204)
|
||||||
|
async def revoke_invite_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
invite_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||||
|
try:
|
||||||
|
await revoke_invite(db, room_id, invite_id)
|
||||||
|
except InviteNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Invite not found")
|
||||||
|
except InviteNotPendingError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models import InviteStatus
|
||||||
|
|
||||||
|
|
||||||
|
class InviteCreate(BaseModel):
|
||||||
|
target_username: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
room_id: uuid.UUID
|
||||||
|
invited_by: uuid.UUID
|
||||||
|
target_user_id: uuid.UUID | None
|
||||||
|
status: InviteStatus
|
||||||
|
expires_at: datetime
|
||||||
|
created_at: datetime
|
||||||
@@ -3,10 +3,18 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models import RoomRole
|
||||||
|
|
||||||
|
|
||||||
class RoomCreate(BaseModel):
|
class RoomCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=100)
|
name: str = Field(min_length=1, max_length=100)
|
||||||
description: str | None = Field(default=None, max_length=2000)
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
|
is_private: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RoomUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class RoomRead(BaseModel):
|
class RoomRead(BaseModel):
|
||||||
@@ -22,3 +30,22 @@ class RoomRead(BaseModel):
|
|||||||
|
|
||||||
class RoomListItem(RoomRead):
|
class RoomListItem(RoomRead):
|
||||||
is_member: bool
|
is_member: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MyRoomItem(RoomRead):
|
||||||
|
role: RoomRole
|
||||||
|
|
||||||
|
|
||||||
|
class RoomMemberRead(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
role: RoomRole
|
||||||
|
joined_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RoomMemberRoleUpdate(BaseModel):
|
||||||
|
role: RoomRole
|
||||||
|
|
||||||
|
|
||||||
|
class TransferOwnershipRequest(BaseModel):
|
||||||
|
new_owner_user_id: uuid.UUID
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import InviteStatus, RoomInvite, RoomMembership, RoomRole, User
|
||||||
|
|
||||||
|
|
||||||
|
class TargetUserNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyMemberError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateInviteError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InviteNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WrongInviteTargetError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InviteNotPendingError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InviteExpiredError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def create_invite(
|
||||||
|
db: AsyncSession, room_id: uuid.UUID, invited_by: uuid.UUID, target_username: str
|
||||||
|
) -> RoomInvite:
|
||||||
|
result = await db.execute(select(User).where(User.username == target_username))
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
if target is None:
|
||||||
|
raise TargetUserNotFoundError()
|
||||||
|
|
||||||
|
existing_membership = await db.execute(
|
||||||
|
select(RoomMembership).where(
|
||||||
|
RoomMembership.room_id == room_id, RoomMembership.user_id == target.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_membership.scalar_one_or_none() is not None:
|
||||||
|
raise AlreadyMemberError()
|
||||||
|
|
||||||
|
existing_invite = await db.execute(
|
||||||
|
select(RoomInvite).where(
|
||||||
|
RoomInvite.room_id == room_id,
|
||||||
|
RoomInvite.target_user_id == target.id,
|
||||||
|
RoomInvite.status == InviteStatus.pending,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_invite.scalar_one_or_none() is not None:
|
||||||
|
raise DuplicateInviteError()
|
||||||
|
|
||||||
|
invite = RoomInvite(
|
||||||
|
room_id=room_id,
|
||||||
|
invited_by=invited_by,
|
||||||
|
token=secrets.token_urlsafe(32),
|
||||||
|
target_user_id=target.id,
|
||||||
|
)
|
||||||
|
db.add(invite)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(invite)
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
async def list_room_invites(db: AsyncSession, room_id: uuid.UUID) -> list[RoomInvite]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomInvite).where(
|
||||||
|
RoomInvite.room_id == room_id, RoomInvite.status == InviteStatus.pending
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_my_invites(db: AsyncSession, user_id: uuid.UUID) -> list[RoomInvite]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomInvite)
|
||||||
|
.where(
|
||||||
|
RoomInvite.target_user_id == user_id,
|
||||||
|
RoomInvite.status == InviteStatus.pending,
|
||||||
|
RoomInvite.expires_at > datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
.options(selectinload(RoomInvite.room))
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_invite(db: AsyncSession, invite_id: uuid.UUID) -> RoomInvite:
|
||||||
|
invite = await db.get(RoomInvite, invite_id)
|
||||||
|
if invite is None:
|
||||||
|
raise InviteNotFoundError()
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership:
|
||||||
|
invite = await _get_invite(db, invite_id)
|
||||||
|
if invite.target_user_id != user_id:
|
||||||
|
raise WrongInviteTargetError()
|
||||||
|
if invite.status != InviteStatus.pending:
|
||||||
|
raise InviteNotPendingError()
|
||||||
|
if invite.expires_at <= datetime.now(timezone.utc):
|
||||||
|
raise InviteExpiredError()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomMembership).where(
|
||||||
|
RoomMembership.room_id == invite.room_id, RoomMembership.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = result.scalar_one_or_none()
|
||||||
|
if membership is None:
|
||||||
|
membership = RoomMembership(room_id=invite.room_id, user_id=user_id, role=RoomRole.member)
|
||||||
|
db.add(membership)
|
||||||
|
|
||||||
|
invite.status = InviteStatus.accepted
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(membership)
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
async def decline_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomInvite:
|
||||||
|
invite = await _get_invite(db, invite_id)
|
||||||
|
if invite.target_user_id != user_id:
|
||||||
|
raise WrongInviteTargetError()
|
||||||
|
if invite.status != InviteStatus.pending:
|
||||||
|
raise InviteNotPendingError()
|
||||||
|
|
||||||
|
invite.status = InviteStatus.revoked
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(invite)
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_invite(db: AsyncSession, room_id: uuid.UUID, invite_id: uuid.UUID) -> RoomInvite:
|
||||||
|
invite = await _get_invite(db, invite_id)
|
||||||
|
if invite.room_id != room_id:
|
||||||
|
raise InviteNotFoundError()
|
||||||
|
if invite.status != InviteStatus.pending:
|
||||||
|
raise InviteNotPendingError()
|
||||||
|
|
||||||
|
invite.status = InviteStatus.revoked
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(invite)
|
||||||
|
return invite
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models import Room, RoomMembership, RoomRole
|
from app.models import Message, Room, RoomInvite, RoomMembership, RoomRole
|
||||||
from app.schemas.room import RoomCreate
|
from app.schemas.room import RoomCreate, RoomUpdate
|
||||||
|
|
||||||
|
|
||||||
class DuplicateRoomError(Exception):
|
class DuplicateRoomError(Exception):
|
||||||
@@ -21,8 +21,29 @@ class RoomIsPrivateError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CannotRemoveOwnerError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientRoleError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerMustTransferError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
||||||
room = Room(name=data.name, description=data.description, owner_id=owner_id)
|
room = Room(
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
is_private=data.is_private,
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
db.add(room)
|
db.add(room)
|
||||||
try:
|
try:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -49,6 +70,16 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_member_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, RoomRole]]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Room, RoomMembership.role)
|
||||||
|
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
||||||
|
.where(RoomMembership.user_id == user_id)
|
||||||
|
.order_by(Room.created_at)
|
||||||
|
)
|
||||||
|
return [(room, role) for room, role in result.all()]
|
||||||
|
|
||||||
|
|
||||||
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
|
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
|
||||||
room = await db.get(Room, room_id)
|
room = await db.get(Room, room_id)
|
||||||
if room is None:
|
if room is None:
|
||||||
@@ -75,3 +106,105 @@ async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) ->
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(membership)
|
await db.refresh(membership)
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
|
||||||
|
if data.name is not None:
|
||||||
|
room.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
room.description = data.description
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise DuplicateRoomError() from exc
|
||||||
|
await db.refresh(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_room(db: AsyncSession, room: Room) -> None:
|
||||||
|
# Explicit deletes rather than relying on ORM cascade + eager-loading —
|
||||||
|
# simpler and more predictable in async code.
|
||||||
|
await db.execute(delete(Message).where(Message.room_id == room.id))
|
||||||
|
await db.execute(delete(RoomInvite).where(RoomInvite.room_id == room.id))
|
||||||
|
await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
|
||||||
|
await db.delete(room)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomMembership)
|
||||||
|
.where(RoomMembership.room_id == room_id)
|
||||||
|
.options(selectinload(RoomMembership.user))
|
||||||
|
.order_by(RoomMembership.joined_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_membership(
|
||||||
|
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID
|
||||||
|
) -> RoomMembership:
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomMembership).where(
|
||||||
|
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = result.scalar_one_or_none()
|
||||||
|
if membership is None:
|
||||||
|
raise MembershipNotFoundError()
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_member(
|
||||||
|
db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, acting_role: RoomRole
|
||||||
|
) -> None:
|
||||||
|
membership = await _get_membership(db, room_id, target_user_id)
|
||||||
|
if membership.role == RoomRole.owner:
|
||||||
|
raise CannotRemoveOwnerError()
|
||||||
|
if membership.role == RoomRole.admin and acting_role != RoomRole.owner:
|
||||||
|
raise InsufficientRoleError()
|
||||||
|
|
||||||
|
await db.delete(membership)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def change_member_role(
|
||||||
|
db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, new_role: RoomRole
|
||||||
|
) -> RoomMembership:
|
||||||
|
membership = await _get_membership(db, room_id, target_user_id)
|
||||||
|
if membership.role == RoomRole.owner or new_role == RoomRole.owner:
|
||||||
|
# Ownership changes only happen through transfer_ownership.
|
||||||
|
raise InsufficientRoleError()
|
||||||
|
|
||||||
|
membership.role = new_role
|
||||||
|
await db.commit()
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomMembership)
|
||||||
|
.where(RoomMembership.room_id == room_id, RoomMembership.user_id == target_user_id)
|
||||||
|
.options(selectinload(RoomMembership.user))
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def transfer_ownership(
|
||||||
|
db: AsyncSession, room: Room, current_owner_id: uuid.UUID, new_owner_user_id: uuid.UUID
|
||||||
|
) -> Room:
|
||||||
|
new_owner_membership = await _get_membership(db, room.id, new_owner_user_id)
|
||||||
|
current_owner_membership = await _get_membership(db, room.id, current_owner_id)
|
||||||
|
|
||||||
|
new_owner_membership.role = RoomRole.owner
|
||||||
|
current_owner_membership.role = RoomRole.admin
|
||||||
|
room.owner_id = new_owner_user_id
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
|
||||||
|
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||||
|
membership = await _get_membership(db, room_id, user_id)
|
||||||
|
if membership.role == RoomRole.owner:
|
||||||
|
raise OwnerMustTransferError()
|
||||||
|
|
||||||
|
await db.delete(membership)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ async def db_session():
|
|||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
engine = create_async_engine(TEST_DATABASE_URL)
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
await conn.begin()
|
await conn.begin()
|
||||||
session = AsyncSession(bind=conn, join_transaction_mode="create_savepoint")
|
# expire_on_commit=False matches app/database.py's production session
|
||||||
|
# factory -- without it, objects loaded earlier in a request (e.g.
|
||||||
|
# current_user) go stale after any service-layer commit and touching
|
||||||
|
# them raises MissingGreenlet on the next sync attribute access.
|
||||||
|
session = AsyncSession(
|
||||||
|
bind=conn, join_transaction_mode="create_savepoint", expire_on_commit=False
|
||||||
|
)
|
||||||
yield session
|
yield session
|
||||||
await session.close()
|
await session.close()
|
||||||
await conn.rollback()
|
await conn.rollback()
|
||||||
@@ -106,6 +112,12 @@ async def register_and_login(
|
|||||||
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
|
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
|
||||||
await register_user(db_session, data)
|
await register_user(db_session, data)
|
||||||
|
|
||||||
|
return await login_as(client, username, password)
|
||||||
|
|
||||||
|
|
||||||
|
async def login_as(client: AsyncClient, username: str, password: str = "password123"):
|
||||||
|
# Switch the shared `client`'s session cookie to an already-created user,
|
||||||
|
# without trying to register them again.
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
json={"username_or_email": username, "password": password},
|
json={"username_or_email": username, "password": password},
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.models import RoomInvite
|
||||||
|
from tests.conftest import login_as, register_and_login
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_private_room(client, name="secret"):
|
||||||
|
resp = await client.post("/api/rooms", json={"name": name, "is_private": True})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_invite_requires_admin(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await register_and_login(client, db_session, username="carol")
|
||||||
|
|
||||||
|
# bob has no membership in the room at all, so he's blocked by the
|
||||||
|
# membership check before role is even considered.
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/invites", json={"target_username": "carol"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invite_unknown_username_404(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/invites", json={"target_username": "nobody"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invite_accept_flow(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
await register_and_login(client, db_session, username="bob") # seed bob's account only
|
||||||
|
|
||||||
|
await login_as(client, "alice")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
invite = resp.json()
|
||||||
|
assert invite["status"] == "pending"
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "bob")
|
||||||
|
|
||||||
|
resp = await client.get("/api/invites/mine")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mine = resp.json()
|
||||||
|
assert len(mine) == 1
|
||||||
|
assert mine[0]["id"] == invite["id"]
|
||||||
|
|
||||||
|
resp = await client.post(f"/api/invites/{invite['id']}/accept")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "member"
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/rooms/{room['id']}/messages")
|
||||||
|
assert resp.status_code == 200 # now a member
|
||||||
|
|
||||||
|
resp = await client.get("/api/rooms/mine")
|
||||||
|
assert any(r["name"] == room["name"] for r in resp.json())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_accept_invite_wrong_user_403(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
invite = (
|
||||||
|
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
|
||||||
|
).json()
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await register_and_login(client, db_session, username="carol")
|
||||||
|
resp = await client.post(f"/api/invites/{invite['id']}/accept")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_decline_invite(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
invite = (
|
||||||
|
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
|
||||||
|
).json()
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "bob")
|
||||||
|
resp = await client.post(f"/api/invites/{invite['id']}/decline")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "revoked"
|
||||||
|
|
||||||
|
resp = await client.post(f"/api/invites/{invite['id']}/accept")
|
||||||
|
assert resp.status_code == 400 # no longer pending
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoke_invite(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
invite = (
|
||||||
|
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
|
||||||
|
).json()
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/rooms/{room['id']}/invites/{invite['id']}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "bob")
|
||||||
|
resp = await client.post(f"/api/invites/{invite['id']}/accept")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_pending_invite_rejected(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
|
||||||
|
resp1 = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
resp2 = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invite_already_member_rejected(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client, name="open-ish")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/rooms/{room['id']}/invites", json={"target_username": "alice"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_invite_rejected_on_accept(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room = await _create_private_room(client)
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
invite = (
|
||||||
|
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
|
||||||
|
).json()
|
||||||
|
|
||||||
|
db_invite = await db_session.get(RoomInvite, uuid.UUID(invite["id"]))
|
||||||
|
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "bob")
|
||||||
|
resp = await client.post(f"/api/invites/{invite['id']}/accept")
|
||||||
|
assert resp.status_code == 400
|
||||||
+181
-1
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.models import Room, RoomMembership, RoomRole
|
from app.models import Room, RoomMembership, RoomRole
|
||||||
from tests.conftest import register_and_login
|
from tests.conftest import login_as, register_and_login
|
||||||
|
|
||||||
|
|
||||||
async def test_create_room_requires_auth(client):
|
async def test_create_room_requires_auth(client):
|
||||||
@@ -75,3 +75,183 @@ async def test_join_private_room_400(client, db_session):
|
|||||||
|
|
||||||
resp = await client.post(f"/api/rooms/{private_room.id}/join")
|
resp = await client.post(f"/api/rooms/{private_room.id}/join")
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_private_room_excluded_from_open_list_but_in_mine(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
resp = await client.post("/api/rooms", json={"name": "secret", "is_private": True})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["is_private"] is True
|
||||||
|
|
||||||
|
open_names = {r["name"] for r in (await client.get("/api/rooms")).json()}
|
||||||
|
assert "secret" not in open_names
|
||||||
|
|
||||||
|
mine = (await client.get("/api/rooms/mine")).json()
|
||||||
|
assert mine[0]["name"] == "secret"
|
||||||
|
assert mine[0]["role"] == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_room_requires_admin(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
|
||||||
|
resp = await client.patch(f"/api/rooms/{room_id}", json={"description": "nope"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
resp = await client.patch(f"/api/rooms/{room_id}", json={"description": "updated"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["description"] == "updated"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_room_owner_only(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/rooms/{room_id}")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
resp = await client.delete(f"/api/rooms/{room_id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(RoomMembership).where(RoomMembership.room_id == uuid.UUID(room_id))
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_leave_room(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(f"/api/rooms/{room_id}/leave")
|
||||||
|
assert resp.status_code == 400 # owner must transfer first
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
resp = await client.post(f"/api/rooms/{room_id}/leave")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/rooms/{room_id}/messages")
|
||||||
|
assert resp.status_code == 403 # no longer a member
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remove_member(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
bob = await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
resp = await client.delete(f"/api/rooms/{room_id}/members/{bob['id']}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/rooms/{room_id}/members/{bob['id']}")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_cannot_remove_another_admin(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
bob = await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
carol = await register_and_login(client, db_session, username="carol")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
resp = await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "admin"
|
||||||
|
resp = await client.patch(f"/api/rooms/{room_id}/members/{carol['id']}", json={"role": "admin"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "bob")
|
||||||
|
resp = await client.delete(f"/api/rooms/{room_id}/members/{carol['id']}")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cannot_remove_owner(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
bob = await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/rooms/{room_id}/members/{(await client.get('/api/auth/me')).json()['id']}")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transfer_ownership(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
bob = await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
await login_as(client, "alice")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/rooms/{room_id}/transfer-ownership", json={"new_owner_user_id": bob["id"]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["owner_id"] == bob["id"]
|
||||||
|
|
||||||
|
resp = await client.post(f"/api/rooms/{room_id}/leave")
|
||||||
|
assert resp.status_code == 204 # alice is admin now, not owner, so she can leave
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(RoomMembership).where(
|
||||||
|
RoomMembership.room_id == uuid.UUID(room_id), RoomMembership.user_id == uuid.UUID(bob["id"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result.scalar_one().role == RoomRole.owner
|
||||||
|
|
||||||
|
|
||||||
|
async def test_change_member_role_owner_only(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
bob = await register_and_login(client, db_session, username="bob")
|
||||||
|
await client.post(f"/api/rooms/{room_id}/join")
|
||||||
|
resp = await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
|
||||||
|
assert resp.status_code == 403 # bob is a plain member, not owner
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_room_members(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/rooms/{room_id}/members")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
members = resp.json()
|
||||||
|
assert len(members) == 1
|
||||||
|
assert members[0]["username"] == "alice"
|
||||||
|
assert members[0]["role"] == "owner"
|
||||||
|
|||||||
Reference in New Issue
Block a user