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_environment_credentials_reads_saved_user_login_without_token_in_argv(self) -> None: commands: list[list[str]] = [] def runner(cmd, **kwargs): commands.append(cmd) return subprocess.CompletedProcess( cmd, 0, stdout=json.dumps( { "api": { "apiBaseUrl": "https://nb.example/api/", "auth": {"type": "token", "accessToken": "user-access-token"}, } } ), stderr="", ) credentials = NbClient(env_name="customer-prod", runner=runner).environment_credentials() self.assertEqual(credentials, ("https://nb.example/api", "user-access-token")) self.assertEqual( commands[0], ["nb", "env", "info", "customer-prod", "--json", "--show-secrets"], ) self.assertNotIn("user-access-token", commands[0]) def test_environment_credentials_requires_the_users_cli_login(self) -> None: def runner(cmd, **kwargs): return subprocess.CompletedProcess( cmd, 0, stdout=json.dumps( { "api": { "apiBaseUrl": "https://nb.example/api", "auth": {"type": "token", "accessToken": "-"}, } } ), stderr="", ) with self.assertRaises(NbAuthenticationError) as caught: NbClient(env_name="customer-prod", runner=runner).environment_credentials() self.assertIn("personal NocoBase API Key", str(caught.exception)) self.assertNotIn("nb env auth", str(caught.exception)) 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("", 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()