mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-18 07:37:56 +02:00
Integrate Find (#1834)
## Purpose integrate Find to Docs ## Proposal - [x] ✨ add a `useSeachDocs` hook in charged of calling the search endpoint. - [x] ✨ add a optional `path` param to the `search` route. This param represents the parent document path in case of a sub-documents (descendants) search. - [x] ⚡️return Indexer results directly without DB calls to retrieve the Document objects. All informations necessary for display are indexed in Find. We can skip the DB calls and improve performance. - [x] ♻️ refactor react `DocSearchContent` components. `DocSearchContent` and `DocSearchSubContent` are now merged a unique component handling all search scenarios and relying on the unique `search` route. - [x] 🔥remove pagination logic in the Indexer. Removing the DB calls also removes the DRF queryset object which handles the pagination. Also we consider pagination not to be necessary for search v1. - [x] 🔥remove the `document/<document_id>/descendants` route. This route is not used anymore. The logic of finding the descendants are moved to the internal `_list_descendants` method. This method is based on the parent `path` instead of the parent `id` which has some consequence about the user access management. Relying on the path prevents the use of the `self.get_object()` method which used to handle the user access logic. - [x] ✨handle fallback logic on DRF based title search in case of non-configured, badly configured or failing at run time indexer. - [x] ✨handle language extension in `title` field. Find returns titles with a language extension (ex: `{ title.fr: "rapport d'activité" }` instead of `{ "title": "rapport d'activité" }`. - [x] 🔧 add a `common.test` file to allow running the tests without docker - [x] ♻️ rename `SearchIndexer` -> `FindDocumentIndexer`. This class has to do with Find in particular and the convention is more coherent with `BaseDocumentIndexer` - [x] ♻️ rename `SEARCH_INDEXER_URL` -> `INDEXING_URL` and `SEARCH_INDEXER_QUERY_URL` -> `SEARCH_URL`. I found the original names very confusing. - [x] 🔧 update the environment variables to activate the FindDocumentIndexer. - [x] ✨automate the generation of encryption key during bootstrap. OIDC_STORE_REFRESH_TOKEN_KEY is a mandatory secret key. We can not push it on Github and we want any contributor to be able to run the app by only running the `make bootstrap`. We chose to generate and wright it into the `common.local` during bootstrap. ## External contributions Thank you for your contribution! 🎉 Please ensure the following items are checked before submitting your pull request: - [x] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) - [x] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md) - [x] I have signed off my commits with `git commit --signoff` (DCO compliance) - [x] I have signed my commits with my SSH or GPG key (`git commit -S`) - [x] My commit messages follow the required format: `<gitmoji>(type) title description` - [x] I have added a changelog entry under `## [Unreleased]` section (if noticeable change) - [x] I have added corresponding tests for new features or bug fixes (if applicable) --------- Signed-off-by: charles <charles.englebert@protonmail.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -19,7 +19,7 @@ from core.services.search_indexers import SearchIndexer
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
@@ -36,7 +36,7 @@ def test_index():
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
@@ -39,12 +39,10 @@ def indexer_settings_fixture(settings):
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
settings.INDEXING_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_URL = "http://localhost:8081/api/v1.0/documents/search/"
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield settings
|
||||
|
||||
@@ -16,7 +16,16 @@ fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_list_filter_and_access_rights():
|
||||
@pytest.mark.parametrize(
|
||||
"title_search_field",
|
||||
# for integration with indexer search we must have
|
||||
# the same filtering behaviour with "q" and "title" parameters
|
||||
[
|
||||
("title"),
|
||||
("q"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_list_filter_and_access_rights(title_search_field):
|
||||
"""Filtering on querystring parameters should respect access rights."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
@@ -76,7 +85,7 @@ def test_api_documents_list_filter_and_access_rights():
|
||||
|
||||
filters = {
|
||||
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
|
||||
"title": random.choice([None, *word_list]),
|
||||
title_search_field: random.choice([None, *word_list]),
|
||||
"favorite": random.choice([None, True, False]),
|
||||
"creator": random.choice([None, user, other_user]),
|
||||
"ordering": random.choice(
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -136,6 +137,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -246,6 +248,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -330,6 +333,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -529,6 +533,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"partial_update": access.role not in ["reader", "commenter"],
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": access.role not in ["reader", "commenter"],
|
||||
"versions_destroy": access.role in ["administrator", "owner"],
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
Tests for Documents API endpoint in impress's core app: search
|
||||
"""
|
||||
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core import factories
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query")
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
def test_api_documents_search_anonymous(search_query, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
Anonymous users should be allowed to search documents with Find.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
# mock Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
@@ -48,7 +33,22 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
q = "alpha"
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert search_query.call_count == 1
|
||||
assert search_query.call_args[1] == {
|
||||
"data": {
|
||||
"q": q,
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": None,
|
||||
},
|
||||
"token": None,
|
||||
}
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -59,64 +59,121 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet.list")
|
||||
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
When indexer is not configured and no path is provided,
|
||||
should fall back on list method
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked list result"}],
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
mock_list.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert mock_list.call_count == 1
|
||||
assert mock_list.call_args[0][0].GET.get("q") == q
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
||||
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
||||
mock_list_descendants, indexer_settings
|
||||
):
|
||||
"""
|
||||
When indexer is not configured and path parameter is provided,
|
||||
should call _list_descendants() method
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked _list_descendants result"}],
|
||||
}
|
||||
mock_list_descendants.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
|
||||
)
|
||||
|
||||
assert mock_list_descendants.call_count == 1
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("q") == q
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
|
||||
def test_api_documents_search_indexer_crashes(mock_title_search, indexer_settings):
|
||||
"""
|
||||
When indexer is configured but crashes -> falls back on title_search
|
||||
"""
|
||||
# indexer is properly configured
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
# but returns an error when the query is sent
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[{"error": "Some indexer error"}],
|
||||
status=404,
|
||||
)
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked title_search result"}],
|
||||
}
|
||||
mock_title_search.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "alpha", "path": parent.path}
|
||||
)
|
||||
|
||||
# the search endpoint did not crash
|
||||
assert response.status_code == 200
|
||||
# fallback on title_search
|
||||
assert mock_title_search.call_count == 1
|
||||
assert mock_title_search.call_args[0][0].GET.get("q") == q
|
||||
assert mock_title_search.call_args[0][0].GET.get("path") == parent.path
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -125,49 +182,28 @@ def test_api_documents_search_invalid_params(indexer_settings):
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
def test_api_documents_search_success(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
document = {"id": "doc-123", "title": "alpha", "path": "path/to/alpha.pdf"}
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
{
|
||||
"_id": str(document["id"]),
|
||||
"_source": {"title": document["title"], "path": document["path"]},
|
||||
},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
@@ -177,249 +213,6 @@ def test_api_documents_search_format(indexer_settings):
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
assert results == [
|
||||
{"id": document["id"], "title": document["title"], "path": document["path"]}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
"""
|
||||
Tests for search API endpoint in impress's core app when indexer is not
|
||||
available and a path param is given.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.filters import remove_accents
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_indexer(indexer_settings):
|
||||
"""Disable search indexer for all tests in this file."""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public", title="doc parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="doc child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="doc grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_role": None,
|
||||
"ancestors_link_reach": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role
|
||||
if (child1.link_reach == "public" and child1.link_role == "editor")
|
||||
else document.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(
|
||||
link_reach="public", title="grand parent doc"
|
||||
)
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent,
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
title="parent doc",
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
parent=parent,
|
||||
title="document",
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child doc"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child doc")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||
def test_api_documents_search_descendants_list_anonymous_restricted_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(title="parent", link_reach=reach)
|
||||
child = factories.DocumentFactory(title="child", parent=document)
|
||||
_grand_child = factories.DocumentFactory(title="grand child", parent=child)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_search_descendants_list_authenticated_unrelated_public_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to retrieve the descendants of a public/authenticated
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, title="parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_search_descendants_list_authenticated_public_or_authenticated_parent(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document who
|
||||
has a public or authenticated ancestor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach, title="grand parent")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", parent=parent, title="document"
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_unrelated_restricted():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve the descendants of a document that is
|
||||
restricted and to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
_grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document
|
||||
to which they are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(title="parent")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_parent():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document if they
|
||||
are related to one of its ancestors whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
parent=parent, link_reach="restricted", title="document"
|
||||
)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_child():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve all the descendants of a document
|
||||
as a result of being related to one of its children.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_team_none(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve the descendants of a restricted document
|
||||
related to teams in which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="document")
|
||||
factories.DocumentFactory.create_batch(2, parent=document, title="child")
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_team_members(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document to which they
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = ["myteam"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("", 7), # Empty string
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 2), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("velo", 1), # Accent-insensitive match (velo vs vélo)
|
||||
("bêta", 1), # Accent-insensitive match (bêta vs beta)
|
||||
],
|
||||
)
|
||||
def test_api_documents_search_descendants_search_on_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their unaccented title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=parent)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": query, "path": parent.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert (
|
||||
remove_accents(query).lower().strip()
|
||||
in remove_accents(result["title"]).lower()
|
||||
)
|
||||
@@ -101,6 +101,7 @@ def test_api_documents_trashbin_format():
|
||||
"partial_update": False,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"search": False,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
|
||||
@@ -189,6 +189,7 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": False,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -255,6 +256,7 @@ def test_models_documents_get_abilities_reader(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": True,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -326,6 +328,7 @@ def test_models_documents_get_abilities_commenter(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": True,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -394,6 +397,7 @@ def test_models_documents_get_abilities_editor(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": True,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -451,6 +455,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
@@ -494,6 +499,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -541,6 +547,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
@@ -598,6 +605,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
@@ -663,6 +671,7 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
|
||||
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
|
||||
@@ -729,6 +738,7 @@ def test_models_documents_get_abilities_commenter_user(
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
|
||||
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
|
||||
@@ -791,6 +801,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+90
-23
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
Unit tests for FindDocumentIndexer
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -30,7 +30,7 @@ def reset_throttle():
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push):
|
||||
@@ -41,7 +41,7 @@ def test_models_documents_post_save_indexer(mock_push):
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
|
||||
@@ -64,14 +64,14 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
"""Test indexation task on doculment creation, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
@@ -91,7 +91,7 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
assert cache.get("file-batch-indexer-throttle") is None
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
@@ -106,13 +106,13 @@ def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_se
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
indexer_settings.INDEXING_URL = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
@@ -145,7 +145,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
@@ -158,7 +158,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
@@ -207,7 +207,7 @@ def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
@@ -244,14 +244,14 @@ def test_models_documents_indexer_hard_deleted():
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
# Call task on deleted document.
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
doc.delete()
|
||||
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@@ -308,7 +308,7 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert len(data) == 2
|
||||
@@ -337,16 +337,16 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_post_save_indexer_throttle():
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
docs = factories.DocumentFactory.create_batch(5, users=(user,))
|
||||
|
||||
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set("document-batch-indexer-throttle", 1)
|
||||
|
||||
@@ -359,7 +359,7 @@ def test_models_documents_post_save_indexer_throttle():
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
@@ -389,7 +389,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
@@ -398,7 +398,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -426,7 +426,7 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -439,3 +439,70 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# the same document is indexed 3 times
|
||||
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "search_query")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_find_document_indexer_search(mock_search_query):
|
||||
"""Test search function of FindDocumentIndexer returns formatted results"""
|
||||
|
||||
# Mock API response from Find
|
||||
hits = [
|
||||
{
|
||||
"_id": "doc-123",
|
||||
"_source": {
|
||||
"title": "Test Document",
|
||||
"content": "This is test content",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"path": "/some/path/doc-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
"_id": "doc-456",
|
||||
"_source": {
|
||||
"title.fr": "Document de test",
|
||||
"content": "Contenu de test",
|
||||
"updated_at": "2024-01-02T00:00:00Z",
|
||||
},
|
||||
},
|
||||
]
|
||||
mock_search_query.return_value = hits
|
||||
|
||||
q = "test"
|
||||
token = "fake-token"
|
||||
nb_results = 10
|
||||
path = "/some/path/"
|
||||
visited = ["doc-123"]
|
||||
results = FindDocumentIndexer().search(
|
||||
q=q, token=token, nb_results=nb_results, path=path, visited=visited
|
||||
)
|
||||
|
||||
mock_search_query.assert_called_once()
|
||||
call_args = mock_search_query.call_args
|
||||
assert call_args[1]["data"] == {
|
||||
"q": q,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": path,
|
||||
}
|
||||
|
||||
assert len(results) == 2
|
||||
assert results == [
|
||||
{
|
||||
"id": hits[0]["_id"],
|
||||
"title": hits[0]["_source"]["title"],
|
||||
"content": hits[0]["_source"]["content"],
|
||||
"updated_at": hits[0]["_source"]["updated_at"],
|
||||
"path": hits[0]["_source"]["path"],
|
||||
},
|
||||
{
|
||||
"id": hits[1]["_id"],
|
||||
"title": hits[1]["_source"]["title.fr"],
|
||||
"title.fr": hits[1]["_source"]["title.fr"], # <- Find response artefact
|
||||
"content": hits[1]["_source"]["content"],
|
||||
"updated_at": hits[1]["_source"]["updated_at"],
|
||||
},
|
||||
]
|
||||
@@ -15,7 +15,7 @@ from requests import HTTPError
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
SearchIndexer,
|
||||
FindDocumentIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
@@ -78,41 +78,41 @@ def test_services_search_indexer_is_configured(indexer_settings):
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.SearchIndexer"
|
||||
"core.services.search_indexers.FindDocumentIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
def test_services_indexing_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
Indexer should raise RuntimeError if INDEXING_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
indexer_settings.INDEXING_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
def test_services_indexing_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
Indexer should raise RuntimeError if INDEXING_URL is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
@@ -122,7 +122,7 @@ def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
@@ -136,39 +136,35 @@ def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
def test_services_search_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
Indexer should raise RuntimeError if SEARCH_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
indexer_settings.SEARCH_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
def test_services_search_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
Indexer should raise RuntimeError if SEARCH_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
indexer_settings.SEARCH_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@@ -192,7 +188,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
}
|
||||
}
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
@@ -221,7 +217,7 @@ def test_services_search_indexers_serialize_document_deleted():
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
@@ -232,7 +228,7 @@ def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
@@ -246,7 +242,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
indexer_settings.INDEXING_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -256,10 +252,10 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().index()
|
||||
FindDocumentIndexer().index()
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
@@ -276,7 +272,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index() == 5
|
||||
assert FindDocumentIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -299,7 +295,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
"""
|
||||
@@ -314,7 +310,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index(batch_size=2) == 5
|
||||
assert FindDocumentIndexer().index(batch_size=2) == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -337,7 +333,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
"""
|
||||
@@ -349,7 +345,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
@@ -365,7 +361,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
|
||||
"""
|
||||
Documents indexing batch can be empty if all the docs are empty.
|
||||
@@ -377,14 +373,14 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
|
||||
# Only empty docs
|
||||
factories.DocumentFactory.create_batch(5, content="", title="")
|
||||
|
||||
assert SearchIndexer().index() == 1
|
||||
assert FindDocumentIndexer().index() == 1
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
results = [doc["id"] for doc in mock_push.call_args[0][0]]
|
||||
assert results == [str(document.id)]
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
@@ -395,7 +391,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert SearchIndexer().index() == 4
|
||||
assert FindDocumentIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
@@ -405,7 +401,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
@@ -415,7 +411,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -428,7 +424,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
@@ -436,7 +432,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -451,9 +447,9 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
indexer_settings.INDEXING_URL = "http://example.com/index"
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
@@ -464,7 +460,7 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert args[0] == indexer_settings.INDEXING_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
@@ -542,9 +538,7 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
indexer_settings.SEARCH_URL = "http://app-find/api/v1.0/documents/search/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -554,17 +548,17 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().search("alpha", token="mytoken")
|
||||
FindDocumentIndexer().search("alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
search() should call requests.post to SEARCH_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -582,7 +576,7 @@ def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
@@ -605,7 +599,7 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
|
||||
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -623,7 +617,7 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert kwargs.get("json")["nb_results"] == 25
|
||||
|
||||
# The argument overrides the setting value
|
||||
@@ -631,5 +625,53 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert kwargs.get("json")["nb_results"] == 109
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_localized_field():
|
||||
"""Test extracting title from localized title field."""
|
||||
source = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_localized_fields():
|
||||
"""Test that first matching localized title is returned."""
|
||||
source = {"title.extension": "Bonjour", "title.en": "Hello", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result in ["Bonjour", "Hello"]
|
||||
|
||||
|
||||
def test_search_indexer_get_title_fallback_to_plain_title():
|
||||
"""Test fallback to plain 'title' field when no localized field exists."""
|
||||
source = {"title": "Hello World", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Hello World"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_no_title_field():
|
||||
"""Test that empty string is returned when no title field exists."""
|
||||
source = {"id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_empty_localized_title():
|
||||
"""Test that fallback works when localized title is empty."""
|
||||
source = {"title.extension": "", "title": "Fallback Title", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Fallback Title"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_extension():
|
||||
"""Test extracting title from title field with multiple extensions."""
|
||||
source = {"title.extension_1.extension_2": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
@@ -205,3 +205,38 @@ def test_utils_users_sharing_documents_with_empty_result():
|
||||
|
||||
cached_data = cache.get(cache_key)
|
||||
assert cached_data == {}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_matching_key():
|
||||
"""Test extracting value from a dictionary with a matching key pattern."""
|
||||
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_multiple_matches():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {
|
||||
"Bonjour",
|
||||
"Hello",
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_multiple_extensions():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_no_match():
|
||||
"""Test that empty list is returned when no key matches the pattern."""
|
||||
data = {"name": "Test", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert result == []
|
||||
|
||||
Reference in New Issue
Block a user