From 782296770c82271cba8ca677dabfe566e4a9d006 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 4 May 2021 16:51:35 +0200
Subject: [PATCH 2/2] caluire-axel: link endpoint (#53704)

---
 functests/caluire_axel/conftest.py            |  24 ++
 functests/caluire_axel/test_caluire_axel.py   |  21 ++
 passerelle/contrib/caluire_axel/__init__.py   |   0
 .../caluire_axel/migrations/0001_initial.py   |  65 ++++
 .../caluire_axel/migrations/__init__.py       |   0
 passerelle/contrib/caluire_axel/models.py     | 119 ++++++
 passerelle/contrib/caluire_axel/schemas.py    |  39 ++
 .../contrib/caluire_axel/xsd/Adresse.xsd      |  23 ++
 .../contrib/caluire_axel/xsd/AllAxelTypes.xsd | 301 +++++++++++++++
 .../contrib/caluire_axel/xsd/Individu.xsd     |  43 +++
 .../caluire_axel/xsd/Q_FindIndividus.xsd      |  37 ++
 .../caluire_axel/xsd/R_FindIndividus.xsd      |  68 ++++
 .../caluire_axel/xsd/R_ShemaResultat.xsd      |  54 +++
 tests/settings.py                             |   1 +
 tests/test_caluire_axel.py                    | 351 ++++++++++++++++++
 15 files changed, 1146 insertions(+)
 create mode 100644 functests/caluire_axel/conftest.py
 create mode 100644 functests/caluire_axel/test_caluire_axel.py
 create mode 100644 passerelle/contrib/caluire_axel/__init__.py
 create mode 100644 passerelle/contrib/caluire_axel/migrations/0001_initial.py
 create mode 100644 passerelle/contrib/caluire_axel/migrations/__init__.py
 create mode 100644 passerelle/contrib/caluire_axel/models.py
 create mode 100644 passerelle/contrib/caluire_axel/schemas.py
 create mode 100644 passerelle/contrib/caluire_axel/xsd/Adresse.xsd
 create mode 100644 passerelle/contrib/caluire_axel/xsd/AllAxelTypes.xsd
 create mode 100644 passerelle/contrib/caluire_axel/xsd/Individu.xsd
 create mode 100644 passerelle/contrib/caluire_axel/xsd/Q_FindIndividus.xsd
 create mode 100644 passerelle/contrib/caluire_axel/xsd/R_FindIndividus.xsd
 create mode 100644 passerelle/contrib/caluire_axel/xsd/R_ShemaResultat.xsd
 create mode 100644 tests/test_caluire_axel.py

diff --git a/functests/caluire_axel/conftest.py b/functests/caluire_axel/conftest.py
new file mode 100644
index 00000000..526c2dfe
--- /dev/null
+++ b/functests/caluire_axel/conftest.py
@@ -0,0 +1,24 @@
+import pytest
+
+
+def pytest_addoption(parser):
+    parser.addoption("--url", help="Url of a passerelle Caluire Axel connector instance")
+    parser.addoption("--nameid", help="Publik Name ID")
+    parser.addoption("--firstname", help="first name of a user")
+    parser.addoption("--lastname", help="Last name of a user")
+    parser.addoption("--family", help="Family ID")
+
+
+@pytest.fixture(scope='session')
+def conn(request):
+    return request.config.getoption("--url")
+
+
+@pytest.fixture(scope='session')
+def user(request):
+    return {
+        'name_id': request.config.getoption("--nameid"),
+        'first_name': request.config.getoption("--firstname"),
+        'last_name': request.config.getoption("--lastname"),
+        'family': request.config.getoption("--family"),
+    }
diff --git a/functests/caluire_axel/test_caluire_axel.py b/functests/caluire_axel/test_caluire_axel.py
new file mode 100644
index 00000000..45a47d0d
--- /dev/null
+++ b/functests/caluire_axel/test_caluire_axel.py
@@ -0,0 +1,21 @@
+import pprint
+
+import requests
+
+
+def test_link(conn, user):
+    name_id = user['name_id']
+    url = conn + '/link?NameID=%s' % name_id
+    payload = {
+        'IDENTFAMILLE': user['family'],
+        'NOM': user['last_name'],
+        'PRENOM': user['first_name'],
+    }
+    print("Creating link with the following payload:")
+    pprint.pprint(payload)
+    resp = requests.post(url, json=payload)
+    resp.raise_for_status()
+    res = resp.json()
+    pprint.pprint(res)
+    assert res['err'] == 0
+    print('\n')
diff --git a/passerelle/contrib/caluire_axel/__init__.py b/passerelle/contrib/caluire_axel/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/contrib/caluire_axel/migrations/0001_initial.py b/passerelle/contrib/caluire_axel/migrations/0001_initial.py
new file mode 100644
index 00000000..97b9c0d7
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/migrations/0001_initial.py
@@ -0,0 +1,65 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0029_auto_20210202_1627'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CaluireAxel',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('title', models.CharField(max_length=50, verbose_name='Title')),
+                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+                ('description', models.TextField(verbose_name='Description')),
+                (
+                    'wsdl_url',
+                    models.CharField(
+                        help_text='Caluire Axel WSDL URL', max_length=128, verbose_name='WSDL URL'
+                    ),
+                ),
+                (
+                    'users',
+                    models.ManyToManyField(
+                        blank=True,
+                        related_name='_caluireaxel_users_+',
+                        related_query_name='+',
+                        to='base.ApiUser',
+                    ),
+                ),
+            ],
+            options={
+                'verbose_name': 'Caluire Axel',
+            },
+        ),
+        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)),
+                ('family_id', models.CharField(max_length=128)),
+                ('person_id', models.CharField(max_length=128)),
+                (
+                    'resource',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to='caluire_axel.CaluireAxel'
+                    ),
+                ),
+            ],
+            options={
+                'unique_together': {('resource', 'name_id')},
+            },
+        ),
+    ]
diff --git a/passerelle/contrib/caluire_axel/migrations/__init__.py b/passerelle/contrib/caluire_axel/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/contrib/caluire_axel/models.py b/passerelle/contrib/caluire_axel/models.py
new file mode 100644
index 00000000..406676fa
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/models.py
@@ -0,0 +1,119 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2021  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 <http://www.gnu.org/licenses/>.
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.base.models import BaseResource
+from passerelle.contrib.utils import axel
+from passerelle.utils.api import endpoint
+from passerelle.utils.jsonresponse import APIError
+
+from . import schemas
+
+
+class CaluireAxel(BaseResource):
+
+    wsdl_url = models.CharField(
+        max_length=128, blank=False, verbose_name=_('WSDL URL'), help_text=_('Caluire Axel WSDL URL')
+    )
+
+    category = _('Business Process Connectors')
+    _category_ordering = [_('Family')]
+
+    class Meta:
+        verbose_name = _('Caluire Axel')
+
+    def check_status(self):
+        response = self.requests.get(self.wsdl_url)
+        response.raise_for_status()
+
+    def check_individu(self, post_data):
+        family_id = post_data.pop('IDENTFAMILLE')
+        for key in ['NAISSANCE', 'CODEPOSTAL', 'VILLE', 'TEL', 'MAIL']:
+            post_data[key] = None
+        try:
+            result = schemas.find_individus(self, {'PORTAIL': {'FINDINDIVIDU': post_data}})
+        except axel.AxelError as e:
+            raise APIError(
+                'Axel error: %s' % e,
+                err_code='error',
+                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
+            )
+        data = result.json_response['DATA']['PORTAIL']['FINDINDIVIDUS']
+        for individu in data.get('INDIVIDU') or []:
+            for famille in individu['FAMILLE']:
+                if famille['IDENTFAMILLE'] == family_id:
+                    place = famille['PLACE']
+                    if place not in ['1', '2']:
+                        # not RL1 or RL2
+                        raise APIError('Wrong place in family', err_code='family-place-error-%s' % place)
+                    return individu, result
+
+        raise APIError('Person not found', err_code='not-found')
+
+    @endpoint(
+        display_category=_('Family'),
+        display_order=1,
+        description=_('Create link between user and Caluire Axel'),
+        perm='can_access',
+        parameters={
+            'NameID': {'description': _('Publik ID')},
+        },
+        post={
+            'request_body': {
+                'schema': {
+                    'application/json': schemas.LINK_SCHEMA,
+                }
+            }
+        },
+    )
+    def link(self, request, NameID, post_data):
+        if not NameID:
+            raise APIError('NameID is empty', err_code='bad-request', http_status=400)
+
+        family_id = post_data['IDENTFAMILLE']
+        try:
+            data, result = self.check_individu(post_data)
+        except APIError as e:
+            if not hasattr(e, 'err_code') or e.err_code == 'error':
+                raise
+            raise APIError('Person not found', err_code='not-found')
+
+        link, created = self.link_set.get_or_create(
+            name_id=NameID, defaults={'family_id': family_id, 'person_id': data['IDENT']}
+        )
+        if not created and (link.family_id != family_id or link.person_id != data['IDENT']):
+            raise APIError('Data conflict', err_code='conflict')
+        return {
+            'link': link.pk,
+            'created': created,
+            'family_id': link.family_id,
+            'data': {
+                'xml_request': result.xml_request,
+                'xml_response': result.xml_response,
+            },
+        }
+
+
+class Link(models.Model):
+    resource = models.ForeignKey(CaluireAxel, on_delete=models.CASCADE)
+    name_id = models.CharField(blank=False, max_length=256)
+    family_id = models.CharField(blank=False, max_length=128)
+    person_id = models.CharField(blank=False, max_length=128)
+
+    class Meta:
+        unique_together = ('resource', 'name_id')
diff --git a/passerelle/contrib/caluire_axel/schemas.py b/passerelle/contrib/caluire_axel/schemas.py
new file mode 100644
index 00000000..37443155
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/schemas.py
@@ -0,0 +1,39 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2021  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 <http://www.gnu.org/licenses/>.
+
+import copy
+import os
+
+from passerelle.contrib.utils import axel
+
+BASE_XSD_PATH = os.path.join(os.path.dirname(__file__), 'xsd')
+
+
+class Operation(axel.Operation):
+    base_xsd_path = BASE_XSD_PATH
+
+
+find_individus = Operation('FindIndividus')
+
+
+LINK_SCHEMA = copy.deepcopy(
+    find_individus.request_schema['properties']['PORTAIL']['properties']['FINDINDIVIDU']
+)
+for key in ['NAISSANCE', 'CODEPOSTAL', 'VILLE', 'TEL', 'MAIL']:
+    LINK_SCHEMA['properties'].pop(key)
+    LINK_SCHEMA['required'].remove(key)
+LINK_SCHEMA['properties']['IDENTFAMILLE'] = {'type': 'string', 'maxLength': 8}
+LINK_SCHEMA['required'].append('IDENTFAMILLE')
diff --git a/passerelle/contrib/caluire_axel/xsd/Adresse.xsd b/passerelle/contrib/caluire_axel/xsd/Adresse.xsd
new file mode 100644
index 00000000..6bc1fa64
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/Adresse.xsd
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema xmlns:all="urn:AllAxelTypes" targetNamespace="urn:Adresse" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:adr="urn:Adresse">
+	
+	<xsd:import schemaLocation="./AllAxelTypes.xsd" namespace="urn:AllAxelTypes"/>
+	
+	<xsd:simpleType name="NPAIType">
+		<xsd:union memberTypes="all:ONType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:complexType name="ADRESSEType">
+		<xsd:sequence>
+			<xsd:element name="ADRESSE3" type="all:COMPLEMENTType"/>
+			<xsd:element name="ADRESSE4" type="all:COMPLEMENTType"/>
+			<xsd:element name="NORUE" type="all:NUMVOIEType"/>
+			<xsd:element name="ADRESSE1" type="all:COMPLEMENTType"/>
+			<xsd:element name="ADRESSE2" type="all:COMPLEMENTType"/>
+			<xsd:element name="CODEPOSTAL" type="all:CODEPOSTALType"/>
+			<xsd:element name="VILLE" type="all:VILLEType"/>
+			<xsd:element name="PAYS" type="all:PAYSType"/>
+			<xsd:element name="NPAI" type="adr:NPAIType"/>
+		</xsd:sequence> 
+	</xsd:complexType>
+</xsd:schema>
\ No newline at end of file
diff --git a/passerelle/contrib/caluire_axel/xsd/AllAxelTypes.xsd b/passerelle/contrib/caluire_axel/xsd/AllAxelTypes.xsd
new file mode 100644
index 00000000..1213f569
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/AllAxelTypes.xsd
@@ -0,0 +1,301 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema targetNamespace="urn:AllAxelTypes" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:all="urn:AllAxelTypes">
+
+	<xsd:simpleType name="empty-string">
+		<xsd:restriction base="xsd:string">
+			<xsd:enumeration value="" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="decimal-or-empty">
+		<xsd:union memberTypes="xsd:decimal all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="unsignedInt-or-empty">
+		<xsd:union memberTypes="xsd:unsignedInt all:empty-string" />
+	</xsd:simpleType>
+		
+	<xsd:simpleType name="TELREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="10" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="TEL2REQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="16" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="COURRIELREQUIREDType">
+		<xsd:restriction base="xsd:string" >
+			<xsd:pattern value="[^@]+@[^\.]+\..+" /> 
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="NOMREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="50" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="PRENOMREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="30" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="NOMType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="0" />
+			<xsd:maxLength value="50" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="PRENOMType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="0" />
+			<xsd:maxLength value="30" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="DATEREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="((0[1-9])|([12][0-9])|(3[01]))/((0[1-9])|(1[012]))/((000[1-9])|(00[1-9][0-9])|(0[1-9][0-9]{2})|([1-9][0-9]{3}))" /> 
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="DATEType">
+		<xsd:union memberTypes="all:DATEREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="DATETIMEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="((0[1-9])|([12][0-9])|(3[01]))/((0[1-9])|(1[012]))/((000[1-9])|(00[1-9][0-9])|(0[1-9][0-9]{2})|([1-9][0-9]{3})) (([01][0-9])|(2[0-3]))(:[0-5][0-9]){2}(\.[0-9]+)?" /> 
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="TIMEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="(([01][0-9])|(2[0-3]))(:[0-5][0-9])" /> 
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="IDENTType">
+		<xsd:restriction base="xsd:string">
+			<xsd:maxLength value="8" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="IDENTREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1"/>
+			<xsd:maxLength value="8"/>
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="IDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:maxLength value="10" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="IDREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1"/>
+			<xsd:maxLength value="10"/>
+		</xsd:restriction>
+	</xsd:simpleType>
+		
+	<xsd:simpleType name="TELType">
+		<xsd:union memberTypes="all:TELREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+
+	<xsd:simpleType name="TEL2Type">
+		<xsd:union memberTypes="all:TEL2REQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="COURRIELType" > 
+		<xsd:union memberTypes="all:COURRIELREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="ANNEEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="[0-9]{4}"/>
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="MOISType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="[0-1][0-9]"/>		
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="OUINONREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="[Oo][Uu][Ii]" />
+			<xsd:pattern value="[Nn][Oo][Nn]" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="BOOLEANREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="0" />
+			<xsd:pattern value="1" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="OUINONType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="|" />
+			<xsd:pattern value="[Oo][Uu][Ii]" />
+			<xsd:pattern value="[Nn][Oo][Nn]" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="NUMVOIEREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="5" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="NUMVOIEType">
+		<xsd:union memberTypes="all:NUMVOIEREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="COMPLEMENTNUMType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="|" />
+			<xsd:pattern value="[Bb][Ii][Ss]" />
+			<xsd:pattern value="[Tt][Ee][Rr]" />
+			<xsd:pattern value="[Qq][Uu][Aa][Tt][Ee][Rr]" />
+			<xsd:pattern value="[Qq][Uu][Ii][Nn][Qq][Uu][Ii][Ee][Ss]" />
+		</xsd:restriction>
+	</xsd:simpleType>
+
+	<xsd:simpleType name="TYPEVOIEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="[Aa][Vv][Ee][Nn][Uu][Ee]" />
+			<xsd:pattern value="[Rr][Uu][Ee]" />
+			<xsd:pattern value="[Ff][Aa][Uu][Bb][Oo][Uu][Rr][Gg]" />
+			<xsd:pattern value="[Cc][Hh][Ee][Mm][Ii][Nn]" />
+			<xsd:pattern value="[Cc][Oo][Uu][Rr][Ss]" />
+			<xsd:pattern value="[Bb][Oo][Uu][Ll][Ee][Vv][Aa][Rr][Dd]" />
+			<xsd:pattern value="[Ii][Mm][Pp][Aa][Ss][Ss][Ee]" />
+			<xsd:pattern value="[Ll][Ii][Ee][Uu] [Dd][Ii][Tt]" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="COMPLEMENTREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="40" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="COMPLEMENTType">
+		<xsd:union memberTypes="all:COMPLEMENTREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+
+	<xsd:simpleType name="CODEPOSTALREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="[0-9]{5}"/>
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="VILLEREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="35" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="PAYSREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="1" />
+			<xsd:maxLength value="30" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="CODEPOSTALType">
+		<xsd:union memberTypes="all:CODEPOSTALREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="VILLEType">
+		<xsd:union memberTypes="all:VILLEREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="PAYSType">
+		<xsd:union memberTypes="all:PAYSREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="CODEINSEEVILLEREQUIREDType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="[0-9]{5}"/>
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="CODEINSEEVILLEType">
+		<xsd:union memberTypes="all:CODEINSEEVILLEREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="ONEmptyType">
+		<xsd:union memberTypes="all:ONType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="ONType">
+		<xsd:restriction base="xsd:string">
+			<xsd:pattern value="[Oo]" />
+			<xsd:pattern value="[Nn]" />
+		</xsd:restriction>
+	</xsd:simpleType>
+
+	<xsd:simpleType name="MONTANTREQUIREDType">
+		<xsd:restriction base="xsd:decimal">
+			<xsd:totalDigits value="10"/>
+			<xsd:fractionDigits value="2"/>
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="MONTANTType">
+		<xsd:union memberTypes="all:MONTANTREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="PRIXREQUIREDType">
+		<xsd:restriction base="xsd:decimal">
+			<xsd:totalDigits value="10"/>
+			<xsd:fractionDigits value="5"/>
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="PRIXType">
+		<xsd:union memberTypes="all:PRIXREQUIREDType all:empty-string" />
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="LIBELLEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="0" />
+			<xsd:maxLength value="40" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="LIBELLE100Type">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="0" />
+			<xsd:maxLength value="100" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="FOOBARType"><!-- CUSTOM -->
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="0" />
+			<xsd:maxLength value="256" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+</xsd:schema>
diff --git a/passerelle/contrib/caluire_axel/xsd/Individu.xsd b/passerelle/contrib/caluire_axel/xsd/Individu.xsd
new file mode 100644
index 00000000..dfd97840
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/Individu.xsd
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema xmlns:adr="urn:Adresse" xmlns:all="urn:AllAxelTypes" targetNamespace="urn:Individu" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:ind="urn:Individu" >
+	
+	<xsd:import schemaLocation="./AllAxelTypes.xsd" namespace="urn:AllAxelTypes" />
+	<xsd:import schemaLocation="./Adresse.xsd" namespace="urn:Adresse" />
+	
+	<xsd:simpleType name="SEXEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:enumeration value="" />
+			<xsd:enumeration value="M" />
+			<xsd:enumeration value="F" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="CIVILITEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:minLength value="0" />
+			<xsd:maxLength value="4" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:complexType name="INDIVIDUType">
+		<xsd:sequence>
+			<xsd:element name="IDENT" type="all:IDENTType"/>
+			<xsd:element name="CIVILITE" type="ind:CIVILITEType"/>
+			<xsd:element name="NOM" type="all:NOMREQUIREDType"/>
+			<xsd:element name="PRENOM" type="all:PRENOMType"/>
+			<xsd:element name="NAISSANCE" type="all:DATEType"/>
+			<xsd:element name="SEXE" type="ind:SEXEType"/>
+			<xsd:element name="NOMJF" type="all:NOMType"/>
+			<xsd:element name="TELFIXE" type="all:TEL2Type"/>
+			<xsd:element name="TELPORTABLE" type="all:TEL2Type"/>
+			<xsd:element name="MAIL" type="all:COURRIELType"/>
+			<xsd:element name="CSP" type="all:FOOBARType"/><!-- CUSTOM -->
+			<xsd:element name="EMPLOYEUR" type="all:FOOBARType"/><!-- CUSTOM -->
+			<xsd:element name="VILLEEMP" type="all:FOOBARType"/><!-- CUSTOM -->
+			<xsd:element name="PAI" type="all:FOOBARType"/><!-- CUSTOM -->
+			<xsd:element name="GARDEALTERNEE" type="all:FOOBARType"/><!-- CUSTOM -->
+			<xsd:element name="ADRESSE" type="adr:ADRESSEType" minOccurs="0" maxOccurs="1"/>
+		</xsd:sequence> 
+	</xsd:complexType>
+		
+</xsd:schema>
diff --git a/passerelle/contrib/caluire_axel/xsd/Q_FindIndividus.xsd b/passerelle/contrib/caluire_axel/xsd/Q_FindIndividus.xsd
new file mode 100644
index 00000000..378e010b
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/Q_FindIndividus.xsd
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:all="urn:AllAxelTypes">
+	
+	<xsd:import schemaLocation="./AllAxelTypes.xsd" namespace="urn:AllAxelTypes"  />
+	
+	
+	<xsd:complexType name="FINDINDIVIDUType">
+		<xsd:sequence>
+			<xsd:element ref="NOM" />
+			<xsd:element ref="PRENOM" />
+			<xsd:element ref="NAISSANCE" />
+			<xsd:element ref="CODEPOSTAL" />
+			<xsd:element ref="VILLE" />
+			<xsd:element ref="TEL" />
+			<xsd:element ref="MAIL" />
+		</xsd:sequence> 
+	</xsd:complexType>
+	
+	<xsd:complexType name="PORTAILType">
+		<xsd:sequence>
+			<xsd:element ref="FINDINDIVIDU" minOccurs="0" maxOccurs="1"/>
+		</xsd:sequence> 
+	</xsd:complexType>
+	
+	<xsd:element name="NOM" type="all:NOMREQUIREDType"/>
+	<xsd:element name="PRENOM" type="all:PRENOMREQUIREDType"/> <!-- CUSTOM, it should be PRENOMType -->
+	<xsd:element name="NAISSANCE" type="all:DATEType"/>
+	<xsd:element name="CODEPOSTAL" type="all:CODEPOSTALType"/>
+	<xsd:element name="VILLE" type="all:VILLEType"/>
+	<xsd:element name="TEL" type="all:TEL2Type"/>
+	<xsd:element name="MAIL" type="all:COURRIELType"/>
+		
+	<xsd:element name="FINDINDIVIDU" type="FINDINDIVIDUType"/>
+	
+	<xsd:element name="PORTAIL" type="PORTAILType"/>	
+		
+</xsd:schema>
diff --git a/passerelle/contrib/caluire_axel/xsd/R_FindIndividus.xsd b/passerelle/contrib/caluire_axel/xsd/R_FindIndividus.xsd
new file mode 100644
index 00000000..5c4cf168
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/R_FindIndividus.xsd
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema	xmlns:all="urn:AllAxelTypes" xmlns:ind="urn:Individu"  xmlns:xsd="http://www.w3.org/2001/XMLSchema" >
+	
+	<xsd:import schemaLocation="./Individu.xsd" namespace="urn:Individu" />
+	<xsd:import schemaLocation="./AllAxelTypes.xsd" namespace="urn:AllAxelTypes" />
+	
+	<xsd:redefine schemaLocation="./R_ShemaResultat.xsd">
+	    <xsd:simpleType name="TYPEType">
+			<xsd:restriction base="TYPEType">
+				<xsd:enumeration value="FindIndividus" />
+			</xsd:restriction>
+	    </xsd:simpleType>
+		
+		<xsd:complexType name="PORTAILType">
+			<xsd:complexContent>
+				<xsd:extension base="PORTAILType">
+					<xsd:sequence>
+							<xsd:element ref="FINDINDIVIDUS" minOccurs="0" maxOccurs="1"/>
+					</xsd:sequence>
+				</xsd:extension>
+			 </xsd:complexContent>
+		</xsd:complexType>
+	</xsd:redefine>	
+			
+	<xsd:complexType name="INDIType">
+		<xsd:complexContent>
+			<xsd:extension base="ind:INDIVIDUType">
+				<xsd:sequence>
+					<xsd:element ref="FAMILLE"  minOccurs="0" maxOccurs="unbounded"/>
+				</xsd:sequence>
+			</xsd:extension>
+		</xsd:complexContent>
+	</xsd:complexType>
+	
+	<xsd:complexType name="FAMILLEType">
+		<xsd:sequence>
+			<xsd:element ref="IDENTFAMILLE" />
+			<xsd:element ref="PLACE"/>
+			<xsd:element ref="SITUATION" minOccurs="0" maxOccurs="1"/><!-- CUSTOM -->
+		</xsd:sequence> 
+	</xsd:complexType>
+	
+	<xsd:simpleType name="PLACEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:enumeration value="" />
+			<xsd:enumeration value="1" />
+			<xsd:enumeration value="2" />
+			<xsd:enumeration value="3" />
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:complexType name="FINDINDIVIDUSType">
+		<xsd:sequence>
+			<xsd:element ref="CODE" />
+			<xsd:element ref="INDIVIDU" minOccurs="0" maxOccurs="unbounded" />
+		</xsd:sequence> 
+	</xsd:complexType>
+
+	<xsd:element name="CODE" type="xsd:integer"/>
+	<xsd:element name="INDIVIDU" type="INDIType"/>
+	<xsd:element name="FAMILLE" type="FAMILLEType"/>
+	<xsd:element name="IDENTFAMILLE" type="all:IDENTType"/>
+	<xsd:element name="PLACE" type="PLACEType"/>
+	<xsd:element name="SITUATION" type="all:FOOBARType"/><!-- CUSTOM -->
+	
+	<xsd:element name="FINDINDIVIDUS" type="FINDINDIVIDUSType"/>
+		
+</xsd:schema>
diff --git a/passerelle/contrib/caluire_axel/xsd/R_ShemaResultat.xsd b/passerelle/contrib/caluire_axel/xsd/R_ShemaResultat.xsd
new file mode 100644
index 00000000..8420ed77
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/R_ShemaResultat.xsd
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:all="urn:AllAxelTypes">
+	
+	<xsd:import schemaLocation="./AllAxelTypes.xsd" namespace="urn:AllAxelTypes"  />
+	
+	<xsd:simpleType name="TYPEType">
+		<xsd:restriction base="xsd:string">
+		</xsd:restriction>
+	</xsd:simpleType>
+	
+	<xsd:simpleType name="STATUSType">
+		<xsd:restriction base="xsd:string">
+			<xsd:enumeration value="OK" />
+			<xsd:enumeration value="NOK" />
+		</xsd:restriction>
+	</xsd:simpleType>
+		
+	<xsd:complexType name="PORTAILType">
+	</xsd:complexType>
+	
+	<xsd:complexType name="RESULTATType">
+		<xsd:sequence>
+			<xsd:element ref="TYPE" />
+			<xsd:element ref="STATUS" />
+			<xsd:element ref="DATE" />
+			<xsd:element ref="COMMENTAIRES" />
+		</xsd:sequence> 
+	</xsd:complexType>
+	
+	<xsd:complexType name="DATAType">
+		<xsd:sequence>
+			<xsd:element ref="PORTAIL"/>
+		</xsd:sequence> 
+	</xsd:complexType>
+		
+	<xsd:complexType name="PORTAILSERVICEType">
+		<xsd:sequence>
+			<xsd:element ref="RESULTAT" />
+			<xsd:element ref="DATA" />
+		</xsd:sequence>
+	</xsd:complexType>
+	
+	<xsd:element name="TYPE" type="TYPEType"/>
+	<xsd:element name="STATUS" type="STATUSType"/>
+	<xsd:element name="DATE" type="all:DATETIMEType"/>
+	<xsd:element name="COMMENTAIRES" type="xsd:string"/>
+		
+	<xsd:element name="PORTAIL" type="PORTAILType"/>
+	
+	<xsd:element name="RESULTAT" type="RESULTATType"/>
+	<xsd:element name="DATA" type="DATAType"/>
+	
+	<xsd:element name="PORTAILSERVICE" type="PORTAILSERVICEType"/>
+</xsd:schema>
diff --git a/tests/settings.py b/tests/settings.py
index dd32fbe8..a491db10 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -16,6 +16,7 @@ KNOWN_SERVICES = {
 # include all contrib apps
 INSTALLED_APPS += (
     'passerelle.contrib.adict',
+    'passerelle.contrib.caluire_axel',
     'passerelle.contrib.dpark',
     'passerelle.contrib.fake_family',
     'passerelle.contrib.gdema',
diff --git a/tests/test_caluire_axel.py b/tests/test_caluire_axel.py
new file mode 100644
index 00000000..109688ff
--- /dev/null
+++ b/tests/test_caluire_axel.py
@@ -0,0 +1,351 @@
+# -*- coding: utf-8 -*-
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2021 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 <http://www.gnu.org/licenses/>.
+
+from contextlib import contextmanager
+
+import mock
+import pytest
+import utils
+import xmlschema
+
+from passerelle.contrib.caluire_axel import schemas
+from passerelle.contrib.caluire_axel.models import CaluireAxel, Link
+from passerelle.contrib.utils.axel import AxelError
+from passerelle.utils.soap import SOAPError
+
+
+@pytest.fixture
+def resource(db):
+    return utils.make_resource(
+        CaluireAxel, slug='test', wsdl_url='http://example.net/AXEL_WS/AxelWS.php?wsdl'
+    )
+
+
+@pytest.fixture
+def link_params():
+    return {
+        'IDENTFAMILLE': '12345',
+        'NOM': 'Doe',
+        'PRENOM': 'John',
+    }
+
+
+@contextmanager
+def mock_getdata(content, operation):
+    with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.soap_client') as client:
+        resp = '''
+        <?xml version="1.0"?>
+        <PORTAILSERVICE>
+          <RESULTAT>
+            <TYPE>%s</TYPE>
+            <STATUS>OK</STATUS>
+            <DATE>10/10/2010 10:10:01</DATE>
+            <COMMENTAIRES><![CDATA[]]></COMMENTAIRES>
+          </RESULTAT>
+          <DATA>
+            %s
+          </DATA>
+        </PORTAILSERVICE>
+        '''.strip() % (
+            operation,
+            content,
+        )
+        client.return_value.service.getData.return_value = resp
+        yield
+
+
+def test_operation_status_error(resource):
+    resp = '''
+    <?xml version="1.0"?>
+    <PORTAILSERVICE>
+      <RESULTAT>
+        <TYPE>FindIndividus</TYPE>
+        <STATUS>NOK</STATUS>
+        <COMMENTAIRES><![CDATA[Foo reason]]></COMMENTAIRES>
+      </RESULTAT>
+      <DATA>
+        <PORTAIL/>
+      </DATA>
+    </PORTAILSERVICE>
+    '''.strip()
+    with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.soap_client') as client:
+        client.return_value.service.getData.return_value = resp
+        with pytest.raises(AxelError, match='Foo reason'):
+            schemas.find_individus(
+                resource,
+                {
+                    'PORTAIL': {
+                        'FINDINDIVIDU': {
+                            'NOM': 'Doe',
+                            'PRENOM': 'John',
+                            'NAISSANCE': None,
+                            'CODEPOSTAL': None,
+                            'VILLE': None,
+                            'TEL': None,
+                            'MAIL': None,
+                        }
+                    }
+                },
+            )
+
+
+@pytest.mark.parametrize(
+    'content',
+    [
+        '<PORTAIL><FINDINDIVIDUS/></PORTAIL>',
+    ],
+)
+def test_operation_find_individus(resource, content):
+    with mock_getdata(content, 'FindIndividus'):
+        with pytest.raises(AxelError):
+            schemas.find_individus(
+                resource,
+                {
+                    'PORTAIL': {
+                        'FINDINDIVIDU': {
+                            'NOM': 'Doe',
+                            'PRENOM': 'John',
+                            'NAISSANCE': None,
+                            'CODEPOSTAL': None,
+                            'VILLE': None,
+                            'TEL': None,
+                            'MAIL': None,
+                        }
+                    }
+                },
+            )
+
+
+def test_link_endpoint_nameid_empty(app, resource, link_params):
+    resp = app.post_json('/caluire-axel/test/link?NameID=', params=link_params, status=400)
+    assert resp.json['err_desc'] == "NameID is empty"
+    assert resp.json['err'] == 'bad-request'
+
+
+def test_link_endpoint_axel_error(app, resource, link_params):
+    with mock.patch('passerelle.contrib.caluire_axel.schemas.find_individus') as operation:
+        operation.side_effect = AxelError('FooBar')
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'] == "Axel error: FooBar"
+    assert resp.json['err'] == 'error'
+    assert resp.json['data'] == {'xml_request': None, 'xml_response': None}
+
+    # test xml_request and xml_response only for this endpoint
+    xml_request = """<PORTAIL>
+  <FINDINDIVIDU>
+    <NOM>Doe</NOM>
+    <PRENOM>John</PRENOM>
+    <NAISSANCE />
+    <CODEPOSTAL />
+    <VILLE />
+    <TEL />
+    <MAIL />
+  </FINDINDIVIDU>
+</PORTAIL>
+"""
+
+    with mock_getdata('', 'FindIndividus'):
+        with mock.patch('xmlschema.XMLSchema.validate') as xml_validate:
+            xml_validate.side_effect = xmlschema.XMLSchemaValidationError(None, None)
+            resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'].startswith("Axel error: invalid request")
+    assert resp.json['err'] == 'error'
+    assert resp.json['data']['xml_request'] == xml_request
+    assert resp.json['data']['xml_response'] is None
+
+    xml_response = """<PORTAILSERVICE>
+  <RESULTAT>
+    <TYPE>FINDINDIVIDUS</TYPE>
+    <STATUS>NOK</STATUS>
+    <COMMENTAIRES>Foo reason</COMMENTAIRES>
+  </RESULTAT>
+  <DATA>
+    <PORTAIL />
+  </DATA>
+</PORTAILSERVICE>"""
+    response = """
+    <?xml version="1.0"?>
+    <PORTAILSERVICE>
+      <RESULTAT>
+        <TYPE>FINDINDIVIDUS</TYPE>
+        <STATUS>NOK</STATUS>
+        <COMMENTAIRES><![CDATA[Foo reason]]></COMMENTAIRES>
+      </RESULTAT>
+      <DATA>
+        <PORTAIL/>
+      </DATA>
+    </PORTAILSERVICE>
+    """.strip()
+    with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.soap_client') as client:
+        client.return_value.service.getData.return_value = response
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'] == 'Axel error: Foo reason'
+    assert resp.json['err'] == 'error'
+    assert resp.json['data']['xml_request'] == xml_request
+    assert resp.json['data']['xml_response'] == xml_response
+
+    content = """<PORTAIL>
+      <FINDINDIVIDUS>
+        <INDIVIDU>
+          <IDENT>123</IDENT>
+          <NOM>Doe</NOM>
+          <PRENOM>John</PRENOM>
+          <FAMILLE>
+            <IDENTFAMILLE>12345</IDENTFAMILLE>
+            <PLACE>2</PLACE>
+          </FAMILLE>
+        </INDIVIDU>
+      </FINDINDIVIDUS>
+    </PORTAIL>"""
+    xml_response = (
+        """<PORTAILSERVICE>
+  <RESULTAT>
+    <TYPE>FindIndividus</TYPE>
+    <STATUS>OK</STATUS>
+    <DATE>10/10/2010 10:10:01</DATE>
+    <COMMENTAIRES />
+  </RESULTAT>
+  <DATA>
+    %s
+  </DATA>
+</PORTAILSERVICE>"""
+        % content
+    )
+    with mock_getdata(content, 'FindIndividus'):
+        with mock.patch('passerelle.contrib.utils.axel.AxelSchema.decode') as decode:
+            decode.side_effect = xmlschema.XMLSchemaValidationError(None, None)
+            resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'].startswith("Axel error: invalid response")
+    assert resp.json['err'] == 'error'
+    assert resp.json['data']['xml_request'] == xml_request
+    assert resp.json['data']['xml_response'] == xml_response
+
+    with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.soap_client') as client:
+        client.side_effect = SOAPError('SOAP service is down')
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'] == "SOAP service is down"
+
+
+@pytest.mark.parametrize(
+    'xml_response',
+    [
+        '',
+        """<INDIVIDU>
+  <IDENT>123</IDENT>
+  <CIVILITE/><NOM>A</NOM><PRENOM/><NAISSANCE/><SEXE/><NOMJF/><TELFIXE/><TELPORTABLE/><MAIL/><CSP/><EMPLOYEUR/><VILLEEMP/><PAI/><GARDEALTERNEE/>
+  <FAMILLE>
+    <IDENTFAMILLE>12346</IDENTFAMILLE>
+    <PLACE>2</PLACE>
+    <SITUATION/>
+  </FAMILLE>
+  <FAMILLE>
+    <IDENTFAMILLE>12347</IDENTFAMILLE>
+    <PLACE>1</PLACE>
+  </FAMILLE>
+  </INDIVIDU>""",
+        """<INDIVIDU>
+  <IDENT>123</IDENT>
+  <CIVILITE/><NOM>A</NOM><PRENOM/><NAISSANCE/><SEXE/><NOMJF/><TELFIXE/><TELPORTABLE/><MAIL/><CSP/><EMPLOYEUR/><VILLEEMP/><PAI/><GARDEALTERNEE/>
+  <FAMILLE>
+    <IDENTFAMILLE>12345</IDENTFAMILLE>
+    <PLACE>3</PLACE>
+    <SITUATION/>
+  </FAMILLE>
+  </INDIVIDU>""",
+    ],
+)
+def test_link_endpoint_no_result(app, resource, link_params, xml_response):
+    content = (
+        '''<PORTAIL>
+    <FINDINDIVIDUS>
+        <CODE>42</CODE>
+        %s
+    </FINDINDIVIDUS>
+</PORTAIL>'''
+        % xml_response
+    )
+    with mock_getdata(content, 'FindIndividus'):
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'] == "Person not found"
+    assert resp.json['err'] == 'not-found'
+
+
+def test_link_endpoint_conflict(app, resource, link_params):
+    content = """<PORTAIL><FINDINDIVIDUS>
+  <CODE>42</CODE>
+  <INDIVIDU>
+    <IDENT>123</IDENT>
+    <CIVILITE/><NOM>A</NOM><PRENOM/><NAISSANCE/><SEXE/><NOMJF/><TELFIXE/><TELPORTABLE/><MAIL/><CSP/><EMPLOYEUR/><VILLEEMP/><PAI/><GARDEALTERNEE/>
+    <FAMILLE>
+      <IDENTFAMILLE>12345</IDENTFAMILLE>
+      <PLACE>2</PLACE>
+      <SITUATION/>
+    </FAMILLE>
+  </INDIVIDU>
+</FINDINDIVIDUS></PORTAIL>"""
+    # existing link but family_id is wrong
+    link = Link.objects.create(resource=resource, name_id='yyy', family_id='YYY', person_id='42')
+    with mock_getdata(content, 'FindIndividus'):
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'] == "Data conflict"
+    assert resp.json['err'] == 'conflict'
+
+    # existing link but person_id is wrong
+    link.family_id = '12345'
+    link.person_id = '35'
+    link.save()
+    with mock_getdata(content, 'FindIndividus'):
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert resp.json['err_desc'] == "Data conflict"
+    assert resp.json['err'] == 'conflict'
+
+
+@pytest.mark.parametrize('place', [1, 2])
+def test_link_endpoint(app, resource, link_params, place):
+    content = (
+        """<PORTAIL><FINDINDIVIDUS>
+  <CODE>42</CODE>
+  <INDIVIDU>
+    <IDENT>123</IDENT>
+    <CIVILITE/><NOM>A</NOM><PRENOM/><NAISSANCE/><SEXE/><NOMJF/><TELFIXE/><TELPORTABLE/><MAIL/><CSP/><EMPLOYEUR/><VILLEEMP/><PAI/><GARDEALTERNEE/>
+    <FAMILLE>
+      <IDENTFAMILLE>12345</IDENTFAMILLE>
+      <PLACE>%s</PLACE>
+      <SITUATION/>
+    </FAMILLE>
+  </INDIVIDU>
+</FINDINDIVIDUS></PORTAIL>"""
+        % place
+    )
+    with mock_getdata(content, 'FindIndividus'):
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert set(resp.json.keys()) == set(['err', 'link', 'created', 'family_id', 'data'])
+    assert resp.json['err'] == 0
+    assert resp.json['family_id'] == '12345'
+    assert resp.json['created'] is True
+    assert 'xml_request' in resp.json['data']
+    assert 'xml_response' in resp.json['data']
+
+    # again
+    with mock_getdata(content, 'FindIndividus'):
+        resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
+    assert set(resp.json.keys()) == set(['err', 'link', 'created', 'family_id', 'data'])
+    assert resp.json['err'] == 0
+    assert resp.json['family_id'] == '12345'
+    assert resp.json['created'] is False  # link already exists
+    assert 'xml_request' in resp.json['data']
+    assert 'xml_response' in resp.json['data']
-- 
2.30.2

