test_sms.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import uuid
  2. from datetime import UTC, 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, Sms, Volunteer
  9. from app.tests.conftest import default_project_id, default_sms_id, default_volunteer_id
  10. pytestmark = pytest.mark.asyncio
  11. ALL_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
  12. class TestListProjectSms:
  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_sms", project_id=default_project_id)
  16. )
  17. assert response.status_code == 401
  18. async def test_project_not_found(self, client: AsyncClient, make_org_user):
  19. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  20. response = await client.get(
  21. app.url_path_for("list_project_sms", project_id=uuid.uuid4()), headers=headers
  22. )
  23. assert response.status_code == 404
  24. async def test_invalid_project_id_format(self, client: AsyncClient, make_org_user):
  25. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  26. response = await client.get(
  27. app.url_path_for("list_project_sms", project_id="pas un uuid valid"), headers=headers
  28. )
  29. assert response.status_code == 422
  30. @pytest.mark.parametrize("role", ALL_ROLES)
  31. async def test_all_roles_can_read(
  32. self, client: AsyncClient, default_public_project: Project, make_org_user, role
  33. ):
  34. _, headers = make_org_user(role=role)
  35. response = await client.get(
  36. app.url_path_for("list_project_sms", project_id=default_project_id), headers=headers
  37. )
  38. assert response.status_code == 200
  39. data = response.json()
  40. assert len(data) == 1
  41. assert data[0]["id"] == default_sms_id
  42. async def test_no_membership_forbidden(
  43. self, client: AsyncClient, default_public_project: Project, make_org_user
  44. ):
  45. _, headers = make_org_user(role=None)
  46. response = await client.get(
  47. app.url_path_for("list_project_sms", project_id=default_project_id), headers=headers
  48. )
  49. assert response.status_code == 403
  50. class TestCreateSms:
  51. async def test_requires_auth(self, client: AsyncClient):
  52. response = await client.post(app.url_path_for("create_sms", project_id=default_project_id))
  53. assert response.status_code == 401
  54. async def test_project_not_found(self, client: AsyncClient, make_org_user):
  55. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  56. payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
  57. response = await client.post(
  58. app.url_path_for("create_sms", project_id=uuid.uuid4()), json=payload, headers=headers
  59. )
  60. assert response.status_code == 404
  61. @pytest.mark.parametrize("role", ALL_ROLES)
  62. async def test_all_roles_can_create(
  63. self,
  64. client: AsyncClient,
  65. default_public_project: Project,
  66. make_org_user,
  67. session: Session,
  68. role,
  69. ):
  70. _, headers = make_org_user(role=role)
  71. payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
  72. before_creation_time = datetime.now(UTC)
  73. response = await client.post(
  74. app.url_path_for("create_sms", project_id=default_project_id),
  75. json=payload,
  76. headers=headers,
  77. )
  78. assert response.status_code == 200
  79. assert response.json()["content"] == "sms_content"
  80. sms = (
  81. session.execute(
  82. select(Sms).where(
  83. (Sms.project_id == default_project_id) & (Sms.id != default_sms_id)
  84. )
  85. )
  86. .scalars()
  87. .first()
  88. )
  89. assert sms is not None
  90. assert sms.content == "sms_content"
  91. assert before_creation_time < sms.sending_time
  92. assert sms.sending_time < datetime.now(UTC)
  93. async def test_no_membership_forbidden(
  94. self, client: AsyncClient, default_public_project: Project, make_org_user
  95. ):
  96. _, headers = make_org_user(role=None)
  97. payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
  98. response = await client.post(
  99. app.url_path_for("create_sms", project_id=default_project_id),
  100. json=payload,
  101. headers=headers,
  102. )
  103. assert response.status_code == 403
  104. async def test_invalid_payload(
  105. self, client: AsyncClient, default_public_project: Project, make_org_user
  106. ):
  107. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  108. payload = {"phone_number": "06 75 75 75 75 "} # missing content
  109. response = await client.post(
  110. app.url_path_for("create_sms", project_id=default_project_id),
  111. json=payload,
  112. headers=headers,
  113. )
  114. assert response.status_code == 422
  115. async def test_forbidden_other_org(
  116. self, client: AsyncClient, default_public_project: Project, other_org_user
  117. ):
  118. _, headers = other_org_user
  119. response = await client.post(
  120. app.url_path_for("create_sms", project_id=default_project_id),
  121. headers=headers,
  122. )
  123. assert response.status_code == 403
  124. class TestUpdateSms:
  125. async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
  126. response = await client.post(
  127. app.url_path_for("update_sms", project_id=default_project_id, sms_id=default_sms_id)
  128. )
  129. assert response.status_code == 401
  130. async def test_invalid_payload(
  131. self, client: AsyncClient, default_public_project: Project, make_org_user
  132. ):
  133. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  134. response = await client.post(
  135. app.url_path_for("update_sms", project_id=default_project_id, sms_id=default_sms_id),
  136. json={"volunteer_id": True},
  137. headers=headers,
  138. )
  139. assert response.status_code == 422
  140. async def test_project_not_found(
  141. self, client: AsyncClient, default_public_project: Project, make_org_user
  142. ):
  143. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  144. payload = {
  145. "volunteer_id": default_volunteer_id,
  146. "phone_number": "06 75 75 75 75 ",
  147. "content": "sms_content",
  148. "sending_time": datetime(2024, 5, 17, tzinfo=UTC).isoformat(),
  149. }
  150. response = await client.post(
  151. app.url_path_for("update_sms", project_id=uuid.uuid4(), sms_id=default_sms_id),
  152. json=payload,
  153. headers=headers,
  154. )
  155. assert response.status_code == 404
  156. async def test_sms_not_found(
  157. self, client: AsyncClient, default_public_project: Project, make_org_user
  158. ):
  159. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  160. payload = {
  161. "volunteer_id": default_volunteer_id,
  162. "phone_number": "06 75 75 75 75 ",
  163. "content": "sms_content",
  164. "sending_time": datetime(2024, 5, 17, tzinfo=UTC).isoformat(),
  165. }
  166. response = await client.post(
  167. app.url_path_for("update_sms", project_id=default_project_id, sms_id=uuid.uuid4()),
  168. json=payload,
  169. headers=headers,
  170. )
  171. assert response.status_code == 404
  172. async def test_invalid_project_id_format(
  173. self, client: AsyncClient, default_public_project: Project, make_org_user
  174. ):
  175. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  176. response = await client.post(
  177. app.url_path_for("update_sms", project_id="pas un uuid valid", sms_id=default_sms_id),
  178. json={"content": "x"},
  179. headers=headers,
  180. )
  181. assert response.status_code == 422
  182. @pytest.mark.parametrize("role", ALL_ROLES)
  183. async def test_all_roles_can_update_each_field(
  184. self, client: AsyncClient, default_public_project: Project, make_org_user, role
  185. ):
  186. _, headers = make_org_user(role=role)
  187. payload = {
  188. "volunteer_id": default_volunteer_id,
  189. "phone_number": "06 75 75 75 75 ",
  190. "content": "sms_content",
  191. "sending_time": datetime(2024, 5, 17, tzinfo=UTC).isoformat(),
  192. }
  193. for k, v in payload.items():
  194. response = await client.post(
  195. app.url_path_for(
  196. "update_sms", project_id=default_project_id, sms_id=default_sms_id
  197. ),
  198. json={k: v},
  199. headers=headers,
  200. )
  201. assert response.status_code == 200
  202. assert response.json()["id"] == default_sms_id
  203. if "time" in k:
  204. parsed_time = datetime.fromisoformat(response.json()[k])
  205. assert parsed_time == datetime.fromisoformat(v)
  206. else:
  207. assert response.json()[k] == v
  208. async def test_no_membership_forbidden(
  209. self, client: AsyncClient, default_public_project: Project, make_org_user
  210. ):
  211. _, headers = make_org_user(role=None)
  212. response = await client.post(
  213. app.url_path_for("update_sms", project_id=default_project_id, sms_id=default_sms_id),
  214. json={"content": "hijacked"},
  215. headers=headers,
  216. )
  217. assert response.status_code == 403
  218. async def test_forbidden_other_org(
  219. self, client: AsyncClient, default_public_project: Project, other_org_user
  220. ):
  221. _, headers = other_org_user
  222. response = await client.post(
  223. app.url_path_for("update_sms", project_id=default_project_id, sms_id=default_sms_id),
  224. json={"content": "hijacked"},
  225. headers=headers,
  226. )
  227. assert response.status_code == 403
  228. class TestDeleteSms:
  229. async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
  230. response = await client.delete(
  231. app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id)
  232. )
  233. assert response.status_code == 401
  234. async def test_forbidden_other_org(
  235. self, client: AsyncClient, default_public_project: Project, other_org_user
  236. ):
  237. _, headers = other_org_user
  238. response = await client.delete(
  239. app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id),
  240. headers=headers,
  241. )
  242. assert response.status_code == 403
  243. async def test_invalid_sms_id_format(
  244. self, client: AsyncClient, default_public_project: Project, make_org_user
  245. ):
  246. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  247. response = await client.delete(
  248. app.url_path_for("delete_sms", project_id=default_project_id, sms_id="not uidstr"),
  249. headers=headers,
  250. )
  251. assert response.status_code == 422
  252. async def test_no_membership_forbidden(
  253. self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
  254. ):
  255. _, headers = make_org_user(role=None)
  256. response = await client.delete(
  257. app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id),
  258. headers=headers,
  259. )
  260. assert response.status_code == 403
  261. assert session.get(Sms, default_sms_id) is not None
  262. @pytest.mark.parametrize("role", ALL_ROLES)
  263. async def test_all_roles_can_delete(
  264. self,
  265. client: AsyncClient,
  266. default_public_project: Project,
  267. make_org_user,
  268. session: Session,
  269. role,
  270. ):
  271. _, headers = make_org_user(role=role)
  272. response = await client.delete(
  273. app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id),
  274. headers=headers,
  275. )
  276. assert response.status_code == 200
  277. assert session.get(Sms, default_sms_id) is None
  278. async def test_idempotent_delete(
  279. self, client: AsyncClient, default_public_project: Project, make_org_user
  280. ):
  281. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  282. url = app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id)
  283. response = await client.delete(url, headers=headers)
  284. assert response.status_code == 200
  285. response = await client.delete(url, headers=headers)
  286. assert response.status_code == 200
  287. async def test_delete_random_uuid(
  288. self, client: AsyncClient, default_public_project: Project, make_org_user
  289. ):
  290. """sms.py still uses bulk DELETE without an existence check --
  291. matches volunteers.py's behavior, not slots.py/templates.py's 404.
  292. Same cross-router inconsistency flagged for delete_volunteer."""
  293. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  294. response = await client.delete(
  295. app.url_path_for("delete_sms", project_id=default_project_id, sms_id=uuid.uuid4()),
  296. headers=headers,
  297. )
  298. assert response.status_code == 200
  299. async def test_delete_cascades_from_volunteer(
  300. self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
  301. ):
  302. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  303. volunteer = (
  304. session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
  305. .scalars()
  306. .first()
  307. )
  308. assert len(volunteer.sms) == 0
  309. sms = session.get(Sms, default_sms_id)
  310. sms.volunteer_id = default_volunteer_id
  311. session.commit()
  312. session.refresh(volunteer)
  313. assert len(volunteer.sms) == 1, "Sms not added to volunteer"
  314. session.expunge(sms)
  315. response = await client.delete(
  316. app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id),
  317. headers=headers,
  318. )
  319. assert response.status_code == 200
  320. assert session.get(Sms, default_sms_id) is None
  321. session.refresh(volunteer)
  322. assert len(volunteer.sms) == 0, "Sms should be deleted"
  323. volunteer_after = (
  324. session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
  325. .scalars()
  326. .first()
  327. )
  328. assert volunteer_after is not None, "Volunteer should not be deleted"
  329. assert len(volunteer_after.sms) == 0