Files
nocobase-mcp/tests/test_nb_client.py
T

335 lines
14 KiB
Python

import json
import os
import subprocess
import unittest
from unittest.mock import patch
from nocobase_crm_mcp.nb_client import NbAuthenticationError, NbClient, NbCommandError
class NbClientTests(unittest.TestCase):
def test_list_records_builds_safe_nb_command_and_environment(self) -> None:
calls: list[dict] = []
def runner(cmd, *, cwd, env, timeout, capture_output, text, check):
calls.append(
{
"cmd": cmd,
"cwd": cwd,
"env": env,
"timeout": timeout,
"capture_output": capture_output,
"text": text,
"check": check,
}
)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[{"id":1}],"meta":{"count":1}}', stderr="")
client = NbClient(env_name="nb-mcp", data_source="main", runner=runner)
result = client.list_records(
"customers",
filter={"$and": [{"status": {"$eq": "active"}}]},
fields=["id", "name"],
sort=["-updatedAt"],
page=2,
page_size=20,
)
self.assertEqual(result["data"], [{"id": 1}])
command = calls[0]["cmd"]
self.assertEqual(command[:4], ["nb", "api", "resource", "list"])
self.assertIn("--resource", command)
self.assertIn("customers", command)
self.assertIn("--env", command)
self.assertIn("nb-mcp", command)
self.assertIn("--yes", command)
self.assertIn("--json-output", command)
self.assertIn("--data-source", command)
self.assertIn("main", command)
self.assertIn("--filter", command)
self.assertIn(json.dumps({"$and": [{"status": {"$eq": "active"}}]}, separators=(",", ":")), command)
self.assertIn("--fields", command)
self.assertIn("id", command)
self.assertIn("--sort", command)
self.assertIn("-updatedAt", command)
self.assertEqual(calls[0]["env"]["NB_CLI_LOG_DISABLED"], "1")
self.assertEqual(calls[0]["env"]["NB_SKIP_STARTUP_UPDATE"], "1")
self.assertTrue(calls[0]["capture_output"])
self.assertTrue(calls[0]["text"])
self.assertFalse(calls[0]["check"])
def test_api_base_url_and_token_override_saved_env_auth_for_every_api_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
client = NbClient(
env_name="nb-mcp",
api_base_url="https://nb.example/api/",
api_token="long-lived-key",
runner=runner,
)
client.list_records("customers")
client.list_collection_fields("customers")
for command in commands:
self.assertEqual(command[command.index("--api-base-url") + 1], "https://nb.example/api")
self.assertEqual(command[command.index("--token") + 1], "long-lived-key")
def test_health_uses_token_aware_resource_probe(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[],"meta":{"count":0}}', stderr="")
health = NbClient(api_token="long-lived-key", runner=runner).health("customers", "id")
self.assertTrue(health["ok"])
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "list"])
self.assertEqual(commands[0][commands[0].index("--resource") + 1], "customers")
self.assertEqual(commands[0][commands[0].index("--fields") + 1], "id")
self.assertEqual(commands[0][commands[0].index("--page-size") + 1], "1")
self.assertEqual(commands[0][commands[0].index("--token") + 1], "long-lived-key")
def test_get_record_and_query_records_use_expected_commands(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
client.get_record("customers", 7, fields=["id"])
client.query_records(
"customers",
measures=[{"field": ["id"], "aggregation": "count", "alias": "count"}],
dimensions=[{"field": ["status"], "alias": "status"}],
orders=[{"field": ["status"], "alias": "status", "order": "asc"}],
limit=10,
timezone="+08:00",
)
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "get"])
self.assertIn("--filter-by-tk", commands[0])
self.assertIn("7", commands[0])
self.assertEqual(commands[1][:4], ["nb", "api", "resource", "query"])
self.assertIn("--measures", commands[1])
self.assertIn("--dimensions", commands[1])
self.assertIn("--orders", commands[1])
self.assertIn("--timezone", commands[1])
def test_absolute_nb_bin_prepends_its_directory_to_subprocess_path(self) -> None:
calls: list[dict] = []
def runner(cmd, *, env, **kwargs):
calls.append({"cmd": cmd, "env": env})
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True):
NbClient(env_name="nb-mcp", nb_bin="/opt/node/bin/nb", runner=runner).list_records("customers")
self.assertEqual(calls[0]["cmd"][0], "/opt/node/bin/nb")
self.assertEqual(calls[0]["env"]["PATH"], "/opt/node/bin:/usr/bin")
def test_create_record_uses_values_and_whitelist(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"id":7}}', stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
client.create_record("t_projects", {"f_project_name": "EV inverter"}, ["f_project_name"])
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "create"])
self.assertEqual(
json.loads(commands[0][commands[0].index("--values") + 1]),
{"f_project_name": "EV inverter"},
)
self.assertEqual(commands[0][commands[0].index("--whitelist") + 1], "f_project_name")
def test_update_record_uses_filter_by_tk(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"id":7}}', stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
client.update_record("t_projects", 7, {"client_ID": 3}, ["client_ID"])
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "update"])
self.assertEqual(commands[0][commands[0].index("--filter-by-tk") + 1], "7")
def test_record_writes_reject_empty_values_without_running_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{}}', stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
with self.assertRaises(ValueError):
client.create_record("t_projects", {}, ["f_project_name"])
with self.assertRaises(ValueError):
client.update_record("t_projects", 7, {}, ["f_project_name"])
self.assertEqual(commands, [])
def test_record_writes_reject_nested_values_without_running_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{}}', stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
nested_values = [
{"client": {"id": 3}},
{"client_ids": [3]},
{"client_ids": {3}},
{"client_ids": (3,)},
]
for values in nested_values:
with self.subTest(operation="create", values=values):
with self.assertRaises(ValueError):
client.create_record("t_projects", values, list(values))
with self.subTest(operation="update", values=values):
with self.assertRaises(ValueError):
client.update_record("t_projects", 7, values, list(values))
self.assertEqual(commands, [])
def test_list_collection_fields_uses_data_modeling_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(
cmd,
0,
stdout='{"data":[{"name":"f_customer_name","title":"客户名称","interface":"input"}]}',
stderr="",
)
result = NbClient(env_name="nb-mcp", runner=runner).list_collection_fields("t_nt3pjadelqi")
self.assertEqual(result["data"][0]["name"], "f_customer_name")
self.assertEqual(
commands[0][:6],
["nb", "api", "data-modeling", "collections", "fields", "list"],
)
self.assertIn("--collection-name", commands[0])
self.assertIn("t_nt3pjadelqi", commands[0])
self.assertIn("--env", commands[0])
self.assertIn("nb-mcp", commands[0])
self.assertIn("--yes", commands[0])
self.assertIn("--json-output", commands[0])
def test_get_collection_uses_data_modeling_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"name":"t_nt3pjadelqi"}}', stderr="")
NbClient(env_name="nb-mcp", runner=runner).get_collection("t_nt3pjadelqi", appends=["fields"])
self.assertEqual(commands[0][:5], ["nb", "api", "data-modeling", "collections", "get"])
self.assertIn("--filter-by-tk", commands[0])
self.assertIn("t_nt3pjadelqi", commands[0])
self.assertIn("--appends", commands[0])
self.assertIn("fields", commands[0])
def test_command_errors_redact_api_token(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd,
1,
stdout="",
stderr="Authorization failed for Bearer super-secret-token and --token super-secret-token",
)
client = NbClient(env_name="nb-mcp", api_token="super-secret-token", runner=runner)
with self.assertRaises(NbCommandError) as caught:
client.list_records("customers")
message = str(caught.exception)
self.assertNotIn("super-secret-token", message)
self.assertIn("<redacted>", message)
def test_authentication_failures_have_a_specific_error_type(self) -> None:
cases = (
"Request failed with status 401",
'{"errors":[{"code":"EXPIRED_TOKEN"}]}',
)
for stderr in cases:
with self.subTest(stderr=stderr):
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr=stderr)
with self.assertRaises(NbAuthenticationError):
NbClient(api_token="secret", runner=runner).list_records("customers")
def test_plain_forbidden_error_remains_an_acl_command_failure(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="Request failed with status 403: No permissions")
with self.assertRaises(NbCommandError) as caught:
NbClient(api_token="secret", runner=runner).list_records("customers")
self.assertNotIsInstance(caught.exception, NbAuthenticationError)
def test_invalid_json_output_raises_command_error(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, stdout="HTTP 200", stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
with self.assertRaises(NbCommandError):
client.list_records("customers")
def test_json_output_parser_tolerates_one_surrounding_log_message(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd,
0,
stdout='startup log\n{"data":[{"id":1}],"meta":{"count":1}}\nfinished',
stderr="",
)
result = NbClient(env_name="nb-mcp", runner=runner).list_records("customers")
self.assertEqual(result["data"], [{"id": 1}])
def test_json_output_parser_rejects_multiple_top_level_objects(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, stdout='{"log":1}\n{"data":[]}', stderr="")
with self.assertRaises(NbCommandError):
NbClient(env_name="nb-mcp", runner=runner).list_records("customers")
def test_environment_overrides_can_change_nb_home_without_shell(self) -> None:
captured_env = {}
def runner(cmd, *, env, **kwargs):
captured_env.update(env)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
with patch.dict(os.environ, {"NB_CLI_ROOT": "/tmp/custom-nb-root"}, clear=True):
NbClient(env_name="nb-mcp", runner=runner).list_records("customers")
self.assertEqual(captured_env["NB_CLI_ROOT"], "/tmp/custom-nb-root")
if __name__ == "__main__":
unittest.main()