Add initial example

This commit is contained in:
Santiago Lo Coco 2023-10-04 19:39:13 -03:00
commit 028a468869
83 changed files with 35169 additions and 0 deletions

4
.env.dev Normal file
View File

@ -0,0 +1,4 @@
POSTGRES_USER=user
POSTGRES_PASS=pass
POSTGRES_DB=api_dev
APP_SETTINGS=src.config.DevelopmentConfig

4
.env.prod Normal file
View File

@ -0,0 +1,4 @@
POSTGRES_USER=user
POSTGRES_PASS=papanata
POSTGRES_DB=api_prod
APP_SETTINGS=src.config.ProductionConfig

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.venv

155
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,155 @@
image: docker:latest
variables:
IMAGE_BASE: "$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME"
DOCKER_BUILDKIT: 1
stages:
- prep
- build
- test
- deliver
- deploy
preparation:
stage: prep
tags:
- dev
script:
- export BUILD_ID=$(date +%Y%m%d%H%M)
- echo "BUILD_ID=${BUILD_ID}" > context.env
- echo "API_PROD_IMAGE_NAME=${IMAGE_BASE}/api:prod-${BUILD_ID}" >> context.env
- echo "API_TEST_IMAGE_NAME=${IMAGE_BASE}/api:test-${BUILD_ID}" >> context.env
- echo "CLIENT_PROD_IMAGE_NAME=${IMAGE_BASE}/client:prod-${BUILD_ID}" >> context.env
- echo "CLIENT_TEST_IMAGE_NAME=${IMAGE_BASE}/client:test-${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_API_IMAGE=$DOCKER_HUB_USER/foodtruckers-api:${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_CLIENT_IMAGE=$DOCKER_HUB_USER/foodtruckers-client:${BUILD_ID}" >> context.env
artifacts:
paths:
- context.env
build-api:
stage: build
tags:
- dev
script:
- export $(cat context.env | xargs)
- docker build sample-api-users -f sample-api-users/Dockerfile.prod -t ${API_PROD_IMAGE_NAME}
- docker build sample-api-users -f sample-api-users/Dockerfile.test --build-arg "BASE_IMAGE=$API_PROD_IMAGE_NAME" -t ${API_TEST_IMAGE_NAME}
- docker login -u $CI_REGISTRY_USER --password $CI_JOB_TOKEN $CI_REGISTRY
- docker push ${API_PROD_IMAGE_NAME}
- docker push ${API_TEST_IMAGE_NAME}
needs:
- job: preparation
artifacts: true
build-client:
stage: build
tags:
- dev
script:
- export $(cat context.env | xargs)
- docker build sample-client-users -t ${CLIENT_PROD_IMAGE_NAME}
- docker build sample-client-users -f sample-client-users/Dockerfile.test -t ${CLIENT_TEST_IMAGE_NAME}
- docker login -u $CI_REGISTRY_USER --password $CI_JOB_TOKEN $CI_REGISTRY
- docker push ${CLIENT_PROD_IMAGE_NAME}
- docker push ${CLIENT_TEST_IMAGE_NAME}
needs:
- job: preparation
artifacts: true
test-api:
stage: test
tags:
- dev
script:
- export $(cat context.env | xargs)
- export API_IMAGE=$API_TEST_IMAGE_NAME
- export CLIENT_IMAGE=dummy-image
- docker login -u $CI_REGISTRY_USER --password $CI_JOB_TOKEN $CI_REGISTRY
- docker compose -f docker-compose.yml --env-file .env.dev --profile api pull
- docker compose -f docker-compose.yml --env-file .env.dev --profile api up --abort-on-container-exit
- docker cp foodtruckers_api:/usr/src/app/coverage.xml .
- docker cp foodtruckers_api:/usr/src/app/report.xml .
artifacts:
when: always
paths:
- coverage.xml
- report.xml
reports:
junit: report.xml
needs:
- job: preparation
- job: build-api
artifacts: true
test-integration:
stage: test
tags:
- dev
script:
- export $(cat context.env | xargs)
- docker login -u $CI_REGISTRY_USER --password $CI_JOB_TOKEN $CI_REGISTRY
- export API_IMAGE=$API_TEST_IMAGE_NAME
- export CLIENT_IMAGE=$CLIENT_TEST_IMAGE_NAME
- export TEST_TARGET=INTEGRATION
- docker compose -f docker-compose.yml --env-file .env.dev --profile all pull
- docker compose -f docker-compose.yml --env-file .env.dev --profile all up --abort-on-container-exit
needs:
- job: test-api
- job: build-client
- job: preparation
artifacts: true
deliver-dockerhub:
stage: deliver
tags:
- dev
script:
- export $(cat context.env | xargs)
- docker login -u $CI_REGISTRY_USER --password $CI_JOB_TOKEN $CI_REGISTRY
- docker login -u $DOCKER_HUB_USER --password $DOCKER_HUB_PASS
- docker tag $API_PROD_IMAGE_NAME $DOCKER_HUB_API_IMAGE
- docker tag $CLIENT_PROD_IMAGE_NAME $DOCKER_HUB_CLIENT_IMAGE
- docker push $DOCKER_HUB_API_IMAGE
- docker push $DOCKER_HUB_CLIENT_IMAGE
needs:
- job: test-integration
- job: preparation
artifacts: true
deploy-prod:
stage: deploy
tags:
- prod
script:
- export $(cat context.env | xargs)
- export API_IMAGE=$DOCKER_HUB_API_IMAGE
- export CLIENT_IMAGE=$DOCKER_HUB_CLIENT_IMAGE
- docker login -u $DOCKER_HUB_USER --password $DOCKER_HUB_PASS
- docker compose -f docker-compose.yml --profile all --env-file .env.prod stop
- docker compose -f docker-compose.yml --profile all --env-file .env.prod rm
- docker compose -f docker-compose.yml --profile all --env-file .env.prod pull
- docker compose -f docker-compose.yml --profile all --env-file .env.prod up -d
needs:
- job: deliver-dockerhub
- job: preparation
artifacts: true

62
README.md Normal file
View File

