|
2 | 2 | Tests for the `openedx-ai-extensions` API endpoints. |
3 | 3 | """ |
4 | 4 |
|
| 5 | +import json |
| 6 | +import sys |
| 7 | +from unittest.mock import MagicMock, Mock, patch |
| 8 | + |
5 | 9 | import pytest |
6 | 10 | from django.contrib.auth import get_user_model |
| 11 | +from django.core.exceptions import ValidationError |
| 12 | +from django.test import RequestFactory |
7 | 13 | from django.urls import reverse |
8 | 14 | from opaque_keys.edx.keys import CourseKey |
9 | 15 | 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 | +) |
11 | 32 |
|
12 | 33 | User = get_user_model() |
13 | 34 |
|
@@ -51,6 +72,35 @@ def course_key(): |
51 | 72 | return CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") |
52 | 73 |
|
53 | 74 |
|
| 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 | + |
54 | 104 | @pytest.mark.django_db |
55 | 105 | def test_api_urls_are_registered(): |
56 | 106 | """ |
@@ -305,3 +355,227 @@ def test_config_endpoint_without_authentication(api_client): # pylint: disable= |
305 | 355 |
|
306 | 356 | # Should require authentication (401 or 403) |
307 | 357 | 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