test_volunteer_groups.py 17 KB

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