@ -0,0 +1,62 @@
# CI
## GitlabRunner
[Instalar el runner](https://docs.gitlab.com/runner/install/docker.html)
`docker volume create gitlab-runner-config`
```bash
docker run -d --name gitlab-runner --restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v gitlab-runner-config:/etc/gitlab-runner \
gitlab/gitlab-runner:latest
```
- [Registrar el runner](https://docs.gitlab.com/runner/register/index.html#docker)
- [Variables predefinidas](https://docs.gitlab.com/ee/ci/variables/predefined_variables.html)
`docker run --rm -it -v gitlab-runner-config:/etc/gitlab-runner gitlab/gitlab-runner:latest register`
Pide una URL y un Token que se consiguen en `<REPO> > Settings > CI/CD > Runners`, usar `docker` como executor. y `docker:latest` como imagen
Hay que modificar el archivo `config.toml` del runner. Se puede acceder al mismo desde el host (la configuracion se monto como volumen) o desde el container. Hay que agregar `privileged=true` y en volumes: `["/cache", "/var/run/docker.sock:/var/run/docker.sock"]`.
## Pipeline
Para crear un pipeline dentro de gitlab vamos a definir un archivo llamado `.gitlab-ci.yml` en la raiz del proyecto. En este caso, vamos a usar un pipeline que ejecute en docker, por eso la imagen del mismo sera `image: docker:latest`.
La estructura basica del archivo consiste en:
```yaml
image: docker:latest
variables:
IMAGE_BASE: "$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME"
...
stages:
- prep
- build
- test
- deliver
- deploy
job1:
...
job2:
...
```
[GitLab CI reference](https://docs.gitlab.com/ee/ci/yaml/)
En este caso estamos declarando la imagen mencionada, declaramos variables a ser utilizadas en el pipeline, definimos el orden y nombre de los stages y declaramos los jobs en cuestión.
En nuestro caso utilizamos un stage llamado `prep` para realizar tareas de preparacion previas a la ejecucion del pipeline. En particular, se utiliza este stage para definir los nombres de todas las imagenes a ser creadas a lo largo del pipeline. Estos valores sera almacendas en un artifact llamado `context.env`. Luego, a medida que sea necesario, cada job solicitara el artifact y obtendra los valores requeridos.
El uso de tags es importante para separar la ejecucion en los distinto ambientes. En este caso, eel ambiente de build + test y el ambiente productivo final.
## Imagenes Docker
La idea de las imagenes es generar imagenes de testing que sean lo mas parecido a la imagen final. En el caso de python, la imagen de testing es una extension de la imagen productiva, se agregan las dependencias de testing y se hacen los ajustes pertinentes.

5
db/Dockerfile Normal file
View File

@ -0,0 +1,5 @@
# pull official base image
FROM postgres:13.3
# run create.sql on init
ADD create.sql /docker-entrypoint-initdb.d

3
db/create.sql Normal file
View File

@ -0,0 +1,3 @@
CREATE DATABASE api_prod;
CREATE DATABASE api_dev;
CREATE DATABASE api_test;

61
docker-compose.yml Normal file
View File

@ -0,0 +1,61 @@
version: '3.8'
services:
api:
container_name: foodtruckers_api
image: ${API_IMAGE}
profiles:
- api
- all
ports:
- 5000:5000
healthcheck:
test: ["CMD", "nc", "-vz", "-w1", "localhost", "5000"]
interval: 2s
timeout: 2s
retries: 5
start_period: 2s
environment:
- TEST_TARGET=${TEST_TARGET}
- PORT=5000
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@api-db/${POSTGRES_DB}
- APP_SETTINGS=${APP_SETTINGS}
depends_on:
api-db:
condition: service_healthy
api-db:
container_name: foodtruckers_api_db
build:
context: ./db
dockerfile: Dockerfile
profiles:
- api
- all
healthcheck:
test: psql postgres --command "select 1" -U ${POSTGRES_USER}
interval: 2s
timeout: 10s
retries: 10
start_period: 2s
expose:
- 5432
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASS}
client:
container_name: foodtruckers_client
image: ${CLIENT_IMAGE}
profiles:
- client
- all
restart: always
ports:
- 8080:80
depends_on:
api:
condition: service_healthy
environment:
- API_HOST=api

View File

@ -0,0 +1,5 @@
exclude_dirs:
- src/tests
#tests: ['B201', 'B301']
#skips: ['B101', 'B601']

View File

@ -0,0 +1,3 @@
[run]
omit = src/tests/*
branch = True

7
sample-api-users/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
**/__pycache__
**/Pipfile.lock
.coverage
.pytest_cache
htmlcov
pact-nginx-ssl/nginx-selfsigned.*
src/tests/pacts

View File

@ -0,0 +1,35 @@
# pull official base image
FROM python:3.11.2-slim-buster AS prod
# set working directory
WORKDIR /usr/src/app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV FLASK_DEBUG 0
ENV FLASK_ENV production
ARG SECRET_KEY
ENV SECRET_KEY $SECRET_KEY
RUN apt-get update \
&& apt-get -y install netcat gcc postgresql \
&& apt-get clean \
&& groupadd -g 999 python \
&& useradd -r -u 999 -g python python \
&& python -m venv /usr/src/app/.venv \
&& chown -R python:python /usr/src/app
ENV PATH="/usr/src/app/.venv/bin:$PATH"
ENV PIP_NO_CACHE_DIR=off
USER 999
COPY --chown=python:python requirements.txt requirements.txt
RUN python -m pip install --upgrade pip && \
python -m pip install -r requirements.txt
COPY --chown=python:python . .
# run gunicorn
CMD ["/usr/src/app/.venv/bin/gunicorn", "manage:app"]

View File

@ -0,0 +1,9 @@
env
.venv
Dockerfile.test
Dockerfile.prod
.coverage
.pytest_cache
htmlcov
src/tests
src/.cicd

View File

@ -0,0 +1,21 @@
# pull official base image
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ENV FLASK_DEBUG=1
ENV FLASK_ENV=development
ENV DATABASE_TEST_URL=postgresql://postgres:postgres@api-db:5432/api_test
# add and install requirements
COPY --chown=python:python ./requirements.test.txt .
RUN python -m pip install -r requirements.test.txt
# add app
COPY --chown=python:python src/tests src/tests
# new
# add entrypoint.sh
COPY --chown=python:python src/.cicd/test.sh .
RUN chmod +x /usr/src/app/test.sh
CMD ["/usr/src/app/test.sh"]

15
sample-api-users/Pipfile Normal file
View File

@ -0,0 +1,15 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
flask = "==2.2.3"
flask-restx = "==1.0.6"
flask-sqlalchemy = "==3.0.3"
psycopg2-binary = "==2.9.5"
[dev-packages]
[requires]
python_version = "3.11"

11
sample-api-users/entrypoint.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
echo "Waiting for postgres..."
while ! nc -z api-db 5432; do
sleep 0.1
done
echo "PostgreSQL started"
python manage.py run -h 0.0.0.0

View File

@ -0,0 +1,30 @@
from flask.cli import FlaskGroup
from src import create_app, db
from src.api.models.users import User
from src.api.models.zones import Zone
app = create_app() # new
cli = FlaskGroup(create_app=create_app)
@cli.command("recreate_db")
def recreate_db():
db.drop_all()
db.create_all()
db.session.commit()
@cli.command("seed_db")
def seed_db():
db.session.add(User(username="fede", email="fede@gmail.com", password="password1234"))
db.session.add(User(username="martin", email="martin@gmail.com", password="password1234"))
db.session.add(User(username="nacho", email="nacho@gmail.com", password="password1234"))
db.session.add(Zone(name="Belgrano"))
db.session.add(Zone(name="San Isidro"))
db.session.commit()
if __name__ == "__main__":
cli()

View File

@ -0,0 +1,10 @@
## Testing
pytest==7.2.2
pytest-cov==4.0.0
flake8==6.0.0
black==23.1.0
isort==5.12.0
bandit==1.7.5
pactman==2.3.0
pytest-xdist==3.2.0
pytest-watch==4.2.0

View File

@ -0,0 +1,9 @@
## Prod
flask==2.2.3
flask-restx==1.0.6
Flask-SQLAlchemy==3.0.3
psycopg2-binary==2.9.5
flask-cors==3.0.10
flask-bcrypt==1.0.1
pyjwt==2.6.0
gunicorn==20.1.0

View File

@ -0,0 +1,2 @@
[flake8]
max-line-length = 119

View File

@ -0,0 +1,23 @@
#!/bin/bash -e
if [ "${TEST_TARGET:-}" = "INTEGRATION" ]; then
# Execute your command here
/usr/src/app/.venv/bin/gunicorn manage:app
else
## pytest
python -m pytest "src/tests" --junitxml=report.xml
## Coverage
python -m pytest "src/tests" -p no:warnings --cov="src" --cov-report xml
## Linting
flake8 src --extend-ignore E221
# black src --check
# isort src --check
## Security
# bandit -c .bandit.yml -r .
fi

View File

@ -0,0 +1,36 @@
import os
from flask import Flask
from flask_bcrypt import Bcrypt
from flask_cors import CORS
from flask_sqlalchemy import SQLAlchemy
# instantiate the db
db = SQLAlchemy()
cors = CORS()
bcrypt = Bcrypt()
def create_app(script_info=None):
# instantiate the app
app = Flask(__name__)
# set config
app_settings = os.getenv("APP_SETTINGS")
app.config.from_object(app_settings)
# set up extensions
db.init_app(app)
cors.init_app(app, resources={r"*": {"origins": "*"}})
# register api
from src.api import api
api.init_app(app)
# shell context for flask cli
@app.shell_context_processor
def ctx():
return {"app": app, "db": db}
return app

View File

@ -0,0 +1,15 @@
from flask_restx import Api
from src.api.auth import auth_namespace
from src.api.ping import ping_namespace
from src.api.users import NAMESPACE as NAMESPACE_USERS
from src.api.users import users_namespace
from src.api.zones import NAMESPACE as NAMESPACE_ZONES
from src.api.zones import zones_namespace
api = Api(version="1.0", title="Users API", doc="/doc")
api.add_namespace(ping_namespace, path="/ping")
api.add_namespace(users_namespace, path=f"/{NAMESPACE_USERS}")
api.add_namespace(auth_namespace, path="/auth")
api.add_namespace(zones_namespace, path=f"/{NAMESPACE_ZONES}")

View File

@ -0,0 +1,123 @@
import jwt
from flask import request
from flask_restx import Namespace, Resource
from src import bcrypt
from src.api.cruds.users import add_user, get_user_by_email, get_user_by_id
from src.api.models.users import User
auth_namespace = Namespace("auth")
auth_user_model = User.get_api_auth_user_model(auth_namespace)
auth_full_user_model = User.get_api_auth_full_user_model(auth_namespace)
auth_login_model = User.get_api_auth_login_model(auth_namespace)
auth_refresh_model = User.get_api_auth_refresh_model(auth_namespace)
auth_tokens_model = User.get_api_auth_tokens_model(auth_namespace)
parser = auth_namespace.parser()
parser.add_argument("Authorization", location="headers")
class Register(Resource):
@auth_namespace.marshal_with(auth_user_model)
@auth_namespace.expect(auth_full_user_model, validate=True)
@auth_namespace.response(201, "Success")
@auth_namespace.response(400, "Sorry. That email already exists.")
def post(self):
post_data = request.get_json()
username = post_data.get("username")
email = post_data.get("email")
password = post_data.get("password")
user = get_user_by_email(email)
if user:
auth_namespace.abort(400, "Sorry. That email already exists.")
user = add_user(username, email, password)
return user, 201
class Login(Resource):
@auth_namespace.marshal_with(auth_tokens_model)
@auth_namespace.expect(auth_login_model, validate=True)
@auth_namespace.response(201, "Success")
@auth_namespace.response(404, "User does not exist")
def post(self):
post_data = request.get_json()
email = post_data.get("email")
password = post_data.get("password")
response_object = {}
user = get_user_by_email(email)
if not user or not bcrypt.check_password_hash(user.password, password):
auth_namespace.abort(404, "User does not exist")
access_token = user.encode_token(user.id, "access")
refresh_token = user.encode_token(user.id, "refresh")
response_object = {"access_token": access_token, "refresh_token": refresh_token}
return response_object, 200
class Refresh(Resource):
@auth_namespace.marshal_with(auth_tokens_model)
@auth_namespace.expect(auth_refresh_model, validate=True)
@auth_namespace.response(200, "Success")
@auth_namespace.response(401, "Invalid token")
def post(self):
post_data = request.get_json()
refresh_token = post_data.get("refresh_token")
response_object = {}
try:
user_id = User.decode_token(refresh_token)
user = get_user_by_id(user_id)
if not user:
auth_namespace.abort(401, "Invalid token")
access_token = user.encode_token(user.id, "access")
refresh_token = user.encode_token(user.id, "refresh")
response_object = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return response_object, 200
except jwt.ExpiredSignatureError:
auth_namespace.abort(401, "Signature expired. Please log in again.")
return "Signature expired. Please log in again."
except jwt.InvalidTokenError:
auth_namespace.abort(401, "Invalid token. Please log in again.")
class Status(Resource):
@auth_namespace.marshal_with(auth_user_model)
@auth_namespace.response(200, "Success")
@auth_namespace.response(401, "Invalid token")
@auth_namespace.expect(parser)
def get(self):
auth_header = request.headers.get("Authorization")
if auth_header:
try:
auth_header_list = auth_header.split(" ")
if len(auth_header_list) != 2:
raise jwt.InvalidTokenError(f"Invalid header: {auth_header}")
access_token = auth_header_list[1]
resp = User.decode_token(access_token)
user = get_user_by_id(resp)
if not user:
auth_namespace.abort(401, "Invalid token")
return user, 200
except jwt.ExpiredSignatureError:
auth_namespace.abort(401, "Signature expired. Please log in again.")
return "Signature expired. Please log in again."
except jwt.InvalidTokenError:
auth_namespace.abort(401, "Invalid token. Please log in again.")
else:
auth_namespace.abort(403, "Token required")
auth_namespace.add_resource(Register, "/register")
auth_namespace.add_resource(Login, "/login")
auth_namespace.add_resource(Refresh, "/refresh")
auth_namespace.add_resource(Status, "/status")

View File

@ -0,0 +1,34 @@
from src import db
from src.api.models import User
def get_all_users():
return User.query.all()
def get_user_by_id(user_id):
return User.query.filter_by(id=user_id).first()
def get_user_by_email(email):
return User.query.filter_by(email=email).first()
def add_user(username, email, password):
user = User(username=username, email=email, password=password)
db.session.add(user)
db.session.commit()
return user
def update_user(user, username, email):
user.username = username
user.email = email
db.session.commit()
return user
def delete_user(user):
db.session.delete(user)
db.session.commit()
return user

View File

@ -0,0 +1,34 @@
from src import db
from src.api.models.users import User
def get_all_users():
return User.query.all()
def get_user_by_id(user_id):
return User.query.filter_by(id=user_id).first()
def get_user_by_email(email):
return User.query.filter_by(email=email).first()
def add_user(username, email, password):
user = User(username=username, email=email, password=password)
db.session.add(user)
db.session.commit()
return user
def update_user(user, username, email):
user.username = username
user.email = email
db.session.commit()
return user
def delete_user(user):
db.session.delete(user)
db.session.commit()
return user

View File

@ -0,0 +1,29 @@
from src import db
from src.api.models.zones import Zone
def get_all_zones():
return Zone.query.all()
def get_zone_by_id(zone_id):
return Zone.query.filter_by(id=zone_id).first()
def add_zone(name):
zone = Zone(name=name)
db.session.add(zone)
db.session.commit()
return zone
def update_zone(zone, name):
zone.name = name
db.session.commit()
return zone
def delete_zone(zone):
db.session.delete(zone)
db.session.commit()
return zone

View File

@ -0,0 +1,115 @@
import datetime
import jwt
from flask import current_app
from flask_restx import fields
from sqlalchemy.sql import func
from src import bcrypt, db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(128), nullable=False)
password = db.Column(db.String(255), nullable=False)
active = db.Column(db.Boolean(), default=True, nullable=False)
created_date = db.Column(db.DateTime, default=func.now(), nullable=False)
def __init__(self, username, email, password):
self.username = username
self.email = email
self.password = bcrypt.generate_password_hash(
password, current_app.config.get("BCRYPT_LOG_ROUNDS")
).decode()
@staticmethod
def encode_token(user_id, token_type):
print(f"encode_token(user_id={user_id}, token_type={token_type}):")
if token_type == "access":
seconds = current_app.config.get("ACCESS_TOKEN_EXPIRATION")
else:
seconds = current_app.config.get("REFRESH_TOKEN_EXPIRATION")
payload = {
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds),
"iat": datetime.datetime.utcnow(),
"sub": user_id,
}
return jwt.encode(
payload, current_app.config.get("SECRET_KEY"), algorithm="HS256"
)
@staticmethod
def decode_token(token):
decoded = jwt.decode(
token, current_app.config.get("SECRET_KEY"), algorithms=["HS256"]
)
return decoded["sub"]
@classmethod
def get_api_user_model(cls, namespace):
return namespace.model(
"User",
{
"id": fields.Integer(readOnly=True),
"username": fields.String(required=True),
"email": fields.String(required=True),
"created_date": fields.DateTime,
},
)
@classmethod
def get_api_user_post_model(cls, namespace):
return namespace.inherit(
"User Post",
cls.get_api_user_model(namespace),
{
"password": fields.String(required=False),
},
)
@classmethod
def get_api_auth_user_model(cls, namespace):
return namespace.model(
"User",
{
"username": fields.String(required=True),
"email": fields.String(required=True),
},
)
@classmethod
def get_api_auth_full_user_model(cls, namespace):
return namespace.clone(
"User Full",
cls.get_api_auth_user_model(namespace),
{
"password": fields.String(required=True),
},
)
@classmethod
def get_api_auth_login_model(cls, namespace):
return namespace.model(
"User",
{
"password": fields.String(required=True),
"email": fields.String(required=True),
},
)
@classmethod
def get_api_auth_refresh_model(cls, namespace):
return namespace.model(
"Refresh", {"refresh_token": fields.String(required=True)}
)
@classmethod
def get_api_auth_tokens_model(cls, namespace):
return namespace.clone(
"Access and Refresh Token",
cls.get_api_auth_refresh_model(namespace),
{"access_token": fields.String(required=True)},
)

View File

@ -0,0 +1,20 @@
from flask_restx import fields
def get_model_create_response(namespace):
return namespace.model(
"GenericCreateResponse",
{
"id": fields.Integer(readOnly=True),
"message": fields.String(required=True),
},
)
def get_model_error_response(namespace):
return namespace.model(
"GenericCreateResponse",
{
"message": fields.String(required=True),
},
)

View File

@ -0,0 +1,115 @@
import datetime
import jwt
from flask import current_app
from flask_restx import fields
from sqlalchemy.sql import func
from src import bcrypt, db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(128), nullable=False)
password = db.Column(db.String(255), nullable=False)
active = db.Column(db.Boolean(), default=True, nullable=False)
created_date = db.Column(db.DateTime, default=func.now(), nullable=False)
def __init__(self, username, email, password):
self.username = username
self.email = email
self.password = bcrypt.generate_password_hash(
password, current_app.config.get("BCRYPT_LOG_ROUNDS")
).decode()
@staticmethod
def encode_token(user_id, token_type):
print(f"encode_token(user_id={user_id}, token_type={token_type}):")
if token_type == "access":
seconds = current_app.config.get("ACCESS_TOKEN_EXPIRATION")
else:
seconds = current_app.config.get("REFRESH_TOKEN_EXPIRATION")
payload = {
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds),
"iat": datetime.datetime.utcnow(),
"sub": user_id,
}
return jwt.encode(
payload, current_app.config.get("SECRET_KEY"), algorithm="HS256"
)
@staticmethod
def decode_token(token):
decoded = jwt.decode(
token, current_app.config.get("SECRET_KEY"), algorithms=["HS256"]
)
return decoded["sub"]
@classmethod
def get_api_user_model(cls, namespace):
return namespace.model(
"User",
{
"id": fields.Integer(readOnly=True),
"username": fields.String(required=True),
"email": fields.String(required=True),
"created_date": fields.DateTime,
},
)
@classmethod
def get_api_user_post_model(cls, namespace):
return namespace.inherit(
"User Post",
cls.get_api_user_model(namespace),
{
"password": fields.String(required=False),
},
)
@classmethod
def get_api_auth_user_model(cls, namespace):
return namespace.model(
"User",
{
"username": fields.String(required=True),
"email": fields.String(required=True),
},
)
@classmethod
def get_api_auth_full_user_model(cls, namespace):
return namespace.clone(
"User Full",
cls.get_api_auth_user_model(namespace),
{
"password": fields.String(required=True),
},
)
@classmethod
def get_api_auth_login_model(cls, namespace):
return namespace.model(
"User",
{
"password": fields.String(required=True),
"email": fields.String(required=True),
},
)
@classmethod
def get_api_auth_refresh_model(cls, namespace):
return namespace.model(
"Refresh", {"refresh_token": fields.String(required=True)}
)
@classmethod
def get_api_auth_tokens_model(cls, namespace):
return namespace.clone(
"Access and Refresh Token",
cls.get_api_auth_refresh_model(namespace),
{"access_token": fields.String(required=True)},
)

