Skip to content

Commit 55a9f53

Browse files
Merge pull request #19 from jazkarta/task/add-unit-test
Feat: Unittest for api
2 parents b24b39b + 67d2f9f commit 55a9f53

16 files changed

Lines changed: 554 additions & 97 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: api-unit-test
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- development
7+
- main
8+
paths:
9+
- 'api/**'
10+
11+
jobs:
12+
test:
13+
name: Run API Unit Tests
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.13'
24+
cache: 'pip'
25+
cache-dependency-path: 'api/requirements.txt'
26+
27+
- name: Install dependencies
28+
working-directory: api
29+
run: |
30+
python -m pip install --upgrade pip
31+
pip install -r requirements.txt
32+
33+
- name: Run unit tests
34+
working-directory: api
35+
env:
36+
# Use SQLite for CI tests to avoid dependency on PostgreSQL
37+
DB_ENGINE: django.db.backends.sqlite3
38+
DB_NAME: ":memory:"
39+
# Dummy values for external integrations (should be mocked in tests)
40+
LIBRECHAT_MONGO_URI: mongodb://localhost:27017/LibreChat
41+
SECRET_KEY: ci_testing_secret_key
42+
DEBUG: "False"
43+
KEYCLOAK_SERVER_URL: http://localhost:8080
44+
KEYCLOAK_REALM: ayo
45+
KEYCLOAK_CLIENT_ID: admin-cli
46+
KEYCLOAK_ADMIN_USERNAME: admin
47+
KEYCLOAK_ADMIN_PASSWORD: admin
48+
DASHBOARD_URL: http://localhost:3000
49+
ALLOWED_HOSTS: "*"
50+
run: |
51+
pytest

api/ayo_api/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@
120120
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
121121

