Forráskód Böngészése

implement unit tests

tripeur 2 éve
szülő
commit
ded5fa2f34

+ 1 - 1
app/api/endpoints/project.py

@@ -129,7 +129,7 @@ async def create_sms_batch(
      - {debut} slot.starting_time
      - {fin} slot.ending_ting
      - {prenom} volunteer.name
-     - {name} volunteer.surname
+     - {nom} volunteer.surname
 
     """
     p = session.get(Project, project_id)

+ 3 - 3
app/api/endpoints/sms.py

@@ -50,7 +50,7 @@ async def create_sms(
     return sms
 
 
-@router.post("/project/{project_id}/sms/{sms_id}", response_model=list[SMSResponse])
+@router.post("/project/{project_id}/sms/{sms_id}", response_model=SMSResponse)
 async def update_sms(
     project_id: UUID,
     sms_id: UUID,
@@ -58,9 +58,9 @@ async def update_sms(
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
 ):
-    """Create a new to the project"""
+    """Update an sms from the project"""
     sms = session.get(Sms, sms_id)
-    if (sms is None) or sms.project_id != project_id:
+    if (sms is None) or (sms.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Sms not found")
     update_object_from_payload(sms, new_sms.dict())
     session.commit()

+ 1 - 1
app/models.py

@@ -94,7 +94,7 @@ class Volunteer(Base):
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
     name: Mapped[str] = mapped_column(String(128))
-    surname: Mapped[str] = mapped_column(String(128), nullable=True)
+    surname: Mapped[str] = mapped_column(String(128))
     email: Mapped[str] = mapped_column(String(128))
     phone_number: Mapped[str] = mapped_column(String(128))
     automatic_sms: Mapped[bool] = mapped_column(Boolean(), default=False)

+ 5 - 5
app/schemas/requests.py

@@ -23,14 +23,14 @@ class UserCreateRequest(BaseRequest):
 
 
 class SmsCreateRequest(BaseRequest):
-    volunteer_id: Optional[str] = None
+    volunteer_id: Optional[UUID4] = None
     phone_number: str
     content: str
     sending_time: Optional[datetime.datetime] = None
 
 
 class SmsUpdateRequest(BaseRequest):
-    volunteer_id: Optional[str] = None
+    volunteer_id: Optional[UUID4] = None
     phone_number: Optional[str] = None
     content: Optional[str] = None
     sending_time: Optional[datetime.datetime] = None
@@ -58,7 +58,7 @@ class ProjectSMSBatchRequest(BaseRequest):
      - {debut} slot.starting_time
      - {fin} slot.ending_ting
      - {prenom} volunteer.name
-     - {name} volunteer.surname""",
+     - {nom} volunteer.surname""",
         examples=[
             "Bonjour {prenom},\nTon créneau {titre} commence à {debut}.\nla com bénévole"
         ],
@@ -71,7 +71,7 @@ class ProjectSMSBatchRequest(BaseRequest):
 
 class VolunteerCreateRequest(BaseRequest):
     name: str
-    surname: Optional[str] = None
+    surname: Optional[str] = Field(default="")
     email: EmailStr
     phone_number: str
     automatic_sms: bool = Field(default=True)
@@ -83,7 +83,7 @@ class VolunteerUpdateRequest(BaseRequest):
     surname: Optional[str] = None
     email: Optional[str] = None
     phone_number: Optional[str] = None
-    automatic_sms: Optional[bool] = True
+    automatic_sms: Optional[bool] = None
     slots: Optional[list[UUID4]] = None
 
 

+ 76 - 4
app/tests/conftest.py

@@ -1,6 +1,6 @@
 import asyncio
 from collections.abc import AsyncGenerator
-from typing import Generator
+from datetime import datetime
 
 import pytest
 import pytest_asyncio
@@ -13,7 +13,7 @@ from app.core.session import engine
 from app.core.session import session as session_maker
 
 from app.main import app
-from app.models import Base, User
+from app.models import Base, Project, Slot, Sms, User, Volunteer
 
 default_user_id = "b75365d9-7bf9-4f54-add5-aeab333a087b"
 default_user_email = "geralt@wiedzmin.pl"
@@ -23,6 +23,12 @@ default_user_access_token = security.create_jwt_token(
     str(default_user_id), 60 * 60 * 24, refresh=False
 )[0]
 
+default_project_id = "e233ac66-3a29-4ae5-991e-30ec7334c566"
+default_project_name = "Default project"
+default_volunteer_id = "5514f4ef-75ee-40b2-ad99-420927c5c9e5"
+default_slot_id = "def04027-0048-48e2-8f47-f955fe080b31"
+default_sms_id = "b05c2b38-edb1-4e7a-9689-a0cba904ef29"
+
 
 @pytest.fixture(scope="session")
 def event_loop():
@@ -60,8 +66,8 @@ async def client() -> AsyncGenerator[AsyncClient, None]:
         yield client
 
 
-@pytest_asyncio.fixture
-async def default_user(test_db_setup_sessionmaker) -> User:
+@pytest.fixture
+def default_user(test_db_setup_sessionmaker) -> User:
     with session_maker() as db:
         result = db.execute(select(User).where(User.email == default_user_email))
         user = result.scalars().first()
@@ -78,6 +84,72 @@ async def default_user(test_db_setup_sessionmaker) -> User:
         return user
 
 
+@pytest.fixture
+def default_project(test_db_setup_sessionmaker) -> Project:
+    """An empty private project"""
+    with session_maker() as db:
+        result = db.execute(select(Project).where(Project.id == default_project_id))
+        project = result.scalars().first()
+        if project is None:
+            new_project = Project(name=default_project_name, is_public=False)
+            new_project.id = default_project_id
+            db.add(new_project)
+            db.commit()
+            db.refresh(new_project)
+            return new_project
+        return project
+
+
+@pytest.fixture
+def default_public_project(test_db_setup_sessionmaker) -> Project:
+    """A public project with 1 volunteer, 1 slot & 1 sms associated to."""
+    with session_maker() as db:
+        result = db.execute(select(Project).where(Project.id == default_project_id))
+        project = result.scalars().first()
+        if project is None:
+            new_project = Project(name=default_project_name, is_public=True)
+            new_project.id = default_project_id
+            db.add(new_project)
+            # Create a volunteer
+            volunteer = Volunteer(
+                project_id=default_project_id,
+                name="Arthur",
+                surname="Pandragon",
+                email="arthur.pandragon@kamelot.fr",
+                phone_number="02 66 66 66 66 66",
+                automatic_sms=True,
+            )
+            volunteer.id = default_volunteer_id
+            db.add(volunteer)
+            # Create a slot
+            slot = Slot(
+                project_id=default_project_id,
+                title="être roi",
+                starting_time=datetime(1600, 1, 1),
+                ending_time=datetime(1900, 1, 1),
+            )
+            slot.id = default_slot_id
+            slot.volunteers.append(volunteer)
+            db.add(slot)
+
+            # Create a sms
+            sms = Sms(
+                project_id=default_project_id,
+                content="Bonjour sir",
+                phone_number="66 66 66 66 66",
+            )
+            sms.id = default_sms_id
+            db.add(sms)
+
+            db.commit()
+            db.refresh(sms)
+            db.refresh(slot)
+            db.refresh(volunteer)
+            db.refresh(new_project)
+            return new_project
+        return project
+
+
 @pytest.fixture
 def default_user_headers(default_user: User):
     return {"Authorization": f"Bearer {default_user_access_token}"}

+ 280 - 0
app/tests/test_project.py

@@ -0,0 +1,280 @@
+import uuid
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Project, Slot, Sms, Volunteer
+from app.tests.conftest import default_project_id, default_project_name, default_sms_id
+
+
+async def test_read_list_project(
+    client: AsyncClient, default_user_headers: dict, default_project: Project
+):
+    response = await client.get(
+        app.url_path_for("list_project"), headers=default_user_headers
+    )
+    assert response.status_code == 200
+    data = response.json()
+    assert len(data) == 1
+    project_response = data[0]
+    assert project_response["id"] == default_project_id
+    assert "created_at" in project_response
+
+
+async def test_read_list_project_without_user(client: AsyncClient):
+    response = await client.get(app.url_path_for("list_project"))
+    assert response.status_code == 401
+
+
+async def test_read_list_public_project_without_user(
+    client: AsyncClient, default_project: Project
+):
+    response = await client.get(app.url_path_for("list_public_project"))
+    assert response.status_code == 200
+    data = response.json()
+    assert len(data) == 0
+
+
+async def test_read_list_public_project_without_user_1(
+    client: AsyncClient, default_public_project: Project
+):
+    response = await client.get(app.url_path_for("list_public_project"))
+    assert response.status_code == 200
+    data = response.json()
+    assert len(data) == 1
+
+
+async def test_get_project_without_user(client: AsyncClient, default_project: Project):
+    response = await client.get(
+        app.url_path_for("get_public_project", project_id=default_project_id)
+    )
+    assert response.status_code == 404
+
+
+async def test_get_public_project(client: AsyncClient, default_public_project: Project):
+    response = await client.get(
+        app.url_path_for("get_public_project", project_id=default_project_id)
+    )
+    assert response.status_code == 200
+    assert response.json()["id"] == default_project_id
+    assert response.json()["name"] == default_project_name
+
+
+async def test_get_project(
+    client: AsyncClient, default_public_project: Project, default_user_headers: dict
+):
+    # Test without autentication
+    response = await client.get(
+        app.url_path_for("get_project", project_id=default_project_id)
+    )
+    assert response.status_code == 401
+
+    response = await client.get(
+        app.url_path_for("get_project", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert response.json()["id"] == default_project_id
+    assert response.json()["name"] == default_project_name
+
+
+async def test_create_project_without_user(client: AsyncClient, session: Session):
+    response = await client.post(
+        app.url_path_for("create_project"),
+        json={"name": "Coucou"},
+    )
+    assert response.status_code == 401
+    # Verify no project were added
+    result = session.execute(select(Project))
+    project = result.scalars().first()
+    assert project is None
+
+
+async def test_create_project(
+    client: AsyncClient, default_user_headers: dict, session: Session
+):
+    # Create a private project
+    response = await client.post(
+        app.url_path_for("create_project"),
+        headers=default_user_headers,
+        json={"name": "Coucou"},
+    )
+    assert response.status_code == 200
+    result = session.execute(select(Project).where(Project.name == "Coucou"))
+    project = result.scalars().first()
+    assert project is not None
+    assert project.is_public == False
+
+    # Create a public project
+    response = await client.post(
+        app.url_path_for("create_project"),
+        headers=default_user_headers,
+        json={"name": "Public", "is_public": True},
+    )
+    assert response.status_code == 200
+    result = session.execute(select(Project).where(Project.name == "Public"))
+    project = result.scalars().first()
+    assert project is not None
+    assert project.is_public
+
+
+async def test_create_project_fail(
+    client: AsyncClient, default_user_headers: dict, session: Session
+):
+    # Create a project with a bad
+    response = await client.post(
+        app.url_path_for("create_project"),
+        headers=default_user_headers,
+        json={"coucou": "Coucou"},
+    )
+    assert response.status_code == 422
+    assert "detail" in response.json()
+    result = session.execute(select(Project))
+    project = result.scalars().first()
+    assert project is None
+
+
+async def test_update_project(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Update a public project
+    response = await client.post(
+        app.url_path_for("update_project", project_id=default_project_id),
+        headers=default_user_headers,
+        json={"name": "Coucou"},
+    )
+    assert response.status_code == 200
+    result = session.execute(select(Project).where(Project.id == default_project_id))
+    project = result.scalars().first()
+    assert project is not None
+    assert project.name == "Coucou"
+
+    # Update a public project
+    response = await client.post(
+        app.url_path_for("update_project", project_id=default_project_id),
+        headers=default_user_headers,
+        json={"name": "Coucou 2", "is_public": False},
+    )
+    assert response.status_code == 200
+    session.refresh(project)
+    assert project is not None
+    assert project.name == "Coucou 2"
+    assert project.created_at < project.updated_at
+    assert not project.is_public
+
+
+async def test_update_project_fail(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Fail updating the project due to not logged in
+    response = await client.post(
+        app.url_path_for("update_project", project_id=default_project_id),
+        json={"name": "Coucou 2"},
+    )
+    assert response.status_code == 401
+    result = session.execute(select(Project).where(Project.id == default_project_id))
+    project = result.scalars().first()
+    assert project is not None
+    assert project.name == default_project_name
+
+    # Update is_public without name => Validation error
+    response = await client.post(
+        app.url_path_for("update_project", project_id=default_project_id),
+        headers=default_user_headers,
+        json={"is_public": False},
+    )
+    assert response.status_code == 422
+
+
+async def test_create_sms_batch(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    #
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_project_id),
+        json={"is_public": False},
+    )
+    assert response.status_code == 401
+
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_project_id),
+        headers=default_user_headers,
+        json={"template": "Bonjour {prenom}!\n{titre}"},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_project_id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is not None
+    assert sms.content == "Bonjour Arthur!\nêtre roi"
+
+
+async def test_delete_project(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Fail deleting the project due to not logged in
+    response = await client.delete(
+        app.url_path_for("delete_project", project_id=default_project_id)
+    )
+    assert response.status_code == 401
+    result = session.execute(select(Project).where(Project.id == default_project_id))
+    project = result.scalars().first()
+    assert project is not None
+
+    # Proper deletion
+    response = await client.delete(
+        app.url_path_for("delete_project", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    result = session.execute(select(Project).where(Project.id == default_project_id))
+    project = result.scalars().first()
+    assert project is None
+    # check deletion is cascaded to volunteers
+    result = session.execute(
+        select(Volunteer).where(Volunteer.project_id == default_project_id)
+    )
+    volunteer = result.scalars().first()
+    assert volunteer is None
+    # check deletion is cascaded to slots
+    result = session.execute(select(Slot).where(Slot.project_id == default_project_id))
+    slot = result.scalars().first()
+    assert slot is None
+
+    # Idempotence test
+    response = await client.delete(
+        app.url_path_for("delete_project", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # can delete random uuid
+    response = await client.delete(
+        app.url_path_for("delete_project", project_id=uuid.uuid4()),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # Cannot delete non uuid string
+    response = await client.delete(
+        app.url_path_for("delete_project", project_id="not uidstr"),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422

+ 302 - 0
app/tests/test_slot.py

@@ -0,0 +1,302 @@
+from datetime import datetime, timedelta, timezone
+import uuid
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Project, Slot, Volunteer
+from app.tests.conftest import (
+    default_project_id,
+    default_slot_id,
+    default_volunteer_id,
+)
+
+
+async def test_read_list_project_slots(
+    client: AsyncClient, default_user_headers: dict, default_public_project: Project
+):
+    response = await client.get(
+        app.url_path_for("list_project_slots", project_id=default_project_id),
+    )
+    assert response.status_code == 401
+    response = await client.get(
+        app.url_path_for("list_project_slots", project_id=uuid.uuid4()),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+    response = await client.get(
+        app.url_path_for("list_project_slots", project_id="pas un uuid valid"),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+    response = await client.get(
+        app.url_path_for("list_project_slots", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    data = response.json()
+    assert len(data) == 1
+    slot_response = data[0]
+    assert slot_response["title"] == "être roi"
+    assert slot_response["id"] == default_slot_id
+    assert "created_at" in slot_response
+
+
+async def test_create_slot(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    # Test without autentication
+    response = await client.post(
+        app.url_path_for("create_slot", project_id=default_project_id)
+    )
+    assert response.status_code == 401
+    starting_time = datetime(1900, 1, 1)
+    payload = {
+        "title": "être mort",
+        "starting_time": starting_time.isoformat(),
+        "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+    }
+    # test invalid project_id
+    response = await client.post(
+        app.url_path_for("create_slot", project_id=uuid.uuid4()),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # Test normal payload
+    response = await client.post(
+        app.url_path_for("create_slot", project_id=default_project_id),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert response.json()["id"] != default_slot_id
+    assert response.json()["title"] == "être mort"
+    result = session.execute(select(Slot).where(Slot.project_id == default_project_id))
+    slots = result.scalars().all()
+    assert len(slots) > 1
+    slot = [s for s in slots if s.id != default_slot_id][0]
+    assert slot.title == "être mort"
+    assert abs(
+        slot.starting_time - starting_time.replace(tzinfo=timezone.utc)
+    ) < timedelta(minutes=30)
+
+    # test invalid payload
+    del payload["title"]
+    response = await client.post(
+        app.url_path_for("create_slot", project_id=default_project_id),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+
+async def test_update_slot(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    # Test without autentication
+    response = await client.post(
+        app.url_path_for(
+            "update_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        )
+    )
+    assert response.status_code == 401
+
+    starting_time = datetime(2000, 1, 1, tzinfo=timezone.utc)
+    payload = {
+        "title": "être mort 2 fois",
+        "starting_time": starting_time.isoformat(),
+        "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+    }
+
+    # test invalid project_id
+    response = await client.post(
+        app.url_path_for(
+            "update_slot",
+            project_id=uuid.uuid4(),
+            slot_id=default_slot_id,
+        ),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # test invalid slot_id
+    response = await client.post(
+        app.url_path_for(
+            "update_slot",
+            project_id=default_project_id,
+            slot_id=uuid.uuid4(),
+        ),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # Test normal payload
+
+    for k, v in payload.items():
+        response = await client.post(
+            app.url_path_for(
+                "update_slot",
+                project_id=default_project_id,
+                slot_id=default_slot_id,
+            ),
+            json={k: v},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["id"] == default_slot_id
+        if "time" in k:
+            assert datetime.fromisoformat(response.json()[k]) == datetime.fromisoformat(
+                v
+            )
+        else:
+            assert response.json()[k] == v
+
+
+async def test_update_slot_volunteers(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    response = await client.post(
+        app.url_path_for(
+            "update_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        ),
+        json={"volunteers": []},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 200
+    result = session.execute(
+        select(Volunteer).where(Volunteer.id == default_volunteer_id)
+    )
+    volunteer = result.scalars().first()
+    assert volunteer is not None
+    assert volunteer.slots_id == []
+
+    response = await client.post(
+        app.url_path_for(
+            "update_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        ),
+        json={"volunteers": [default_volunteer_id]},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 200
+    session.refresh(volunteer)
+    assert volunteer is not None
+    assert volunteer.slots_id == [default_slot_id]
+
+    # An invalid volunteer list
+    response = await client.post(
+        app.url_path_for(
+            "update_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        ),
+        json={"volunteers": [str(uuid.uuid4())]},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 400
+
+    # An invalid volunteer list
+    response = await client.post(
+        app.url_path_for(
+            "update_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        ),
+        json={"volunteers": ["not uuid str"]},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 422
+
+
+async def test_delete_slot(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Fail deleting the project due to not logged in
+    response = await client.delete(
+        app.url_path_for(
+            "delete_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        )
+    )
+    assert response.status_code == 401
+    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
+    slot = result.scalars().first()
+    assert slot is not None
+
+    # Proper deletion
+    response = await client.delete(
+        app.url_path_for(
+            "delete_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
+    slot = result.scalars().first()
+    assert slot is None
+
+    # check deletion is cascaded to volunteers
+    result = session.execute(
+        select(Volunteer).where(Volunteer.id == default_volunteer_id)
+    )
+    volunteer: Volunteer | None = result.scalars().first()
+    assert default_slot_id not in volunteer.slots_id
+
+    # Idempotence test
+    response = await client.delete(
+        app.url_path_for(
+            "delete_slot",
+            project_id=default_project_id,
+            slot_id=default_slot_id,
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # can delete random uuid
+    response = await client.delete(
+        app.url_path_for(
+            "delete_slot", project_id=default_project_id, slot_id=uuid.uuid4()
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # Cannot delete non uuid string
+    response = await client.delete(
+        app.url_path_for(
+            "delete_slot", project_id=default_project_id, slot_id="not uidstr"
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422

+ 231 - 0
app/tests/test_sms.py

@@ -0,0 +1,231 @@
+from datetime import datetime, timedelta, timezone
+import uuid
+from httpx import AsyncClient
+from sqlalchemy import false, select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Project, Sms, Sms
+from app.tests.conftest import default_project_id, default_volunteer_id, default_sms_id
+
+
+async def test_read_list_project_sms(
+    client: AsyncClient, default_user_headers: dict, default_public_project: Project
+):
+    response = await client.get(
+        app.url_path_for("list_project_sms", project_id=default_project_id),
+    )
+    assert response.status_code == 401
+    response = await client.get(
+        app.url_path_for("list_project_sms", project_id=uuid.uuid4()),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+    response = await client.get(
+        app.url_path_for("list_project_sms", project_id="pas un uuid valid"),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+    response = await client.get(
+        app.url_path_for("list_project_sms", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    data = response.json()
+    assert len(data) == 1
+    assert data[0]["id"] == default_sms_id
+
+
+async def test_create_sms(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    # Test without autentication
+    response = await client.post(
+        app.url_path_for("create_sms", project_id=default_project_id)
+    )
+    assert response.status_code == 401
+    starting_time = datetime(1900, 1, 1)
+    payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
+    # test invalid project_id
+    response = await client.post(
+        app.url_path_for("create_sms", project_id=uuid.uuid4()),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # Test normal payload
+    before_creation_time = datetime.now(timezone.utc)
+    response = await client.post(
+        app.url_path_for("create_sms", project_id=default_project_id),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert response.json()["content"] == "sms_content"
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_project_id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+
+    assert sms is not None
+    assert sms.content == "sms_content"
+    assert before_creation_time < sms.sending_time
+    assert sms.sending_time < datetime.now(timezone.utc)
+
+    # test invalid payload
+    del payload["content"]
+    response = await client.post(
+        app.url_path_for("create_sms", project_id=default_project_id),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+
+async def test_update_sms(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    # Test without autentication
+    response = await client.post(
+        app.url_path_for(
+            "update_sms",
+            project_id=default_project_id,
+            sms_id=default_sms_id,
+        )
+    )
+    assert response.status_code == 401
+
+    # Test invalid payload
+    response = await client.post(
+        app.url_path_for(
+            "update_sms",
+            project_id=default_project_id,
+            sms_id=default_sms_id,
+        ),
+        json={"volunteer_id": True},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+    payload = {
+        "volunteer_id": default_volunteer_id,
+        "phone_number": "06 75 75 75 75 ",
+        "content": "sms_content",
+        "sending_time": datetime(2024, 5, 17, tzinfo=timezone.utc).isoformat(),
+    }
+
+    # test invalid project_id
+    response = await client.post(
+        app.url_path_for(
+            "update_sms",
+            project_id=uuid.uuid4(),
+            sms_id=default_sms_id,
+        ),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # test invalid sms_id
+    response = await client.post(
+        app.url_path_for(
+            "update_sms",
+            project_id=default_project_id,
+            sms_id=uuid.uuid4(),
+        ),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # Test normal payload
+    for k, v in payload.items():
+        response = await client.post(
+            app.url_path_for(
+                "update_sms",
+                project_id=default_project_id,
+                sms_id=default_sms_id,
+            ),
+            json={k: v},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["id"] == default_sms_id
+        if "time" in k:
+            parsed_time = datetime.fromisoformat(response.json()[k])
+            assert parsed_time == datetime.fromisoformat(v)
+        else:
+            assert response.json()[k] == v
+
+
+async def test_delete_sms(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Fail deleting the project due to not logged in
+    response = await client.delete(
+        app.url_path_for(
+            "delete_sms",
+            project_id=default_project_id,
+            sms_id=default_sms_id,
+        )
+    )
+    assert response.status_code == 401
+    result = session.execute(select(Sms).where(Sms.id == default_sms_id))
+    sms = result.scalars().first()
+    assert sms is not None
+
+    # Proper deletion
+    response = await client.delete(
+        app.url_path_for(
+            "delete_sms",
+            project_id=default_project_id,
+            sms_id=default_sms_id,
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    result = session.execute(select(Sms).where(Sms.id == default_sms_id))
+    sms = result.scalars().first()
+    assert sms is None
+
+    # Idempotence test
+    response = await client.delete(
+        app.url_path_for(
+            "delete_sms",
+            project_id=default_project_id,
+            sms_id=default_sms_id,
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # can delete random uuid
+    response = await client.delete(
+        app.url_path_for(
+            "delete_sms", project_id=default_project_id, sms_id=uuid.uuid4()
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # Cannot delete non uuid string
+    response = await client.delete(
+        app.url_path_for(
+            "delete_sms", project_id=default_project_id, sms_id="not uidstr"
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422

+ 292 - 0
app/tests/test_volunteer.py

@@ -0,0 +1,292 @@
+import uuid
+from httpx import AsyncClient
+from sqlalchemy import false, select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Project, Slot, Sms, User, Volunteer
+from app.tests.conftest import (
+    default_project_id,
+    default_volunteer_id,
+    default_slot_id,
+)
+
+
+async def test_read_list_project_volunteers(
+    client: AsyncClient, default_user_headers: dict, default_public_project: Project
+):
+    response = await client.get(
+        app.url_path_for("list_project_volunteers", project_id=default_project_id),
+    )
+    assert response.status_code == 401
+    response = await client.get(
+        app.url_path_for("list_project_volunteers", project_id=uuid.uuid4()),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+    response = await client.get(
+        app.url_path_for("list_project_volunteers", project_id="pas un uuid valid"),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+    response = await client.get(
+        app.url_path_for("list_project_volunteers", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    data = response.json()
+    assert len(data) == 1
+    volunteer_response = data[0]
+    assert volunteer_response["name"] == "Arthur"
+    assert volunteer_response["id"] == default_volunteer_id
+    assert "created_at" in volunteer_response
+
+
+async def test_create_volunteer(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    # Test without autentication
+    response = await client.post(
+        app.url_path_for("create_volunteer", project_id=default_project_id)
+    )
+    assert response.status_code == 401
+    payload = {
+        "name": "Lancelot",
+        "email": "lancelot@dulac.fr",
+        "phone_number": "03 14 15 92 65",
+    }
+    # test invalid project_id
+    response = await client.post(
+        app.url_path_for("create_volunteer", project_id=uuid.uuid4()),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # Test normal payload
+    response = await client.post(
+        app.url_path_for("create_volunteer", project_id=default_project_id),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert response.json()["id"] != default_project_id
+    assert response.json()["name"] == "Lancelot"
+    result = session.execute(
+        select(Volunteer).where(Volunteer.project_id == default_project_id)
+    )
+    volunteers = result.scalars().all()
+    assert len(volunteers) > 1
+
+    # test invalid payload
+    del payload["name"]
+    response = await client.post(
+        app.url_path_for("create_volunteer", project_id=default_project_id),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+
+async def test_update_volunteer(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    # Test without autentication
+    response = await client.post(
+        app.url_path_for(
+            "update_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        )
+    )
+    assert response.status_code == 401
+
+    payload = {
+        "name": "Lancelot",
+        "email": "lancelot@dulac.fr",
+        "phone_number": "03 14 15 92 65",
+        "automatic_sms": False,
+    }
+
+    # test invalid project_id
+    response = await client.post(
+        app.url_path_for(
+            "update_volunteer",
+            project_id=uuid.uuid4(),
+            volunteer_id=default_volunteer_id,
+        ),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # test invalid volunteer_id
+    response = await client.post(
+        app.url_path_for(
+            "update_volunteer",
+            project_id=default_project_id,
+            volunteer_id=uuid.uuid4(),
+        ),
+        json=payload,
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+
+    # Test normal payload
+
+    for k, v in payload.items():
+        response = await client.post(
+            app.url_path_for(
+                "update_volunteer",
+                project_id=default_project_id,
+                volunteer_id=default_volunteer_id,
+            ),
+            json={k: v},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["id"] == default_volunteer_id
+        assert response.json()[k] == v
+
+
+async def test_update_volunteer_slots(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    response = await client.post(
+        app.url_path_for(
+            "update_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        ),
+        json={"slots": []},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 200
+    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
+    slot = result.scalars().first()
+    assert slot is not None
+    assert slot.volunteers_id == []
+
+    response = await client.post(
+        app.url_path_for(
+            "update_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        ),
+        json={"slots": [default_slot_id]},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 200
+    session.refresh(slot)
+    assert slot is not None
+    assert slot.volunteers_id == [default_volunteer_id]
+
+    # An invalid slot list
+    response = await client.post(
+        app.url_path_for(
+            "update_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        ),
+        json={"slots": [str(uuid.uuid4())]},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 400
+
+    # An invalid slot list
+    response = await client.post(
+        app.url_path_for(
+            "update_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        ),
+        json={"slots": ["not uuid str"]},
+        headers=default_user_headers,
+    )
+
+    assert response.status_code == 422
+
+
+async def test_delete_volunteer(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Fail deleting the project due to not logged in
+    response = await client.delete(
+        app.url_path_for(
+            "delete_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        )
+    )
+    assert response.status_code == 401
+    result = session.execute(
+        select(Volunteer).where(Volunteer.id == default_volunteer_id)
+    )
+    volunteer = result.scalars().first()
+    assert volunteer is not None
+
+    # Proper deletion
+    response = await client.delete(
+        app.url_path_for(
+            "delete_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Volunteer).where(Volunteer.id == default_volunteer_id)
+    )
+    volunteer = result.scalars().first()
+    assert volunteer is None
+
+    # check deletion is cascaded to slots
+    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
+    slot: Slot | None = result.scalars().first()
+    assert default_volunteer_id not in slot.volunteers_id
+
+    # Idempotence test
+    response = await client.delete(
+        app.url_path_for(
+            "delete_volunteer",
+            project_id=default_project_id,
+            volunteer_id=default_volunteer_id,
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # can delete random uuid
+    response = await client.delete(
+        app.url_path_for(
+            "delete_volunteer", project_id=default_project_id, volunteer_id=uuid.uuid4()
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+
+    # Cannot delete non uuid string
+    response = await client.delete(
+        app.url_path_for(
+            "delete_volunteer", project_id=default_project_id, volunteer_id="not uidstr"
+        ),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422