udata 14.0.0__py3-none-any.whl → 14.5.1.dev6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of udata might be problematic. Click here for more details.

Files changed (152) hide show
  1. udata/api/__init__.py +2 -0
  2. udata/api_fields.py +35 -4
  3. udata/app.py +18 -20
  4. udata/auth/__init__.py +29 -6
  5. udata/auth/forms.py +2 -2
  6. udata/auth/views.py +13 -6
  7. udata/commands/dcat.py +1 -1
  8. udata/commands/serve.py +3 -11
  9. udata/commands/tests/test_fixtures.py +9 -9
  10. udata/core/access_type/api.py +1 -1
  11. udata/core/access_type/constants.py +12 -8
  12. udata/core/activity/api.py +5 -6
  13. udata/core/badges/tests/test_commands.py +6 -6
  14. udata/core/csv.py +5 -0
  15. udata/core/dataservices/api.py +8 -1
  16. udata/core/dataservices/apiv2.py +2 -5
  17. udata/core/dataservices/models.py +5 -2
  18. udata/core/dataservices/rdf.py +2 -1
  19. udata/core/dataservices/tasks.py +13 -2
  20. udata/core/dataset/api.py +10 -0
  21. udata/core/dataset/models.py +6 -6
  22. udata/core/dataset/permissions.py +31 -0
  23. udata/core/dataset/rdf.py +8 -2
  24. udata/core/dataset/tasks.py +23 -7
  25. udata/core/discussions/api.py +15 -1
  26. udata/core/discussions/models.py +6 -0
  27. udata/core/legal/__init__.py +0 -0
  28. udata/core/legal/mails.py +128 -0
  29. udata/core/organization/api.py +16 -5
  30. udata/core/organization/apiv2.py +2 -3
  31. udata/core/organization/mails.py +1 -1
  32. udata/core/organization/models.py +15 -2
  33. udata/core/organization/notifications.py +84 -0
  34. udata/core/organization/permissions.py +1 -1
  35. udata/core/organization/tasks.py +3 -0
  36. udata/core/pages/tests/test_api.py +32 -0
  37. udata/core/post/api.py +24 -69
  38. udata/core/post/models.py +84 -16
  39. udata/core/post/tests/test_api.py +24 -1
  40. udata/core/reports/api.py +18 -0
  41. udata/core/reports/models.py +42 -2
  42. udata/core/reuse/api.py +8 -0
  43. udata/core/reuse/apiv2.py +2 -5
  44. udata/core/reuse/models.py +1 -1
  45. udata/core/reuse/tasks.py +7 -0
  46. udata/core/spatial/forms.py +2 -2
  47. udata/core/topic/models.py +8 -2
  48. udata/core/user/api.py +10 -3
  49. udata/core/user/models.py +12 -2
  50. udata/features/notifications/api.py +7 -18
  51. udata/features/notifications/models.py +56 -0
  52. udata/features/notifications/tasks.py +25 -0
  53. udata/flask_mongoengine/engine.py +0 -4
  54. udata/flask_mongoengine/pagination.py +1 -1
  55. udata/frontend/markdown.py +2 -1
  56. udata/harvest/actions.py +21 -1
  57. udata/harvest/api.py +25 -8
  58. udata/harvest/backends/base.py +27 -1
  59. udata/harvest/backends/ckan/harvesters.py +11 -2
  60. udata/harvest/backends/dcat.py +4 -1
  61. udata/harvest/commands.py +33 -0
  62. udata/harvest/filters.py +17 -6
  63. udata/harvest/models.py +16 -0
  64. udata/harvest/permissions.py +27 -0
  65. udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
  66. udata/harvest/tests/test_actions.py +58 -5
  67. udata/harvest/tests/test_api.py +276 -122
  68. udata/harvest/tests/test_base_backend.py +86 -1
  69. udata/harvest/tests/test_dcat_backend.py +81 -10
  70. udata/harvest/tests/test_filters.py +6 -0
  71. udata/i18n.py +1 -4
  72. udata/mail.py +19 -1
  73. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  74. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  75. udata/mongo/slug_fields.py +1 -1
  76. udata/rdf.py +58 -10
  77. udata/routing.py +2 -2
  78. udata/settings.py +11 -0
  79. udata/tasks.py +1 -0
  80. udata/templates/mail/message.html +5 -31
  81. udata/tests/__init__.py +27 -2
  82. udata/tests/api/__init__.py +108 -21
  83. udata/tests/api/test_activities_api.py +36 -0
  84. udata/tests/api/test_auth_api.py +121 -95
  85. udata/tests/api/test_base_api.py +7 -4
  86. udata/tests/api/test_datasets_api.py +50 -19
  87. udata/tests/api/test_organizations_api.py +192 -197
  88. udata/tests/api/test_reports_api.py +157 -0
  89. udata/tests/api/test_reuses_api.py +147 -147
  90. udata/tests/api/test_security_api.py +12 -12
  91. udata/tests/api/test_swagger.py +4 -4
  92. udata/tests/api/test_tags_api.py +8 -8
  93. udata/tests/api/test_user_api.py +1 -1
  94. udata/tests/apiv2/test_search.py +30 -0
  95. udata/tests/apiv2/test_swagger.py +4 -4
  96. udata/tests/cli/test_cli_base.py +8 -9
  97. udata/tests/dataservice/test_dataservice_tasks.py +29 -0
  98. udata/tests/dataset/test_dataset_commands.py +4 -4
  99. udata/tests/dataset/test_dataset_model.py +66 -26
  100. udata/tests/dataset/test_dataset_rdf.py +99 -5
  101. udata/tests/dataset/test_dataset_tasks.py +25 -0
  102. udata/tests/frontend/test_auth.py +58 -1
  103. udata/tests/frontend/test_csv.py +0 -3
  104. udata/tests/helpers.py +31 -27
  105. udata/tests/organization/test_notifications.py +67 -2
  106. udata/tests/plugin.py +6 -261
  107. udata/tests/search/test_search_integration.py +33 -0
  108. udata/tests/site/test_site_csv_exports.py +22 -10
  109. udata/tests/test_activity.py +9 -9
  110. udata/tests/test_api_fields.py +10 -0
  111. udata/tests/test_dcat_commands.py +2 -2
  112. udata/tests/test_discussions.py +5 -5
  113. udata/tests/test_legal_mails.py +359 -0
  114. udata/tests/test_migrations.py +21 -21
  115. udata/tests/test_notifications.py +15 -57
  116. udata/tests/test_notifications_task.py +43 -0
  117. udata/tests/test_owned.py +81 -1
  118. udata/tests/test_storages.py +25 -19
  119. udata/tests/test_topics.py +77 -61
  120. udata/tests/test_uris.py +33 -0
  121. udata/tests/workers/test_jobs_commands.py +23 -23
  122. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  123. udata/translations/ar/LC_MESSAGES/udata.po +187 -108
  124. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  125. udata/translations/de/LC_MESSAGES/udata.po +187 -108
  126. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  127. udata/translations/es/LC_MESSAGES/udata.po +187 -108
  128. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  129. udata/translations/fr/LC_MESSAGES/udata.po +188 -109
  130. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  131. udata/translations/it/LC_MESSAGES/udata.po +187 -108
  132. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  133. udata/translations/pt/LC_MESSAGES/udata.po +187 -108
  134. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  135. udata/translations/sr/LC_MESSAGES/udata.po +187 -108
  136. udata/translations/udata.pot +215 -106
  137. udata/uris.py +0 -2
  138. udata-14.5.1.dev6.dist-info/METADATA +109 -0
  139. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/RECORD +143 -140
  140. udata/core/post/forms.py +0 -30
  141. udata/flask_mongoengine/json.py +0 -38
  142. udata/templates/mail/base.html +0 -105
  143. udata/templates/mail/base.txt +0 -6
  144. udata/templates/mail/button.html +0 -3
  145. udata/templates/mail/layouts/1-column.html +0 -19
  146. udata/templates/mail/layouts/2-columns.html +0 -20
  147. udata/templates/mail/layouts/center-panel.html +0 -16
  148. udata-14.0.0.dist-info/METADATA +0 -132
  149. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/WHEEL +0 -0
  150. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/entry_points.txt +0 -0
  151. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/licenses/LICENSE +0 -0
  152. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  from flask import current_app, url_for
