Skip to content

Commit 601bb77

Browse files
committed
feat: add tests
1 parent eec6abf commit 601bb77

File tree

2 files changed

+275
-153
lines changed

2 files changed

+275
-153
lines changed

backend/tests/test_api.py

Lines changed: 275 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,33 @@
22
Tests for the `openedx-ai-extensions` API endpoints.
33
"""
44

5+
import json
6+
import sys
7+
from unittest.mock import MagicMock, Mock, patch
8+
59
import pytest
610
from django.contrib.auth import get_user_model
11+
from django.core.exceptions import ValidationError
12+
from django.test import RequestFactory
713
from django.urls import reverse
814
from opaque_keys.edx.keys import CourseKey
915
from opaque_keys.edx.locator import BlockUsageLocator
10-
from rest_framework.test import APIClient
16+
from rest_framework.test import APIClient, APIRequestFactory
17+
18+
# Mock the submissions module before any imports that depend on it
19+
sys.modules["submissions"] = MagicMock()
20+
sys.modules["submissions.api"] = MagicMock()
21+
22+
from openedx_ai_extensions.api.v1.workflows.serializers import ( # noqa: E402 pylint: disable=wrong-import-position
23+
AIWorkflowConfigSerializer,
24+
)
25+
from openedx_ai_extensions.api.v1.workflows.views import ( # noqa: E402 pylint: disable=wrong-import-position
26+
AIGenericWorkflowView,
27+
AIWorkflowConfigView,
28+
)
29+
from openedx_ai_extensions.workflows.models import ( # noqa: E402 pylint: disable=wrong-import-position
30+
AIWorkflowConfig,
31+
)
1132

1233
User = get_user_model()
1334

@@ -51,6 +72,35 @@ def course_key():
5172
return CourseKey.from_string("course-v1:edX+DemoX+Demo_Course")
5273

5374

75+
@pytest.fixture
76+
def workflow_config():
77+
"""
78+
Create a mock workflow config for unit tests.
79+
"""
80+
config = Mock(spec=AIWorkflowConfig)
81+
config.id = 1
82+
config.pk = 1
83+
config.action = "summarize"
84+
config.course_id = "course-v1:edX+DemoX+Demo_Course"
85+
config.location_id = None
86+
config.orchestrator_class = "MockResponse"
87+
config.processor_config = {"LLMProcessor": {"function": "summarize_content"}}
88+
config.actuator_config = {
89+
"UIComponents": {
90+
"request": {"component": "AIRequestComponent", "config": {"type": "text"}}
91+
}
92+
}
93+
config._state = Mock() # pylint: disable=protected-access
94+
config._state.db = "default" # pylint: disable=protected-access
95+
config._state.adding = False # pylint: disable=protected-access
96+
return config
97+
98+
99+
# ============================================================================
100+
# Integration Tests - Full HTTP Stack
101+
# ============================================================================
102+
103+
54104
@pytest.mark.django_db
55105
def test_api_urls_are_registered():
56106
"""
@@ -305,3 +355,227 @@ def test_config_endpoint_without_authentication(api_client): # pylint: disable=
305355

