Browse Source

test: implement test for sms_sender service

clovis 1 week ago
parent
commit
44a257516c
3 changed files with 317 additions and 0 deletions
  1. 40 0
      app/tests/test_slots.py
  2. 266 0
      app/tests/test_sms_sender.py
  3. 11 0
      app/tests/test_templates.py

+ 40 - 0
app/tests/test_slots.py

@@ -297,6 +297,46 @@ class TestCreateSlot:
         )
         assert response.status_code == 403
 
+    async def test_with_invalid_volunteer(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        starting_time = datetime(1900, 1, 1)
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json={
+                "title": "Scene shift",
+                "starting_time": starting_time.isoformat(),
+                "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+                "volunteers": [str(uuid4())],
+            },
+            headers=headers,
+        )
+        assert response.status_code == 400
+
+    async def test_with_volunteer(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        starting_time = datetime(1900, 1, 1)
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json={
+                "title": "Scene shift",
+                "starting_time": starting_time.isoformat(),
+                "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+                "volunteers": [default_volunteer_id],
+            },
+            headers=headers,
+        )
+        assert response.status_code == 200
+
 
 class TestUpdateSlot:
     async def test_slot_not_found(

+ 266 - 0
app/tests/test_sms_sender.py

@@ -0,0 +1,266 @@
+import datetime
+import uuid
+from datetime import timedelta
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.main import app
+from app.models import GlobalRole, OrgRole, Project, ServerStatus, Sms
+from app.schemas.responses import EnumServerStatus
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+def super_admin_headers(make_org_user) -> dict:
+    """Fixture to get headers for a user with SUPER_ADMIN global role."""
+    _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.SUPER_ADMIN)
+    return headers
+
+
+@pytest.fixture
+def standard_user_headers(make_org_user) -> dict:
+    """Fixture to get headers for a standard user."""
+    _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.USER)
+    return headers
+
+
+@pytest.fixture
+def sms_records(session: Session, default_project: Project) -> dict[str, Sms]:
+    """Creates a matrix of SMS records to test various time and state conditions."""
+    now = datetime.datetime.now()
+
+    # Ready to send (within the default 10 min window)
+    sms_to_send = Sms(
+        project_id=default_project.id,
+        content="to send",
+        phone_number="0600000001",
+        sending_time=now - timedelta(minutes=5),
+    )
+
+    # Too old (missed the default 10 min window)
+    sms_too_old = Sms(
+        project_id=default_project.id,
+        content="too old",
+        phone_number="0600000002",
+        sending_time=now - timedelta(minutes=15),
+    )
+
+    # Scheduled for the future
+    sms_future = Sms(
+        project_id=default_project.id,
+        content="future",
+        phone_number="0600000003",
+        sending_time=now + timedelta(minutes=10),
+    )
+
+    # 4. Already sent
+    sms_sent = Sms(
+        project_id=default_project.id,
+        content="sent",
+        phone_number="0600000004",
+        sending_time=now - timedelta(minutes=5),
+        send_time=now - timedelta(minutes=1),
+    )
+
+    session.add_all([sms_to_send, sms_too_old, sms_future, sms_sent])
+    session.commit()
+    session.refresh(sms_to_send)
+    session.refresh(sms_too_old)
+    session.refresh(sms_future)
+    session.refresh(sms_sent)
+
+    return {
+        "to_send": sms_to_send,
+        "too_old": sms_too_old,
+        "future": sms_future,
+        "sent": sms_sent,
+    }
+
+
+class TestSmsSenderSecurity:
+    """Ensure all endpoints are strictly protected by require_super_admin"""
+
+    @pytest.fixture(autouse=True)
+    def setup_routes(self, sms_records):
+        sms_id = sms_records["to_send"].id
+        self.routes = [
+            ("GET", "list_sms_to_send", {}, None),
+            ("POST", "send_sms_now", {"sms_id": sms_id}, None),
+            ("GET", "list_not_sent", {}, None),
+            ("GET", "list_future_sms", {}, None),
+            ("POST", "update_status", {}, None),
+            ("GET", "get_status", {}, None),
+        ]
+
+    async def test_rejects_unauthenticated(self, client: AsyncClient):
+        for method, endpoint, kwargs, payload in self.routes:
+            url = app.url_path_for(endpoint, **kwargs)
+            req = getattr(client, method.lower())
+            response = await req(url, json=payload) if payload else await req(url)
+            assert response.status_code == 401
+
+    async def test_rejects_standard_user(self, client: AsyncClient, standard_user_headers):
+        for method, endpoint, kwargs, payload in self.routes:
+            url = app.url_path_for(endpoint, **kwargs)
+            req = getattr(client, method.lower())
+            response = (
+                await req(url, json=payload, headers=standard_user_headers)
+                if payload
+                else await req(url, headers=standard_user_headers)
+            )
+            # require_super_admin usually throws 403
+            assert response.status_code == 403
+
+
+class TestListSmsToSend:
+    async def test_default_delay_window(
+        self, client: AsyncClient, super_admin_headers, sms_records
+    ):
+        """Should only return SMS scheduled in the past 10 minutes (default)."""
+        response = await client.get(
+            app.url_path_for("list_sms_to_send"), headers=super_admin_headers
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        assert len(data) == 1
+        assert data[0]["id"] == str(sms_records["to_send"].id)
+
+    async def test_custom_max_delay(self, client: AsyncClient, super_admin_headers, sms_records):
+        """Increasing max_delay to 20 mins should include the 'too_old' SMS."""
+        response = await client.get(
+            f"{app.url_path_for('list_sms_to_send')}?max_delay=20", headers=super_admin_headers
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        assert len(data) == 2
+        ids = [sms["id"] for sms in data]
+        assert str(sms_records["to_send"].id) in ids
+        assert str(sms_records["too_old"].id) in ids
+
+
+class TestSendSmsNow:
+    async def test_send_success_and_updates_status(
+        self, client: AsyncClient, super_admin_headers, sms_records, session: Session
+    ):
+        sms_id = sms_records["to_send"].id
+
+        # Ensure status is empty initially
+        assert session.query(ServerStatus).first() is None
+
+        response = await client.post(
+            app.url_path_for("send_sms_now", sms_id=sms_id),
+            headers=super_admin_headers,
+        )
+
+        assert response.status_code == 200
+        assert response.json()["send_time"] is not None
+
+        # Verify DB is updated
+        session.refresh(sms_records["to_send"])
+        assert sms_records["to_send"].send_time is not None
+
+        # Verify ServerStatus was updated implicitly by the router
+        status = session.query(ServerStatus).first()
+        assert status is not None
+        assert status.host == "127.0.0.1"  # HTTPX default test client host
+
+    async def test_already_sent_returns_400(
+        self, client: AsyncClient, super_admin_headers, sms_records
+    ):
+        response = await client.post(
+            app.url_path_for("send_sms_now", sms_id=sms_records["sent"].id),
+            headers=super_admin_headers,
+        )
+        assert response.status_code == 400
+        assert response.json()["detail"] == "SMS has already been sent"
+
+    async def test_not_found_returns_404(self, client: AsyncClient, super_admin_headers):
+        response = await client.post(
+            app.url_path_for("send_sms_now", sms_id=uuid.uuid4()),
+            headers=super_admin_headers,
+        )
+        assert response.status_code == 404
+
+
+class TestListNotSent:
+    async def test_returns_all_unsent(self, client: AsyncClient, super_admin_headers, sms_records):
+        response = await client.get(app.url_path_for("list_not_sent"), headers=super_admin_headers)
+        assert response.status_code == 200
+        data = response.json()
+
+        # Should include to_send, too_old, and future (everything where send_time is None)
+        assert len(data) == 3
+        ids = [sms["id"] for sms in data]
+        assert str(sms_records["sent"].id) not in ids
+
+
+class TestListFutureSms:
+    async def test_returns_only_future(self, client: AsyncClient, super_admin_headers, sms_records):
+        response = await client.get(
+            app.url_path_for("list_future_sms"), headers=super_admin_headers
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        # Should only include the future SMS
+        assert len(data) == 1
+        assert data[0]["id"] == str(sms_records["future"].id)
+
+
+class TestServerStatus:
+    async def test_post_status_creates_new(
+        self, client: AsyncClient, super_admin_headers, session: Session
+    ):
+        response = await client.post(
+            app.url_path_for("update_status"),
+            headers={**super_admin_headers, "user-agent": "test-agent"},
+        )
+        assert response.status_code == 200
+
+        status = session.query(ServerStatus).first()
+        assert status.id == 1
+        assert status.user_agent == "test-agent"
+
+    async def test_get_status_invalid_if_empty(self, client: AsyncClient, super_admin_headers):
+        """If the server has never pinged, it should return INVALID."""
+        response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
+        assert response.status_code == 200
+        assert response.json()["message"] == EnumServerStatus.INVALID.value
+
+    async def test_get_status_active(
+        self, client: AsyncClient, super_admin_headers, session: Session
+    ):
+        """Should be ACTIVE if pinged recently."""
+        status = ServerStatus(
+            id=1,
+            user_agent="python",
+            host="127.0.0.1",
+            updated_at=datetime.datetime.now(datetime.UTC),
+        )
+        session.add(status)
+        session.commit()
+
+        response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
+        assert response.status_code == 200
+        assert response.json()["message"] == EnumServerStatus.ACTIVE.value
+
+    async def test_get_status_inactive(
+        self, client: AsyncClient, super_admin_headers, session: Session
+    ):
+        """Should be INACTIVE if time since last ping exceeds settings threshold."""
+        old_time = datetime.datetime.now(datetime.UTC) - timedelta(
+            seconds=settings.INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS + 10
+        )
+        status = ServerStatus(id=1, user_agent="python", host="127.0.0.1", updated_at=old_time)
+        session.add(status)
+        session.commit()
+
+        response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
+        assert response.status_code == 200
+        assert response.json()["message"] == EnumServerStatus.INACTIVE.value

+ 11 - 0
app/tests/test_templates.py

@@ -57,6 +57,17 @@ class TestSlotCrossCuttingAccess(SharedProjectAccessTests):
         return request.param
 
 
+class TestListProjectTemplate:
+    async def test_ok(self, client: AsyncClient, default_public_project: Project, make_org_user):
+        _, headers = make_org_user(OrgRole.ORG_ADMIN)
+        response = await client.get(
+            app.url_path_for("list_project_templates", project_id=default_public_project.id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert len(response.json()) > 0
+
+
 class TestCreateTemplate:
     async def test_validation_error(
         self,