diff --git a/.env.template b/.env.template index 3ae757dd..022d416e 100644 --- a/.env.template +++ b/.env.template @@ -2,7 +2,13 @@ ## Package where the app is located export FLASK_APP=time_tracker_api - # Common attributes -## 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 +## In case you use an Azure SQL database, you must specify the database connection URI. Check out the README.md for more details +#export DATABASE_URI=mssql+pyodbc://:@time-tracker-srv.database.windows.net/?driver\=ODBC Driver 17 for SQL Server + +## For Azure Cosmos DB +export DATABASE_ACCOUNT_URI=https://.documents.azure.com:443 +export DATABASE_MASTER_KEY= +export DATABASE_NAME= +### or +# export DATABASE_URI=AccountEndpoint=;AccountKey= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a6482d82..bf9bf448 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY . . RUN apk update \ && apk add --no-cache $buildDeps gcc unixodbc-dev \ - && pip3 install --no-cache-dir -r requirements/prod.txt \ + && pip3 install --no-cache-dir -r requirements/time_tracker_api/prod.txt \ && curl -O https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.2.1-1_amd64.apk \ && curl -O https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.2.1-1_amd64.apk \ && curl -O https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.2.1-1_amd64.sig \ diff --git a/README.md b/README.md index 11b4ec81..0338ae95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # time-tracker-api -This is the mono-repository for the backend services and common codebase +This is the mono-repository for the backend services and their common codebase ## Getting started Follow the following instructions to get the project ready to use ASAP. @@ -100,7 +100,7 @@ The [integrations tests](https://en.wikipedia.org/wiki/Integration_testing) veri are working well together. These are the default tests we should run: ```dotenv -python3 -m pytest -v --ignore=tests/sql_repository_test.py +python3 -m pytest -v --ignore=tests/commons/data_access_layer/azure/sql_repository_test.py ``` As you may have noticed we are ignoring the tests related with the repository. diff --git a/commons/data_access_layer/azure/__init__.py b/commons/data_access_layer/azure/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py new file mode 100644 index 00000000..13d9272d --- /dev/null +++ b/commons/data_access_layer/cosmos_db.py @@ -0,0 +1,149 @@ +import dataclasses +import uuid +from typing import Callable + +import azure.cosmos.cosmos_client as cosmos_client +import azure.cosmos.exceptions as exceptions +from azure.cosmos import ContainerProxy +from flask import Flask + + +class CosmosDBFacade: + def __init__(self, app: Flask): # pragma: no cover + self.app = app + + db_uri = app.config.get('DATABASE_URI') + if db_uri is None: + app.logger.warn("DATABASE_URI was not found. Looking for alternative variables.") + account_uri = app.config.get('DATABASE_ACCOUNT_URI') + if account_uri is None: + raise EnvironmentError("DATABASE_ACCOUNT_URI is not defined in the environment") + + master_key = app.config.get('DATABASE_MASTER_KEY') + if master_key is None: + raise EnvironmentError("DATABASE_MASTER_KEY is not defined in the environment") + + self.client = cosmos_client.CosmosClient(account_uri, {'masterKey': master_key}, + user_agent="CosmosDBDotnetQuickstart", + user_agent_overwrite=True) + else: + self.client = cosmos_client.CosmosClient.from_connection_string(db_uri) + + db_id = app.config.get('DATABASE_NAME') + if db_id is None: + raise EnvironmentError("DATABASE_NAME is not defined in the environment") + + self.db = self.client.get_database_client(db_id) + + def create_container(self, container_definition: dict): + try: + return self.db.create_container(**container_definition) + + except exceptions.CosmosResourceExistsError: # pragma: no cover + self.app.logger.info('Container with id \'{0}\' was found'.format(container_definition["id"])) + + def delete_container(self, container_id: str): + try: + return self.db.delete_container(container_id) + + except exceptions.CosmosHttpResponseError: # pragma: no cover + self.app.logger.info('Container with id \'{0}\' was not deleted'.format(container_id)) + + +cosmos_helper: CosmosDBFacade = None + + +class CosmosDBModel(): + def __init__(self, data): + names = set([f.name for f in dataclasses.fields(self)]) + for k, v in data.items(): + if k in names: + setattr(self, k, v) + + +class CosmosDBRepository: + def __init__(self, container_id: str, + mapper: Callable = None, + custom_cosmos_helper: CosmosDBFacade = None): + global cosmos_helper + self.cosmos_helper = custom_cosmos_helper or cosmos_helper + if self.cosmos_helper is None: # pragma: no cover + raise ValueError("The cosmos_db module has not been initialized!") + self.mapper = mapper + self.container: ContainerProxy = self.cosmos_helper.db.get_container_client(container_id) + + @classmethod + def from_definition(cls, container_definition: dict, + mapper: Callable = None, + custom_cosmos_helper: CosmosDBFacade = None): + return cls(container_definition['id'], mapper, custom_cosmos_helper) + + def create(self, data: dict, mapper: Callable = None): + function_mapper = self.get_mapper_or_dict(mapper) + return function_mapper(self.container.create_item(body=data)) + + def find(self, id: str, partition_key_value, visible_only=True, mapper: Callable = None): + found_item = self.container.read_item(id, partition_key_value) + function_mapper = self.get_mapper_or_dict(mapper) + return function_mapper(self.check_visibility(found_item, visible_only)) + + def find_all(self, partition_key_value: str, max_count=None, offset=0, + visible_only=True, mapper: Callable = None): + # TODO Use the tenant_id param and change container alias + max_count = self.get_page_size_or(max_count) + result = self.container.query_items( + query=""" + SELECT * FROM c WHERE c.tenant_id=@tenant_id AND {visibility_condition} + OFFSET @offset LIMIT @max_count + """.format(visibility_condition=self.create_sql_condition_for_visibility(visible_only)), + parameters=[ + {"name": "@tenant_id", "value": partition_key_value}, + {"name": "@offset", "value": offset}, + {"name": "@max_count", "value": max_count}, + ], + partition_key=partition_key_value, + max_item_count=max_count) + + function_mapper = self.get_mapper_or_dict(mapper) + return list(map(function_mapper, result)) + + def partial_update(self, id: str, changes: dict, partition_key_value: str, + visible_only=True, mapper: Callable = None): + item_data = self.find(id, partition_key_value, visible_only=visible_only) + item_data.update(changes) + return self.update(id, item_data, mapper=mapper) + + def update(self, id: str, item_data: dict, mapper: Callable = None): + function_mapper = self.get_mapper_or_dict(mapper) + return function_mapper(self.container.replace_item(id, body=item_data)) + + def delete(self, id: str, partition_key_value: str, mapper: Callable = None): + return self.partial_update(id, { + 'deleted': str(uuid.uuid4()) + }, partition_key_value, visible_only=True, mapper=mapper) + + def check_visibility(self, item, throw_not_found_if_deleted): + if throw_not_found_if_deleted and item.get('deleted') is not None: + raise exceptions.CosmosResourceNotFoundError(message='Deleted item', + status_code=404) + + return item + + def create_sql_condition_for_visibility(self, visible_only: bool, container_name='c') -> str: + if visible_only: + # We are considering that `deleted == null` is not a choice + return 'NOT IS_DEFINED(%s.deleted)' % container_name + return 'true' + + def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable: + return alternative_mapper or self.mapper or dict + + def get_page_size_or(self, custom_page_size: int) -> int: + # TODO The default value should be taken from the Azure Feature Manager + # or any other repository for the settings + return custom_page_size or 100 + + +def init_app(app: Flask) -> None: + global cosmos_helper + cosmos_helper = CosmosDBFacade(app) diff --git a/commons/data_access_layer/azure/sql_repository.py b/commons/data_access_layer/sql.py similarity index 97% rename from commons/data_access_layer/azure/sql_repository.py rename to commons/data_access_layer/sql.py index 7bd5934a..e9a02491 100644 --- a/commons/data_access_layer/azure/sql_repository.py +++ b/commons/data_access_layer/sql.py @@ -14,7 +14,7 @@ def handle_commit_issues(f): def rollback_if_necessary(*args, **kw): try: return f(*args, **kw) - except: + except: # pragma: no cover db.session.rollback() raise @@ -92,7 +92,7 @@ def delete(self, id): self.repository.remove(id) -class SQLSeeder(Seeder): +class SQLSeeder(Seeder): # pragma: no cover def run(self): print("Provisioning database...") db.create_all() diff --git a/requirements/azure_cosmos.txt b/requirements/azure_cosmos.txt new file mode 100644 index 00000000..53ab3e98 --- /dev/null +++ b/requirements/azure_cosmos.txt @@ -0,0 +1,14 @@ +# requirements/azure_cosmos.txt + +# For Cosmos DB + +# Azure Cosmos DB official library +azure-core==1.1.1 +azure-cosmos==4.0.0b6 +certifi==2019.11.28 +chardet==3.0.4 +idna==2.8 +six==1.13.0 +urllib3==1.25.7 +virtualenv==16.7.9 +virtualenv-clone==0.5.3 \ No newline at end of file diff --git a/requirements/commons.txt b/requirements/commons.txt new file mode 100644 index 00000000..d3bc2f6c --- /dev/null +++ b/requirements/commons.txt @@ -0,0 +1,6 @@ +# requirements/commons.txt + +# For Common dependencies + +# Handling requests +requests==2.23.0 diff --git a/requirements/sql_db_serverless.txt b/requirements/sql_db.txt similarity index 59% rename from requirements/sql_db_serverless.txt rename to requirements/sql_db.txt index d3eadf51..c3f62a2d 100644 --- a/requirements/sql_db_serverless.txt +++ b/requirements/sql_db.txt @@ -1,6 +1,6 @@ -# requirements/sql_db_serverless.txt +# requirements/sql_db.txt -# For SQL database serverless (MS SQL) +# For SQL database (MS SQL) # SQL Server driver diff --git a/requirements/time_tracker_api/prod.txt b/requirements/time_tracker_api/prod.txt index 3ce670df..f1221c4a 100644 --- a/requirements/time_tracker_api/prod.txt +++ b/requirements/time_tracker_api/prod.txt @@ -1,8 +1,9 @@ # requirements/time_tracker_api/prod.txt # Dependencies --r ../sql_db_serverless.txt - +-r ../commons.txt +-r ../azure_cosmos.txt +-r ../sql_db.txt # For production releases @@ -19,15 +20,9 @@ gunicorn==20.0.4 #Swagger support for Restful API flask-restplus==0.12.1 -#Mocking -Faker==4.0.2 - #CLI support Flask-Script==2.0.6 -# Handling requests -requests==2.23.0 - # The Debug Toolbar Flask-DebugToolbar==0.11.0 diff --git a/setup.cfg b/setup.cfg index f7333a82..824945c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,3 +6,4 @@ addopts = -p no:warnings branch = True source = time_tracker_api + commons diff --git a/tests/commons/data_access_layer/azure/__init__.py b/tests/commons/data_access_layer/azure/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/commons/data_access_layer/azure/resources.py b/tests/commons/data_access_layer/azure/resources.py deleted file mode 100644 index 4b2300a4..00000000 --- a/tests/commons/data_access_layer/azure/resources.py +++ /dev/null @@ -1,15 +0,0 @@ -from commons.data_access_layer.azure.sql_repository import db, AuditedSQLModel - -from sqlalchemy_utils import UUIDType -import uuid - - -class PersonSQLModel(db.Model, AuditedSQLModel): - __tablename__ = 'test' - id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) - name = db.Column(db.String(80), unique=False, nullable=False) - email = db.Column(db.String(120), unique=True, nullable=False) - age = db.Column(db.Integer, nullable=False) - - def __repr__(self): - return '' % self.name diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py new file mode 100644 index 00000000..d369cf20 --- /dev/null +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -0,0 +1,509 @@ +from dataclasses import dataclass +from typing import Callable + +import pytest +from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError +from faker import Faker +from pytest import fail + +from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel + +fake = Faker() +Faker.seed() + +existing_item: dict = None + + +@dataclass() +class Person(CosmosDBModel): + id: str + name: str + email: str + age: int + tenant_id: str + + def __init__(self, data): + super(Person, self).__init__(data) + + def is_adult(self): + return self.age >= 18 + + +def test_repository_exists(cosmos_db_repository): + assert cosmos_db_repository is not None + + +def test_create_should_succeed(cosmos_db_repository: CosmosDBRepository, tenant_id: str): + global existing_item + existing_item = dict(id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id) + + created_item = cosmos_db_repository.create(existing_item) + + assert created_item is not None + assert all(item in created_item.items() for item in existing_item.items()) + + +def test_create_should_fail_if_user_is_same(cosmos_db_repository: CosmosDBRepository): + try: + global existing_elemen + cosmos_db_repository.create(existing_item) + + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceExistsError + assert e.status_code == 409 + + +def test_create_with_diff_unique_data_but_same_tenant_should_succeed( + cosmos_db_repository: CosmosDBRepository): + global existing_item + new_data = existing_item.copy() + new_data.update({ + 'id': fake.uuid4(), + 'email': fake.safe_email(), + }) + + result = cosmos_db_repository.create(new_data) + assert result["id"] != existing_item["id"], 'It should be a new element' + + +def test_create_with_same_id_should_fail( + cosmos_db_repository: CosmosDBRepository): + try: + global existing_item + new_data = existing_item.copy() + new_data.update({ + 'email': fake.safe_email(), + }) + + cosmos_db_repository.create(new_data) + + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceExistsError + assert e.status_code == 409 + + +def test_create_with_diff_id_but_same_unique_field_should_fail( + cosmos_db_repository: CosmosDBRepository): + try: + global existing_item + new_data = existing_item.copy() + new_data.update({ + 'id': fake.uuid4() + }) + + cosmos_db_repository.create(new_data) + + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceExistsError + assert e.status_code == 409 + + +def test_create_with_no_partition_key_attrib_should_pass( + cosmos_db_repository: CosmosDBRepository): + global existing_item + new_data = existing_item.copy() + + new_data.update({ + 'tenant_id': None, + }) + + result = cosmos_db_repository.create(new_data) + assert result["tenant_id"] is None, "A None value in a partition key is valid" + + +def test_create_with_same_id_but_diff_partition_key_attrib_should_succeed( + cosmos_db_repository: CosmosDBRepository, + another_tenant_id: str): + global existing_item + new_data = existing_item.copy() + + new_data.update({ + 'tenant_id': another_tenant_id, + }) + + result = cosmos_db_repository.create(new_data) + assert result["id"] == existing_item["id"], "Should have allowed same id" + + +def test_create_with_mapper_should_provide_calculated_fields( + cosmos_db_repository: CosmosDBRepository, tenant_id): + new_item = dict(id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id) + + created_item: Person = cosmos_db_repository.create(new_item, mapper=Person) + + assert created_item is not None + assert all(item in created_item.__dict__.items() for item in new_item.items()) + assert type(created_item) is Person, "The result should be wrapped with a class" + assert created_item.is_adult() is (new_item["age"] >= 18) + + +def test_find_by_valid_id_should_succeed(cosmos_db_repository: CosmosDBRepository): + found_item = cosmos_db_repository.find(existing_item["id"], + existing_item['tenant_id']) + + assert all(item in found_item.items() for item in existing_item.items()) + + +def test_find_by_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository): + try: + cosmos_db_repository.find(fake.uuid4(), existing_item['tenant_id']) + + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_find_by_invalid_partition_key_value_should_fail(cosmos_db_repository: CosmosDBRepository): + try: + cosmos_db_repository.find(existing_item["id"], fake.uuid4()) + + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_find_by_valid_id_and_mapper_should_succeed(cosmos_db_repository: CosmosDBRepository): + found_item: Person = cosmos_db_repository.find(existing_item["id"], + existing_item['tenant_id'], + mapper=Person) + + assert all(item in found_item.__dict__.items() for item in existing_item.items()) + assert type(found_item) is Person, "The result should be wrapped with a class" + assert found_item.is_adult() is (existing_item["age"] >= 18) + + +@pytest.mark.parametrize( + 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] +) +def test_find_all_with_mapper(cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + mapper: Callable, + expected_type: Callable): + result = cosmos_db_repository.find_all(tenant_id, mapper=mapper) + + assert result is not None + assert len(result) > 0 + assert type(result[0]) is expected_type, "The result type is not the expected" + + +def test_find_all_should_return_items_from_specified_partition_key_value( + cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + another_tenant_id: str): + result_tenant_id = cosmos_db_repository.find_all(tenant_id) + + assert len(result_tenant_id) > 1 + assert all((i["tenant_id"] == tenant_id for i in result_tenant_id)) + + result_another_tenant_id = cosmos_db_repository.find_all(another_tenant_id) + + assert len(result_another_tenant_id) > 0 + assert all((i["tenant_id"] == another_tenant_id for i in result_another_tenant_id)) + + assert not any(item in result_another_tenant_id for item in result_tenant_id), \ + "There should be no interceptions" + + +def test_find_all_should_succeed_with_partition_key_value_with_no_items( + cosmos_db_repository: CosmosDBRepository): + no_items = cosmos_db_repository.find_all(fake.uuid4()) + + assert no_items is not None + assert len(no_items) == 0, "No items are expected" + + +def test_find_all_with_max_count(cosmos_db_repository: CosmosDBRepository, + tenant_id: str): + all_items = cosmos_db_repository.find_all(tenant_id) + + assert len(all_items) > 2 + + first_two_items = cosmos_db_repository.find_all(tenant_id, max_count=2) + assert len(first_two_items) == 2, "The result should be limited to 2" + + +def test_find_all_with_offset(cosmos_db_repository: CosmosDBRepository, + tenant_id: str): + result_all_items = cosmos_db_repository.find_all(tenant_id) + + assert len(result_all_items) >= 3 + + result_after_the_first_item = cosmos_db_repository.find_all(tenant_id, offset=1) + + assert result_after_the_first_item == result_all_items[1:] + + result_after_the_second_item = cosmos_db_repository.find_all(tenant_id, offset=2) + + assert result_after_the_second_item == result_all_items[2:] + + +@pytest.mark.parametrize( + 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] +) +def test_partial_update_with_mapper(cosmos_db_repository: CosmosDBRepository, + mapper: Callable, + expected_type: Callable): + changes = { + 'name': fake.name(), + 'email': fake.safe_email(), + } + + updated_item = cosmos_db_repository.partial_update(existing_item['id'], + changes, + existing_item['tenant_id'], + mapper=mapper) + + assert updated_item is not None + assert type(updated_item) is expected_type + + +def test_partial_update_with_new_partition_key_value_should_fail( + cosmos_db_repository: CosmosDBRepository, + another_tenant_id: str, + sample_item: dict): + changes = { + 'name': fake.name(), + 'email': fake.safe_email(), + } + + try: + cosmos_db_repository.partial_update(sample_item['id'], changes, another_tenant_id) + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_partial_update_with_invalid_id_should_fail( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + changes = { + 'name': fake.name(), + 'email': fake.safe_email(), + } + + try: + cosmos_db_repository.partial_update(fake.uuid4(), changes, sample_item['tenant_id']) + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_partial_update_should_only_update_fields_in_changes( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + changes = { + 'name': fake.name(), + 'email': fake.safe_email(), + } + + updated_item = cosmos_db_repository.partial_update( + sample_item['id'], + changes, sample_item['tenant_id']) + + assert updated_item is not None + assert updated_item['name'] == changes["name"] != sample_item["name"] + assert updated_item['email'] == changes["email"] != sample_item["email"] + assert updated_item['id'] == sample_item["id"] + assert updated_item['tenant_id'] == sample_item["tenant_id"] + assert updated_item['age'] == sample_item["age"] + + +@pytest.mark.parametrize( + 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] +) +def test_update_with_mapper(cosmos_db_repository: CosmosDBRepository, + mapper: Callable, + expected_type: Callable): + changed_item = existing_item.copy() + changed_item.update({ + 'name': fake.name(), + 'email': fake.safe_email(), + }) + + updated_item = cosmos_db_repository.update(existing_item['id'], + changed_item, + mapper=mapper) + + assert updated_item is not None + assert type(updated_item) is expected_type + + +def test_update_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository): + changes = { + 'name': fake.name(), + 'email': fake.safe_email(), + } + + try: + cosmos_db_repository.update(fake.uuid4(), changes) + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_update_with_partial_changes_without_required_fields_it_should_fail( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + changes = { + 'id': sample_item['id'], + 'email': fake.safe_email(), + 'tenant_id': fake.uuid4(), + } + + try: + cosmos_db_repository.update(sample_item['id'], changes) + fail('It should have failed') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_update_with_partial_changes_with_required_fields_should_delete_the_missing_ones( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + changes = { + 'id': fake.uuid4(), + 'email': fake.safe_email(), + 'tenant_id': sample_item['tenant_id'], + } + + updated_item = cosmos_db_repository.update(sample_item['id'], changes) + + assert updated_item is not None + assert updated_item['id'] == changes["id"] != sample_item["id"] + assert updated_item['email'] == changes["email"] != sample_item["email"] + assert updated_item['tenant_id'] == changes["tenant_id"] + assert updated_item.get('name') is None + assert updated_item.get('age') is None + + try: + cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id']) + fail('The previous version should not exist') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_delete_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, + tenant_id: str): + try: + cosmos_db_repository.delete(fake.uuid4(), tenant_id) + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +@pytest.mark.parametrize( + 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] +) +def test_delete_with_mapper(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + mapper: Callable, + expected_type: Callable): + deleted_item = cosmos_db_repository.delete(sample_item['id'], + sample_item['tenant_id'], + mapper=mapper) + + assert deleted_item is not None + assert type(deleted_item) is expected_type + + try: + cosmos_db_repository.find(sample_item['id'], + sample_item['tenant_id'], + mapper=mapper) + fail('It should have not found the deleted item') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_find_can_find_deleted_item_only_if_visibile_only_is_true(cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) + + assert deleted_item is not None + assert deleted_item['deleted'] is not None + + try: + cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id']) + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + found_deleted_item = cosmos_db_repository.find(sample_item['id'], + sample_item['tenant_id'], + visible_only=False) + assert found_deleted_item is not None + + +def test_find_all_can_find_deleted_items_only_if_visibile_only_is_true( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) + assert deleted_item is not None + assert deleted_item['deleted'] is not None + + visible_items = cosmos_db_repository.find_all(sample_item['tenant_id']) + + assert visible_items is not None + assert any(item['id'] == sample_item['id'] for item in visible_items) == False, \ + 'The deleted item should not be visible' + + all_items = cosmos_db_repository.find_all(sample_item['tenant_id'], visible_only=False) + + assert all_items is not None + assert any(item['id'] == sample_item['id'] for item in all_items), \ + 'Deleted item should be visible' + + +def test_delete_should_not_find_element_that_is_already_deleted( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) + + assert deleted_item is not None + + try: + cosmos_db_repository.delete(deleted_item['id'], deleted_item['tenant_id']) + fail('It should have not found the deleted item') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 + + +def test_partial_update_should_not_find_element_that_is_already_deleted( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict): + deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) + + assert deleted_item is not None + + try: + changes = { + 'name': fake.name(), + 'email': fake.safe_email(), + } + cosmos_db_repository.partial_update(deleted_item['id'], + changes, + deleted_item['tenant_id']) + + fail('It should have not found the deleted item') + except Exception as e: + assert type(e) is CosmosResourceNotFoundError + assert e.status_code == 404 diff --git a/tests/commons/data_access_layer/azure/sql_repository_test.py b/tests/commons/data_access_layer/sql_test.py similarity index 100% rename from tests/commons/data_access_layer/azure/sql_repository_test.py rename to tests/commons/data_access_layer/sql_test.py diff --git a/tests/conftest.py b/tests/conftest.py index 1e481ced..78ec7584 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,14 @@ import pytest +from faker import Faker from flask import Flask from flask.testing import FlaskClient +from commons.data_access_layer.cosmos_db import CosmosDBRepository from time_tracker_api import create_app +fake = Faker() +Faker.seed() + @pytest.fixture(scope='session') def app() -> Flask: @@ -17,16 +22,90 @@ def client(app: Flask) -> FlaskClient: @pytest.fixture(scope="module") -def sql_repository(app: Flask): - with app.test_client(): - from tests.commons.data_access_layer.azure.resources import PersonSQLModel - from commons.data_access_layer.azure.sql_repository import db +def sql_model_class(): + from commons.data_access_layer.sql import db, AuditedSQLModel + class PersonSQLModel(db.Model, AuditedSQLModel): + __tablename__ = 'test' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + age = db.Column(db.Integer, nullable=False) + + def __repr__(self): + return '' % self.name + + return PersonSQLModel + + +@pytest.fixture(scope="module") +def sql_repository(app: Flask, sql_model_class): + with app.app_context(): + from commons.data_access_layer.sql import init_app, db + + if db is None: + init_app(app) + from commons.data_access_layer.sql import db + + db.metadata.create_all(bind=db.engine, tables=[sql_model_class.__table__]) + app.logger.info("SQl test models created!") + + from commons.data_access_layer.sql import SQLRepository + yield SQLRepository(sql_model_class) + + db.metadata.drop_all(bind=db.engine, tables=[sql_model_class.__table__]) + app.logger.info("SQL test models removed!") + + +@pytest.fixture(scope="module") +def cosmos_db_model(): + from azure.cosmos import PartitionKey + return { + 'id': 'tests', + 'partition_key': PartitionKey(path='/tenant_id'), + 'unique_key_policy': { + 'uniqueKeys': [ + {'paths': ['/email']}, + ] + } + } + + +@pytest.yield_fixture(scope="module") +def cosmos_db_repository(app: Flask, cosmos_db_model) -> CosmosDBRepository: + with app.app_context(): + from commons.data_access_layer.cosmos_db import init_app, cosmos_helper + + if cosmos_helper is None: + init_app(app) + from commons.data_access_layer.cosmos_db import cosmos_helper + + app.logger.info("Creating Cosmos DB test models...") + cosmos_helper.create_container(cosmos_db_model) + app.logger.info("Cosmos DB test models created!") + + yield CosmosDBRepository.from_definition(cosmos_db_model) + + app.logger.info("Removing Cosmos DB test models...") + cosmos_helper.delete_container(cosmos_db_model["id"]) + app.logger.info("Cosmos DB test models removed!") + + +@pytest.fixture(scope="session") +def tenant_id() -> str: + return fake.uuid4() + + +@pytest.fixture(scope="session") +def another_tenant_id() -> str: + return fake.uuid4() - db.metadata.create_all(bind=db.engine, tables=[PersonSQLModel.__table__]) - print("Test models created!") - from commons.data_access_layer.azure.sql_repository import SQLRepository - yield SQLRepository(PersonSQLModel) +@pytest.fixture(scope="function") +def sample_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dict: + sample_item_data = dict(id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id) - db.metadata.drop_all(bind=db.engine, tables=[PersonSQLModel.__table__]) - print("Test models removed!") + return cosmos_db_repository.create(sample_item_data) diff --git a/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index fb1045d3..735a884b 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -31,14 +31,11 @@ def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, def test_create_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): from time_tracker_api.activities.activities_namespace import activity_dao - invalid_activity_data = valid_activity_data.copy().update({ - "invalid_field": 123, - }) repository_create_mock = mocker.patch.object(activity_dao.repository, 'create', return_value=fake_activity) - response = client.post("/activities", json=invalid_activity_data, follow_redirects=True) + response = client.post("/activities", json=None, follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() @@ -123,15 +120,12 @@ def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, moc def test_update_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): from time_tracker_api.activities.activities_namespace import activity_dao - invalid_activity_data = valid_activity_data.copy().update({ - "invalid_field": 123, - }) repository_update_mock = mocker.patch.object(activity_dao.repository, 'update', return_value=fake_activity) valid_id = fake.random_int(1, 9999) - response = client.put("/activities/%s" % valid_id, json=invalid_activity_data, follow_redirects=True) + response = client.put("/activities/%s" % valid_id, json=None, follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() diff --git a/tests/time_tracker_api/projects/projects_namespace_test.py b/tests/time_tracker_api/projects/projects_namespace_test.py index 2e077640..5d5459a3 100644 --- a/tests/time_tracker_api/projects/projects_namespace_test.py +++ b/tests/time_tracker_api/projects/projects_namespace_test.py @@ -35,8 +35,9 @@ def test_create_project_should_succeed_with_valid_request(client: FlaskClient, m def test_create_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', + invalid_project_data = valid_project_data.copy() + invalid_project_data.update({ + "project_type_id": fake.pyint(min_value=1, max_value=100), }) repository_create_mock = mocker.patch.object(project_dao.repository, 'create', @@ -125,8 +126,9 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mock 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', + invalid_project_data = valid_project_data.copy() + invalid_project_data.update({ + "project_type_id": fake.pyint(min_value=1, max_value=100), }) repository_update_mock = mocker.patch.object(project_dao.repository, 'update', diff --git a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py index 09be8af9..e45e4510 100644 --- a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py @@ -35,7 +35,8 @@ def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient def test_create_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - invalid_time_entry_input = valid_time_entry_input.copy().update({ + invalid_time_entry_input = valid_time_entry_input.copy() + invalid_time_entry_input.update({ "project_id": None, }) repository_create_mock = mocker.patch.object(time_entries_dao.repository, @@ -110,8 +111,9 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, m def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - invalid_time_entry_data = valid_time_entry_input.copy().update({ - "project_id": 'anything', + invalid_time_entry_data = valid_time_entry_input.copy() + invalid_time_entry_data.update({ + "project_id": fake.pyint(min_value=1, max_value=100), }) repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'update', diff --git a/time_tracker_api/activities/activities_model.py b/time_tracker_api/activities/activities_model.py index bfbb6c57..60e97541 100644 --- a/time_tracker_api/activities/activities_model.py +++ b/time_tracker_api/activities/activities_model.py @@ -8,8 +8,8 @@ class ActivityDao(CRUDDao): def create_dao() -> ActivityDao: from sqlalchemy_utils import UUIDType import uuid - from commons.data_access_layer.azure.sql_repository import db - from commons.data_access_layer.azure.sql_repository import SQLCRUDDao + from commons.data_access_layer.sql import db + from commons.data_access_layer.sql import SQLCRUDDao class ActivitySQLModel(db.Model): __tablename__ = 'activity' diff --git a/time_tracker_api/config.py b/time_tracker_api/config.py index ebdddb81..4b3b90c3 100644 --- a/time_tracker_api/config.py +++ b/time_tracker_api/config.py @@ -27,12 +27,19 @@ class SQLConfig(Config): SQLALCHEMY_DATABASE_URI = DATABASE_URI -class TestConfig(SQLConfig): +class CosmosDB(Config): + DATABASE_URI = os.environ.get('DATABASE_URI') + DATABASE_ACCOUNT_URI = os.environ.get('DATABASE_ACCOUNT_URI') + DATABASE_MASTER_KEY = os.environ.get('DATABASE_MASTER_KEY') + DATABASE_NAME = os.environ.get('DATABASE_NAME') + + +class TestConfig(CosmosDB, SQLConfig): TESTING = True FLASK_DEBUG = True TEST_TABLE = 'tests' - DATABASE_URI = os.environ.get('DATABASE_URI', 'sqlite:///:memory:') - SQLALCHEMY_DATABASE_URI = DATABASE_URI + DATABASE_URI = os.environ.get('DATABASE_URI') + SQLALCHEMY_DATABASE_URI = DATABASE_URI or 'sqlite:///:memory:' class ProductionConfig(Config): @@ -41,9 +48,8 @@ class ProductionConfig(Config): FLASK_ENV = 'production' -class AzureConfig(SQLConfig): - DATABASE_URI = os.environ.get('SQLAZURECONNSTR_DATABASE_URI', SQLConfig.DATABASE_URI) - SQLALCHEMY_DATABASE_URI = DATABASE_URI +class AzureConfig(CosmosDB): + DATABASE_URI = os.environ.get('SQLAZURECONNSTR_DATABASE_URI', CosmosDB.DATABASE_URI) class AzureDevelopmentConfig(DevelopmentConfig, AzureConfig): diff --git a/time_tracker_api/customers/customers_model.py b/time_tracker_api/customers/customers_model.py index 9659ec5a..cc994bfe 100644 --- a/time_tracker_api/customers/customers_model.py +++ b/time_tracker_api/customers/customers_model.py @@ -6,9 +6,9 @@ class CustomerDao(CRUDDao): def create_dao() -> CustomerDao: - from time_tracker_api.sql_repository import db + from commons.data_access_layer.sql import db from time_tracker_api.database import COMMENTS_MAX_LENGTH - from time_tracker_api.sql_repository import SQLCRUDDao + from commons.data_access_layer.sql import SQLCRUDDao from sqlalchemy_utils import UUIDType import uuid diff --git a/time_tracker_api/database.py b/time_tracker_api/database.py index 094c894d..c9a21b54 100644 --- a/time_tracker_api/database.py +++ b/time_tracker_api/database.py @@ -52,7 +52,28 @@ def __call__(self, *args, **kwargs): def init_app(app: Flask) -> None: - from commons.data_access_layer.azure.sql_repository import init_app, SQLSeeder + init_sql(app) + + +def init_sql(app: Flask) -> None: + from commons.data_access_layer.sql import init_app, SQLSeeder init_app(app) global seeder seeder = SQLSeeder() + + +def init_cosmos_db(app: Flask) -> None: + # from commons.data_access_layer.azure.cosmos_db import cosmos_helper + class CosmosSeeder(Seeder): + def run(self): + print("Provisioning namespace(database)...") + # cosmos_helper.create_container() + print("Database seeded!") + + def fresh(self): + print("Removing namespace(database)...") + # cosmos_helper.remove_container() + self.run() + + global seeder + seeder = CosmosSeeder() diff --git a/time_tracker_api/project_types/project_types_model.py b/time_tracker_api/project_types/project_types_model.py index abab5477..589ce5ef 100644 --- a/time_tracker_api/project_types/project_types_model.py +++ b/time_tracker_api/project_types/project_types_model.py @@ -6,9 +6,9 @@ class ProjectTypeDao(CRUDDao): def create_dao() -> ProjectTypeDao: - from time_tracker_api.sql_repository import db + from commons.data_access_layer.sql import db from time_tracker_api.database import COMMENTS_MAX_LENGTH - from time_tracker_api.sql_repository import SQLCRUDDao + from commons.data_access_layer.sql import SQLCRUDDao from sqlalchemy_utils import UUIDType import uuid diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index 5da7ddcf..2c580ca6 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -17,12 +17,11 @@ class ProjectDao(CRUDDao): def create_dao() -> ProjectDao: - from commons.data_access_layer.azure.sql_repository import db + from commons.data_access_layer.sql import db from time_tracker_api.database import COMMENTS_MAX_LENGTH - from time_tracker_api.sql_repository import SQLCRUDDao from sqlalchemy_utils import UUIDType import uuid - from commons.data_access_layer.azure.sql_repository import SQLCRUDDao, AuditedSQLModel + from commons.data_access_layer.sql import SQLCRUDDao class ProjectSQLModel(db.Model): __tablename__ = 'project' diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 80a3aef4..7759ace8 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -8,11 +8,11 @@ class TimeEntriesDao(CRUDDao): def create_dao() -> TimeEntriesDao: - from commons.data_access_layer.azure.sql_repository import db + from commons.data_access_layer.sql import db from time_tracker_api.database import COMMENTS_MAX_LENGTH from sqlalchemy_utils import UUIDType import uuid - from commons.data_access_layer.azure.sql_repository import SQLCRUDDao + from commons.data_access_layer.sql import SQLCRUDDao class TimeEntrySQLModel(db.Model): __tablename__ = 'time_entry'