test_sms_batch.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. from datetime import datetime, timedelta
  2. import pytest
  3. from httpx import AsyncClient
  4. from sqlalchemy import select
  5. from sqlalchemy.orm import Session
  6. from app.main import app
  7. from app.models import (
  8. Commission,
  9. OrgRole,
  10. Project,
  11. Slot,
  12. SlotTemplate,
  13. Sms,
  14. User,
  15. Volunteer,
  16. )
  17. from app.tests.conftest import default_sms_id, default_volunteer_id
  18. pytestmark = pytest.mark.asyncio
  19. WRITE_FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
  20. def get_arthur(session) -> Volunteer:
  21. result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
  22. arthur = result.scalars().first()
  23. assert arthur is not None
  24. return arthur
  25. @pytest.fixture()
  26. def user_headers(make_org_user) -> dict:
  27. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  28. return headers
  29. class TestCreateSmsBatchAuth:
  30. async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
  31. response = await client.post(
  32. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  33. json={"is_public": False},
  34. )
  35. assert response.status_code == 401
  36. async def test_forbidden_other_org(
  37. self, client: AsyncClient, default_public_project: Project, other_org_user
  38. ):
  39. _, headers = other_org_user
  40. response = await client.post(
  41. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  42. json={"is_public": False},
  43. headers=headers,
  44. )
  45. assert response.status_code == 403
  46. @pytest.mark.parametrize("role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE])
  47. async def test_write_roles_allowed(
  48. self, client: AsyncClient, default_public_project: Project, make_org_user, role
  49. ):
  50. _, headers = make_org_user(role=role)
  51. response = await client.post(
  52. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  53. headers=headers,
  54. json={"template": "Bonjour!"},
  55. )
  56. assert response.status_code == 200
  57. @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
  58. async def test_read_only_roles_forbidden(
  59. self, client: AsyncClient, default_public_project: Project, make_org_user, role
  60. ):
  61. _, headers = make_org_user(role=role)
  62. response = await client.post(
  63. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  64. headers=headers,
  65. json={"template": "Bonjour!"},
  66. )
  67. assert response.status_code == 403
  68. async def test_other_org_member_forbidden(
  69. self, client: AsyncClient, default_public_project: Project, other_org_user
  70. ):
  71. _, headers = other_org_user
  72. response = await client.post(
  73. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  74. headers=headers,
  75. json={"template": "Bonjour!"},
  76. )
  77. assert response.status_code == 403
  78. class TestCreateSmsBatchContent:
  79. """Content-generation behavior -- exercised as respo_benevole throughout,
  80. since role gating itself is covered separately in TestCreateSmsBatchAuth."""
  81. async def test_basic(
  82. self,
  83. client: AsyncClient,
  84. user_headers: dict,
  85. session: Session,
  86. default_public_project: Project,
  87. ):
  88. slot = Slot(
  89. project_id=default_public_project.id,
  90. title="replanter excalibure",
  91. starting_time=datetime.now() + timedelta(minutes=30),
  92. ending_time=datetime.now() + timedelta(minutes=60),
  93. )
  94. slot.volunteers.append(get_arthur(session))
  95. session.add(slot)
  96. session.commit()
  97. response = await client.post(
  98. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  99. headers=user_headers,
  100. json={"template": "Bonjour {prenom}!\n{titre}"},
  101. )
  102. assert response.status_code == 200
  103. sms = (
  104. session.execute(
  105. select(Sms).where(
  106. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  107. )
  108. )
  109. .scalars()
  110. .first()
  111. )
  112. assert sms is not None
  113. assert sms.content == "Bonjour Arthur!\nreplanter excalibure"
  114. async def test_multiple_volunteer(
  115. self,
  116. client: AsyncClient,
  117. user_headers: dict,
  118. session: Session,
  119. default_public_project: Project,
  120. ):
  121. slot = Slot(
  122. project_id=default_public_project.id,
  123. title="replanter excalibure",
  124. starting_time=datetime.now() + timedelta(minutes=30),
  125. ending_time=datetime.now() + timedelta(minutes=60),
  126. )
  127. slot.volunteers.append(get_arthur(session))
  128. slot.volunteers.append(
  129. Volunteer(
  130. project_id=default_public_project.id,
  131. name="benevole 2",
  132. surname="echo",
  133. phone_number="t",
  134. email="",
  135. automatic_sms=True,
  136. )
  137. )
  138. session.add(slot)
  139. session.commit()
  140. response = await client.post(
  141. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  142. headers=user_headers,
  143. json={"template": "Bonjour {prenom}!\n{titre}"},
  144. )
  145. assert response.status_code == 200
  146. result = session.execute(
  147. select(Sms).where(
  148. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  149. )
  150. )
  151. count_sms = sum(1 for _ in result.fetchall())
  152. assert count_sms == 2
  153. @pytest.mark.parametrize("automatic_sms,expected", [(True, True), (False, False)])
  154. async def test_volunteer_settings(
  155. self,
  156. client: AsyncClient,
  157. user_headers: dict,
  158. session: Session,
  159. default_public_project: Project,
  160. automatic_sms: bool,
  161. expected: bool,
  162. ):
  163. slot = Slot(
  164. project_id=default_public_project.id,
  165. title="replanter excalibure",
  166. starting_time=datetime.now() + timedelta(minutes=30),
  167. ending_time=datetime.now() + timedelta(minutes=60),
  168. )
  169. arthur = get_arthur(session)
  170. arthur.automatic_sms = automatic_sms
  171. slot.volunteers.append(arthur)
  172. session.add(slot)
  173. session.commit()
  174. response = await client.post(
  175. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  176. headers=user_headers,
  177. json={"template": "Bonjour {prenom}!\n{titre}"},
  178. )
  179. assert response.status_code == 200
  180. sms = (
  181. session.execute(
  182. select(Sms).where(
  183. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  184. )
  185. )
  186. .scalars()
  187. .first()
  188. )
  189. assert (sms is not None) == expected
  190. async def test_only_future_slot(
  191. self,
  192. client: AsyncClient,
  193. user_headers: dict,
  194. session: Session,
  195. default_public_project: Project,
  196. ):
  197. slot = Slot(
  198. project_id=default_public_project.id,
  199. title="replanter excalibure",
  200. starting_time=datetime.now() + timedelta(minutes=-30),
  201. ending_time=datetime.now() + timedelta(minutes=60),
  202. )
  203. slot.volunteers.append(get_arthur(session))
  204. session.add(slot)
  205. session.commit()
  206. response = await client.post(
  207. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  208. headers=user_headers,
  209. json={"template": "Bonjour {prenom}!\n{titre}"},
  210. )
  211. assert response.status_code == 200
  212. sms = (
  213. session.execute(
  214. select(Sms).where(
  215. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  216. )
  217. )
  218. .scalars()
  219. .first()
  220. )
  221. assert sms is None
  222. @pytest.mark.parametrize("delta_t,expected", [(32, False), (28, True)])
  223. async def test_time_delta(
  224. self,
  225. client: AsyncClient,
  226. user_headers: dict,
  227. session: Session,
  228. default_public_project: Project,
  229. delta_t: int,
  230. expected,
  231. ):
  232. slot = Slot(
  233. project_id=default_public_project.id,
  234. title="replanter excalibure",
  235. starting_time=datetime.now() + timedelta(minutes=30),
  236. ending_time=datetime.now() + timedelta(minutes=60),
  237. )
  238. slot.volunteers.append(get_arthur(session))
  239. session.add(slot)
  240. session.commit()
  241. response = await client.post(
  242. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  243. headers=user_headers,
  244. json={"template": "Bonjour {prenom}!\n{titre}", "delta_t": delta_t},
  245. )
  246. assert response.status_code == 200
  247. sms = (
  248. session.execute(
  249. select(Sms).where(
  250. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  251. )
  252. )
  253. .scalars()
  254. .first()
  255. )
  256. assert (sms is not None) == expected
  257. starting_time = datetime.now() + timedelta(minutes=30)
  258. class TestSmsPlaceholders:
  259. @pytest.mark.parametrize(
  260. "template,expected",
  261. [
  262. ("no escape", "no escape"),
  263. ("{titre}", "replanter excalibure"),
  264. ("{prenom}", "Arthur"),
  265. ("{prenom} {nom}", "Arthur Pandragon"),
  266. ("{debut}", starting_time.strftime("%Hh%M")),
  267. ("{titre} {description}, {respo}", "replanter excalibure {description}, {respo}"),
  268. ],
  269. )
  270. async def test_content_no_template(
  271. self,
  272. client: AsyncClient,
  273. user_headers: dict,
  274. session: Session,
  275. default_public_project: Project,
  276. template: str,
  277. expected: str,
  278. ):
  279. """No SlotTemplate attached at all -- {respo}/{description} placeholders
  280. are left untouched, since the replace only happens when slot.template
  281. is not None."""
  282. slot = Slot(
  283. project_id=default_public_project.id,
  284. title="replanter excalibure",
  285. starting_time=starting_time,
  286. ending_time=datetime.now() + timedelta(minutes=60),
  287. )
  288. slot.volunteers.append(get_arthur(session))
  289. session.add(slot)
  290. session.commit()
  291. response = await client.post(
  292. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  293. headers=user_headers,
  294. json={"template": template},
  295. )
  296. assert response.status_code == 200
  297. sms = (
  298. session.execute(
  299. select(Sms).where(
  300. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  301. )
  302. )
  303. .scalars()
  304. .first()
  305. )
  306. assert sms is not None
  307. assert sms.content == expected
  308. @pytest.mark.parametrize(
  309. "template,expected",
  310. [
  311. ("no escape", "no escape"),
  312. ("{titre}", "replanter excalibure"),
  313. ("{prenom}", "Arthur"),
  314. ("{prenom} {nom}", "Arthur Pandragon"),
  315. ("{debut}", starting_time.strftime("%Hh%M")),
  316. ],
  317. )
  318. async def test_content_with_template_non_respo_placeholders(
  319. self,
  320. client: AsyncClient,
  321. user_headers: dict,
  322. session: Session,
  323. default_public_project: Project,
  324. template: str,
  325. expected: str,
  326. ):
  327. """Placeholders unrelated to {respo} behave the same regardless of
  328. how the contact is resolved."""
  329. slot_template = SlotTemplate(
  330. project_id=default_public_project.id,
  331. title="replanter excalibure",
  332. description="rendre l'épee à son caillou",
  333. place="gros caillou",
  334. )
  335. slot = Slot(
  336. project_id=default_public_project.id,
  337. title="replanter excalibure",
  338. starting_time=starting_time,
  339. ending_time=datetime.now() + timedelta(minutes=60),
  340. )
  341. slot.template = slot_template
  342. slot.volunteers.append(get_arthur(session))
  343. session.add(slot_template)
  344. session.add(slot)
  345. session.commit()
  346. response = await client.post(
  347. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  348. headers=user_headers,
  349. json={"template": template},
  350. )
  351. assert response.status_code == 200
  352. sms = (
  353. session.execute(
  354. select(Sms).where(
  355. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  356. )
  357. )
  358. .scalars()
  359. .first()
  360. )
  361. assert sms is not None
  362. assert sms.content == expected
  363. async def test_respo_placeholder_uses_override_when_set(
  364. self,
  365. client: AsyncClient,
  366. user_headers: dict,
  367. session: Session,
  368. default_public_project: Project,
  369. ):
  370. """responsible_override wins even if a commission is also attached."""
  371. commission = Commission(project_id=default_public_project.id, name="Bar")
  372. session.add(commission)
  373. session.commit()
  374. slot_template = SlotTemplate(
  375. project_id=default_public_project.id,
  376. title="replanter excalibure",
  377. description="rendre l'épee à son caillou",
  378. place="gros caillou",
  379. commission_id=commission.id,
  380. responsible_override="dieu",
  381. )
  382. slot = Slot(
  383. project_id=default_public_project.id,
  384. title="replanter excalibure",
  385. starting_time=starting_time,
  386. ending_time=datetime.now() + timedelta(minutes=60),
  387. )
  388. slot.template = slot_template
  389. slot.volunteers.append(get_arthur(session))
  390. session.add(slot_template)
  391. session.add(slot)
  392. session.commit()
  393. response = await client.post(
  394. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  395. headers=user_headers,
  396. json={"template": "{titre} {description}, {respo}"},
  397. )
  398. assert response.status_code == 200
  399. sms = (
  400. session.execute(
  401. select(Sms).where(
  402. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  403. )
  404. )
  405. .scalars()
  406. .first()
  407. )
  408. assert sms is not None
  409. assert sms.content == "replanter excalibure rendre l'épee à son caillou, dieu"
  410. async def test_respo_placeholder_derives_from_commission_members(
  411. self,
  412. client: AsyncClient,
  413. user_headers: dict,
  414. session: Session,
  415. default_public_project: Project,
  416. ):
  417. """No override set -- {respo} falls back to the commission's members'
  418. name : phone_number, comma-joined."""
  419. commission = Commission(project_id=default_public_project.id, name="Bar")
  420. session.add(commission)
  421. session.commit()
  422. member1 = User(
  423. id="c1a1a1a1-0000-0000-0000-000000000001",
  424. email="merlin@test.com",
  425. hashed_password="x",
  426. name="Merlin",
  427. phone_number="0601020304",
  428. )
  429. member2 = User(
  430. id="c1a1a1a1-0000-0000-0000-000000000002",
  431. email="morgane@test.com",
  432. hashed_password="x",
  433. name="Morgane",
  434. phone_number="0605060708",
  435. )
  436. session.add_all([member1, member2])
  437. session.commit()
  438. commission.members.append(member1)
  439. commission.members.append(member2)
  440. session.commit()
  441. slot_template = SlotTemplate(
  442. project_id=default_public_project.id,
  443. title="replanter excalibure",
  444. description="rendre l'épee à son caillou",
  445. place="gros caillou",
  446. commission_id=commission.id,
  447. )
  448. slot = Slot(
  449. project_id=default_public_project.id,
  450. title="replanter excalibure",
  451. starting_time=starting_time,
  452. ending_time=datetime.now() + timedelta(minutes=60),
  453. )
  454. slot.template = slot_template
  455. slot.volunteers.append(get_arthur(session))
  456. session.add(slot_template)
  457. session.add(slot)
  458. session.commit()
  459. response = await client.post(
  460. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  461. headers=user_headers,
  462. json={"template": "{titre} {description}, {respo}"},
  463. )
  464. assert response.status_code == 200
  465. sms = (
  466. session.execute(
  467. select(Sms).where(
  468. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  469. )
  470. )
  471. .scalars()
  472. .first()
  473. )
  474. assert sms is not None
  475. assert sms.content == (
  476. "replanter excalibure rendre l'épee à son caillou, "
  477. "Merlin : 0601020304, Morgane : 0605060708"
  478. )
  479. async def test_respo_placeholder_empty_when_no_commission_or_override(
  480. self,
  481. client: AsyncClient,
  482. user_headers: dict,
  483. session: Session,
  484. default_public_project: Project,
  485. ):
  486. """Template attached but no commission and no override -- {respo}
  487. resolves to an empty string rather than leaving the placeholder
  488. literal (that untouched-placeholder behavior only applies when
  489. slot.template is None, covered in test_content_no_template)."""
  490. slot_template = SlotTemplate(
  491. project_id=default_public_project.id,
  492. title="replanter excalibure",
  493. description="rendre l'épee à son caillou",
  494. place="gros caillou",
  495. )
  496. slot = Slot(
  497. project_id=default_public_project.id,
  498. title="replanter excalibure",
  499. starting_time=starting_time,
  500. ending_time=datetime.now() + timedelta(minutes=60),
  501. )
  502. slot.template = slot_template
  503. slot.volunteers.append(get_arthur(session))
  504. session.add(slot_template)
  505. session.add(slot)
  506. session.commit()
  507. response = await client.post(
  508. app.url_path_for("create_sms_batch", project_id=default_public_project.id),
  509. headers=user_headers,
  510. json={"template": "{titre}, {respo}"},
  511. )
  512. assert response.status_code == 200
  513. sms = (
  514. session.execute(
  515. select(Sms).where(
  516. (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
  517. )
  518. )
  519. .scalars()
  520. .first()
  521. )
  522. assert sms is not None
  523. assert sms.content == "replanter excalibure, "