"""Integration tests for the Groups API router (app/routers/groups.py). app/models/groups.py owns the schema (see test_group_models.py); this covers create/join/leave, role authorization, ownership transfer, and the group page's member-list privacy. """ from fastapi.testclient import TestClient from app.main import app def _register(client: TestClient, username: str) -> dict: resp = client.post( "/api/auth/register", json={ "username": username, "email": f"{username}@example.com", "password": "secret123", "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201, resp.text token = resp.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} me = client.get("/api/users/me", headers=headers) assert me.status_code == 200, me.text return {"headers": headers, "id": me.json()["id"], "username": username} def _create_group(client: TestClient, headers: dict, name: str, description: str | None = None) -> dict: resp = client.post( "/api/groups", json={"name": name, "description": description}, headers=headers, ) assert resp.status_code == 201, resp.text return resp.json() class TestCreateGroup: def test_create_makes_creator_owner(self): client = TestClient(app) owner = _register(client, "grp_api_create_owner") group = _create_group(client, owner["headers"], "Explorers", "We explore worlds") assert group["name"] == "Explorers" assert group["description"] == "We explore worlds" assert group["owner_id"] == owner["id"] assert group["member_count"] == 1 assert len(group["members"]) == 1 assert group["members"][0]["user_id"] == owner["id"] assert group["members"][0]["role"] == "owner" def test_requires_auth(self): client = TestClient(app) resp = client.post("/api/groups", json={"name": "No Auth"}) assert resp.status_code == 401 def test_blank_name_rejected(self): client = TestClient(app) owner = _register(client, "grp_api_blank_name") resp = client.post("/api/groups", json={"name": " "}, headers=owner["headers"]) assert resp.status_code == 422 def test_name_over_max_length_rejected(self): client = TestClient(app) owner = _register(client, "grp_api_long_name") resp = client.post( "/api/groups", json={"name": "x" * 101}, headers=owner["headers"] ) assert resp.status_code == 422 class TestGroupPage: def test_get_group_page_shows_members_and_roles_no_private_fields(self): client = TestClient(app) owner = _register(client, "grp_api_page_owner") member = _register(client, "grp_api_page_member") group = _create_group(client, owner["headers"], "Page Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) resp = client.get(f"/api/groups/{group['id']}", headers=member["headers"]) assert resp.status_code == 200, resp.text body = resp.json() assert body["name"] == "Page Group" assert body["member_count"] == 2 by_user = {m["user_id"]: m for m in body["members"]} assert by_user[owner["id"]]["role"] == "owner" assert by_user[member["id"]]["role"] == "member" assert by_user[member["id"]]["username"] == member["username"] # No private User fields (email, hashed_password, etc.) leak. for m in body["members"]: assert set(m.keys()) == {"user_id", "username", "role", "joined_at"} def test_get_nonexistent_group_is_404(self): client = TestClient(app) user = _register(client, "grp_api_404") resp = client.get("/api/groups/999999999", headers=user["headers"]) assert resp.status_code == 404 class TestJoinLeave: def test_join_is_idempotent(self): client = TestClient(app) owner = _register(client, "grp_api_join_owner") joiner = _register(client, "grp_api_join_joiner") group = _create_group(client, owner["headers"], "Join Group") r1 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"]) assert r1.status_code == 200, r1.text r2 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"]) assert r2.status_code == 200, r2.text assert r1.json()["role"] == r2.json()["role"] == "member" page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json() assert page["member_count"] == 2 # not 3 -- duplicate join didn't add a row def test_leave_removes_membership(self): client = TestClient(app) owner = _register(client, "grp_api_leave_owner") member = _register(client, "grp_api_leave_member") group = _create_group(client, owner["headers"], "Leave Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) resp = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"]) assert resp.status_code == 200, resp.text page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json() assert page["member_count"] == 1 assert all(m["user_id"] != member["id"] for m in page["members"]) def test_owner_cannot_leave_without_transfer(self): client = TestClient(app) owner = _register(client, "grp_api_owner_leave") group = _create_group(client, owner["headers"], "Owner Leave Group") resp = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"]) assert resp.status_code == 409, resp.text def test_leave_when_not_a_member_is_404(self): client = TestClient(app) owner = _register(client, "grp_api_notmember_owner") stranger = _register(client, "grp_api_notmember_stranger") group = _create_group(client, owner["headers"], "Not A Member Group") resp = client.post(f"/api/groups/{group['id']}/leave", headers=stranger["headers"]) assert resp.status_code == 404 class TestRoleAuthorization: def test_owner_can_promote_and_demote(self): client = TestClient(app) owner = _register(client, "grp_api_role_owner") member = _register(client, "grp_api_role_member") group = _create_group(client, owner["headers"], "Role Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) promote = client.post( f"/api/groups/{group['id']}/members/{member['id']}/role", json={"role": "admin"}, headers=owner["headers"], ) assert promote.status_code == 200, promote.text assert promote.json()["role"] == "admin" demote = client.post( f"/api/groups/{group['id']}/members/{member['id']}/role", json={"role": "member"}, headers=owner["headers"], ) assert demote.status_code == 200, demote.text assert demote.json()["role"] == "member" def test_plain_member_cannot_promote(self): client = TestClient(app) owner = _register(client, "grp_api_noauth_owner") member = _register(client, "grp_api_noauth_member") other = _register(client, "grp_api_noauth_other") group = _create_group(client, owner["headers"], "No Auth Promote Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) client.post(f"/api/groups/{group['id']}/join", headers=other["headers"]) resp = client.post( f"/api/groups/{group['id']}/members/{other['id']}/role", json={"role": "admin"}, headers=member["headers"], ) assert resp.status_code == 403 def test_admin_cannot_promote_or_demote(self): """Only the owner may change roles -- admins cannot, per spec.""" client = TestClient(app) owner = _register(client, "grp_api_adminauth_owner") admin = _register(client, "grp_api_adminauth_admin") other = _register(client, "grp_api_adminauth_other") group = _create_group(client, owner["headers"], "Admin Cannot Promote Group") client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"]) client.post(f"/api/groups/{group['id']}/join", headers=other["headers"]) client.post( f"/api/groups/{group['id']}/members/{admin['id']}/role", json={"role": "admin"}, headers=owner["headers"], ) resp = client.post( f"/api/groups/{group['id']}/members/{other['id']}/role", json={"role": "admin"}, headers=admin["headers"], ) assert resp.status_code == 403 def test_plain_member_cannot_delete_group(self): client = TestClient(app) owner = _register(client, "grp_api_nodelete_owner") member = _register(client, "grp_api_nodelete_member") group = _create_group(client, owner["headers"], "No Delete Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) resp = client.delete(f"/api/groups/{group['id']}", headers=member["headers"]) assert resp.status_code == 403 def test_admin_cannot_delete_group(self): client = TestClient(app) owner = _register(client, "grp_api_admindelete_owner") admin = _register(client, "grp_api_admindelete_admin") group = _create_group(client, owner["headers"], "Admin No Delete Group") client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"]) client.post( f"/api/groups/{group['id']}/members/{admin['id']}/role", json={"role": "admin"}, headers=owner["headers"], ) resp = client.delete(f"/api/groups/{group['id']}", headers=admin["headers"]) assert resp.status_code == 403 def test_plain_member_cannot_transfer_ownership(self): client = TestClient(app) owner = _register(client, "grp_api_notransfer_owner") member = _register(client, "grp_api_notransfer_member") group = _create_group(client, owner["headers"], "No Transfer Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) resp = client.post( f"/api/groups/{group['id']}/transfer", json={"new_owner_id": member["id"]}, headers=member["headers"], ) assert resp.status_code == 403 def test_cannot_set_role_to_owner_via_role_endpoint(self): client = TestClient(app) owner = _register(client, "grp_api_noownerrole_owner") member = _register(client, "grp_api_noownerrole_member") group = _create_group(client, owner["headers"], "No Owner Role Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) resp = client.post( f"/api/groups/{group['id']}/members/{member['id']}/role", json={"role": "owner"}, headers=owner["headers"], ) assert resp.status_code == 422 class TestOwnershipTransfer: def test_transfer_moves_ownership_and_demotes_old_owner(self): client = TestClient(app) owner = _register(client, "grp_api_xfer_owner") member = _register(client, "grp_api_xfer_member") group = _create_group(client, owner["headers"], "Transfer Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) resp = client.post( f"/api/groups/{group['id']}/transfer", json={"new_owner_id": member["id"]}, headers=owner["headers"], ) assert resp.status_code == 200, resp.text body = resp.json() assert body["owner_id"] == member["id"] by_user = {m["user_id"]: m for m in body["members"]} assert by_user[member["id"]]["role"] == "owner" assert by_user[owner["id"]]["role"] == "admin" # Exactly one owner remains. owners = [m for m in body["members"] if m["role"] == "owner"] assert len(owners) == 1 # Old owner can now leave (no longer the owner). leave = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"]) assert leave.status_code == 200, leave.text # New owner cannot leave without transferring again. leave2 = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"]) assert leave2.status_code == 409 def test_transfer_target_must_be_a_member(self): client = TestClient(app) owner = _register(client, "grp_api_xfernonmember_owner") stranger = _register(client, "grp_api_xfernonmember_stranger") group = _create_group(client, owner["headers"], "Transfer Nonmember Group") resp = client.post( f"/api/groups/{group['id']}/transfer", json={"new_owner_id": stranger["id"]}, headers=owner["headers"], ) assert resp.status_code == 404 def test_transfer_to_self_rejected(self): client = TestClient(app) owner = _register(client, "grp_api_xferself_owner") group = _create_group(client, owner["headers"], "Transfer Self Group") resp = client.post( f"/api/groups/{group['id']}/transfer", json={"new_owner_id": owner["id"]}, headers=owner["headers"], ) assert resp.status_code == 400 class TestUpdateGroup: def test_owner_can_update_name_and_description(self): client = TestClient(app) owner = _register(client, "grp_api_update_owner") group = _create_group(client, owner["headers"], "Old Name", "Old desc") resp = client.patch( f"/api/groups/{group['id']}", json={"name": "New Name", "description": "New desc"}, headers=owner["headers"], ) assert resp.status_code == 200, resp.text assert resp.json()["name"] == "New Name" assert resp.json()["description"] == "New desc" def test_admin_can_update(self): client = TestClient(app) owner = _register(client, "grp_api_adminupdate_owner") admin = _register(client, "grp_api_adminupdate_admin") group = _create_group(client, owner["headers"], "Admin Update Group") client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"]) client.post( f"/api/groups/{group['id']}/members/{admin['id']}/role", json={"role": "admin"}, headers=owner["headers"], ) resp = client.patch( f"/api/groups/{group['id']}", json={"name": "Updated By Admin"}, headers=admin["headers"], ) assert resp.status_code == 200, resp.text assert resp.json()["name"] == "Updated By Admin" def test_plain_member_cannot_update(self): client = TestClient(app) owner = _register(client, "grp_api_noupdate_owner") member = _register(client, "grp_api_noupdate_member") group = _create_group(client, owner["headers"], "No Update Group") client.post(f"/api/groups/{group['id']}/join", headers=member["headers"]) resp = client.patch( f"/api/groups/{group['id']}", json={"name": "Hacked Name"}, headers=member["headers"], ) assert resp.status_code == 403 def test_non_member_cannot_update(self): client = TestClient(app) owner = _register(client, "grp_api_strangernoupdate_owner") stranger = _register(client, "grp_api_strangernoupdate_stranger") group = _create_group(client, owner["headers"], "Stranger No Update Group") resp = client.patch( f"/api/groups/{group['id']}", json={"name": "Hacked Name"}, headers=stranger["headers"], ) assert resp.status_code == 403 class TestDeleteGroup: def test_owner_can_delete(self): client = TestClient(app) owner = _register(client, "grp_api_delete_owner") group = _create_group(client, owner["headers"], "Delete Group") resp = client.delete(f"/api/groups/{group['id']}", headers=owner["headers"]) assert resp.status_code == 204, resp.text after = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]) assert after.status_code == 404 class TestListGroups: def test_list_all_groups_paginated(self): client = TestClient(app) owner = _register(client, "grp_api_list_owner") _create_group(client, owner["headers"], "List Group A") _create_group(client, owner["headers"], "List Group B") resp = client.get("/api/groups", params={"limit": 1}, headers=owner["headers"]) assert resp.status_code == 200, resp.text body = resp.json() assert len(body["groups"]) == 1 assert body["total"] >= 2 def test_list_mine_only_returns_own_groups(self): client = TestClient(app) owner = _register(client, "grp_api_listmine_owner") other_owner = _register(client, "grp_api_listmine_other") mine = _create_group(client, owner["headers"], "Mine Group") _create_group(client, other_owner["headers"], "Not Mine Group") resp = client.get("/api/groups", params={"mine": True}, headers=owner["headers"]) assert resp.status_code == 200, resp.text ids = [g["id"] for g in resp.json()["groups"]] assert mine["id"] in ids assert all( g["id"] != mine["id"] or g["owner_id"] == owner["id"] for g in resp.json()["groups"] ) # The other owner's group must not appear. other_group_ids = [ g["id"] for g in client.get( "/api/groups", params={"mine": True}, headers=other_owner["headers"] ).json()["groups"] ] assert mine["id"] not in other_group_ids