2
2
  from flask_security.utils import hash_data
3
3
 
4
- from udata.core.user.factories import AdminFactory
4
+ from udata.core.user.factories import AdminFactory, UserFactory
5
5
  from udata.tests.api import APITestCase
6
6
 
7
7
 
@@ -22,3 +22,60 @@ class AuthTest(APITestCase):
22
22
 
23
23
  user.reload()
24
24
  assert user.email == new_email
25
+
26
+ def test_change_mail_already_taken(self):
27
+ """Should not allow changing email to one already taken by another user"""
28
+ user = self.login(AdminFactory())
29
+ original_email = user.email
30
+
31
+ # Create another user with the target email
32
+ existing_user = UserFactory(email="taken@example.com")
33
+ new_email = existing_user.email
34
+
35
+ security = current_app.extensions["security"]
36
+
37
+ data = [str(user.fs_uniquifier), hash_data(user.email), new_email]
38
+ token = security.confirm_serializer.dumps(data)
39
+ confirmation_link = url_for("security.confirm_change_email", token=token)
40
+
41
+ resp = self.get(confirmation_link)
42
+ assert resp.status_code == 302
43
+ assert "change_email_already_taken" in resp.location
44
+
45
+ # Email should not have changed
46
+ user.reload()
47
+ assert user.email == original_email
48
+
49
+ def test_change_mail_after_password_change(self):
50
+ """Changing password rotates fs_uniquifier and invalidates email change token"""
51
+ user = UserFactory(password="Password123")
52
+ self.login(user)
53
+ old_uniquifier = user.fs_uniquifier
54
+
55
+ new_email = "new@example.com"
56
+
57
+ security = current_app.extensions["security"]
58
+
59
+ data = [str(user.fs_uniquifier), hash_data(user.email), new_email]
60
+ token = security.confirm_serializer.dumps(data)
61
+ confirmation_link = url_for("security.confirm_change_email", token=token)
62
+
63
+ # Change password via API
64
+ resp = self.post(
65
+ url_for("security.change_password"),
66
+ {
67
+ "password": "Password123",
68
+ "new_password": "NewPassword456",
69
+ "new_password_confirm": "NewPassword456",
70
+ "submit": True,
71
+ },
72
+ )
73
+ assert resp.status_code == 200, f"Password change failed: {resp.data}"
74
+
75
+ user.reload()
76
+ assert user.fs_uniquifier != old_uniquifier, "fs_uniquifier should have changed"
77
+
78
+ # Now try to use the email change link - should fail
79
+ resp = self.get(confirmation_link)
80
+ assert resp.status_code == 302
81
+ assert "change_email_invalid" in resp.location
@@ -269,7 +269,6 @@ class CsvTest(APITestCase):
269
269
 
