Skip to content

Commit

Permalink
test: library block tests
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Mar 24, 2024
1 parent bd28dec commit 90f7e62
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 31 deletions.
38 changes: 35 additions & 3 deletions openedx/core/djangoapps/content/search/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,19 +354,19 @@ def upsert_xblock_index_doc(
"""
current_rebuild_index_name = _get_running_rebuild_index_name()

course = modulestore().get_item(usage_key)
xblock = modulestore().get_item(usage_key)
client = _get_meilisearch_client()

docs = []

def add_with_children(block):
""" Recursively index the given XBlock/component """
doc = searchable_doc_for_course_block(block, metadata=update_metadata, tags=update_tags)
doc = searchable_doc_for_course_block(block, include_metadata=update_metadata, include_tags=update_tags)
docs.append(doc)
if recursive:
_recurse_children(block, add_with_children)

add_with_children(course)
add_with_children(xblock)

tasks = []
if current_rebuild_index_name:
Expand Down Expand Up @@ -397,6 +397,38 @@ def delete_xblock_index_doc(usage_key: UsageKey) -> None:
_wait_for_meili_tasks(tasks)


def upsert_library_block_index_doc(
usage_key: UsageKey, update_metadata: bool = True, update_tags: bool = True
) -> None:
"""
Creates or updates the document for the given Library Block in the search index
Args:
usage_key (UsageKey): The usage key of the Library Block to index
update_metadata (bool): If True, update the metadata of the Library Block
update_tags (bool): If True, update the tags of the Library Block
"""
current_rebuild_index_name = _get_running_rebuild_index_name()

library_block = lib_api.get_component_from_usage_key(usage_key)
library_block_metadata = lib_api.LibraryXBlockMetadata.from_component(usage_key.context_key, library_block)
client = _get_meilisearch_client()

docs = [
searchable_doc_for_library_block(
library_block_metadata, include_metadata=update_metadata, include_tags=update_tags
)
]

tasks = []
if current_rebuild_index_name:
# If there is a rebuild in progress, the document will also be added to the new index.
tasks.append(client.index(current_rebuild_index_name).update_documents(docs))
tasks.append(client.index(INDEX_NAME).update_documents(docs))

_wait_for_meili_tasks(tasks)

def generate_user_token(user):
"""
Returns a Meilisearch API key that only allows the user to search content that they have permission to view
Expand Down
47 changes: 29 additions & 18 deletions openedx/core/djangoapps/content/search/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ class implementation returns only:
{"content": {"display_name": "..."}, "content_type": "..."}
"""
block_data = {
Fields.id: meili_id_from_opaque_key(block.usage_key),
Fields.usage_key: str(block.usage_key),
Fields.block_id: str(block.usage_key.block_id),
Fields.display_name: xblock_api.get_block_display_name(block),
Expand Down Expand Up @@ -196,46 +195,58 @@ def _tags_for_content_object(object_id: UsageKey | LearningContextKey) -> dict:
return {Fields.tags: result}


def searchable_doc_for_library_block(metadata: lib_api.LibraryXBlockMetadata) -> dict:
def searchable_doc_for_library_block(
xblock_metadata: lib_api.LibraryXBlockMetadata, include_metadata: bool = True, include_tags: bool = True
) -> dict:
"""
Generate a dictionary document suitable for ingestion into a search engine
like Meilisearch or Elasticsearch, so that the given library block can be
found using faceted search.
Args:
xblock_metadata: The XBlock component metadata to index
include_metadata: If True, include the block's metadata in the doc
include_tags: If True, include the block's tags in the doc
"""
library_name = lib_api.get_library(metadata.usage_key.context_key).title
doc = {}
try:
block = xblock_api.load_block(metadata.usage_key, user=None)
except Exception as err: # pylint: disable=broad-except
log.exception(f"Failed to load XBlock {metadata.usage_key}: {err}")
doc.update(_fields_from_block(block))
doc.update(_tags_for_content_object(metadata.usage_key))
doc[Fields.type] = DocType.library_block
# Add the breadcrumbs. In v2 libraries, the library itself is not a "parent" of the XBlocks so we add it here:
doc[Fields.breadcrumbs] = [{"display_name": library_name}]
library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title
block = xblock_api.load_block(xblock_metadata.usage_key, user=None)

doc = {
Fields.id: meili_id_from_opaque_key(xblock_metadata.usage_key),
Fields.type: DocType.library_block,
}

if include_metadata:
doc.update(_fields_from_block(block))
# Add the breadcrumbs. In v2 libraries, the library itself is not a "parent" of the XBlocks so we add it here:
doc[Fields.breadcrumbs] = [{"display_name": library_name}]

if include_tags:
doc.update(_tags_for_content_object(xblock_metadata.usage_key))

return doc


def searchable_doc_for_course_block(block, metadata: bool = True, tags: bool = True) -> dict:
def searchable_doc_for_course_block(block, include_metadata: bool = True, include_tags: bool = True) -> dict:
"""
Generate a dictionary document suitable for ingestion into a search engine
like Meilisearch or Elasticsearch, so that the given course block can be
found using faceted search.
Args:
block: The XBlock instance to index
metadata: If True, include the block's metadata in the doc
tags: If True, include the block's tags in the doc
include_metadata: If True, include the block's metadata in the doc
include_tags: If True, include the block's tags in the doc
"""
doc = {
Fields.id: meili_id_from_opaque_key(block.usage_key),
Fields.type: DocType.course_block,
}

if metadata:
if include_metadata:
doc.update(_fields_from_block(block))

if tags:
if include_tags:
doc.update(_tags_for_content_object(block.usage_key))

return doc
30 changes: 27 additions & 3 deletions openedx/core/djangoapps/content/search/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
from __future__ import annotations

from unittest.mock import MagicMock, call, patch
from organizations.tests.factories import OrganizationFactory

import ddt
from django.test import override_settings
from organizations.tests.factories import OrganizationFactory

from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangolib.testing.utils import skip_unless_cms
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from openedx.core.djangoapps.content_libraries import api as library_api

from .. import api

Expand Down Expand Up @@ -92,7 +92,7 @@ def setUp(self):
title="Library",
)
# Populate it with a problem:
self.problem_key = library_api.create_library_block(self.library.key, "problem", "p1").usage_key
self.problem = library_api.create_library_block(self.library.key, "problem", "p1")
self.doc_problem = {
"id": "lborg1libproblemp1-a698218e",
"usage_key": "lb:org1:lib:problem:p1",
Expand Down Expand Up @@ -158,3 +158,27 @@ def test_delete_index_xblock(self, mock_meilisearch):
mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with(
self.doc_sequential['id']
)

@override_settings(MEILISEARCH_ENABLED=True)
def test_index_library_block_metadata(self, mock_meilisearch):
"""
Test indexing a Library Block.
"""
api.upsert_library_block_index_doc(
self.problem.usage_key,
update_metadata=True,
update_tags=False,
)

mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.doc_problem])

@override_settings(MEILISEARCH_ENABLED=True)
def test_delete_index_library_block(self, mock_meilisearch):
"""
Test deleting a Library Block doc from the index.
"""
api.delete_xblock_index_doc(self.problem.usage_key)

mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with(
self.doc_problem['id']
)
23 changes: 16 additions & 7 deletions openedx/core/djangoapps/content/search/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@
"""
from __future__ import annotations

from unittest.mock import MagicMock, patch

from django.test import override_settings
from rest_framework.test import APIClient, APITestCase

from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangolib.testing.utils import skip_unless_cms

from .test_api import MeilisearchTestMixin
from .. import api

STUDIO_SEARCH_ENDPOINT_URL = "/api/content_search/v2/studio/"


@skip_unless_cms
class StudioSearchViewTest(MeilisearchTestMixin, APITestCase):
@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task", new=MagicMock(return_value=None))
@patch("openedx.core.djangoapps.content.search.api.MeilisearchClient")
class StudioSearchViewTest(APITestCase):
"""
General tests for the Studio search REST API.
"""
Expand All @@ -34,24 +38,28 @@ def setUp(self):
super().setUp()
self.client = APIClient()

# Clear the Meilisearch client to avoid side effects from other tests
api.clear_meilisearch_client()


@override_settings(MEILISEARCH_ENABLED=False)
def test_studio_search_unathenticated_disabled(self):
def test_studio_search_unathenticated_disabled(self, _meilisearch_client):
"""
Whether or not Meilisearch is enabled, the API endpoint requires authentication.
"""
result = self.client.get(STUDIO_SEARCH_ENDPOINT_URL)
assert result.status_code == 401

@override_settings(MEILISEARCH_ENABLED=True)
def test_studio_search_unathenticated_enabled(self):
def test_studio_search_unathenticated_enabled(self, _meilisearch_client):
"""
Whether or not Meilisearch is enabled, the API endpoint requires authentication.
"""
result = self.client.get(STUDIO_SEARCH_ENDPOINT_URL)
assert result.status_code == 401

@override_settings(MEILISEARCH_ENABLED=False)
def test_studio_search_disabled(self):
def test_studio_search_disabled(self, _meilisearch_client):
"""
When Meilisearch is disabled, the Studio search endpoint gives a 404
"""
Expand All @@ -60,7 +68,7 @@ def test_studio_search_disabled(self):
assert result.status_code == 404

@override_settings(MEILISEARCH_ENABLED=True)
def test_studio_search_student_forbidden(self):
def test_studio_search_student_forbidden(self, _meilisearch_client):
"""
Until we implement fine-grained permissions, only global staff can use
the Studio search endpoint.
Expand All @@ -70,11 +78,12 @@ def test_studio_search_student_forbidden(self):
assert result.status_code == 403

@override_settings(MEILISEARCH_ENABLED=True)
def test_studio_search_staff(self):
def test_studio_search_staff(self, _meilisearch_client):
"""
Global staff can get a restricted API key for Meilisearch using the REST
API.
"""
_meilisearch_client.return_value.generate_tenant_token.return_value = "api_key"
self.client.login(username='staff', password='staff_pass')
result = self.client.get(STUDIO_SEARCH_ENDPOINT_URL)
assert result.status_code == 200
Expand Down

0 comments on commit 90f7e62

Please sign in to comment.