From de6f735d406e54c4de7307703c1329025cee3672 Mon Sep 17 00:00:00 2001 From: TMCreme Date: Mon, 30 Mar 2026 08:48:56 +0000 Subject: [PATCH 1/3] fix: use db construction on non production --- db/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/database.py b/db/database.py index 406708b..22dff90 100644 --- a/db/database.py +++ b/db/database.py @@ -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() From 852a759a1dc66f9f3b6748e054965175b7abc37d Mon Sep 17 00:00:00 2001 From: TMCreme Date: Mon, 30 Mar 2026 09:49:06 +0000 Subject: [PATCH 2/3] chore: remove the migrate media endpoint --- api/routes/migrate_media.py | 8 ++++---- app.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/routes/migrate_media.py b/api/routes/migrate_media.py index d85e0c2..5339ea1 100644 --- a/api/routes/migrate_media.py +++ b/api/routes/migrate_media.py @@ -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), diff --git a/app.py b/app.py index 9401d75..0f0b255 100644 --- a/app.py +++ b/app.py @@ -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) @@ -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) From 9221bb0a221968a2cd451b20d9b9b3cf7d778b15 Mon Sep 17 00:00:00 2001 From: rkowusu17 Date: Tue, 31 Mar 2026 13:55:11 +0000 Subject: [PATCH 3/3] fix: fixed reset password error on the email template, added async.io.thread to speed up the execution time and added a test --- services/auth_service.py | 6 +- test/test_auth.py | 130 ++++++++++++++++++++++++++++++++++++--- utils/mail_service.py | 30 +++++---- 3 files changed, 144 insertions(+), 22 deletions(-) diff --git a/services/auth_service.py b/services/auth_service.py index 8551f8c..72b6b97 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -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: diff --git a/test/test_auth.py b/test/test_auth.py index 9e10a67..987184e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -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 @@ -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 = "test@example.com" -# 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="ghost@nowhere.com"), + 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="

Hi {0}, click here: {1}

" + ) + 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": "nobody@example.com"} + ) + 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" + diff --git a/utils/mail_service.py b/utils/mail_service.py index fa0f940..54da257 100644 --- a/utils/mail_service.py +++ b/utils/mail_service.py @@ -1,3 +1,4 @@ +import asyncio import smtplib import ssl from email.mime.text import MIMEText @@ -7,7 +8,6 @@ # 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): @@ -15,27 +15,35 @@ def read_html_file(file_path): 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}"}) @@ -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(