270
270
  self.assert200(response)
271
271
  self.assertEqual(response.mimetype, "text/csv")
272
- self.assertEqual(response.charset, "utf-8")
273
272
 
274
273
  csvfile = StringIO(response.data.decode("utf8"))
275
274
  reader = csv.get_reader(csvfile)
@@ -327,7 +326,6 @@ class CsvTest(APITestCase):
327
326
 
328
327
  self.assert200(response)
329
328
  self.assertEqual(response.mimetype, "text/csv")
330
- self.assertEqual(response.charset, "utf-8")
331
329
 
332
330
  csvfile = StringIO(response.data.decode("utf8"))
333
331
  reader = csv.get_reader(csvfile)
@@ -349,7 +347,6 @@ class CsvTest(APITestCase):
349
347
 
350
348
  self.assert200(response)
351
349
  self.assertEqual(response.mimetype, "text/csv")
352
- self.assertEqual(response.charset, "utf-8")
353
350
 
354
351
  csvfile = StringIO(response.data.decode("utf8"))
355
352
  reader = csv.get_reader(csvfile)
udata/tests/helpers.py CHANGED
@@ -4,7 +4,7 @@ from datetime import timedelta
4
4
  from io import BytesIO
5
5
  from urllib.parse import parse_qs, urlparse
