test_volunteer_groups.py 16 KB

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