f6c55d888f
Implements docs/planning/social.md US-5's API layer on top of the
already-merged Group/GroupMember models (app/models/groups.py):
- POST/GET /api/groups, GET/PATCH/DELETE /api/groups/{id}
- POST .../join (idempotent), .../leave (owner must transfer first)
- POST .../members/{user_id}/role (owner-only, admin/member),
.../transfer (owner-only, demotes prior owner to admin)
Group page member list is a dedicated schema (user_id/username/role/
joined_at) so no private User fields leak. 27 new tests cover
authorization boundaries, idempotency, and exactly-one-owner invariants;
full backend suite (220 tests) passes with zero regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFSPt6rX1gwZmcr2HEfvzg
438 lines
18 KiB
Python
438 lines
18 KiB
Python
"""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
|