From e1eacf7f55d20dacae25a519b7779b718c271083 Mon Sep 17 00:00:00 2001 From: EliuX Date: Mon, 23 Mar 2020 19:00:25 -0500 Subject: [PATCH 1/2] Close #30 Persist and test API ns for projects --- tests/conftest.py | 8 +- tests/projects/projects_namespace_test.py | 191 +++++++++++++++++- time_tracker_api/__init__.py | 7 +- time_tracker_api/api.py | 8 - time_tracker_api/config.py | 1 + time_tracker_api/projects/projects_model.py | 2 +- .../projects/projects_namespace.py | 13 +- time_tracker_api/sql_repository.py | 2 +- 8 files changed, 207 insertions(+), 25 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 23b9204c..49bf0892 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,13 +22,13 @@ def client(app: Flask) -> FlaskClient: @pytest.fixture(scope="module") def sql_repository(): from .resources import PersonSQLModel - from time_tracker_api.database import seeder from time_tracker_api.sql_repository import db - seeder.fresh() + db.metadata.create_all(bind=db.engine, tables=[PersonSQLModel.__table__]) + print("Test models created!") from time_tracker_api.sql_repository import SQLRepository yield SQLRepository(PersonSQLModel) - db.drop_all() - print("Models for test removed!") + db.metadata.drop_all(bind=db.engine, tables=[PersonSQLModel.__table__]) + print("Test models removed!") diff --git a/tests/projects/projects_namespace_test.py b/tests/projects/projects_namespace_test.py index 847318e4..8ca3533d 100644 --- a/tests/projects/projects_namespace_test.py +++ b/tests/projects/projects_namespace_test.py @@ -1,16 +1,201 @@ +from faker import Faker from flask import json from flask.testing import FlaskClient from pytest_mock import MockFixture +from time_tracker_api.projects.projects_model import PROJECT_TYPE -def test_list_all_elements(client: FlaskClient, mocker: MockFixture): +fake = Faker() + +valid_project_data = { + "name": fake.company(), + "description": fake.paragraph(), + "type": fake.word(PROJECT_TYPE.valid_type_values()), +} +fake_project = ({ + "id": fake.random_int(1, 9999) +}).update(valid_project_data) + + +def test_create_project_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + repository_create_mock = mocker.patch.object(project_dao.repository, + 'create', + return_value=fake_project) + + response = client.post("/projects", json=valid_project_data, follow_redirects=True) + + assert 201 == response.status_code + repository_create_mock.assert_called_once_with(valid_project_data) + + +def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): from time_tracker_api.projects.projects_namespace import project_dao - repository_find_all_mock = mocker.patch.object(project_dao.repository, 'find_all', return_value=[]) + invalid_project_data = valid_project_data.copy().update({ + "type": 'anything', + }) + repository_create_mock = mocker.patch.object(project_dao.repository, + 'create', + return_value=fake_project) + + response = client.post("/projects", json=invalid_project_data, follow_redirects=True) + + assert 400 == response.status_code + repository_create_mock.assert_not_called() + + +def test_list_all_projects(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + repository_find_all_mock = mocker.patch.object(project_dao.repository, + 'find_all', + return_value=[]) response = client.get("/projects", follow_redirects=True) assert 200 == response.status_code - json_data = json.loads(response.data) assert [] == json_data repository_find_all_mock.assert_called_once() + + +def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + + valid_id = fake.random_int(1, 9999) + + repository_find_mock = mocker.patch.object(project_dao.repository, + 'find', + return_value=fake_project) + + response = client.get("/projects/%s" % valid_id, follow_redirects=True) + + assert 200 == response.status_code + fake_project == json.loads(response.data) + repository_find_mock.assert_called_once_with(str(valid_id)) + + +def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_find_mock = mocker.patch.object(project_dao.repository, + 'find', + side_effect=NotFound) + + response = client.get("/projects/%s" % invalid_id, follow_redirects=True) + + assert 404 == response.status_code + repository_find_mock.assert_called_once_with(str(invalid_id)) + + +def test_get_project_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + from werkzeug.exceptions import UnprocessableEntity + + invalid_id = fake.company() + + repository_find_mock = mocker.patch.object(project_dao.repository, + 'find', + side_effect=UnprocessableEntity) + + response = client.get("/projects/%s" % invalid_id, follow_redirects=True) + + assert 422 == response.status_code + repository_find_mock.assert_called_once_with(str(invalid_id)) + + +def update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + + repository_update_mock = mocker.patch.object(project_dao.repository, + 'update', + return_value=fake_project) + + valid_id = fake.random_int(1, 9999) + response = client.put("/projects/%s" % valid_id, json=valid_project_data, follow_redirects=True) + + assert 200 == response.status_code + fake_project == json.loads(response.data) + repository_update_mock.assert_called_once_with(valid_id, valid_project_data) + + +def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + invalid_project_data = valid_project_data.copy().update({ + "type": 'anything', + }) + repository_update_mock = mocker.patch.object(project_dao.repository, + 'update', + return_value=fake_project) + + valid_id = fake.random_int(1, 9999) + response = client.put("/projects/%s" % valid_id, json=invalid_project_data, follow_redirects=True) + + assert 400 == response.status_code + repository_update_mock.assert_not_called() + + +def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_update_mock = mocker.patch.object(project_dao.repository, + 'update', + side_effect=NotFound) + + response = client.put("/projects/%s" % invalid_id, json=valid_project_data, follow_redirects=True) + + assert 404 == response.status_code + repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_data) + + +def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + + valid_id = fake.random_int(1, 9999) + + repository_remove_mock = mocker.patch.object(project_dao.repository, + 'remove', + return_value=None) + + response = client.delete("/projects/%s" % valid_id, follow_redirects=True) + + assert 204 == response.status_code + assert b'' == response.data + repository_remove_mock.assert_called_once_with(str(valid_id)) + + +def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_remove_mock = mocker.patch.object(project_dao.repository, + 'remove', + side_effect=NotFound) + + response = client.delete("/projects/%s" % invalid_id, follow_redirects=True) + + assert 404 == response.status_code + repository_remove_mock.assert_called_once_with(str(invalid_id)) + + +def test_delete_project_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.projects.projects_namespace import project_dao + from werkzeug.exceptions import UnprocessableEntity + + invalid_id = fake.company() + + repository_remove_mock = mocker.patch.object(project_dao.repository, + 'remove', + side_effect=UnprocessableEntity) + + response = client.delete("/projects/%s" % invalid_id, follow_redirects=True) + + assert 422 == response.status_code + repository_remove_mock.assert_called_once_with(str(invalid_id)) diff --git a/time_tracker_api/__init__.py b/time_tracker_api/__init__.py index 52081a2d..8a7da1ee 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -1,3 +1,4 @@ +import logging import os from flask import Flask @@ -34,16 +35,18 @@ def init_app_config(app: Flask, config_path: str, config_data: dict = None): def init_app(app: Flask): - from .database import init_app as init_database + from time_tracker_api.database import init_app as init_database init_database(app) - from .api import api + from time_tracker_api.api import api api.init_app(app) if app.config.get('DEBUG'): + app.logger.setLevel(logging.INFO) add_debug_toolbar(app) + def add_debug_toolbar(app): app.config['DEBUG_TB_PANELS'] = ( 'flask_debugtoolbar.panels.versions.VersionDebugPanel', diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 6b8fb8d0..010b8ae8 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -25,14 +25,6 @@ description='Date of update', example=faker.iso8601(end_datetime=None), ), - # TODO Activate it when the tenants model is implemented - # 'tenant_id': fields.String( - # readOnly=True, - # title='Tenant', - # max_length=64, - # description='The tenant this belongs to', - # example=faker.random_int(1, 9999), - # ), 'created_by': fields.String( readOnly=True, title='Creator', diff --git a/time_tracker_api/config.py b/time_tracker_api/config.py index 5e935a97..79ac9990 100644 --- a/time_tracker_api/config.py +++ b/time_tracker_api/config.py @@ -7,6 +7,7 @@ class Config: SECRET_KEY = generate_dev_secret_key() DATABASE_URI = os.environ.get('DATABASE_URI') PROPAGATE_EXCEPTIONS = True + RESTPLUS_VALIDATE = True class DevelopConfig(Config): diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index a47fbc72..4de694a7 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -3,7 +3,6 @@ from flask import Flask from time_tracker_api.database import CRUDDao -from time_tracker_api.sql_repository import SQLCRUDDao, AuditedSQLModel, SQLModel class PROJECT_TYPE(enum.Enum): @@ -21,6 +20,7 @@ class ProjectDao(CRUDDao): def create_dao(app: Flask) -> ProjectDao: from time_tracker_api.sql_repository import db + from time_tracker_api.sql_repository import SQLCRUDDao, AuditedSQLModel, SQLModel class ProjectSQLModel(db.Model, SQLModel, AuditedSQLModel): __tablename__ = 'projects' diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index 3ed32736..57e713b2 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -3,7 +3,7 @@ from time_tracker_api import flask_app from time_tracker_api.api import audit_fields -from .projects_model import PROJECT_TYPE, create_dao +from time_tracker_api.projects.projects_model import PROJECT_TYPE, create_dao faker = Faker() @@ -65,21 +65,19 @@ class Projects(Resource): @ns.doc('list_projects') @ns.marshal_list_with(project, code=200) def get(self): + """List all projects""" return project_dao.get_all(), 200 @ns.doc('create_project') @ns.response(409, 'This project already exists') - @ns.response(422, 'The data has an invalid format') + @ns.response(400, 'Bad request') @ns.expect(project_input) @ns.marshal_with(project, code=201) def post(self): + """Create a project""" return project_dao.create(ns.payload), 201 -# TODO : fix, this parser is for a field that is not being used. -project_update_parser = ns.parser() - - @ns.route('/') @ns.response(404, 'Project not found') @ns.param('id', 'The project identifier') @@ -88,6 +86,7 @@ class Project(Resource): @ns.response(422, 'The id has an invalid format') @ns.marshal_with(project) def get(self, id): + """Get a project""" return project_dao.get(id) @ns.doc('update_project') @@ -96,11 +95,13 @@ def get(self, id): @ns.expect(project_input) @ns.marshal_with(project) def put(self, id): + """Update a project""" return project_dao.update(id, ns.payload) @ns.doc('delete_project') @ns.response(204, 'Project deleted successfully') @ns.response(422, 'The id has an invalid format') def delete(self, id): + """Delete a project""" project_dao.delete(id) return None, 204 diff --git a/time_tracker_api/sql_repository.py b/time_tracker_api/sql_repository.py index 0570afb4..800bd477 100644 --- a/time_tracker_api/sql_repository.py +++ b/time_tracker_api/sql_repository.py @@ -6,7 +6,7 @@ from time_tracker_api.database import CRUDDao, Seeder, DatabaseModel, convert_result_to_dto from time_tracker_api.security import current_user_id -db = None +db: SQLAlchemy = None SQLModel = None AuditedSQLModel = None From 4ce46121dddbf4300e55e367be3b84fc4f34b3dc Mon Sep 17 00:00:00 2001 From: EliuX Date: Tue, 24 Mar 2020 13:45:32 -0500 Subject: [PATCH 2/2] Apply requested changes --- .env.template | 5 +++++ .gitignore | 3 +++ README.md | 25 ++++++++++++++++++--- run.py | 4 ++-- tests/conftest.py | 6 ++--- tests/projects/projects_namespace_test.py | 12 +++++----- time_tracker_api/__init__.py | 1 - time_tracker_api/config.py | 27 ++++++++++++++--------- time_tracker_api/database.py | 3 --- 9 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 .env.template diff --git a/.env.template b/.env.template new file mode 100644 index 00000000..96325fea --- /dev/null +++ b/.env.template @@ -0,0 +1,5 @@ +# Package where the app is located +export FLASK_APP=time_tracker_api + +# The database connection URI. Check out the README.md for more details +DATABASE_URI=mssql+pyodbc://:@time-tracker-srv.database.windows.net/?driver\=ODBC Driver 17 for SQL Server diff --git a/.gitignore b/.gitignore index 23ff50ef..e5f5178c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ htmlcov/ .env timetracker-api-postman-collection.json swagger.json + +# Ignore any SQLite generated database +*.db diff --git a/README.md b/README.md index 0f999373..c012b5f0 100644 --- a/README.md +++ b/README.md @@ -87,15 +87,34 @@ a link to the swagger.json with the definition of the api. ## Development ### Test -We are using Pytest](https://docs.pytest.org/en/latest/index.html) for tests. The tests are located in the package +We are using [Pytest](https://docs.pytest.org/en/latest/index.html) for tests. The tests are located in the package `tests` and use the [conventions for python test discovery](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery). -To run the tests just execute: +#### Integration tests +The [integrations tests](https://en.wikipedia.org/wiki/Integration_testing) verifies that all the components of the app +are working well together. These are the default tests we should run: -```bash +```dotenv +python3 -m pytest -v --ignore=tests/sql_repository_test.py +``` + +As you may have noticed we are ignoring the tests related with the repository. + + +#### System tests +In addition to the integration testing we might include tests to the data access layer in order to verify that the +persisted data is being managed the right way, i.e. it actually works. We may classify the execution of all the existing +tests as [system testing](https://en.wikipedia.org/wiki/System_testing): + +```dotenv python3 -m pytest -v ``` +The database tests will be done in the table `tests` of the database specified by the variable `DATABASE_URI`. If this +variable is not specified it will automatically connect to `sqlite:///tests.db`. This will do, because we are using SQL +Alchemy to be able connect to any SQL database maintaining the same codebase. + + The option `-v` shows which tests failed or succeeded. Have into account that you can also debug each test (test_* files) with the help of an IDE like PyCharm. diff --git a/run.py b/run.py index bec4799b..a99707a3 100644 --- a/run.py +++ b/run.py @@ -4,5 +4,5 @@ """ from time_tracker_api import create_app -app = create_app() -print("TimeTracker API server was created") +app = create_app('time_tracker_api.config.ProductionConfig') +print("TimeTracker API server created!") diff --git a/tests/conftest.py b/tests/conftest.py index 49bf0892..679c7ee8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,10 @@ from time_tracker_api import create_app -CONFIGURATIONS = ['AzureSQLDatabaseDevelopTestConfig'] - -@pytest.fixture(scope='session', params=CONFIGURATIONS) +@pytest.fixture(scope='session') def app(request: FixtureRequest) -> Flask: - return create_app("time_tracker_api.config.%s" % request.param) + return create_app("time_tracker_api.config.TestConfig") @pytest.fixture diff --git a/tests/projects/projects_namespace_test.py b/tests/projects/projects_namespace_test.py index 8ca3533d..283d85c4 100644 --- a/tests/projects/projects_namespace_test.py +++ b/tests/projects/projects_namespace_test.py @@ -106,7 +106,7 @@ def test_get_project_should_return_422_for_invalid_id_format(client: FlaskClient repository_find_mock.assert_called_once_with(str(invalid_id)) -def update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): from time_tracker_api.projects.projects_namespace import project_dao repository_update_mock = mocker.patch.object(project_dao.repository, @@ -118,7 +118,7 @@ def update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: M assert 200 == response.status_code fake_project == json.loads(response.data) - repository_update_mock.assert_called_once_with(valid_id, valid_project_data) + repository_update_mock.assert_called_once_with(str(valid_id), valid_project_data) def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): @@ -147,7 +147,9 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli 'update', side_effect=NotFound) - response = client.put("/projects/%s" % invalid_id, json=valid_project_data, follow_redirects=True) + response = client.put("/projects/%s" % invalid_id, + json=valid_project_data, + follow_redirects=True) assert 404 == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_data) @@ -192,8 +194,8 @@ def test_delete_project_should_return_422_for_invalid_id_format(client: FlaskCli invalid_id = fake.company() repository_remove_mock = mocker.patch.object(project_dao.repository, - 'remove', - side_effect=UnprocessableEntity) + 'remove', + side_effect=UnprocessableEntity) response = client.delete("/projects/%s" % invalid_id, follow_redirects=True) diff --git a/time_tracker_api/__init__.py b/time_tracker_api/__init__.py index 8a7da1ee..cca09e77 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -46,7 +46,6 @@ def init_app(app: Flask): add_debug_toolbar(app) - def add_debug_toolbar(app): app.config['DEBUG_TB_PANELS'] = ( 'flask_debugtoolbar.panels.versions.VersionDebugPanel', diff --git a/time_tracker_api/config.py b/time_tracker_api/config.py index 79ac9990..0dd96511 100644 --- a/time_tracker_api/config.py +++ b/time_tracker_api/config.py @@ -10,37 +10,44 @@ class Config: RESTPLUS_VALIDATE = True -class DevelopConfig(Config): +class DevelopmentConfig(Config): DEBUG = True FLASK_DEBUG = True - FLASK_ENV = "develop" + FLASK_ENV = "development" -class TestConfig(Config): +class SQLConfig(Config): + SQLALCHEMY_DATABASE_URI = Config.DATABASE_URI + SQLALCHEMY_COMMIT_ON_TEARDOWN = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class TestConfig(SQLConfig): TESTING = True FLASK_DEBUG = True TEST_TABLE = 'tests' + DATABASE_URI = os.environ.get('DATABASE_URI', 'sqlite:///tests.db') + SQLALCHEMY_DATABASE_URI = DATABASE_URI -class SQLConfig(Config): - SQLALCHEMY_DATABASE_URI = Config.DATABASE_URI - SQLALCHEMY_COMMIT_ON_TEARDOWN = True - SQLALCHEMY_TRACK_MODIFICATIONS = False +class ProductionConfig(Config): + FLASK_ENV = 'production' class AzureConfig(SQLConfig): pass -class AzureSQLDatabaseDevelopConfig(DevelopConfig, AzureConfig): +class AzureDevelopmentConfig(DevelopmentConfig, AzureConfig): pass -class AzureSQLDatabaseDevelopTestConfig(TestConfig, AzureSQLDatabaseDevelopConfig): +class AzureProductionConfig(ProductionConfig, AzureConfig): pass -DefaultConfig = AzureSQLDatabaseDevelopConfig +DefaultConfig = AzureDevelopmentConfig +ProductionConfig = AzureProductionConfig class CLIConfig(DefaultConfig): diff --git a/time_tracker_api/database.py b/time_tracker_api/database.py index 845735fe..f71d6354 100644 --- a/time_tracker_api/database.py +++ b/time_tracker_api/database.py @@ -6,8 +6,6 @@ To know more about protocols and subtyping check out PEP-0544 """ import abc -import enum -from datetime import datetime from flask import Flask @@ -75,4 +73,3 @@ def init_app(app: Flask) -> None: init_app(app) global seeder seeder = SQLSeeder() -