test_volunteers.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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.main import app
  7. from app.models import OrgRole, Project, Slot, Sms, Volunteer
  8. from app.tests.conftest import default_project_id, default_slot_id, default_volunteer_id
  9. from app.tests.shared_access import SharedProjectAccessTests
  10. pytestmark = pytest.mark.asyncio
  11. WRITE_FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
  12. route_kwargs = {"project_id": default_project_id}
  13. route_kwargs_2 = {**route_kwargs, "volunteer_id": default_volunteer_id}
  14. VOLUNTEER_ROUTES = [
  15. ("GET", "list_project_volunteers", route_kwargs, None),
  16. ("POST", "create_volunteer", route_kwargs, {"name": "Test", "email": "a@free.fr"}),
  17. ("POST", "update_volunteer", route_kwargs_2, {"name": "Updated"}),
  18. ("DELETE", "delete_volunteer", route_kwargs_2, None),
  19. ]
  20. class TestVolunteerCrossCuttingAccess(SharedProjectAccessTests):
  21. @pytest.fixture(params=VOLUNTEER_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
  22. def resolved_route(self, request, default_project):
  23. return request.param
  24. class TestListVolunteer:
  25. @pytest.mark.parametrize(
  26. "role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
  27. )
  28. async def test_readable_by_all_org_roles(
  29. self,
  30. client: AsyncClient,
  31. default_public_project: Project,
  32. make_org_user,
  33. role,
  34. ):
  35. """respo_commission can read volunteers -- needs visibility into who
  36. they'll be working with -- even though they can't manage them."""
  37. _, headers = make_org_user(role=role)
  38. response = await client.get(
  39. app.url_path_for("list_project_volunteers", project_id=default_project_id),
  40. headers=headers,
  41. )
  42. assert response.status_code == 200
  43. data = response.json()
  44. assert len(data) == 1
  45. assert data[0]["name"] == "Arthur"
  46. assert data[0]["id"] == default_volunteer_id
  47. assert "created_at" in data[0]
  48. async def test_no_membership_forbidden(
  49. self,
  50. client: AsyncClient,
  51. default_public_project: Project,
  52. make_org_user,
  53. ):
  54. _, headers = make_org_user(role=None)
  55. response = await client.get(
  56. app.url_path_for("list_project_volunteers", project_id=default_project_id),
  57. headers=headers,
  58. )
  59. assert response.status_code == 403
  60. class TestCreateVolunteer:
  61. async def test_invalid_payload(
  62. self,
  63. client: AsyncClient,
  64. default_public_project: Project,
  65. make_org_user,
  66. ):
  67. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  68. payload = {"email": "lancelot@dulac.fr", "phone_number": "03 14 15 92 65"}
  69. response = await client.post(
  70. app.url_path_for("create_volunteer", project_id=default_project_id),
  71. json=payload,
  72. headers=headers,
  73. )
  74. assert response.status_code == 422
  75. @pytest.mark.parametrize("role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE])
  76. async def test_write_roles_create(
  77. self,
  78. client: AsyncClient,
  79. default_public_project: Project,
  80. make_org_user,
  81. session: Session,
  82. role,
  83. ):
  84. _, headers = make_org_user(role=role)
  85. payload = {
  86. "name": "Lancelot",
  87. "email": "lancelot@dulac.fr",
  88. "phone_number": "03 14 15 92 65",
  89. }
  90. response = await client.post(
  91. app.url_path_for("create_volunteer", project_id=default_project_id),
  92. json=payload,
  93. headers=headers,
  94. )
  95. assert response.status_code == 200
  96. assert response.json()["id"] != default_project_id
  97. assert response.json()["name"] == "Lancelot"
  98. assert response.json()["comment"] == ""
  99. volunteers = (
  100. session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
  101. .scalars()
  102. .all()
  103. )
  104. assert len(volunteers) > 1
  105. @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
  106. async def test_read_only_roles_forbidden(
  107. self,
  108. client: AsyncClient,
  109. default_public_project: Project,
  110. make_org_user,
  111. role,
  112. ):
  113. _, headers = make_org_user(role=role)
  114. payload = {
  115. "name": "Lancelot",
  116. "email": "lancelot@dulac.fr",
  117. "phone_number": "03 14 15 92 65",
  118. }
  119. response = await client.post(
  120. app.url_path_for("create_volunteer", project_id=default_project_id),
  121. json=payload,
  122. headers=headers,
  123. )
  124. assert response.status_code == 403
  125. async def test_create_with_slots(
  126. self,
  127. client: AsyncClient,
  128. default_public_project: Project,
  129. make_org_user,
  130. session: Session,
  131. ):
  132. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  133. payload = {
  134. "name": "Lancelot",
  135. "email": "lancelot@dulac.fr",
  136. "phone_number": "03 14 15 92 65",
  137. "slots": [default_slot_id],
  138. }
  139. response = await client.post(
  140. app.url_path_for("create_volunteer", project_id=default_project_id),
  141. json=payload,
  142. headers=headers,
  143. )
  144. assert response.status_code == 200
  145. volunteers = (
  146. session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
  147. .scalars()
  148. .all()
  149. )
  150. new_volunteer = [v for v in volunteers if v.id != default_volunteer_id][0]
  151. assert len(new_volunteer.slots) == 1
  152. async def test_create_with_comment(
  153. self,
  154. client: AsyncClient,
  155. default_public_project: Project,
  156. make_org_user,
  157. session: Session,
  158. ):
  159. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  160. payload = {
  161. "name": "Lancelot",
  162. "email": "lancelot@dulac.fr",
  163. "phone_number": "03 14 15 92 65",
  164. "comment": "it's a knight",
  165. }
  166. response = await client.post(
  167. app.url_path_for("create_volunteer", project_id=default_project_id),
  168. json=payload,
  169. headers=headers,
  170. )
  171. assert response.status_code == 200
  172. assert response.json()["comment"] != ""
  173. volunteer = session.get(Volunteer, response.json()["id"])
  174. assert volunteer.comment == "it's a knight"
  175. class TestUpdateVolunteer:
  176. async def test_invalid_volunteer_id(
  177. self,
  178. client: AsyncClient,
  179. default_public_project: Project,
  180. make_org_user,
  181. ):
  182. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  183. payload = {"name": "Lancelot", "email": "l@dulac.fr", "phone_number": "0314159265"}
  184. response = await client.post(
  185. app.url_path_for(
  186. "update_volunteer", project_id=default_project_id, volunteer_id=uuid.uuid4()
  187. ),
  188. json=payload,
  189. headers=headers,
  190. )
  191. assert response.status_code == 404
  192. @pytest.mark.parametrize("role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE])
  193. async def test_write_roles_update_each_field(
  194. self,
  195. client: AsyncClient,
  196. default_public_project: Project,
  197. make_org_user,
  198. role,
  199. ):
  200. _, headers = make_org_user(role=role)
  201. payload = {
  202. "name": "Lancelot",
  203. "email": "lancelot@dulac.fr",
  204. "phone_number": "03 14 15 92 65",
  205. "automatic_sms": False,
  206. "comment": "new comment",
  207. }
  208. for k, v in payload.items():
  209. response = await client.post(
  210. app.url_path_for(
  211. "update_volunteer",
  212. project_id=default_project_id,
  213. volunteer_id=default_volunteer_id,
  214. ),
  215. json={k: v},
  216. headers=headers,
  217. )
  218. assert response.status_code == 200
  219. assert response.json()["id"] == default_volunteer_id
  220. assert response.json()[k] == v
  221. @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
  222. async def test_read_only_roles_forbidden(
  223. self,
  224. client: AsyncClient,
  225. default_public_project: Project,
  226. make_org_user,
  227. role,
  228. ):
  229. _, headers = make_org_user(role=role)
  230. response = await client.post(
  231. app.url_path_for(
  232. "update_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
  233. ),
  234. json={"comment": "hijacked"},
  235. headers=headers,
  236. )
  237. assert response.status_code == 403
  238. async def test_update_slots(
  239. self,
  240. client: AsyncClient,
  241. default_public_project: Project,
  242. make_org_user,
  243. session: Session,
  244. ):
  245. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  246. url = app.url_path_for(
  247. "update_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
  248. )
  249. response = await client.post(url, json={"slots": []}, headers=headers)
  250. assert response.status_code == 200
  251. slot = session.execute(select(Slot).where(Slot.id == default_slot_id)).scalars().first()
  252. assert slot.volunteers_id == []
  253. response = await client.post(url, json={"slots": [default_slot_id]}, headers=headers)
  254. assert response.status_code == 200
  255. session.refresh(slot)
  256. assert slot.volunteers_id == [default_volunteer_id]
  257. response = await client.post(url, json={"slots": [str(uuid.uuid4())]}, headers=headers)
  258. assert response.status_code == 400
  259. response = await client.post(url, json={"slots": ["not uuid str"]}, headers=headers)
  260. assert response.status_code == 422
  261. class TestDeleteVolunteer:
  262. @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
  263. async def test_read_only_roles_forbidden(
  264. self,
  265. client: AsyncClient,
  266. default_public_project: Project,
  267. make_org_user,
  268. session: Session,
  269. role,
  270. ):
  271. _, headers = make_org_user(role=role)
  272. response = await client.delete(
  273. app.url_path_for(
  274. "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
  275. ),
  276. headers=headers,
  277. )
  278. assert response.status_code == 403
  279. assert session.get(Volunteer, default_volunteer_id) is not None
  280. async def test_org_admin_deletes(
  281. self,
  282. client: AsyncClient,
  283. default_public_project: Project,
  284. make_org_user,
  285. session: Session,
  286. ):
  287. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  288. response = await client.delete(
  289. app.url_path_for(
  290. "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
  291. ),
  292. headers=headers,
  293. )
  294. assert response.status_code == 200
  295. assert session.get(Volunteer, default_volunteer_id) is None
  296. slot = session.execute(select(Slot).where(Slot.id == default_slot_id)).scalars().first()
  297. assert default_volunteer_id not in slot.volunteers_id
  298. async def test_idempotent_delete(
  299. self,
  300. client: AsyncClient,
  301. default_public_project: Project,
  302. make_org_user,
  303. ):
  304. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  305. url = app.url_path_for(
  306. "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
  307. )
  308. response = await client.delete(url, headers=headers)
  309. assert response.status_code == 200
  310. response = await client.delete(url, headers=headers)
  311. assert response.status_code == 200
  312. async def test_delete_random_uuid(
  313. self,
  314. client: AsyncClient,
  315. default_public_project: Project,
  316. make_org_user,
  317. ):
  318. """Unlike delete_slot/delete_template, this still bulk-DELETEs
  319. without an existence check via a Depends() resolver -- so a
  320. nonexistent volunteer_id stays a silent 200, matching the original
  321. behavior. If you want 404-on-missing consistency across all three
  322. routers, this needs the same get()-then-check pattern slots.py uses."""
  323. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  324. response = await client.delete(
  325. app.url_path_for(
  326. "delete_volunteer", project_id=default_project_id, volunteer_id=uuid.uuid4()
  327. ),
  328. headers=headers,
  329. )
  330. assert response.status_code == 200
  331. async def test_invalid_uuid_format(
  332. self,
  333. client: AsyncClient,
  334. default_public_project: Project,
  335. make_org_user,
  336. ):
  337. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  338. response = await client.delete(
  339. app.url_path_for(
  340. "delete_volunteer", project_id=default_project_id, volunteer_id="not uidstr"
  341. ),
  342. headers=headers,
  343. )
  344. assert response.status_code == 422
  345. async def test_delete_cascades_sms(
  346. self,
  347. client: AsyncClient,
  348. default_public_project: Project,
  349. make_org_user,
  350. session: Session,
  351. ):
  352. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  353. sms = Sms(
  354. project_id=default_project_id,
  355. content="coucou",
  356. phone_number="02 66 66 66 66 66",
  357. volunteer_id=default_volunteer_id,
  358. )
  359. session.add(sms)
  360. session.commit()
  361. sms_id = sms.id
  362. response = await client.delete(
  363. app.url_path_for(
  364. "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
  365. ),
  366. headers=headers,
  367. )
  368. assert response.status_code == 200
  369. assert session.get(Volunteer, default_volunteer_id) is None
  370. assert session.execute(select(Sms).where(Sms.id == sms_id)).scalar_one_or_none() is None