test_tag.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import uuid
  2. from datetime import datetime
  3. import pytest
  4. from httpx import AsyncClient
  5. from sqlalchemy import select
  6. from sqlalchemy.orm import Session
  7. from app.main import app
  8. from app.models import OrgRole, Project, Slot, SlotTag, SlotTemplate
  9. from app.tests.conftest import default_organization_id, default_project_id, default_tag_id
  10. pytestmark = pytest.mark.asyncio
  11. ALL_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
  12. class TestListProjectTags:
  13. async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
  14. response = await client.get(
  15. app.url_path_for("list_project_tags", project_id=default_project_id)
  16. )
  17. assert response.status_code == 401
  18. @pytest.mark.parametrize("role", ALL_ROLES)
  19. async def test_all_roles_can_read(
  20. self,
  21. client: AsyncClient,
  22. default_public_project: Project,
  23. make_org_user,
  24. session: Session,
  25. role,
  26. ):
  27. _, headers = make_org_user(role=role)
  28. response = await client.get(
  29. app.url_path_for("list_project_tags", project_id=default_project_id), headers=headers
  30. )
  31. assert response.status_code == 200
  32. assert len(response.json()) == 1
  33. tag = SlotTag(title="1er tag", project_id=default_project_id)
  34. session.add(tag)
  35. session.commit()
  36. response = await client.get(
  37. app.url_path_for("list_project_tags", project_id=default_project_id), headers=headers
  38. )
  39. assert response.status_code == 200
  40. assert len(response.json()) == 2
  41. async def test_no_membership_forbidden(
  42. self, client: AsyncClient, default_public_project: Project, make_org_user
  43. ):
  44. _, headers = make_org_user(role=None)
  45. response = await client.get(
  46. app.url_path_for("list_project_tags", project_id=default_project_id), headers=headers
  47. )
  48. assert response.status_code == 403
  49. async def test_other_org_member_forbidden(
  50. self, client: AsyncClient, default_public_project: Project, other_org_user
  51. ):
  52. _, headers = other_org_user
  53. response = await client.get(
  54. app.url_path_for("list_project_tags", project_id=default_project_id), headers=headers
  55. )
  56. assert response.status_code == 403
  57. class TestCreateTag:
  58. async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
  59. response = await client.post(app.url_path_for("create_tag", project_id=default_project_id))
  60. assert response.status_code == 401
  61. async def test_invalid_project_id_format(
  62. self, client: AsyncClient, default_public_project: Project, make_org_user
  63. ):
  64. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  65. response = await client.post(
  66. app.url_path_for("create_tag", project_id="default_project_id"), headers=headers
  67. )
  68. assert response.status_code == 422
  69. async def test_project_not_found(
  70. self, client: AsyncClient, default_public_project: Project, make_org_user
  71. ):
  72. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  73. response = await client.post(
  74. app.url_path_for("create_tag", project_id=uuid.uuid4()),
  75. json={"title": "1st tag"},
  76. headers=headers,
  77. )
  78. assert response.status_code == 404
  79. async def test_invalid_payload(
  80. self, client: AsyncClient, default_public_project: Project, make_org_user
  81. ):
  82. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  83. response = await client.post(
  84. app.url_path_for("create_tag", project_id=default_project_id),
  85. json={"titl": "1st tag"},
  86. headers=headers,
  87. )
  88. assert response.status_code == 422
  89. @pytest.mark.parametrize("role", ALL_ROLES)
  90. async def test_all_roles_can_create(
  91. self, client: AsyncClient, default_project: Project, make_org_user, session: Session, role
  92. ):
  93. _, headers = make_org_user(role=role)
  94. response = await client.post(
  95. app.url_path_for("create_tag", project_id=default_project_id),
  96. json={"title": "1st tag"},
  97. headers=headers,
  98. )
  99. assert response.status_code == 200
  100. tag = (
  101. session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
  102. .scalars()
  103. .first()
  104. )
  105. assert tag is not None
  106. assert tag.title == "1st tag"
  107. response = await client.post(
  108. app.url_path_for("create_tag", project_id=default_project_id),
  109. json={"title": "1st tag"},
  110. headers=headers,
  111. )
  112. assert response.status_code == 200
  113. tag_count = len(
  114. session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
  115. .scalars()
  116. .all()
  117. )
  118. assert tag_count == 2
  119. async def test_no_membership_forbidden(
  120. self, client: AsyncClient, default_project: Project, make_org_user
  121. ):
  122. _, headers = make_org_user(role=None)
  123. response = await client.post(
  124. app.url_path_for("create_tag", project_id=default_project_id),
  125. json={"title": "1st tag"},
  126. headers=headers,
  127. )
  128. assert response.status_code == 403
  129. async def test_create_with_template(
  130. self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
  131. ):
  132. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  133. template = SlotTemplate(
  134. project_id=default_project_id, title="coucou", description="ceci est une description"
  135. )
  136. session.add(template)
  137. session.commit()
  138. response = await client.post(
  139. app.url_path_for("create_tag", project_id=default_project_id),
  140. json={"title": "1st tag", "templates": [template.id]},
  141. headers=headers,
  142. )
  143. assert response.status_code == 200
  144. assert response.json()["templates_id"][0] == template.id
  145. session.refresh(template)
  146. assert len(template.tags) > 0
  147. async def test_create_with_template_from_other_project_rejected(
  148. self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
  149. ):
  150. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  151. template = SlotTemplate(
  152. project_id=default_project_id, title="coucou", description="ceci est une description"
  153. )
  154. session.add(template)
  155. session.commit()
  156. other_project = Project(
  157. organization_id=default_organization_id, name="second project", is_public=False
  158. )
  159. session.add(other_project)
  160. session.commit()
  161. response = await client.post(
  162. app.url_path_for("create_tag", project_id=other_project.id),
  163. json={"title": "1st tag", "templates": [template.id]},
  164. headers=headers,
  165. )
  166. assert response.status_code == 400
  167. async def test_other_org_member_forbidden(
  168. self, client: AsyncClient, default_project: Project, other_org_user
  169. ):
  170. _, headers = other_org_user
  171. response = await client.post(
  172. app.url_path_for("create_tag", project_id=default_project_id),
  173. json={"title": "hijacked"},
  174. headers=headers,
  175. )
  176. assert response.status_code == 403
  177. class TestUpdateTag:
  178. async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
  179. url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
  180. response = await client.post(url, json={"title": "royaux"})
  181. assert response.status_code == 401
  182. @pytest.mark.parametrize(
  183. "payload", [{"title": [1.001, 2]}, {"title": {}}, {"templates": ["1", "2"]}]
  184. )
  185. async def test_invalid_payload(
  186. self, client: AsyncClient, default_public_project: Project, make_org_user, payload: dict
  187. ):
  188. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  189. url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
  190. response = await client.post(url, json=payload, headers=headers)
  191. assert response.status_code == 422
  192. @pytest.mark.parametrize("role", ALL_ROLES)
  193. async def test_all_roles_can_update(
  194. self,
  195. client: AsyncClient,
  196. default_public_project: Project,
  197. make_org_user,
  198. session: Session,
  199. role,
  200. ):
  201. _, headers = make_org_user(role=role)
  202. tag = session.get(SlotTag, default_tag_id)
  203. assert tag is not None
  204. url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
  205. response = await client.post(url, json={"title": "new_title"}, headers=headers)
  206. assert response.status_code == 200
  207. session.refresh(tag)
  208. assert tag.title == "new_title"
  209. assert response.json()["title"] == "new_title"
  210. template = SlotTemplate(project_id=default_project_id, title="template")
  211. session.add(template)
  212. session.commit()
  213. response = await client.post(url, json={"templates": [template.id]}, headers=headers)
  214. assert response.status_code == 200
  215. session.refresh(tag)
  216. assert len(tag.templates) == 1
  217. response = await client.post(url, json={"templates": []}, headers=headers)
  218. assert response.status_code == 200
  219. session.refresh(tag)
  220. assert len(tag.templates) == 0
  221. async def test_no_membership_forbidden(
  222. self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
  223. ):
  224. _, headers = make_org_user(role=None)
  225. tag = session.get(SlotTag, default_tag_id)
  226. url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
  227. response = await client.post(url, json={"title": "hijacked"}, headers=headers)
  228. assert response.status_code == 403
  229. session.refresh(tag)
  230. assert tag.title != "hijacked"
  231. async def test_template_from_other_project_rejected(
  232. self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
  233. ):
  234. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  235. tag = session.get(SlotTag, default_tag_id)
  236. assert tag is not None
  237. other_project = Project(
  238. organization_id=default_organization_id, name="other project", is_public=False
  239. )
  240. template = SlotTemplate(title="template 1")
  241. other_project.templates.append(template)
  242. session.add(template)
  243. session.add(other_project)
  244. session.commit()
  245. url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
  246. response = await client.post(url, json={"templates": [template.id]}, headers=headers)
  247. assert response.status_code == 400
  248. async def test_other_org_member_forbidden(
  249. self, client: AsyncClient, default_public_project: Project, other_org_user, session: Session
  250. ):
  251. _, headers = other_org_user
  252. tag = session.get(SlotTag, default_tag_id)
  253. url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
  254. response = await client.post(url, json={"title": "hijacked"}, headers=headers)
  255. assert response.status_code == 403
  256. session.refresh(tag)
  257. assert tag.title != "hijacked"
  258. class TestListTaggedSlot:
  259. @pytest.mark.parametrize("n_slot", [1, 5, 10])
  260. async def test_returns_slots_for_tag(
  261. self,
  262. client: AsyncClient,
  263. default_public_project: Project,
  264. make_org_user,
  265. session: Session,
  266. n_slot: int,
  267. ):
  268. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  269. url = app.url_path_for(
  270. "list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id
  271. )
  272. response = await client.get(url, headers=headers)
  273. assert response.status_code == 200
  274. assert len(response.json()) == 0
  275. tag = session.get(SlotTag, default_tag_id)
  276. assert tag is not None
  277. for i in range(n_slot):
  278. template = SlotTemplate(project_id=default_project_id, title=f"template {i}")
  279. slot = Slot(
  280. project_id=default_project_id,
  281. title=f"Slot {i}",
  282. starting_time=datetime(2024, 9, 9, 12, 2 * i),
  283. ending_time=datetime(2024, 9, 9, 12, 2 * (i + 1)),
  284. )
  285. slot.template = template
  286. tag.templates.append(template)
  287. session.add_all([template, slot])
  288. session.commit()
  289. response = await client.get(url, headers=headers)
  290. assert response.status_code == 200
  291. assert len(response.json()) == n_slot
  292. async def test_no_membership_forbidden(
  293. self, client: AsyncClient, default_public_project: Project, make_org_user
  294. ):
  295. _, headers = make_org_user(role=None)
  296. url = app.url_path_for(
  297. "list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id
  298. )
  299. response = await client.get(url, headers=headers)
  300. assert response.status_code == 403
  301. async def test_other_org_member_forbidden(
  302. self, client: AsyncClient, default_public_project: Project, other_org_user
  303. ):
  304. _, headers = other_org_user
  305. url = app.url_path_for(
  306. "list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id
  307. )
  308. response = await client.get(url, headers=headers)
  309. assert response.status_code == 403
  310. class TestDeleteTag:
  311. async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
  312. url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
  313. response = await client.delete(url)
  314. assert response.status_code == 401
  315. async def test_invalid_tag_id_format(
  316. self, client: AsyncClient, default_public_project: Project, make_org_user
  317. ):
  318. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  319. url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id="default_tag_id")
  320. response = await client.delete(url, headers=headers)
  321. assert response.status_code == 422
  322. async def test_invalid_project_id_format(
  323. self, client: AsyncClient, default_public_project: Project, make_org_user
  324. ):
  325. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  326. url = app.url_path_for(
  327. "delete_tag", project_id="default_volunteer_id", tag_id=default_tag_id
  328. )
  329. response = await client.delete(url, headers=headers)
  330. assert response.status_code == 422
  331. async def test_no_membership_forbidden(
  332. self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
  333. ):
  334. _, headers = make_org_user(role=None)
  335. url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
  336. response = await client.delete(url, headers=headers)
  337. assert response.status_code == 403
  338. assert session.get(SlotTag, default_tag_id) is not None
  339. @pytest.mark.parametrize("role", ALL_ROLES)
  340. async def test_all_roles_can_delete(
  341. self,
  342. client: AsyncClient,
  343. default_public_project: Project,
  344. make_org_user,
  345. session: Session,
  346. role,
  347. ):
  348. _, headers = make_org_user(role=role)
  349. url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
  350. response = await client.delete(url, headers=headers)
  351. assert response.status_code == 200
  352. assert session.get(SlotTag, default_tag_id) is None
  353. async def test_idempotent_delete(
  354. self, client: AsyncClient, default_public_project: Project, make_org_user
  355. ):
  356. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  357. url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
  358. response = await client.delete(url, headers=headers)
  359. assert response.status_code == 200
  360. response = await client.delete(url, headers=headers)
  361. assert response.status_code == 200
  362. async def test_delete_random_uuid(
  363. self, client: AsyncClient, default_public_project: Project, make_org_user
  364. ):
  365. """tags.py still uses bulk DELETE with no existence check -- same
  366. idempotent-200-on-missing pattern as volunteers.py/sms.py, not the
  367. 404-via-dependency-resolution used by slots.py/templates.py."""
  368. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  369. url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=uuid.uuid4())
  370. response = await client.delete(url, headers=headers)
  371. assert response.status_code == 200
  372. async def test_other_org_member_forbidden(
  373. self, client: AsyncClient, default_public_project: Project, other_org_user, session: Session
  374. ):
  375. _, headers = other_org_user
  376. url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
  377. response = await client.delete(url, headers=headers)
  378. assert response.status_code == 403
  379. assert session.get(SlotTag, default_tag_id) is not None