diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 736ae11..6135ece 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -2,9 +2,10 @@ FROM python:3.12 WORKDIR /code -COPY ./requirements.txt /code/requirements.txt +# COPY ./requirements.txt /code/requirements.txt +COPY ./requirements_lock.txt /code/requirements_lock.txt -RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt +RUN pip install --no-cache-dir -r /code/requirements_lock.txt COPY ./app /code/app diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index 354ec02..d4316bd 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -43,4 +43,4 @@ services: volumes: mysql_database: - name: mysql_database + name: mysql_database \ No newline at end of file diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..fae60a4 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,45 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + + +name: Python application + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Format with Black + run: black . + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Test with pytest and coverage + run: | + pytest --cov=app --cov-report=term --cov-fail-under=100 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..736ae11 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12 + +WORKDIR /code + +COPY ./requirements.txt /code/requirements.txt + +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt + +COPY ./app /code/app + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"] diff --git a/README.md b/README.md index adc30bd..4c67d76 100644 --- a/README.md +++ b/README.md @@ -1 +1,100 @@ # stars-api + +### Adicionando Verificações de Qualidade de Código + +1. **Adicionar Verificação de Formatação com Black e Padrão PEP8:** + + O PEP 8 define regras para a indentação, uso de espaços em branco, nomes de variáveis, entre outros aspectos do estilo de código. Ao adicionar a verificação do Black no GitHub Actions, estaremos garantindo que o código esteja formatado de acordo com as recomendações do PEP 8. + + - Para instalar o Black, utilize o comando: + ```bash + pip install black + ``` + - Para executar o Black, utilize: + ```bash + black + ``` + +2. **Adicionar Verificação do Pytest com 100% de Cobertura:** + + O Pytest é uma biblioteca de testes unitários que permite escrever testes simples e escaláveis em Python. Ele fornece suporte para detecção automática de testes, relatórios detalhados e plugins personalizados. + + **Cobertura:** + A biblioteca Coverage é usada para medir a cobertura de testes do código-fonte Python. Ela ajuda a identificar áreas do código que não estão sendo testadas, fornecendo relatórios sobre a porcentagem de código coberto pelos testes. + + - Para instalar o Pytest e o pytest-cov, utilize o comando: + ```bash + pip install pytest pytest-cov + ``` + - Para executar os testes com o Pytest e calcular a cobertura de código, utilize o pytest-cov diretamente no comando Pytest: + ```bash + pytest --cov=. + ``` + - Para cobrir todo o código ou + ```bash + pytest --cov=app + ``` + - para cobrir apenas o diretório app. + +3. **Adicionar Verificação do Flake8:** + + O Flake8 é uma ferramenta de verificação de código que combina as funcionalidades de outras ferramentas populares. Ele verifica o estilo do código, identifica problemas potenciais e fornece sugestões de melhoria. + + - Para instalar o Flake8, utilize o comando: + ```bash + pip install flake8 + ``` + - Para verificar seu código com o Flake8, utilize o seguinte comando: + ```bash + flake8 + ``` + + +# Documentação +## Este projeto é um aplicativo Python com FastApi com todo o ambiente de execução encapsulado em Docker. + +### ambiente virtual + python3 -m venv env + +### ativação do ambiente + source env/bin/activate + +### install nas dependencias + pip install -r requirements.txt + +#### Atulização nas dependencias + pip freeze > requirements.txt + +#### Comando para criar a imagem docker no projeto + docker build -t backoffice -f .docker/Dockerfile . + +#### Configurações de vulnerabilidade da imagem sugerida pelo docker + docker scout cves local://backoffice:latest + docker scout recommendations local://backoffice:latest + +### Comando para checar se a imagem foi criada + docker images + +### Executar o container e verificar se esta em execução + docker run -d -p 80:80 nome_do_container + docker ps + +### Comandos para criar/subir os containers +docker-compose up +docker-compose ps + +### Parar containers +docker compose down + +### Comandos uteis docker +docker-compose stop +docker-compose start +docker-compose restart + +### Porta e swagger +http://localhost:8000/docs + +### Parar o servidor + docker stop 65d05c5e44806478fd97914e8ecdb61a3a1b530686b20640da7c68e5717ec7a3 + + diff --git a/alembic/env.py b/alembic/env.py index fc4fa78..ad7cd67 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -81,4 +81,4 @@ def run_migrations_online() -> None: if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() + run_migrations_online() \ No newline at end of file diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..8061af2 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,71 @@ +from typing import Annotated, Union +from fastapi import Depends, HTTPException, status +from pydantic import BaseModel +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.database import get_db, SessionLocal +from datetime import datetime, timedelta +from app.settings import settings +from app.schemas import UserAuth, TokenData +from app.utils import get_user_by_username + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +# Configuração de criptografia de senhas +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password: str, hashed_password: str): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +def authenticate_user(db: Session, email: str, password: str): + user = get_user_by_username(db, email) + if not user: + return False + if not verify_password(password, user.hashed_password): + return False + return user + +def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) + data.update({"exp": expire}) + encoded_jwt = jwt.encode(data, settings.JWT_SECRETE_KEY, algorithm=settings.PASSWORD_HASH_ALGORITHM) + return encoded_jwt + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode(token, settings.JWT_SECRETE_KEY, algorithms=[settings.PASSWORD_HASH_ALGORITHM]) + email: Union[str, None] = payload.get("sub") + if not email is None: + raise credentials_exception + token_data = TokenData(username=email) + except JWTError: + raise credentials_exception + + if not token_data.username is None: + raise credentials_exception + + user = get_user_by_username(db, token_data.username) #type:ignore + if not user is None: + raise credentials_exception + + return user + +def get_current_active_user(current_user: UserAuth = Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user \ No newline at end of file diff --git a/app/crud.py b/app/crud.py index 23f5caa..5ad376f 100644 --- a/app/crud.py +++ b/app/crud.py @@ -1,25 +1,17 @@ from sqlalchemy.orm import Session - from sqlalchemy import create_engine, Column, Integer, String, Boolean, func - from . import models, schemas - - -def get_user(db: Session, user_id: int): - return db.query(models.User).filter(models.User.id == user_id).first() - - -def get_user_by_email(db: Session, email: str): - return db.query(models.User).filter(models.User.email == email).first() - - -def get_users(db: Session, skip: int = 0, limit: int = 100): - return db.query(models.User).offset(skip).limit(limit).all() +from app.auth import get_password_hash def create_user(db: Session, user: schemas.UserCreate): - fake_hashed_password = user.password + "notreallyhashed" - db_user = models.User(email=user.email, hashed_password=fake_hashed_password) + hashed_password = get_password_hash(user.password) + db_user = models.User( + username=user.username, + email=user.email, + hashed_password=hashed_password, + is_active=True, + ) db.add(db_user) db.commit() db.refresh(db_user) @@ -39,15 +31,16 @@ def create_user_item(db: Session, item: schemas.ItemCreate, user_id: int): def get_volunteers(db: Session, skip: int = 0, limit: int = 100): return db.query( - models.Volunteer.id, - models.Volunteer.name, - func.replace( - models.Volunteer.email, - func.substr(models.Volunteer.email, 1, func.instr(models.Volunteer.email, '@') - 1), - '***').label("masked_email"), - models.Volunteer.is_active, - models.Volunteer.jobtitle_id, - # models.Volunteer.email + models.Volunteer.id, + models.Volunteer.name, + func.replace( + models.Volunteer.email, + func.substr(models.Volunteer.email, 1, func.instr(models.Volunteer.email, '@') - 1), + '***').label("masked_email"), + models.Volunteer.is_active, + models.Volunteer.jobtitle_id, + models.Volunteer.linkedin, + # models.Volunteer.email ).all() # return db.query(models.Volunteer).offset(skip).limit(limit).all() @@ -57,33 +50,25 @@ def get_volunteers_by_email(db: Session, skip: int = 0, limit: int = 100, email: models.Volunteer.id, models.Volunteer.name, func.replace( - models.Volunteer.email, + models.Volunteer.email, func.substr(models.Volunteer.email, 1, func.instr(models.Volunteer.email, '@') - 1), '***').label("masked_email"), models.Volunteer.is_active, models.Volunteer.jobtitle_id, ).filter(models.Volunteer.email == email).first() -def create_volunteer(db: Session, volunteer: schemas.Volunteer, jobtitle_id: int): - # print(volunteer.jobtitle_id[0].id) - # return - db_volunteer = models.Volunteer( - name=volunteer.name, - email=volunteer.email, - linkedin=volunteer.linkedin, - is_active=volunteer.is_active, - jobtitle_id=jobtitle_id - ) +def create_volunteer(db: Session, volunteer: schemas.VolunteerCreate, jobtitle_id: int): + db_volunteer = models.Volunteer(**volunteer.dict()) db.add(db_volunteer) db.commit() db.refresh(db_volunteer) - print("db_volunteer",db_volunteer) return db_volunteer + def get_jobtitles(db: Session, skip: int = 0, limit: int = 100): return db.query(models.JobTitle).offset(skip).limit(limit).all() -def get_volunteer_by_email(db: Session, email: str): - return db.query(models.Volunteer).filter(models.Volunteer.email == email).first() +def get_volunteer_by_email(db: Session, email: str): + return db.query(models.Volunteer).filter(models.Volunteer.email == email).first() diff --git a/app/database.py b/app/database.py index f226669..8388a6d 100644 --- a/app/database.py +++ b/app/database.py @@ -3,14 +3,24 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -from dotenv import load_dotenv +from .settings import settings -load_dotenv() -SQLALCHEMY_DATABASE_URL = f"{os.getenv('DB_DRIVER')}://{os.getenv('DB_USERNAME')}:{os.getenv('DB_PASSWORD')}@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_DATABASE')}" +SQLALCHEMY_DATABASE_URL = ( + f"{settings.DB_DRIVER}://{settings.DB_USERNAME}:{settings.DB_PASSWORD}" + f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_DATABASE}" +) engine = create_engine(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() metadata = Base.metadata + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py index d29da7a..4268cb2 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,29 @@ from __future__ import print_function import os -from fastapi import Depends, FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException, status from sqlalchemy.orm import Session -import sib_api_v3_sdk -from sib_api_v3_sdk.rest import ApiException +import sib_api_v3_sdk # type:ignore +from sib_api_v3_sdk.rest import ApiException # type:ignore from pprint import pprint -from dotenv import load_dotenv from fastapi.middleware.cors import CORSMiddleware -load_dotenv() +from app import crud, models, schemas +from app.database import SessionLocal, engine -from . import crud, models, schemas -from .database import SessionLocal, engine +from app.settings import settings +from app.auth import oauth2_scheme, authenticate_user, create_access_token, get_current_user, get_current_active_user +from typing import Annotated +from fastapi.security import OAuth2PasswordRequestForm +from fastapi import Depends, FastAPI +from datetime import datetime, timedelta +from app.database import get_db +from app import utils models.Base.metadata.create_all(bind=engine) + app = FastAPI() origins = [ @@ -33,19 +40,28 @@ allow_headers=["*"], ) +@app.post("/token", response_model=schemas.Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) -# Dependency -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() + if not settings.JWT_EXPIRE_MINUTES: + raise ValueError("ACCESS_TOKEN_EXPIRE_MINUTES cannot be None") + access_token_expires = timedelta(minutes=float(settings.JWT_EXPIRE_MINUTES)) + access_token = create_access_token( + data={"sub": user.email} + ) + return {"access_token": access_token, "token_type": "bearer"} @app.post("/users/", response_model=schemas.User) def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): - db_user = crud.get_user_by_email(db, email=user.email) + db_user = utils.get_user_by_email(db, email=user.email) if db_user: raise HTTPException(status_code=400, detail="Email already registered") return crud.create_user(db=db, user=user) @@ -53,13 +69,13 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): @app.get("/users/", response_model=list[schemas.User]) def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - users = crud.get_users(db, skip=skip, limit=limit) + users = utils.get_users(db, skip=skip, limit=limit) return users @app.get("/users/{user_id}", response_model=schemas.User) def read_user(user_id: int, db: Session = Depends(get_db)): - db_user = crud.get_user(db, user_id=user_id) + db_user = utils.get_user(db, user_id=user_id) if db_user is None: raise HTTPException(status_code=404, detail="User not found") return db_user @@ -97,7 +113,7 @@ def read_items(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): # volunteer -@app.get("/volunteers/", response_model=list[schemas.Volunteer]) +@app.get("/volunteers/", response_model=list[schemas.VolunteerList]) def get_volunteers(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): db_volunteers = crud.get_volunteers(db) if db_volunteers is None: @@ -122,6 +138,9 @@ def create_volunteer(volunteer: schemas.VolunteerCreate, db: Session = Depends(g if db_user: raise HTTPException(status_code=400, detail="Email already registered") + if volunteer.jobtitle_id <= 0: + raise HTTPException(status_code=400, detail="We need jobtitle_id") + vol = crud.create_volunteer( db=db, volunteer=volunteer, jobtitle_id=volunteer.jobtitle_id ) diff --git a/app/models.py b/app/models.py index 3223398..284933c 100644 --- a/app/models.py +++ b/app/models.py @@ -3,7 +3,6 @@ from .database import Base - class User(Base): __tablename__ = "users" @@ -20,7 +19,7 @@ class Item(Base): id = Column(Integer, primary_key=True) title = Column(String(255), index=True) - description = Column(Text(3000), index=True) + description = Column(Text(300), index=True) owner_id = Column(Integer, ForeignKey("users.id")) owner = relationship("User", back_populates="items") @@ -42,7 +41,7 @@ class Volunteer(Base): id = Column(Integer, primary_key=True) name = Column(String(45), index=True) linkedin = Column(String(3072), index=True) - email = Column(String(320), index=True) + email = Column(String(255), index=True) is_active = Column(Boolean, default=True) jobtitle_id = Column(Integer, ForeignKey("jobtitle.id")) diff --git a/app/schemas.py b/app/schemas.py index 1513954..00144f6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Optional +from typing import Optional, Union class ItemBase(BaseModel): @@ -24,6 +24,7 @@ class UserBase(BaseModel): class UserCreate(UserBase): + username: str password: str @@ -47,21 +48,37 @@ class VolunteerBase(BaseModel): name: str linkedin: str # email: str - is_active: bool + is_active: Optional[bool] class VolunteerCreate(VolunteerBase): - name: str - email: str - masked_email: Optional[str] = None + # name: str + # email: str + # masked_email: Optional[str] = None is_active: Optional[bool] = True - jobtitle_id: int + jobtitle_id: int class Volunteer(VolunteerBase): id: int - jobtitle_id: int + jobtitle_id: int masked_email: Optional[str] = None +class VolunteerList(VolunteerBase): + id: int + jobtitle_id: int + masked_email: Optional[str] = None - class Config: - orm_mode = True +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + username: Union[str, None] = None + +class UserAuth(BaseModel): + username: str + email: str + is_active: Union[bool, None] = None + +class UserInDB(UserAuth): + hashed_password: str diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..4ca2e78 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + +class __Settings(BaseSettings): + DB_DRIVER: str + DB_USERNAME: str + DB_PASSWORD: str + DB_HOST: str + DB_PORT: int + DB_DATABASE: str + JWT_SECRETE_KEY: str + PASSWORD_HASH_ALGORITHM: str + JWT_EXPIRE_MINUTES: int + + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + +settings = __Settings() # type:ignore \ No newline at end of file diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..d3d9045 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,14 @@ +from sqlalchemy.orm import Session +from app import models + +def get_user_by_username(db: Session, email: str): + return db.query(models.User).filter(models.User.email == email).first() + +def get_user(db: Session, user_id: int): + return db.query(models.User).filter(models.User.id == user_id).first() + +def get_user_by_email(db: Session, email: str): + return db.query(models.User).filter(models.User.email == email).first() + +def get_users(db: Session, skip: int = 0, limit: int = 100): + return db.query(models.User).offset(skip).limit(limit).all() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f2e5a08 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + stars-api: + build: . + container_name: stars-api + environment: + PORT: 8000 + DEBUG: 1 + DB_DRIVER: mysql+mysqlconnector + DB_USERNAME: mysql + DB_PASSWORD: mysql + DB_HOST: mysql_database + DB_PORT: 3306 + DB_DATABASE: db + JWT_SECRETE_KEY: testestestestetsteste + PASSWORD_HASH_ALGORITHM: HS256 + JWT_EXPIRE_MINUTES: 30 + ports: + - '8000:80' + volumes: + - ./app:/code/app + restart: on-failure + command: uvicorn app.main:app --host 0.0.0.0 --port 80 --reload + depends_on: + mysql_database: + condition: service_healthy + + mysql_database: + image: mysql:8.3 + restart: unless-stopped + command: --default-authentication-plugin=caching_sha2_password + environment: + MYSQL_DATABASE: 'db' + MYSQL_USER: 'mysql' + MYSQL_PASSWORD: 'mysql' + MYSQL_ROOT_PASSWORD: 'mysql' + ports: + - '3306:3306' + volumes: + - mysql_database:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + timeout: 20s + retries: 10 + +volumes: + mysql_database: + name: mysql_database diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3cf59f2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = --cov=app --cov-report=term --cov-fail-under=100 +testpaths = tests +pythonpath = . \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cdc8a96..db4335a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,34 @@ -alembic==1.13.1 +alembic==1.13.3 +annotated-types==0.7.0 +anyio==4.6.2.post1 asgiref==3.8.1 -certifi==2024.2.2 +certifi==2024.8.30 click==8.1.7 -fastapi==0.68.2 -greenlet==3.0.3 +ecdsa==0.19.0 +fastapi==0.115.2 +greenlet==3.1.1 h11==0.14.0 -pydantic==1.10.14 +idna==3.10 +Mako==1.3.5 +MarkupSafe==3.0.1 +mysql-connector-python==9.0.0 +passlib==1.7.4 +pyasn1==0.6.1 +pydantic==2.9.2 +pydantic-settings==2.5.2 +pydantic_core==2.23.4 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 -setuptools==69.1.1 +python-jose==3.3.0 +python-multipart==0.0.12 +rsa==4.9 +setuptools==75.1.0 sib-api-v3-sdk==7.6.0 six==1.16.0 -SQLAlchemy==2.0.29 -starlette==0.14.2 -typing_extensions==4.10.0 -urllib3==2.2.1 -uvicorn==0.15.0 -wheel==0.43.0 -mysql-connector-python==8.3.0 +sniffio==1.3.1 +SQLAlchemy==2.0.35 +starlette==0.39.2 +typing_extensions==4.12.2 +urllib3==2.2.3 +uvicorn==0.31.1 +wheel==0.44.0 diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..6977b84 --- /dev/null +++ b/tests/test_all.py @@ -0,0 +1,4 @@ +from app.utils import get_user_by_username + +def test_username(): + assert get_user_by_username() == str