Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions api/routes/migrate_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ def _find_cloudinary_url(s3_url: str, mapping: dict[str, str]) -> str | None:
return None


@migrate_media_route.post(
"/s3-to-cloudinary",
summary="Replace S3 URLs with Cloudinary URLs in the database",
)
# @migrate_media_route.post(
# "/s3-to-cloudinary",
# summary="Replace S3 URLs with Cloudinary URLs in the database",
# )
def migrate_s3_to_cloudinary(
dry_run: bool = True,
db: Session = Depends(get_db),
Expand Down
4 changes: 2 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from fastapi_pagination import add_pagination
from api.routes.endpoints import endpoints_route
from api.routes.users import users_route
from api.routes.migrate_media import migrate_media_route
# from api.routes.migrate_media import migrate_media_route
from utils.endpoints_status import create_signup_endpoint

# Base.metadata.create_all(bind=engine)
Expand Down Expand Up @@ -77,7 +77,7 @@ async def startup_event():
app.include_router(email_templates_route, prefix=v1_prefix)
app.include_router(endpoints_route, prefix=v1_prefix)
app.include_router(users_route, prefix=v1_prefix)
app.include_router(migrate_media_route, prefix=v1_prefix)
# app.include_router(migrate_media_route, prefix=v1_prefix)

add_pagination(app)

Expand Down
2 changes: 1 addition & 1 deletion db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def set_up_db(production_env) -> tuple:
if production_env:
engine = create_engine(settings.DATABASE_URL)
else:
engine = create_engine(settings.DATABASE_URL)
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
Expand Down
6 changes: 5 additions & 1 deletion services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ async def forgot_password(self, email: str) -> dict:
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
reset_token = create_reset_token(email)
email_template = self.email_template_repo.get_by_name("PASSWORD RESET")
try:
email_template = self.email_template_repo.get_by_name("PASSWORD RESET")
except Exception:
logger.warning("email_templates table not found or inaccessible — falling back to HTML file template")
email_template = None
return await send_password_reset_email(email, reset_token, user.username, email_template)

def reset_password(self, token: str, new_password: str) -> dict:
Expand Down
130 changes: 120 additions & 10 deletions test/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from fastapi import HTTPException
from fastapi.testclient import TestClient
from jose import jwt
from api.api_models.user import ResetPasswordRequest
from api.routes.auth import reset_password
from unittest.mock import AsyncMock, patch

from api.api_models.user import ForgotPasswordRequest, ResetPasswordRequest
from api.routes.auth import forgot_password, reset_password
from app import app
from core.config import settings
# from utils.mail_service import send_email
from utils.oauth2 import create_reset_token, verify_reset_token


Expand Down Expand Up @@ -140,11 +141,120 @@ def test_reset_password_invalid_token():
assert e.value.detail == 'Invalid token'


# test needs a valid email.

# @pytest.mark.asyncio
# async def test_send_email():
# email = "[email protected]"
# reset_token = "test_reset_token"
# response = await send_email(email, reset_token)
# assert response.status_code == 200
# Forgot Password Tests


@pytest.mark.asyncio
async def test_forgot_password_user_not_found(session):
"""Should raise 404 when the email doesn't match any user in the DB."""
with pytest.raises(HTTPException) as exc:
await forgot_password(
ForgotPasswordRequest(email="[email protected]"),
db=session
)
assert exc.value.status_code == 404
assert exc.value.detail == "User not found"


@pytest.mark.asyncio
async def test_forgot_password_success(test_user, session):
"""
Happy path: user exists → reset token created → email dispatched.
The SMTP call is mocked so no real email is sent.

Flow traced here:
POST /forgot-password
→ AuthService.forgot_password()
→ UserRepo.get_by_email() ✓ user found
→ create_reset_token(email) ✓ JWT built (mocked)
→ EmailTemplateRepo.get_by_name ✓ returns None (empty DB table)
→ send_password_reset_email()
→ reads utils/email_templates/password-reset.html
→ injects {username} and {reset_url} into the template
→ send_email() → _send_email_sync() [MOCKED]
"""
email = test_user["email"]

# Mock SMTP so the test never opens a real connection
with patch(
"utils.mail_service._send_email_sync",
return_value=None # sync mock — to_thread wraps it
) as mock_smtp:
response = await forgot_password(
ForgotPasswordRequest(email=email),
db=session
)

# _send_email_sync should have been called exactly once
mock_smtp.assert_called_once()

# The subject and recipient in the call args
call_args = mock_smtp.call_args
subject, recipient, html_body = call_args.args

assert recipient == email
assert "Slightly Techie" in subject # matches email_subject fallback

# Verify the HTML template was loaded and the username was injected
username = test_user["username"]
assert username in html_body # {0} → username substituted
assert "reset" in html_body.lower() # sanity: reset link present


@pytest.mark.asyncio
async def test_forgot_password_uses_db_template_when_present(test_user, session):
"""
When a 'PASSWORD RESET' template exists in the DB, it should be used
instead of the HTML file.
"""
from db.models.email_template import EmailTemplate

db_template = EmailTemplate(
template_name="PASSWORD RESET",
subject="Custom Reset Subject",
html_content="<p>Hi {0}, click here: {1}</p>"
)
session.add(db_template)
session.commit()

email = test_user["email"]

with patch("utils.mail_service._send_email_sync", return_value=None) as mock_smtp:
await forgot_password(
ForgotPasswordRequest(email=email),
db=session
)

call_args = mock_smtp.call_args
subject, recipient, html_body = call_args.args

assert subject == "Custom Reset Subject" # DB template subject used
assert test_user["username"] in html_body # {0} substituted with username
assert "http" in html_body # {1} substituted with reset URL


def test_forgot_password_via_http_user_not_found(client):
"""End-to-end HTTP test: unknown email → 404."""
res = client.post(
"/api/v1/users/forgot-password",
json={"email": "[email protected]"}
)
assert res.status_code == 404
assert res.json()["detail"] == "User not found"


def test_forgot_password_via_http_success(client, test_user):
"""
End-to-end HTTP test: registered user email → 200.
SMTP is mocked at the module level so no real email is sent.
"""
with patch("utils.mail_service._send_email_sync", return_value=None):
res = client.post(
"/api/v1/users/forgot-password",
json={"email": test_user["email"]}
)

assert res.status_code == 200
assert res.json()["message"] == "Email sent successfully"

30 changes: 19 additions & 11 deletions utils/mail_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import smtplib
import ssl
from email.mime.text import MIMEText
Expand All @@ -7,35 +8,42 @@

# from api.api_models.email_template import EmailTemplateName
from core.config import settings
# from db.models.email_template import EmailTemplate


def read_html_file(file_path):
with open(file_path, 'r') as file:
return file.read()


async def send_email(subject: str, recipient_email: str, html_content: str) -> JSONResponse:
def _send_email_sync(subject: str, recipient_email: str, html_content: str):
"""
Generic email sending function.
Blocking SMTP call — intentionally synchronous so it can be
offloaded to a thread pool via asyncio.to_thread().
Uses SMTP_SSL (port 465). If switching to port 587, replace with
SMTP + starttls() instead.
"""
email_sender = settings.EMAIL_SENDER
email_password = settings.EMAIL_PASSWORD
email_receiver = recipient_email

em = MIMEMultipart()
em['From'] = email_sender
em['To'] = email_receiver
em['To'] = recipient_email
em['Subject'] = subject
html_part = MIMEText(html_content, 'html')
em.attach(html_part)
em.attach(MIMEText(html_content, 'html'))

context = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.EMAIL_SERVER, settings.EMAIL_PORT, context=context) as smtp:
smtp.login(email_sender, email_password)
smtp.sendmail(email_sender, recipient_email, em.as_string())


async def send_email(subject: str, recipient_email: str, html_content: str) -> JSONResponse:
"""
Async wrapper — runs the blocking SMTP call in a thread pool
so it never blocks the FastAPI event loop.
"""
try:
with smtplib.SMTP_SSL(settings.EMAIL_SERVER, settings.EMAIL_PORT, context=context) as smtp:
smtp.login(email_sender, email_password)
smtp.sendmail(email_sender, email_receiver, em.as_string())
await asyncio.to_thread(_send_email_sync, subject, recipient_email, html_content)
return JSONResponse(status_code=200, content={"message": "Email sent successfully"})
except Exception as e:
return JSONResponse(status_code=500, content={"message": f"An error occurred: {e}"})
Expand All @@ -57,7 +65,7 @@ async def send_password_reset_email(email: str, reset_token: str, username: str,
html_content = f"Hello {username}, please reset your password by clicking this link: {reset_password_url}"
email_subject = "Slightly Techie Password Reset"

await send_email(email_subject, email, html_content)
return await send_email(email_subject, email, html_content)


async def send_applicant_task(
Expand Down
Loading