test_sms.py 11 KB

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