Private
Public Access
Add resizable room panel, searchable user picker, and direct room membership
Room info panel is now user-resizable (fixing a layout clip at narrow widths), and every user-selection spot (room membership, admin ownership transfer) uses a new searchable UserPicker instead of raw text input or prompt(). Member rows fold role + actions into a single inline dropdown instead of a row of buttons, so the member list stays usable as rooms grow. Room invites (the accept/decline flow) are replaced by adding a user to a room directly -- an admin/owner picks someone and they're a member immediately, with a "you've been added" notification email instead of an invite email. Drops the now-unused room_invites table.
This commit is contained in:
@@ -1,233 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models import RoomInvite, User
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def _configure_smtp(client):
|
||||
resp = await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
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"
|
||||
assert invite["target_username"] == "bob"
|
||||
|
||||
resp = await client.get(f"/api/rooms/{room['id']}/invites")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()[0]["target_username"] == "bob"
|
||||
|
||||
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"]
|
||||
assert mine[0]["room_name"] == room["name"]
|
||||
assert mine[0]["invited_by_username"] == "alice"
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def test_create_invite_sends_email_to_target(client, db_session, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await _make_admin(db_session, alice["id"])
|
||||
await _configure_smtp(client)
|
||||
room = await _create_private_room(client)
|
||||
await register_and_login(client, db_session, username="bob")
|
||||
await login_as(client, "alice")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["hostname"] == "smtp.example.com"
|
||||
|
||||
|
||||
async def test_create_invite_succeeds_even_if_email_delivery_fails(client, db_session, monkeypatch):
|
||||
async def fake_send(message, **kwargs):
|
||||
raise ConnectionRefusedError("boom")
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await _make_admin(db_session, alice["id"])
|
||||
await _configure_smtp(client)
|
||||
room = await _create_private_room(client)
|
||||
await register_and_login(client, db_session, username="bob")
|
||||
await login_as(client, "alice")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -245,6 +245,82 @@ async def test_change_member_role_owner_only(client, db_session):
|
||||
assert resp.status_code == 403 # bob is a plain member, not owner
|
||||
|
||||
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake(db, to, subject, body):
|
||||
calls.append({"to": to, "subject": subject, "body": body})
|
||||
|
||||
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
||||
return calls
|
||||
|
||||
|
||||
async def test_add_member_directly(client, db_session, monkeypatch):
|
||||
calls = _fake_send_email(monkeypatch)
|
||||
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("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["username"] == "bob"
|
||||
assert resp.json()["role"] == "member"
|
||||
|
||||
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.member
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["to"] == bob["email"]
|
||||
assert "added" in calls[0]["subject"].lower()
|
||||
|
||||
|
||||
async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
|
||||
_fake_send_email(monkeypatch)
|
||||
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")
|
||||
|
||||
carol = await register_and_login(client, db_session, username="carol")
|
||||
|
||||
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": carol["id"]})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_add_member_already_member_conflict(client, db_session, monkeypatch):
|
||||
_fake_send_email(monkeypatch)
|
||||
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}/members", json={"user_id": bob["id"]})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_add_member_unknown_user_404(client, db_session, monkeypatch):
|
||||
_fake_send_email(monkeypatch)
|
||||
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}/members", json={"user_id": str(uuid.uuid4())})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import uuid
|
||||
|
||||
from app.models import User
|
||||
from app.services.bot_service import create_bot
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def test_user_directory_requires_auth(client):
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_user_directory_lists_active_users(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username=_unique("alice"))
|
||||
await register_and_login(client, db_session, username=_unique("bob"))
|
||||
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 200
|
||||
usernames = {u["username"] for u in resp.json()}
|
||||
assert alice["username"] in usernames
|
||||
entry = next(u for u in resp.json() if u["id"] == alice["id"])
|
||||
assert entry["display_name"] is None
|
||||
assert entry["avatar_filename"] is None
|
||||
|
||||
|
||||
async def test_user_directory_excludes_bots(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
admin_user = await db_session.get(User, uuid.UUID(admin["id"]))
|
||||
bot_username = _unique("bot")
|
||||
await create_bot(db_session, admin_user, bot_username)
|
||||
|
||||
resp = await client.get("/api/users")
|
||||
usernames = {u["username"] for u in resp.json()}
|
||||
assert bot_username not in usernames
|
||||
|
||||
|
||||
async def test_user_directory_excludes_deactivated_users(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
bob = await register_and_login(client, db_session, username=_unique("bob"))
|
||||
|
||||
await client.post("/api/auth/login", json={"username_or_email": admin["username"], "password": "password123"})
|
||||
await client.post(f"/api/admin/users/{bob['id']}/deactivate")
|
||||
|
||||
resp = await client.get("/api/users")
|
||||
usernames = {u["username"] for u in resp.json()}
|
||||
assert bob["username"] not in usernames
|
||||
Reference in New Issue
Block a user