View File

@ -0,0 +1,33 @@
from flask_restx import fields
from sqlalchemy.sql import func
from src import db
class Zone(db.Model):
__tablename__ = "zones"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(128), nullable=False)
create_date = db.Column(db.DateTime, default=func.now(), nullable=False)
def __init__(self, name):
self.name = name
@classmethod
def get_model_full(cls, namespace):
return namespace.model(
"ZoneFull",
{
"id": fields.Integer(readOnly=True),
"name": fields.String(required=True),
},
)
@classmethod
def get_model_create(cls, namespace):
return namespace.model(
"ZoneCreate",
{
"name": fields.String(required=False),
},
)

View File

@ -0,0 +1,34 @@
from flask import Blueprint
from flask_restx import Api, Namespace, Resource
from src import db
from src.api.models.users import User
from src.api.models.zones import Zone
ping_blueprint = Blueprint("ping", __name__)
api = Api(ping_blueprint)
ping_namespace = Namespace("ping")
class Ping(Resource):
def delete(self):
db.drop_all()
db.create_all()
db.session.commit()
return {"status": "recreated"}
def post(self):
db.session.add(User(username="fede", email="fede@gmail.com", password="password1234"))
db.session.add(User(username="martin", email="martin@gmail.com", password="password1234"))
db.session.add(User(username="nacho", email="nacho@gmail.com", password="password1234"))
db.session.add(Zone(name="Belgrano"))
db.session.add(Zone(name="San Isidro"))
db.session.commit()
return {"status": "seeded"}
def get(self):
return {"status": "success", "message": "pong!"}
ping_namespace.add_resource(Ping, "")

