test_volunteer_groups.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import uuid
  2. from datetime import datetime, timedelta
  3. import pytest
  4. from httpx import AsyncClient
  5. from sqlalchemy import select
  6. from sqlalchemy.orm import Session
  7. from app.main import app
  8. from app.models import OrgRole, Organization, Project, Slot, Volunteer, VolunteerGroup
  9. from app.core.session import session as session_maker
  10. pytestmark = pytest.mark.asyncio
  11. MANAGE_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE]
  12. FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
  13. @pytest.fixture
  14. def two_volunteers(default_project: Project) -> tuple[Volunteer, Volunteer]:
  15. with session_maker() as db:
  16. v1 = Volunteer(
  17. project_id=default_project.id, name="Yvain", surname="Lion", email="y@test.com",
  18. phone_number="0601010101", automatic_sms=True,
  19. )
  20. v2 = Volunteer(
  21. project_id=default_project.id, name="Lancelot", surname="Lac", email="l@test.com",
  22. phone_number="0602020202", automatic_sms=True,
  23. )
  24. db.add_all([v1, v2])
  25. db.commit()
  26. db.refresh(v1)
  27. db.refresh(v2)
  28. return v1, v2
  29. @pytest.fixture
  30. def default_group(default_project: Project) -> VolunteerGroup:
  31. with session_maker() as db:
  32. group = VolunteerGroup(project_id=default_project.id, name="Chevaliers", color="#FF0000")
  33. db.add(group)
  34. db.commit()
  35. db.refresh(group)
  36. return group
  37. class TestListGroups:
  38. @pytest.mark.parametrize("role", MANAGE_ROLES)
  39. async def test_role_access(
  40. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup, make_org_user, role
  41. ):
  42. _, headers = make_org_user(role=role)
  43. response = await client.get(
  44. app.url_path_for("list_project_groups", project_id=default_project.id), headers=headers
  45. )
  46. assert response.status_code == 200
  47. data = response.json()
  48. assert len(data) == 1
  49. assert data[0]["name"] == "Chevaliers"
  50. @pytest.mark.parametrize("role", FORBIDDEN_ROLES)
  51. async def test_forbidden_roles(self, client: AsyncClient, default_project: Project, make_org_user, role):
  52. _, headers = make_org_user(role=role)
  53. response = await client.get(
  54. app.url_path_for("list_project_groups", project_id=default_project.id), headers=headers
  55. )
  56. assert response.status_code == 403
  57. async def test_project_not_found(self, client: AsyncClient, make_org_user):
  58. _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=None) if False else make_org_user(role=None)
  59. # super_admin needed since require_org_role 404s on missing project regardless of role
  60. from app.models import GlobalRole
  61. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  62. response = await client.get(
  63. app.url_path_for("list_project_groups", project_id=uuid.uuid4()), headers=headers
  64. )
  65. assert response.status_code == 404
  66. class TestCreateGroup:
  67. @pytest.mark.parametrize("role", MANAGE_ROLES)
  68. async def test_role_access(
  69. self, client: AsyncClient, default_project: Project, make_org_user, session: Session, role
  70. ):
  71. _, headers = make_org_user(role=role)
  72. response = await client.post(
  73. app.url_path_for("create_group", project_id=default_project.id),
  74. headers=headers,
  75. json={"name": "Bar team", "color": "#00FF00"},
  76. )
  77. assert response.status_code == 200
  78. group = session.execute(
  79. select(VolunteerGroup).where(VolunteerGroup.name == "Bar team")
  80. ).scalars().first()
  81. assert group is not None
  82. assert group.color == "#00FF00"
  83. @pytest.mark.parametrize("role", FORBIDDEN_ROLES)
  84. async def test_forbidden_roles(self, client: AsyncClient, default_project: Project, make_org_user, role):
  85. _, headers = make_org_user(role=role)
  86. response = await client.post(
  87. app.url_path_for("create_group", project_id=default_project.id),
  88. headers=headers,
  89. json={"name": "Nope"},
  90. )
  91. assert response.status_code == 403
  92. class TestGetGroup:
  93. async def test_not_found(self, client: AsyncClient, make_org_user, default_project: Project):
  94. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  95. response = await client.get(
  96. app.url_path_for("get_group", project_id=default_project.id, group_id=uuid.uuid4()),
  97. headers=headers,
  98. )
  99. assert response.status_code == 404
  100. async def test_group_from_other_project_not_found(
  101. self, client: AsyncClient, default_group: VolunteerGroup, make_org_user, session: Session
  102. ):
  103. """A group belonging to a different project must 404, even for a
  104. role that would otherwise pass, since group.project_id doesn't
  105. match the project_id in the URL."""
  106. other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
  107. session.add(other_org)
  108. session.commit()
  109. other_project = Project(name="Other Project", is_public=False, organization_id=other_org.id)
  110. session.add(other_project)
  111. session.commit()
  112. _, headers = make_org_user(role=OrgRole.ORG_ADMIN) # member of default_organization
  113. response = await client.get(
  114. app.url_path_for("get_group", project_id=other_project.id, group_id=default_group.id),
  115. headers=headers,
  116. )
  117. # 403 (not a member of other_project's org) fires before the group-mismatch
  118. # check even runs -- both are valid rejections, but confirm which one applies
  119. assert response.status_code in (403, 404)
  120. class TestUpdateGroup:
  121. async def test_updates_name_and_color(
  122. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup, make_org_user, session: Session
  123. ):
  124. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  125. response = await client.patch(
  126. app.url_path_for("update_group", project_id=default_project.id, group_id=default_group.id),
  127. headers=headers,
  128. json={"name": "Renamed", "color": "#0000FF"},
  129. )
  130. assert response.status_code == 200
  131. group = session.get(VolunteerGroup, default_group.id)
  132. assert group.name == "Renamed"
  133. assert group.color == "#0000FF"
  134. class TestDeleteGroup:
  135. async def test_deletes_group_without_deleting_volunteers(
  136. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  137. two_volunteers, make_org_user, session: Session
  138. ):
  139. v1, _ = two_volunteers
  140. group = session.get(VolunteerGroup, default_group.id)
  141. volunteer = session.get(Volunteer, v1.id)
  142. group.volunteers.append(volunteer)
  143. session.commit()
  144. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  145. response = await client.delete(
  146. app.url_path_for("delete_group", project_id=default_project.id, group_id=default_group.id),
  147. headers=headers,
  148. )
  149. assert response.status_code == 200
  150. assert session.get(VolunteerGroup, default_group.id) is None
  151. assert session.get(Volunteer, v1.id) is not None
  152. class TestGroupMembership:
  153. async def test_add_volunteers_to_group(
  154. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  155. two_volunteers, make_org_user
  156. ):
  157. v1, v2 = two_volunteers
  158. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  159. response = await client.post(
  160. app.url_path_for("add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id),
  161. headers=headers,
  162. json={"volunteer_ids": [v1.id, v2.id]},
  163. )
  164. assert response.status_code == 200
  165. assert sorted(response.json()["volunteers_id"]) == sorted([v1.id, v2.id])
  166. async def test_add_is_idempotent(
  167. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  168. two_volunteers, make_org_user
  169. ):
  170. v1, _ = two_volunteers
  171. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  172. await client.post(
  173. app.url_path_for("add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id),
  174. headers=headers,
  175. json={"volunteer_ids": [v1.id]},
  176. )
  177. response = await client.post(
  178. app.url_path_for("add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id),
  179. headers=headers,
  180. json={"volunteer_ids": [v1.id]},
  181. )
  182. assert response.status_code == 200
  183. assert response.json()["volunteers_id"] == [v1.id]
  184. async def test_volunteer_from_other_project_rejected(
  185. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  186. make_org_user, session: Session
  187. ):
  188. other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
  189. session.add(other_org)
  190. session.commit()
  191. other_project = Project(name="Other Project 2", is_public=False, organization_id=other_org.id)
  192. session.add(other_project)
  193. session.commit()
  194. stray_volunteer = Volunteer(
  195. project_id=other_project.id, name="Stray", surname="Cat", email="s@test.com",
  196. phone_number="0600000000",
  197. )
  198. session.add(stray_volunteer)
  199. session.commit()
  200. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  201. response = await client.post(
  202. app.url_path_for("add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id),
  203. headers=headers,
  204. json={"volunteer_ids": [stray_volunteer.id]},
  205. )
  206. assert response.status_code == 400
  207. async def test_remove_volunteer_from_group(
  208. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  209. two_volunteers, make_org_user, session: Session
  210. ):
  211. v1, v2 = two_volunteers
  212. group = session.get(VolunteerGroup, default_group.id)
  213. group.volunteers.append(session.get(Volunteer, v1.id))
  214. group.volunteers.append(session.get(Volunteer, v2.id))
  215. session.commit()
  216. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  217. response = await client.delete(
  218. app.url_path_for(
  219. "remove_volunteer_from_group",
  220. project_id=default_project.id, group_id=default_group.id, volunteer_id=v1.id,
  221. ),
  222. headers=headers,
  223. )
  224. assert response.status_code == 200
  225. assert response.json()["volunteers_id"] == [v2.id]
  226. class TestAddGroupToSlot:
  227. async def test_bulk_assigns_all_group_members(
  228. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  229. two_volunteers, make_org_user, session: Session
  230. ):
  231. v1, v2 = two_volunteers
  232. group = session.get(VolunteerGroup, default_group.id)
  233. group.volunteers.append(session.get(Volunteer, v1.id))
  234. group.volunteers.append(session.get(Volunteer, v2.id))
  235. slot = Slot(
  236. project_id=default_project.id,
  237. title="Garde du Graal",
  238. starting_time=datetime.now() + timedelta(hours=1),
  239. ending_time=datetime.now() + timedelta(hours=2),
  240. )
  241. session.add(slot)
  242. session.commit()
  243. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  244. response = await client.post(
  245. app.url_path_for(
  246. "add_group_to_slot", project_id=default_project.id, group_id=default_group.id, slot_id=slot.id
  247. ),
  248. headers=headers,
  249. )
  250. assert response.status_code == 200
  251. ids = [v["id"] for v in response.json()]
  252. assert sorted(ids) == sorted([v1.id, v2.id])
  253. async def test_slot_from_other_project_not_found(
  254. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  255. make_org_user, session: Session
  256. ):
  257. other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
  258. session.add(other_org)
  259. session.commit()
  260. other_project = Project(name="Other Project 3", is_public=False, organization_id=other_org.id)
  261. session.add(other_project)
  262. session.commit()
  263. stray_slot = Slot(
  264. project_id=other_project.id, title="Stray slot",
  265. starting_time=datetime.now(), ending_time=datetime.now() + timedelta(hours=1),
  266. )
  267. session.add(stray_slot)
  268. session.commit()
  269. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  270. response = await client.post(
  271. app.url_path_for(
  272. "add_group_to_slot",
  273. project_id=default_project.id, group_id=default_group.id, slot_id=stray_slot.id,
  274. ),
  275. headers=headers,
  276. )
  277. assert response.status_code == 404
  278. class TestSendSmsToGroup:
  279. async def test_sends_to_each_member_with_automatic_sms(
  280. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  281. two_volunteers, make_org_user, session: Session
  282. ):
  283. v1, v2 = two_volunteers
  284. group = session.get(VolunteerGroup, default_group.id)
  285. group.volunteers.append(session.get(Volunteer, v1.id))
  286. group.volunteers.append(session.get(Volunteer, v2.id))
  287. session.commit()
  288. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  289. response = await client.post(
  290. app.url_path_for("send_sms_to_group", project_id=default_project.id, group_id=default_group.id),
  291. headers=headers,
  292. json={"content": "Bonjour {prenom} {nom} !"},
  293. )
  294. assert response.status_code == 200
  295. contents = sorted(sms["content"] for sms in response.json())
  296. assert contents == ["Bonjour Lancelot Lac !", "Bonjour Yvain Lion !"]
  297. async def test_skips_volunteers_without_automatic_sms(
  298. self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
  299. two_volunteers, make_org_user, session: Session
  300. ):
  301. v1, v2 = two_volunteers
  302. vol2 = session.get(Volunteer, v2.id)
  303. vol2.automatic_sms = False
  304. group = session.get(VolunteerGroup, default_group.id)
  305. group.volunteers.append(session.get(Volunteer, v1.id))
  306. group.volunteers.append(session.get(Volunteer, v2.id))
  307. session.commit()
  308. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  309. response = await client.post(
  310. app.url_path_for("send_sms_to_group", project_id=default_project.id, group_id=default_group.id),
  311. headers=headers,
  312. json={"content": "Bonjour {prenom} !"},
  313. )
  314. assert response.status_code == 200
  315. assert len(response.json()) == 1
  316. assert response.json()[0]["content"] == "Bonjour Yvain !"