6
6
 
7
- import mock
7
+ import pytest
8
8
  from flask import current_app, json
9
9
  from flask_security.babel import FsDomain
10
10
  from PIL import Image
@@ -12,6 +12,11 @@ from PIL import Image
12
12
  from udata.core.spatial.factories import GeoZoneFactory
13
13
  from udata.mail import mail_sent
14
14
 
15
+ requires_search_service = pytest.mark.skipif(
16
+ not os.environ.get("UDATA_TEST_SEARCH_INTEGRATION"),
17
+ reason="Set UDATA_TEST_SEARCH_INTEGRATION=1 to run search integration tests",
18
+ )
19
+
15
20
 
16
21
  def assert_equal_dates(datetime1, datetime2, limit=1): # Seconds.
17
22
  """Lax date comparison, avoid comparing milliseconds and seconds."""
@@ -35,51 +40,50 @@ def assert_json_equal(first, second):
35
40
 
36
41
 
37
42
  @contextmanager
38
- def mock_signals(callback, *signals):
43
+ def mock_signals(*signals):
39
44
  __tracebackhide__ = True
40
- specs = []
41
45
 
42
- def handler(sender, **kwargs):
43
- pass
46
+ callbacks_by_signal = {}
47
+ calls_kwargs_by_signal = {}
44
48
 
45
- for signal in signals:
46
- m = mock.Mock(spec=handler)
47
- signal.connect(m, weak=False)
48
- specs.append((signal, m))
49
+ for requestSignal in signals:
50
+ # We capture requestSignal with a default argument
51
+ def callback(*args, requestSignal=requestSignal, **kwargs):
52
+ calls_kwargs_by_signal.setdefault(requestSignal, [])
53
+ calls_kwargs_by_signal[requestSignal].append(kwargs)
54
+
55
+ callbacks_by_signal[requestSignal] = callback
56
+ requestSignal.connect(callback, weak=False)
49
57
 
50
- yield
58
+ yield calls_kwargs_by_signal
51
59
 
52
- for signal, mock_handler in specs:
53
- signal.disconnect(mock_handler)
54
- signal_name = getattr(signal, "name", str(signal))
55
- callback(signal_name, mock_handler)
60
+ for sig in signals:
61
+ sig.disconnect(callbacks_by_signal[sig])
56
62
 
57
63
 
58
64
  @contextmanager
59
65
  def assert_emit(*signals, assertions_callback=None):
60
66
  __tracebackhide__ = True
61
- msg = 'Signal "{0}" should have been emitted'
62
67
 
63
- def callback(name, handler):
64
- assert handler.called, msg.format(name)
65
- if assertions_callback is not None:
66
- assertions_callback(handler.call_args)
67
-
68
- with mock_signals(callback, *signals):
68
+ with mock_signals(*signals) as calls_kwargs_by_signal:
69
69
  yield
70
70
 
71
+ for signal in signals:
72
+ assert signal in calls_kwargs_by_signal, f'Signal "{signal}" should have been emitted'
73
+ if assertions_callback is not None:
74
+ for kwargs in calls_kwargs_by_signal[signal]:
75
+ assertions_callback(kwargs)
76
+
71
77
 
72
78
  @contextmanager