View File

@ -0,0 +1,101 @@
from flask import request
from flask_restx import Namespace, Resource
from src.api.models.users import User
from src.api.cruds.users import ( # isort:skip
get_all_users,
get_user_by_email,
add_user,
get_user_by_id,
update_user,
delete_user,
)
NAMESPACE = "users"
users_namespace = Namespace(NAMESPACE)
user_api_model = User.get_api_user_model(users_namespace)
user_post_api_model = User.get_api_user_post_model(users_namespace)
class UsersList(Resource):
@users_namespace.response(200, "Success")
@users_namespace.marshal_with(user_api_model, as_list=True)
def get(self):
return get_all_users(), 200
@users_namespace.response(201, "<user_email> was added!")
@users_namespace.response(400, "Sorry. That email already exists.")
@users_namespace.expect(user_post_api_model, validate=True)
def post(self):
post_data = request.get_json()
username = post_data.get("username")
email = post_data.get("email")
password = post_data.get("password")
response_object = {}
user = get_user_by_email(email)
if user:
response_object["message"] = "Sorry. That email already exists."
return response_object, 400
user = add_user(username, email, password)
response_object["message"] = f"{email} was added!"
response_object["id"] = user.id
return response_object, 201
class Users(Resource):
@users_namespace.response(200, "Success")
@users_namespace.response(404, "User <user_id> does not exist")
@users_namespace.marshal_with(user_api_model)
def get(self, user_id):
user = get_user_by_id(user_id) # updated
if not user:
users_namespace.abort(404, f"User {user_id} does not exist")
return user, 200
@users_namespace.response(200, "<user_id> was updated!")
@users_namespace.response(404, "User <user_id> does not exist")
@users_namespace.response(400, "Sorry. That email already exists.")
@users_namespace.expect(user_api_model, validate=True)
def put(self, user_id):
post_data = request.get_json()
username = post_data.get("username")
email = post_data.get("email")
response_object = {}
user = get_user_by_id(user_id) # updated
if not user:
users_namespace.abort(404, f"User {user_id} does not exist")
if get_user_by_email(email): # updated
response_object["message"] = "Sorry. That email already exists."
return response_object, 400
user = update_user(user, username, email) # new
response_object["message"] = f"{user.id} was updated!"
response_object["id"] = user.id
return response_object, 200
@users_namespace.response(200, "<user_id> was removed!")
@users_namespace.response(404, "User <user_id> does not exist")
def delete(self, user_id):
response_object = {}
user = get_user_by_id(user_id)
if not user:
users_namespace.abort(404, f"User {user_id} does not exist")
delete_user(user)
response_object["message"] = f"{user.email} was removed!"
response_object["id"] = user.id
return response_object, 200
users_namespace.add_resource(UsersList, "")
users_namespace.add_resource(Users, "/<int:user_id>")

View File

@ -0,0 +1,97 @@
from flask import request
from flask_restx import Namespace, Resource
from src.api.models.generic import get_model_create_response
from src.api.models.zones import Zone as ZoneModel
from src.api.cruds.zones import ( # isort:skip
get_all_zones,
get_zone_by_id,
add_zone,
update_zone,
delete_zone,
)
NAMESPACE = "zones"
zones_namespace = Namespace(NAMESPACE)
zone_model_full = ZoneModel.get_model_full(namespace=zones_namespace)
zone_model_create = ZoneModel.get_model_create(namespace=zones_namespace)
zone_model_create_response = get_model_create_response(namespace=zones_namespace)
class ZonesList(Resource):
@zones_namespace.response(201, "Zone was added!")
@zones_namespace.response(400, "Invalid payload")
@zones_namespace.expect(zone_model_create, validate=True)
@zones_namespace.marshal_with(zone_model_create_response)
def post(self):
post_data = request.get_json()
name = post_data.get("name")
response_object = {}
if not name:
response_object["message"] = "Invalid payload"
return response_object, 400
zone = add_zone(name=name)
response_object["message"] = f"{name} was added!"
response_object["id"] = zone.id
return response_object, 201
@zones_namespace.response(200, "Success")
@zones_namespace.marshal_with(zone_model_full, as_list=True)
def get(self):
return get_all_zones(), 200
class Zones(Resource):
@zones_namespace.response(200, "Success")
@zones_namespace.response(404, "Zone <zone_id> does not exist")
@zones_namespace.marshal_with(zone_model_full)
def get(self, zone_id):
zone = get_zone_by_id(zone_id)
if not zone:
zones_namespace.abort(404, f"Zone {zone_id} does not exist")
return zone, 200
@zones_namespace.response(200, "<zone_id> was updated!")
@zones_namespace.response(404, "Zone <zone_id> does not exist")
@zones_namespace.response(400, "Invalid payload.")
@zones_namespace.expect(zone_model_create, validate=True)
def put(self, zone_id):
post_data = request.get_json()
name = post_data.get("name")
response_object = {}
if not name:
zones_namespace.abort(400, "Invalid payload.")
zone = get_zone_by_id(zone_id)
if not zone:
zones_namespace.abort(404, f"Zone {zone_id} does not exist")
zone = update_zone(zone, name)
response_object["message"] = f"{zone.id} was updated!"
response_object["id"] = zone.id
return response_object, 200
@zones_namespace.response(200, "Success")
@zones_namespace.response(404, "Zone <zone_id> does not exist")
def delete(self, zone_id):
response_object = {}
zone = get_zone_by_id(zone_id)
if not zone:
zones_namespace.abort(404, f"Zone {zone_id} does not exist")
delete_zone(zone)
response_object["message"] = "Success"
return response_object, 200
zones_namespace.add_resource(ZonesList, "")
zones_namespace.add_resource(Zones, "/<int:zone_id>")

View File

@ -0,0 +1,35 @@
import os
class BaseConfig:
TESTING = False
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = "my_precious"
ACCESS_TOKEN_EXPIRATION = 900 # 15 minutes
REFRESH_TOKEN_EXPIRATION = 2592000 # 30 days
class DevelopmentConfig(BaseConfig):
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
BCRYPT_LOG_ROUNDS = 4
class TestingConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_TEST_URL")
BCRYPT_LOG_ROUNDS = 4
ACCESS_TOKEN_EXPIRATION = 5
REFRESH_TOKEN_EXPIRATION = 5
class ProductionConfig(BaseConfig):
BCRYPT_LOG_ROUNDS = 13
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
SECRET_KEY = os.getenv("SECRET_KEY", "my_precious")
def __init__(self):
self.SECRET_KEY = os.getenv("SECRET_KEY", "my_precious")
url = os.environ.get("DATABASE_URL")
if url is not None and url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql://", 1)
self.SQLALCHEMY_DATABASE_URI = url

View File

View File

@ -0,0 +1,67 @@
import pytest
from flask_restx import Namespace
from src import create_app, db
from src.api.models.users import User
from src.config import ProductionConfig
# from pactman import Consumer, Provider
# from src.tests.client.client import UsersClient
PACT_DIR = "src/tests/pacts"
@pytest.fixture(scope="module")
def test_app():
app = create_app()
app.config.from_object("src.config.TestingConfig")
with app.app_context():
yield app # testing happens here
@pytest.fixture(scope="module")
def test_namespace():
ns = Namespace("testing")
yield ns
@pytest.fixture(scope="module")
def test_database():
db.drop_all()
db.create_all()
yield db # testing happens here
db.session.remove()
db.drop_all()
# @pytest.fixture(scope="function")
# def pact():
# pact = Consumer("UsersConsumer").has_pact_with(
# Provider("UsersProvider"), pact_dir=PACT_DIR
# )
# pact.start_service()
# yield pact
# pact.stop_service()
# @pytest.fixture(scope="function")
# def user_client(pact):
# cli = UsersClient(uri=pact.uri)
# yield cli
@pytest.fixture(scope="function")
def prod_config():
yield ProductionConfig()
@pytest.fixture(scope="module")
def add_user():
def _add_user(username, email, password):
user = User(username=username, email=email, password=password)
db.session.add(user)
db.session.commit()
return user
return _add_user

View File

