Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -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://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ htmlcov/
.env
timetracker-api-postman-collection.json
swagger.json

# Ignore any SQLite generated database
*.db
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!")
14 changes: 6 additions & 8 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,13 +20,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!")
193 changes: 190 additions & 3 deletions tests/projects/projects_namespace_test.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,203 @@
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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use the name test_create_project_should_fail_with_invalid_request for consistency with the previous request.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather to leave bad request because that is the verbose name of the HTTP status code 400.

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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about test_get_all_projects or test_get_projects?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description in the comments says list. As long as it makes sense and it is not ambiguous we are Ok.

from time_tracker_api.projects.projects_namespace import project_dao
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice, you are calling from time_tracker_api.projects.projects_namespace import project_dao in all the tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if I do it before the project_dao would be broken because it has to be constructed after the app instance is ready. This is a necessary evil.

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 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,
'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(str(valid_id), valid_project_data)


def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use the name test_update_project_should_fail_with_invalid_request as before.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad request is better

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))
6 changes: 4 additions & 2 deletions time_tracker_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os

from flask import Flask
Expand Down Expand Up @@ -34,13 +35,14 @@ 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not be app.logger.setLevel(logging.DEBUG)?

Copy link
Contributor Author

@EliuX EliuX Mar 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO logging.INFO is detailed enough.

add_debug_toolbar(app)


Expand Down
8 changes: 0 additions & 8 deletions time_tracker_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
28 changes: 18 additions & 10 deletions time_tracker_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,47 @@ class Config:
SECRET_KEY = generate_dev_secret_key()
DATABASE_URI = os.environ.get('DATABASE_URI')
PROPAGATE_EXCEPTIONS = True
RESTPLUS_VALIDATE = True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good.



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):
Expand Down
Loading