blob: 008aacf1b29a5a34fe671e72c3a79db91b4175c1 [file]
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from iotdb_mcp_server.session_manager import IoTDBSessionManager # noqa: E402
from iotdb_mcp_server.session_manager import ( # noqa: E402
create_table_session_pool,
create_tree_session_pool,
is_iotdb_connection_error,
make_tree_pool_config,
)
from iotdb_mcp_server.target_lifecycle import ( # noqa: E402
IoTDBTargetCandidateStore,
candidate_target,
verify_target_once,
)
from iotdb_mcp_server.target_registry import ( # noqa: E402
IoTDBTargetRegistry,
target_from_mapping,
)
def _seed_session_pool(pool, *sessions) -> None:
queue = getattr(pool, "_SessionPool__queue")
for session in sessions:
queue.put(session)
setattr(pool, "_SessionPool__pool_size", len(sessions))
class TargetLifecycleTest(unittest.TestCase):
def test_empty_registry_is_valid_and_not_resolvable(self) -> None:
registry = IoTDBTargetRegistry({})
self.assertIsNone(registry.default_target_id)
self.assertEqual(registry.list_targets(), [])
with self.assertRaisesRegex(KeyError, "No verified IoTDB target"):
registry.resolve()
def test_from_env_ignores_unverified_persisted_targets(self) -> None:
payload = {
"default_target_id": "unverified",
"targets": {
"unverified": {
"target_id": "unverified",
"host": "192.168.99.15",
"port": 6667,
"user": "root",
"password": "stale",
},
"verified": {
"target_id": "verified",
"host": "192.168.99.20",
"port": 6667,
"user": "root",
"password": "known-good",
"verified_at": "2026-07-10T00:00:00+00:00",
},
},
}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "targets.json"
path.write_text(json.dumps(payload), encoding="utf-8")
registry = IoTDBTargetRegistry.from_env(
{"TIMESEEK_IOTDB_TARGETS_FILE": str(path)}
)
self.assertEqual(
[target["target_id"] for target in registry.list_targets()],
["verified"],
)
self.assertEqual(registry.default_target_id, "verified")
def test_candidate_rejects_credentials_and_is_one_time(self) -> None:
store = IoTDBTargetCandidateStore()
with self.assertRaisesRegex(ValueError, "cannot contain credentials"):
store.prepare({"host": "192.168.99.15", "password": "secret"})
candidate = store.prepare({"host": "192.168.99.15", "port": 6667})
self.assertNotIn("password", candidate.as_dict()["template"])
self.assertEqual(store.consume(candidate.candidate_id), candidate)
with self.assertRaisesRegex(KeyError, "already consumed"):
store.consume(candidate.candidate_id)
def test_failed_candidate_requires_user_confirmed_retry(self) -> None:
store = IoTDBTargetCandidateStore(retry_guard_seconds=60)
candidate = store.prepare({"host": "192.168.99.15", "port": 6667})
store.consume(candidate.candidate_id)
store.record_failure(candidate)
with self.assertRaisesRegex(PermissionError, "explicitly requests"):
store.prepare({"host": "192.168.99.15", "port": 6667})
retried = store.prepare(
{"host": "192.168.99.15", "port": 6667},
user_confirmed_retry=True,
)
self.assertTrue(retried.candidate_id.startswith("candidate-"))
def test_successful_verification_marks_target_publishable(self) -> None:
store = IoTDBTargetCandidateStore()
candidate = store.prepare({"host": "192.168.99.15", "port": 6667})
target = candidate_target(
store.consume(candidate.candidate_id),
user="root",
password="known-good",
)
session = Mock()
with patch("iotdb_mcp_server.target_lifecycle.Session", return_value=session):
verified = verify_target_once(target)
session.open.assert_called_once()
session.execute_query_statement.assert_called_once_with("SHOW VERSION")
session.close.assert_called_once()
self.assertTrue(verified.verified_at)
self.assertEqual(
verified.last_known_good_credential["password"], "known-good"
)
def test_connection_error_evicts_last_target(self) -> None:
target = target_from_mapping(
{
"target_id": "cloud",
"host": "192.168.99.15",
"port": 6667,
"user": "root",
"password": "known-good",
"sql_dialect": "tree",
}
)
manager = IoTDBSessionManager(
IoTDBTargetRegistry({target.target_id: target}, target.target_id)
)
callback = Mock()
manager.set_connection_error_callback(callback)
raw_pool = Mock()
raw_pool.get_session.side_effect = ConnectionError("connection refused")
with patch(
"iotdb_mcp_server.session_manager.create_tree_session_pool",
return_value=raw_pool,
):
pool = manager.tree_pool(target.target_id)
with self.assertRaises(ConnectionError):
pool.get_session()
self.assertEqual(manager.registry.list_targets(), [])
callback.assert_called_once()
def test_pool_config_uses_single_primary_endpoint(self) -> None:
target = target_from_mapping(
{
"target_id": "cloud",
"host": "192.168.99.15",
"port": 6667,
"node_urls": ["192.168.99.15:6667", "192.168.99.16:6667"],
"user": "root",
"password": "known-good",
}
)
pool_config = make_tree_pool_config(target)
self.assertEqual(pool_config.host, "192.168.99.15")
self.assertEqual(pool_config.port, 6667)
self.assertFalse(pool_config.node_urls)
def test_generic_sql_error_does_not_count_as_connection_error(self) -> None:
self.assertFalse(is_iotdb_connection_error(RuntimeError("syntax error")))
self.assertTrue(is_iotdb_connection_error(RuntimeError("Status code 801")))
def test_pool_wait_timeout_does_not_count_as_connection_error(self) -> None:
self.assertFalse(
is_iotdb_connection_error(
TimeoutError("Wait to get session timeout in SessionPool")
)
)
def test_managed_tree_session_is_returned_to_pool(self) -> None:
target = target_from_mapping(
{
"target_id": "cloud",
"host": "192.168.99.15",
"port": 6667,
"user": "root",
"password": "known-good",
"sql_dialect": "tree",
}
)
manager = IoTDBSessionManager(
IoTDBTargetRegistry({target.target_id: target}, target.target_id)
)
manager.set_connection_error_callback(Mock())
raw_pool = Mock()
raw_session = Mock()
raw_pool.get_session.return_value = raw_session
with patch(
"iotdb_mcp_server.session_manager.create_tree_session_pool",
return_value=raw_pool,
):
session = manager.tree_pool(target.target_id).get_session()
session.close()
session.close()
raw_pool.put_back.assert_called_once_with(raw_session)
raw_session.close.assert_not_called()
def test_closed_table_pool_discards_all_borrowed_sessions(self) -> None:
target = target_from_mapping(
{
"target_id": "table-cloud",
"host": "192.168.99.15",
"port": 6667,
"user": "root",
"password": "known-good",
"database": "test",
"sql_dialect": "table",
}
)
manager = IoTDBSessionManager(
IoTDBTargetRegistry({target.target_id: target}, target.target_id)
)
callback = Mock()
manager.set_connection_error_callback(callback)
driver_session_a = Mock()
driver_session_b = Mock()
database_error = ConnectionError("original database failure")
successful_result = object()
driver_session_a.execute_query_statement.side_effect = database_error
driver_session_b.execute_query_statement.return_value = successful_result
table_pool = create_table_session_pool(target, max_pool_size=2)
driver_pool = table_pool._session_pool
_seed_session_pool(driver_pool, driver_session_a, driver_session_b)
with patch(
"iotdb_mcp_server.session_manager.create_table_session_pool",
return_value=table_pool,
):
pool = manager.table_pool(target.target_id, max_pool_size=2)
session_a = pool.get_session()
session_b = pool.get_session()
self.assertIs(
session_b.execute_query_statement("SHOW TABLES"),
successful_result,
)
with self.assertRaisesRegex(
ConnectionError,
"original database failure",
):
try:
session_a.execute_query_statement("SHOW TABLES")
finally:
session_a.close()
session_b.close()
session_b.close()
self.assertTrue(getattr(driver_pool, "_SessionPool__closed"))
driver_session_a.close.assert_called_once()
driver_session_b.close.assert_called_once()
callback.assert_called_once_with(
target.target_id,
database_error,
manager.registry,
)
def test_closed_tree_pool_discards_all_borrowed_sessions(self) -> None:
target = target_from_mapping(
{
"target_id": "tree-cloud",
"host": "192.168.99.15",
"port": 6667,
"user": "root",
"password": "known-good",
"sql_dialect": "tree",
}
)
manager = IoTDBSessionManager(
IoTDBTargetRegistry({target.target_id: target}, target.target_id)
)
callback = Mock()
manager.set_connection_error_callback(callback)
raw_session_a = Mock()
raw_session_b = Mock()
database_error = ConnectionError("original database failure")
successful_result = object()
raw_session_a.execute_query_statement.side_effect = database_error
raw_session_b.execute_query_statement.return_value = successful_result
raw_pool = create_tree_session_pool(target, max_pool_size=2)
_seed_session_pool(raw_pool, raw_session_a, raw_session_b)
with patch(
"iotdb_mcp_server.session_manager.create_tree_session_pool",
return_value=raw_pool,
):
pool = manager.tree_pool(target.target_id, max_pool_size=2)
session_a = pool.get_session()
session_b = pool.get_session()
self.assertIs(
session_b.execute_query_statement("SHOW DATABASES"),
successful_result,
)
with self.assertRaisesRegex(
ConnectionError,
"original database failure",
):
try:
session_a.execute_query_statement("SHOW DATABASES")
finally:
session_a.close()
session_b.close()
session_b.close()
self.assertTrue(getattr(raw_pool, "_SessionPool__closed"))
raw_session_a.close.assert_called_once()
raw_session_b.close.assert_called_once()
callback.assert_called_once_with(
target.target_id,
database_error,
manager.registry,
)
if __name__ == "__main__":
unittest.main()