Private
Public Access
Add direct messages (#52)
A DM is a Room with a new is_dm flag, not a separate model -- reuses all the membership/message/WS plumbing Room already has instead of duplicating it. The room's `name` (still required + globally unique) is an internal, never-displayed token derived deterministically from the two participants' sorted user IDs (dm_room_name), which makes find-or-create a single indexed lookup and gets free race-condition safety from the existing unique constraint -- a concurrent double- start from both people just hits the same IntegrityError->retry-as- lookup path create_room already established. Both participants get the plain 'member' role (no owner/admin distinction makes sense for a 1:1 DM), which incidentally reuses every existing role gate to block add-member, room-settings edits, and join-via-browse on a DM for free. update_room also gets an explicit is_dm guard independent of that, since renaming a DM isn't just a privacy concern -- it would silently corrupt the find-or-create invariant. DMs are excluded from both Browse Rooms and the admin portal's room listing (fully private, per scope). GET /api/rooms/mine precomputes each DM's other participant (name, avatar, presence) as dm_partner in one batched query, so the sidebar can render a DM row without a fetch per row. Frontend: a new "Direct Messages" sidebar section (searchable by partner name, not the internal room name), clicking someone in the People list starts or resumes a DM, and the chat header/composer/RoomInfoPanel all render the partner's identity instead of a room name where it's a DM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Room, RoomMembership
|
||||
from app.services.room_service import dm_room_name
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
async def test_start_dm_creates_private_room_with_both_members(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
room = resp.json()
|
||||
assert room["is_dm"] is True
|
||||
assert room["is_private"] is True
|
||||
# The internal name is never meant to be shown, but its scheme is part
|
||||
# of the contract find_or_create_dm relies on -- pin it here so a
|
||||
# future refactor can't silently change it without this test noticing.
|
||||
assert room["name"] == dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"]))
|
||||
|
||||
result = await db_session.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == uuid.UUID(room["id"]))
|
||||
)
|
||||
member_ids = {str(row[0]) for row in result.all()}
|
||||
assert member_ids == {alice["id"], bob["id"]}
|
||||
|
||||
|
||||
async def test_start_dm_is_idempotent_regardless_of_who_initiates(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
|
||||
resp1 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp1.status_code == 201
|
||||
room_id = resp1.json()["id"]
|
||||
|
||||
# bob -> alice again should return the same room, not create a second one.
|
||||
resp2 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp2.status_code == 201
|
||||
assert resp2.json()["id"] == room_id
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
# alice -> bob (reversed direction) should also find the same room.
|
||||
resp3 = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp3.status_code == 201
|
||||
assert resp3.json()["id"] == room_id
|
||||
|
||||
|
||||
async def test_start_dm_rejects_self(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_start_dm_404s_for_unknown_user(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": str(uuid.uuid4())})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_dm_excluded_from_browse_rooms(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
|
||||
# A third user should never see the DM in the open-rooms listing, even
|
||||
# though find_or_create_dm sets is_private=True (which alone would
|
||||
# already exclude it) -- confirms the belt-and-suspenders is_dm filter
|
||||
# in list_open_rooms is doing something, not just is_private.
|
||||
await client.post("/api/auth/logout")
|
||||
await register_and_login(client, db_session, username="carol")
|
||||
resp = await client.get("/api/rooms")
|
||||
assert resp.status_code == 200
|
||||
assert all(not r["is_dm"] for r in resp.json())
|
||||
|
||||
|
||||
async def test_dm_excluded_from_admin_room_list(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
admin = await register_and_login(client, db_session, username="dave")
|
||||
from app.models import User
|
||||
|
||||
user = await db_session.get(User, uuid.UUID(admin["id"]))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/admin/rooms")
|
||||
assert resp.status_code == 200
|
||||
names = [r["name"] for r in resp.json()]
|
||||
assert dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"])) not in names
|
||||
|
||||
|
||||
async def test_dm_appears_in_mine_with_partner_info(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob", password="password123")
|
||||
# Give bob a display name so the partner payload's precedence is checked
|
||||
# for something other than the fallback username.
|
||||
await client.patch("/api/auth/me", json={"display_name": "Bobby"})
|
||||
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
dm_entry = next(r for r in mine if r["id"] == dm["id"])
|
||||
assert dm_entry["is_dm"] is True
|
||||
assert dm_entry["dm_partner"]["user_id"] == bob["id"]
|
||||
assert dm_entry["dm_partner"]["username"] == "bob"
|
||||
assert dm_entry["dm_partner"]["display_name"] == "Bobby"
|
||||
|
||||
# A regular room's dm_partner is always null.
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
room_entry = next(r for r in mine if r["id"] == room["id"])
|
||||
assert room_entry["is_dm"] is False
|
||||
assert room_entry["dm_partner"] is None
|
||||
|
||||
|
||||
async def test_dm_cannot_be_updated(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
# bob is a plain 'member' of the DM (no admin/owner role exists for a
|
||||
# DM), so this 403s on the ordinary role gate before ever reaching
|
||||
# update_room's own is_dm guard.
|
||||
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# A site admin bypasses that role gate (see #48) -- confirms the
|
||||
# explicit is_dm guard inside update_room itself is what stops this,
|
||||
# not just incidental role-based protection.
|
||||
await client.post("/api/auth/logout")
|
||||
admin = await register_and_login(client, db_session, username="carol")
|
||||
from app.models import User
|
||||
|
||||
user = await db_session.get(User, uuid.UUID(admin["id"]))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_dm_rejects_add_member_and_join(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
carol = await register_and_login(client, db_session, username="carol")
|
||||
|
||||
# Neither participant has admin/owner role in a DM, so adding a third
|
||||
# person 403s on the existing role gate.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "bob")
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/members", json={"user_id": carol["id"]})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# is_private=True on the DM already blocks the plain join endpoint too.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "carol")
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/join")
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user