diff --git a/.coverage b/.coverage index 89efd3d..fd7fb8e 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.github/workflows/create_pull_request.yml b/.github/workflows/create_pull_request.yml index 105cf0c..7d4242b 100644 --- a/.github/workflows/create_pull_request.yml +++ b/.github/workflows/create_pull_request.yml @@ -42,6 +42,7 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi pip install pytest # Ensures pytest is available even if not in requirements.txt + pip install pytest pytest-cov - name: Create .env file run: | @@ -55,11 +56,26 @@ jobs: echo "MAIL_PORT=465" >> .env echo "MAIL_SERVER=smtp.gmail.com" >> .env - - name: Run Tests + id: coverage_step run: | mkdir logs - pytest -s + coverage run -m pytest + PERCENT=$(coverage report | grep TOTAL | awk '{print $NF}' | sed 's/%//') + echo "PERCENTAGE=$PERCENT" >> $GITHUB_OUTPUT + + - name: Create Coverage Badge + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.GIST_SECRET }} + gistID: b56b3d61a5e739fd26252cda094bace2 + filename: fastapi_project_structure_coverage.json + label: Coverage + message: ${{ steps.coverage_step.outputs.PERCENTAGE }}% + valColorRange: ${{ steps.coverage_step.outputs.PERCENTAGE }} + maxColorRange: 100 + minColorRange: 0 + create_pull_request: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 247cd3f..dfbd175 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # FastAPI Project Structure Template ⚙️ You can Generate Project Interactively Based on this template with the [FastAPI Gen8 CLI Tool](https://pypi.org/project/fastapi-gen8/) + +![Coverage Badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/brianobot/b56b3d61a5e739fd26252cda094bace2/raw/fastapi_project_structure_coverage.json) + [📖 Read Article here](https://medium.com/@brianobot9/the-ultimate-fastapi-project-blueprint-build-scalable-secure-and-maintainable-systems-with-ease-acbc4e058012) This repository provides a clean and scalable template for building FastAPI applications. It is designed to help you start new projects quickly with best practices in mind. diff --git a/app/services/auth.py b/app/services/auth.py index 4fad6a4..3185e1f 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -171,7 +171,7 @@ async def reset_password( user = await get_user(reset_data.email, session) if not user: - raise HTTPException(status_code=404, detail="User not found") + raise HTTPException(status_code=404, detail="Invalid Reset Code") hashed_password = get_password_hash(reset_data.new_password) stmt = ( @@ -201,7 +201,7 @@ async def update_user( if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", + detail="Incorrect Old Password", headers={"WWW-Authenticate": "Bearer"}, ) data.update({"password": get_password_hash(new_password)}) @@ -218,7 +218,9 @@ async def update_user( return result.scalar_one() -def create_access_token(data: dict, expires_delta: timedelta | None = None): +def create_access_token( + data: dict[str, str | datetime], expires_delta: timedelta | None = None +): to_encode = data.copy() if expires_delta: expire = datetime.now(UTC) + expires_delta @@ -229,7 +231,9 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): return encoded_jwt -def create_refresh_token(data: dict, expires_delta: timedelta | None = None): +def create_refresh_token( + data: dict[str, str | datetime], expires_delta: timedelta | None = None +): to_encode = data.copy() if expires_delta: expire = datetime.now(UTC) + expires_delta diff --git a/app/services/tests/test_auth.py b/app/services/tests/test_auth.py index e0b4118..e1e643b 100644 --- a/app/services/tests/test_auth.py +++ b/app/services/tests/test_auth.py @@ -1,3 +1,4 @@ +from datetime import timedelta from typing import Any import pytest @@ -109,11 +110,9 @@ async def test_initiate_password_reset_for_user(user: UserDB, session: AsyncSess assert result == {"detail": "Password reset code sent"} -async def test_initiate_password_reset_for_none_user( - user: UserDB, session: AsyncSession -): +async def test_initiate_password_reset_for_none_user(session: AsyncSession): result = await auth_services.initiate_password_reset( - user.email, session, BackgroundTasks(tasks=[]) + faker.email(), session, BackgroundTasks(tasks=[]) ) assert result == {"detail": "Password reset code sent"} @@ -133,14 +132,19 @@ async def test_verify_reset_password_otp_for_user(user: UserDB, session: AsyncSe ], ) async def test_verify_reset_password_otp_fails(session: AsyncSession, code: str): - redis_manager.cache_json_item(f"reset-code-{faker.email()}", {"code": "000000"}) + none_existence_email = faker.email() + redis_manager.cache_json_item( + f"reset-code-{none_existence_email}", {"code": "000000"} + ) with pytest.raises(HTTPException) as err: - await auth_services.verify_reset_password_otp(code, faker.email(), session) + await auth_services.verify_reset_password_otp( + code, none_existence_email, session + ) assert err.value.detail == "Invalid Reset Code" -async def test_reset_password(user: UserDB, session: AsyncSession): +async def test_reset_password_for_user(user: UserDB, session: AsyncSession): redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "000000"}) result = await auth_services.reset_password( auth_schemas.PasswordResetData( @@ -149,3 +153,72 @@ async def test_reset_password(user: UserDB, session: AsyncSession): session, ) assert result == {"detail": "Password reset successfully"} + + +@pytest.mark.parametrize( + "code", + [ + "000000", + "000001", + ], +) +async def test_reset_password_fails(user: UserDB, session: AsyncSession, code: str): + none_existence_email = faker.email() + redis_manager.cache_json_item( + f"reset-code-{none_existence_email}", {"code": "000000"} + ) + with pytest.raises(HTTPException) as err: + await auth_services.reset_password( + auth_schemas.PasswordResetData( + code=code, email=none_existence_email, new_password="newpassword" + ), + session, + ) + + assert err.value.detail == "Invalid Reset Code" + + +async def test_update_user(user: UserDB, session: AsyncSession): + updated_user = await auth_services.update_user( + user.email, + auth_schemas.UpdateUserModel( + old_password="password", new_password="new_password" + ), + session, + ) + assert isinstance(updated_user, UserDB) + assert auth_services.verify_password("new_password", updated_user.password) + + +async def test_update_user_fails(user: UserDB, session: AsyncSession): + with pytest.raises(HTTPException) as err: + await auth_services.update_user( + user.email, + auth_schemas.UpdateUserModel( + old_password="incorrectpassword", new_password="new_password" + ), + session, + ) + assert err.value.detail == "Incorrect Old Password" + + +@pytest.mark.parametrize( + "expires_delta", + [ + None, + timedelta(days=10), + ], +) +async def test_create_access_token(expires_delta: timedelta | None): + auth_services.create_access_token({"sub": faker.email()}, expires_delta) + + +@pytest.mark.parametrize( + "expires_delta", + [ + None, + timedelta(days=10), + ], +) +async def test_refresh_access_token(expires_delta: timedelta | None): + auth_services.create_refresh_token({"sub": faker.email()}, expires_delta)