@ -0,0 +1,192 @@
import json
import time
import pytest
TEST_USERNAME = "fede_auth"
TEST_EMAIL = "fede_auth@gmail.com"
TEST_PASSWD = "password1234"
def test_user_registration(test_app, test_database):
client = test_app.test_client()
resp = client.post(
"/auth/register",
data=json.dumps(
{
"username": TEST_USERNAME,
"email": TEST_EMAIL,
"password": TEST_PASSWD,
}
),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 201
assert resp.content_type == "application/json"
assert TEST_USERNAME in data["username"]
assert TEST_EMAIL in data["email"]
assert "password" not in data
def test_user_registration_duplicate_email(test_app, test_database, add_user):
add_user(TEST_USERNAME, TEST_EMAIL, TEST_PASSWD)
client = test_app.test_client()
resp = client.post(
"/auth/register",
data=json.dumps(
{"username": "martin", "email": TEST_EMAIL, "password": "test"}
),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert resp.content_type == "application/json"
assert "Sorry. That email already exists." == data["message"]
@pytest.mark.parametrize(
"payload",
[
{},
{"email": TEST_EMAIL, "password": TEST_PASSWD},
{"username": TEST_USERNAME, "password": TEST_PASSWD},
{"email": TEST_EMAIL, "username": TEST_USERNAME},
{"mail": TEST_EMAIL, "username": TEST_USERNAME, "password": TEST_PASSWD},
{"email": TEST_EMAIL, "user": TEST_USERNAME, "password": TEST_PASSWD},
{"email": TEST_EMAIL, "username": TEST_USERNAME, "passwd": TEST_PASSWD},
],
)
def test_user_registration_invalid_json(test_app, test_database, payload):
client = test_app.test_client()
resp = client.post(
"/auth/register",
data=json.dumps(payload),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert resp.content_type == "application/json"
assert "Input payload validation failed" in data["message"]
def test_registered_user_login(test_app, test_database, add_user):
add_user(TEST_USERNAME, TEST_EMAIL, TEST_PASSWD)
client = test_app.test_client()
resp = client.post(
"/auth/login",
data=json.dumps({"email": TEST_EMAIL, "password": TEST_PASSWD}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert resp.content_type == "application/json"
assert data["access_token"]
assert data["refresh_token"]
def test_not_registered_user_login(test_app, test_database):
client = test_app.test_client()
resp = client.post(
"/auth/login",
data=json.dumps({"email": "invalid@gmail.com", "password": TEST_PASSWD}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 404
assert resp.content_type == "application/json"
assert "User does not exist." in data["message"]
def test_valid_refresh(test_app, test_database, add_user):
add_user(TEST_USERNAME, TEST_EMAIL, TEST_PASSWD)
client = test_app.test_client()
# user login
resp_login = client.post(
"/auth/login",
data=json.dumps({"email": TEST_EMAIL, "password": TEST_PASSWD}),
content_type="application/json",
)
# valid refresh
refresh_token = json.loads(resp_login.data.decode())["refresh_token"]
resp = client.post(
"/auth/refresh",
data=json.dumps({"refresh_token": refresh_token}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert data["access_token"]
assert data["refresh_token"]
assert resp.content_type == "application/json"
def test_invalid_refresh_expired_token(test_app, test_database, add_user):
add_user("test5", "test5@test.com", "test")
client = test_app.test_client()
# user login
resp_login = client.post(
"/auth/login",
data=json.dumps({"email": "test5@test.com", "password": "test"}),
content_type="application/json",
)
# invalid token refresh
time.sleep(10)
refresh_token = json.loads(resp_login.data.decode())["refresh_token"]
resp = client.post(
"/auth/refresh",
data=json.dumps({"refresh_token": refresh_token}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 401
assert resp.content_type == "application/json"
assert "Signature expired. Please log in again." in data["message"]
def test_invalid_refresh(test_app, test_database):
client = test_app.test_client()
resp = client.post(
"/auth/refresh",
data=json.dumps({"refresh_token": "Invalid"}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 401
assert resp.content_type == "application/json"
assert "Invalid token. Please log in again." in data["message"]
def test_user_status(test_app, test_database, add_user):
add_user("test6", "test6@test.com", "test")
client = test_app.test_client()
resp_login = client.post(
"/auth/login",
data=json.dumps({"email": "test6@test.com", "password": "test"}),
content_type="application/json",
)
token = json.loads(resp_login.data.decode())["access_token"]
resp = client.get(
"/auth/status",
headers={"Authorization": f"Bearer {token}"},
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert resp.content_type == "application/json"
assert "test6" in data["username"]
assert "test6@test.com" in data["email"]
assert "password" not in data
def test_invalid_status(test_app, test_database):
client = test_app.test_client()
resp = client.get(
"/auth/status",
headers={"Authorization": "Bearer invalid"},
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 401
assert resp.content_type == "application/json"
assert "Invalid token. Please log in again." in data["message"]

View File

@ -0,0 +1,10 @@
import json
def test_ping(test_app):
client = test_app.test_client()
resp = client.get("/ping")
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert "pong" in data["message"]
assert "success" in data["status"]

View File

@ -0,0 +1,26 @@
import pytest
from src.api.models.users import User
TOKEN_TYPES = ["access", "refresh"]
def test_passwords_are_random(test_app, test_database, add_user):
user_one = add_user("fede", "fede@gmail.com", "test")
user_two = add_user("fedec", "fedec@gmail.com", "test")
assert user_one.password != user_two.password
@pytest.mark.parametrize("token_type", TOKEN_TYPES)
def test_encode_token(test_app, test_database, add_user, token_type):
user = add_user("fede", "fede@gmail.com", "test")
token = User.encode_token(user.id, token_type)
assert isinstance(token, str)
@pytest.mark.parametrize("token_type", TOKEN_TYPES)
def test_decode_token(test_app, test_database, add_user, token_type):
user = add_user("fede", "fede@gmail.com", "test")
token = User.encode_token(user.id, token_type)
token_user_id = User.decode_token(token)
assert isinstance(token_user_id, int)
assert user.id == token_user_id

View File

@ -0,0 +1,219 @@
import json
import pytest
from src import bcrypt
from src.api.cruds.users import get_user_by_id
from src.api.models.users import User
def test_add_user(test_app, test_database):
client = test_app.test_client()
resp = client.post(
"/users",
data=json.dumps(
{
"username": "fede",
"email": "fcastaneda@itba.edu.ar",
"password": "1234566789",
}
),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 201
assert "fcastaneda@itba.edu.ar was added!" in data["message"]
def test_add_user_invalid_json(test_app, test_database):
client = test_app.test_client()
resp = client.post(
"/users",
data=json.dumps({}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert "Input payload validation failed" in data["message"]
def test_add_user_invalid_json_keys(test_app, test_database):
client = test_app.test_client()
resp = client.post(
"/users",
data=json.dumps({"email": "john@begood.io"}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert "Input payload validation failed" in data["message"]
def test_add_user_duplicate_email(test_app, test_database):
client = test_app.test_client()
client.post(
"/users",
data=json.dumps({"username": "fede", "email": "fcastaneda@itba.edu.ar"}),
content_type="application/json",
)
resp = client.post(
"/users",
data=json.dumps(
{
"username": "federico",
"email": "fcastaneda@itba.edu.ar",
"password": "passwd1234",
}
),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert "Sorry. That email already exists." in data["message"]
def test_single_user(test_app, test_database, add_user):
user = add_user("fede", "fede@itba.edu", "passwd1234")
client = test_app.test_client()
resp = client.get(f"/users/{user.id}")
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert "fede" in data["username"]
assert "fede@itba.edu" in data["email"]
assert "password" not in data # Password must not be returned
def test_single_user_incorrect_id(test_app, test_database):
client = test_app.test_client()
resp = client.get("/users/999")
data = json.loads(resp.data.decode())
assert resp.status_code == 404
assert "User 999 does not exist" in data["message"]
def test_all_users(test_app, test_database, add_user):
test_database.session.query(User).delete()
add_user("martin", "martin@itba.edu", "passwd1234")
add_user("nacho", "nacho@itba.edu", "passwd1234")
client = test_app.test_client()
resp = client.get("/users")
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert len(data) == 2
assert "martin" in data[0]["username"]
assert "martin@itba.edu" in data[0]["email"]
assert "nacho" in data[1]["username"]
assert "nacho@itba.edu" in data[1]["email"]
assert "password" not in data[0]
assert "password" not in data[1]
def test_remove_user(test_app, test_database, add_user):
USER_EMAIL = "remove-me@itba.edu"
test_database.session.query(User).delete()
user = add_user("user-to-be-removed", USER_EMAIL, "passwd1234")
client = test_app.test_client()
resp_one = client.get("/users")
data = json.loads(resp_one.data.decode())
assert resp_one.status_code == 200
assert len(data) == 1
resp_two = client.delete(f"/users/{user.id}")
data = json.loads(resp_two.data.decode())
assert resp_two.status_code == 200
assert f"{USER_EMAIL} was removed!" in data["message"]
resp_three = client.get("/users")
data = json.loads(resp_three.data.decode())
assert resp_three.status_code == 200
assert len(data) == 0
def test_remove_user_incorrect_id(test_app, test_database):
client = test_app.test_client()
resp = client.delete("/users/999")
data = json.loads(resp.data.decode())
assert resp.status_code == 404
assert "User 999 does not exist" in data["message"]
def test_update_user(test_app, test_database, add_user):
user = add_user("user-to-be-updated", "update-me@itba.edu", "passwd1234")
client = test_app.test_client()
resp_one = client.put(
f"/users/{user.id}",
data=json.dumps({"username": "me", "email": "me@itba.edu"}),
content_type="application/json",
)
data = json.loads(resp_one.data.decode())
assert resp_one.status_code == 200
assert f"{user.id} was updated!" in data["message"]
resp_two = client.get(f"/users/{user.id}")
data = json.loads(resp_two.data.decode())
assert resp_two.status_code == 200
assert "me" in data["username"]
assert "me@itba.edu" in data["email"]
@pytest.mark.parametrize(
"user_id, payload, status_code, message",
[
[1, {}, 400, "Input payload validation failed"],
[1, {"email": "me@itba.edu"}, 400, "Input payload validation failed"],
[
999,
{"username": "me", "email": "me@itba.edu"},
404,
"User 999 does not exist",
],
],
)
def test_update_user_invalid(
test_app, test_database, user_id, payload, status_code, message
):
client = test_app.test_client()
resp = client.put(
f"/users/{user_id}",
data=json.dumps(payload),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == status_code
assert message in data["message"]
def test_update_user_duplicate_email(test_app, test_database, add_user):
add_user("carlos", "carlos@garca.org", "passwd1234")
user = add_user("charly", "charly@garca.org", "passwd1234")
client = test_app.test_client()
resp = client.put(
f"/users/{user.id}",
data=json.dumps({"username": "charly", "email": "carlos@garca.org"}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert "Sorry. That email already exists." in data["message"]
def test_update_user_with_passord(test_app, test_database, add_user):
password_one = "greaterthaneight"
password_two = "somethingdifferent"
user = add_user("user-to-be-updated", "update-me@testdriven.io", password_one)
assert bcrypt.check_password_hash(user.password, password_one)
client = test_app.test_client()
resp = client.put(
f"/users/{user.id}",
data=json.dumps(
{"username": "me", "email": "foo@testdriven.io", "password": password_two}
),
content_type="application/json",
)
assert resp.status_code == 200
user = get_user_by_id(user.id)
assert bcrypt.check_password_hash(user.password, password_one)
assert not bcrypt.check_password_hash(user.password, password_two)

View File

@ -0,0 +1,126 @@
import json
import pytest
from src.api.cruds.zones import add_zone, get_zone_by_id
from src.api.zones import NAMESPACE as ZONE_NAMESPACE
ZONE_NAME = "San Isidro"
ZONE_NAME_2 = "Bajo San Isidro"
ZONE_INVALID_ID = 99999999
def test_zone_add(test_app, test_database):
client = test_app.test_client()
resp = client.post(
f"/{ZONE_NAMESPACE}",
data=json.dumps({"name": "Belgrano"}),
content_type="application/json",
)
assert resp.status_code == 201
data = json.loads(resp.data.decode())
assert "Belgrano was added!" in data["message"]
assert "id" in data
assert isinstance(data["id"], int)
INVALID_ADD_PAYLOADS = [{}, {"name": ""}, {"names": "bla bla"}]
@pytest.mark.parametrize("payload", INVALID_ADD_PAYLOADS)
def test_zone_add_no_desc(test_app, test_database, payload):
client = test_app.test_client()
resp = client.post(
f"/{ZONE_NAMESPACE}",
data=json.dumps(payload),
content_type="application/json",
)
assert resp.status_code == 400
data = json.loads(resp.data.decode())
assert "Invalid payload" in data["message"]
def test_zone_get_all(test_app, test_database):
client = test_app.test_client()
add_zone(ZONE_NAME)
resp = client.get(f"/{ZONE_NAMESPACE}")
assert resp.status_code == 200
data = json.loads(resp.data.decode())
assert isinstance(data, list)
assert len(data) > 0
def test_zone_get(test_app, test_database):
client = test_app.test_client()
zone = add_zone(ZONE_NAME)
resp = client.get(f"/{ZONE_NAMESPACE}/{zone.id}")
assert resp.status_code == 200
data = json.loads(resp.data.decode())
assert isinstance(data, dict)
assert "id" in data
assert data["id"] == zone.id
assert "name" in data
assert data["name"] == ZONE_NAME
def test_zone_get_not_found(test_app, test_database):
client = test_app.test_client()
resp = client.get(f"/{ZONE_NAMESPACE}/{ZONE_INVALID_ID}")
assert resp.status_code == 404
data = json.loads(resp.data.decode())
assert "message" in data
assert f"Zone {ZONE_INVALID_ID} does not exist" in data["message"]
def test_zone_update(test_app, test_database):
client = test_app.test_client()
zone = add_zone(ZONE_NAME)
body = {"name": ZONE_NAME_2}
resp = client.put(f"/{ZONE_NAMESPACE}/{zone.id}", json=body)
assert resp.status_code == 200
updated_zone = get_zone_by_id(zone.id)
assert updated_zone.name == ZONE_NAME_2
def test_zone_update_not_found(test_app, test_database):
client = test_app.test_client()
body = {"name": ZONE_NAME_2}
resp = client.put(f"/{ZONE_NAMESPACE}/{ZONE_INVALID_ID}", json=body)
assert resp.status_code == 404
data = json.loads(resp.data.decode())
assert "message" in data
assert f"Zone {ZONE_INVALID_ID} does not exist" in data["message"]
@pytest.mark.parametrize("payload", INVALID_ADD_PAYLOADS)
def test_zone_update_invalid_payload(test_app, test_database, payload):
client = test_app.test_client()
zone = add_zone(ZONE_NAME)
resp = client.put(f"/{ZONE_NAMESPACE}/{zone.id}", json=payload)
assert resp.status_code == 400
data = json.loads(resp.data.decode())
assert "Invalid payload" in data["message"]
def test_zone_delete(test_app, test_database):
client = test_app.test_client()
zone = add_zone(ZONE_NAME)
zone_id = zone.id
resp = client.delete(f"/{ZONE_NAMESPACE}/{zone_id}")
assert resp.status_code == 200
data = json.loads(resp.data.decode())
assert "message" in data
assert data["message"] == "Success"
zone = get_zone_by_id(zone_id)
assert not zone
def test_zone_delete_not_found(test_app, test_database):
client = test_app.test_client()
resp = client.delete(f"/{ZONE_NAMESPACE}/{ZONE_INVALID_ID}")
assert resp.status_code == 404
data = json.loads(resp.data.decode())
assert "message" in data
assert f"Zone {ZONE_INVALID_ID} does not exist" in data["message"]

View File

View File

@ -0,0 +1,44 @@
import os
from src.config import ProductionConfig
def test_development_config(test_app):
test_app.config.from_object("src.config.DevelopmentConfig")
assert test_app.config["SECRET_KEY"] == "my_precious"
assert not test_app.config["TESTING"]
assert test_app.config["SQLALCHEMY_DATABASE_URI"] == os.environ.get("DATABASE_URL")
assert test_app.config["BCRYPT_LOG_ROUNDS"] == 4
assert test_app.config["ACCESS_TOKEN_EXPIRATION"] == 900
assert test_app.config["REFRESH_TOKEN_EXPIRATION"] == 2592000
def test_testing_config(test_app):
test_app.config.from_object("src.config.TestingConfig")
assert test_app.config["SECRET_KEY"] == "my_precious"
assert test_app.config["TESTING"]
assert test_app.config["SQLALCHEMY_DATABASE_URI"] == os.environ.get(
"DATABASE_TEST_URL"
)
assert test_app.config["BCRYPT_LOG_ROUNDS"] == 4
assert test_app.config["ACCESS_TOKEN_EXPIRATION"] == 5
assert test_app.config["REFRESH_TOKEN_EXPIRATION"] == 5
def test_production_config(test_app, monkeypatch):
monkeypatch.setenv(
"DATABASE_URL", "postgresql://postgres:postgres@api-db:5432/api_users"
)
test_app.config.from_object(ProductionConfig())
# assert test_app.config["SECRET_KEY"] == "my_precious"
assert not test_app.config["TESTING"]
assert test_app.config["SQLALCHEMY_DATABASE_URI"] == os.environ.get("DATABASE_URL")
assert test_app.config["BCRYPT_LOG_ROUNDS"] == 13
assert test_app.config["ACCESS_TOKEN_EXPIRATION"] == 900
assert test_app.config["REFRESH_TOKEN_EXPIRATION"] == 2592000
def test_production_db_url_rewrite(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgres://server")
prod_config = ProductionConfig()
assert prod_config.SQLALCHEMY_DATABASE_URI == "postgresql://server"

View File

@ -0,0 +1,13 @@
from src.api.models.generic import (get_model_create_response,
get_model_error_response)
def test_model_create_response(test_namespace):
model = get_model_create_response(test_namespace)
assert model.get("id")
assert model.get("message")
def test_get_model_error_response(test_namespace):
model = get_model_error_response(test_namespace)
assert "message" in model

View File

@ -0,0 +1,269 @@
import json
from datetime import datetime
import pytest
import src.api.users
class AttrDict(dict):
def __init__(self, *args, **kwargs):
super(AttrDict, self).__init__(*args, **kwargs)
self.__dict__ = self
def test_add_user(test_app, monkeypatch):
def mock_get_user_by_email(email):
return None
def mock_add_user(username, email, passwd):
d = AttrDict()
d.update(
{
"id": 10,
"username": "me",
"email": "me@itba.edu",
"password": "123456789",
}
)
return d
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
monkeypatch.setattr(src.api.users, "add_user", mock_add_user)
client = test_app.test_client()
resp = client.post(
"/users",
data=json.dumps(
{
"username": "michael",
"email": "michael@itba.edu",
"password": "123456789",
}
),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 201
assert "michael@itba.edu was added!" in data["message"]
assert data["id"] == 10
def test_add_user_duplicate_email(test_app, monkeypatch):
def mock_get_user_by_email(email):
return True
def mock_add_user(username, email, passwd):
d = AttrDict()
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
return d
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
monkeypatch.setattr(src.api.users, "add_user", mock_add_user)
client = test_app.test_client()
resp = client.post(
"/users",
data=json.dumps(
{
"username": "michael",
"email": "michael@itba.edu",
"password": "123456789",
}
),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert "Sorry. That email already exists." in data["message"]
def test_single_user(test_app, monkeypatch):
def mock_get_user_by_id(user_id):
return {
"id": 1,
"username": "jeffrey",
"email": "jeffrey@itba.edu",
"created_date": datetime.now(),
}
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
client = test_app.test_client()
resp = client.get("/users/1")
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert "jeffrey" in data["username"]
assert "jeffrey@itba.edu" in data["email"]
assert "password" not in data
def test_single_user_incorrect_id(test_app, monkeypatch):
def mock_get_user_by_id(user_id):
return None
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
client = test_app.test_client()
resp = client.get("/users/999")
data = json.loads(resp.data.decode())
assert resp.status_code == 404
assert "User 999 does not exist" in data["message"]
def test_all_users(test_app, monkeypatch):
def mock_get_all_users():
return [
{
"id": 1,
"username": "michael",
"email": "michael@mherman.org",
"created_date": datetime.now(),
},
{
"id": 1,
"username": "fletcher",
"email": "fletcher@notreal.com",
"created_date": datetime.now(),
},
]
monkeypatch.setattr(src.api.users, "get_all_users", mock_get_all_users)
client = test_app.test_client()
resp = client.get("/users")
data = json.loads(resp.data.decode())
assert resp.status_code == 200
assert len(data) == 2
assert "michael" in data[0]["username"]
assert "michael@mherman.org" in data[0]["email"]
assert "fletcher" in data[1]["username"]
assert "fletcher@notreal.com" in data[1]["email"]
assert "password" not in data[0]
assert "password" not in data[1]
def test_remove_user(test_app, monkeypatch):
def mock_get_user_by_id(user_id):
d = AttrDict()
d.update(
{"id": 1, "username": "user-to-be-removed", "email": "remove-me@itba.edu"}
)
return d
def mock_delete_user(user):
return True
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
monkeypatch.setattr(src.api.users, "delete_user", mock_delete_user)
client = test_app.test_client()
resp_two = client.delete("/users/1")
data = json.loads(resp_two.data.decode())
assert resp_two.status_code == 200
assert "remove-me@itba.edu was removed!" in data["message"]
def test_remove_user_incorrect_id(test_app, monkeypatch):
def mock_get_user_by_id(user_id):
return None
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
client = test_app.test_client()
resp = client.delete("/users/999")
data = json.loads(resp.data.decode())
assert resp.status_code == 404
assert "User 999 does not exist" in data["message"]
def test_update_user(test_app, monkeypatch):
def mock_get_user_by_id(user_id):
d = AttrDict()
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
return d
def mock_update_user(user, username, email):
d = AttrDict()
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
return d
def mock_get_user_by_email(email):
return None
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
monkeypatch.setattr(src.api.users, "update_user", mock_update_user)
client = test_app.test_client()
resp_one = client.put(
"/users/1",
data=json.dumps({"username": "me", "email": "me@itba.edu"}),
content_type="application/json",
)
data = json.loads(resp_one.data.decode())
assert resp_one.status_code == 200
assert "1 was updated!" in data["message"]
resp_two = client.get("/users/1")
data = json.loads(resp_two.data.decode())
assert resp_two.status_code == 200
assert "me" in data["username"]
assert "me@itba.edu" in data["email"]
@pytest.mark.parametrize(
"user_id, payload, status_code, message",
[
[1, {}, 400, "Input payload validation failed"],
[1, {"email": "me@itba.edu"}, 400, "Input payload validation failed"],
[
999,
{"username": "me", "email": "me@itba.edu"},
404,
"User 999 does not exist",
],
],
)
def test_update_user_invalid(
test_app, monkeypatch, user_id, payload, status_code, message
):
def mock_get_user_by_id(user_id):
return None
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
client = test_app.test_client()
resp = client.put(
f"/users/{user_id}",
data=json.dumps(payload),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == status_code
assert message in data["message"]
def test_update_user_duplicate_email(test_app, monkeypatch):
class AttrDict(dict):
def __init__(self, *args, **kwargs):
super(AttrDict, self).__init__(*args, **kwargs)
self.__dict__ = self
def mock_get_user_by_id(user_id):
d = AttrDict()
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
return d
def mock_update_user(user, username, email):
return True
def mock_get_user_by_email(email):
return True
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
monkeypatch.setattr(src.api.users, "update_user", mock_update_user)
client = test_app.test_client()
resp = client.put(
"/users/1",
data=json.dumps({"username": "me", "email": "me@itba.edu"}),
content_type="application/json",
)
data = json.loads(resp.data.decode())
assert resp.status_code == 400
assert "Sorry. That email already exists." in data["message"]

View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1,12 @@
FROM node:17.9.1 AS app
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=app /app/build .
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,9 @@
FROM node:17.9.1 AS app
WORKDIR /app
COPY package.json .
COPY package-lock.json .
RUN npm install
COPY . .
RUN chmod +x /app/test.sh
ENTRYPOINT ["/app/test.sh"]

View File

@ -0,0 +1,7 @@
Para correr la app basta con ejecutar
npm run start
para corerr los test
npm run test

View File

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
};

View File

@ -0,0 +1,15 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index unresolvable-file-html.html;
try_files $uri @index;
}
location @index {
root /usr/share/nginx/html;
add_header Cache-Control no-cache;
expires 0;
try_files /index.html =404;
}
}

31983
sample-client-users/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
{
"name": "sample-client-users",
"version": "0.1.0",
"private": true,
"dependencies": {
"antd": "^5.3.3",
"axios": "^1.3.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^6.10.0",
"react-router-dom": "^6.10.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "jest --coverage --collectCoverageFrom=\"./src/**\"",
"test:integration": "jest integration",
"eject": "react-scripts eject",
"docker:build": "docker build -t client-users .",
"docker:run": " docker run --rm -it -p 8080:80 client-users"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^28.1.8",
"@types/node": "^16.18.23",
"@types/react": "^18.0.32",
"@types/react-dom": "^18.0.11",
"jest": "^28.0.0",
"jest-environment-jsdom": "^28.0.0",
"ts-jest": "^28.0.0",
"typescript": "^4.9.5"
}
}

View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Client Users</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
--></body>
</html>

View File

@ -0,0 +1,9 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,65 @@
import { Axios, AxiosError } from "axios";
import { Credentials, Token, User, Zone } from "./Types";
const instance = new Axios({
baseURL: "http://127.0.0.1:5000/",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
});
instance.interceptors.response.use(
(response) => {
return JSON.parse(response.data);
},
(error) => {
const err = error as AxiosError;
return Promise.reject(err);
}
);
instance.interceptors.request.use((request) => {
request.data = JSON.stringify(request.data);
return request;
});
//Ping
export const ping = () => {
return instance.get("ping");
};
//Users
export const createUser = (
credentials: Credentials
): Promise<{ id?: string; message: string }> => {
return instance.post("users", credentials);
};
export const fetchUsers = (): Promise<User[]> => {
return instance.get("users");
};
export const fetchUserById = (id: number): Promise<User> => {
return instance.get("users/" + id);
};
//Auth
export const logIn = (
credentials: Credentials
): Promise<Token & Partial<{ message: string; user_id: number }>> => {
return instance.post("auth/login", credentials);
};
export const tokenStatus = (
token: string
): Promise<User & { message?: string }> => {
return instance.get("auth/status", {
headers: { Authorization: `Bearer ${token}` },
});
};
//Zones
export const fetchZones = (): Promise<Zone[]> => {
return instance.get("zones");
};

View File

@ -0,0 +1,41 @@
import React, { useEffect } from "react";
import { LogIn } from "./components/LogIn/LogIn";
import { useIsConnected } from "./hooks/useIsConnected";
import { useAuthenticateUser } from "./hooks/useAuthenticateUser";
import { Route, Routes } from "react-router";
import { SignUp } from "./components/SignUp/SignUp";
import { Home } from "./components/Home/Home";
import { Button } from "antd";
function App() {
const { user, validateToken, logout } = useAuthenticateUser();
const connection = useIsConnected();
useEffect(() => {
validateToken();
}, []);
return (
<div className="App">
<Routes>
<Route path="/login" element={!user ? <LogIn /> : <Home />} />
<Route path="/signup" element={<SignUp />} />
<Route path="/home" element={!user ? <Home /> : <LogIn />} />
<Route path="/" element={!user ? <LogIn /> : <Home />} />
</Routes>
<div className="FloatingStatus">{connection}</div>
<div className="LogoutButton">
{
<Button
onClick={() => logout()}
disabled={!!!localStorage.getItem("token")}
>
Logout
</Button>
}
</div>
</div>
);
}
export default App;

22
sample-client-users/src/Types.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
export interface Credentials {
password: string;
email: string;
username?: string;
}
export interface Token {
refresh_token: string;
access_token: string;
}
export interface User {
id: number;
username: string;
email: string;
created_date?: Date;
}
export interface Zone {
id: number;
name: string;
}

View File

@ -0,0 +1,17 @@
import "../../matchMedia.mock";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { Button } from "antd";
describe("Button Component Test", () => {
test("Display button label and clicked", async () => {
const onClick = jest.fn();
render(<Button onClick={() => onClick()}>Button</Button>);
expect(screen.getByText("Button")).toBeVisible();
await userEvent.click(screen.getByText("Button"));
expect(onClick).toBeCalled();
});
});

View File

@ -0,0 +1,14 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import "../../../matchMedia.mock";
import { Card } from "./Card";
describe("Card Component Test", () => {
test("Display initial, name and icon", async () => {
render(<Card name="Belgrano" />);
expect(screen.getByText("Belgrano📍")).toBeVisible();
expect(screen.getByText("B")).toBeVisible();
});
});

View File

@ -0,0 +1,16 @@
import React from "react";
import { Avatar, Button } from "antd";
interface Props {
name: string;
}
export const Card: React.FC<Props> = ({ name }) => {
return (
<div className="Card">
<Avatar size="large">{name.slice(0, 1).toUpperCase()}</Avatar>
{name}
📍
</div>
);
};

View File

@ -0,0 +1,28 @@
const mockedUsedNavigate = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useNavigate: () => mockedUsedNavigate,
}));
import "../../matchMedia.mock";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { Home } from "./Home";
describe("Home View Test", () => {
test("Display initial, name and icon", async () => {
render(
<Home
zones={[
{ id: 1, name: "Belgrano" },
{ id: 2, name: "San Isidro" },
]}
/>
);
expect(screen.getByText("Zones")).toBeVisible();
expect(screen.getByText("Belgrano📍")).toBeVisible();
expect(screen.getByText("San Isidro📍")).toBeVisible();
});
});

View File

@ -0,0 +1,26 @@
import React from "react";
import { Card } from "./Card/Card";
import { useFetchZones } from "../../hooks/useFetchZones";
import { Zone } from "../../Types";
interface Props {
zones?: Zone[];
}
export const Home: React.FC<Props> = (props) => {
const { zones, error } = useFetchZones();
return (
<div className="Box Big">
<div className="Items">
<h2>Zones</h2>
<div className="List">
{(props.zones ? props.zones : zones).map((u) => {
return <Card key={u.id} name={u.name} />;
})}
</div>
{error ? <div className="Disconnected">{error}</div> : <></>}
</div>
</div>
);
};

View File

@ -0,0 +1,45 @@
import React, { useState } from "react";
import { Button, Input } from "antd";
import { useAuthenticateUser } from "../../hooks/useAuthenticateUser";
export const LogIn = () => {
const { isLoading, error, authenticate } = useAuthenticateUser();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<div className="Box Small">
<div className="Section">
<img
alt="logo"
className="Image"
src="https://www.seekpng.com/png/full/353-3537757_logo-itba.png"
/>
<div className="Section">
<Input
placeholder="User"
onChange={(ev) => setEmail(ev.target.value)}
/>
<Input.Password
placeholder="Password"
onChange={(ev) => setPassword(ev.target.value)}
/>
<Button
style={{ width: "100%" }}
onClick={async () =>
await authenticate({ email, password })
}
loading={isLoading}
>
Log in
</Button>
{error ? (
<div className="Disconnected">{error}</div>
) : (
<></>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,63 @@
import React, { useState } from "react";
import { Button, Input } from "antd";
import { useCreateUser } from "../../hooks/useCreateUser";
export const SignUp = () => {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [repeatPassword, setRepeatPassword] = useState("");
const { createUser, isLoading, error } = useCreateUser();
return (
<div className="Box Small">
<div className="Section">
<img
alt="logo"
className="Image"
src="https://www.seekpng.com/png/full/353-3537757_logo-itba.png"
/>
<div className="Section">
<Input
type="email"
placeholder="Email"
onChange={(ev) => setEmail(ev.target.value)}
/>
<Input
placeholder="Username"
onChange={(ev) => setUsername(ev.target.value)}
/>
<Input.Password
placeholder="Password"
onChange={(ev) => setPassword(ev.target.value)}
/>
<Input.Password
placeholder="Repeat password"
onChange={(ev) => setRepeatPassword(ev.target.value)}
/>
<Button
style={{ width: "100%" }}
onClick={async () =>
await createUser({ email, password, username })
}
loading={isLoading}
disabled={
email === "" ||
username === "" ||
password === "" ||
password !== repeatPassword
}
>
Sign up
</Button>
{error ? (
<div className="Disconnected">{error}</div>
) : (
<></>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,66 @@
const mockedUsedNavigate = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useNavigate: () => mockedUsedNavigate,
}));
import "../matchMedia.mock";
import { act, renderHook } from "@testing-library/react";
import { useAuthenticateUser } from "./useAuthenticateUser";
describe("UseAuthenticateUser Hook Test", () => {
afterEach(() => {
localStorage.removeItem("token");
});
test("Hook initial state", async () => {
const { result } = renderHook(() => useAuthenticateUser());
expect(result.current.isLoading).toBeFalsy();
expect(result.current.error).toBeNull();
});
test("Hook fetch state - Authenticate function - Promise not resolved", async () => {
const { result } = renderHook(() => useAuthenticateUser());
act(() => {
result.current.authenticate({
email: "martin@gmail.com",
password: "password1234",
});
});
expect(result.current.isLoading).toBeTruthy();
expect(result.current.error).toBeNull();
});
test("Hook fetch state - Authenticate function - Promise success", async () => {
const { result } = renderHook(() => useAuthenticateUser());
await act(async () => {
await result.current.authenticate({
email: "martin@gmail.com",
password: "password1234",
});
});
expect(localStorage.getItem("token")).not.toBeNull();
expect(result.current.isLoading).toBeFalsy();
expect(result.current.error).not.toBeNull();
});
test("Hook fetch state - Authenticate function - Promise failed", async () => {
const { result } = renderHook(() => useAuthenticateUser());
await act(async () => {
await result.current.authenticate({
email: "notExistingUser",
password: "notExistingUser",
});
});
expect(localStorage.getItem("token")).toBe("undefined");
expect(result.current.isLoading).toBeFalsy();
expect(result.current.error).not.toBeNull();
});
});

View File

@ -0,0 +1,75 @@
import React, { useEffect } from "react";
import { useState } from "react";
import { Credentials, User } from "../Types";
import { useNavigate } from "react-router-dom";
import { fetchUserById, logIn } from "../Api";
import { tokenStatus } from "../Api";
export const useAuthenticateUser = () => {
const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
if (user) {
navigate("/home");
} else {
if (window.location.pathname === "/signup") {
navigate("/signup");
} else {
navigate("/login");
}
}
}, [user]);
const authenticate = async (credentials: Credentials): Promise<void> => {
if (!user) {
try {
setIsLoading(true);
setError(null);
const tokens = await logIn(credentials);
localStorage.setItem("token", tokens.access_token);
if (tokens.user_id) {
const user = await fetchUserById(tokens.user_id);
setUser(user);
} else {
setError(tokens.message!.split(".")[0] + ".");
setUser(null);
}
} catch (error) {
setError(error as string);
} finally {
setIsLoading(false);
}
}
};
const validateToken = async () => {
try {
const existingToken = localStorage.getItem("token");
if (existingToken) {
const response = await tokenStatus(existingToken);
const { message } = response;
if (message) throw new Error("Invalid token");
const user = await fetchUserById(response.id);
setUser(user);
}
} catch (error) {
logout();
}
};
const logout = () => {
localStorage.removeItem("token");
setUser(null);
navigate("/login");
};
return { user, isLoading, authenticate, validateToken, logout, error };
};

View File

@ -0,0 +1,32 @@
import React from "react";
import { useState } from "react";
import { Credentials } from "../Types";
import { createUser as createUserAPI } from "../Api";
import { useAuthenticateUser } from "./useAuthenticateUser";
export const useCreateUser = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { authenticate } = useAuthenticateUser();
const createUser = async (credentials: Credentials): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const createResponse = await createUserAPI(credentials);
if (createResponse.id) {
authenticate(credentials);
} else {
setError(createResponse.message);
}
} catch (error) {
setError(error as string);
} finally {
setIsLoading(false);
}
};
return { createUser, isLoading, error };
};

View File

@ -0,0 +1,23 @@
import React, { useEffect } from "react";
import { useState } from "react";
import { User, Zone } from "../Types";
import { fetchZones } from "../Api";
export const useFetchZones = () => {
const [error, setError] = useState<string | null>(null);
const [zones, setZones] = useState<Zone[]>([]);
useEffect(() => {
setError(null);
fetchZones()
.then((data) => {
setZones(data);
})
.catch((error) => {
setError(error as string);
});
}, []);
return { zones, error };
};

View File

@ -0,0 +1,27 @@
import React, { useEffect } from "react";
import { useState } from "react";
import { ping } from "../Api";
export const useIsConnected = () => {
const [connected, setConnected] = useState(false);
useEffect(() => {
ping()
.then(() => {
setConnected(true);
})
.catch(() => {
setConnected(false);
});
}, []);
return (
<div>
{connected ? (
<p className="Connected">Connected</p>
) : (
<p className="Disconnected">Disconnected</p>
)}
</div>
);
};

View File

@ -0,0 +1,110 @@
body {
margin: 0;
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
.App {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #eff2f7;
}
.Box {
border-radius: 20px;
box-shadow: 0px 20px 60px rgba(0, 0, 0, 0.2);
padding: 50px;
gap: 30px;
background-color: white;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.Small {
width: 250px;
height: 400px;
}
.Big {
width: 350px;
height: 600px;
}
.Section {
flex: 1;
width: 100%;
padding: 30px 50px;
gap: 30px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.Image {
width: 150px;
}
.Connected {
color: green;
}
.Disconnected {
color: red;
}
.FloatingStatus {
position: absolute;
top: 10px;
right: 50px;
}
.LogoutButton {
position: absolute;
bottom: 10px;
right: 50px;
}
.Card {
border-radius: 8px;
box-shadow: 0px 10px 10px rgba(0, 0, 0, 0.2);
gap: 10px;
padding: 10px;
width: 100%;
background-color: white;
display: flex;
align-items: center;
}
.Items {
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: column;
}
.List {
width: 100%;
height: 500px;
gap: 30px;
padding: 20px;
overflow-y: auto;
display: flex;
align-items: center;
flex-direction: column;
}

View File

@ -0,0 +1,23 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -0,0 +1,13 @@
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,8 @@
#!/bin/bash
curl -X DELETE api:5000/ping
curl -X POST api:5000/ping
# npm test
echo "NPM TEST"

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}