Phase 1: auth, room CRUD, WebSocket chat, PWA frontend

Invite-only FastAPI + SQLAlchemy(async) + Postgres backend (session-cookie
auth via CLI-provisioned accounts, open-room CRUD, single-instance /ws/chat)
and a React + Vite PWA frontend (login, room list, chat view). Backend tests
pass against a local Postgres DB. See README.md and backend/README.md for
setup, and ARCHITECTURE.md for the full phased design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 20:01:17 -06:00
co-authored by Claude Sonnet 5
parent 8ac35062dc
commit 99aa029c0d
77 changed files with 9042 additions and 0 deletions
View File
+114
View File
@@ -0,0 +1,114 @@
import os
from pathlib import Path
os.environ.setdefault(
"DATABASE_URL", "postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp_test"
)
os.environ.setdefault("SESSION_SECRET", "test-secret")
os.environ.setdefault("SESSION_HTTPS_ONLY", "false")
import pytest
import pytest_asyncio
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import get_db
from app.main import create_app
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
BACKEND_DIR = Path(__file__).resolve().parent.parent
TEST_DATABASE_URL = os.environ["DATABASE_URL"]
@pytest.fixture(scope="session", autouse=True)
def apply_migrations():
config = Config(str(BACKEND_DIR / "alembic.ini"))
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
command.upgrade(config, "head")
yield
@pytest_asyncio.fixture
async def db_session():
# Function-scoped (not session-scoped): asyncpg connections are bound to
# the event loop they were created on, and pytest-asyncio gives each test
# function its own loop by default. A session-scoped engine here would be
# reused across loops and fail with asyncpg "another operation is in
# progress" errors.
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.connect() as conn:
await conn.begin()
session = AsyncSession(bind=conn, join_transaction_mode="create_savepoint")
yield session
await session.close()
await conn.rollback()
await engine.dispose()
@pytest.fixture
def app(db_session):
application = create_app()
async def _get_db():
yield db_session
application.dependency_overrides[get_db] = _get_db
yield application
application.dependency_overrides.clear()
@pytest_asyncio.fixture
async def client(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
def ws_client():
# Starlette's TestClient (needed for websocket_connect, which httpx's
# async client doesn't support) runs the ASGI app on a background thread
# with its own event loop via anyio's BlockingPortal. asyncpg connections
# are bound to the loop they're opened on, so this app gets its own
# engine created here (no connections opened yet) rather than reusing
# the `db_session`/`app` fixtures' engine, which belongs to pytest's
# loop. No per-test rollback here (see test_ws_chat.py for the
# unique-name convention that keeps tests independent without it).
application = create_app()
test_engine = create_async_engine(TEST_DATABASE_URL)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async with test_session_factory() as session:
yield session
application.dependency_overrides[get_db] = _get_db
with TestClient(application) as tc:
tc.session_factory = test_session_factory # type: ignore[attr-defined]
yield tc
async def register_and_login(
client: AsyncClient,
db_session: AsyncSession,
username: str = "alice",
password: str = "password123",
):
# No public register endpoint (invite-only site) -- tests seed the
# account the same way an operator would via `python -m app.cli
# create-user`, by calling the service function directly, then log in
# through the real endpoint to get a session cookie on `client`.
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
await register_user(db_session, data)
resp = await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()
+70
View File
@@ -0,0 +1,70 @@
import pytest
from app.schemas.user import UserCreate
from app.services.auth_service import DuplicateUserError, register_user
from tests.conftest import register_and_login
async def test_login_sets_session_and_me_returns_user(client, db_session):
user = await register_and_login(client, db_session, username="alice")
assert user["username"] == "alice"
assert user["email"] == "alice@example.com"
resp = await client.get("/api/auth/me")
assert resp.status_code == 200
assert resp.json()["id"] == user["id"]
async def test_login_wrong_password(client, db_session):
data = UserCreate(username="erin", email="erin@example.com", password="password123")
await register_user(db_session, data)
resp = await client.post(
"/api/auth/login",
json={"username_or_email": "erin", "password": "wrong-password"},
)
assert resp.status_code == 401
async def test_login_unknown_user(client):
resp = await client.post(
"/api/auth/login",
json={"username_or_email": "nobody", "password": "password123"},
)
assert resp.status_code == 401
async def test_me_requires_auth(client):
resp = await client.get("/api/auth/me")
assert resp.status_code == 401
async def test_logout_clears_session(client, db_session):
await register_and_login(client, db_session, username="frank")
resp = await client.post("/api/auth/logout")
assert resp.status_code == 204
resp = await client.get("/api/auth/me")
assert resp.status_code == 401
async def test_register_user_duplicate_username_conflicts(db_session):
await register_user(
db_session, UserCreate(username="bob", email="bob@example.com", password="password123")
)
with pytest.raises(DuplicateUserError):
await register_user(
db_session,
UserCreate(username="bob", email="different@example.com", password="password123"),
)
async def test_register_user_duplicate_email_conflicts(db_session):
await register_user(
db_session, UserCreate(username="carol", email="carol@example.com", password="password123")
)
with pytest.raises(DuplicateUserError):
await register_user(
db_session,
UserCreate(username="different", email="carol@example.com", password="password123"),
)
+77
View File
@@ -0,0 +1,77 @@
import uuid
from sqlalchemy import select
from app.models import Room, RoomMembership, RoomRole
from tests.conftest import register_and_login
async def test_create_room_requires_auth(client):
resp = await client.post("/api/rooms", json={"name": "general"})
assert resp.status_code == 401
async def test_create_room_creates_owner_membership(client, db_session):
user = await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/rooms", json={"name": "general", "description": "chat"})
assert resp.status_code == 201
room = resp.json()
assert room["name"] == "general"
assert room["owner_id"] == user["id"]
result = await db_session.execute(
select(RoomMembership).where(RoomMembership.room_id == uuid.UUID(room["id"]))
)
membership = result.scalar_one()
assert membership.user_id == uuid.UUID(user["id"])
assert membership.role == RoomRole.owner
async def test_list_rooms_excludes_private(client, db_session):
user = await register_and_login(client, db_session, username="alice")
await client.post("/api/rooms", json={"name": "open-room"})
private_room = Room(
name="secret-room", is_private=True, owner_id=uuid.UUID(user["id"])
)
db_session.add(private_room)
await db_session.commit()
resp = await client.get("/api/rooms")
assert resp.status_code == 200
names = {r["name"] for r in resp.json()}
assert "open-room" in names
assert "secret-room" not in names
async def test_join_room_idempotent(client, db_session):
await register_and_login(client, db_session, username="alice")
create_resp = await client.post("/api/rooms", json={"name": "general"})
room_id = create_resp.json()["id"]
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
resp1 = await client.post(f"/api/rooms/{room_id}/join")
assert resp1.status_code == 200
resp2 = await client.post(f"/api/rooms/{room_id}/join")
assert resp2.status_code == 200
async def test_join_nonexistent_room_404(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post(f"/api/rooms/{uuid.uuid4()}/join")
assert resp.status_code == 404
async def test_join_private_room_400(client, db_session):
user = await register_and_login(client, db_session, username="alice")
private_room = Room(
name="secret-room", is_private=True, owner_id=uuid.UUID(user["id"])
)
db_session.add(private_room)
await db_session.commit()
await db_session.refresh(private_room)
resp = await client.post(f"/api/rooms/{private_room.id}/join")
assert resp.status_code == 400
+74
View File
@@ -0,0 +1,74 @@
import uuid
from starlette.websockets import WebSocketDisconnect
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from app.ws.chat import WS_UNAUTHENTICATED
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _register(ws_client, username):
# No public register endpoint (invite-only site): seed the user directly
# via the ws_client's own session factory (see conftest.ws_client), then
# log in through the real endpoint to get a session cookie.
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login",
json={"username_or_email": username, "password": "password123"},
)
assert resp.status_code == 200, resp.text
return resp.json()
def test_ws_requires_auth(ws_client):
try:
with ws_client.websocket_connect("/ws/chat"):
pass
assert False, "expected the connection to be rejected"
except WebSocketDisconnect as exc:
assert exc.code == WS_UNAUTHENTICATED
def test_ws_join_and_message_roundtrip(ws_client):
username = _unique("alice")
_register(ws_client, username=username)
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
joined = ws.receive_json()
assert joined == {"type": "joined", "room_id": room["id"]}
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
message = ws.receive_json()
assert message["type"] == "message"
assert message["content"] == "hello"
assert message["room_id"] == room["id"]
assert message["username"] == username
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
assert resp.status_code == 200
contents = [m["content"] for m in resp.json()]
assert "hello" in contents
def test_ws_message_without_join_errors(ws_client):
_register(ws_client, username=_unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
resp = ws.receive_json()
assert resp["type"] == "error"