test_slot.py 22 KB

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