test_volunteer.py 16 KB

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