Skip to content

Commit d608b86

Browse files
authored
Merge pull request #6 from Devasy23/feat/auth-tests
Add authentication tests and GitHub Actions workflow
2 parents 7c7b60a + c91c105 commit d608b86

File tree

6 files changed

+365
-0
lines changed

6 files changed

+365
-0
lines changed

.github/workflows/run-tests.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Run Tests
2+
3+
on:
4+
pull_request:
5+
branches: [ main, master, feature/*]
6+
push:
7+
branches: [ main, master ]
8+
9+
jobs:
10+
test-backend:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: '3.12'
20+
21+
- name: Install dependencies
22+
run: |
23+
python -m pip install --upgrade pip
24+
cd backend
25+
pip install -r requirements.txt
26+
27+
- name: Run tests
28+
run: |
29+
cd $GITHUB_WORKSPACE
30+
export PYTHONPATH=$PYTHONPATH:./backend
31+
pytest backend/tests/

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ service-account*.json
7272
# IDE
7373
.vscode/settings.json
7474
.idea/
75+
.vscode/
7576

7677
# Logs
7778
*.log

backend/pytest.ini

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[pytest]
2+
env =
3+
SECRET_KEY=test_secret_key_for_pytest_1234567890abcdef
4+
TESTING=True # Example: if the app ever needs to know it's in test mode
5+
6+
asyncio_mode = auto
7+
8+
python_files = test_*.py tests_*.py *_test.py *_tests.py
9+
python_classes = Test* Tests*
10+
python_functions = test_*
11+
12+
# Optional: Add default command line options if desired
13+
# addopts = --verbose

backend/requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ firebase-admin==6.9.0
1111
python-dotenv==1.0.0
1212
bcrypt==4.0.1
1313
email-validator==2.2.0
14+
pytest
15+
pytest-asyncio
16+
httpx
17+
mongomock-motor
18+
pytest-env
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import pytest
2+
from httpx import AsyncClient, ASGITransport
3+
from fastapi import FastAPI, status
4+
from main import app # Assuming your FastAPI app instance is here
5+
from app.config import settings # To potentially override settings if needed, or check values
6+
from app.auth.security import verify_password, get_password_hash # For checking hashed password if necessary
7+
from datetime import datetime
8+
from bson import ObjectId
9+
10+
# It's good practice to set a specific test secret key if not relying on external env vars
11+
# For now, we assume 'your-super-secret-jwt-key-change-this-in-production' from config.py is used,
12+
# or an environment variable overrides it.
13+
# Ensure settings.secret_key is sufficiently long/random for HS256, the default is.
14+
15+
# Helper to get the mock_db if direct interaction is needed (though often not preferred)
16+
# from app.database import get_database
17+
18+
@pytest.mark.asyncio
19+
async def test_signup_with_email_success(mock_db): # mock_db fixture is auto-used
20+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
21+
signup_data = {
22+
"email": "[email protected]",
23+
"password": "securepassword123",
24+
"name": "Test User"
25+
}
26+
response = await ac.post("/auth/signup/email", json=signup_data)
27+
print(f"Response text for test_signup_with_email_success: {response.text}") # Print response text
28+
assert response.status_code == status.HTTP_200_OK # Or the actual success code used by the app
29+
response_data = response.json()
30+
assert "access_token" in response_data
31+
assert "refresh_token" in response_data
32+
assert "user" in response_data
33+
assert response_data["user"]["email"] == signup_data["email"]
34+
assert response_data["user"]["name"] == signup_data["name"]
35+
assert "_id" in response_data["user"] # Changed 'id' to '_id'
36+
37+
# Verify user creation in the mock database
38+
# db = get_database() # This will be the mock_db instance due to the fixture
39+
# Directly using mock_db fixture passed to test function
40+
created_user = await mock_db.users.find_one({"email": signup_data["email"]})
41+
assert created_user is not None
42+
assert created_user["name"] == signup_data["name"]
43+
assert verify_password(signup_data["password"], created_user["hashed_password"])
44+
45+
# Verify refresh token creation
46+
refresh_token_record = await mock_db.refresh_tokens.find_one({"user_id": created_user["_id"]})
47+
assert refresh_token_record is not None
48+
assert not refresh_token_record["revoked"]
49+
assert response_data["refresh_token"] == refresh_token_record["token"]
50+
51+
@pytest.mark.asyncio
52+
async def test_signup_with_existing_email(mock_db):
53+
# Pre-populate with a user
54+
existing_email = "[email protected]"
55+
await mock_db.users.insert_one({
56+
"email": existing_email,
57+
"hashed_password": "hashedpassword",
58+
"name": "Existing User",
59+
"created_at": "sometime" # Simplified for mock
60+
})
61+
62+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
63+
signup_data = {
64+
"email": existing_email,
65+
"password": "newpassword123",
66+
"name": "New User"
67+
}
68+
response = await ac.post("/auth/signup/email", json=signup_data)
69+
70+
assert response.status_code == status.HTTP_400_BAD_REQUEST
71+
response_data = response.json()
72+
assert "detail" in response_data
73+
assert "User with this email already exists" in response_data["detail"]
74+
75+
@pytest.mark.asyncio
76+
@pytest.mark.parametrize(
77+
"payload_modifier, affected_field, description",
78+
[
79+
(lambda p: p.pop("email"), "email", "missing_email"),
80+
(lambda p: p.pop("password"), "password", "missing_password"),
81+
(lambda p: p.pop("name"), "name", "missing_name"),
82+
(lambda p: p.update({"password": "short"}), "password", "short_password"),
83+
(lambda p: p.update({"email": "invalidemail"}), "email", "invalid_email"),
84+
]
85+
)
86+
async def test_signup_invalid_input_refined(mock_db, payload_modifier, affected_field, description):
87+
base_payload = {
88+
"email": "[email protected]",
89+
"password": "securepassword123",
90+
"name": "Test User"
91+
}
92+
payload_modifier(base_payload) # Modify the payload based on the current test case
93+
94+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
95+
response = await ac.post("/auth/signup/email", json=base_payload)
96+
97+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
98+
response_data = response.json()
99+
assert "detail" in response_data
100+
101+
error_found = False
102+
for error_item in response_data["detail"]:
103+
# Check if the 'loc' array (location of the error) contains the affected_field
104+
if affected_field in error_item.get("loc", []):
105+
error_type = error_item.get("type", "")
106+
# Specific checks for error types for Pydantic v2
107+
if description == "short_password" and error_type == "string_too_short":
108+
error_found = True
109+
break
110+
elif description == "invalid_email" and error_type == "value_error": # Simpler check, msg gives more detail
111+
error_found = True
112+
break
113+
elif "missing" in description and error_type == "missing":
114+
error_found = True
115+
break
116+
assert error_found, f"Validation error for '{description}' (field: {affected_field}) not found or not specific enough in {response_data['detail']}"
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_login_with_email_success(mock_db):
121+
user_email = "[email protected]"
122+
user_password = "loginpassword123"
123+
hashed_password = get_password_hash(user_password)
124+
125+
# Pre-populate user in mock_db
126+
# Ensure _id is an ObjectId if other parts of the code expect it,
127+
# though for mock_db string usually works fine unless there's specific BSON type checking.
128+
# For consistency with how AuthService creates user_id for refresh tokens (ObjectId(user_id)),
129+
# let's store _id as ObjectId here.
130+
user_obj_id = ObjectId()
131+
await mock_db.users.insert_one({
132+
"_id": user_obj_id,
133+
"email": user_email,
134+
"hashed_password": hashed_password,
135+
"name": "Login User",
136+
"avatar": None,
137+
"currency": "USD",
138+
"created_at": datetime.utcnow(), # Ensure datetime is used
139+
"auth_provider": "email",
140+
"firebase_uid": None
141+
})
142+
143+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
144+
login_data = {
145+
"email": user_email,
146+
"password": user_password
147+
}
148+
response = await ac.post("/auth/login/email", json=login_data)
149+
150+
assert response.status_code == status.HTTP_200_OK
151+
response_data = response.json()
152+
assert "access_token" in response_data
153+
assert "refresh_token" in response_data
154+
assert "user" in response_data
155+
assert response_data["user"]["email"] == user_email
156+
assert response_data["user"]["_id"] == str(user_obj_id) # Changed 'id' to '_id'
157+
158+
# Verify refresh token creation for this user
159+
# Refresh token service stores user_id as ObjectId
160+
refresh_token_record = await mock_db.refresh_tokens.find_one({"user_id": user_obj_id})
161+
assert refresh_token_record is not None
162+
assert not refresh_token_record["revoked"]
163+
assert response_data["refresh_token"] == refresh_token_record["token"]
164+
165+
@pytest.mark.asyncio
166+
async def test_login_with_incorrect_password(mock_db):
167+
user_email = "[email protected]"
168+
correct_password = "correctpassword"
169+
incorrect_password = "incorrectpassword"
170+
171+
await mock_db.users.insert_one({
172+
"_id": ObjectId(),
173+
"email": user_email,
174+
"hashed_password": get_password_hash(correct_password),
175+
"name": "Wrong Pass User",
176+
"created_at": datetime.utcnow()
177+
})
178+
179+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
180+
login_data = {
181+
"email": user_email,
182+
"password": incorrect_password
183+
}
184+
response = await ac.post("/auth/login/email", json=login_data)
185+
186+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
187+
response_data = response.json()
188+
assert "detail" in response_data
189+
assert "Incorrect email or password" in response_data["detail"]
190+
191+
@pytest.mark.asyncio
192+
async def test_login_with_non_existent_email(mock_db):
193+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
194+
login_data = {
195+
"email": "[email protected]",
196+
"password": "anypassword"
197+
}
198+
response = await ac.post("/auth/login/email", json=login_data)
199+
200+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
201+
response_data = response.json()
202+
assert "detail" in response_data
203+
assert "Incorrect email or password" in response_data["detail"] # Same message for both cases
204+
205+
@pytest.mark.asyncio
206+
@pytest.mark.parametrize(
207+
"payload_modifier, affected_field, description",
208+
[
209+
(lambda p: p.pop("email"), "email", "missing_email"),
210+
(lambda p: p.pop("password"), "password", "missing_password"),
211+
(lambda p: p.update({"email": "invalidemailformat"}), "email", "invalid_email_format"),
212+
]
213+
)
214+
async def test_login_invalid_input(mock_db, payload_modifier, affected_field, description):
215+
base_payload = {
216+
"email": "[email protected]",
217+
"password": "validpassword123"
218+
}
219+
# It doesn't matter if the user exists or not for input validation,
220+
# as validation happens before DB lookup for these kinds of errors.
221+
payload_modifier(base_payload)
222+
223+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
224+
response = await ac.post("/auth/login/email", json=base_payload)
225+
226+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
227+
response_data = response.json()
228+
assert "detail" in response_data
229+
230+
error_found = False
231+
for error_item in response_data["detail"]:
232+
if affected_field in error_item.get("loc", []):
233+
error_type = error_item.get("type", "")
234+
if description == "invalid_email_format" and error_type == "value_error": # Simpler check
235+
error_found = True
236+
break
237+
elif "missing" in description and error_type == "missing":
238+
error_found = True
239+
break
240+
assert error_found, f"Validation error for '{description}' (field: {affected_field}) not found in {response_data['detail']}"

backend/tests/conftest.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import pytest
2+
import pytest_asyncio
3+
from unittest.mock import patch, MagicMock
4+
import firebase_admin # Added
5+
import os # Added
6+
from mongomock_motor import AsyncMongoMockClient
7+
8+
@pytest.fixture(scope="session", autouse=True)
9+
def mock_firebase_admin(request):
10+
# Check if we're in a test session that might use firebase,
11+
# otherwise this mock might be too broad.
12+
# For now, apply session-wide for simplicity as auth_service imports firebase_admin.
13+
14+
# Mock firebase_admin.credentials.Certificate
15+
# Create a mock object that can be called and returns another mock
16+
mock_certificate = MagicMock()
17+
# When firebase_admin.credentials.Certificate(path) is called, it returns a dummy object
18+
mock_certificate.return_value = MagicMock()
19+
20+
# Mock firebase_admin.initialize_app
21+
mock_initialize_app = MagicMock()
22+
23+
# Mock firebase_admin.auth for verify_id_token if Google login tests were being added
24+
mock_firebase_auth = MagicMock()
25+
mock_firebase_auth.verify_id_token.return_value = {
26+
"uid": "test_firebase_uid",
27+
"email": "[email protected]",
28+
"name": "Firebase User",
29+
"picture": None
30+
} # Dummy decoded token
31+
32+
patches = [
33+
patch("firebase_admin.credentials.Certificate", mock_certificate),
34+
patch("firebase_admin.initialize_app", mock_initialize_app),
35+
patch("firebase_admin.auth", mock_firebase_auth) # Mock auth module
36+
]
37+
38+
for p in patches:
39+
p.start()
40+
request.addfinalizer(p.stop)
41+
42+
# Also, to prevent the "Firebase service account not found" print,
43+
# we can temporarily set one of the expected firebase env vars
44+
# so the code thinks it's configured, but initialize_app being mocked means nothing happens.
45+
# This is optional and depends on whether the print is problematic.
46+
# with patch.dict(os.environ, {"FIREBASE_PROJECT_ID": "test-project"}, clear=True):
47+
# yield
48+
49+
# If not using the os.environ patch, just yield:
50+
yield
51+
52+
@pytest_asyncio.fixture(scope="function", autouse=True)
53+
async def mock_db():
54+
print("mock_db fixture: Creating AsyncMongoMockClient")
55+
mock_mongo_client = AsyncMongoMockClient()
56+
print(f"mock_db fixture: mock_mongo_client type: {type(mock_mongo_client)}")
57+
mock_database_instance = mock_mongo_client["test_db"]
58+
print(f"mock_db fixture: mock_database_instance type: {type(mock_database_instance)}, is None: {mock_database_instance is None}")
59+
60+
# Ensure we are patching the correct target
61+
# 'app.database.get_database' is where the function is defined.
62+
# 'app.auth.service.get_database' is where it's imported and looked up by AuthService.
63+
# Patching where it's looked up can be more robust.
64+
65+
with patch("app.auth.service.get_database", return_value=mock_database_instance) as mock_get_database_function:
66+
print(f"mock_db fixture: Patching app.auth.service.get_database. Patched object: {mock_get_database_function}")
67+
print(f"mock_db fixture: Patched return_value: {mock_get_database_function.return_value}, type: {type(mock_get_database_function.return_value)}")
68+
yield mock_database_instance # yield the same instance for direct use if needed
69+
print("mock_db fixture: Restoring app.auth.service.get_database")
70+
71+
# Optional: clear all collections in the mock_database after each test
72+
# This ensures test isolation.
73+
# mongomock doesn't have a straightforward way to list all collections like a real DB,
74+
# so we might need to clear known collections if necessary, or rely on new client per test.
75+
# For now, a new AsyncMongoMockClient per function scope should provide good isolation.

0 commit comments

Comments
 (0)