306356
# Should require authentication (401 or 403)
307357
assert response.status_code in [401, 403]
358+
359+
360+
# ============================================================================
361+
# Unit Tests - Serializers
362+
# ============================================================================
363+
364+
365+
def test_serializer_serialize_config(workflow_config): # pylint: disable=redefined-outer-name
366+
"""
367+
Test AIWorkflowConfigSerializer serializes config correctly.
368+
"""
369+
serializer = AIWorkflowConfigSerializer(workflow_config)
370+
data = serializer.data
371+
372+
assert data["action"] == "summarize"
373+
assert data["course_id"] == "course-v1:edX+DemoX+Demo_Course"
374+
assert "ui_components" in data
375+
assert data["ui_components"]["request"]["component"] == "AIRequestComponent"
376+
377+
378+
def test_serializer_get_ui_components(workflow_config): # pylint: disable=redefined-outer-name
379+
"""
380+
Test serializer extracts ui_components from actuator_config.
381+
"""
382+
serializer = AIWorkflowConfigSerializer(workflow_config)
383+
ui_components = serializer.get_ui_components(workflow_config)
384+
385+
assert "request" in ui_components
386+
assert ui_components["request"]["component"] == "AIRequestComponent"
387+
assert ui_components["request"]["config"]["type"] == "text"
388+
389+
390+
def test_serializer_get_ui_components_empty_config():
391+
"""
392+
Test serializer handles empty actuator_config.
393+
"""
394+
config = Mock(spec=AIWorkflowConfig)
395+
config.action = "test"
396+
config.course_id = None
397+
config.actuator_config = None
398+
399+
serializer = AIWorkflowConfigSerializer(config)
400+
ui_components = serializer.get_ui_components(config)
401+
402+
assert ui_components == {}
403+
404+
405+
def test_serializer_create_not_implemented(workflow_config): # pylint: disable=redefined-outer-name
406+
"""
407+
Test that serializer.create raises NotImplementedError.
408+
"""
409+
serializer = AIWorkflowConfigSerializer(workflow_config)
410+
411+
with pytest.raises(NotImplementedError) as exc_info:
412+
serializer.create({})
413+
414+
assert "read-only" in str(exc_info.value)
415+
416+
417+
def test_serializer_update_not_implemented(workflow_config): # pylint: disable=redefined-outer-name
418+
"""
419+
Test that serializer.update raises NotImplementedError.
420+
"""
421+
serializer = AIWorkflowConfigSerializer(workflow_config)
422+
423+
with pytest.raises(NotImplementedError) as exc_info:
424+
serializer.update(workflow_config, {})
425+
426+
assert "read-only" in str(exc_info.value)
427+
428+
429+
# ============================================================================
430+
# Unit Tests - Views with Mocks
431+
# ============================================================================
432+
433+
434+
@pytest.mark.django_db
435+
@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflow.find_workflow_for_context")
436+
def test_generic_workflow_view_post_validation_error_unit(
437+
mock_find_workflow, user, course_key # pylint: disable=redefined-outer-name
438+
):
439+
"""
440+
Test AIGenericWorkflowView handles ValidationError (unit test).
441+
"""
442+
mock_find_workflow.side_effect = ValidationError("Invalid workflow configuration")
443+
444+
factory = RequestFactory()
445+
request_data = {
446+
"action": "invalid_action",
447+
"courseId": str(course_key),
448+
"context": {},
449+
}
450+
451+
request = factory.post(
452+
"/openedx-ai-extensions/v1/workflows/",
453+
data=json.dumps(request_data),
454+
content_type="application/json",
455+
)
456+
request.user = user
457+
458+
view = AIGenericWorkflowView.as_view()
459+
response = view(request)
460+
461+
assert response.status_code == 400
462+
data = json.loads(response.content)
463+
assert "error" in data
464+
assert data["status"] == "validation_error"
465+
466+
467+
@pytest.mark.django_db
468+
@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflow.find_workflow_for_context")
469+
def test_generic_workflow_view_post_general_exception_unit(
470+
mock_find_workflow, user, course_key # pylint: disable=redefined-outer-name
471+
):
472+
"""
473+
Test AIGenericWorkflowView handles general exceptions (unit test).
474+
"""
475+
mock_find_workflow.side_effect = Exception("Unexpected error")
476+
477+
factory = RequestFactory()
478+
request_data = {
479+
"action": "summarize",
480+
"courseId": str(course_key),
481+
"context": {},
482+
}
483+
484+
request = factory.post(
485+
"/openedx-ai-extensions/v1/workflows/",
486+
data=json.dumps(request_data),
487+
content_type="application/json",
488+
)
489+
request.user = user
490+
491+
view = AIGenericWorkflowView.as_view()
492+
response = view(request)
493+
494+
assert response.status_code == 500
495+
data = json.loads(response.content)
496+
assert "error" in data
497+
498+
499+
@pytest.mark.django_db
500+
@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowConfig.get_config")
501+
def test_workflow_config_view_get_not_found_unit(
502+
mock_get_config, user # pylint: disable=redefined-outer-name
503+
):
504+
"""
505+
Test AIWorkflowConfigView returns 404 when no config found (unit test).
506+
"""
507+
mock_get_config.return_value = None
508+
509+
factory = APIRequestFactory()
510+
request = factory.get(
511+
"/openedx-ai-extensions/v1/config/",
512+
{"action": "nonexistent", "context": "{}"},
513+
)
514+
request.user = user
515+
516+
view = AIWorkflowConfigView.as_view()
517+
response = view(request)
518+
519+
assert response.status_code == 404
520+
assert "error" in response.data
521+
assert response.data["status"] == "not_found"
522+
523+
524+
@pytest.mark.django_db
525+
@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowConfig.get_config")
526+
def test_workflow_config_view_get_with_location_id_unit(
527+
mock_get_config, workflow_config, user, course_key # pylint: disable=redefined-outer-name
528+
):
529+
"""
530+
Test AIWorkflowConfigView GET request with location_id in context (unit test).
531+
"""
532+
mock_get_config.return_value = workflow_config
533+
534+
location = BlockUsageLocator(course_key, block_type="vertical", block_id="unit-1")
535+
context_json = json.dumps({"unitId": str(location)})
536+
537+
factory = APIRequestFactory()
538+
request = factory.get(
539+
"/openedx-ai-extensions/v1/config/",
540+
{
541+
"action": "summarize",
542+
"courseId": str(course_key),
543+
"context": context_json,
544+
},
545+
)
546+
request.user = user
547+
548+
view = AIWorkflowConfigView.as_view()
549+
response = view(request)
550+
551+
assert response.status_code == 200
552+
# Verify get_config was called with correct parameters
553+
mock_get_config.assert_called_once()
554+
call_kwargs = mock_get_config.call_args[1]
555+
assert call_kwargs["action"] == "summarize"
556+
assert call_kwargs["course_id"] == str(course_key)
557+
assert call_kwargs["location_id"] == str(location)
558+
559+
560+
@pytest.mark.django_db
561+
@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowConfig.get_config")
562+
def test_workflow_config_view_invalid_context_json_unit(
563+
mock_get_config, workflow_config, user # pylint: disable=redefined-outer-name
564+
):
565+
"""
566+
Test AIWorkflowConfigView handles invalid JSON in context parameter (unit test).
567+
"""
568+
mock_get_config.return_value = workflow_config
569+
570+
factory = APIRequestFactory()
571+
request = factory.get(
572+
"/openedx-ai-extensions/v1/config/",
573+
{"action": "summarize", "context": "invalid json{"},
574+
)
575+
request.user = user
576+
577+
view = AIWorkflowConfigView.as_view()
578+
response = view(request)
579+
580+
# Should handle invalid JSON gracefully and use empty context
581+
assert response.status_code == 200

0 commit comments

Comments
 (0)