Skip to content

Commit 777f677

Browse files
authored
Merge pull request #40 from ioet/feature/Persist-projects-ns-#38
Persist and test API namespace for projects
2 parents e909f45 + 4ce4612 commit 777f677

File tree

13 files changed

+259
-47
lines changed

13 files changed

+259
-47
lines changed

.env.template

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Package where the app is located
2+
export FLASK_APP=time_tracker_api
3+
4+
# The database connection URI. Check out the README.md for more details
5+
DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ htmlcov/
2727
.env
2828
timetracker-api-postman-collection.json
2929
swagger.json
30+
31+
# Ignore any SQLite generated database
32+
*.db

README.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,34 @@ a link to the swagger.json with the definition of the api.
8787
## Development
8888
8989
### Test
90-
We are using Pytest](https://docs.pytest.org/en/latest/index.html) for tests. The tests are located in the package
90+
We are using [Pytest](https://docs.pytest.org/en/latest/index.html) for tests. The tests are located in the package
9191
`tests` and use the [conventions for python test discovery](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery).
9292
93-
To run the tests just execute:
93+
#### Integration tests
94+
The [integrations tests](https://en.wikipedia.org/wiki/Integration_testing) verifies that all the components of the app
95+
are working well together. These are the default tests we should run:
9496
95-
```bash
97+
```dotenv
98+
python3 -m pytest -v --ignore=tests/sql_repository_test.py
99+
```
100+
101+
As you may have noticed we are ignoring the tests related with the repository.
102+
103+
104+
#### System tests
105+
In addition to the integration testing we might include tests to the data access layer in order to verify that the
106+
persisted data is being managed the right way, i.e. it actually works. We may classify the execution of all the existing
107+
tests as [system testing](https://en.wikipedia.org/wiki/System_testing):
108+
109+
```dotenv
96110
python3 -m pytest -v
97111
```
98112

113+
The database tests will be done in the table `tests` of the database specified by the variable `DATABASE_URI`. If this
114+
variable is not specified it will automatically connect to `sqlite:///tests.db`. This will do, because we are using SQL
115+
Alchemy to be able connect to any SQL database maintaining the same codebase.
116+
117+
99118
The option `-v` shows which tests failed or succeeded. Have into account that you can also debug each test
100119
(test_* files) with the help of an IDE like PyCharm.
101120

run.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
"""
55
from time_tracker_api import create_app
66

7-
app = create_app()
8-
print("TimeTracker API server was created")
7+
app = create_app('time_tracker_api.config.ProductionConfig')
8+
print("TimeTracker API server created!")

tests/conftest.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@
55

66
from time_tracker_api import create_app
77

8-
CONFIGURATIONS = ['AzureSQLDatabaseDevelopTestConfig']
98

10-
11-
@pytest.fixture(scope='session', params=CONFIGURATIONS)
9+
@pytest.fixture(scope='session')
1210
def app(request: FixtureRequest) -> Flask:
13-
return create_app("time_tracker_api.config.%s" % request.param)
11+
return create_app("time_tracker_api.config.TestConfig")
1412

1513

1614
@pytest.fixture
@@ -22,13 +20,13 @@ def client(app: Flask) -> FlaskClient:
2220
@pytest.fixture(scope="module")
2321
def sql_repository():
2422
from .resources import PersonSQLModel
25-
from time_tracker_api.database import seeder
2623
from time_tracker_api.sql_repository import db
2724

28-
seeder.fresh()
25+
db.metadata.create_all(bind=db.engine, tables=[PersonSQLModel.__table__])
26+
print("Test models created!")
2927

3028
from time_tracker_api.sql_repository import SQLRepository
3129
yield SQLRepository(PersonSQLModel)
3230

33-
db.drop_all()
34-
print("Models for test removed!")
31+
db.metadata.drop_all(bind=db.engine, tables=[PersonSQLModel.__table__])
32+
print("Test models removed!")
Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,203 @@
1+
from faker import Faker
12
from flask import json
23
from flask.testing import FlaskClient
34
from pytest_mock import MockFixture
45

6+
from time_tracker_api.projects.projects_model import PROJECT_TYPE
57

6-
def test_list_all_elements(client: FlaskClient, mocker: MockFixture):
8+
fake = Faker()
9+
10+
valid_project_data = {
11+
"name": fake.company(),
12+
"description": fake.paragraph(),
13+
"type": fake.word(PROJECT_TYPE.valid_type_values()),
14+
}
15+
fake_project = ({
16+
"id": fake.random_int(1, 9999)
17+
}).update(valid_project_data)
18+
19+
20+
def test_create_project_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture):
21+
from time_tracker_api.projects.projects_namespace import project_dao
22+
repository_create_mock = mocker.patch.object(project_dao.repository,
23+
'create',
24+
return_value=fake_project)
25+
26+
response = client.post("/projects", json=valid_project_data, follow_redirects=True)
27+
28+
assert 201 == response.status_code
29+
repository_create_mock.assert_called_once_with(valid_project_data)
30+
31+
32+
def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture):
733
from time_tracker_api.projects.projects_namespace import project_dao
8-
repository_find_all_mock = mocker.patch.object(project_dao.repository, 'find_all', return_value=[])
34+
invalid_project_data = valid_project_data.copy().update({
35+
"type": 'anything',
36+
})
37+
repository_create_mock = mocker.patch.object(project_dao.repository,
38+
'create',
39+
return_value=fake_project)
40+
41+
response = client.post("/projects", json=invalid_project_data, follow_redirects=True)
42+
43+
assert 400 == response.status_code
44+
repository_create_mock.assert_not_called()
45+
46+
47+
def test_list_all_projects(client: FlaskClient, mocker: MockFixture):
48+
from time_tracker_api.projects.projects_namespace import project_dao
49+
repository_find_all_mock = mocker.patch.object(project_dao.repository,
50+
'find_all',
51+
return_value=[])
952

1053
response = client.get("/projects", follow_redirects=True)
1154

1255
assert 200 == response.status_code
13-
1456
json_data = json.loads(response.data)
1557
assert [] == json_data
1658
repository_find_all_mock.assert_called_once()
59+
60+
61+
def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture):
62+
from time_tracker_api.projects.projects_namespace import project_dao
63+
64+
valid_id = fake.random_int(1, 9999)
65+
66+
repository_find_mock = mocker.patch.object(project_dao.repository,
67+
'find',
68+
return_value=fake_project)
69+
70+
response = client.get("/projects/%s" % valid_id, follow_redirects=True)
71+
72+
assert 200 == response.status_code
73+
fake_project == json.loads(response.data)
74+
repository_find_mock.assert_called_once_with(str(valid_id))
75+
76+
77+
def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture):
78+
from time_tracker_api.projects.projects_namespace import project_dao
79+
from werkzeug.exceptions import NotFound
80+
81+
invalid_id = fake.random_int(1, 9999)
82+
83+
repository_find_mock = mocker.patch.object(project_dao.repository,
84+
'find',
85+
side_effect=NotFound)
86+
87+
response = client.get("/projects/%s" % invalid_id, follow_redirects=True)
88+
89+
assert 404 == response.status_code
90+
repository_find_mock.assert_called_once_with(str(invalid_id))
91+
92+
93+
def test_get_project_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture):
94+
from time_tracker_api.projects.projects_namespace import project_dao
95+
from werkzeug.exceptions import UnprocessableEntity
96+
97+
invalid_id = fake.company()
98+
99+
repository_find_mock = mocker.patch.object(project_dao.repository,
100+
'find',
101+
side_effect=UnprocessableEntity)
102+
103+
response = client.get("/projects/%s" % invalid_id, follow_redirects=True)
104+
105+
assert 422 == response.status_code
106+
repository_find_mock.assert_called_once_with(str(invalid_id))
107+
108+
109+
def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture):
110+
from time_tracker_api.projects.projects_namespace import project_dao
111+
112+
repository_update_mock = mocker.patch.object(project_dao.repository,
113+
'update',
114+
return_value=fake_project)
115+
116+
valid_id = fake.random_int(1, 9999)
117+
response = client.put("/projects/%s" % valid_id, json=valid_project_data, follow_redirects=True)
118+
119+
assert 200 == response.status_code
120+
fake_project == json.loads(response.data)
121+
repository_update_mock.assert_called_once_with(str(valid_id), valid_project_data)
122+
123+
124+
def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture):
125+
from time_tracker_api.projects.projects_namespace import project_dao
126+
invalid_project_data = valid_project_data.copy().update({
127+
"type": 'anything',
128+
})
129+
repository_update_mock = mocker.patch.object(project_dao.repository,
130+
'update',
131+
return_value=fake_project)
132+
133+
valid_id = fake.random_int(1, 9999)
134+
response = client.put("/projects/%s" % valid_id, json=invalid_project_data, follow_redirects=True)
135+
136+
assert 400 == response.status_code
137+
repository_update_mock.assert_not_called()
138+
139+
140+
def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture):
141+
from time_tracker_api.projects.projects_namespace import project_dao
142+
from werkzeug.exceptions import NotFound
143+
144+
invalid_id = fake.random_int(1, 9999)
145+
146+
repository_update_mock = mocker.patch.object(project_dao.repository,
147+
'update',
148+
side_effect=NotFound)
149+
150+
response = client.put("/projects/%s" % invalid_id,
151+
json=valid_project_data,
152+
follow_redirects=True)
153+
154+
assert 404 == response.status_code
155+
repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_data)
156+
157+
158+
def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture):
159+
from time_tracker_api.projects.projects_namespace import project_dao
160+
161+
valid_id = fake.random_int(1, 9999)
162+
163+
repository_remove_mock = mocker.patch.object(project_dao.repository,
164+
'remove',
165+
return_value=None)
166+
167+
response = client.delete("/projects/%s" % valid_id, follow_redirects=True)
168+
169+
assert 204 == response.status_code
170+
assert b'' == response.data
171+
repository_remove_mock.assert_called_once_with(str(valid_id))
172+
173+
174+
def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture):
175+
from time_tracker_api.projects.projects_namespace import project_dao
176+
from werkzeug.exceptions import NotFound
177+
178+
invalid_id = fake.random_int(1, 9999)
179+
180+
repository_remove_mock = mocker.patch.object(project_dao.repository,
181+
'remove',
182+
side_effect=NotFound)
183+
184+
response = client.delete("/projects/%s" % invalid_id, follow_redirects=True)
185+
186+
assert 404 == response.status_code
187+
repository_remove_mock.assert_called_once_with(str(invalid_id))
188+
189+
190+
def test_delete_project_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture):
191+
from time_tracker_api.projects.projects_namespace import project_dao
192+
from werkzeug.exceptions import UnprocessableEntity
193+
194+
invalid_id = fake.company()
195+
196+
repository_remove_mock = mocker.patch.object(project_dao.repository,
197+
'remove',
198+
side_effect=UnprocessableEntity)
199+
200+
response = client.delete("/projects/%s" % invalid_id, follow_redirects=True)
201+
202+
assert 422 == response.status_code
203+
repository_remove_mock.assert_called_once_with(str(invalid_id))

time_tracker_api/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import os
23

34
from flask import Flask
@@ -34,13 +35,14 @@ def init_app_config(app: Flask, config_path: str, config_data: dict = None):
3435

3536

3637
def init_app(app: Flask):
37-
from .database import init_app as init_database
38+
from time_tracker_api.database import init_app as init_database
3839
init_database(app)
3940

40-
from .api import api
41+
from time_tracker_api.api import api
4142
api.init_app(app)
4243

4344
if app.config.get('DEBUG'):
45+
app.logger.setLevel(logging.INFO)
4446
add_debug_toolbar(app)
4547

4648

time_tracker_api/api.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,6 @@
2525
description='Date of update',
2626
example=faker.iso8601(end_datetime=None),
2727
),
28-
# TODO Activate it when the tenants model is implemented
29-
# 'tenant_id': fields.String(
30-
# readOnly=True,
31-
# title='Tenant',
32-
# max_length=64,
33-
# description='The tenant this belongs to',
34-
# example=faker.random_int(1, 9999),
35-
# ),
3628
'created_by': fields.String(
3729
readOnly=True,
3830
title='Creator',

time_tracker_api/config.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,47 @@ class Config:
77
SECRET_KEY = generate_dev_secret_key()
88
DATABASE_URI = os.environ.get('DATABASE_URI')
99
PROPAGATE_EXCEPTIONS = True
10+
RESTPLUS_VALIDATE = True
1011

1112

12-
class DevelopConfig(Config):
13+
class DevelopmentConfig(Config):
1314
DEBUG = True
1415
FLASK_DEBUG = True
15-
FLASK_ENV = "develop"
16+
FLASK_ENV = "development"
1617

1718

18-
class TestConfig(Config):
19+
class SQLConfig(Config):
20+
SQLALCHEMY_DATABASE_URI = Config.DATABASE_URI
21+
SQLALCHEMY_COMMIT_ON_TEARDOWN = True
22+
SQLALCHEMY_TRACK_MODIFICATIONS = False
23+
24+
25+
class TestConfig(SQLConfig):
1926
TESTING = True
2027
FLASK_DEBUG = True
2128
TEST_TABLE = 'tests'
29+
DATABASE_URI = os.environ.get('DATABASE_URI', 'sqlite:///tests.db')
30+
SQLALCHEMY_DATABASE_URI = DATABASE_URI
2231

2332

24-
class SQLConfig(Config):
25-
SQLALCHEMY_DATABASE_URI = Config.DATABASE_URI
26-
SQLALCHEMY_COMMIT_ON_TEARDOWN = True
27-
SQLALCHEMY_TRACK_MODIFICATIONS = False
33+
class ProductionConfig(Config):
34+
FLASK_ENV = 'production'
2835

2936

3037
class AzureConfig(SQLConfig):
3138
pass
3239

3340

34-
class AzureSQLDatabaseDevelopConfig(DevelopConfig, AzureConfig):
41+
class AzureDevelopmentConfig(DevelopmentConfig, AzureConfig):
3542
pass
3643

3744

38-
class AzureSQLDatabaseDevelopTestConfig(TestConfig, AzureSQLDatabaseDevelopConfig):
45+
class AzureProductionConfig(ProductionConfig, AzureConfig):
3946
pass
4047

4148

42-
DefaultConfig = AzureSQLDatabaseDevelopConfig
49+
DefaultConfig = AzureDevelopmentConfig
50+
ProductionConfig = AzureProductionConfig
4351

4452

4553
class CLIConfig(DefaultConfig):

0 commit comments

Comments
 (0)