diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8d2f6a7..e7c2559 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -9,13 +9,32 @@ on: pull_request: branches: ["main", "develop"] -permissions: - contents: read +env: + DB_NAME: db + DB_USER: user + DB_PASSWORD: postgres + DB_HOST: localhost + DB_PORT: 5432 jobs: ci: runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_DB: ${{ env.DB_NAME }} + POSTGRES_USER: ${{ env.DB_USER }} + POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Checkout uses: actions/checkout@v3 @@ -38,4 +57,4 @@ jobs: run: poetry install --no-interaction --no-root - name: Run tests - run: make test + run: poetry run pytest -v . diff --git a/Makefile b/Makefile index 1b547b1..dd679c2 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ lint: test: rm test.db 2> /dev/null || true - poetry run pytest -sv . + DB_NAME="./test.db" poetry run pytest -sv . run: install poetry run uvicorn src.main:app --host localhost --port 8000 --reload diff --git a/poetry.lock b/poetry.lock index 00b29a0..8ff7643 100644 --- a/poetry.lock +++ b/poetry.lock @@ -682,6 +682,29 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "psycopg2" +version = "2.9.9" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "psycopg2-2.9.9-cp310-cp310-win32.whl", hash = "sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516"}, + {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, + {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, + {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, + {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, + {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, + {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, + {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, + {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, + {file = "psycopg2-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:bac58c024c9922c23550af2a581998624d6e02350f4ae9c5f0bc642c633a2d5e"}, + {file = "psycopg2-2.9.9-cp39-cp39-win32.whl", hash = "sha256:c92811b2d4c9b6ea0285942b2e7cac98a59e166d59c588fe5cfe1eda58e72d59"}, + {file = "psycopg2-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:de80739447af31525feddeb8effd640782cf5998e1a4e9192ebdf829717e3913"}, + {file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"}, +] + [[package]] name = "pycodestyle" version = "2.11.1" @@ -1443,4 +1466,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "2395519ececf3b1e8caf106ec9023236387dfcacf2c40f35fa7e298ba78831d7" +content-hash = "9f4090d04340fc40cdf14a5c1c6b3875f792b19a57d2e2c3ac6098b17d11affa" diff --git a/pyproject.toml b/pyproject.toml index 146a61e..e541fb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ fastapi = "^0.111.0" sqlalchemy = "^2.0.30" uvicorn = "^0.29.0" flake8 = "^7.0.0" +psycopg2 = "^2.9.9" [tool.poetry.group.dev.dependencies] diff --git a/src/crud.py b/src/crud.py index f6bb227..0648180 100644 --- a/src/crud.py +++ b/src/crud.py @@ -3,6 +3,10 @@ import hashlib +def get_user_by_id(db: Session, id: int): + return db.query(models.User).filter(models.User.id == id).first() + + def get_user_by_email(db: Session, email: str): return db.query(models.User).filter(models.User.email == email).first() @@ -14,3 +18,22 @@ def create_user(db: Session, user: schemas.UserCreate): db.commit() db.refresh(db_user) return db_user + + +def create_group(db: Session, group: schemas.GroupCreate, user_id: int): + db_group = models.Group( + owner_id=user_id, name=group.name, description=group.description + ) + db.add(db_group) + db.commit() + db.refresh(db_group) + return db_group + + +def get_groups_by_owner_id(db: Session, owner_id: int): + return ( + db.query(models.Group) + .filter(models.Group.owner_id == owner_id) + .limit(100) + .all() + ) diff --git a/src/database.py b/src/database.py index 7f23f0c..77c324c 100644 --- a/src/database.py +++ b/src/database.py @@ -1,12 +1,33 @@ -from sqlalchemy import create_engine +from logging import info +import os +from sqlalchemy import create_engine, URL from sqlalchemy.orm import DeclarativeBase, sessionmaker -SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" -# SQLALCHEMY_DATABASE_URL = "postgresql://user:password@postgresserver/db" -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -) +DB_NAME = os.environ.get("DB_NAME", "./sql_app.db") +DB_USER = os.environ.get("DB_USER") +DB_PASS = os.environ.get("DB_PASSWORD") + +# set automatically by kubernetes +# resolves to postgres service's host/port +DB_HOST = os.environ.get("DB_HOST") +DB_PORT = os.environ.get("DB_PORT") + + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_NAME}" + +if DB_HOST is not None: + info("Using PostgreSQL database") + SQLALCHEMY_DATABASE_URL = URL.create( + "postgresql", + username=DB_USER, + password=DB_PASS, + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + ) + +engine = create_engine(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/src/main.py b/src/main.py index cf0f89c..5a88c74 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,6 @@ -from fastapi import Depends, FastAPI, HTTPException +from http import HTTPStatus +from typing import Annotated +from fastapi import Depends, FastAPI, HTTPException, Header from . import crud, models, schemas from .database import SessionLocal, engine from sqlalchemy.orm import Session @@ -7,7 +9,6 @@ models.Base.metadata.create_all(bind=engine) -# Dependency def get_db(): db = SessionLocal() try: @@ -16,31 +17,62 @@ def get_db(): db.close() +DbDependency = Annotated[Session, Depends(get_db)] + + +def get_user(db: DbDependency, x_user: Annotated[int, Header()]) -> models.User: + db_user = crud.get_user_by_id(db, x_user) + if db_user is None: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="Necesita loguearse para continuar", + ) + return db_user + + app = FastAPI(dependencies=[Depends(get_db)]) +UserDependency = Annotated[models.User, Depends(get_user)] -@app.post("/user/register") -def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): + +@app.post("/user/register", status_code=HTTPStatus.CREATED) +def create_user(user: schemas.UserCreate, db: DbDependency): db_user = crud.get_user_by_email(db, email=user.email) if db_user is not None: - raise HTTPException(status_code=400, detail="Email already registered") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Email ya existente" + ) db_user = crud.create_user(db, user) return {"id": db_user.id} -@app.post("/user/login") -def login(user: schemas.UserLogin, db: Session = Depends(get_db)): +@app.post("/user/login", status_code=HTTPStatus.CREATED) +def login(user: schemas.UserLogin, db: DbDependency): db_user = crud.get_user_by_email(db, email=user.email) if db_user is None: - raise HTTPException(status_code=404, detail="User not found") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Usuario no existe" + ) hashed_password = hashlib.sha256(user.password.encode(encoding="utf-8")).hexdigest() if db_user.password != hashed_password: - raise HTTPException(status_code=404, detail="Verify your credentials") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="ContraseƱa incorrecta" + ) return {"token": db_user.id} + + +@app.post("/group", status_code=HTTPStatus.CREATED) +def create_group(group: schemas.GroupCreate, db: DbDependency, user: UserDependency): + return crud.create_group(db, group, user.id) + + +@app.get("/group") +def list_groups(db: DbDependency, user: UserDependency): + return crud.get_groups_by_owner_id(db, user.id) diff --git a/src/models.py b/src/models.py index e0e0b86..ad2aef5 100644 --- a/src/models.py +++ b/src/models.py @@ -10,3 +10,12 @@ class User(Base): id = Column(Integer, primary_key=True) email = Column(String, unique=True, index=True) password = Column(String) + + +class Group(Base): + __tablename__ = "groups" + + id = Column(Integer, primary_key=True) + owner_id = Column(ForeignKey("users.id")) + name = Column(String) + description = Column(String) diff --git a/src/schemas.py b/src/schemas.py index 911f1ab..a8050a3 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -15,3 +15,17 @@ class UserCreate(UserBase): class User(UserBase): id: int + + +class GroupBase(BaseModel): + name: str + description: str + + +class GroupCreate(GroupBase): + pass + + +class Group(GroupBase): + id: int + owner_id: int diff --git a/src/test_main.py b/src/test_main.py index ae90650..ea114b9 100644 --- a/src/test_main.py +++ b/src/test_main.py @@ -1,3 +1,4 @@ +from http import HTTPStatus from fastapi.testclient import TestClient import pytest @@ -5,23 +6,14 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool - -from .database import Base +from .database import Base, SQLALCHEMY_DATABASE_URL from .main import app, get_db -SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" -engine = create_engine( - SQLALCHEMY_DATABASE_URL, - connect_args={"check_same_thread": False}, - poolclass=StaticPool, -) +engine = create_engine(SQLALCHEMY_DATABASE_URL, poolclass=StaticPool) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base.metadata.create_all(bind=engine) - - def override_get_db(): try: db = TestingSessionLocal() @@ -31,15 +23,22 @@ def override_get_db(): @pytest.fixture() -def set_up_db(): +def client(): + app.dependency_overrides[get_db] = override_get_db + Base.metadata.create_all(bind=engine) - yield + yield TestClient(app) Base.metadata.drop_all(bind=engine) -app.dependency_overrides[get_db] = override_get_db - -client = TestClient(app) +@pytest.fixture() +def some_user_id(client: TestClient): + response = client.post( + url="/user/register", + json={"email": "example@example.com", "password": "my_ultra_secret_password"}, + ) + assert response.status_code == HTTPStatus.CREATED + return response.json()["id"] ################################################ @@ -47,22 +46,22 @@ def set_up_db(): ################################################ -def test_register_a_user(set_up_db): +def test_register_a_user(client: TestClient): response = client.post( url="/user/register", json={"email": "example@example.com", "password": "my_ultra_secret_password"}, ) - assert response.status_code == 200 + assert response.status_code == HTTPStatus.CREATED assert "id" in response.json() -def test_register_a_user_with_an_email_already_used(set_up_db): +def test_register_a_user_with_an_email_already_used(client: TestClient): first_response = client.post( url="/user/register", json={"email": "example@example.com", "password": "my_ultra_secret_password"}, ) - assert first_response.status_code == 200 + assert first_response.status_code == HTTPStatus.CREATED assert "id" in first_response.json() second_response = client.post( @@ -78,13 +77,13 @@ def test_register_a_user_with_an_email_already_used(set_up_db): ################################################ -def test_successful_login(set_up_db): +def test_successful_login(client: TestClient): first_response = client.post( url="/user/register", json={"email": "example@example.com", "password": "my_ultra_secret_password"}, ) - assert first_response.status_code == 200 + assert first_response.status_code == HTTPStatus.CREATED assert "id" in first_response.json() second_response = client.post( @@ -92,17 +91,17 @@ def test_successful_login(set_up_db): json={"email": "example@example.com", "password": "my_ultra_secret_password"}, ) - assert second_response.status_code == 200 + assert second_response.status_code == HTTPStatus.CREATED assert "token" in second_response.json() -def test_login_with_wrong_password(set_up_db): +def test_login_with_wrong_password(client: TestClient): first_response = client.post( url="/user/register", json={"email": "example@example.com", "password": "my_ultra_secret_password"}, ) - assert first_response.status_code == 200 + assert first_response.status_code == HTTPStatus.CREATED assert "id" in first_response.json() second_response = client.post( @@ -110,17 +109,17 @@ def test_login_with_wrong_password(set_up_db): json={"email": "example@example.com", "password": "a_wrong_password"}, ) - assert second_response.status_code == 404 + assert second_response.status_code == HTTPStatus.UNAUTHORIZED assert "token" not in second_response.json() -def test_login_with_wrong_email(set_up_db): +def test_login_with_wrong_email(client: TestClient): first_response = client.post( url="/user/register", json={"email": "example@example.com", "password": "my_ultra_secret_password"}, ) - assert first_response.status_code == 200 + assert first_response.status_code == HTTPStatus.CREATED assert "id" in first_response.json() second_response = client.post( @@ -131,5 +130,52 @@ def test_login_with_wrong_email(set_up_db): }, ) - assert second_response.status_code == 404 + assert second_response.status_code == HTTPStatus.NOT_FOUND assert "token" not in second_response.json() + + +################################################ +# GROUPS +################################################ + + +@pytest.fixture() +def some_group(client: TestClient, some_user_id: int): + response = client.post( + url="/group", + json={"name": "grupo 1", "description": "really long description 1234"}, + headers={"x-user": str(some_user_id)}, + ) + + assert response.status_code == HTTPStatus.CREATED + response_body = response.json() + assert "id" in response_body + assert response_body["owner_id"] == some_user_id + return response_body + + +def test_create_group(client: TestClient, some_group: int): + # NOTE: test is inside fixture + pass + + +def test_create_group_for_invalid_user(client: TestClient): + first_response = client.post( + url="/group", + json={"name": "grupo 1", "description": "really long description 1234"}, + headers={"x-user": "5636262"}, + ) + + assert first_response.status_code == HTTPStatus.UNAUTHORIZED + + +def test_get_newly_created_group( + client: TestClient, some_user_id: int, some_group: int +): + response = client.get( + url="/group", + headers={"x-user": str(some_user_id)}, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == [some_group]