"""
Module Tools Unit Tests
Tests for the Canvas module management tools:
- list_modules
- create_module
- update_module
- delete_module
- add_module_item
- update_module_item
- delete_module_item
These tests use mocking to avoid requiring real Canvas API access.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
# Sample mock data
MOCK_MODULES = [
{
"id": 12345,
"name": "Week 1: Introduction",
"position": 1,
"state": "active",
"published": True,
"items_count": 5,
"unlock_at": None,
"require_sequential_progress": False,
"prerequisite_module_ids": []
},
{
"id": 12346,
"name": "Week 2: Core Concepts",
"position": 2,
"state": "active",
"published": True,
"items_count": 8,
"unlock_at": "2026-01-20T00:00:00Z",
"require_sequential_progress": True,
"prerequisite_module_ids": [12345]
},
{
"id": 12347,
"name": "Week 3: Advanced Topics",
"position": 3,
"state": "unpublished",
"published": False,
"items_count": 0,
"unlock_at": None,
"require_sequential_progress": False,
"prerequisite_module_ids": [12345, 12346]
}
]
# We need to create a test helper that can call the tool functions directly
# without going through MCP registration
@pytest.fixture
def mock_canvas_api():
"""Fixture to mock Canvas API calls."""
with patch('canvas_mcp.tools.modules.get_course_id') as mock_get_id, \
patch('canvas_mcp.tools.modules.get_course_code') as mock_get_code, \
patch('canvas_mcp.tools.modules.fetch_all_paginated_results') as mock_fetch, \
patch('canvas_mcp.tools.modules.make_canvas_request') as mock_request:
mock_get_id.return_value = "60366"
mock_get_code.return_value = "badm_350_120251"
yield {
'get_course_id': mock_get_id,
'get_course_code': mock_get_code,
'fetch_all_paginated_results': mock_fetch,
'make_canvas_request': mock_request
}
def get_tool_function(tool_name: str):
"""Get a tool function by name from the registered tools."""
from mcp.server.fastmcp import FastMCP
from canvas_mcp.tools.modules import register_module_tools
# Create a mock MCP server and register tools
mcp = FastMCP("test")
# Store captured functions
captured_functions = {}
# Override the tool decorator to capture the function
original_tool = mcp.tool
def capturing_tool(*args, **kwargs):
decorator = original_tool(*args, **kwargs)
def wrapper(fn):
captured_functions[fn.__name__] = fn
return decorator(fn)
return wrapper
mcp.tool = capturing_tool
register_module_tools(mcp)
return captured_functions.get(tool_name)
class TestListModules:
"""Tests for list_modules tool."""
@pytest.mark.asyncio
async def test_list_modules_basic(self, mock_canvas_api):
"""Test basic module listing."""
mock_canvas_api['fetch_all_paginated_results'].return_value = MOCK_MODULES
list_modules = get_tool_function('list_modules')
assert list_modules is not None
result = await list_modules("badm_350_120251")
# Verify API was called correctly
mock_canvas_api['get_course_id'].assert_called_once_with("badm_350_120251")
mock_canvas_api['fetch_all_paginated_results'].assert_called_once()
# Verify output contains module info
assert "Week 1: Introduction" in result
assert "Week 2: Core Concepts" in result
assert "12345" in result
assert "Published: Yes" in result
@pytest.mark.asyncio
async def test_list_modules_empty(self, mock_canvas_api):
"""Test listing modules when course has none."""
mock_canvas_api['fetch_all_paginated_results'].return_value = []
list_modules = get_tool_function('list_modules')
result = await list_modules("empty_course")
assert "No modules found" in result
@pytest.mark.asyncio
async def test_list_modules_error_handling(self, mock_canvas_api):
"""Test error handling when API fails."""
mock_canvas_api['fetch_all_paginated_results'].return_value = {"error": "Course not found"}
list_modules = get_tool_function('list_modules')
result = await list_modules("invalid_course")
assert "Error" in result
assert "Course not found" in result
@pytest.mark.asyncio
async def test_list_modules_with_search_term(self, mock_canvas_api):
"""Test listing modules with search filter."""
mock_canvas_api['fetch_all_paginated_results'].return_value = [MOCK_MODULES[0]]
list_modules = get_tool_function('list_modules')
result = await list_modules("60366", search_term="Introduction")
# Verify search_term was passed to API
call_args = mock_canvas_api['fetch_all_paginated_results'].call_args
assert "search_term" in call_args[0][1]
class TestCreateModule:
"""Tests for create_module tool."""
@pytest.mark.asyncio
async def test_create_module_basic(self, mock_canvas_api):
"""Test basic module creation."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 12348,
"name": "New Module",
"position": 4,
"published": True,
"state": "active"
}
create_module = get_tool_function('create_module')
result = await create_module("badm_350_120251", "New Module")
# Verify success
assert "successfully" in result
assert "New Module" in result
assert "12348" in result
@pytest.mark.asyncio
async def test_create_module_with_options(self, mock_canvas_api):
"""Test module creation with all options."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 12349,
"name": "Sequential Module",
"position": 5,
"published": False,
"state": "unpublished",
"unlock_at": "2026-02-01T00:00:00Z",
"require_sequential_progress": True
}
create_module = get_tool_function('create_module')
result = await create_module(
"badm_350_120251",
"Sequential Module",
position=5,
require_sequential_progress=True,
published=False,
unlock_at="2026-02-01"
)
assert "successfully" in result
assert "Sequential Module" in result
assert "Sequential Progress: Required" in result
@pytest.mark.asyncio
async def test_create_module_error(self, mock_canvas_api):
"""Test module creation failure handling."""
mock_canvas_api['make_canvas_request'].return_value = {"error": "Insufficient permissions"}
create_module = get_tool_function('create_module')
result = await create_module("60366", "Test Module")
assert "Error" in result
assert "Insufficient permissions" in result
class TestUpdateModule:
"""Tests for update_module tool."""
@pytest.mark.asyncio
async def test_update_module_name(self, mock_canvas_api):
"""Test updating module name."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 12345,
"name": "Updated Module Name",
"position": 1,
"published": True
}
update_module = get_tool_function('update_module')
result = await update_module("60366", 12345, name="Updated Module Name")
assert "successfully" in result
assert "Updated Module Name" in result
@pytest.mark.asyncio
async def test_update_module_no_changes(self, mock_canvas_api):
"""Test update with no changes specified."""
update_module = get_tool_function('update_module')
result = await update_module("60366", 12345)
assert "No changes specified" in result
@pytest.mark.asyncio
async def test_update_module_publish(self, mock_canvas_api):
"""Test publishing a module."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 12345,
"name": "Test Module",
"position": 1,
"published": True
}
update_module = get_tool_function('update_module')
result = await update_module("60366", 12345, published=True)
assert "successfully" in result
assert "Published: Yes" in result
class TestDeleteModule:
"""Tests for delete_module tool."""
@pytest.mark.asyncio
async def test_delete_module_success(self, mock_canvas_api):
"""Test successful module deletion."""
# First call gets module info, second call deletes
mock_canvas_api['make_canvas_request'].side_effect = [
{"id": 12345, "name": "Module to Delete", "items_count": 3},
{} # Successful deletion returns empty or confirmation
]
delete_module = get_tool_function('delete_module')
result = await delete_module("60366", 12345)
assert "successfully" in result
assert "Module to Delete" in result
assert "Items affected: 3" in result
@pytest.mark.asyncio
async def test_delete_module_error(self, mock_canvas_api):
"""Test module deletion failure."""
mock_canvas_api['make_canvas_request'].side_effect = [
{"id": 12345, "name": "Test", "items_count": 0},
{"error": "Module not found"}
]
delete_module = get_tool_function('delete_module')
result = await delete_module("60366", 99999)
assert "Error" in result
class TestAddModuleItem:
"""Tests for add_module_item tool."""
@pytest.mark.asyncio
async def test_add_assignment_item(self, mock_canvas_api):
"""Test adding an assignment to a module."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55010,
"title": "Week 1 Assignment",
"type": "Assignment",
"position": 4,
"indent": 0,
"content_id": 98765
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "Assignment", content_id=98765
)
assert "successfully" in result
assert "Assignment" in result
@pytest.mark.asyncio
async def test_add_item_missing_content_id(self, mock_canvas_api):
"""Test error when content_id is required but missing."""
add_module_item = get_tool_function('add_module_item')
# Assignment requires content_id
result = await add_module_item("60366", 12345, "Assignment")
assert "content_id is required" in result
@pytest.mark.asyncio
async def test_add_page_missing_page_url(self, mock_canvas_api):
"""Test error when page_url is required but missing."""
add_module_item = get_tool_function('add_module_item')
# Page requires page_url
result = await add_module_item("60366", 12345, "Page")
assert "page_url is required" in result
@pytest.mark.asyncio
async def test_add_subheader(self, mock_canvas_api):
"""Test adding a subheader to a module."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55011,
"title": "Required Readings",
"type": "SubHeader",
"position": 1,
"indent": 0
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "SubHeader", title="Required Readings"
)
assert "successfully" in result
assert "SubHeader" in result
@pytest.mark.asyncio
async def test_add_subheader_missing_title(self, mock_canvas_api):
"""Test SubHeader requires title."""
add_module_item = get_tool_function('add_module_item')
result = await add_module_item("60366", 12345, "SubHeader")
assert "title is required" in result
@pytest.mark.asyncio
async def test_add_item_invalid_type(self, mock_canvas_api):
"""Test error with invalid item type."""
add_module_item = get_tool_function('add_module_item')
result = await add_module_item("60366", 12345, "InvalidType")
assert "Invalid item_type" in result
@pytest.mark.asyncio
async def test_add_item_invalid_indent(self, mock_canvas_api):
"""Test error with invalid indent level."""
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "SubHeader", title="Test", indent=5
)
assert "indent must be between 0 and 4" in result
@pytest.mark.asyncio
async def test_add_item_valid_indent(self, mock_canvas_api):
"""Test valid indent levels are accepted."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55012,
"title": "Indented Item",
"type": "SubHeader",
"position": 1,
"indent": 2
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "SubHeader", title="Indented Item", indent=2
)
assert "successfully" in result
@pytest.mark.asyncio
async def test_add_item_with_completion_requirement(self, mock_canvas_api):
"""Test adding item with completion requirement."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55012,
"title": "Required Reading",
"type": "Page",
"position": 2,
"completion_requirement": {"type": "must_view"}
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "Page",
page_url="required-reading",
completion_requirement_type="must_view"
)
assert "successfully" in result
assert "must_view" in result
@pytest.mark.asyncio
async def test_add_page_item(self, mock_canvas_api):
"""Test adding a Page item."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55013,
"title": "Course Syllabus",
"type": "Page",
"position": 1
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "Page", page_url="course-syllabus"
)
assert "successfully" in result
assert "Page" in result
class TestUpdateModuleItem:
"""Tests for update_module_item tool."""
@pytest.mark.asyncio
async def test_update_item_title(self, mock_canvas_api):
"""Test updating item title."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55001,
"title": "New Title",
"type": "Page",
"position": 1,
"module_id": 12345,
"published": True
}
update_module_item = get_tool_function('update_module_item')
result = await update_module_item("60366", 12345, 55001, title="New Title")
assert "successfully" in result
assert "New Title" in result
@pytest.mark.asyncio
async def test_update_item_no_changes(self, mock_canvas_api):
"""Test update with no changes specified."""
update_module_item = get_tool_function('update_module_item')
result = await update_module_item("60366", 12345, 55001)
assert "No changes specified" in result
@pytest.mark.asyncio
async def test_update_item_move_to_module(self, mock_canvas_api):
"""Test moving item to different module."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55001,
"title": "Moved Item",
"type": "Page",
"position": 1,
"module_id": 12346,
"published": True
}
update_module_item = get_tool_function('update_module_item')
result = await update_module_item(
"60366", 12345, 55001, move_to_module_id=12346
)
assert "successfully" in result
assert "Moved to module 12346" in result
class TestDeleteModuleItem:
"""Tests for delete_module_item tool."""
@pytest.mark.asyncio
async def test_delete_item_success(self, mock_canvas_api):
"""Test successful item deletion."""
# First call gets item info, second call deletes
mock_canvas_api['make_canvas_request'].side_effect = [
{"id": 55001, "title": "Item to Delete", "type": "Page"},
{} # Successful deletion
]
delete_module_item = get_tool_function('delete_module_item')
result = await delete_module_item("60366", 12345, 55001)
assert "successfully" in result
assert "Item to Delete" in result
assert "NOT deleted" in result # Warning about content preservation
class TestInputValidation:
"""Test input validation for module tools."""
@pytest.mark.asyncio
async def test_completion_requirement_validation(self, mock_canvas_api):
"""Test invalid completion requirement type."""
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "SubHeader",
title="Test",
completion_requirement_type="invalid_type"
)
assert "Invalid completion_requirement_type" in result
@pytest.mark.asyncio
async def test_min_score_without_type(self, mock_canvas_api):
"""Test min_score requirement needs corresponding type."""
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "Quiz",
content_id=123,
completion_requirement_type="min_score"
# Missing completion_requirement_min_score
)
assert "min_score" in result.lower()
@pytest.mark.asyncio
async def test_valid_completion_types(self, mock_canvas_api):
"""Test all valid completion requirement types."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55001,
"title": "Test",
"type": "Page",
"position": 1
}
add_module_item = get_tool_function('add_module_item')
valid_types = ["must_view", "must_submit", "must_contribute", "must_mark_done"]
for completion_type in valid_types:
result = await add_module_item(
"60366", 12345, "Page",
page_url="test-page",
completion_requirement_type=completion_type
)
assert "successfully" in result or "Error" not in result
class TestExternalUrlItem:
"""Tests specific to ExternalUrl item type."""
@pytest.mark.asyncio
async def test_external_url_requires_url(self, mock_canvas_api):
"""Test ExternalUrl requires external_url parameter."""
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "ExternalUrl",
title="Link Title"
# Missing external_url
)
assert "external_url is required" in result
@pytest.mark.asyncio
async def test_external_url_requires_title(self, mock_canvas_api):
"""Test ExternalUrl requires title parameter."""
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "ExternalUrl",
external_url="https://example.com"
# Missing title
)
assert "title is required" in result
@pytest.mark.asyncio
async def test_external_url_success(self, mock_canvas_api):
"""Test successful ExternalUrl creation."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55020,
"title": "External Resource",
"type": "ExternalUrl",
"external_url": "https://example.com",
"position": 1,
"new_tab": True
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "ExternalUrl",
title="External Resource",
external_url="https://example.com",
new_tab=True
)
assert "successfully" in result
assert "ExternalUrl" in result
class TestAllItemTypes:
"""Test all supported item types."""
@pytest.mark.asyncio
async def test_file_item(self, mock_canvas_api):
"""Test adding File item type."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55030,
"title": "Lecture Notes",
"type": "File",
"content_id": 111,
"position": 1
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "File", content_id=111
)
assert "successfully" in result
@pytest.mark.asyncio
async def test_discussion_item(self, mock_canvas_api):
"""Test adding Discussion item type."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55031,
"title": "Week 1 Discussion",
"type": "Discussion",
"content_id": 222,
"position": 2
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "Discussion", content_id=222
)
assert "successfully" in result
@pytest.mark.asyncio
async def test_quiz_item(self, mock_canvas_api):
"""Test adding Quiz item type."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55032,
"title": "Chapter 1 Quiz",
"type": "Quiz",
"content_id": 333,
"position": 3
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "Quiz", content_id=333
)
assert "successfully" in result
@pytest.mark.asyncio
async def test_external_tool_item(self, mock_canvas_api):
"""Test adding ExternalTool item type."""
mock_canvas_api['make_canvas_request'].return_value = {
"id": 55033,
"title": "Zoom Meeting",
"type": "ExternalTool",
"content_id": 444,
"position": 4
}
add_module_item = get_tool_function('add_module_item')
result = await add_module_item(
"60366", 12345, "ExternalTool", content_id=444
)
assert "successfully" in result
if __name__ == "__main__":
pytest.main([__file__, "-v"])