73
79
  def assert_not_emit(*signals):
74
80
  __tracebackhide__ = True
75
- msg = 'Signal "{0}" should NOT have been emitted'
76
-
77
- def callback(name, handler):
78
- assert not handler.called, msg.format(name)
79
-
80
- with mock_signals(callback, *signals):
81
+ with mock_signals(*signals) as calls_args_by_signal:
81
82
  yield
82
83
 
84
+ for signal in signals:
85
+ assert signal not in calls_args_by_signal, f'Signal "{signal}" should not have been emitted'
86
+
83
87
 
84
88
  @contextmanager
85
89
  def capture_mails():
@@ -1,8 +1,11 @@
1
1
  from udata.core.organization.factories import OrganizationFactory
2
- from udata.core.organization.notifications import membership_request_notifications
2
+ from udata.core.organization.notifications import (
3
+ membership_request_notifications,
4
+ )
3
5
  from udata.core.user.factories import UserFactory
6
+ from udata.features.notifications.models import Notification
4
7
  from udata.models import Member, MembershipRequest
5
- from udata.tests.api import PytestOnlyDBTestCase
8
+ from udata.tests.api import DBTestCase, PytestOnlyDBTestCase
6
9
  from udata.tests.helpers import assert_equal_dates
7
10
 
8
11
 
@@ -27,3 +30,65 @@ class OrganizationNotificationsTest(PytestOnlyDBTestCase):
27
30
  assert details["user"]["id"] == applicant.id
28
31
  assert details["user"]["fullname"] == applicant.fullname
29
32
  assert details["user"]["avatar"] == str(applicant.avatar)
33
+
34
+
35
+ class MembershipRequestNotificationTest(DBTestCase):
36
+ def test_notification_created_for_admins_only(self):
37
+ """Notifications are created for all admin users, not editors"""
38
+ admin1 = UserFactory()
39
+ admin2 = UserFactory()
40
+ editor = UserFactory()
41
+ applicant = UserFactory()
42
+ members = [
43
+ Member(user=editor, role="editor"),
44
+ Member(user=admin1, role="admin"),
45
+ Member(user=admin2, role="admin"),
46
+ ]
47
+ org = OrganizationFactory(members=members)
48
+
49
+ request = MembershipRequest(user=applicant, comment="test")
50
+ org.add_membership_request(request)
51
+
52
+ notifications = Notification.objects.all()
53
+ assert len(notifications) == 2
54
+
55
+ admin_users = [notif.user for notif in notifications]
56
+ self.assertIn(admin1, admin_users)
57
+ self.assertIn(admin2, admin_users)
58
+
59
+ for notification in notifications:
60
+ assert notification.details.request_organization == org
61
+ assert notification.details.request_user == applicant
62
+ assert_equal_dates(notification.created_at, request.created)
63
+
64
+ def test_no_duplicate_notifications(self):
65
+ """Duplicate notifications are not created on subsequent saves"""
66
+ admin = UserFactory()
67
+ applicant = UserFactory()
68
+ org = OrganizationFactory(members=[Member(user=admin, role="admin")])
69
+
70
+ request = MembershipRequest(user=applicant, comment="test")
71
+ org.add_membership_request(request)
72
+ org.add_membership_request(request)
73
+
74
+ assert Notification.objects.count() == 1
75
+
76
+ def test_multiple_requests_create_separate_notifications(self):
77
+ """Multiple requests from different users create separate notifications"""
78
+ admin = UserFactory()
79
+ applicant1 = UserFactory()
80
+ applicant2 = UserFactory()
81
+ org = OrganizationFactory(members=[Member(user=admin, role="admin")])
82
+
83
+ request1 = MembershipRequest(user=applicant1, comment="test 1")
84
+ org.add_membership_request(request1)
85
+
86
+ request2 = MembershipRequest(user=applicant2, comment="test 2")
87
+ org.add_membership_request(request2)
88
+
89
+ notifications = Notification.objects.all()
90
+ assert len(notifications) == 2
91
+
92
+ request_users = [notif.details.request_user for notif in notifications]
93
+ self.assertIn(applicant1, request_users)
94
+ self.assertIn(applicant2, request_users)
udata/tests/plugin.py CHANGED
@@ -1,147 +1,20 @@
1
- import shlex
2
- from contextlib import contextmanager
3
-
4
1
  import pytest