122122
DATABASES = {
123-
"default": {
124-
"ENGINE": "django.db.backends.postgresql",
123+
"default": {
124+
"ENGINE": os.getenv("DB_ENGINE", "django.db.backends.postgresql"),
125125
"NAME": os.getenv("DB_NAME"),
126126
"USER": os.getenv("DB_USER"),
127127
"PASSWORD": os.getenv("DB_PASSWORD"),

api/chat/tests.py

Lines changed: 0 additions & 3 deletions
This file was deleted.

api/participant/tests.py

Lines changed: 0 additions & 89 deletions
This file was deleted.

api/participant/tests/__init__.py

Whitespace-only changes.

api/participant/tests/conftest.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import pytest
2+
from django.contrib.auth import get_user_model
3+
from rest_framework.test import APIClient
4+
from users.models.user import UserRole
5+
from model_bakery import baker
6+
7+
User = get_user_model()
8+
9+
@pytest.fixture
10+
def researcher_user(db):
11+
"""Fixture to create and return a user with RESEARCHER role."""
12+
return User.objects.create_user(
13+
email="researcher@example.com",
14+
password="password123",
15+
role=UserRole.RESEARCHER,
16+
first_name="Research",
17+
last_name="er"
18+
)
19+
20+
@pytest.fixture
21+
def participant_user(db):
22+
"""Fixture to create and return an inactive user with PARTICIPANT role and profile."""
23+
user = User.objects.create_user(
24+
email="participant@example.com",
25+
username="participant",
26+
password="password123",
27+
role=UserRole.PARTICIPANT,
28+
is_active=False,
29+
first_name="Parti",
30+
last_name="cipant"
31+
)
32+
from participant.models import ParticipantProfile
33+
ParticipantProfile.objects.get_or_create(
34+
user=user,
35+
defaults={
36+
"date_of_birth": "2010-01-01",
37+
"gender": "M"
38+
}
39+
)
40+
return user
41+
42+
@pytest.fixture
43+
def regular_user(db):
44+
"""Fixture to create and return a regular user (defaults to PARTICIPANT but active)."""
45+
user = User.objects.create_user(
46+
email="user@example.com",
47+
username="regular_user",
48+
password="password123",
49+
role=UserRole.PARTICIPANT,
50+
is_active=True
51+
)
52+
from participant.models import ParticipantProfile
53+
ParticipantProfile.objects.get_or_create(
54+
user=user,
55+
defaults={
56+
"date_of_birth": "2010-01-01",
57+
"gender": "M"
58+
}
59+
)
60+
return user
61+
62+
@pytest.fixture
63+
def invitation(participant_user, researcher_user):
64+
"""Fixture for a valid, active invitation."""
65+
from participant.models import Invitation
66+
from django.utils import timezone
67+
from datetime import timedelta
68+
return Invitation.objects.create(
69+
user=participant_user,
70+
invited_by=researcher_user,
71+
parent_email="guardian@example.com",
72+
expiry_date=timezone.now() + timedelta(days=2),
73+
is_active=True,
74+
has_accepted=False
75+
)
76+
77+
@pytest.fixture
78+
def expired_invitation(db, researcher_user):
79+
"""Fixture for an expired invitation."""
80+
from participant.models import Invitation
81+
from django.utils import timezone
82+
from datetime import timedelta
83+
84+
user = User.objects.create_user(
85+
email="expired@example.com",
86+
username="expired",
87+
role=UserRole.PARTICIPANT,
88+
is_active=False
89+
)
90+
from participant.models import ParticipantProfile
91+
ParticipantProfile.objects.create(user=user, date_of_birth="2010-01-01", gender="M")
92+
93+
return Invitation.objects.create(
94+
user=user,
95+
invited_by=researcher_user,
96+
parent_email="guardian@example.com",
97+
expiry_date=timezone.now() - timedelta(days=1),
98+
is_active=True,
99+
has_accepted=False
100+
)
101+
102+
@pytest.fixture
103+
def accepted_invitation(db, researcher_user):
104+
"""Fixture for an already accepted invitation."""
105+
from participant.models import Invitation
106+
from django.utils import timezone
107+
from datetime import timedelta
108+
109+
user = User.objects.create_user(
110+
email="accepted@example.com",
111+
username="accepted",
112+
role=UserRole.PARTICIPANT,
113+
is_active=False
114+
)
115+
from participant.models import ParticipantProfile
116+
ParticipantProfile.objects.create(user=user, date_of_birth="2010-01-01", gender="M")
117+
118+
return Invitation.objects.create(
119+
user=user,
120+
invited_by=researcher_user,
121+
parent_email="guardian@example.com",
122+
expiry_date=timezone.now() + timedelta(days=2),
123+
is_active=True,
124+
has_accepted=True
125+
)
126+
127+
@pytest.fixture
128+
def mock_email_manager(mocker):
129+
"""Fixture to mock EmailManager."""
130+
mock_email = mocker.patch("participant.serializers.EmailManager")
131+
return mock_email.return_value
132+
133+
@pytest.fixture
134+
def api_client():
135+
"""Fixture to provide a DRF APIClient."""
136+
return APIClient()
137+
138+
@pytest.fixture
139+
def mock_keycloak(mocker):
140+
"""Fixture to mock KeycloakSync."""
141+
mock_sync = mocker.patch("participant.serializers.KeycloakSync")
142+
return mock_sync.return_value
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import pytest
2+
from django.urls import reverse
3+
from rest_framework import status
4+
from participant.models import Invitation, ParticipantProfile, Guardian
5+
from django.contrib.auth import get_user_model
6+
from django.utils import timezone
7+
8+
User = get_user_model()
9+
10+
@pytest.mark.django_db
11+
class TestInvitationRetrieve:
12+
def test_retrieve_invitation_success(self, api_client, invitation):
13+
url = reverse('invitation-detail', kwargs={'pk': invitation.id})
14+
response = api_client.get(url)
15+
assert response.status_code == status.HTTP_200_OK
16+
assert response.data['id'] == str(invitation.id)
17+
assert response.data['parent_email'] == invitation.parent_email
18+
19+
def test_retrieve_invitation_not_found(self, api_client):
20+
url = reverse('invitation-detail', kwargs={'pk': '00000000-0000-0000-0000-000000000000'})
21+
response = api_client.get(url)
22+
assert response.status_code == status.HTTP_404_NOT_FOUND
23+
24+
@pytest.mark.django_db
25+
class TestInvitationAccept:
26+
27+
@pytest.fixture
28+
def valid_payload(self):
29+
return {
30+
"first_name": "Guardian",
31+
"last_name": "One",
32+
"username": "new_participant_user",
33+
"phone_number": "1234567890",
34+
"email": "guardian@example.com",
35+
"address": "123 Test St",
36+
"relationship": "Father"
37+
}
38+
39+
def test_accept_invitation_success(self, api_client, invitation, valid_payload, mock_keycloak):
40+
# Setup mock behavior
41+
mock_keycloak.create_user.return_value = "fake-keycloak-uuid"
42+
43+
url = reverse('invitation-accept', kwargs={'pk': invitation.id})
44+
response = api_client.post(url, valid_payload)
45+
46+
assert response.status_code == status.HTTP_200_OK
47+
48+
# Verify invitation status
49+
invitation.refresh_from_db()
50+
assert invitation.has_accepted is True
51+
assert invitation.is_active is False
52+
53+
# Verify user status
54+
user = invitation.user
55+
user.refresh_from_db()
56+
assert user.is_active is True
57+
assert user.username == "new_participant_user"
58+
assert user.keycloak_id == "fake-keycloak-uuid"
59+
60+
# Verify guardian creation
61+
assert Guardian.objects.filter(email=valid_payload['email']).exists()
62+
guardian = Guardian.objects.get(email=valid_payload['email'])
63+
assert user.participant_profile.guardian == guardian
64+
65+
@pytest.mark.parametrize("scenario, invitation_fixture, payload_overrides, expected_status, error_key", [
66+
("already_accepted", "accepted_invitation", {}, status.HTTP_400_BAD_REQUEST, "non_field_errors"),
67+
("expired", "expired_invitation", {}, status.HTTP_400_BAD_REQUEST, "non_field_errors"),
68+
("missing_fields", "invitation", {"first_name": ""}, status.HTTP_400_BAD_REQUEST, "first_name"),
69+
("duplicate_username", "invitation", {"username": "regular_user"}, status.HTTP_400_BAD_REQUEST, "username"),
70+
])
71+
def test_accept_invitation_failures(self, api_client, scenario, invitation_fixture, payload_overrides, expected_status, error_key, request, valid_payload, regular_user):
72+
invitation = request.getfixturevalue(invitation_fixture)
73+
payload = {**valid_payload, **payload_overrides}
74+
75+
url = reverse('invitation-accept', kwargs={'pk': invitation.id})
76+
response = api_client.post(url, payload)
77+
78+
assert response.status_code == expected_status
79+
assert error_key in response.data

0 commit comments

Comments
 (0)