"""Tests for Resource Manager implementation.""" import asyncio import base64 import logging import pytest from arcade_mcp_server.exceptions import NotFoundError, ResourceError from arcade_mcp_server.managers.resource import ( MultipleMatchPolicy, ResourceManager, _is_template_uri, _template_to_regex, _template_to_sample_uri, ) from arcade_mcp_server.types import ( BlobResourceContents, Resource, ResourceContents, ResourceTemplate, TextResourceContents, ) class TestResourceManager: """Test ResourceManager class.""" @pytest.fixture def resource_manager(self): """Create a resource manager instance.""" return ResourceManager() @pytest.fixture def sample_resource(self): """Create a sample resource.""" return Resource( uri="file:///test.txt", name="test.txt", description="A test text file", mimeType="text/plain", ) @pytest.fixture def sample_template(self): """Create a sample resource template.""" return ResourceTemplate( uriTemplate="file:///{path}", name="File Template", description="Template for file resources", mimeType="text/plain", ) def test_manager_initialization(self): """Test resource manager initialization.""" manager = ResourceManager() # Passive manager: no started flag assert isinstance(manager, ResourceManager) @pytest.mark.asyncio async def test_manager_lifecycle(self, resource_manager): """Passive manager has no explicit lifecycle; ensure methods work.""" resources = await resource_manager.list_resources() assert resources == [] @pytest.mark.asyncio async def test_add_resource(self, resource_manager, sample_resource): """Test adding resources.""" await resource_manager.add_resource(sample_resource) resources = await resource_manager.list_resources() assert len(resources) == 1 assert resources[0].uri == sample_resource.uri @pytest.mark.asyncio async def test_remove_resource(self, resource_manager, sample_resource): """Test removing resources.""" await resource_manager.add_resource(sample_resource) removed = await resource_manager.remove_resource(sample_resource.uri) assert removed.uri == sample_resource.uri resources = await resource_manager.list_resources() assert len(resources) == 0 @pytest.mark.asyncio async def test_remove_nonexistent_resource(self, resource_manager): """Test removing non-existent resource.""" with pytest.raises(NotFoundError): await resource_manager.remove_resource("file:///nonexistent.txt") @pytest.mark.asyncio async def test_add_resource_template(self, resource_manager, sample_template): """Test adding resource templates.""" await resource_manager.add_template(sample_template) templates = await resource_manager.list_resource_templates() assert len(templates) == 1 assert templates[0].uriTemplate == sample_template.uriTemplate @pytest.mark.asyncio async def test_resource_handlers(self, resource_manager): """Test adding and using resource handlers.""" resource = Resource( uri="custom://test", name="Custom Resource", description="Resource with custom handler" ) async def custom_handler(uri: str) -> list[ResourceContents]: return [ TextResourceContents( uri=uri, text="Custom content for " + uri, mimeType="text/plain" ) ] await resource_manager.add_resource(resource, handler=custom_handler) contents = await resource_manager.read_resource("custom://test") assert len(contents) == 1 assert contents[0].text == "Custom content for custom://test" @pytest.mark.asyncio async def test_read_resource_without_handler(self, resource_manager, sample_resource): """Test reading resource without a handler returns default content.""" await resource_manager.add_resource(sample_resource) contents = await resource_manager.read_resource(sample_resource.uri) assert len(contents) == 1 assert contents[0].uri == sample_resource.uri @pytest.mark.asyncio async def test_read_nonexistent_resource(self, resource_manager): """Test reading non-existent resource.""" with pytest.raises(NotFoundError): await resource_manager.read_resource("file:///nonexistent.txt") @pytest.mark.asyncio async def test_binary_resource_content(self, resource_manager): """Test handling binary resource content.""" resource = Resource(uri="file:///image.png", name="image.png", mimeType="image/png") async def image_handler(uri: str) -> list[ResourceContents]: png_data = base64.b64encode( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde" ).decode() return [BlobResourceContents(uri=uri, blob=png_data, mimeType="image/png")] await resource_manager.add_resource(resource, handler=image_handler) contents = await resource_manager.read_resource("file:///image.png") assert len(contents) == 1 assert isinstance(contents[0], BlobResourceContents) assert contents[0].mimeType == "image/png" @pytest.mark.asyncio async def test_multiple_resource_contents(self, resource_manager): """Test resources that return multiple contents.""" resource = Resource(uri="multi://resource", name="Multi Resource") async def multi_handler(uri: str) -> list[ResourceContents]: return [ TextResourceContents(uri=uri + "#part1", text="Part 1"), TextResourceContents(uri=uri + "#part2", text="Part 2"), BlobResourceContents(uri=uri + "#data", blob="YmluYXJ5"), ] await resource_manager.add_resource(resource, handler=multi_handler) contents = await resource_manager.read_resource("multi://resource") assert len(contents) == 3 assert contents[0].text == "Part 1" assert contents[1].text == "Part 2" assert contents[2].blob == "YmluYXJ5" @pytest.mark.asyncio async def test_concurrent_resource_operations(self, resource_manager): """Test concurrent resource operations.""" # Create multiple resources resources = [] for i in range(10): resource = Resource( uri=f"file:///{i}.txt", name=f"File {i}", description=f"Test file {i}" ) resources.append(resource) tasks = [resource_manager.add_resource(r) for r in resources] await asyncio.gather(*tasks) listed = await resource_manager.list_resources() assert len(listed) == 10 @pytest.mark.asyncio async def test_list_resources_and_templates_initial(self): """Passive manager lists resources/templates initially as empty.""" manager = ResourceManager() resources = await manager.list_resources() assert resources == [] templates = await manager.list_resource_templates() assert templates == [] class TestBytesReturnType: """Test that handlers returning raw bytes are auto-encoded to BlobResourceContents.""" @pytest.mark.asyncio async def test_handler_returning_bytes_produces_blob_contents(self): manager = ResourceManager() resource = Resource(uri="img://test", name="test") await manager.add_resource(resource, handler=lambda _uri: b"\x89PNG\r\n") result = await manager.read_resource("img://test") assert len(result) == 1 assert isinstance(result[0], BlobResourceContents) assert result[0].blob == base64.b64encode(b"\x89PNG\r\n").decode("ascii") @pytest.mark.asyncio async def test_handler_returning_bytes_preserves_mime_type(self): manager = ResourceManager() resource = Resource(uri="img://png", name="png", mimeType="image/png") await manager.add_resource(resource, handler=lambda _uri: b"\x89PNG") result = await manager.read_resource("img://png") assert isinstance(result[0], BlobResourceContents) assert result[0].mimeType == "image/png" @pytest.mark.asyncio async def test_handler_returning_empty_bytes(self): manager = ResourceManager() resource = Resource(uri="img://empty", name="empty") await manager.add_resource(resource, handler=lambda _uri: b"") result = await manager.read_resource("img://empty") assert isinstance(result[0], BlobResourceContents) assert result[0].blob == "" class TestResourceTemplateMatching: """Test URI template matching for resources/read.""" def test_is_template_uri_with_braces(self): assert _is_template_uri("weather://{city}/current") is True def test_is_template_uri_without_braces(self): assert _is_template_uri("file:///static.txt") is False def test_template_to_regex_single_param(self): pattern = _template_to_regex("weather://{city}/current") m = pattern.match("weather://london/current") assert m is not None assert m.group("city") == "london" def test_template_to_regex_multiple_params(self): pattern = _template_to_regex("db://{database}/{table}") m = pattern.match("db://mydb/users") assert m is not None assert m.group("database") == "mydb" assert m.group("table") == "users" def test_template_to_regex_wildcard(self): pattern = _template_to_regex("file:///{path*}") m = pattern.match("file:///a/b/c.txt") assert m is not None assert m.group("path") == "a/b/c.txt" @pytest.mark.asyncio async def test_read_resource_matches_template(self): manager = ResourceManager() tmpl = ResourceTemplate( uriTemplate="weather://{city}/current", name="Weather", mimeType="text/plain", ) def handler(uri: str, city: str) -> str: return f"Weather for {city}" await manager.add_template_with_handler(tmpl, handler) result = await manager.read_resource("weather://london/current") assert len(result) == 1 assert isinstance(result[0], TextResourceContents) assert result[0].text == "Weather for london" @pytest.mark.asyncio async def test_read_resource_template_handler_receives_params(self): manager = ResourceManager() tmpl = ResourceTemplate( uriTemplate="weather://{city}/current", name="Weather", ) received: dict[str, str] = {} def handler(uri: str, city: str) -> str: received["city"] = city return "ok" await manager.add_template_with_handler(tmpl, handler) await manager.read_resource("weather://london/current") assert received["city"] == "london" @pytest.mark.asyncio async def test_read_resource_exact_match_takes_priority(self): manager = ResourceManager() # Add exact match exact = Resource(uri="weather://london/current", name="London Weather") await manager.add_resource(exact, handler=lambda _uri: "exact match") # Add template tmpl = ResourceTemplate( uriTemplate="weather://{city}/current", name="Weather Template" ) await manager.add_template_with_handler(tmpl, lambda uri, city: "template match") result = await manager.read_resource("weather://london/current") assert result[0].text == "exact match" @pytest.mark.asyncio async def test_read_resource_template_no_match_raises_not_found(self): manager = ResourceManager() tmpl = ResourceTemplate( uriTemplate="weather://{city}/current", name="Weather" ) await manager.add_template_with_handler(tmpl, lambda uri, city: "ok") with pytest.raises(NotFoundError): await manager.read_resource("completely://different") @pytest.mark.asyncio async def test_read_resource_template_handler_str_return_coercion(self): manager = ResourceManager() tmpl = ResourceTemplate( uriTemplate="data://{item_id}", name="Data", mimeType="text/plain", ) await manager.add_template_with_handler(tmpl, lambda uri, item_id: "hello") result = await manager.read_resource("data://42") assert isinstance(result[0], TextResourceContents) assert result[0].text == "hello" @pytest.mark.asyncio async def test_read_resource_template_handler_bytes_return_coercion(self): manager = ResourceManager() tmpl = ResourceTemplate( uriTemplate="bin://{item_id}", name="Binary", mimeType="application/octet-stream", ) await manager.add_template_with_handler(tmpl, lambda uri, item_id: b"\x00\x01") result = await manager.read_resource("bin://42") assert isinstance(result[0], BlobResourceContents) assert result[0].blob == base64.b64encode(b"\x00\x01").decode("ascii") @pytest.mark.asyncio async def test_read_resource_template_async_handler(self): manager = ResourceManager() tmpl = ResourceTemplate( uriTemplate="async://{key}", name="Async", ) async def handler(uri: str, key: str) -> str: return f"async-{key}" await manager.add_template_with_handler(tmpl, handler) result = await manager.read_resource("async://abc") assert result[0].text == "async-abc" class TestStaticResources: """Test convenience methods for static resources.""" @pytest.mark.asyncio async def test_add_text_resource(self): manager = ResourceManager() await manager.add_text_resource("text://hello", text="hello") result = await manager.read_resource("text://hello") assert isinstance(result[0], TextResourceContents) assert result[0].text == "hello" @pytest.mark.asyncio async def test_add_text_resource_with_metadata(self): manager = ResourceManager() await manager.add_text_resource( "text://meta", text="content", name="my-resource", description="A description", mime_type="text/html", ) resources = await manager.list_resources() assert len(resources) == 1 assert resources[0].name == "my-resource" assert resources[0].description == "A description" assert resources[0].mimeType == "text/html" @pytest.mark.asyncio async def test_add_file_resource(self, tmp_path): manager = ResourceManager() f = tmp_path / "test.txt" f.write_text("file content") await manager.add_file_resource("file:///test.txt", path=str(f)) result = await manager.read_resource("file:///test.txt") assert isinstance(result[0], TextResourceContents) assert result[0].text == "file content" @pytest.mark.asyncio async def test_add_file_resource_binary(self, tmp_path): manager = ResourceManager() f = tmp_path / "image.bin" f.write_bytes(b"\x89PNG\r\n") await manager.add_file_resource("file:///image.bin", path=str(f)) result = await manager.read_resource("file:///image.bin") assert isinstance(result[0], BlobResourceContents) @pytest.mark.asyncio async def test_add_file_resource_not_found(self, tmp_path): manager = ResourceManager() await manager.add_file_resource( "file:///missing.txt", path=str(tmp_path / "missing.txt") ) with pytest.raises(NotFoundError, match="File not found"): await manager.read_resource("file:///missing.txt") class TestDuplicateHandlingPolicy: """Test duplicate resource handling policies.""" def test_default_policy_is_warn(self): manager = ResourceManager() assert manager.duplicate_policy == "warn" @pytest.mark.asyncio async def test_policy_warn_logs_and_replaces(self, caplog): manager = ResourceManager(duplicate_policy="warn") r1 = Resource(uri="dup://test", name="first") r2 = Resource(uri="dup://test", name="second") await manager.add_resource(r1, handler=lambda _: "first") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): await manager.add_resource(r2, handler=lambda _: "second") assert "Replacing duplicate resource" in caplog.text # Second handler should win result = await manager.read_resource("dup://test") assert result[0].text == "second" @pytest.mark.asyncio async def test_policy_error_raises(self): manager = ResourceManager(duplicate_policy="error") r = Resource(uri="dup://test", name="first") await manager.add_resource(r) with pytest.raises(ResourceError, match="already registered"): await manager.add_resource(r) @pytest.mark.asyncio async def test_policy_replace_silently(self, caplog): manager = ResourceManager(duplicate_policy="replace") r1 = Resource(uri="dup://test", name="first") r2 = Resource(uri="dup://test", name="second") await manager.add_resource(r1, handler=lambda _: "first") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): await manager.add_resource(r2, handler=lambda _: "second") # No warning logged assert "Replacing duplicate resource" not in caplog.text result = await manager.read_resource("dup://test") assert result[0].text == "second" @pytest.mark.asyncio async def test_policy_ignore_keeps_first(self): manager = ResourceManager(duplicate_policy="ignore") r1 = Resource(uri="dup://test", name="first") r2 = Resource(uri="dup://test", name="second") await manager.add_resource(r1, handler=lambda _: "first") await manager.add_resource(r2, handler=lambda _: "second") # First handler should be kept result = await manager.read_resource("dup://test") assert result[0].text == "first" class TestCoerceResultDictBranches: """Test _coerce_result dict sub-branches and other handler return types.""" @pytest.mark.asyncio async def test_handler_returning_dict_with_text_key(self): manager = ResourceManager() resource = Resource(uri="dict://text", name="text", mimeType="text/plain") await manager.add_resource(resource, handler=lambda _uri: {"text": "from dict"}) result = await manager.read_resource("dict://text") assert isinstance(result[0], TextResourceContents) assert result[0].text == "from dict" assert result[0].mimeType == "text/plain" @pytest.mark.asyncio async def test_handler_returning_dict_with_blob_key(self): manager = ResourceManager() resource = Resource(uri="dict://blob", name="blob", mimeType="application/octet-stream") await manager.add_resource(resource, handler=lambda _uri: {"blob": "AQID"}) result = await manager.read_resource("dict://blob") assert isinstance(result[0], BlobResourceContents) assert result[0].blob == "AQID" assert result[0].mimeType == "application/octet-stream" @pytest.mark.asyncio async def test_handler_returning_dict_with_no_text_or_blob(self): manager = ResourceManager() resource = Resource(uri="dict://empty", name="empty", mimeType="application/json") await manager.add_resource(resource, handler=lambda _uri: {"foo": "bar"}) result = await manager.read_resource("dict://empty") assert isinstance(result[0], ResourceContents) assert result[0].mimeType == "application/json" @pytest.mark.asyncio async def test_handler_returning_non_standard_type(self): """Handlers returning arbitrary types get str()-converted.""" manager = ResourceManager() resource = Resource(uri="misc://int", name="int") await manager.add_resource(resource, handler=lambda _uri: 42) result = await manager.read_resource("misc://int") assert isinstance(result[0], TextResourceContents) assert result[0].text == "42" class TestUpdateResource: """Test ResourceManager.update_resource.""" @pytest.mark.asyncio async def test_update_existing_resource(self): manager = ResourceManager() r = Resource(uri="upd://test", name="original") await manager.add_resource(r, handler=lambda _uri: "original") updated = Resource(uri="upd://test", name="updated") result = await manager.update_resource("upd://test", updated, handler=lambda _uri: "updated") assert result.name == "updated" contents = await manager.read_resource("upd://test") assert contents[0].text == "updated" @pytest.mark.asyncio async def test_update_nonexistent_resource_raises(self): manager = ResourceManager() r = Resource(uri="upd://missing", name="missing") with pytest.raises(NotFoundError, match="not found"): await manager.update_resource("upd://missing", r) class TestRemoveTemplate: """Test ResourceManager.remove_template.""" @pytest.mark.asyncio async def test_remove_template_success(self): manager = ResourceManager() tmpl = ResourceTemplate(uriTemplate="rm://{id}", name="removable") await manager.add_template_with_handler(tmpl, lambda uri, id: f"item {id}") removed = await manager.remove_template("rm://{id}") assert removed.uriTemplate == "rm://{id}" # Template handler should no longer match with pytest.raises(NotFoundError): await manager.read_resource("rm://123") @pytest.mark.asyncio async def test_remove_template_not_found(self): manager = ResourceManager() with pytest.raises(NotFoundError, match="not found"): await manager.remove_template("nonexistent://{x}") class TestTemplateOverlapWarning: """Test that overlapping templates produce a warning at registration time.""" def test_template_to_sample_uri_simple(self): assert _template_to_sample_uri("weather://{city}/current") == "weather://__city__/current" def test_template_to_sample_uri_wildcard(self): sample = _template_to_sample_uri("file:///{path*}") assert sample == "file:///__path__/nested" @pytest.mark.asyncio async def test_wildcard_before_specific_warns(self, caplog): """A broad wildcard registered first should warn when a narrower template is added.""" manager = ResourceManager() broad = ResourceTemplate(uriTemplate="kb://docs/{path*}", name="Catch-all") specific = ResourceTemplate( uriTemplate="kb://docs/{category}/articles/{slug}", name="Specific" ) await manager.add_template_with_handler(broad, lambda uri, path: "broad") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): await manager.add_template_with_handler(specific, lambda uri, category, slug: "specific") assert "overlaps" in caplog.text assert "kb://docs/{path*}" in caplog.text assert "kb://docs/{category}/articles/{slug}" in caplog.text @pytest.mark.asyncio async def test_specific_before_wildcard_warns(self, caplog): """Adding a broad template after a specific one also warns.""" manager = ResourceManager() specific = ResourceTemplate( uriTemplate="kb://docs/{category}/articles/{slug}", name="Specific" ) broad = ResourceTemplate(uriTemplate="kb://docs/{path*}", name="Catch-all") await manager.add_template_with_handler(specific, lambda uri, category, slug: "specific") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): await manager.add_template_with_handler(broad, lambda uri, path: "broad") assert "overlaps" in caplog.text @pytest.mark.asyncio async def test_non_overlapping_templates_no_warning(self, caplog): """Templates with different prefixes should not warn.""" manager = ResourceManager() t1 = ResourceTemplate(uriTemplate="weather://{city}/current", name="Weather") t2 = ResourceTemplate(uriTemplate="news://{topic}/latest", name="News") await manager.add_template_with_handler(t1, lambda uri, city: "weather") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): await manager.add_template_with_handler(t2, lambda uri, topic: "news") assert "overlaps" not in caplog.text class TestMultipleMatchPolicy: """Test runtime multiple-template-match detection via multiple_match_policy.""" async def _setup_overlapping( self, policy: MultipleMatchPolicy ) -> ResourceManager: """Register a wildcard and a specific template that overlap.""" manager = ResourceManager(multiple_match_policy=policy) broad = ResourceTemplate(uriTemplate="kb://docs/{path*}", name="Catch-all") specific = ResourceTemplate( uriTemplate="kb://docs/{category}/articles/{slug}", name="Specific" ) await manager.add_template_with_handler(broad, lambda uri, path: "broad") await manager.add_template_with_handler(specific, lambda uri, category, slug: "specific") return manager def test_default_policy_is_warn(self): manager = ResourceManager() assert manager.multiple_match_policy == "warn" @pytest.mark.asyncio async def test_warn_logs_and_returns_first(self, caplog): manager = await self._setup_overlapping("warn") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): result = await manager.read_resource("kb://docs/science/articles/quantum") assert result[0].text == "broad" assert "matched 2 resource templates" in caplog.text assert "kb://docs/{path*}" in caplog.text @pytest.mark.asyncio async def test_error_raises(self): manager = await self._setup_overlapping("error") with pytest.raises(ResourceError, match="matched 2 resource templates"): await manager.read_resource("kb://docs/science/articles/quantum") @pytest.mark.asyncio async def test_ignore_returns_first_silently(self, caplog): manager = await self._setup_overlapping("ignore") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): result = await manager.read_resource("kb://docs/science/articles/quantum") assert result[0].text == "broad" assert "matched" not in caplog.text @pytest.mark.asyncio async def test_single_match_no_warning(self, caplog): """When only one template matches, no warning regardless of policy.""" manager = ResourceManager(multiple_match_policy="warn") tmpl = ResourceTemplate(uriTemplate="weather://{city}/current", name="Weather") await manager.add_template_with_handler(tmpl, lambda uri, city: f"weather-{city}") with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.resource"): result = await manager.read_resource("weather://london/current") assert result[0].text == "weather-london" assert "matched" not in caplog.text