diff --git a/README.md b/README.md index 2cfb752..5842207 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,10 @@ This is a simple app is aimed to solidify and escalate web api knowledge Build and run the app: +```bash docker build -t webapi:dev .; docker run --rm -p 8000:8000 webapi:dev +``` + +## Database diagram documentation + +The ORM model and relational mapping documentation for dbdiagram.io is available at [`plans/dbdiagram.md`](plans/dbdiagram.md). diff --git a/plans/dbdiagram.md b/plans/dbdiagram.md new file mode 100644 index 0000000..f7873a0 --- /dev/null +++ b/plans/dbdiagram.md @@ -0,0 +1,49 @@ +# Database Diagram Documentation + +This document describes the ORM data model implemented in [`User`](../webapi/models/user.py:8) and [`Prompts`](../webapi/models/prompts.py:6), and provides a DBML definition ready for dbdiagram.io. + +## Source of truth + +- [`User`](../webapi/models/user.py:8) +- [`Prompts`](../webapi/models/prompts.py:6) +- DB engine initialization in [`engine = create_engine(config.DB_URL, echo=True)`](../webapi/db/db_connection.py:6) +- Schema bootstrap in [`SQLModel.metadata.create_all(engine)`](../webapi/db/db_connection.py:10) + +## DBML for dbdiagram.io +Visualize below code at: https://dbdiagram.io/d + +```dbml +Table user { + id int [pk, increment] + username varchar(50) [not null] + name varchar(100) [not null] + last_name varchar(100) [not null] + phone bigint [unique] + email varchar(100) [not null] + hashed_password varchar(255) [not null] + + Note: 'Mapped from SQLModel User entity' +} + +Table prompts { + id int [pk, increment] + user_id int [not null, ref: > user.id] + model_name varchar(30) [not null] + prompt_text varchar(150) [not null] + category varchar(30) [not null] + rate varchar(30) [not null] + + Note: 'Mapped from SQLModel Prompts entity' +} +``` + +## Relationship cardinality + +- One-to-many from [`User.prompts`](../webapi/models/user.py:18) to [`Prompts.user`](../webapi/models/prompts.py:14) +- Foreign key defined at [`user_id: int = Field(foreign_key='user.id', nullable=False)`](../webapi/models/prompts.py:8) + +## Notes and naming consistency + +- ORM class is pluralized as [`class Prompts(SQLModel, table=True):`](../webapi/models/prompts.py:6), while the related attribute in [`User`](../webapi/models/user.py:18) is also plural. +- File naming appears to include both [`webapi/models/prompts.py`](../webapi/models/prompts.py) and a visible editor tab for [`webapi/models/prompt.py`](../webapi/models/prompt.py). Consolidating to one naming convention may reduce confusion. +- If desired in a future refactor, entity naming could be normalized to singular class names with explicit `__tablename__` values to keep SQL table names stable. diff --git a/webapi/api/endpoints/v1/cards.py b/webapi/api/endpoints/v1/cards.py deleted file mode 100644 index fafcf38..0000000 --- a/webapi/api/endpoints/v1/cards.py +++ /dev/null @@ -1,92 +0,0 @@ -from fastapi import APIRouter, HTTPException, Depends, Header -from sqlmodel import Session, select -from typing import Optional -from models.user import User -from models.card import Cards -from db.db_connection import get_session -from auth.auth_service import validar_jwt -from infrastructure.email.smtp_service import send_email - -router = APIRouter() - -@router.post("/cards", response_model=Cards) -async def create_card( - card: Cards, - session: Session = Depends(get_session), - authorization: Optional[str] = Header(None), - send_email_header: Optional[str] = Header(None, alias="send_email") -): - data = validar_jwt(authorization) - if not data: - raise HTTPException(status_code=401, detail="Unauthorized token") - # Ensure the user exists before creating a card - user = session.get(User, card.user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if str(send_email_header).lower() != "false": - await send_email(user.email, user.username) - session.add(card) - session.commit() - session.refresh(card) - return card - -@router.get("", response_model=list[Cards]) -def read_cards(skip: int = 0, limit: int = 10, - session: Session = Depends(get_session), - authorization: Optional[str] = Header(None)): - data = validar_jwt(authorization) - if not data: - raise HTTPException(status_code=401, detail="Unauthorized token") - statement = select(Cards).offset(skip).limit(limit) - cards = session.exec(statement).all() - if not cards: - raise HTTPException(status_code=404, detail="No cards found") - return cards - - -@router.get("/cards/{card_id}", response_model=Cards) -def get_card(card_id: int, session: Session = Depends(get_session), - authorization: Optional[str] = Header(None)): - data = validar_jwt(authorization) - if not data: - raise HTTPException(status_code=401, detail="Unauthorized token") - card = session.get(Cards, card_id) - if not card: - raise HTTPException(status_code=404, detail="Card not found") - return card - -@router.put("/cards/{card_id}", response_model=Cards) -def update_card(card_id: int, card: Cards, - session: Session = Depends(get_session), - authorization: Optional[str] = Header(None)): - data = validar_jwt(authorization) - if not data: - raise HTTPException(status_code=401, detail="Unauthorized token") - existing_card = session.get(Cards, card_id) - if not existing_card: - raise HTTPException(status_code=404, detail="Card not found") - existing_card.card_type = card.card_type - existing_card.card_number = card.card_number - existing_card.expiration_date = card.expiration_date - existing_card.user_id = card.user_id # Ensure the user exists - user = session.get(User, card.user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found for this card") - session.add(existing_card) - session.commit() - session.refresh(existing_card) - return existing_card - - -@router.delete("/cards/{card_id}") -def delete_card(card_id: int, session: Session = Depends(get_session), - authorization: Optional[str] = Header(None)): - data = validar_jwt(authorization) - if not data: - raise HTTPException(status_code=401, detail="Unauthorized token") - card = session.get(Cards, card_id) - if not card: - raise HTTPException(status_code=404, detail="Card not found to delete") - session.delete(card) - session.commit() - return card diff --git a/webapi/api/endpoints/v1/prompts.py b/webapi/api/endpoints/v1/prompts.py new file mode 100644 index 0000000..cba625e --- /dev/null +++ b/webapi/api/endpoints/v1/prompts.py @@ -0,0 +1,100 @@ +from fastapi import APIRouter, HTTPException, Depends, Header +from sqlmodel import Session, select +from typing import Optional +from models.user import User +from models.prompts import Prompts +from db.db_connection import get_session +from auth.auth_service import validar_jwt +from infrastructure.email.smtp_service import send_email + +router = APIRouter() + +@router.post("", response_model=Prompts) +async def create_prompt( + prompt: Prompts, + session: Session = Depends(get_session), + authorization: Optional[str] = Header(None), + send_email_header: Optional[str] = Header("false", alias="send_email") +): + data = validar_jwt(authorization) + if not data: + raise HTTPException(status_code=401, detail="Unauthorized token") + # Ensure the user exists before creating a prompt + user = session.get(User, prompt.user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + session.add(prompt) + session.commit() + session.refresh(prompt) + + # Email notification is best-effort and should not block prompt persistence. + if str(send_email_header).lower() == "true": + try: + await send_email(user.email, user.username) + except Exception: + pass + + return prompt + +@router.get("", response_model=list[Prompts]) +def read_prompts(skip: int = 0, limit: int = 10, + session: Session = Depends(get_session), + authorization: Optional[str] = Header(None)): + data = validar_jwt(authorization) + if not data: + raise HTTPException(status_code=401, detail="Unauthorized token") + statement = select(Prompts).offset(skip).limit(limit) + prompts = session.exec(statement).all() + if not prompts: + raise HTTPException(status_code=404, detail="No prompts found") + return prompts + + +@router.get("/{prompt_id}", response_model=Prompts) +def get_prompt(prompt_id: int, session: Session = Depends(get_session), + authorization: Optional[str] = Header(None)): + data = validar_jwt(authorization) + if not data: + raise HTTPException(status_code=401, detail="Unauthorized token") + prompt = session.get(Prompts, prompt_id) + if not prompt: + raise HTTPException(status_code=404, detail="Prompt not found") + return prompt + +@router.put("/{prompt_id}", response_model=Prompts) +def update_prompt(prompt_id: int, prompt: Prompts, + session: Session = Depends(get_session), + authorization: Optional[str] = Header(None)): + data = validar_jwt(authorization) + if not data: + raise HTTPException(status_code=401, detail="Unauthorized token") + existing_prompt = session.get(Prompts, prompt_id) + if not existing_prompt: + raise HTTPException(status_code=404, detail="Prompt not found") + existing_prompt.model_name = prompt.model_name + existing_prompt.prompt_text = prompt.prompt_text + existing_prompt.category = prompt.category + existing_prompt.rate = prompt.rate + existing_prompt.user_id = prompt.user_id # Ensure the user exists + user = session.get(User, prompt.user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found for this prompt") + session.add(existing_prompt) + session.commit() + session.refresh(existing_prompt) + return existing_prompt + + +@router.delete("/{prompt_id}") +def delete_prompt(prompt_id: int, session: Session = Depends(get_session), + authorization: Optional[str] = Header(None)): + data = validar_jwt(authorization) + if not data: + raise HTTPException(status_code=401, detail="Unauthorized token") + prompt = session.get(Prompts, prompt_id) + if not prompt: + raise HTTPException(status_code=404, detail="Prompt not found to delete") + session.delete(prompt) + session.commit() + return prompt diff --git a/webapi/api/endpoints/v1/users.py b/webapi/api/endpoints/v1/users.py index 895c538..bb68e09 100644 --- a/webapi/api/endpoints/v1/users.py +++ b/webapi/api/endpoints/v1/users.py @@ -2,7 +2,7 @@ from sqlmodel import Session, select from models.user import User from typing import Optional -from schemas.user_schema import UserReadWithCards +from schemas.user_schema import UserReadWithPrompts from db.db_connection import get_session from auth.auth_service import validar_jwt from passlib.hash import sha256_crypt @@ -41,8 +41,8 @@ def get_user(user_id: int, session: Session = Depends(get_session), return user -@router.get("/cards/{user_id}", response_model=UserReadWithCards) -def get_user_with_cards(user_id: int, session: Session = Depends(get_session), +@router.get("/prompts/{user_id}", response_model=UserReadWithPrompts) +def get_user_with_prompts(user_id: int, session: Session = Depends(get_session), authorization: Optional[str] = Header(None)): data = validar_jwt(authorization) if not data: @@ -52,8 +52,8 @@ def get_user_with_cards(user_id: int, session: Session = Depends(get_session), user = result.one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") - # Access cards within session to trigger lazy load - _ = user.cards + # Access prompts within session to trigger lazy load + _ = user.prompts return user @router.put("/{user_id}", response_model=User) @@ -95,7 +95,9 @@ def delete_user(user_id: int, session: Session = Depends(get_session), raise HTTPException(status_code=404, detail="User not found to delete") session.delete(user) session.commit() + except HTTPException: + raise except Exception as e: session.rollback() raise HTTPException(status_code=500, detail=f"Error deleting user: {str(e)}") - return user \ No newline at end of file + return user diff --git a/webapi/api/routers.py b/webapi/api/routers.py index 3af8c20..8043648 100644 --- a/webapi/api/routers.py +++ b/webapi/api/routers.py @@ -1,7 +1,7 @@ from fastapi import APIRouter -from .endpoints.v1 import auths, users, cards +from .endpoints.v1 import auths, users, prompts api_router = APIRouter() api_router.include_router(auths.router, prefix="/auth", tags=["Auth"]) -api_router.include_router(cards.router, prefix="/cards", tags=["Cards"]) -api_router.include_router(users.router, prefix="/users", tags=["Users"]) \ No newline at end of file +api_router.include_router(prompts.router, prefix="/prompts", tags=["Prompts"]) +api_router.include_router(users.router, prefix="/users", tags=["Users"]) diff --git a/webapi/main.py b/webapi/main.py index 657426b..07c33a5 100644 --- a/webapi/main.py +++ b/webapi/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI import uvicorn from api.routers import api_router -# from api.endpoints.v1 import auths, users, cards +# from api.endpoints.v1 import auths, users, prompts tags_metadata = [ { @@ -13,15 +13,15 @@ "description": "Operations with users (CRUD).", }, { - "name": "Cards", - "description": "Operations with cards.", + "name": "Prompts", + "description": "Operations with prompts.", }, ] myapp = FastAPI( - title="Bank API", - description="API for managing users, cards, and authentication in a banking system.", - version="1.0.0", + title="Portfolio API", + description="API for managing users, prompts, and authentication in a web environment.", + version="0.1.0", docs_url="/docs", redoc_url="/redoc", openapi_tags=tags_metadata @@ -46,7 +46,7 @@ @myapp.get("/") def root(): - return {"Bank App": "This is a simple app using FastAPI and mariadb."} + return {"Portfolio App": "This is a simple app using FastAPI and mariadb."} # Include all API routes @@ -55,9 +55,9 @@ def root(): # Include routers """ app.include_router(users.router, prefix="/users", tags=["Users"]) -app.include_router(cards.router, prefix="/cards", tags=["Cards"]) +app.include_router(prompts.router, prefix="/prompts", tags=["Prompts"]) app.include_router(auths.router, prefix="/auth", tags=["Auth"]) """ if __name__ == "__main__": - uvicorn.run(myapp, host="127.0.0.1", port=8000) \ No newline at end of file + uvicorn.run(myapp, host="127.0.0.1", port=8000) diff --git a/webapi/models/__init__.py b/webapi/models/__init__.py index a39092b..09a9abc 100644 --- a/webapi/models/__init__.py +++ b/webapi/models/__init__.py @@ -1,5 +1,5 @@ # models/__init__.py from .user import User -from .card import Cards +from .prompts import Prompts -__all__ = ["User", "Cards"] \ No newline at end of file +__all__ = ["User", "Prompts"] diff --git a/webapi/models/card.py b/webapi/models/prompts.py similarity index 50% rename from webapi/models/card.py rename to webapi/models/prompts.py index b937cb1..dcc58ed 100644 --- a/webapi/models/card.py +++ b/webapi/models/prompts.py @@ -1,17 +1,17 @@ from typing import Optional from sqlmodel import SQLModel, Field, Relationship -from sqlalchemy import CHAR import models -class Cards(SQLModel, table=True): +class Prompts(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id", nullable=False) - card_type: str = Field(max_length=10, nullable=False) - card_number: str = Field(sa_type=CHAR(16), nullable=False, unique=True) - expiration_date: str = Field(max_length=7, nullable=False) # MM/YYYY format + model_name: str = Field(max_length=30, nullable=False) + prompt_text: str = Field(max_length=150, nullable=False) + category: str = Field(max_length=30, nullable=False) + rate: str = Field(max_length=30, nullable=False) user: Optional["User"] = Relationship( sa_relationship_kwargs={"lazy": "selectin"}, - back_populates="cards" - ) \ No newline at end of file + back_populates="prompts" + ) diff --git a/webapi/models/user.py b/webapi/models/user.py index b2e5080..29ada1a 100644 --- a/webapi/models/user.py +++ b/webapi/models/user.py @@ -15,7 +15,7 @@ class User(SQLModel, table=True): email: EmailStr = Field(max_length=100, nullable=False) hashed_password: str = Field(max_length=255) # Longer for bcrypt hashes - cards: List["Cards"] = Relationship( + prompts: List["Prompts"] = Relationship( sa_relationship_kwargs={"lazy": "selectin"}, back_populates="user" - ) \ No newline at end of file + ) diff --git a/webapi/schemas/card_schema.py b/webapi/schemas/card_schema.py deleted file mode 100644 index f9bba94..0000000 --- a/webapi/schemas/card_schema.py +++ /dev/null @@ -1,12 +0,0 @@ -from pydantic import BaseModel - - -class CardRead(BaseModel): - id: int - card_type: str - card_number: str - expiration_date: str - - class Config: - from_attributes = True - diff --git a/webapi/schemas/prompt_schema.py b/webapi/schemas/prompt_schema.py new file mode 100644 index 0000000..ba36e7a --- /dev/null +++ b/webapi/schemas/prompt_schema.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class PromptRead(BaseModel): + id: int + model_name: str + prompt_text: str + category: str + rate: str + + class Config: + from_attributes = True diff --git a/webapi/schemas/user_schema.py b/webapi/schemas/user_schema.py index 1ed00e8..cf2f30a 100644 --- a/webapi/schemas/user_schema.py +++ b/webapi/schemas/user_schema.py @@ -1,14 +1,14 @@ from typing import Optional, List from pydantic import BaseModel -from .card_schema import CardRead +from .prompt_schema import PromptRead -class UserReadWithCards(BaseModel): +class UserReadWithPrompts(BaseModel): id: int name: str last_name: str phone: Optional[int] email: str - cards: List[CardRead] = [] + prompts: List[PromptRead] = [] class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/webapi/tests/conftest.py b/webapi/tests/conftest.py new file mode 100644 index 0000000..066d29d --- /dev/null +++ b/webapi/tests/conftest.py @@ -0,0 +1,119 @@ +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, Session, create_engine +from sqlalchemy.pool import StaticPool + + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from main import myapp +from db.db_connection import get_session +from db.redis_connection import get_redis +from auth.auth_service import crear_jwt +from models.user import User +from models.prompts import Prompts + + +class FakeRedis: + def __init__(self): + self.store: dict[str, str] = {} + + def exists(self, key: str) -> bool: + return key in self.store + + def setex(self, key: str, ttl: int, value: str): + self.store[key] = value + + def get(self, key: str): + return self.store.get(key) + + +@pytest.fixture +def engine(): + test_engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(test_engine) + return test_engine + + +@pytest.fixture +def db_session(engine): + with Session(engine) as session: + yield session + + +@pytest.fixture +def fake_redis(): + return FakeRedis() + + +@pytest.fixture +def client(db_session, fake_redis): + def _override_get_session(): + yield db_session + + def _override_get_redis(): + yield fake_redis + + myapp.dependency_overrides[get_session] = _override_get_session + myapp.dependency_overrides[get_redis] = _override_get_redis + + with TestClient(myapp, raise_server_exceptions=False) as test_client: + yield test_client + + myapp.dependency_overrides.clear() + + +@pytest.fixture +def user_payload(): + return { + "username": "pytest_user", + "name": "Py", + "last_name": "Tester", + "phone": 5512345678, + "email": "pytest_user@example.com", + "hashed_password": "pytest_password", + } + + +@pytest.fixture +def created_user(db_session): + user = User( + username="base_user", + name="Base", + last_name="User", + phone=5500000001, + email="base_user@example.com", + hashed_password="base_password", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def auth_header(created_user): + token = crear_jwt({"sub": created_user.username}) + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def created_prompt(db_session, created_user): + prompt = Prompts( + user_id=created_user.id, + model_name="gpt-4.1", + prompt_text="existing prompt", + category="qa", + rate="high", + ) + db_session.add(prompt) + db_session.commit() + db_session.refresh(prompt) + return prompt diff --git a/webapi/tests/test_auth_routes.py b/webapi/tests/test_auth_routes.py new file mode 100644 index 0000000..b1df994 --- /dev/null +++ b/webapi/tests/test_auth_routes.py @@ -0,0 +1,254 @@ +import base64 + +from passlib.hash import sha256_crypt +from sqlmodel import select + +from models.user import User +import api.endpoints.v1.auths as auths_module + + +def test_signup_success(client, user_payload, db_session): + response = client.post("/api/v1/auth/signup", json=user_payload) + + assert response.status_code == 200 + assert response.json() == {"message": "User created successfully"} + + created = db_session.exec(select(User).where(User.username == user_payload["username"])).first() + assert created is not None + assert created.hashed_password != user_payload["hashed_password"] + assert sha256_crypt.verify(user_payload["hashed_password"], created.hashed_password) + + +def test_signup_duplicate_username_returns_400(client, db_session): + db_session.add( + User( + username="dup_user", + name="dup", + last_name="user", + phone=5500000010, + email="dup_user@example.com", + hashed_password=sha256_crypt.hash("password"), + ) + ) + db_session.commit() + + response = client.post( + "/api/v1/auth/signup", + json={ + "username": "dup_user", + "name": "new", + "last_name": "user", + "phone": 5500000011, + "email": "new_dup_user@example.com", + "hashed_password": "password", + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "username already taken" + + +def test_login_success_returns_token(client, user_payload): + client.post("/api/v1/auth/signup", json=user_payload) + + response = client.post( + "/api/v1/auth/login", + json={"username": user_payload["username"], "password": user_payload["hashed_password"]}, + ) + + assert response.status_code == 200 + assert response.json()["token_type"] == "bearer" + assert isinstance(response.json()["access_token"], str) + + +def test_login_invalid_credentials_returns_401(client, db_session): + db_session.add( + User( + username="known_user", + name="known", + last_name="user", + phone=5500000012, + email="known_user@example.com", + hashed_password=sha256_crypt.hash("right_password"), + ) + ) + db_session.commit() + + response = client.post( + "/api/v1/auth/login", + json={"username": "known_user", "password": "wrong_password"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Incorrect username or password" + + +def test_profile_success(client, auth_header, created_user): + response = client.get("/api/v1/auth/profile", headers=auth_header) + + assert response.status_code == 200 + assert response.json()["profile darta"]["sub"] == created_user.username + + +def test_profile_invalid_token_returns_401(client): + response = client.get("/api/v1/auth/profile", headers={"Authorization": "Token abc"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Unauthorized token" + + +def test_generate_password_user_not_found_returns_404(client): + response = client.post("/api/v1/auth/generate", params={"username": "missing_user"}) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_generate_password_key_exists_returns_400(client, db_session, fake_redis, monkeypatch): + user = User( + username="recover_user", + name="recover", + last_name="user", + phone=5500000013, + email="recover_user@example.com", + hashed_password=sha256_crypt.hash("original_password"), + ) + db_session.add(user) + db_session.commit() + + monkeypatch.setattr(auths_module.secrets, "token_hex", lambda n: "fixedhex") + encoded = base64.b64encode(user.username.encode("utf-8")).decode("utf-8") + fake_redis.store[f"fixedhex.{encoded}"] = "already_exists" + + response = client.post("/api/v1/auth/generate", params={"username": user.username}) + + assert response.status_code == 400 + assert response.json()["detail"] == "Key already exists" + + +def test_generate_password_success_saves_password_and_calls_email(client, db_session, fake_redis, monkeypatch): + user = User( + username="mail_user", + name="mail", + last_name="user", + phone=5500000014, + email="mail_user@example.com", + hashed_password=sha256_crypt.hash("old_password"), + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + called = {"value": False} + + async def fake_send_email(to, username, body): + called["value"] = True + assert to == user.email + assert username == user.username + assert "recovery key" in body + + monkeypatch.setattr(auths_module.secrets, "token_hex", lambda n: "fixedhex2") + monkeypatch.setattr(auths_module.secrets, "token_urlsafe", lambda n: "temporary_pwd") + monkeypatch.setattr(auths_module, "send_email", fake_send_email) + + response = client.post("/api/v1/auth/generate", params={"username": user.username, "ttl": 600}) + + assert response.status_code == 200 + assert called["value"] is True + + encoded = base64.b64encode(user.username.encode("utf-8")).decode("utf-8") + key = f"fixedhex2.{encoded}" + assert fake_redis.get(key) == "temporary_pwd" + + updated = db_session.get(User, user.id) + assert sha256_crypt.verify("temporary_pwd", updated.hashed_password) + + +def test_generate_password_email_failure_returns_500(client, db_session, monkeypatch): + user = User( + username="mail_fail_user", + name="mail", + last_name="fail", + phone=5500000015, + email="mail_fail_user@example.com", + hashed_password=sha256_crypt.hash("old_password"), + ) + db_session.add(user) + db_session.commit() + + async def failing_send_email(*args, **kwargs): + raise Exception("smtp unavailable") + + monkeypatch.setattr(auths_module, "send_email", failing_send_email) + + response = client.post("/api/v1/auth/generate", params={"username": user.username}) + + assert response.status_code == 500 + + +def test_recover_invalid_key_format_returns_401(client): + response = client.post("/api/v1/auth/recover", params={"key": "invalid_key_without_dot"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid key format" + + +def test_recover_decode_error_returns_401(client, monkeypatch): + def broken_decode(_value): + raise auths_module.binascii.Error("bad base64") + + monkeypatch.setattr(auths_module.base64, "b64decode", broken_decode) + + response = client.post("/api/v1/auth/recover", params={"key": "prefix.encoded"}) + + assert response.status_code == 401 + assert response.json()["detail"].startswith("Decode error:") + + +def test_recover_user_not_found_returns_404(client): + encoded = base64.b64encode("ghost_user".encode("utf-8")).decode("utf-8") + response = client.post("/api/v1/auth/recover", params={"key": f"anyprefix.{encoded}"}) + + assert response.status_code == 404 + assert response.json()["detail"] == "Key corrputed" + + +def test_recover_password_not_found_in_redis_returns_404(client, db_session): + user = User( + username="recover_missing_pwd", + name="recover", + last_name="missing", + phone=5500000016, + email="recover_missing_pwd@example.com", + hashed_password=sha256_crypt.hash("old_password"), + ) + db_session.add(user) + db_session.commit() + + encoded = base64.b64encode(user.username.encode("utf-8")).decode("utf-8") + response = client.post("/api/v1/auth/recover", params={"key": f"anyprefix.{encoded}"}) + + assert response.status_code == 404 + assert response.json()["detail"] == "Password not found in redis or expired" + + +def test_recover_success_returns_key_and_password(client, db_session, fake_redis): + user = User( + username="recover_ok", + name="recover", + last_name="ok", + phone=5500000017, + email="recover_ok@example.com", + hashed_password=sha256_crypt.hash("old_password"), + ) + db_session.add(user) + db_session.commit() + + encoded = base64.b64encode(user.username.encode("utf-8")).decode("utf-8") + key = f"keyprefix.{encoded}" + fake_redis.store[key] = "temporary_pwd" + + response = client.post("/api/v1/auth/recover", params={"key": key}) + + assert response.status_code == 200 + assert response.json() == {"key": key, "password": "temporary_pwd"} diff --git a/webapi/tests/test_ci.py b/webapi/tests/test_ci.py index d729171..ceda2b1 100644 --- a/webapi/tests/test_ci.py +++ b/webapi/tests/test_ci.py @@ -12,7 +12,7 @@ def test_root(): response = client.get("/") assert response.status_code == 200 assert response.json() == { - "Bank App": "This is a simple app using FastAPI and mariadb." + "Portfolio App": "This is a simple app using FastAPI and mariadb." } @@ -40,22 +40,24 @@ def get_token(): return token -def test_register_card(): +def test_register_prompt(): token = get_token() headers = {"Authorization": f"Bearer {token}", "send_email": "false"} - response = client.post("/api/v1/cards/cards", json={ + response = client.post("/api/v1/prompts", json={ "user_id": 1, - "card_type": "visa", - "card_number": "5256010203040506", - "expiration_date": "01/2025", + "model_name": "gpt-4.1", + "prompt_text": "Generate a test response", + "category": "qa", + "rate": "high", }, headers=headers) assert response.status_code == 200 assert response.json() == { "id": 1, - "card_number": "5256010203040506", "user_id": 1, - "card_type": "visa", - "expiration_date": "01/2025" + "model_name": "gpt-4.1", + "prompt_text": "Generate a test response", + "category": "qa", + "rate": "high" } def test_read_users(): @@ -65,8 +67,8 @@ def test_read_users(): assert response.status_code == 200 -def test_read_cards(): +def test_read_prompts(): token = get_token() headers = {"Authorization": f"Bearer {token}"} - response = client.get("/api/v1/cards", headers=headers) - assert response.status_code == 200 \ No newline at end of file + response = client.get("/api/v1/prompts", headers=headers) + assert response.status_code == 200 diff --git a/webapi/tests/test_prompts_routes.py b/webapi/tests/test_prompts_routes.py new file mode 100644 index 0000000..797c95d --- /dev/null +++ b/webapi/tests/test_prompts_routes.py @@ -0,0 +1,201 @@ +import api.endpoints.v1.prompts as prompts_module + + +def test_create_prompt_success(client, auth_header, created_user): + payload = { + "user_id": created_user.id, + "model_name": "gpt-4.1", + "prompt_text": "Generate answer", + "category": "qa", + "rate": "high", + } + + response = client.post("/api/v1/prompts", json=payload, headers=auth_header) + + assert response.status_code == 200 + assert response.json()["user_id"] == created_user.id + assert response.json()["model_name"] == "gpt-4.1" + + +def test_create_prompt_unauthorized_returns_401(client, created_user): + payload = { + "user_id": created_user.id, + "model_name": "gpt-4.1", + "prompt_text": "Generate answer", + "category": "qa", + "rate": "high", + } + + response = client.post( + "/api/v1/prompts", + json=payload, + headers={"Authorization": "Bearer invalid"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Unauthorized token" + + +def test_create_prompt_user_not_found_returns_404(client, auth_header): + payload = { + "user_id": 9999, + "model_name": "gpt-4.1", + "prompt_text": "Generate answer", + "category": "qa", + "rate": "high", + } + + response = client.post("/api/v1/prompts", json=payload, headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_create_prompt_send_email_true_calls_email(client, auth_header, created_user, monkeypatch): + called = {"value": False} + + async def fake_send_email(to, username, message_body=""): + called["value"] = True + assert to == created_user.email + assert username == created_user.username + + monkeypatch.setattr(prompts_module, "send_email", fake_send_email) + + payload = { + "user_id": created_user.id, + "model_name": "gpt-4.1", + "prompt_text": "Notify me", + "category": "qa", + "rate": "medium", + } + headers = {**auth_header, "send_email": "true"} + + response = client.post("/api/v1/prompts", json=payload, headers=headers) + + assert response.status_code == 200 + assert called["value"] is True + + +def test_create_prompt_send_email_exception_still_success(client, auth_header, created_user, monkeypatch): + async def failing_send_email(*args, **kwargs): + raise Exception("smtp unavailable") + + monkeypatch.setattr(prompts_module, "send_email", failing_send_email) + + payload = { + "user_id": created_user.id, + "model_name": "gpt-4.1", + "prompt_text": "Ignore email failure", + "category": "ops", + "rate": "low", + } + headers = {**auth_header, "send_email": "true"} + + response = client.post("/api/v1/prompts", json=payload, headers=headers) + + assert response.status_code == 200 + assert response.json()["prompt_text"] == "Ignore email failure" + + +def test_read_prompts_success(client, auth_header, created_prompt): + response = client.get("/api/v1/prompts", headers=auth_header) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["id"] == created_prompt.id + + +def test_read_prompts_empty_returns_404(client, auth_header): + response = client.get("/api/v1/prompts", headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "No prompts found" + + +def test_read_prompts_unauthorized_returns_401(client): + response = client.get("/api/v1/prompts", headers={"Authorization": "Bearer invalid"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Unauthorized token" + + +def test_get_prompt_success(client, auth_header, created_prompt): + response = client.get(f"/api/v1/prompts/{created_prompt.id}", headers=auth_header) + + assert response.status_code == 200 + assert response.json()["id"] == created_prompt.id + + +def test_get_prompt_missing_returns_404(client, auth_header): + response = client.get("/api/v1/prompts/9999", headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "Prompt not found" + + +def test_update_prompt_success(client, auth_header, created_prompt, created_user): + payload = { + "user_id": created_user.id, + "model_name": "gpt-4o-mini", + "prompt_text": "Updated prompt", + "category": "dev", + "rate": "medium", + } + + response = client.put(f"/api/v1/prompts/{created_prompt.id}", json=payload, headers=auth_header) + + assert response.status_code == 200 + assert response.json()["model_name"] == "gpt-4o-mini" + assert response.json()["prompt_text"] == "Updated prompt" + + +def test_update_prompt_missing_returns_404(client, auth_header, created_user): + payload = { + "user_id": created_user.id, + "model_name": "gpt-4.1", + "prompt_text": "Updated prompt", + "category": "qa", + "rate": "high", + } + + response = client.put("/api/v1/prompts/9999", json=payload, headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "Prompt not found" + + +def test_update_prompt_user_not_found_returns_404(client, auth_header, created_prompt): + payload = { + "user_id": 9999, + "model_name": "gpt-4.1", + "prompt_text": "Updated prompt", + "category": "qa", + "rate": "high", + } + + response = client.put(f"/api/v1/prompts/{created_prompt.id}", json=payload, headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found for this prompt" + + +def test_delete_prompt_success(client, auth_header, created_prompt): + response = client.delete(f"/api/v1/prompts/{created_prompt.id}", headers=auth_header) + + assert response.status_code == 200 + assert response.json()["id"] == created_prompt.id + + +def test_delete_prompt_missing_returns_404(client, auth_header): + response = client.delete("/api/v1/prompts/9999", headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "Prompt not found to delete" + + +def test_delete_prompt_unauthorized_returns_401(client): + response = client.delete("/api/v1/prompts/1", headers={"Authorization": "Bearer invalid"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Unauthorized token" + diff --git a/webapi/tests/test_users_routes.py b/webapi/tests/test_users_routes.py new file mode 100644 index 0000000..240b9ce --- /dev/null +++ b/webapi/tests/test_users_routes.py @@ -0,0 +1,167 @@ +from passlib.hash import sha256_crypt +from sqlmodel import select + +from models.user import User +from auth.auth_service import crear_jwt + + +def test_read_users_success(client, auth_header, created_user): + response = client.get("/api/v1/users", headers=auth_header) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["id"] == created_user.id + + +def test_read_users_with_phone_filter_success(client, auth_header, created_user): + response = client.get(f"/api/v1/users?phone={created_user.phone}", headers=auth_header) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["phone"] == created_user.phone + + +def test_read_users_not_found_returns_404(client, auth_header): + response = client.get("/api/v1/users?phone=5599998888", headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_read_users_unauthorized_returns_401(client): + response = client.get("/api/v1/users", headers={"Authorization": "Bearer invalid"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Unauthorized token" + + +def test_get_user_success(client, auth_header, created_user): + response = client.get(f"/api/v1/users/{created_user.id}", headers=auth_header) + + assert response.status_code == 200 + assert response.json()["id"] == created_user.id + + +def test_get_user_missing_returns_404(client, auth_header): + response = client.get("/api/v1/users/9999", headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_get_user_with_prompts_success(client, auth_header, created_user, created_prompt): + response = client.get(f"/api/v1/users/prompts/{created_user.id}", headers=auth_header) + + assert response.status_code == 200 + assert response.json()["id"] == created_user.id + assert len(response.json()["prompts"]) == 1 + assert response.json()["prompts"][0]["id"] == created_prompt.id + + +def test_get_user_with_prompts_missing_returns_404(client, auth_header): + response = client.get("/api/v1/users/prompts/9999", headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_update_user_success(client, auth_header, created_user, db_session): + payload = { + "username": created_user.username, + "name": "UPDATED", + "last_name": "USER", + "phone": 5599990000, + "email": "updated_user@example.com", + "hashed_password": "new_password", + } + + response = client.put(f"/api/v1/users/{created_user.id}", json=payload, headers=auth_header) + + assert response.status_code == 200 + body = response.json() + assert body["name"] == "updated" + assert body["last_name"] == "user" + assert body["phone"] == 5599990000 + assert body["email"] == "updated_user@example.com" + + updated = db_session.get(User, created_user.id) + assert sha256_crypt.verify("new_password", updated.hashed_password) + + +def test_update_user_missing_returns_404(client, auth_header): + payload = { + "username": "missing", + "name": "Missing", + "last_name": "User", + "phone": 5511112222, + "email": "missing_user@example.com", + "hashed_password": "password", + } + + response = client.put("/api/v1/users/9999", json=payload, headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_update_user_duplicate_username_returns_400(client, auth_header, created_user, db_session): + another_user = User( + username="taken_username", + name="Another", + last_name="User", + phone=5500000090, + email="another_user@example.com", + hashed_password=sha256_crypt.hash("password"), + ) + db_session.add(another_user) + db_session.commit() + + payload = { + "username": "taken_username", + "name": "Updated", + "last_name": "User", + "phone": created_user.phone, + "email": "updated_conflict@example.com", + "hashed_password": "new_password", + } + + response = client.put(f"/api/v1/users/{created_user.id}", json=payload, headers=auth_header) + + assert response.status_code == 400 + assert response.json()["detail"] == "username already taken" + + +def test_delete_user_success(client, auth_header, created_user): + response = client.delete(f"/api/v1/users/{created_user.id}", headers=auth_header) + + assert response.status_code == 200 + assert response.json()["id"] == created_user.id + + +def test_delete_user_missing_returns_404(client, auth_header): + response = client.delete("/api/v1/users/9999", headers=auth_header) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found to delete" + + +def test_delete_user_commit_exception_returns_500(client, auth_header, created_user, monkeypatch): + from db.db_connection import get_session + from main import myapp + + override = myapp.dependency_overrides[get_session] + session = next(override()) + + original_commit = session.commit + + def fail_commit(): + raise Exception("forced commit error") + + monkeypatch.setattr(session, "commit", fail_commit) + + response = client.delete(f"/api/v1/users/{created_user.id}", headers=auth_header) + + monkeypatch.setattr(session, "commit", original_commit) + + assert response.status_code == 500 + assert "forced commit error" in response.json()["detail"]