test_slots.py 23 KB

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