test_slots.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. from datetime import UTC, datetime, timedelta
  2. from uuid import uuid4
  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 (
  9. Commission,
  10. CommissionMember,
  11. OrgRole,
  12. Project,
  13. Slot,
  14. SlotTemplate,
  15. Volunteer,
  16. )
  17. from app.tests.conftest import (
  18. default_project_id,
  19. default_slot_id,
  20. default_template_id,
  21. default_volunteer_id,
  22. )
  23. from app.tests.shared_access import SharedProjectAccessTests
  24. pytestmark = pytest.mark.asyncio
  25. @pytest.fixture
  26. def commission_and_member(session: Session, default_public_project: Project, make_org_user):
  27. commission = Commission(project_id=default_public_project.id, name="Bar")
  28. session.add(commission)
  29. session.commit()
  30. user, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
  31. session.add(CommissionMember(commission_id=commission.id, user_id=user.id))
  32. session.commit()
  33. return commission, user, headers
  34. @pytest.fixture
  35. def other_commission(session: Session, default_public_project: Project):
  36. commission = Commission(project_id=default_public_project.id, name="Scene")
  37. session.add(commission)
  38. session.commit()
  39. return commission
  40. @pytest.fixture
  41. def commission_template(
  42. session: Session, default_public_project: Project, commission_and_member
  43. ) -> SlotTemplate:
  44. commission, _, _ = commission_and_member
  45. template = SlotTemplate(
  46. project_id=default_public_project.id, title="Bar template", commission_id=commission.id
  47. )
  48. session.add(template)
  49. session.commit()
  50. return template
  51. @pytest.fixture
  52. def other_commission_template(
  53. session: Session, default_public_project: Project, other_commission
  54. ) -> SlotTemplate:
  55. template = SlotTemplate(
  56. project_id=default_public_project.id,
  57. title="Scene template",
  58. commission_id=other_commission.id,
  59. )
  60. session.add(template)
  61. session.commit()
  62. return template
  63. t1 = datetime(2000, 1, 1, tzinfo=UTC)
  64. SLOT_ROUTES = [
  65. ("GET", "list_project_slots", {"project_id": "PROJECT"}, None),
  66. (
  67. "POST",
  68. "create_slot",
  69. {"project_id": "PROJECT"},
  70. {
  71. "title": "Test",
  72. "starting_time": t1.isoformat(),
  73. "ending_time": (t1 + timedelta(days=1)).isoformat(),
  74. },
  75. ),
  76. (
  77. "POST",
  78. "update_slot",
  79. {"project_id": "PROJECT", "slot_id": default_slot_id},
  80. {"title": "Updated"},
  81. ),
  82. ("DELETE", "delete_slot", {"project_id": "PROJECT", "slot_id": default_slot_id}, None),
  83. ]
  84. class TestSlotCrossCuttingAccess(SharedProjectAccessTests):
  85. @pytest.fixture(params=SLOT_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
  86. def resolved_route(self, request, default_project):
  87. method, endpoint, kwargs, payload = request.param
  88. resolved_kwargs = {
  89. k: (default_project.id if v == "PROJECT" else v) for k, v in kwargs.items()
  90. }
  91. return method, endpoint, resolved_kwargs, payload
  92. class TestListProjectSlots:
  93. async def test_invalid_project_id(
  94. self, client: AsyncClient, default_user_headers: dict, default_user_org_membership
  95. ):
  96. response = await client.get(
  97. app.url_path_for("list_project_slots", project_id="pas un uuid valid"),
  98. headers=default_user_headers,
  99. )
  100. assert response.status_code == 422
  101. async def test_returns_slots(
  102. self,
  103. client: AsyncClient,
  104. default_public_project: Project,
  105. default_user_headers: dict,
  106. default_user_org_membership,
  107. ):
  108. response = await client.get(
  109. app.url_path_for("list_project_slots", project_id=default_project_id),
  110. headers=default_user_headers,
  111. )
  112. assert response.status_code == 200
  113. data = response.json()
  114. assert len(data) == 1
  115. assert data[0]["title"] == "être roi"
  116. assert data[0]["id"] == default_slot_id
  117. assert "created_at" in data[0]
  118. async def test_respo_commission_sees_all_project_slots(
  119. self,
  120. client: AsyncClient,
  121. default_public_project: Project,
  122. commission_and_member,
  123. ):
  124. """respo_commission has read access to the whole plan, not just
  125. their own commission's slots."""
  126. _, _, headers = commission_and_member
  127. response = await client.get(
  128. app.url_path_for("list_project_slots", project_id=default_project_id), headers=headers
  129. )
  130. assert response.status_code == 200
  131. assert len(response.json()) == 1
  132. async def test_cannot_look_other_org_slot(
  133. self,
  134. client: AsyncClient,
  135. default_public_project: Project,
  136. session: Session,
  137. commission_and_member,
  138. other_org_user,
  139. ):
  140. """An admin of a project belonging to a DIFFERENT organization cannot access the project."""
  141. _, headers = other_org_user
  142. response = await client.get(
  143. app.url_path_for("list_project_slots", project_id=default_public_project.id),
  144. headers=headers,
  145. )
  146. assert response.status_code == 403
  147. class TestCreateSlot:
  148. async def test_org_admin_creates(
  149. self,
  150. client: AsyncClient,
  151. default_public_project: Project,
  152. default_user_headers: dict,
  153. default_user_org_membership,
  154. session: Session,
  155. ):
  156. starting_time = datetime(1900, 1, 1)
  157. payload = {
  158. "title": "être mort",
  159. "starting_time": starting_time.isoformat(),
  160. "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
  161. }
  162. response = await client.post(
  163. app.url_path_for("create_slot", project_id=default_project_id),
  164. json=payload,
  165. headers=default_user_headers,
  166. )
  167. assert response.status_code == 200
  168. assert response.json()["id"] != default_slot_id
  169. assert response.json()["title"] == "être mort"
  170. assert response.json()["required_volunteers"] == 0
  171. result = session.execute(select(Slot).where(Slot.project_id == default_project_id))
  172. slots = result.scalars().all()
  173. assert len(slots) > 1
  174. slot = [s for s in slots if s.id != default_slot_id][0]
  175. assert slot.title == "être mort"
  176. assert abs(slot.starting_time - starting_time.replace(tzinfo=UTC)) < timedelta(minutes=30)
  177. del payload["title"]
  178. response = await client.post(
  179. app.url_path_for("create_slot", project_id=default_project_id),
  180. json=payload,
  181. headers=default_user_headers,
  182. )
  183. assert response.status_code == 422
  184. async def test_min_volunteer(
  185. self,
  186. client: AsyncClient,
  187. default_public_project: Project,
  188. default_user_headers: dict,
  189. default_user_org_membership,
  190. session: Session,
  191. ):
  192. starting_time = datetime(1900, 1, 1)
  193. payload = {
  194. "title": "être mort",
  195. "starting_time": starting_time.isoformat(),
  196. "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
  197. "required_volunteers": 2,
  198. }
  199. response = await client.post(
  200. app.url_path_for("create_slot", project_id=default_project_id),
  201. json=payload,
  202. headers=default_user_headers,
  203. )
  204. assert response.status_code == 200
  205. slots = (
  206. session.execute(select(Slot).where(Slot.project_id == default_project_id))
  207. .scalars()
  208. .all()
  209. )
  210. slot = [s for s in slots if s.id != default_slot_id][0]
  211. assert slot.required_volunteers == 2
  212. async def test_respo_benevole_creates_without_template(
  213. self,
  214. client: AsyncClient,
  215. default_public_project: Project,
  216. make_org_user,
  217. ):
  218. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  219. starting_time = datetime(1900, 1, 1)
  220. response = await client.post(
  221. app.url_path_for("create_slot", project_id=default_project_id),
  222. json={
  223. "title": "Benevole slot",
  224. "starting_time": starting_time.isoformat(),
  225. "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
  226. },
  227. headers=headers,
  228. )
  229. assert response.status_code == 200
  230. async def test_respo_commission_creates_with_own_template(
  231. self,
  232. client: AsyncClient,
  233. default_public_project: Project,
  234. commission_and_member,
  235. commission_template,
  236. ):
  237. _, _, headers = commission_and_member
  238. starting_time = datetime(1900, 1, 1)
  239. response = await client.post(
  240. app.url_path_for("create_slot", project_id=default_project_id),
  241. json={
  242. "title": "Bar shift",
  243. "starting_time": starting_time.isoformat(),
  244. "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
  245. "template_id": commission_template.id,
  246. },
  247. headers=headers,
  248. )
  249. assert response.status_code == 200
  250. assert response.json()["template_id"] == commission_template.id
  251. async def test_respo_commission_rejected_with_other_commission_template(
  252. self,
  253. client: AsyncClient,
  254. default_public_project: Project,
  255. commission_and_member,
  256. other_commission_template,
  257. ):
  258. _, _, headers = commission_and_member
  259. starting_time = datetime(1900, 1, 1)
  260. response = await client.post(
  261. app.url_path_for("create_slot", project_id=default_project_id),
  262. json={
  263. "title": "Scene shift",
  264. "starting_time": starting_time.isoformat(),
  265. "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
  266. "template_id": other_commission_template.id,
  267. },
  268. headers=headers,
  269. )
  270. assert response.status_code == 403
  271. class TestUpdateSlot:
  272. async def test_slot_not_found(
  273. self,
  274. client: AsyncClient,
  275. default_public_project: Project,
  276. default_user_headers: dict,
  277. default_user_org_membership,
  278. ):
  279. response = await client.post(
  280. app.url_path_for("update_slot", project_id=default_project_id, slot_id=uuid4()),
  281. json={"title": "x"},
  282. headers=default_user_headers,
  283. )
  284. assert response.status_code == 404
  285. async def test_org_admin_updates_each_field(
  286. self,
  287. client: AsyncClient,
  288. default_public_project: Project,
  289. default_user_headers: dict,
  290. default_user_org_membership,
  291. ):
  292. starting_time = datetime(2000, 1, 1, tzinfo=UTC)
  293. payload = {
  294. "title": "être mort 2 fois",
  295. "starting_time": starting_time.isoformat(),
  296. "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
  297. "required_volunteers": 2,
  298. }
  299. for k, v in payload.items():
  300. response = await client.post(
  301. app.url_path_for(
  302. "update_slot", project_id=default_project_id, slot_id=default_slot_id
  303. ),
  304. json={k: v},
  305. headers=default_user_headers,
  306. )
  307. assert response.status_code == 200
  308. assert response.json()["id"] == default_slot_id
  309. if "time" in k:
  310. assert datetime.fromisoformat(response.json()[k]) == datetime.fromisoformat(v)
  311. else:
  312. assert response.json()[k] == v
  313. async def test_remove_template(
  314. self,
  315. client: AsyncClient,
  316. default_public_project: Project,
  317. default_user_headers: dict,
  318. default_user_org_membership,
  319. session: Session,
  320. ):
  321. response = await client.post(
  322. app.url_path_for("update_slot", project_id=default_project_id, slot_id=default_slot_id),
  323. json={"template_id": default_template_id},
  324. headers=default_user_headers,
  325. )
  326. assert response.status_code == 200
  327. slot = session.execute(
  328. select(Slot).where(Slot.project_id == default_project_id)
  329. ).scalar_one()
  330. assert slot.template_id == default_template_id
  331. response = await client.post(
  332. app.url_path_for("update_slot", project_id=default_project_id, slot_id=default_slot_id),
  333. json={"template_id": ""},
  334. headers=default_user_headers,
  335. )
  336. assert response.status_code == 200
  337. session.refresh(slot)
  338. assert slot.template_id is None
  339. async def test_bad_template_id(
  340. self,
  341. client: AsyncClient,
  342. default_public_project: Project,
  343. default_user_headers: dict,
  344. default_user_org_membership,
  345. ):
  346. path = app.url_path_for(
  347. "update_slot", project_id=default_project_id, slot_id=default_slot_id
  348. )
  349. response = await client.post(
  350. path, json={"template_id": "invalid uuid"}, headers=default_user_headers
  351. )
  352. assert response.status_code == 422
  353. response = await client.post(
  354. path, json={"template_id": str(uuid4())}, headers=default_user_headers
  355. )
  356. assert response.status_code == 400
  357. async def test_assign_template(
  358. self,
  359. client: AsyncClient,
  360. default_public_project: Project,
  361. default_user_headers: dict,
  362. default_user_org_membership,
  363. session: Session,
  364. ):
  365. path = app.url_path_for(
  366. "update_slot", project_id=default_project_id, slot_id=default_slot_id
  367. )
  368. template = SlotTemplate(project_id=default_project_id, title="template 0")
  369. session.add(template)
  370. session.commit()
  371. response = await client.post(
  372. path, json={"template_id": template.id}, headers=default_user_headers
  373. )
  374. assert response.status_code == 200
  375. assert response.json()["template_id"] == template.id
  376. session.refresh(template)
  377. assert len(template.slots) == 1
  378. response = await client.post(path, json={"template_id": None}, headers=default_user_headers)
  379. assert response.status_code == 200
  380. assert response.json()["template_id"] is None
  381. async def test_update_volunteers(
  382. self,
  383. client: AsyncClient,
  384. default_public_project: Project,
  385. default_user_headers: dict,
  386. default_user_org_membership,
  387. session: Session,
  388. ):
  389. path = app.url_path_for(
  390. "update_slot", project_id=default_project_id, slot_id=default_slot_id
  391. )
  392. response = await client.post(path, json={"volunteers": []}, headers=default_user_headers)
  393. assert response.status_code == 200
  394. volunteer = (
  395. session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
  396. .scalars()
  397. .first()
  398. )
  399. assert volunteer.slots_id == []
  400. response = await client.post(
  401. path, json={"volunteers": [default_volunteer_id]}, headers=default_user_headers
  402. )
  403. assert response.status_code == 200
  404. session.refresh(volunteer)
  405. assert volunteer.slots_id == [default_slot_id]
  406. response = await client.post(
  407. path, json={"volunteers": [str(uuid4())]}, headers=default_user_headers
  408. )
  409. assert response.status_code == 400
  410. response = await client.post(
  411. path, json={"volunteers": ["not uuid str"]}, headers=default_user_headers
  412. )
  413. assert response.status_code == 422
  414. async def test_respo_commission_updates_own_slot(
  415. self,
  416. client: AsyncClient,
  417. default_public_project: Project,
  418. commission_and_member,
  419. commission_template,
  420. session: Session,
  421. ):
  422. _, _, headers = commission_and_member
  423. slot = Slot(
  424. project_id=default_project_id,
  425. title="Bar shift",
  426. starting_time=datetime.now(),
  427. ending_time=datetime.now() + timedelta(hours=1),
  428. template_id=commission_template.id,
  429. )
  430. session.add(slot)
  431. session.commit()
  432. response = await client.post(
  433. app.url_path_for("update_slot", project_id=default_project_id, slot_id=slot.id),
  434. json={"title": "Bar shift renamed"},
  435. headers=headers,
  436. )
  437. assert response.status_code == 200
  438. session.refresh(slot)
  439. assert slot.title == "Bar shift renamed"
  440. async def test_respo_commission_rejected_for_other_commission_slot(
  441. self,
  442. client: AsyncClient,
  443. default_public_project: Project,
  444. commission_and_member,
  445. other_commission_template,
  446. session: Session,
  447. ):
  448. _, _, headers = commission_and_member
  449. slot = Slot(
  450. project_id=default_project_id,
  451. title="Scene shift",
  452. starting_time=datetime.now(),
  453. ending_time=datetime.now() + timedelta(hours=1),
  454. template_id=other_commission_template.id,
  455. )
  456. session.add(slot)
  457. session.commit()
  458. response = await client.post(
  459. app.url_path_for("update_slot", project_id=default_project_id, slot_id=slot.id),
  460. json={"title": "Hijacked"},
  461. headers=headers,
  462. )
  463. assert response.status_code == 403
  464. async def test_respo_commission_reassign_to_other_commission_template_rejected(
  465. self,
  466. client: AsyncClient,
  467. default_public_project: Project,
  468. commission_and_member,
  469. commission_template,
  470. other_commission_template,
  471. session: Session,
  472. ):
  473. _, _, headers = commission_and_member
  474. slot = Slot(
  475. project_id=default_project_id,
  476. title="Bar shift",
  477. starting_time=datetime.now(),
  478. ending_time=datetime.now() + timedelta(hours=1),
  479. template_id=commission_template.id,
  480. )
  481. session.add(slot)
  482. session.commit()
  483. response = await client.post(
  484. app.url_path_for("update_slot", project_id=default_project_id, slot_id=slot.id),
  485. json={"template_id": other_commission_template.id},
  486. headers=headers,
  487. )
  488. assert response.status_code == 403
  489. class TestDeleteSlot:
  490. async def test_invalid_slot_id(
  491. self,
  492. client: AsyncClient,
  493. default_public_project: Project,
  494. default_user_headers: dict,
  495. default_user_org_membership,
  496. ):
  497. response = await client.delete(
  498. app.url_path_for("delete_slot", project_id=default_project_id, slot_id="not uidstr"),
  499. headers=default_user_headers,
  500. )
  501. assert response.status_code == 422
  502. async def test_org_admin_deletes(
  503. self,
  504. client: AsyncClient,
  505. default_public_project: Project,
  506. default_user_headers: dict,
  507. default_user_org_membership,
  508. session: Session,
  509. ):
  510. response = await client.delete(
  511. app.url_path_for("delete_slot", project_id=default_project_id, slot_id=default_slot_id),
  512. headers=default_user_headers,
  513. )
  514. assert response.status_code == 200
  515. assert session.get(Slot, default_slot_id) is None
  516. volunteer = (
  517. session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
  518. .scalars()
  519. .first()
  520. )
  521. assert default_slot_id not in volunteer.slots_id
  522. async def test_nonexistent_slot_returns_404(
  523. self,
  524. client: AsyncClient,
  525. default_public_project: Project,
  526. default_user_headers: dict,
  527. default_user_org_membership,
  528. ):
  529. """require_commission_scope resolves the slot before the handler
  530. runs, so a missing slot_id now 404s -- differs from the old bulk
  531. DELETE behavior, which silently returned 200 either way."""
  532. response = await client.delete(
  533. app.url_path_for("delete_slot", project_id=default_project_id, slot_id=uuid4()),
  534. headers=default_user_headers,
  535. )
  536. assert response.status_code == 404
  537. async def test_respo_commission_deletes_own_slot(
  538. self,
  539. client: AsyncClient,
  540. default_public_project: Project,
  541. commission_and_member,
  542. commission_template,
  543. session: Session,
  544. ):
  545. _, _, headers = commission_and_member
  546. slot = Slot(
  547. project_id=default_project_id,
  548. title="Bar shift",
  549. starting_time=datetime.now(),
  550. ending_time=datetime.now() + timedelta(hours=1),
  551. template_id=commission_template.id,
  552. )
  553. session.add(slot)
  554. session.commit()
  555. slot_id = slot.id
  556. session.expunge(slot)
  557. response = await client.delete(
  558. app.url_path_for("delete_slot", project_id=default_project_id, slot_id=slot_id),
  559. headers=headers,
  560. )
  561. assert response.status_code == 200
  562. assert session.get(Slot, slot_id) is None
  563. async def test_respo_commission_rejected_for_other_commission_slot(
  564. self,
  565. client: AsyncClient,
  566. default_public_project: Project,
  567. commission_and_member,
  568. other_commission_template,
  569. session: Session,
  570. ):
  571. _, _, headers = commission_and_member
  572. slot = Slot(
  573. project_id=default_project_id,
  574. title="Scene shift",
  575. starting_time=datetime.now(),
  576. ending_time=datetime.now() + timedelta(hours=1),
  577. template_id=other_commission_template.id,
  578. )
  579. session.add(slot)
  580. session.commit()
  581. response = await client.delete(
  582. app.url_path_for("delete_slot", project_id=default_project_id, slot_id=slot.id),
  583. headers=headers,
  584. )
  585. assert response.status_code == 403
  586. assert session.get(Slot, slot.id) is not None