5
- from flask import current_app, json, template_rendered, url_for
6
- from flask.testing import FlaskClient
7
- from flask_principal import Identity, identity_changed
8
- from lxml import etree
9
-
10
- from udata.core.user.factories import UserFactory
11
-
12
- from .helpers import assert200, assert_command_ok
13
-
14
-
15
- class TestClient(FlaskClient):
16
- """
17
- The goal of these `post`, `put` and `delete` functions is to
18
- switch from `data` in kwargs to `data` in args and be able to
19
- `client.post(url, data)` without doing `client.post(url, data=data)`
20
-
21
- Same as in :TestClientOverride
22
- """
23
-
24
- def post(self, url, data=None, **kwargs):
25
- return super(TestClient, self).post(url, data=data, **kwargs)
26
-
27
- def put(self, url, data=None, **kwargs):
28
- return super(TestClient, self).put(url, data=data, **kwargs)
29
-
30
- def delete(self, url, data=None, **kwargs):
31
- return super(TestClient, self).delete(url, data=data, **kwargs)
32
-
33
- def login(self, user=None):
34
- user = user or UserFactory()
35
- with self.session_transaction() as session:
36
- # Since flask-security-too 4.0.0, the user.fs_uniquifier is used instead of user.id for auth
37
- user_id = getattr(user, current_app.login_manager.id_attribute)()
38
- session["user_id"] = user_id
39
- session["_fresh"] = True
40
- session["_id"] = current_app.login_manager._session_identifier_generator()
41
- current_app.login_manager._update_request_context_with_user(user)
42
- identity_changed.send(current_app._get_current_object(), identity=Identity(user.id))
43
- return user
44
-
45
- def logout(self):
46
- with self.session_transaction() as session:
47
- del session["user_id"]
48
- del session["_fresh"]
49
- del session["_id"]
50
2
 
51
3
 
52
4
  @pytest.fixture
53
- def client(app):
54
- """
55
- Fixes https://github.com/pytest-dev/pytest-flask/issues/42
56
- """
57
- return app.test_client()
58
-
59
-
60
- class ApiClient(object):
61
- def __init__(self, client):
62
- self.client = client
63
- self._user = None
64
-
65
- def login(self, *args, **kwargs):
66
- return self.client.login(*args, **kwargs)
67
-
68
- @contextmanager
69
- def user(self, user=None):
70
- self._user = user or UserFactory()
71
- if not self._user.apikey:
72
- self._user.generate_api_key()
73
- self._user.save()
74
- yield self._user
75
-
76
- def perform(self, verb, url, **kwargs):
77
- headers = kwargs.pop("headers", {})
78
- headers["Content-Type"] = "application/json"
79
-
80
- data = kwargs.get("data")
81
- if data is not None:
82
- data = json.dumps(data)
83
- headers["Content-Length"] = len(data)
84
- kwargs["data"] = data
85
-
86
- if self._user:
87
- headers["X-API-KEY"] = kwargs.get("X-API-KEY", self._user.apikey)
88
-
89
- kwargs["headers"] = headers
90
- method = getattr(self.client, verb)
91
- return method(url, **kwargs)
92
-
93
- def get(self, url, *args, **kwargs):
94
- return self.perform("get", url, *args, **kwargs)
95
-
96
- def post(self, url, data=None, json=True, *args, **kwargs):
97
- if not json:
98
- return self.client.post(url, data or {}, *args, **kwargs)
99
- return self.perform("post", url, data=data or {}, *args, **kwargs)
100
-
101
- def put(self, url, data=None, json=True, *args, **kwargs):
102
- if not json:
103
- return self.client.put(url, data or {}, *args, **kwargs)
104
- return self.perform("put", url, data=data or {}, *args, **kwargs)
105
-
106
- def patch(self, url, data=None, json=True, *args, **kwargs):
107
- if not json:
108
- return self.client.patch(url, data or {}, *args, **kwargs)
109
- return self.perform("patch", url, data=data or {}, *args, **kwargs)
110
-
111
- def delete(self, url, data=None, *args, **kwargs):
112
- return self.perform("delete", url, data=data or {}, *args, **kwargs)
113
-
114
- def options(self, url, data=None, *args, **kwargs):
115
- return self.perform("options", url, data=data or {}, *args, **kwargs)
116
-
117
-
118
- @pytest.fixture
119
- def api(client):
120
- api_client = ApiClient(client)
121
- return api_client
122
-
123
-
124
- @pytest.fixture(name="cli")
125
- def cli_fixture(app):
126
- def mock_runner(*args, **kwargs):
127
- from udata.commands import cli
128
-
129
- if len(args) == 1 and " " in args[0]:
130
- args = shlex.split(args[0])
131
- runner = app.test_cli_runner()
132
- result = runner.invoke(cli, args, catch_exceptions=False)
133
- if kwargs.get("check", True):
134
- assert_command_ok(result)
135
- return result
5
+ def rmock():
6
+ """A requests-mock fixture"""
7
+ import requests_mock
136
8
 
