test_volunteer_groups.py 15 KB

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