diff --git a/api/tests/test_article.py b/api/tests/test_article.py index 8d76f85..361f5c4 100644 --- a/api/tests/test_article.py +++ b/api/tests/test_article.py @@ -12,6 +12,7 @@ from webob.multidict import MultiDict import managers + from api.views.article import ( ArticleAPI, ArticleXML, @@ -19,7 +20,7 @@ ArticleManifest, ) from managers.models.article_model import ArticleDocument -from persistence.databases import DBFailed +from persistence.databases import DBFailed, UpdateFailure def _get_file_property(filename, content, size): @@ -429,6 +430,66 @@ def test_post_article_returns_article_version_url(mocked_post_article, assert response.json.get('url').endswith(xml_file.filename) +@patch.object(managers, 'delete_article') +def test_http_article_calls_delete_article_service_unavailable( + mocked_delete_article, + dummy_request): + mocked_delete_article.side_effect = DBFailed + dummy_request.DELETE = MultiDict( + [('id', 'ID')] + ) + article_api = ArticleAPI(dummy_request) + with pytest.raises(HTTPServiceUnavailable): + article_api.delete() + + +@patch.object(managers, 'delete_article') +def test_http_article_calls_delete_article_not_found( + mocked_delete_article, + dummy_request): + mocked_delete_article.side_effect = \ + managers.article_manager.ArticleManagerException( + message='Article ID not registered' + ) + dummy_request.DELETE = MultiDict( + [('id', 'ID')] + ) + article_api = ArticleAPI(dummy_request) + with pytest.raises(HTTPNotFound): + article_api.delete() + + +@patch.object(managers, 'delete_article') +def test_http_article_calls_delete_article_bad_request( + mocked_delete_article, + dummy_request): + error_msg = 'Article ID is not allowed to delete' + mocked_delete_article.side_effect = \ + UpdateFailure( + message=error_msg + ) + dummy_request.DELETE = MultiDict( + [('id', 'ID')] + ) + article_api = ArticleAPI(dummy_request) + with pytest.raises(HTTPBadRequest) as excinfo: + article_api.delete() + assert excinfo.value.message == error_msg + + +@patch.object(managers, 'delete_article') +def test_http_article_calls_delete_article_success( + mocked_delete_article, + dummy_request): + dummy_request.DELETE = MultiDict( + [('id', 'ID')] + ) + article_api = ArticleAPI(dummy_request) + response = article_api.delete() + assert response.status_code == 200 + assert response.json is not None + + @patch.object(managers, 'get_article_document') def test_http_get_article_manifest_db_failed( mocked_get_article_document, @@ -483,3 +544,4 @@ def test_http_get_article_manifest_succeeded( response = article_api.get() assert response.status == '200 OK' assert response.json == expected + diff --git a/api/views/article.py b/api/views/article.py index 6de6e05..37778a7 100644 --- a/api/views/article.py +++ b/api/views/article.py @@ -12,6 +12,7 @@ from prometheus_client import Summary import managers +import persistence REQUEST_TIME_API_ARTICLE_GET = Summary( @@ -105,6 +106,21 @@ def get(self): except managers.article_manager.ArticleManagerException as e: raise HTTPNotFound(detail=e.message) + def delete(self): + """Delete Article.""" + try: + article_data = managers.delete_article( + article_id=self.request.DELETE['id'], + **self.request.db_settings + ) + return Response(status_code=200, json=article_data) + except managers.article_manager.ArticleManagerException as e: + raise HTTPNotFound(detail=e.message) + except persistence.databases.UpdateFailure as e: + raise HTTPBadRequest(detail=e.message) + except persistence.databases.DBFailed as e: + raise HTTPServiceUnavailable() + @resource(path='/articles/{id}/_manifest', renderer='json') class ArticleManifest: diff --git a/managers/__init__.py b/managers/__init__.py index 1c22b96..f696145 100644 --- a/managers/__init__.py +++ b/managers/__init__.py @@ -93,6 +93,26 @@ def put_article(article_id, xml_file, assets_files=[], **db_settings): files=assets_files) +def delete_article(article_id, **db_settings): + """ + Marca o Documento de Artigo como "apagado" + + :param article_id: ID do Documento do tipo Artigo, para identificação + referencial + :param db_settings: dicionário com as configurações do banco de dados. + Deve conter: + - database_uri: URI do banco de dados (host:porta) + - database_username: usuário do banco de dados + - database_password: senha do banco de dados + + :returns: + - HTTPServiceUnavailable + """ + article_manager = _get_article_manager(**db_settings) + return article_manager.delete_article( + article_id=article_id) + + def get_article_data(article_id, **db_settings): """ Recupera metadados do Documento de Artigo, usados para controle de diff --git a/managers/article_manager.py b/managers/article_manager.py index 76e68bf..4a49ac7 100644 --- a/managers/article_manager.py +++ b/managers/article_manager.py @@ -33,6 +33,10 @@ def __init__(self, articles_db_manager, changes_services): self.article_db_service = DatabaseService( articles_db_manager, changes_services) + def delete_article(self, article_id): + document_record = self.article_db_service.read(article_id) + self.article_db_service.delete(article_id, document_record) + def receive_package(self, id, xml_file, files=None): article = self.receive_xml_file(id, xml_file) self.receive_asset_files(article, files) diff --git a/managers/models/article_model.py b/managers/models/article_model.py index 783360d..11ec47a 100644 --- a/managers/models/article_model.py +++ b/managers/models/article_model.py @@ -154,6 +154,8 @@ def _v0_to_v1(self, record): version['assets'] = assets versions = [version] _record['versions'] = versions + if record.get('deleted_date') or record.get('is_removed'): + _record['is_removed'] = 'True' return _record def set_data(self, data): diff --git a/managers/tests/test_article_manager.py b/managers/tests/test_article_manager.py index df1b8ff..3450706 100644 --- a/managers/tests/test_article_manager.py +++ b/managers/tests/test_article_manager.py @@ -5,10 +5,10 @@ from persistence.databases import ( DocumentNotFound, DBFailed, + UpdateFailure, ) from persistence.services import DatabaseService -from persistence.models import RecordType - +from persistence.models import get_record, RecordType from managers.models.article_model import ( ArticleDocument, ) @@ -226,3 +226,62 @@ def test_get_asset_files(databaseservice_params, test_package_A): assert len(msg) == 0 for asset in files: assert asset.content in asset_contents + + +@patch.object(DatabaseService, 'read') +def test_delete_article_db_failed( + mocked_dataservices_read, + setup, + databaseservice_params): + article_id = 'ID' + mocked_dataservices_read.side_effect = DBFailed + article_manager = ArticleManager( + databaseservice_params[0], + databaseservice_params[1] + ) + pytest.raises( + DBFailed, + article_manager.delete_article, + article_id) + + +@patch.object(DatabaseService, 'delete') +def test_delete_article_update_failure( + mocked_dataservices_delete, + setup, + databaseservice_params): + article_id = 'ID' + error_msg = 'Article ID not allowed to delete' + article_manager = ArticleManager( + databaseservice_params[0], + databaseservice_params[1] + ) + article_manager.article_db_service.register( + article_id, + get_record(article_id) + ) + + mocked_dataservices_delete.side_effect = \ + UpdateFailure(error_msg) + with pytest.raises(UpdateFailure) as excinfo: + article_manager.delete_article(article_id) + assert excinfo.value.message == error_msg + + +def test_delete_article_success( + setup, + databaseservice_params): + article_id = 'ID' + article_manager = ArticleManager( + databaseservice_params[0], + databaseservice_params[1] + ) + article_record = get_record(article_id) + article_manager.article_db_service.register( + article_id, + article_record + ) + assert article_manager.delete_article(article_id) is None + + deleted = article_manager.get_article_document(article_id) + assert deleted.manifest.get('is_removed') == 'True' diff --git a/persistence/databases.py b/persistence/databases.py index eff42e8..943f86e 100644 --- a/persistence/databases.py +++ b/persistence/databases.py @@ -32,6 +32,8 @@ class QueryOperator(Enum): class BaseDBManager(metaclass=abc.ABCMeta): _attachments_properties_key = 'attachments_properties' + rev_key = 'document_rev' + _rev_key = '_rev' @abc.abstractmethod def drop_database(self) -> None: @@ -104,6 +106,7 @@ def __init__(self, **kwargs): self._database_name = kwargs['database_name'] self._attachments_key = 'attachments' self._attachments_properties_key = 'attachments_properties' + self._rev_key = 'revision' self._database = {} @property @@ -117,23 +120,23 @@ def drop_database(self): self._database = {} def create(self, id, document): - document['revision'] = 1 + document[self._rev_key] = 1 self.database.update({id: document}) def read(self, id): doc = self.database.get(id) if not doc: raise DocumentNotFound - doc['document_rev'] = doc['revision'] + doc[self.rev_key] = doc[self._rev_key] return doc def update(self, id, document): _document = self.read(id) - if _document.get('revision') != document.get('document_rev'): + if _document.get(self._rev_key) != document.get(self.rev_key): raise UpdateFailure( - 'You are trying to update a record which data is out of date') + 'You are trying to update a record which is out of date') _document.update(document) - _document['revision'] += 1 + _document[self._rev_key] += 1 self.database.update({id: _document}) def delete(self, id): @@ -274,7 +277,7 @@ def create(self, id, document): def read(self, id): try: doc = dict(self.database[id]) - doc['document_rev'] = doc['_rev'] + doc[self.rev_key] = doc[self._rev_key] except couchdb.http.ResourceNotFound: raise DocumentNotFound return doc @@ -284,14 +287,24 @@ def update(self, id, document): Para atualizar documento no CouchDB, é necessário informar a revisão do documento atual. Por isso, é obtido o documento atual para que os dados dele sejam atualizados com o registro informado. + + Retorno. Uma das opções: + - DocumentNotFound + - Falha de atualização """ - doc = self.read(id) - if doc.get('_rev') != document.get('document_rev'): - raise UpdateFailure( - 'You are trying to update a record which data is out of date') + read = self.read(id) + + if self._rev_key not in document.keys(): + if self.rev_key in document.keys(): + document[self._rev_key] = document[self.rev_key] + read.update(document) + document = read - doc.update(document) - self.database[id] = doc + try: + self.database[id] = document + except: + raise UpdateFailure( + 'You are trying to update a record which is out of date') def delete(self, id): doc = self.read(id) diff --git a/persistence/services.py b/persistence/services.py index 1b5a5e5..abe9cd3 100644 --- a/persistence/services.py +++ b/persistence/services.py @@ -3,7 +3,7 @@ from prometheus_client import Summary -from .databases import QueryOperator +from .databases import QueryOperator, UpdateFailure REQUEST_TIME_CHANGES_UPD = Summary( @@ -133,8 +133,9 @@ def read(self, document_id): 'created_date': document['created_date'], 'document_rev': document['document_rev'], } - if document.get('updated_date'): - document_record['updated_date'] = document['updated_date'] + for optional in ['updated_date', 'is_removed']: + if optional in document.keys(): + document_record[optional] = document[optional] attachments = self.db_manager.list_attachments(document_id) if attachments: document_record['attachments'] = \ @@ -169,12 +170,20 @@ def delete(self, document_id, document_record): document_id: ID do documento a ser deletado document_record: registro de documento a ser deletado - Erro: + Erros: DocumentNotFound: documento não encontrado na base de dados. + UpdateFailure: documento não apagado da base de dados. """ - self.db_manager.delete(document_id) - self.changes_service.register_change( - document_record, ChangeType.DELETE) + document_record.update({ + 'is_removed': 'True', + }) + try: + self.db_manager.update(document_id, document_record) + self.changes_service.register_change( + document_record, ChangeType.DELETE) + except UpdateFailure: + raise UpdateFailure( + 'Document {} not allowed to delete'.format(document_id)) @REQUEST_TIME_DOC_FIND.time() def find(self, selector, fields, sort): diff --git a/persistence/tests/test_databases.py b/persistence/tests/test_databases.py index f3611c7..7109890 100644 --- a/persistence/tests/test_databases.py +++ b/persistence/tests/test_databases.py @@ -8,6 +8,7 @@ DocumentNotFound, sort_results, DBFailed, + UpdateFailure, CouchDBManager, ) from persistence.services import ( @@ -24,34 +25,13 @@ def get_article_record(content={'Test': 'Test'}): created_date=datetime.utcnow()) -def test_register_document(database_service): - article_record = get_article_record() - database_service.register( - article_record['document_id'], - article_record - ) - - check_list = database_service.find({}, [], []) - assert isinstance(check_list[0], dict) - article_check = check_list[0] - assert article_check['document_id'] == article_record['document_id'] - assert article_check['document_type'] == article_record['document_type'] - assert article_check['content'] == article_record['content'] - assert article_check['created_date'] is not None - - -@patch.object(ChangesService, 'register_change') -def test_register_document_register_change(mocked_register_change, - database_service): - article_record = get_article_record() - database_service.register( - article_record['document_id'], - article_record +def test_read_document_not_found(database_service): + pytest.raises( + DocumentNotFound, + database_service.db_manager.read, + '336abebdd31894idnaoexistente' ) - mocked_register_change.assert_called_with(article_record, - ChangeType.CREATE) - def test_read_document(database_service): article_record = get_article_record({'Test': 'Test2'}) @@ -60,7 +40,8 @@ def test_read_document(database_service): article_record ) - record_check = database_service.read(article_record['document_id']) + record_check = database_service.db_manager.read( + article_record['document_id']) assert record_check is not None assert record_check['document_id'] == article_record['document_id'] assert record_check['document_type'] == article_record['document_type'] @@ -77,13 +58,34 @@ def test_db_failed(article_db_settings): db_manager.database -def test_read_document_not_found(database_service): - pytest.raises( - DocumentNotFound, - database_service.read, - '336abebdd31894idnaoexistente' +def test_register_document(database_service): + article_record = get_article_record() + database_service.register( + article_record['document_id'], + article_record ) + check_list = database_service.find({}, [], []) + assert isinstance(check_list[0], dict) + article_check = check_list[0] + assert article_check['document_id'] == article_record['document_id'] + assert article_check['document_type'] == article_record['document_type'] + assert article_check['content'] == article_record['content'] + assert article_check['created_date'] is not None + + +@patch.object(ChangesService, 'register_change') +def test_register_document_register_change(mocked_register_change, + database_service): + article_record = get_article_record() + database_service.register( + article_record['document_id'], + article_record + ) + + mocked_register_change.assert_called_with(article_record, + ChangeType.CREATE) + def test_update_document(database_service): article_record = get_article_record({'Test': 'Test3'}) @@ -130,55 +132,10 @@ def test_update_document_register_change(mocked_register_change, def test_update_document_not_found(database_service): article_record = get_article_record({'Test': 'Test4'}) + database_service.db_manager.drop_database() pytest.raises( DocumentNotFound, - database_service.delete, - article_record['document_id'], - article_record - ) - - -def test_delete_document(database_service): - article_record = get_article_record({'Test': 'Test5'}) - database_service.register( - article_record['document_id'], - article_record - ) - - record_check = database_service.read(article_record['document_id']) - database_service.delete( - article_record['document_id'], - record_check - ) - pytest.raises(DocumentNotFound, - database_service.read, - article_record['document_id']) - - -@patch.object(ChangesService, 'register_change') -def test_delete_document_register_change(mocked_register_change, - database_service): - article_record = get_article_record({'Test': 'Test5'}) - database_service.register( - article_record['document_id'], - article_record - ) - - record_check = database_service.read(article_record['document_id']) - database_service.delete( - article_record['document_id'], - record_check - ) - - mocked_register_change.assert_called_with(record_check, - ChangeType.DELETE) - - -def test_delete_document_not_found(database_service): - article_record = get_article_record({'Test': 'Test6'}) - pytest.raises( - DocumentNotFound, - database_service.delete, + database_service.update, article_record['document_id'], article_record ) @@ -497,9 +454,32 @@ def test_find_documents_by_selected_field_returns_according_to_filter( def compare_documents(document, expected): if document == expected: assert document == expected - elif len(document) == len(expected) + 1: - # para ignorar "revision" + else: for k in expected.keys(): assert document[k] == expected[k] - else: - assert document == expected + + +def test_databases_update(database_service): + article_record = get_article_record({'teste': 'teste'}) + database_service.db_manager.create( + article_record['document_id'], + article_record + ) + read = database_service.db_manager.read( + article_record['document_id'] + ) + read1 = read.copy() + read2 = read.copy() + + read1.update({'text': 'read1'}) + read2.update({'text': 'read2'}) + + database_service.db_manager.update( + article_record['document_id'], read2) + + pytest.raises( + UpdateFailure, + database_service.db_manager.update, + article_record['document_id'], + read1 + ) diff --git a/persistence/tests/test_services.py b/persistence/tests/test_services.py index cdd2901..77d1572 100644 --- a/persistence/tests/test_services.py +++ b/persistence/tests/test_services.py @@ -3,6 +3,49 @@ from persistence.databases import QueryOperator from persistence.services import ChangeType, SortOrder +from unittest.mock import patch + +import pytest +from datetime import datetime +from uuid import uuid4 + +from persistence.models import get_record, RecordType +from persistence.databases import DocumentNotFound, UpdateFailure +from persistence.services import ( + ChangesService, +) + + +def get_article_record(content={'Test': 'Test'}): + document_id = uuid4().hex + return get_record(document_id=document_id, + document_type=RecordType.ARTICLE, + content=content, + created_date=datetime.utcnow()) + + +def test_read_document(database_service): + article_record = get_article_record({'Test': 'Test2'}) + database_service.register( + article_record['document_id'], + article_record + ) + + record_check = database_service.read(article_record['document_id']) + assert record_check is not None + assert record_check['document_id'] == article_record['document_id'] + assert record_check['document_type'] == article_record['document_type'] + assert record_check['content'] == article_record['content'] + assert record_check['created_date'] is not None + + +def test_read_document_not_found(database_service): + pytest.raises( + DocumentNotFound, + database_service.read, + '336abebdd31894idnaoexistente' + ) + def test_list_changes_calls_db_manager_find(inmemory_db_setup, test_changes_records, @@ -113,3 +156,55 @@ def test_list_changes_returns_db_manager_find_no_changes(inmemory_db_setup, check_list = inmemory_db_setup.list_changes(last_sequence=last_sequence, limit=limit) assert len(check_list) == 0 + + +def test_delete_document(database_service): + article_record = get_article_record({'Test': 'Test5'}) + database_service.register( + article_record['document_id'], + article_record + ) + + record_check = database_service.read(article_record['document_id']) + database_service.delete( + article_record['document_id'], + record_check + ) + deleted = database_service.read(article_record['document_id']) + assert deleted['is_removed'] == 'True' + + +@patch.object(ChangesService, 'register_change') +def test_delete_document_register_change(mocked_register_change, + database_service): + article_record = get_article_record({'Test': 'Test5'}) + database_service.register( + article_record['document_id'], + article_record + ) + + record_check = database_service.read(article_record['document_id']) + database_service.delete( + article_record['document_id'], + record_check + ) + + mocked_register_change.assert_called_with(record_check, + ChangeType.DELETE) + + +def test_delete_document_update_failure(database_service): + article_record = get_article_record({'Test': 'Test10'}) + database_service.register( + article_record['document_id'], + article_record + ) + read = database_service.read(article_record['document_id']) + updated = read.copy() + database_service.update( + article_record['document_id'], updated) + + error_msg = 'Document {} not allowed to delete'.format( + article_record['document_id']) + with pytest.raises(UpdateFailure, message=error_msg): + database_service.delete(article_record['document_id'], read)