137
- return mock_runner
9
+ with requests_mock.Mocker() as m:
10
+ m.ANY = requests_mock.ANY
11
+ yield m
138
12
 
139
13
 
140
14
  @pytest.fixture
141
15
  def instance_path(app, tmpdir):
142
16
  """Use temporary application instance_path"""
143
17
  from udata.core import storages
144
- from udata.core.storages.views import blueprint
145
18
 
146
19
  app.instance_path = str(tmpdir)
147
20
  app.config["FS_ROOT"] = str(tmpdir / "fs")
@@ -152,133 +25,5 @@ def instance_path(app, tmpdir):
152
25
  app.config.pop(key.format("ROOT"), None)
153
26
 
154
27
  storages.init_app(app)
155
- app.register_blueprint(blueprint, name="test-storage")
156
28
 
157
29
  return tmpdir
158
-
159
-
160
- class ContextVariableDoesNotExist(Exception):
161
- pass
162
-
163
-
164
- class TemplateRecorder:
165
- @contextmanager
166
- def capture(self):
167
- self.templates = []
168
- template_rendered.connect(self._add_template)
169
- yield
170
- template_rendered.disconnect(self._add_template)
171
-
172
- def _add_template(self, app, template, context):
173
- self.templates.append((template, context))
174
-
175
- def assert_used(self, name):
176
- """
177
- Checks if a given template is used in the request.
178
-
179
- :param name: template name
180
- """
181
- __tracebackhide__ = True
182
-
183
- used_templates = []
184
-
185
- for template, context in self.templates:
186
- if template.name == name:
187
- return True
188
-
189
- used_templates.append(template)
190
-
191
- msg = "Template %s not used. Templates were used: %s" % (
192
- name,
193
- " ".join(repr(used_templates)),
194
- )
195
- raise AssertionError(msg)
196
-
197
- def get_context_variable(self, name):
198
- """
199
- Returns a variable from the context passed to the template.
200
-
201
- :param name: name of variable
202
- :raises ContextVariableDoesNotExist: if does not exist.
203
- """
204
- for template, context in self.templates:
205
- if name in context:
206
- return context[name]
207
- raise ContextVariableDoesNotExist()
208
-
209
-
210
- @pytest.fixture
211
- def templates():
212
- recorder = TemplateRecorder()
213
- with recorder.capture():
214
- yield recorder
215
-
216
-
217
- @pytest.fixture
218
- def httpretty():
219
- import httpretty
220
-
221
- httpretty.reset()
222
- httpretty.enable()
223
- yield httpretty
224
- httpretty.disable()
225
-
226
-
227
- @pytest.fixture
228
- def rmock():
229
- """A requests-mock fixture"""
230
- import requests_mock
231
-
232
- with requests_mock.Mocker() as m:
233
- m.ANY = requests_mock.ANY
234
- yield m
235
-
236
-
237
- class SitemapClient:
238
- # Needed for lxml XPath not supporting default namespace
239
- NAMESPACES = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
240
- MISMATCH = 'URL "{0}" {1} mismatch: expected "{2}" found "{3}"'
241
-
242
- def __init__(self, client):
243
- self.client = client
244
- self._sitemap = None
245
-
246
- def fetch(self, secure=False):
247
- base_url = "{0}://local.test".format("https" if secure else "http")
248
- response = self.client.get("sitemap.xml", base_url=base_url)
249
- assert200(response)
250
- self._sitemap = etree.fromstring(response.data)
251
- return self._sitemap
252
-
253
- def xpath(self, query):
254
- return self._sitemap.xpath(query, namespaces=self.NAMESPACES)
255
-
256
- def get_by_url(self, endpoint, **kwargs):
257
- url = url_for(endpoint, _external=True, **kwargs)
258
- query = 's:url[s:loc="{url}"]'.format(url=url)
259
- result = self.xpath(query)
260
- return result[0] if result else None
261
-
262
- def assert_url(self, url, priority, changefreq):
263
- """
264
- Check than a URL is present in the sitemap
265
- with given `priority` and `changefreq`
266
- """
267
- __tracebackhide__ = True
268
- r = url.xpath("s:priority", namespaces=self.NAMESPACES)
269
- assert len(r) == 1, 'URL "{0}" should have one priority'.format(url)
270
- found = r[0].text
271
- msg = self.MISMATCH.format(url, "priority", priority, found)
272
- assert found == str(priority), msg
273
-
274
- r = url.xpath("s:changefreq", namespaces=self.NAMESPACES)
275
- assert len(r) == 1, 'URL "{0}" should have one changefreq'.format(url)
276
- found = r[0].text
277
- msg = self.MISMATCH.format(url, "changefreq", changefreq, found)
278
- assert found == changefreq, msg
279
-
280
-
281
- @pytest.fixture
282
- def sitemap(client):
283
- sitemap_client = SitemapClient(client)
284
- return sitemap_client
@@ -0,0 +1,33 @@
1
+ import time
2
+
3
+ import pytest
4
+
5
+ from udata.core.dataset.factories import DatasetFactory
6
+ from udata.tests.api import APITestCase
7
+ from udata.tests.helpers import requires_search_service
8
+
9
+
10
+ @requires_search_service
11
+ @pytest.mark.options(SEARCH_SERVICE_API_URL="http://localhost:5000/api/1/", AUTO_INDEX=True)
12
+ class SearchIntegrationTest(APITestCase):
13
+ """Integration tests that require a running search-service and Elasticsearch."""
14
+
15
+ def test_dataset_fuzzy_search(self):
16
+ """
17
+ Test that Elasticsearch fuzzy search works.
18
+
19
+ A typo in the search query ("spectakulaire" instead of "spectaculaire")
20
+ should still find the dataset thanks to ES fuzzy matching.
21
+ """
22
+ DatasetFactory(title="Données spectaculaires sur les transports")
23
+
24
+ # Small delay to let ES index the document
25
+ time.sleep(1)
26
+
27
+ # Search with a typo - only ES fuzzy search can handle this
28
+ response = self.get("/api/2/datasets/search/?q=spectakulaire")
29
+ self.assert200(response)
30
+ assert response.json["total"] >= 1
31
+
32
+ titles = [d["title"] for d in response.json["data"]]
33
+ assert "Données spectaculaires sur les transports" in titles