Files
nocobase-mcp/tests/test_crm_service.py
T
2026-07-08 21:51:08 +00:00

331 lines
15 KiB
Python

import unittest
import json
import tempfile
from pathlib import Path
from nocobase_crm_mcp.config import load_config
from nocobase_crm_mcp.crm import CrmService
class FakeNbClient:
def __init__(self) -> None:
self.list_calls = []
self.get_calls = []
self.query_calls = []
self.field_calls = []
def health(self):
return {"ok": True, "status": "reachable"}
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {
"data": [
{
"id": 54,
"username": "alice",
"nickname": "Alice Wang",
"email": "alice@example.com",
"f_yh_english_name": "Alice Wang",
}
],
"meta": {"count": 1},
}
if resource == "followups":
return {
"data": [
{
"id": 1,
"f_srysmjkwfq1": "2026-07-08T09:00:00.000Z",
"f_19iplxz0nds": "Called customer about sample status",
"gjjl_client": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
"gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
"gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"createdBy": {"id": 54, "nickname": "Alice Wang"},
}
],
"meta": {"count": 1},
}
return {"data": [{"id": 1, "name": "Acme"}], "meta": {"count": 1}}
def get_record(self, resource, filter_by_tk, **kwargs):
self.get_calls.append((resource, filter_by_tk, kwargs))
return {"data": {"id": filter_by_tk, "name": "Acme", "f_q2q60q7bqbg": "Acme China"}}
def query_records(self, resource, **kwargs):
self.query_calls.append((resource, kwargs))
dimensions = kwargs.get("dimensions") or []
alias = dimensions[0].get("alias") if dimensions else None
if alias in {"status", "kh_progress"}:
return {"data": [{"count": 3, "kh_progress": "kh_p_inprogress"}]}
if alias in {"source", "f_225h6n6v654"}:
return {"data": [{"count": 2, "f_225h6n6v654": "客户推荐"}]}
if alias == "owner_id":
return {"data": [{"count": 1, "owner_id": 54}]}
if alias == "kehu_followup_person_id":
return {"data": [{"count": 1, "kehu_followup_person_id": 54}]}
return {"data": [{"count": 3}]}
def list_collection_fields(self, collection_name, **kwargs):
self.field_calls.append((collection_name, kwargs))
if collection_name == "contacts":
return {
"data": [
{"name": "id", "title": "ID", "type": "bigInt"},
{"name": "f_2zpg91gtmwp", "title": "联系人姓名", "type": "string"},
{"name": "khlxr_phone_number", "title": "联系人电话", "type": "string"},
{"name": "khlxr_email", "title": "联系人邮箱", "type": "string"},
{"name": "kehulianxiren_client", "title": "公司", "type": "belongsToMany"},
]
}
if collection_name == "followups":
return {
"data": [
{"name": "id", "title": "跟进ID", "type": "bigInt"},
{"name": "gjjl_client", "title": "客户", "type": "belongsToMany"},
{"name": "gjjl_client_contacts", "title": "客户联系人", "type": "belongsToMany"},
{"name": "gjjl_followup_person", "title": "参与跟进人员", "type": "belongsToMany"},
{"name": "createdBy", "title": "{{t(\"Created by\")}}", "type": "belongsTo"},
{"name": "f_srysmjkwfq1", "title": "跟进时间", "type": "date"},
{"name": "f_19iplxz0nds", "title": "跟进内容", "type": "text"},
]
}
return {
"data": [
{"name": "id", "title": "ID", "type": "bigInt"},
{"name": "f_q2q60q7bqbg", "title": "中文名称", "type": "string"},
{"name": "f_kh_english_name", "title": "英文名称", "type": "string"},
{"name": "f_territorial_name", "title": "属地名称", "type": "string"},
{"name": "kh_progress", "title": "进度", "type": "string"},
{"name": "f_225h6n6v654", "title": "客户来源", "type": "string"},
{"name": "kehu_followup_person", "title": "跟进人", "type": "belongsToMany"},
]
}
class CrmServiceTests(unittest.TestCase):
def test_list_customers_applies_configured_fields_and_filters(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.list_customers(query="Acme", status="active", owner="Alice", limit=10, offset=20)
self.assertEqual(result["results"], [{"id": 1, "name": "Acme"}])
self.assertEqual(result["total"], 1)
self.assertEqual(client.list_calls[0][0], "users")
resource, kwargs = client.list_calls[1]
self.assertEqual(resource, "customers")
self.assertEqual(kwargs["fields"], load_config(None).customer.fields)
self.assertEqual(kwargs["page_size"], 10)
self.assertEqual(kwargs["page"], 3)
self.assertEqual(
kwargs["filter"],
{
"$and": [
{"status": {"$eq": "active"}},
{"owner": {"id": {"$eq": 54}}},
{
"$or": [
{"name": {"$includes": "Acme"}},
{"code": {"$includes": "Acme"}},
]
},
]
},
)
def test_get_customer_loads_related_contacts_and_followups(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.get_customer(7, include_contacts=True, include_recent_followups=True, followup_limit=5)
self.assertEqual(result["customer"]["id"], 7)
self.assertEqual(result["customer"]["name"], "Acme")
self.assertEqual(result["contacts"], [{"id": 1, "name": "Acme"}])
self.assertEqual(result["recent_followups"][0]["id"], 1)
self.assertEqual(result["recent_followups"][0]["summary"]["content"], "Called customer about sample status")
self.assertEqual(client.get_calls[0][0], "customers")
self.assertEqual(client.list_calls[0][0], "contacts")
self.assertEqual(client.list_calls[0][1]["filter"], {"$and": [{"customer": {"id": {"$eq": 7}}}]})
self.assertEqual(client.list_calls[1][0], "followups")
self.assertEqual(client.list_calls[1][1]["page_size"], 5)
def test_crm_overview_uses_count_and_group_queries(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.crm_overview(owner="Alice")
self.assertEqual(result["totals"]["customers"], 3)
self.assertEqual(result["totals"]["contacts"], 3)
self.assertEqual(result["totals"]["followups"], 3)
self.assertEqual(result["customer_status"][0]["display"], "进行中")
self.assertEqual(result["customer_owner"][0]["owner_id"], 54)
self.assertGreaterEqual(len(client.query_calls), 3)
self.assertEqual(client.query_calls[0][0], "customers")
self.assertEqual(client.query_calls[0][1]["measures"][0]["aggregation"], "count")
def test_crm_schema_include_fields_reads_each_configured_collection_fields(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
schema = service.crm_schema(include_fields=True)
self.assertEqual(
[call[0] for call in client.field_calls],
["customers", "contacts", "followups"],
)
self.assertEqual(client.field_calls[0][1].get("fields"), None)
self.assertEqual(schema["metadata"]["fields"]["customers"][1]["name"], "f_q2q60q7bqbg")
self.assertEqual(schema["metadata"]["field_errors"], {})
def test_crm_schema_include_fields_reports_metadata_acl_hint(self) -> None:
class ForbiddenFieldsClient(FakeNbClient):
def list_collection_fields(self, collection_name, **kwargs):
raise Exception("Request failed with status 403: No permissions")
service = CrmService(load_config(None), ForbiddenFieldsClient())
schema = service.crm_schema(include_fields=True)
self.assertIn("metadata_permission_required", schema["metadata"]["field_errors"]["customers"]["code"])
self.assertIn("data-modeling collections fields list", schema["metadata"]["field_errors"]["customers"]["hint"])
def test_crm_config_template_infers_mapping_from_metadata(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
template = service.crm_config_template()
self.assertEqual(
template["collections"]["customers"]["searchFields"],
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
)
self.assertEqual(template["collections"]["customers"]["statusField"], "kh_progress")
self.assertEqual(template["collections"]["customers"]["sourceField"], "f_225h6n6v654")
self.assertEqual(template["collections"]["customers"]["ownerField"], "kehu_followup_person")
self.assertEqual(template["collections"]["contacts"]["customerField"], "kehulianxiren_client")
self.assertEqual(template["collections"]["followups"]["customerField"], "gjjl_client")
self.assertEqual(template["collections"]["followups"]["contactField"], "gjjl_client_contacts")
self.assertEqual(template["collections"]["followups"]["ownerFields"], ["gjjl_followup_person", "createdBy"])
self.assertEqual(template["collections"]["followups"]["dateField"], "f_srysmjkwfq1")
def test_list_customers_resolves_owner_name_to_relation_id_filter(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
service.list_customers(owner="alice")
self.assertEqual(client.list_calls[0][0], "users")
self.assertEqual(
client.list_calls[1][1]["filter"],
{"$and": [{"owner": {"id": {"$eq": 54}}}]},
)
def test_list_followups_filters_owner_by_participant_or_creator(self) -> None:
client = FakeNbClient()
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"collections": {
"followups": {
"ownerFields": ["gjjl_followup_person", "createdBy"],
}
}
}
),
encoding="utf-8",
)
service = CrmService(load_config(config_path), client)
service.list_followups(owner="alice")
self.assertEqual(client.list_calls[0][0], "users")
self.assertEqual(
client.list_calls[1][1]["filter"],
{
"$and": [
{
"$or": [
{"gjjl_followup_person": {"id": {"$eq": 54}}},
{"createdBy": {"id": {"$eq": 54}}},
]
}
]
},
)
def test_owner_resolution_returns_structured_errors_for_no_or_ambiguous_matches(self) -> None:
class NoUserClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {"data": [], "meta": {"count": 0}}
return super().list_records(resource, **kwargs)
missing = CrmService(load_config(None), NoUserClient()).list_customers(owner="missing")
self.assertEqual(missing["error"]["code"], "owner_not_found")
self.assertEqual(missing["error"]["query"], "missing")
class AmbiguousUserClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {
"data": [
{"id": 1, "username": "alice", "nickname": "Alice"},
{"id": 2, "username": "alice2", "nickname": "Alice"},
],
"meta": {"count": 2},
}
return super().list_records(resource, **kwargs)
ambiguous = CrmService(load_config(None), AmbiguousUserClient()).list_customers(owner="alice")
self.assertEqual(ambiguous["error"]["code"], "owner_ambiguous")
self.assertEqual(len(ambiguous["error"]["candidates"]), 2)
def test_resolve_owner_returns_candidate_users(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).resolve_owner("alice")
self.assertEqual(result["query"], "alice")
self.assertEqual(result["results"][0]["id"], 54)
self.assertEqual(result["results"][0]["label"], "Alice Wang")
def test_crm_filter_options_exposes_filter_values_and_owner_candidates(self) -> None:
client = FakeNbClient()
result = CrmService(load_config(None), client).crm_filter_options(owner_query="alice")
self.assertEqual(result["customer_status"][0]["value"], "kh_p_inprogress")
self.assertEqual(result["customer_status"][0]["label"], "进行中")
self.assertEqual(result["customer_source"][0]["value"], "客户推荐")
self.assertEqual(result["owner_candidates"][0]["id"], 54)
def test_list_tools_clamp_limit_and_offset_for_remote_agents(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.list_customers(limit=10000, offset=-50)
self.assertEqual(result["limit"], 100)
self.assertEqual(result["offset"], 0)
self.assertEqual(client.list_calls[0][1]["page_size"], 100)
self.assertEqual(client.list_calls[0][1]["page"], 1)
def test_list_followups_adds_human_readable_summary_without_dropping_raw_fields(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1)
row = result["results"][0]
self.assertEqual(row["f_19iplxz0nds"], "Called customer about sample status")
self.assertEqual(row["summary"]["content"], "Called customer about sample status")
self.assertEqual(row["summary"]["followedAt"], "2026-07-08T09:00:00.000Z")
self.assertEqual(row["summary"]["customer_names"], ["Acme China"])
self.assertEqual(row["summary"]["owner_names"], ["Alice Wang"])
if __name__ == "__main__":
unittest.main()