test_sms_batch.py 19 KB

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