hexdag 0.5.0.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- hexdag_plugins/storage/vector/pgvector.py +502 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Tests for Azure Blob Storage adapter."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hexdag_plugins.azure.azure_blob_adapter import AzureBlobAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def blob_adapter():
|
|
12
|
+
"""Create Azure Blob adapter for testing."""
|
|
13
|
+
return AzureBlobAdapter(
|
|
14
|
+
connection_string="DefaultEndpointsProtocol=https;AccountName=test;AccountKey=testkey123;EndpointSuffix=core.windows.net",
|
|
15
|
+
container_name="test-container",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def blob_adapter_managed_identity():
|
|
21
|
+
"""Create Azure Blob adapter with managed identity."""
|
|
22
|
+
return AzureBlobAdapter(
|
|
23
|
+
account_url="https://teststorage.blob.core.windows.net",
|
|
24
|
+
container_name="test-container",
|
|
25
|
+
use_managed_identity=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_adapter_initialization(blob_adapter):
|
|
31
|
+
"""Test adapter initializes with correct parameters."""
|
|
32
|
+
assert blob_adapter.container_name == "test-container"
|
|
33
|
+
assert blob_adapter.use_managed_identity is False
|
|
34
|
+
assert blob_adapter._container_client is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_adapter_initialization_managed_identity(blob_adapter_managed_identity):
|
|
39
|
+
"""Test adapter initializes with managed identity."""
|
|
40
|
+
assert blob_adapter_managed_identity.account_url == "https://teststorage.blob.core.windows.net"
|
|
41
|
+
assert blob_adapter_managed_identity.use_managed_identity is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_upload_success(blob_adapter):
|
|
46
|
+
"""Test successful blob upload."""
|
|
47
|
+
mock_blob_client = AsyncMock()
|
|
48
|
+
mock_blob_client.url = "https://test.blob.core.windows.net/test-container/test.txt"
|
|
49
|
+
mock_blob_client.upload_blob = AsyncMock()
|
|
50
|
+
|
|
51
|
+
mock_container = AsyncMock()
|
|
52
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
53
|
+
|
|
54
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
55
|
+
url = await blob_adapter.aupload("test.txt", b"hello world")
|
|
56
|
+
|
|
57
|
+
assert url == "https://test.blob.core.windows.net/test-container/test.txt"
|
|
58
|
+
mock_blob_client.upload_blob.assert_called_once()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_upload_string(blob_adapter):
|
|
63
|
+
"""Test uploading string data."""
|
|
64
|
+
mock_blob_client = AsyncMock()
|
|
65
|
+
mock_blob_client.url = "https://test.blob.core.windows.net/test-container/test.txt"
|
|
66
|
+
mock_blob_client.upload_blob = AsyncMock()
|
|
67
|
+
|
|
68
|
+
mock_container = AsyncMock()
|
|
69
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
70
|
+
|
|
71
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
72
|
+
await blob_adapter.aupload("test.txt", "hello string")
|
|
73
|
+
|
|
74
|
+
call_args = mock_blob_client.upload_blob.call_args
|
|
75
|
+
assert call_args.kwargs.get("content_type") == "text/plain"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio
|
|
79
|
+
async def test_download_success(blob_adapter):
|
|
80
|
+
"""Test successful blob download."""
|
|
81
|
+
mock_stream = AsyncMock()
|
|
82
|
+
mock_stream.readall = AsyncMock(return_value=b"downloaded content")
|
|
83
|
+
|
|
84
|
+
mock_blob_client = AsyncMock()
|
|
85
|
+
mock_blob_client.download_blob = AsyncMock(return_value=mock_stream)
|
|
86
|
+
|
|
87
|
+
mock_container = AsyncMock()
|
|
88
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
89
|
+
|
|
90
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
91
|
+
content = await blob_adapter.adownload("test.txt")
|
|
92
|
+
|
|
93
|
+
assert content == b"downloaded content"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_download_not_found(blob_adapter):
|
|
98
|
+
"""Test download raises FileNotFoundError for missing blob."""
|
|
99
|
+
mock_blob_client = AsyncMock()
|
|
100
|
+
mock_blob_client.download_blob = AsyncMock(
|
|
101
|
+
side_effect=Exception("BlobNotFound: The specified blob does not exist")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
mock_container = AsyncMock()
|
|
105
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
106
|
+
|
|
107
|
+
with (
|
|
108
|
+
patch.object(blob_adapter, "_get_container", return_value=mock_container),
|
|
109
|
+
pytest.raises(FileNotFoundError, match="not found"),
|
|
110
|
+
):
|
|
111
|
+
await blob_adapter.adownload("missing.txt")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_download_text(blob_adapter):
|
|
116
|
+
"""Test downloading blob as text."""
|
|
117
|
+
mock_stream = AsyncMock()
|
|
118
|
+
mock_stream.readall = AsyncMock(return_value=b"text content")
|
|
119
|
+
|
|
120
|
+
mock_blob_client = AsyncMock()
|
|
121
|
+
mock_blob_client.download_blob = AsyncMock(return_value=mock_stream)
|
|
122
|
+
|
|
123
|
+
mock_container = AsyncMock()
|
|
124
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
125
|
+
|
|
126
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
127
|
+
text = await blob_adapter.adownload_text("test.txt")
|
|
128
|
+
|
|
129
|
+
assert text == "text content"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_delete_success(blob_adapter):
|
|
134
|
+
"""Test successful blob deletion."""
|
|
135
|
+
mock_blob_client = AsyncMock()
|
|
136
|
+
mock_blob_client.delete_blob = AsyncMock()
|
|
137
|
+
|
|
138
|
+
mock_container = AsyncMock()
|
|
139
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
140
|
+
|
|
141
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
142
|
+
result = await blob_adapter.adelete("test.txt")
|
|
143
|
+
|
|
144
|
+
assert result is True
|
|
145
|
+
mock_blob_client.delete_blob.assert_called_once()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_delete_not_found(blob_adapter):
|
|
150
|
+
"""Test delete returns False for missing blob."""
|
|
151
|
+
mock_blob_client = AsyncMock()
|
|
152
|
+
mock_blob_client.delete_blob = AsyncMock(side_effect=Exception("BlobNotFound"))
|
|
153
|
+
|
|
154
|
+
mock_container = AsyncMock()
|
|
155
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
156
|
+
|
|
157
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
158
|
+
result = await blob_adapter.adelete("missing.txt")
|
|
159
|
+
|
|
160
|
+
assert result is False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.asyncio
|
|
164
|
+
async def test_exists_true(blob_adapter):
|
|
165
|
+
"""Test exists returns True for existing blob."""
|
|
166
|
+
mock_blob_client = AsyncMock()
|
|
167
|
+
mock_blob_client.exists = AsyncMock(return_value=True)
|
|
168
|
+
|
|
169
|
+
mock_container = AsyncMock()
|
|
170
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
171
|
+
|
|
172
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
173
|
+
result = await blob_adapter.aexists("test.txt")
|
|
174
|
+
|
|
175
|
+
assert result is True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_exists_false(blob_adapter):
|
|
180
|
+
"""Test exists returns False for missing blob."""
|
|
181
|
+
mock_blob_client = AsyncMock()
|
|
182
|
+
mock_blob_client.exists = AsyncMock(return_value=False)
|
|
183
|
+
|
|
184
|
+
mock_container = AsyncMock()
|
|
185
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
186
|
+
|
|
187
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
188
|
+
result = await blob_adapter.aexists("missing.txt")
|
|
189
|
+
|
|
190
|
+
assert result is False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def test_list_blobs(blob_adapter):
|
|
195
|
+
"""Test listing blobs."""
|
|
196
|
+
# Create mock blobs
|
|
197
|
+
mock_blob1 = MagicMock()
|
|
198
|
+
mock_blob1.name = "file1.txt"
|
|
199
|
+
mock_blob1.size = 100
|
|
200
|
+
mock_blob1.last_modified = "2024-01-01"
|
|
201
|
+
mock_blob1.content_settings = MagicMock()
|
|
202
|
+
mock_blob1.content_settings.content_type = "text/plain"
|
|
203
|
+
mock_blob1.metadata = {"key": "value"}
|
|
204
|
+
|
|
205
|
+
mock_blob2 = MagicMock()
|
|
206
|
+
mock_blob2.name = "file2.txt"
|
|
207
|
+
mock_blob2.size = 200
|
|
208
|
+
mock_blob2.last_modified = "2024-01-02"
|
|
209
|
+
mock_blob2.content_settings = None
|
|
210
|
+
mock_blob2.metadata = None
|
|
211
|
+
|
|
212
|
+
# Create async iterator
|
|
213
|
+
async def mock_list_blobs(name_starts_with=None):
|
|
214
|
+
for blob in [mock_blob1, mock_blob2]:
|
|
215
|
+
yield blob
|
|
216
|
+
|
|
217
|
+
mock_container = AsyncMock()
|
|
218
|
+
mock_container.list_blobs = mock_list_blobs
|
|
219
|
+
|
|
220
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
221
|
+
results = await blob_adapter.alist()
|
|
222
|
+
|
|
223
|
+
assert len(results) == 2
|
|
224
|
+
assert results[0]["name"] == "file1.txt"
|
|
225
|
+
assert results[0]["size"] == 100
|
|
226
|
+
assert results[1]["name"] == "file2.txt"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.mark.asyncio
|
|
230
|
+
async def test_list_blobs_with_prefix(blob_adapter):
|
|
231
|
+
"""Test listing blobs with prefix filter."""
|
|
232
|
+
mock_blob = MagicMock()
|
|
233
|
+
mock_blob.name = "reports/file1.txt"
|
|
234
|
+
mock_blob.size = 100
|
|
235
|
+
mock_blob.last_modified = "2024-01-01"
|
|
236
|
+
mock_blob.content_settings = None
|
|
237
|
+
mock_blob.metadata = None
|
|
238
|
+
|
|
239
|
+
async def mock_list_blobs(name_starts_with=None):
|
|
240
|
+
if name_starts_with == "reports/":
|
|
241
|
+
yield mock_blob
|
|
242
|
+
|
|
243
|
+
mock_container = AsyncMock()
|
|
244
|
+
mock_container.list_blobs = mock_list_blobs
|
|
245
|
+
|
|
246
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
247
|
+
results = await blob_adapter.alist(prefix="reports/")
|
|
248
|
+
|
|
249
|
+
assert len(results) == 1
|
|
250
|
+
assert results[0]["name"] == "reports/file1.txt"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pytest.mark.asyncio
|
|
254
|
+
async def test_upload_json(blob_adapter):
|
|
255
|
+
"""Test uploading JSON data."""
|
|
256
|
+
mock_blob_client = AsyncMock()
|
|
257
|
+
mock_blob_client.url = "https://test.blob.core.windows.net/test-container/data.json"
|
|
258
|
+
mock_blob_client.upload_blob = AsyncMock()
|
|
259
|
+
|
|
260
|
+
mock_container = AsyncMock()
|
|
261
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
262
|
+
|
|
263
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
264
|
+
url = await blob_adapter.aupload_json("data.json", {"key": "value"})
|
|
265
|
+
|
|
266
|
+
assert url == "https://test.blob.core.windows.net/test-container/data.json"
|
|
267
|
+
call_args = mock_blob_client.upload_blob.call_args
|
|
268
|
+
assert call_args.kwargs.get("content_type") == "application/json"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@pytest.mark.asyncio
|
|
272
|
+
async def test_download_json(blob_adapter):
|
|
273
|
+
"""Test downloading and parsing JSON."""
|
|
274
|
+
mock_stream = AsyncMock()
|
|
275
|
+
mock_stream.readall = AsyncMock(return_value=b'{"key": "value"}')
|
|
276
|
+
|
|
277
|
+
mock_blob_client = AsyncMock()
|
|
278
|
+
mock_blob_client.download_blob = AsyncMock(return_value=mock_stream)
|
|
279
|
+
|
|
280
|
+
mock_container = AsyncMock()
|
|
281
|
+
mock_container.get_blob_client.return_value = mock_blob_client
|
|
282
|
+
|
|
283
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
284
|
+
data = await blob_adapter.adownload_json("data.json")
|
|
285
|
+
|
|
286
|
+
assert data == {"key": "value"}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.asyncio
|
|
290
|
+
async def test_health_check_healthy(blob_adapter):
|
|
291
|
+
"""Test health check returns healthy status."""
|
|
292
|
+
mock_blob = MagicMock()
|
|
293
|
+
mock_blob.name = "file.txt"
|
|
294
|
+
|
|
295
|
+
async def mock_list_blobs():
|
|
296
|
+
yield mock_blob
|
|
297
|
+
|
|
298
|
+
mock_container = AsyncMock()
|
|
299
|
+
mock_container.list_blobs = mock_list_blobs
|
|
300
|
+
|
|
301
|
+
with patch.object(blob_adapter, "_get_container", return_value=mock_container):
|
|
302
|
+
status = await blob_adapter.ahealth_check()
|
|
303
|
+
|
|
304
|
+
assert status.status == "healthy"
|
|
305
|
+
assert status.adapter_name == "AzureBlob"
|
|
306
|
+
assert status.details["container"] == "test-container"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@pytest.mark.asyncio
|
|
310
|
+
async def test_health_check_unhealthy(blob_adapter):
|
|
311
|
+
"""Test health check returns unhealthy on error."""
|
|
312
|
+
with patch.object(blob_adapter, "_get_container", side_effect=Exception("Connection failed")):
|
|
313
|
+
status = await blob_adapter.ahealth_check()
|
|
314
|
+
|
|
315
|
+
assert status.status == "unhealthy"
|
|
316
|
+
assert "error" in status.details
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@pytest.mark.asyncio
|
|
320
|
+
async def test_to_dict(blob_adapter):
|
|
321
|
+
"""Test serialization excludes secrets."""
|
|
322
|
+
config = blob_adapter.to_dict()
|
|
323
|
+
|
|
324
|
+
assert "container_name" in config
|
|
325
|
+
assert config["container_name"] == "test-container"
|
|
326
|
+
assert "connection_string" not in config # Secret excluded
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@pytest.mark.asyncio
|
|
330
|
+
async def test_managed_identity_requires_account_url():
|
|
331
|
+
"""Test managed identity requires account_url."""
|
|
332
|
+
adapter = AzureBlobAdapter(
|
|
333
|
+
use_managed_identity=True,
|
|
334
|
+
container_name="test",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
with pytest.raises(ValueError, match="account_url is required"):
|
|
338
|
+
await adapter._get_container()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@pytest.mark.asyncio
|
|
342
|
+
async def test_connection_string_required_without_managed_identity():
|
|
343
|
+
"""Test connection_string required without managed identity."""
|
|
344
|
+
adapter = AzureBlobAdapter(
|
|
345
|
+
use_managed_identity=False,
|
|
346
|
+
container_name="test",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
with pytest.raises(ValueError, match="connection_string is required"):
|
|
350
|
+
await adapter._get_container()
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Tests for Azure Cosmos DB adapter."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hexdag_plugins.azure.azure_cosmos_adapter import AzureCosmosAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def cosmos_adapter():
|
|
12
|
+
"""Create Azure Cosmos adapter for testing."""
|
|
13
|
+
return AzureCosmosAdapter(
|
|
14
|
+
endpoint="https://test-cosmos.documents.azure.com:443/",
|
|
15
|
+
key="test-key-123",
|
|
16
|
+
database_name="test-db",
|
|
17
|
+
container_name="test-container",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def cosmos_adapter_managed_identity():
|
|
23
|
+
"""Create Azure Cosmos adapter with managed identity."""
|
|
24
|
+
return AzureCosmosAdapter(
|
|
25
|
+
endpoint="https://test-cosmos.documents.azure.com:443/",
|
|
26
|
+
database_name="test-db",
|
|
27
|
+
container_name="test-container",
|
|
28
|
+
use_managed_identity=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_adapter_initialization(cosmos_adapter):
|
|
34
|
+
"""Test adapter initializes with correct parameters."""
|
|
35
|
+
assert cosmos_adapter.endpoint == "https://test-cosmos.documents.azure.com:443/"
|
|
36
|
+
assert cosmos_adapter.database_name == "test-db"
|
|
37
|
+
assert cosmos_adapter.container_name == "test-container"
|
|
38
|
+
assert cosmos_adapter.partition_key == "/agent_id"
|
|
39
|
+
assert cosmos_adapter.use_managed_identity is False
|
|
40
|
+
assert cosmos_adapter._container is None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_store_success(cosmos_adapter):
|
|
45
|
+
"""Test successful data storage."""
|
|
46
|
+
mock_container = AsyncMock()
|
|
47
|
+
mock_container.upsert_item = AsyncMock()
|
|
48
|
+
|
|
49
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
50
|
+
await cosmos_adapter.astore("agent-1", {"context": "test data"})
|
|
51
|
+
|
|
52
|
+
mock_container.upsert_item.assert_called_once()
|
|
53
|
+
call_args = mock_container.upsert_item.call_args
|
|
54
|
+
document = call_args[0][0]
|
|
55
|
+
assert document["id"] == "agent-1"
|
|
56
|
+
assert document["agent_id"] == "agent-1"
|
|
57
|
+
assert document["data"] == {"context": "test data"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_store_with_composite_key(cosmos_adapter):
|
|
62
|
+
"""Test storage with composite key extracts agent_id."""
|
|
63
|
+
mock_container = AsyncMock()
|
|
64
|
+
mock_container.upsert_item = AsyncMock()
|
|
65
|
+
|
|
66
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
67
|
+
await cosmos_adapter.astore("agent-1:conversation:session-1", {"messages": []})
|
|
68
|
+
|
|
69
|
+
call_args = mock_container.upsert_item.call_args
|
|
70
|
+
document = call_args[0][0]
|
|
71
|
+
assert document["id"] == "agent-1:conversation:session-1"
|
|
72
|
+
assert document["agent_id"] == "agent-1"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_store_with_metadata(cosmos_adapter):
|
|
77
|
+
"""Test storage with metadata."""
|
|
78
|
+
mock_container = AsyncMock()
|
|
79
|
+
mock_container.upsert_item = AsyncMock()
|
|
80
|
+
|
|
81
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
82
|
+
await cosmos_adapter.astore("agent-1", {"data": "value"}, metadata={"type": "context"})
|
|
83
|
+
|
|
84
|
+
call_args = mock_container.upsert_item.call_args
|
|
85
|
+
document = call_args[0][0]
|
|
86
|
+
assert document["metadata"] == {"type": "context"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_retrieve_success(cosmos_adapter):
|
|
91
|
+
"""Test successful data retrieval."""
|
|
92
|
+
mock_item = {"id": "agent-1", "data": {"context": "test"}}
|
|
93
|
+
|
|
94
|
+
mock_container = AsyncMock()
|
|
95
|
+
mock_container.read_item = AsyncMock(return_value=mock_item)
|
|
96
|
+
|
|
97
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
98
|
+
result = await cosmos_adapter.aretrieve("agent-1")
|
|
99
|
+
|
|
100
|
+
assert result == {"context": "test"}
|
|
101
|
+
mock_container.read_item.assert_called_once_with(item="agent-1", partition_key="agent-1")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_retrieve_not_found(cosmos_adapter):
|
|
106
|
+
"""Test retrieval returns None for missing key."""
|
|
107
|
+
mock_container = AsyncMock()
|
|
108
|
+
mock_container.read_item = AsyncMock(side_effect=Exception("NotFound"))
|
|
109
|
+
|
|
110
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
111
|
+
result = await cosmos_adapter.aretrieve("missing-key")
|
|
112
|
+
|
|
113
|
+
assert result is None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_delete_success(cosmos_adapter):
|
|
118
|
+
"""Test successful deletion."""
|
|
119
|
+
mock_container = AsyncMock()
|
|
120
|
+
mock_container.delete_item = AsyncMock()
|
|
121
|
+
|
|
122
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
123
|
+
result = await cosmos_adapter.adelete("agent-1")
|
|
124
|
+
|
|
125
|
+
assert result is True
|
|
126
|
+
mock_container.delete_item.assert_called_once_with(item="agent-1", partition_key="agent-1")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.asyncio
|
|
130
|
+
async def test_delete_not_found(cosmos_adapter):
|
|
131
|
+
"""Test deletion returns False for missing key."""
|
|
132
|
+
mock_container = AsyncMock()
|
|
133
|
+
mock_container.delete_item = AsyncMock(side_effect=Exception("NotFound"))
|
|
134
|
+
|
|
135
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
136
|
+
result = await cosmos_adapter.adelete("missing-key")
|
|
137
|
+
|
|
138
|
+
assert result is False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_list_all_keys(cosmos_adapter):
|
|
143
|
+
"""Test listing all keys."""
|
|
144
|
+
mock_items = [{"id": "key-1"}, {"id": "key-2"}]
|
|
145
|
+
|
|
146
|
+
async def mock_iter():
|
|
147
|
+
for item in mock_items:
|
|
148
|
+
yield item
|
|
149
|
+
|
|
150
|
+
mock_container = MagicMock()
|
|
151
|
+
mock_container.query_items.return_value = mock_iter()
|
|
152
|
+
|
|
153
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
154
|
+
keys = await cosmos_adapter.alist()
|
|
155
|
+
|
|
156
|
+
assert keys == ["key-1", "key-2"]
|
|
157
|
+
mock_container.query_items.assert_called_once()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.asyncio
|
|
161
|
+
async def test_list_with_prefix(cosmos_adapter):
|
|
162
|
+
"""Test listing keys with prefix filter."""
|
|
163
|
+
mock_items = [{"id": "agent-1:conv"}, {"id": "agent-1:ctx"}]
|
|
164
|
+
|
|
165
|
+
async def mock_iter():
|
|
166
|
+
for item in mock_items:
|
|
167
|
+
yield item
|
|
168
|
+
|
|
169
|
+
mock_container = MagicMock()
|
|
170
|
+
mock_container.query_items.return_value = mock_iter()
|
|
171
|
+
|
|
172
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
173
|
+
keys = await cosmos_adapter.alist(prefix="agent-1")
|
|
174
|
+
|
|
175
|
+
assert len(keys) == 2
|
|
176
|
+
call_args = mock_container.query_items.call_args
|
|
177
|
+
assert "STARTSWITH" in call_args[1]["query"]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.mark.asyncio
|
|
181
|
+
async def test_store_conversation(cosmos_adapter):
|
|
182
|
+
"""Test storing conversation history."""
|
|
183
|
+
mock_container = AsyncMock()
|
|
184
|
+
mock_container.upsert_item = AsyncMock()
|
|
185
|
+
|
|
186
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
187
|
+
messages = [
|
|
188
|
+
{"role": "user", "content": "Hello"},
|
|
189
|
+
{"role": "assistant", "content": "Hi!"},
|
|
190
|
+
]
|
|
191
|
+
await cosmos_adapter.astore_conversation("agent-1", messages, "session-1")
|
|
192
|
+
|
|
193
|
+
call_args = mock_container.upsert_item.call_args
|
|
194
|
+
document = call_args[0][0]
|
|
195
|
+
assert document["id"] == "agent-1:conversation:session-1"
|
|
196
|
+
assert document["data"]["messages"] == messages
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.mark.asyncio
|
|
200
|
+
async def test_retrieve_conversation(cosmos_adapter):
|
|
201
|
+
"""Test retrieving conversation history."""
|
|
202
|
+
mock_item = {
|
|
203
|
+
"id": "agent-1:conversation:session-1",
|
|
204
|
+
"data": {
|
|
205
|
+
"messages": [
|
|
206
|
+
{"role": "user", "content": "Hello"},
|
|
207
|
+
{"role": "assistant", "content": "Hi!"},
|
|
208
|
+
]
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
mock_container = AsyncMock()
|
|
213
|
+
mock_container.read_item = AsyncMock(return_value=mock_item)
|
|
214
|
+
|
|
215
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
216
|
+
messages = await cosmos_adapter.aretrieve_conversation("agent-1", "session-1")
|
|
217
|
+
|
|
218
|
+
assert len(messages) == 2
|
|
219
|
+
assert messages[0]["role"] == "user"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_retrieve_conversation_not_found(cosmos_adapter):
|
|
224
|
+
"""Test retrieving missing conversation returns empty list."""
|
|
225
|
+
mock_container = AsyncMock()
|
|
226
|
+
mock_container.read_item = AsyncMock(side_effect=Exception("NotFound"))
|
|
227
|
+
|
|
228
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
229
|
+
messages = await cosmos_adapter.aretrieve_conversation("agent-1", "missing")
|
|
230
|
+
|
|
231
|
+
assert messages == []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.mark.asyncio
|
|
235
|
+
async def test_clear_agent(cosmos_adapter):
|
|
236
|
+
"""Test clearing all agent data."""
|
|
237
|
+
mock_items = [{"id": "agent-1:conv"}, {"id": "agent-1:ctx"}]
|
|
238
|
+
|
|
239
|
+
async def mock_iter():
|
|
240
|
+
for item in mock_items:
|
|
241
|
+
yield item
|
|
242
|
+
|
|
243
|
+
mock_container = MagicMock()
|
|
244
|
+
mock_container.query_items.return_value = mock_iter()
|
|
245
|
+
mock_container.delete_item = AsyncMock()
|
|
246
|
+
|
|
247
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
248
|
+
count = await cosmos_adapter.aclear_agent("agent-1")
|
|
249
|
+
|
|
250
|
+
assert count == 2
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pytest.mark.asyncio
|
|
254
|
+
async def test_search(cosmos_adapter):
|
|
255
|
+
"""Test searching memories."""
|
|
256
|
+
mock_items = [
|
|
257
|
+
{"id": "key-1", "data": {"content": "test query"}, "metadata": {}, "created_at": 1234567890}
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
async def mock_iter():
|
|
261
|
+
for item in mock_items:
|
|
262
|
+
yield item
|
|
263
|
+
|
|
264
|
+
mock_container = MagicMock()
|
|
265
|
+
mock_container.query_items.return_value = mock_iter()
|
|
266
|
+
|
|
267
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
268
|
+
results = await cosmos_adapter.asearch("test", top_k=5)
|
|
269
|
+
|
|
270
|
+
assert len(results) == 1
|
|
271
|
+
call_args = mock_container.query_items.call_args
|
|
272
|
+
assert "CONTAINS" in call_args[1]["query"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@pytest.mark.asyncio
|
|
276
|
+
async def test_health_check_healthy(cosmos_adapter):
|
|
277
|
+
"""Test health check returns healthy status."""
|
|
278
|
+
|
|
279
|
+
async def mock_iter():
|
|
280
|
+
yield 42 # Document count
|
|
281
|
+
|
|
282
|
+
mock_container = MagicMock()
|
|
283
|
+
mock_container.query_items.return_value = mock_iter()
|
|
284
|
+
|
|
285
|
+
with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
|
|
286
|
+
status = await cosmos_adapter.ahealth_check()
|
|
287
|
+
|
|
288
|
+
assert status.status == "healthy"
|
|
289
|
+
assert status.adapter_name == "AzureCosmos"
|
|
290
|
+
assert status.details["database"] == "test-db"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
async def test_health_check_unhealthy(cosmos_adapter):
|
|
295
|
+
"""Test health check returns unhealthy on error."""
|
|
296
|
+
with patch.object(cosmos_adapter, "_get_container", side_effect=Exception("Connection failed")):
|
|
297
|
+
status = await cosmos_adapter.ahealth_check()
|
|
298
|
+
|
|
299
|
+
assert status.status == "unhealthy"
|
|
300
|
+
assert "error" in status.details
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.mark.asyncio
|
|
304
|
+
async def test_to_dict(cosmos_adapter):
|
|
305
|
+
"""Test serialization excludes secrets."""
|
|
306
|
+
config = cosmos_adapter.to_dict()
|
|
307
|
+
|
|
308
|
+
assert "endpoint" in config
|
|
309
|
+
assert "database_name" in config
|
|
310
|
+
assert "key" not in config # Secret excluded
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@pytest.mark.asyncio
|
|
314
|
+
async def test_key_required_without_managed_identity():
|
|
315
|
+
"""Test key is required without managed identity."""
|
|
316
|
+
adapter = AzureCosmosAdapter(
|
|
317
|
+
endpoint="https://test.documents.azure.com:443/",
|
|
318
|
+
use_managed_identity=False,
|
|
319
|
+
database_name="test",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
with pytest.raises(ValueError, match="key is required"):
|
|
323
|
+
await adapter._get_container()
|