diff --git a/passerelle/contrib/mdph13/__init__.py b/passerelle/contrib/mdph13/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/contrib/mdph13/migrations/0001_initial.py b/passerelle/contrib/mdph13/migrations/0001_initial.py new file mode 100644 index 00000000..96b76c9d --- /dev/null +++ b/passerelle/contrib/mdph13/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.10 on 2019-02-15 09:57 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('base', '0010_loggingparameters_trace_emails'), + ] + + operations = [ + migrations.CreateModel( + name='Link', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name_id', models.CharField(max_length=256, verbose_name='NameID')), + ('file_number', models.CharField(max_length=64, verbose_name='MDPH beneficiary file number')), + ('secret', models.CharField(max_length=64, verbose_name='MDPH beneficiary secret')), + ('dob', models.DateField(verbose_name='MDPH beneficiary date of birth')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')), + ], + options={ + 'ordering': ['file_number'], + }, + ), + migrations.CreateModel( + name='MDPH13Resource', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=50, verbose_name='Title')), + ('description', models.TextField(verbose_name='Description')), + ('slug', models.SlugField(unique=True, verbose_name='Identifier')), + ('basic_auth_username', models.CharField(blank=True, max_length=128, verbose_name='Basic authentication username')), + ('basic_auth_password', models.CharField(blank=True, max_length=128, verbose_name='Basic authentication password')), + ('client_certificate', models.FileField(blank=True, null=True, upload_to=b'', verbose_name='TLS client certificate')), + ('trusted_certificate_authorities', models.FileField(blank=True, null=True, upload_to=b'', verbose_name='TLS trusted CAs')), + ('verify_cert', models.BooleanField(default=True, verbose_name='TLS verify certificates')), + ('http_proxy', models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy')), + ('webservice_base_url', models.URLField(verbose_name='Webservice Base URL')), + ('users', models.ManyToManyField(blank=True, to='base.ApiUser')), + ], + options={ + 'verbose_name': 'MDPH CD13', + }, + ), + migrations.AddField( + model_name='link', + name='resource', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mdph13.MDPH13Resource'), + ), + migrations.AlterUniqueTogether( + name='link', + unique_together=set([('resource', 'name_id', 'file_number')]), + ), + ] diff --git a/passerelle/contrib/mdph13/migrations/__init__.py b/passerelle/contrib/mdph13/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/contrib/mdph13/models.py b/passerelle/contrib/mdph13/models.py new file mode 100644 index 00000000..114828e6 --- /dev/null +++ b/passerelle/contrib/mdph13/models.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import base64 +import urlparse +import datetime +import re + +import requests + +from django.db import models, transaction +from django.utils import six +from django.utils.translation import ugettext_lazy as _ + +from passerelle.utils.jsonresponse import APIError, to_json +from passerelle.utils.api import endpoint +from passerelle.base.models import BaseResource, HTTPResource + + +class MDPH13Resource(BaseResource, HTTPResource): + category = _('Business Process Connectors') + + webservice_base_url = models.URLField(_('Webservice Base URL')) + + EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + + class Meta: + verbose_name = _('MDPH CD13') + + def situation_dossier_url(self, file_number): + return urlparse.urljoin(self.webservice_base_url, 'situation/dossier/%s' % file_number) + + def url_get(self, *args, **kwargs): + try: + response = None + response = self.requests.get(*args, **kwargs) + response.raise_for_status() + except requests.RequestException as e: + data = {'exception': six.text_type(e)} + if response is not None: + try: + if response.json(): + data['body'] = response.json() + except ValueError: + body = response.content[:1000] + if len(response.content) > 1000: + body += b'' + data['body'] = repr(body) + data['status_code'] = response.status_code + raise APIError('HTTP request failed', data=data) + try: + content = response.json() + except ValueError as e: + data = {'exception': six.text_type(e)} + data['body'] = '%r' % response.content[:1000] + raise APIError('HTTP request did not respond with JSON', data=data) + return content + + def call_situation_dossier(self, file_number, secret, dob, email=None): + url = self.situation_dossier_url(file_number) + email = email or 'appel-sans-utilisateur@cd13.fr' + error_mapping = { + 'dossier-inconnu': 'dossier-inconnu', + 'secret-invalide': 'secret-invalide', + 'dateNaissance-erronee': 'date-de-naissance-erronee', + } + try: + content = self.url_get( + url, + headers={ + 'X-CD13-Secret': base64.b64encode(secret.encode('utf-8')).decode('ascii'), + 'X-CD13-Email': email, + 'X-CD13-DateNaissBenef': dob.isoformat(), + }) + except APIError as e: + if e.data and e.data.get('status_code') == 404 and isinstance(e.data.get('body'), dict): + content = e.data['body'] + if content.get('err') != 0: + error_code = content.get('err_code') + err_desc = error_mapping.get(error_code, 'err != 0') + raise APIError(err_desc, data=e.data) + raise + + if content.get('err') != 0: + error_code = content.get('err_code') + err_desc = error_mapping.get(error_code, 'err != 0') + raise APIError(err_desc, data=content) + + data = content.get('data') + if not isinstance(data, dict): + raise APIError('data-must-be-a-dict', data=content) + + if str(data.get('numero')) != str(file_number): + raise APIError('numero-must-match-numero-dossier', date=content) + + # Reorganize entourage + beneficiaire = data.get('beneficiaire', {}) + entourage = beneficiaire.get('entourage') + if entourage is not None: + if not isinstance(entourage, list): + raise APIError('entourage-must-be-a-list', data=content) + if not all(isinstance(person, dict) for person in entourage): + raise APIError('demandes-content-must-be-dicts', data=content) + new_entourage = {} + for person in entourage: + if not isinstance(person, dict): + raise APIError('entourage-content-must-be-dicts', data=content) + if person.get('role') in [u'Père', u'Mère']: + new_entourage.setdefault('parents', []).append(person) + else: + new_entourage.setdefault('aidants', []).append(person) + beneficiaire['entourage'] = new_entourage + + # Reorganize demandes + demandes = data.get('demandes') + if demandes: + if not isinstance(demandes, list): + raise APIError('demandes-must-be-a-list', data=content) + if not all(isinstance(demande, dict) for demande in demandes): + raise APIError('demandes-content-must-be-dicts', data=content) + new_demandes = {} + typologies = { + u'demande en cours': 'en_cours', + u'traitée et expédiée': 'historique', + u'traitée non expédiée': 'historique', + } + if not all(isinstance(demande.get('typologie'), six.text_type) for demande in demandes): + raise APIError('typologie-must-be-a-string', data=content) + if not all(demande['typologie'].lower() in typologies for demande in demandes): + unknowns = set([demande['typologie'].lower() for demande in demandes]) - set(typologies.keys()) + raise APIError('typologie-is-unknown', + data={ + 'unknowns': list(unknowns), + 'choices': typologies.keys() + }) + for demande in demandes: + new_demandes.setdefault( + typologies[demande['typologie'].lower()], + [] + ).append(demande) + data['demandes'] = new_demandes + return data + + def check_status(self): + try: + link = Link.objects.latest('created') + except Link.DoesNotExist: + return + # no email passed, it's a background check + link.get_file() + + @endpoint(name='link', + methods=['post'], + description=_('Create link with an extranet account'), + perm='can_access', + parameters={ + 'NameID': { + 'description': _('Publik NameID'), + 'example_value': 'xyz24d934', + }, + 'numero_dossier': { + 'description': _('MDPH13 beneficiary file number'), + 'example_value': '1234', + }, + 'secret': { + 'description': _('MDPH13 beneficiary secret'), + 'example_value': 'secret', + }, + 'date_de_naissance': { + 'description': _('MDPH13 beneficiary date of birth'), + 'example_value': '1992-03-05', + }, + 'email': { + 'description': _('Publik known email'), + 'example_value': 'john.doe@example.com', + }, + }) + def link(self, request, NameID, numero_dossier, secret, date_de_naissance, email): + file_number = numero_dossier.strip() + try: + int(file_number) + except ValueError: + raise APIError('numero_dossier must be a number', http_status=400) + try: + dob = datetime.datetime.strptime(date_de_naissance.strip(), '%Y-%m-%d').date() + except ValueError: + raise APIError('date_de_naissance must be a date YYYY-MM-DD', http_status=400) + email = email.strip() + if not self.EMAIL_RE.match(email): + raise APIError('email is not valid', http_status=400) + with transaction.atomic(): + link, created = Link.objects.get_or_create( + resource=self, + name_id=NameID, + file_number=file_number, + defaults={ + 'secret': secret, + 'dob': dob + }) + updated = False + if not created: + if link.secret != secret or link.dob != dob: + link.secret = secret + link.dob = dob + updated = True + # email is necessary for audit purpose + link.get_file(email=email) + if updated: + link.save() + return {'link_id': link.pk, 'created': created, 'updated': updated} + + @endpoint(name='unlink', + methods=['post', 'delete'], + description=_('Delete link with an extranet account'), + perm='can_access', + parameters={ + 'NameID': { + 'description': _('Publik NameID'), + 'example_value': 'xyz24d934', + }, + 'link_id': { + 'description': _('Identifier of the link'), + 'example_value': '1', + }, + }) + def unlink(self, request, NameID, link_id): + qs = Link.objects.filter(resource=self, name_id=NameID) + if link_id == 'all': + pass # unlink of all links + else: + try: + link_id = int(link_id.strip()) + except ValueError: + raise APIError('link_id-must-be-a-number') + qs = qs.filter(pk=link_id) + count = qs.count() + qs.delete() + return {'deleted': count} + + @endpoint(name='dossiers', + description=_('Get datas for all links, or for a specified one'), + perm='can_access', + parameters={ + 'NameID': { + 'description': _('Publik NameID'), + 'example_value': 'xyz24d934', + }, + 'email': { + 'description': _('Publik known email'), + 'example_value': 'john.doe@example.com', + }, + 'link_id': { + 'description': _('Link identifier'), + 'example_value': '1', + } + }) + def dossiers(self, request, NameID, email, link_id=None): + email = email.strip() + if not self.EMAIL_RE.match(email): + raise APIError('email is not valid', http_status=400) + + qs = Link.objects.filter( + resource=self, + name_id=NameID) + if link_id: + try: + link_id = int(link_id) + except ValueError: + raise APIError('invalid-link-id', http_status=400) + qs = qs.filter(id=link_id) + data = [] + for link in qs: + file_data = { + 'id': str(link.id), + 'err': 0, + } + try: + mdph_file = link.get_file(email=email) + except Exception as e: + if link_id: + raise + file_data.update(to_json().err_to_response(e)) + else: + file_data.update({ + 'numero_dossier': link.file_number, + 'date_de_naissance': link.dob.isoformat(), + 'dossier': mdph_file, + }) + data.append(file_data) + if link_id: + return {'data': data[0] if data else None} + return {'data': data} + + +class Link(models.Model): + resource = models.ForeignKey( + MDPH13Resource, + on_delete=models.CASCADE) + name_id = models.CharField( + verbose_name=_('NameID'), + max_length=256) + file_number = models.CharField( + max_length=64, + verbose_name=_('MDPH beneficiary file number')) + secret = models.CharField( + verbose_name=_('MDPH beneficiary secret'), + max_length=64) + dob = models.DateField( + verbose_name=_('MDPH beneficiary date of birth')) + created = models.DateTimeField( + verbose_name=_('Creation date'), + auto_now_add=True) + + def get_file(self, email=None): + # email is necessary for audit purpose + return self.resource.call_situation_dossier( + file_number=self.file_number, + secret=self.secret, + dob=self.dob, + email=email) + + class Meta: + unique_together = ( + 'resource', 'name_id', 'file_number', + ) + ordering = ['file_number'] diff --git a/tests/settings.py b/tests/settings.py index f02f28a9..375daabd 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -27,6 +27,7 @@ INSTALLED_APPS += ( 'passerelle.contrib.iws', 'passerelle.contrib.maarch', 'passerelle.contrib.mdel', + 'passerelle.contrib.mdph13', 'passerelle.contrib.meyzieu_newsletters', 'passerelle.contrib.nancypoll', 'passerelle.contrib.planitech', diff --git a/tests/test_mdph13.py b/tests/test_mdph13.py new file mode 100644 index 00000000..0fc98b39 --- /dev/null +++ b/tests/test_mdph13.py @@ -0,0 +1,462 @@ +# -*- coding: utf-8 -*- +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2018 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import base64 +import datetime + +import httmock +import pytest + +import utils + +from passerelle.utils.jsonresponse import APIError +from passerelle.contrib.mdph13.models import MDPH13Resource, Link + +NAME_ID = 'xyz' +FILE_NUMBER = '1234' +DOB = datetime.date(1993, 5, 4) +DOB_ISOFORMAT = '1993-05-04' +EMAIL = 'john.doe@example.com' +SECRET = 'secret' + +VALID_RESPONSE = json.dumps({ + 'err': 0, + "data": { + "numero": FILE_NUMBER, + "beneficiaire": { + "nom": "MARTINI", + "prenom": "ALFONSO", + "tel_mobile": "06 01 02 03 04", + "tel_fixe": "04.01.02.03.04", + "date_de_naissance": "1951-03-23", + "email": "martini.a@free.fr", + "entourage": [ + { + "role": "Père", + "nom": "DUPONT Henri", + "tel_mobile": "0123232323", + "tel_fixe": "0202020202", + "email": "henri.dupont@xyz.com" + }, + { + "role": "Mère", + "nom": "DUPONT Marie", + "tel_mobile": "0123232323", + "tel_fixe": "0202020202", + "email": "marie.dupont@xyz.com" + }, + { + "role": "Aidant", + "nom": "ROBERT Fanny", + "tel_mobile": "0123232323", + "tel_fixe": "0202020202", + "email": "frobert@xyz.com" + } + ], + "adresse": { + "adresse_2": "Bliblibli", + "adresse_3": "Bliblibli", + "adresse_4": "CHEMIN DE LA CARRAIRE", + "adresse_5": "Bliblibli", + "code_postal": "13500", + "ville": "MARTIGUES" + }, + "incapacite": { + "taux": "Taux >=80%", + "date_fin_effet": "2019-06-30" + } + }, + "demandes": [ + { + "numero": "1544740", + "date_demande": "2015-11-26", + "type_demande": "Renouvellement", + "prestation": "Carte d'invalidité (de priorité) pour personne handicapée", + "statut": "Instruction administrative terminée en attente de passage en évaluation", + "typologie": "Demande En Cours", + "date_decision": "2015-01-16" + }, + { + "numero": "1210524", + "date_demande": "2014-06-13", + "type_demande": "Renouvellement", + "prestation": "Carte d'invalidité (de priorité) pour personne handicapée", + "statut": "Décision prononcée et expédition réalisée (traitement terminé)", + "typologie": "Traitée non expédiée", + "date_decision": "2014-07-10", + "date_debut_effet": "2014-08-01", + "date_fin_effet": "2016-05-01" + }, + { + "numero": "1231345", + "date_demande": "2014-07-22", + "type_demande": "Recours Gracieux", + "prestation": "Carte d'invalidité (de priorité) pour personne handicapée", + "statut": "Décision prononcée et expédition réalisée (traitement terminé)", + "typologie": "Traitée et expédiée", + "date_decision": "2014-09-17", + "date_debut_effet": "2014-08-01", + "date_fin_effet": "2016-05-01" + }, + { + "numero": "666660", + "date_demande": "2012-08-13", + "type_demande": "Recours Gracieux", + "prestation": "Carte d'invalidité (de priorité) pour personne handicapée", + "statut": "Décision prononcée et expédition réalisée (traitement terminé)", + "typologie": "Traitée et expédiée", + "date_decision": "2012-09-26", + "date_debut_effet": "2012-07-19", + "date_fin_effet": "2014-08-01" + }, + { + "numero": "605280", + "date_demande": "2012-04-05", + "type_demande": "1ère demande", + "prestation": "Carte d'invalidité (de priorité) pour personne handicapée", + "statut": "Décision prononcée et expédition réalisée (traitement terminé)", + "typologie": "Traitée et expédiée", + "date_decision": "2012-07-19", + "date_debut_effet": "2012-07-19", + "date_fin_effet": "2014-05-01" + }, + { + "numero": "1544741", + "date_demande": "2015-11-26", + "type_demande": "Renouvellement", + "prestation": "Carte d'invalidité (de priorité) pour personne handicapée", + "statut": "Décision prononcée et expédition réalisée (traitement terminé)", + "typologie": "Traitée et expédiée", + "date_decision": "2015-12-22", + "date_debut_effet": "2016-05-01", + "date_fin_effet": "2026-05-01" + }, + { + "numero": "1210526", + "date_demande": "2014-06-13", + "type_demande": "Renouvellement", + "prestation": "Carte européenne de Stationnement", + "statut": "Décision prononcée et expédition réalisée (traitement terminé)", + "typologie": "Traitée et expédiée", + "date_decision": "2014-07-04", + "date_debut_effet": "2014-05-01", + "date_fin_effet": "2015-05-01" + }, + { + "numero": "605281", + "date_demande": "2012-04-05", + "type_demande": "1ère demande", + "prestation": "Carte européenne de Stationnement", + "statut": "Décision prononcée et expédition réalisée (traitement terminé)", + "typologie": "Traitée et expédiée", + "date_decision": "2012-07-04", + "date_debut_effet": "2012-05-01", + "date_fin_effet": "2014-05-01" + } + ] + } +}) + +DOSSIER_INCONNU = { + 'status_code': 404, + 'content': json.dumps({ + 'err': 1, + 'err_code': 'dossier-inconnu', + }), +} + +SECRET_INVALIDE = { + 'status_code': 404, + 'content': json.dumps({ + 'err': 1, + 'err_code': 'secret-invalide', + }), +} + + +@pytest.fixture +def mdph13(db): + return utils.make_resource( + MDPH13Resource, + title='Test 1', + slug='test1', + description='Connecteur de test', + webservice_base_url='http://cd13.fr/') + + +@pytest.fixture +def mock_http(): + class MockHttp(object): + def __init__(self): + self.requests = [] + self.responses = [] + + def add_response(self, response): + self.responses.append(response) + + @property + def last_request(self): + return self.requests[-1] + + def request_handler(self, url, request): + idx = len(self.requests) + self.requests.append(request) + return self.responses[idx] + + mock_http = MockHttp() + with httmock.HTTMock(httmock.urlmatch()(mock_http.request_handler)): + yield mock_http + + +def test_situation_dossier_url(mdph13): + assert mdph13.situation_dossier_url(1234) == 'http://cd13.fr/situation/dossier/1234' + + +def test_call_situation_dossier(mdph13, mock_http): + mock_http.add_response(VALID_RESPONSE) + mdph13.call_situation_dossier(1234, SECRET, DOB) + request = mock_http.last_request + headers = request.headers + url = request.url + assert url == 'http://cd13.fr/situation/dossier/1234' + assert headers['X-CD13-Secret'] == base64.b64encode(SECRET.encode('utf-8')).decode('ascii') + assert headers['X-CD13-DateNaissBenef'] == '1993-05-04' + assert headers['X-CD13-Email'] == 'appel-sans-utilisateur@cd13.fr' + + +def test_call_situation_dossier_with_email(mdph13, mock_http): + mock_http.add_response(VALID_RESPONSE) + mdph13.call_situation_dossier(1234, SECRET, DOB, email=EMAIL) + request = mock_http.last_request + headers = request.headers + url = request.url + assert url == 'http://cd13.fr/situation/dossier/1234' + assert headers['X-CD13-Secret'] == base64.b64encode(SECRET.encode('utf-8')).decode('ascii') + assert headers['X-CD13-DateNaissBenef'] == DOB_ISOFORMAT + assert headers['X-CD13-Email'] == EMAIL + + +def test_check_status_no_link(mdph13): + assert Link.objects.count() == 0 + try: + mdph13.check_status() + except Exception as e: + pytest.fail('check_status() should not raise') + + +def test_check_status_with_link_nok(mdph13, mock_http): + mock_http.add_response({'status_code': 500}) + Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER, + secret=SECRET, + dob=DOB) + assert Link.objects.count() == 1 + with pytest.raises(Exception): + mdph13.check_status() + + +def test_check_status_with_link_ok(mdph13, mock_http): + mock_http.add_response(VALID_RESPONSE) + Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER, + secret=SECRET, + dob=DOB) + assert Link.objects.count() == 1 + try: + mdph13.check_status() + except Exception: + pytest.fail('check_status() should not raise') + + +def test_link_bad_file_number(mdph13): + with pytest.raises(APIError) as e: + mdph13.link(request=None, NameID=NAME_ID, numero_dossier='x', secret=None, + date_de_naissance=None, email=None) + assert str(e.value) == 'numero_dossier must be a number' + + +def test_link_bad_date_de_naissance(mdph13): + with pytest.raises(APIError) as e: + mdph13.link(request=None, NameID=NAME_ID, numero_dossier=FILE_NUMBER, secret=None, + date_de_naissance='34-45-6', email=None) + assert str(e.value) == 'date_de_naissance must be a date YYYY-MM-DD' + + +def test_link_bad_email(mdph13): + with pytest.raises(APIError) as e: + mdph13.link(request=None, NameID=NAME_ID, numero_dossier=FILE_NUMBER, secret=None, + date_de_naissance=DOB_ISOFORMAT, email='xxx@@vvv') + assert str(e.value) == 'email is not valid' + + +def test_link_nok_dossier_inconnu(mdph13, mock_http): + mock_http.add_response(DOSSIER_INCONNU) + with pytest.raises(APIError) as e: + mdph13.link(request=None, NameID=NAME_ID, numero_dossier=FILE_NUMBER, secret=SECRET, + date_de_naissance=DOB_ISOFORMAT, email=EMAIL) + assert str(e.value) == 'dossier-inconnu' + + +def test_link_nok_secret_invalide(mdph13, mock_http): + mock_http.add_response(SECRET_INVALIDE) + with pytest.raises(APIError) as e: + mdph13.link(request=None, NameID=NAME_ID, numero_dossier=FILE_NUMBER, secret=SECRET, + date_de_naissance=DOB_ISOFORMAT, email=EMAIL) + assert str(e.value) == 'secret-invalide' + + +def test_link_numero_dont_match(mdph13, mock_http): + mock_http.add_response(json.dumps({'err': 0, 'data': {'numero': '456'}})) + with pytest.raises(APIError) as e: + mdph13.link(request=None, NameID=NAME_ID, numero_dossier=FILE_NUMBER, secret=SECRET, + date_de_naissance=DOB_ISOFORMAT, email=EMAIL) + assert str(e.value) == 'numero-must-match-numero-dossier' + + +def test_link_ok(mdph13, mock_http): + # check first time link + mock_http.add_response(VALID_RESPONSE) + assert not Link.objects.count() + response = mdph13.link(request=None, NameID=NAME_ID, + numero_dossier=FILE_NUMBER, secret=SECRET, + date_de_naissance=DOB_ISOFORMAT, email=EMAIL) + link = Link.objects.get() + assert response == { + 'link_id': link.pk, + 'created': True, + 'updated': False + } + # check relinking with update + mock_http.add_response(VALID_RESPONSE) + response = mdph13.link(request=None, NameID=NAME_ID, + numero_dossier=FILE_NUMBER, secret=SECRET+'a', + date_de_naissance=DOB_ISOFORMAT, email=EMAIL) + assert response == { + 'link_id': link.pk, + 'created': False, + 'updated': True, + } + + +def test_unlink_nok_bad_link_id(mdph13): + with pytest.raises(APIError) as e: + mdph13.unlink(None, None, 'e') + assert str(e.value) == 'link_id-must-be-a-number' + + +def test_unlink_ok(mdph13): + link = Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER, + secret=SECRET, + dob=DOB) + result = mdph13.unlink(None, NAME_ID, str(link.pk)) + assert result['deleted'] == 1 + result = mdph13.unlink(None, NAME_ID, str(link.pk)) + assert result['deleted'] == 0 + + +def test_unlink_all_ok(mdph13): + Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER, + secret=SECRET, + dob=DOB) + Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number='12345', + secret=SECRET, + dob=DOB) + result = mdph13.unlink(None, NAME_ID, 'all') + assert result['deleted'] == 2 + result = mdph13.unlink(None, NAME_ID, 'all') + assert result['deleted'] == 0 + + +def test_dossier_ok(mdph13, mock_http): + link = Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER, + secret=SECRET, + dob=DOB) + mock_http.add_response(VALID_RESPONSE) + response = mdph13.dossiers(None, NAME_ID, EMAIL) + assert response['data'] + assert response['data'][0]['id'] == str(link.pk) + assert response['data'][0]['numero_dossier'] == FILE_NUMBER + assert response['data'][0]['date_de_naissance'] == DOB.isoformat() + assert response['data'][0]['dossier']['numero'] == FILE_NUMBER + assert len(response['data'][0]['dossier']['beneficiaire']['entourage']) == 2 + assert len(response['data'][0]['dossier']['beneficiaire']['entourage']['parents']) == 2 + assert len(response['data'][0]['dossier']['beneficiaire']['entourage']['aidants']) == 1 + assert len(response['data'][0]['dossier']['demandes']) == 2 + assert len(response['data'][0]['dossier']['demandes']['en_cours']) == 1 + assert len(response['data'][0]['dossier']['demandes']['historique']) == 7 + + +def test_dossier_with_link_id_ok(mdph13, mock_http): + link = Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER, + secret=SECRET, + dob=DOB) + mock_http.add_response(VALID_RESPONSE) + response = mdph13.dossiers(None, NAME_ID, EMAIL, link_id=str(link.pk)) + assert response['data'] + assert response['data']['id'] == str(link.pk) + assert response['data']['numero_dossier'] == FILE_NUMBER + assert response['data']['date_de_naissance'] == DOB.isoformat() + assert response['data']['dossier']['numero'] == FILE_NUMBER + assert len(response['data']['dossier']['beneficiaire']['entourage']) == 2 + assert len(response['data']['dossier']['beneficiaire']['entourage']['parents']) == 2 + assert len(response['data']['dossier']['beneficiaire']['entourage']['aidants']) == 1 + assert len(response['data']['dossier']['demandes']) == 2 + assert len(response['data']['dossier']['demandes']['en_cours']) == 1 + assert len(response['data']['dossier']['demandes']['historique']) == 7 + + +def test_dossier_partial_failure(mdph13, mock_http): + link1 = Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER, + secret=SECRET, + dob=DOB) + link2 = Link.objects.create( + resource=mdph13, + name_id=NAME_ID, + file_number=FILE_NUMBER + '2', + secret=SECRET, + dob=DOB) + mock_http.add_response(VALID_RESPONSE) + mock_http.add_response({'status_code': 500, 'content': ''}) + response = mdph13.dossiers(None, NAME_ID, EMAIL) + assert response['data'] + assert response['data'][0]['id'] == str(link1.pk) + assert response['data'][0]['err'] == 0 + assert response['data'][1]['id'] == str(link2.pk) + assert response['data'][1]['err'] == 1 diff --git a/tox.ini b/tox.ini index d795ed12..b7fb761f 100644 --- a/tox.ini +++ b/tox.ini @@ -29,6 +29,7 @@ deps = mohawk pytest-freezegun pytest-httpbin + pytest-localserver commands = django18: py.test {posargs: {env:FAST:} --junitxml=test_{envname}_results.xml --cov-report xml --cov-report html --cov=passerelle/ --cov-config .coveragerc tests/} django18: ./pylint.sh passerelle/