From fe2401d64d5b5327bca2bf1ca89f05d4885323a8 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Mon, 30 Mar 2020 18:38:04 +0200
Subject: [PATCH] opendatasoft: add opendatasoft connector (#40979)

---
 passerelle/apps/opendatasoft/__init__.py      |   0
 .../opendatasoft/migrations/0001_initial.py   |  55 ++++
 .../apps/opendatasoft/migrations/__init__.py  |   0
 passerelle/apps/opendatasoft/models.py        | 148 ++++++++++
 .../opendatasoft/query_confirm_delete.html    |   1 +
 .../templates/opendatasoft/query_form.html    |   1 +
 passerelle/apps/opendatasoft/urls.py          |  28 ++
 passerelle/apps/opendatasoft/views.py         |  47 +++
 passerelle/settings.py                        |   1 +
 passerelle/static/css/style.css               |   5 +
 tests/test_opendatasoft.py                    | 274 ++++++++++++++++++
 11 files changed, 560 insertions(+)
 create mode 100644 passerelle/apps/opendatasoft/__init__.py
 create mode 100644 passerelle/apps/opendatasoft/migrations/0001_initial.py
 create mode 100644 passerelle/apps/opendatasoft/migrations/__init__.py
 create mode 100644 passerelle/apps/opendatasoft/models.py
 create mode 100644 passerelle/apps/opendatasoft/templates/opendatasoft/query_confirm_delete.html
 create mode 100644 passerelle/apps/opendatasoft/templates/opendatasoft/query_form.html
 create mode 100644 passerelle/apps/opendatasoft/urls.py
 create mode 100644 passerelle/apps/opendatasoft/views.py
 create mode 100644 tests/test_opendatasoft.py

diff --git a/passerelle/apps/opendatasoft/__init__.py b/passerelle/apps/opendatasoft/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/opendatasoft/migrations/0001_initial.py b/passerelle/apps/opendatasoft/migrations/0001_initial.py
new file mode 100644
index 00000000..94ecf8b0
--- /dev/null
+++ b/passerelle/apps/opendatasoft/migrations/0001_initial.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-04-15 07:53
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import passerelle.utils.templates
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0018_smslog'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='OpenDataSoft',
+            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')),
+                ('service_url', models.CharField(default='https://examples.opendatasoft.com', help_text='OpenData Adresse Web Service URL', max_length=256, verbose_name='Service URL')),
+                ('api_key', models.CharField(blank=True, default='', help_text='API key used as credentials', max_length=128, verbose_name='API key')),
+                ('users', models.ManyToManyField(blank=True, related_name='_opendatasoft_users_+', related_query_name='+', to='base.ApiUser')),
+            ],
+            options={
+                'verbose_name': 'OpenDataSoft Web Service',
+            },
+        ),
+        migrations.CreateModel(
+            name='Query',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=128, verbose_name='Name')),
+                ('slug', models.SlugField(max_length=128, verbose_name='Slug')),
+                ('description', models.TextField(blank=True, verbose_name='Description')),
+                ('dataset', models.CharField(default='world-heritage-unesco-list', help_text='dataset to query', max_length=128, verbose_name='Dataset')),
+                ('text_template', models.TextField(blank=True, default='{{name_fr}} en/au {{country_fr}}', help_text="Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}", validators=[passerelle.utils.templates.validate_template], verbose_name='Text template')),
+                ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queries', to='opendatasoft.OpenDataSoft', verbose_name='Resource')),
+            ],
+            options={
+                'verbose_name': 'Query',
+                'ordering': ['name'],
+                'abstract': False,
+            },
+        ),
+        migrations.AlterUniqueTogether(
+            name='query',
+            unique_together=set([('resource', 'name'), ('resource', 'slug')]),
+        ),
+    ]
diff --git a/passerelle/apps/opendatasoft/migrations/__init__.py b/passerelle/apps/opendatasoft/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/opendatasoft/models.py b/passerelle/apps/opendatasoft/models.py
new file mode 100644
index 00000000..62937eb3
--- /dev/null
+++ b/passerelle/apps/opendatasoft/models.py
@@ -0,0 +1,148 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020  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.shortcuts import get_object_or_404
+from django.template import Context, Template
+from django.core.urlresolvers import reverse
+from django.utils.encoding import force_text
+from django.utils.six.moves.urllib import parse as urlparse
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.utils.templates import validate_template
+from passerelle.base.models import BaseResource, BaseQuery
+from passerelle.utils.api import endpoint
+
+
+class OpenDataSoft(BaseResource):
+    service_url = models.CharField(
+        _('Service URL'),
+        max_length=256, blank=False,
+        help_text=_('OpenData Adresse Web Service URL'),
+        default='https://examples.opendatasoft.com',
+    )
+    api_key = models.CharField(
+        _('API key'),
+        max_length=128, blank=True,
+        help_text=_('API key used as credentials'),
+        default='',
+    )
+
+    category = _('Data Sources')
+    documentation_url = 'https://doc-publik.entrouvert.com/admin-fonctionnel/parametrage-avance/connecteur-opendadasoft/'
+
+    class Meta:
+        verbose_name = _('OpenDataSoft Web Service')
+
+    @endpoint(
+        perm='can_access',
+        description=_('Search'),
+        parameters={
+            'dataset': {'description': _('Dataset'), 'example_value': 'world-heritage-unesco-list'},
+            'text_template': {'description': _('Text template'), 'example_value': '{{name_fr}} en/au {{country_fr}}'},
+            'id': {'description': _('Record identifier')},
+            'q': {'description': _('Full text query'), 'example_value': "plage"},
+            'rows': {'description': _('Maximum items')},
+        })
+    def search(self, request, dataset=None, text_template='', id=None, q=None, rows=None, **kwargs):
+        scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.service_url)
+        path = urlparse.urljoin(path, 'api/records/1.0/search/')
+        url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
+
+        if id is not None:
+            query = 'recordid:%s' % id
+        else:
+            query = q
+        params = {
+            'dataset': dataset,
+            'q': query,
+        }
+        if self.api_key:
+            params.update({'apikey': self.api_key})
+        if rows:
+            params.update({'rows': rows})
+
+        result_response = self.requests.get(url, params=params)
+
+        result = []
+        for record in result_response.json().get('records'):
+            data = {}
+            data['id'] = record.get('recordid')
+
+            context = {}
+            for key, value in record.get('fields').items():
+                context[key] = force_text(value)
+            template = Template(text_template)
+            data['text'] = template.render(Context(context)).strip()
+
+            result.append(data)
+        return {'data': result}
+
+    @endpoint(name='q',
+              description=_('Query'),
+              pattern=r'^(?P<query_slug>[\w:_-]+)/$',
+              perm='can_access',
+              show=False)
+    def q(self, request, query_slug, **kwargs):
+        query = get_object_or_404(Query, resource=self, slug=query_slug)
+        return query.q(request, **kwargs)
+
+    def create_query_url(self):
+        return reverse('opendatasoft-query-new', kwargs={'slug': self.slug})
+
+
+class Query(BaseQuery):
+    resource = models.ForeignKey(
+        to=OpenDataSoft,
+        related_name='queries',
+        verbose_name=_('Resource'))
+    dataset = models.CharField(
+        _('Dataset'),
+        max_length=128, blank=False,
+        help_text=_('dataset to query'),
+        default='world-heritage-unesco-list',
+    )
+    text_template = models.TextField(
+        verbose_name=_('Text template'),
+        help_text=_(
+            "Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}"
+        ),
+        default='{{name_fr}} en/au {{country_fr}}',
+        validators=[validate_template],
+        blank=True
+    )
+
+    delete_view = 'opendatasoft-query-delete'
+    edit_view = 'opendatasoft-query-edit'
+
+    def q(self, request, **kwargs):
+        return self.resource.search(
+            request, dataset=self.dataset, text_template=self.text_template, **kwargs)
+
+    def as_endpoint(self):
+        endpoint = super(Query, self).as_endpoint(path=self.resource.q.endpoint_info.name)
+
+        search_endpoint = self.resource.search.endpoint_info
+        endpoint.func = search_endpoint.func
+        endpoint.show_undocumented_params = False
+
+        # Copy generic params descriptions from original endpoint
+        # if they are not overloaded by the query
+        for param in search_endpoint.parameters:
+            if param in ('dataset', 'text_template') and getattr(self, param):
+                continue
+            endpoint.parameters[param] = search_endpoint.parameters[param]
+        return endpoint
diff --git a/passerelle/apps/opendatasoft/templates/opendatasoft/query_confirm_delete.html b/passerelle/apps/opendatasoft/templates/opendatasoft/query_confirm_delete.html
new file mode 100644
index 00000000..a98a97ec
--- /dev/null
+++ b/passerelle/apps/opendatasoft/templates/opendatasoft/query_confirm_delete.html
@@ -0,0 +1 @@
+{% extends "passerelle/manage/resource_child_confirm_delete.html" %}
diff --git a/passerelle/apps/opendatasoft/templates/opendatasoft/query_form.html b/passerelle/apps/opendatasoft/templates/opendatasoft/query_form.html
new file mode 100644
index 00000000..1c8a99b3
--- /dev/null
+++ b/passerelle/apps/opendatasoft/templates/opendatasoft/query_form.html
@@ -0,0 +1 @@
+{% extends "passerelle/manage/resource_child_form.html" %}
diff --git a/passerelle/apps/opendatasoft/urls.py b/passerelle/apps/opendatasoft/urls.py
new file mode 100644
index 00000000..1018ba18
--- /dev/null
+++ b/passerelle/apps/opendatasoft/urls.py
@@ -0,0 +1,28 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020 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.conf.urls import url
+
+from . import views
+
+management_urlpatterns = [
+    url(r'^(?P<slug>[\w,-]+)/query/new/$',
+        views.QueryNew.as_view(), name='opendatasoft-query-new'),
+    url(r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/$',
+        views.QueryEdit.as_view(), name='opendatasoft-query-edit'),
+    url(r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/delete/$',
+        views.QueryDelete.as_view(), name='opendatasoft-query-delete'),
+]
diff --git a/passerelle/apps/opendatasoft/views.py b/passerelle/apps/opendatasoft/views.py
new file mode 100644
index 00000000..09a55b1a
--- /dev/null
+++ b/passerelle/apps/opendatasoft/views.py
@@ -0,0 +1,47 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020 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 import forms
+from django.views.generic import UpdateView, CreateView, DeleteView
+
+from passerelle.base.mixins import ResourceChildViewMixin
+
+from . import models
+
+
+class QueryForm(forms.ModelForm):
+    class Meta:
+        model = models.Query
+        fields = '__all__'
+        exclude = ['resource']
+
+
+class QueryNew(ResourceChildViewMixin, CreateView):
+    model = models.Query
+    form_class = QueryForm
+
+    def form_valid(self, form):
+        form.instance.resource = self.resource
+        return super(QueryNew, self).form_valid(form)
+
+
+class QueryEdit(ResourceChildViewMixin, UpdateView):
+    model = models.Query
+    form_class = QueryForm
+
+
+class QueryDelete(ResourceChildViewMixin, DeleteView):
+    model = models.Query
diff --git a/passerelle/settings.py b/passerelle/settings.py
index 04173804..e1e0e446 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -141,16 +141,17 @@ INSTALLED_APPS = (
     'passerelle.apps.gdc',
     'passerelle.apps.gesbac',
     'passerelle.apps.jsondatastore',
     'passerelle.apps.sp_fr',
     'passerelle.apps.mdel',
     'passerelle.apps.mdel_ddpacs',
     'passerelle.apps.mobyt',
     'passerelle.apps.okina',
+    'passerelle.apps.opendatasoft',
     'passerelle.apps.opengis',
     'passerelle.apps.orange',
     'passerelle.apps.ovh',
     'passerelle.apps.oxyd',
     'passerelle.apps.pastell',
     'passerelle.apps.phonecalls',
     'passerelle.apps.solis',
     'passerelle.apps.vivaticket',
diff --git a/passerelle/static/css/style.css b/passerelle/static/css/style.css
index d6c9672d..a1a3ae31 100644
--- a/passerelle/static/css/style.css
+++ b/passerelle/static/css/style.css
@@ -176,16 +176,21 @@ li.connector.apientreprise a::before {
 li.connector.dpark a::before {
 	content: "\f1b9";  /* car */
 }
 
 li.connector.cryptor a::before {
 	content: "\f023";  /* lock */
 }
 
+li.connector.opendatasoft a::before {
+	content: "\f1ad"; /* building */
+}
+
+
 li.connector.status-down span.connector-name::after {
 	font-family: FontAwesome;
 	content: "\f00d"; /* times */
 	color: #CD2026;
 	padding-left: 1ex;
 	margin-left: 1ex;
 	border-left: 1px solid #003388;
 }
diff --git a/tests/test_opendatasoft.py b/tests/test_opendatasoft.py
new file mode 100644
index 00000000..5b05c350
--- /dev/null
+++ b/tests/test_opendatasoft.py
@@ -0,0 +1,274 @@
+# -*- coding: utf-8 -*-
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020  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 mock
+import json
+import pytest
+
+import utils
+
+from passerelle.apps.opendatasoft.models import OpenDataSoft, Query
+
+from test_manager import login, admin_user
+
+
+FAKED_CONTENT_Q_SEARCH = json.dumps({
+    "nhits": 76,
+    "parameters": {
+        "dataset": "referentiel-adresse-test",
+        "format": "json",
+        "q": "rue de l'aubepine",
+        "rows": 3,
+        "timezone": "UTC"
+    },
+    "records": [
+        {
+            "datasetid": "referentiel-adresse-test",
+            "fields": {
+                "adresse_complete": "33 RUE DE L'AUBEPINE STRASBOURG",
+                "date_exprt": "2019-10-23",
+                "geo_point": [
+                    48.6060963542,
+                    7.76978279836
+                ],
+                "nom_commun": "Strasbourg",
+                "nom_rue": "RUE DE L'AUBEPINE",
+                "num_com": 482,
+                "numero": "33",
+                "source": u"Ville et Eurométropole de Strasbourg"
+            },
+            "geometry": {
+                "coordinates": [
+                    7.76978279836,
+                    48.6060963542
+                ],
+                "type": "Point"
+            },
+            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
+            "recordid": "e00cf6161e52a4c8fe510b2b74d4952036cb3473"
+        },
+        {
+            "datasetid": "referentiel-adresse-test",
+            "fields": {
+                "adresse_complete": "19 RUE DE L'AUBEPINE LIPSHEIM",
+                "date_exprt": "2019-10-23",
+                "geo_point": [
+                    48.4920620548,
+                    7.66177412454
+                ],
+                "nom_commun": "Lipsheim",
+                "nom_rue": "RUE DE L'AUBEPINE",
+                "num_com": 268,
+                "numero": "19",
+                "source": u"Ville et Eurométropole de Strasbourg"
+            },
+            "geometry": {
+                "coordinates": [
+                    7.66177412454,
+                    48.4920620548
+                ],
+                "type": "Point"
+            },
+            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
+            "recordid": "7cafcd5c692773e8b863587b2d38d6be82e023d8"
+        },
+        {
+            "datasetid": "referentiel-adresse-test",
+            "fields": {
+                "adresse_complete": "29 RUE DE L'AUBEPINE STRASBOURG",
+                "date_exprt": "2019-10-23",
+                "geo_point": [
+                    48.6056497224,
+                    7.76988497729
+                ],
+                "nom_commun": "Strasbourg",
+                "nom_rue": "RUE DE L'AUBEPINE",
+                "num_com": 482,
+                "numero": "29",
+                "source": u"Ville et Eurométropole de Strasbourg"
+            },
+            "geometry": {
+                "coordinates": [
+                    7.76988497729,
+                    48.6056497224
+                ],
+                "type": "Point"
+            },
+            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
+            "recordid": "0984a5e1745701f71c91af73ce764e1f7132e0ff"
+        }
+    ]
+})
+
+FAKED_CONTENT_ID_SEARCH = json.dumps({
+    "nhits": 1,
+    "parameters": {
+        "dataset": "referentiel-adresse-test",
+        "format": "json",
+        "q": "recordid:7cafcd5c692773e8b863587b2d38d6be82e023d8",
+        "rows": 1,
+        "timezone": "UTC"
+    },
+    "records": [
+        {
+            "datasetid": "referentiel-adresse-test",
+            "fields": {
+                "adresse_complete": "19 RUE DE L'AUBEPINE LIPSHEIM",
+                "date_exprt": "2019-10-23",
+                "geo_point": [
+                    48.4920620548,
+                    7.66177412454
+                ],
+                "nom_commun": "Lipsheim",
+                "nom_rue": "RUE DE L'AUBEPINE",
+                "num_com": 268,
+                "numero": "19",
+                u"source": "Ville et Eurométropole de Strasbourg"
+            },
+            "geometry": {
+                "coordinates": [
+                    7.66177412454,
+                    48.4920620548
+                ],
+                "type": "Point"
+            },
+            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
+            "recordid": "7cafcd5c692773e8b863587b2d38d6be82e023d8"
+        }
+    ]
+})
+
+
+@pytest.fixture
+def connector(db):
+    return utils.setup_access_rights(OpenDataSoft.objects.create(
+        slug='my_connector',
+        api_key='my_secret',
+    ))
+
+
+@pytest.fixture
+def query(connector):
+    return Query.objects.create(
+        resource=connector,
+        name='Référenciel adresses de test',
+        slug='my_query',
+        description='Rechercher une adresse',
+        dataset='referentiel-adresse-test',
+        text_template='{{numero}} {{nom_rue|safe}} {{nom_commun}}',
+    )
+
+
+def test_views(db, admin_user, app, connector):
+    app = login(app)
+    resp = app.get('/opendatasoft/my_connector/', status=200)
+    resp = resp.click('New Query')
+    resp.form['name'] = 'my query'
+    resp.form['slug'] = 'my-query'
+    resp = resp.form.submit()
+    resp = resp.follow()
+    assert [x.text for x in resp.html.find_all('span', {'class': 'description'})]
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_search_using_q(mocked_get, app, connector):
+    endpoint = utils.generic_endpoint_url('opendatasoft', 'search', slug=connector.slug)
+    assert endpoint == '/opendatasoft/my_connector/search'
+    params = {
+        'dataset': 'referentiel-adresse-test',
+        'text_template': '{{numero}} {{nom_rue|safe}} {{nom_commun}}',
+        'q': "rue de l'aubepine",
+        'rows': 3,
+    }
+    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_Q_SEARCH, status_code=200)
+    resp = app.get(endpoint, params=params, status=200)
+    assert not resp.json['err']
+    assert len(resp.json['data']) == 3
+    # order is keept
+    assert resp.json['data'][0] == {
+        'id': 'e00cf6161e52a4c8fe510b2b74d4952036cb3473',
+        'text': "33 RUE DE L'AUBEPINE Strasbourg"
+    }
+    assert resp.json['data'][1] == {
+        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
+        'text': "19 RUE DE L'AUBEPINE Lipsheim"
+    }
+    assert resp.json['data'][2] == {
+        'id': '0984a5e1745701f71c91af73ce764e1f7132e0ff',
+        'text': "29 RUE DE L'AUBEPINE Strasbourg"
+    }
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_search_using_id(mocked_get, app, connector):
+    endpoint = utils.generic_endpoint_url('opendatasoft', 'search', slug=connector.slug)
+    assert endpoint == '/opendatasoft/my_connector/search'
+    params = {
+        'dataset': 'referentiel-adresse-test',
+        'text_template': '{{numero}} {{nom_rue|safe}} {{nom_commun}}',
+        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
+    }
+    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_ID_SEARCH, status_code=200)
+    resp = app.get(endpoint, params=params, status=200)
+    assert resp.json == {
+        'err': 0,
+        'data': [{
+            'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
+            'text': "19 RUE DE L'AUBEPINE Lipsheim"
+        }]}
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_query_q_using_q(mocked_get, app, query):
+    endpoint = '/opendatasoft/my_connector/q/my_query/'
+    params = {
+        'q': "rue de l'aubepine",
+        'rows': 3,
+    }
+    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_Q_SEARCH, status_code=200)
+    resp = app.get(endpoint, params=params, status=200)
+    assert not resp.json['err']
+    assert len(resp.json['data']) == 3
+    # order is keept
+    assert resp.json['data'][0] == {
+        'id': 'e00cf6161e52a4c8fe510b2b74d4952036cb3473',
+        'text': "33 RUE DE L'AUBEPINE Strasbourg"
+    }
+    assert resp.json['data'][1] == {
+        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
+        'text': "19 RUE DE L'AUBEPINE Lipsheim"
+    }
+    assert resp.json['data'][2] == {
+        'id': '0984a5e1745701f71c91af73ce764e1f7132e0ff',
+        'text': "29 RUE DE L'AUBEPINE Strasbourg"
+    }
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_query_q_using_id(mocked_get, app, query):
+    endpoint = '/opendatasoft/my_connector/q/my_query/'
+    params = {
+        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
+    }
+    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_ID_SEARCH, status_code=200)
+    resp = app.get(endpoint, params=params, status=200)
+    assert resp.json == {
+        'err': 0,
+        'data': [{
+            'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
+            'text': "19 RUE DE L'AUBEPINE Lipsheim"
+        }]}
-- 
2.25.1

