feat: add team-aware CRM queries and project tracking
This commit is contained in:
@@ -10,6 +10,7 @@ from nocobase_crm_mcp.filters import (
|
||||
build_and_filter,
|
||||
build_date_range_condition,
|
||||
build_relation_id_condition,
|
||||
build_relation_id_in_condition,
|
||||
build_relation_id_or_condition,
|
||||
build_search_filter,
|
||||
)
|
||||
@@ -28,6 +29,64 @@ class ConfigTests(unittest.TestCase):
|
||||
self.assertIn("name", config.customer.search_fields)
|
||||
self.assertEqual(config.contact.customer_field, "customer")
|
||||
self.assertEqual(config.followup.customer_field, "customer")
|
||||
self.assertEqual(config.timezone, "Asia/Shanghai")
|
||||
self.assertEqual(config.team.resource, "t_team_management")
|
||||
self.assertEqual(config.team.formal_user_status_value, "nkluuumb1r1")
|
||||
# Label resolution order: the first non-empty field wins. f_kh_english_name
|
||||
# must stay in the list -- leaving it out is what made customer 4249 render
|
||||
# as its own id.
|
||||
self.assertIn("f_kh_english_name", config.customer.name_fields)
|
||||
self.assertIn("f_territorial_name", config.customer.name_fields)
|
||||
self.assertEqual(config.customer.manager_field, "kehu_manage_person")
|
||||
self.assertEqual(config.contact.name_fields[0], "f_2zpg91gtmwp")
|
||||
self.assertEqual(config.user.label_fields[0], "nickname")
|
||||
self.assertNotIn("id", config.user.label_fields)
|
||||
self.assertEqual(config.followup.content_field, "f_19iplxz0nds")
|
||||
self.assertEqual(config.followup.location_field, "f_b5c4hfi5og6")
|
||||
self.assertEqual(config.project.name_fields[0], "f_project_name")
|
||||
self.assertEqual(config.project.customer_relation_field, "f_projects_client")
|
||||
|
||||
def test_load_config_reads_label_and_detail_field_overrides(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_path = Path(tmp) / "crm.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"users": {"labelFields": ["email"]},
|
||||
"collections": {
|
||||
"customers": {
|
||||
"nameFields": ["company_name"],
|
||||
"managerField": "account_manager",
|
||||
"detailFields": {"address": "billing_address"},
|
||||
},
|
||||
"contacts": {"nameFields": ["full_name"], "phoneField": "mobile"},
|
||||
"followups": {"contentField": "notes", "locationField": "venue"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = load_config(config_path)
|
||||
|
||||
self.assertEqual(config.customer.name_fields, ["company_name"])
|
||||
self.assertEqual(config.customer.manager_field, "account_manager")
|
||||
self.assertEqual(config.customer.detail_fields, {"address": "billing_address"})
|
||||
self.assertEqual(config.contact.name_fields, ["full_name"])
|
||||
self.assertEqual(config.contact.phone_field, "mobile")
|
||||
self.assertEqual(config.followup.content_field, "notes")
|
||||
self.assertEqual(config.followup.location_field, "venue")
|
||||
self.assertEqual(config.user.label_fields, ["email"])
|
||||
|
||||
def test_project_name_fields_follow_a_renamed_name_field(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_path = Path(tmp) / "crm.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"projects": {"nameField": "project_title"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = load_config(config_path)
|
||||
|
||||
self.assertEqual(config.project.name_fields[0], "project_title")
|
||||
|
||||
def test_load_config_merges_file_and_environment_overrides(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -162,6 +221,22 @@ class FilterBuilderTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_build_relation_id_in_condition_supports_team_members(self) -> None:
|
||||
self.assertEqual(
|
||||
build_relation_id_in_condition("kehu_followup_person", [3, 7]),
|
||||
{"kehu_followup_person": {"id": {"$in": [3, 7]}}},
|
||||
)
|
||||
self.assertEqual(
|
||||
build_relation_id_in_condition(["f_projects_client", "kehu_followup_person", "id"], [3, 7]),
|
||||
{
|
||||
"f_projects_client": {
|
||||
"kehu_followup_person": {
|
||||
"id": {"$in": [3, 7]},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+431
-32
@@ -1,10 +1,41 @@
|
||||
import unittest
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from nocobase_crm_mcp.config import load_config
|
||||
from nocobase_crm_mcp.crm import CrmService
|
||||
from nocobase_crm_mcp.crm import PROJECTION_FALLBACK_WARNING, CrmService, _prune
|
||||
from nocobase_crm_mcp.nb_client import NbCommandError
|
||||
|
||||
|
||||
def project_row(row, fields):
|
||||
"""Shape a canned row the way NocoBase shapes a projected response.
|
||||
|
||||
The fake client used to ignore `fields` entirely, which meant a missing
|
||||
relation projection was invisible to the tests -- the canned rows already
|
||||
looked projected. Honouring `fields` here is what makes assertions like
|
||||
"phone never reaches the response" mean anything.
|
||||
"""
|
||||
if not fields:
|
||||
return dict(row)
|
||||
projected = {}
|
||||
for path in fields:
|
||||
head, _, leaf = path.partition(".")
|
||||
if head not in row:
|
||||
continue
|
||||
value = row[head]
|
||||
if not leaf:
|
||||
projected[head] = value
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
nested = projected.setdefault(head, [{} for _ in value])
|
||||
for slot, item in zip(nested, value):
|
||||
if isinstance(item, dict) and leaf in item:
|
||||
slot[leaf] = item[leaf]
|
||||
elif isinstance(value, dict) and leaf in value:
|
||||
projected.setdefault(head, {})[leaf] = value[leaf]
|
||||
return projected
|
||||
|
||||
|
||||
class FakeNbClient:
|
||||
@@ -21,6 +52,12 @@ class FakeNbClient:
|
||||
|
||||
def list_records(self, resource, **kwargs):
|
||||
self.list_calls.append((resource, kwargs))
|
||||
response = self._rows(resource)
|
||||
fields = kwargs.get("fields")
|
||||
response["data"] = [project_row(row, fields) for row in response["data"]]
|
||||
return response
|
||||
|
||||
def _rows(self, resource):
|
||||
if resource == "users":
|
||||
return {
|
||||
"data": [
|
||||
@@ -40,9 +77,13 @@ class FakeNbClient:
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Alice Contact",
|
||||
"f_2zpg91gtmwp": "Alice Contact",
|
||||
"phone": "+1 555 0100",
|
||||
"khlxr_phone_number": "+1 555 0100",
|
||||
"email": "alice.contact@example.com",
|
||||
"khlxr_email": "alice.contact@example.com",
|
||||
"title": "Buyer",
|
||||
"f_tn8zdm2f938": "Buyer",
|
||||
"customer": [{"id": 1, "name": "Acme"}],
|
||||
"kehulianxiren_client": [{"id": 1, "f_q2q60q7bqbg": "Acme China"}],
|
||||
"createdAt": "2026-07-01T00:00:00.000Z",
|
||||
@@ -52,14 +93,21 @@ class FakeNbClient:
|
||||
"meta": {"count": 1},
|
||||
}
|
||||
if resource == "followups":
|
||||
# Carries both the default-config key names and the production field
|
||||
# codes so the same fixture serves tests running under either config.
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"followedAt": "2026-07-08T09:00:00.000Z",
|
||||
"f_srysmjkwfq1": "2026-07-08T09:00:00.000Z",
|
||||
"content": "Called customer about sample status and confirmed next procurement meeting.",
|
||||
"f_19iplxz0nds": "Called customer about sample status and confirmed next procurement meeting.",
|
||||
"customer": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
|
||||
"gjjl_client": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
|
||||
"contact": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
|
||||
"gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
|
||||
"owner": [{"id": 54, "nickname": "Alice Wang"}],
|
||||
"gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
|
||||
"createdBy": {"id": 54, "nickname": "Alice Wang"},
|
||||
"createdAt": "2026-07-08T09:10:00.000Z",
|
||||
@@ -94,7 +142,19 @@ class FakeNbClient:
|
||||
|
||||
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"}}
|
||||
row = {
|
||||
"id": filter_by_tk,
|
||||
"name": "Acme",
|
||||
"f_q2q60q7bqbg": "Acme China",
|
||||
"f_kh_english_name": "Acme",
|
||||
"kh_progress": "kh_p_inprogress",
|
||||
"f_y8iyf2jhana": "88 Nanjing Road",
|
||||
"f_spgoie0arjq": "Connector supplier.",
|
||||
"kehu_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
|
||||
"khlxr_phone_number": "+1 555 0100",
|
||||
"khlxr_email": "alice.contact@example.com",
|
||||
}
|
||||
return {"data": project_row(row, kwargs.get("fields"))}
|
||||
|
||||
def query_records(self, resource, **kwargs):
|
||||
self.query_calls.append((resource, kwargs))
|
||||
@@ -174,16 +234,20 @@ class CrmServiceTests(unittest.TestCase):
|
||||
result = service.list_customers(query="Acme", status="active", owner="Alice", limit=10, offset=20)
|
||||
|
||||
self.assertEqual(result["results"][0]["id"], 1)
|
||||
self.assertEqual(result["results"][0]["name"], "Acme China")
|
||||
self.assertEqual(result["results"][0]["status_display"], "进行中")
|
||||
self.assertEqual(result["results"][0]["owner_names"], ["Alice Wang"])
|
||||
self.assertEqual(result["results"][0]["name"], "Acme")
|
||||
self.assertEqual(result["results"][0]["status"], "进行中")
|
||||
self.assertEqual(result["results"][0]["owners"], [{"id": 54, "name": "Alice Wang"}])
|
||||
self.assertNotIn("kehu_contacts", result["results"][0])
|
||||
self.assertNotIn("raw", result["results"][0])
|
||||
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)
|
||||
# The owner relation is fetched as a dotted projection, never bare -- a bare
|
||||
# relation makes NocoBase return the whole related row.
|
||||
self.assertIn("owner.id", kwargs["fields"])
|
||||
self.assertIn("owner.nickname", kwargs["fields"])
|
||||
self.assertNotIn("owner", kwargs["fields"])
|
||||
self.assertEqual(kwargs["page_size"], 10)
|
||||
self.assertEqual(kwargs["page"], 3)
|
||||
self.assertEqual(
|
||||
@@ -210,11 +274,18 @@ class CrmServiceTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(result["customer"]["id"], 7)
|
||||
self.assertEqual(result["customer"]["name"], "Acme")
|
||||
self.assertEqual(result["timezone"], "Asia/Shanghai")
|
||||
# The customer is compacted, not handed back as the raw NocoBase row.
|
||||
self.assertNotIn("f_q2q60q7bqbg", result["customer"])
|
||||
self.assertNotIn("raw", result["customer"])
|
||||
self.assertEqual(result["contacts"][0]["id"], 2)
|
||||
self.assertEqual(result["contacts"][0]["customer_names"], ["Acme"])
|
||||
self.assertEqual(result["contacts"][0]["customers"], [{"id": 1, "name": "Acme"}])
|
||||
# Personal contact details do not belong in a customer overview.
|
||||
self.assertNotIn("phone", result["contacts"][0])
|
||||
self.assertNotIn("email", result["contacts"][0])
|
||||
self.assertEqual(result["recent_followups"][0]["id"], 1)
|
||||
self.assertEqual(
|
||||
result["recent_followups"][0]["content_preview"],
|
||||
result["recent_followups"][0]["content"],
|
||||
"Called customer about sample status and confirmed next procurement meeting.",
|
||||
)
|
||||
self.assertNotIn("f_19iplxz0nds", result["recent_followups"][0])
|
||||
@@ -233,8 +304,8 @@ class CrmServiceTests(unittest.TestCase):
|
||||
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["recent_followups"][0]["content_preview"], "Called customer about sample status and confirmed next procurement meeting.")
|
||||
self.assertEqual(result["customer_status"][0]["label"], "已下单")
|
||||
self.assertEqual(result["recent_followups"][0]["content"], "Called customer about sample status and confirmed next procurement meeting.")
|
||||
self.assertEqual(result["customer_owner"][0]["owner_id"], 54)
|
||||
self.assertGreaterEqual(len(client.query_calls), 3)
|
||||
self.assertEqual(client.query_calls[0][0], "customers")
|
||||
@@ -248,7 +319,7 @@ class CrmServiceTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
[call[0] for call in client.field_calls],
|
||||
["customers", "contacts", "followups"],
|
||||
["customers", "contacts", "followups", "t_team_management", "t_projects", "t_qxeepjvi39s"],
|
||||
)
|
||||
self.assertEqual(client.field_calls[0][1].get("fields"), None)
|
||||
self.assertEqual(schema["metadata"]["fields"]["customers"][1]["name"], "f_q2q60q7bqbg")
|
||||
@@ -297,7 +368,7 @@ class CrmServiceTests(unittest.TestCase):
|
||||
{"$and": [{"owner": {"id": {"$eq": 54}}}]},
|
||||
)
|
||||
|
||||
def test_list_followups_filters_owner_by_participant_or_creator(self) -> None:
|
||||
def test_list_followups_filters_owner_by_participant_only(self) -> None:
|
||||
client = FakeNbClient()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_path = Path(tmp) / "crm.json"
|
||||
@@ -306,6 +377,7 @@ class CrmServiceTests(unittest.TestCase):
|
||||
{
|
||||
"collections": {
|
||||
"followups": {
|
||||
"ownerField": "gjjl_followup_person",
|
||||
"ownerFields": ["gjjl_followup_person", "createdBy"],
|
||||
}
|
||||
}
|
||||
@@ -320,18 +392,190 @@ class CrmServiceTests(unittest.TestCase):
|
||||
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}}},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{"$and": [{"gjjl_followup_person": {"id": {"$eq": 54}}}]},
|
||||
)
|
||||
|
||||
def test_customer_without_a_chinese_name_shows_its_english_name_not_its_id(self) -> None:
|
||||
"""Regression: customer 4249 surfaced as the string "4249".
|
||||
|
||||
Its Chinese-name field was empty and its real name lived in
|
||||
f_kh_english_name, which was missing from the name candidates, so label
|
||||
resolution fell all the way through to the id.
|
||||
"""
|
||||
|
||||
class ZomatoClient(FakeNbClient):
|
||||
def _rows(self, resource):
|
||||
if resource == "followups":
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"followedAt": "2026-07-08T09:00:00.000Z",
|
||||
"content": "Dinner with the procurement team.",
|
||||
"customer": [
|
||||
{
|
||||
"id": 4249,
|
||||
"f_q2q60q7bqbg": None,
|
||||
"f_kh_english_name": "Zomato(Eternal)",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"meta": {"count": 1},
|
||||
}
|
||||
return super()._rows(resource)
|
||||
|
||||
row = CrmService(load_config(None), ZomatoClient()).list_followups(limit=1)["results"][0]
|
||||
|
||||
self.assertEqual(row["customers"], [{"id": 4249, "name": "Zomato(Eternal)"}])
|
||||
|
||||
def test_customer_with_no_name_at_all_is_marked_as_an_id_not_passed_off_as_a_name(self) -> None:
|
||||
class NamelessClient(FakeNbClient):
|
||||
def _rows(self, resource):
|
||||
if resource == "followups":
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"followedAt": "2026-07-08T09:00:00.000Z",
|
||||
"customer": [{"id": 4249}],
|
||||
}
|
||||
],
|
||||
"meta": {"count": 1},
|
||||
}
|
||||
return super()._rows(resource)
|
||||
|
||||
row = CrmService(load_config(None), NamelessClient()).list_followups(limit=1)["results"][0]
|
||||
|
||||
self.assertEqual(row["customers"], [{"id": 4249, "name": "#4249"}])
|
||||
self.assertNotEqual(row["customers"][0]["name"], "4249")
|
||||
|
||||
def test_get_customer_renames_detail_fields_and_leaks_no_field_codes(self) -> None:
|
||||
"""The whole point of the rewrite: readable keys, no NocoBase codes."""
|
||||
client = FakeNbClient()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_path = Path(tmp) / "crm.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collections": {
|
||||
"customers": {
|
||||
"fields": [
|
||||
"id",
|
||||
"f_q2q60q7bqbg",
|
||||
"f_kh_english_name",
|
||||
"kh_progress",
|
||||
"f_y8iyf2jhana",
|
||||
"f_spgoie0arjq",
|
||||
"kehu_followup_person",
|
||||
],
|
||||
"nameFields": ["f_q2q60q7bqbg", "f_kh_english_name"],
|
||||
"statusField": "kh_progress",
|
||||
"ownerField": "kehu_followup_person",
|
||||
"detailFields": {"address": "f_y8iyf2jhana", "intro": "f_spgoie0arjq"},
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = CrmService(load_config(config_path), client)
|
||||
|
||||
result = service.get_customer(7, include_contacts=False, include_recent_followups=False)
|
||||
|
||||
self.assertEqual(result["customer"]["name"], "Acme China")
|
||||
self.assertEqual(result["customer"]["address"], "88 Nanjing Road")
|
||||
self.assertEqual(result["customer"]["intro"], "Connector supplier.")
|
||||
self.assertEqual(result["customer"]["status"], "进行中")
|
||||
self.assertEqual(result["customer"]["owners"], [{"id": 54, "name": "Alice Wang"}])
|
||||
|
||||
def field_codes(value):
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
if re.fullmatch(r"f_[a-z0-9_]+", str(key)):
|
||||
yield key
|
||||
yield from field_codes(nested)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from field_codes(item)
|
||||
|
||||
self.assertEqual(list(field_codes(result)), [])
|
||||
|
||||
def test_get_customer_never_returns_internal_nocobase_field_codes(self) -> None:
|
||||
result = CrmService(load_config(None), FakeNbClient()).get_customer(7)
|
||||
|
||||
def field_codes(value):
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
if re.fullmatch(r"f_[a-z0-9_]+", str(key)):
|
||||
yield key
|
||||
yield from field_codes(nested)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from field_codes(item)
|
||||
|
||||
self.assertEqual(list(field_codes(result)), [])
|
||||
|
||||
def test_get_customer_reports_a_structured_error_for_an_unknown_id(self) -> None:
|
||||
class MissingClient(FakeNbClient):
|
||||
def get_record(self, resource, filter_by_tk, **kwargs):
|
||||
self.get_calls.append((resource, filter_by_tk, kwargs))
|
||||
return {"data": None}
|
||||
|
||||
result = CrmService(load_config(None), MissingClient()).get_customer(999)
|
||||
|
||||
self.assertEqual(result["error"]["code"], "customer_not_found")
|
||||
|
||||
def test_requested_fields_never_ask_for_a_relation_both_bare_and_projected(self) -> None:
|
||||
client = FakeNbClient()
|
||||
service = CrmService(load_config(None), client)
|
||||
|
||||
service.list_customers()
|
||||
service.list_contacts()
|
||||
service.list_followups()
|
||||
service.list_stale_customers()
|
||||
|
||||
for resource, kwargs in client.list_calls:
|
||||
fields = kwargs.get("fields") or []
|
||||
roots = {path.split(".", 1)[0] for path in fields if "." in path}
|
||||
overlap = roots.intersection(fields)
|
||||
self.assertEqual(overlap, set(), f"{resource} requested {overlap} bare and projected")
|
||||
|
||||
def test_projections_always_lead_with_the_id_field(self) -> None:
|
||||
client = FakeNbClient()
|
||||
service = CrmService(load_config(None), client)
|
||||
|
||||
service.list_followups()
|
||||
|
||||
fields = client.list_calls[0][1]["fields"]
|
||||
for root in {path.split(".", 1)[0] for path in fields if "." in path}:
|
||||
projected = [path for path in fields if path.startswith(f"{root}.")]
|
||||
self.assertEqual(projected[0], f"{root}.id")
|
||||
|
||||
def test_stale_customer_scan_projects_the_customer_relation_down_to_its_id(self) -> None:
|
||||
client = FakeNbClient()
|
||||
|
||||
CrmService(load_config(None), client).list_stale_customers()
|
||||
|
||||
followup_call = next(kwargs for resource, kwargs in client.list_calls if resource == "followups")
|
||||
self.assertIn("customer.id", followup_call["fields"])
|
||||
self.assertNotIn("customer", followup_call["fields"])
|
||||
|
||||
def test_rejected_projection_falls_back_to_relation_roots_with_a_warning(self) -> None:
|
||||
class PickyClient(FakeNbClient):
|
||||
def list_records(self, resource, **kwargs):
|
||||
if resource == "followups" and any("." in path for path in kwargs.get("fields") or []):
|
||||
raise NbCommandError("Unknown field gjjl_client.f_bogus")
|
||||
return super().list_records(resource, **kwargs)
|
||||
|
||||
result = CrmService(load_config(None), PickyClient()).list_followups(limit=1)
|
||||
|
||||
self.assertEqual(result["results"][0]["id"], 1)
|
||||
self.assertIn(PROJECTION_FALLBACK_WARNING, result["data_quality_warnings"])
|
||||
|
||||
def test_empty_values_are_omitted_but_ids_are_always_kept(self) -> None:
|
||||
self.assertEqual(_prune({"id": None, "name": "", "tags": [], "status": "OK"}), {"id": None, "status": "OK"})
|
||||
|
||||
def test_owner_resolution_returns_structured_errors_for_no_or_ambiguous_matches(self) -> None:
|
||||
class NoUserClient(FakeNbClient):
|
||||
def list_records(self, resource, **kwargs):
|
||||
@@ -382,8 +626,9 @@ class CrmServiceTests(unittest.TestCase):
|
||||
result = CrmService(load_config(None), FakeNbClient()).list_customers(limit=1, include_raw=True)
|
||||
|
||||
row = result["results"][0]
|
||||
self.assertEqual(row["name"], "Acme China")
|
||||
self.assertEqual(row["raw"]["kehu_contacts"][0]["id"], 99)
|
||||
self.assertEqual(row["name"], "Acme")
|
||||
# raw keeps the relation expanded the way NocoBase returned it.
|
||||
self.assertEqual(row["raw"]["owner"][0]["nickname"], "Alice Wang")
|
||||
|
||||
def test_list_tools_clamp_limit_and_offset_for_remote_agents(self) -> None:
|
||||
client = FakeNbClient()
|
||||
@@ -400,22 +645,176 @@ class CrmServiceTests(unittest.TestCase):
|
||||
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1)
|
||||
|
||||
row = result["results"][0]
|
||||
# Pinning the exact key set is the regression guard against the row
|
||||
# quietly growing duplicate people/status/timestamp fields again.
|
||||
self.assertEqual(
|
||||
set(row),
|
||||
{"id", "followedAt", "customers", "contacts", "participants", "content"},
|
||||
)
|
||||
self.assertEqual(row["id"], 1)
|
||||
self.assertEqual(row["content_preview"], "Called customer about sample status and confirmed next procurement meeting.")
|
||||
self.assertEqual(row["followedAt"], "2026-07-08T09:00:00.000Z")
|
||||
self.assertEqual(row["customer_names"], ["Acme China"])
|
||||
self.assertEqual(row["owner_names"], ["Alice Wang"])
|
||||
self.assertEqual(row["followedAt"], "2026-07-08T17:00:00+08:00")
|
||||
self.assertEqual(row["customers"], [{"id": 7, "name": "Acme China"}])
|
||||
self.assertEqual(row["contacts"], [{"id": 8, "name": "Alice Contact"}])
|
||||
self.assertEqual(row["participants"], [{"id": 54, "name": "Alice Wang"}])
|
||||
self.assertEqual(
|
||||
row["content"],
|
||||
"Called customer about sample status and confirmed next procurement meeting.",
|
||||
)
|
||||
# The submitter is also a participant, so naming them twice adds nothing.
|
||||
self.assertNotIn("creator", row)
|
||||
self.assertNotIn("f_19iplxz0nds", row)
|
||||
self.assertNotIn("summary", row)
|
||||
|
||||
def test_list_followups_can_opt_into_raw_rows(self) -> None:
|
||||
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1, include_raw=True)
|
||||
|
||||
row = result["results"][0]
|
||||
self.assertEqual(row["content_preview"], "Called customer about sample status and confirmed next procurement meeting.")
|
||||
self.assertEqual(row["raw"]["f_19iplxz0nds"], "Called customer about sample status and confirmed next procurement meeting.")
|
||||
self.assertEqual(row["raw"]["gjjl_client"][0]["f_q2q60q7bqbg"], "Acme China")
|
||||
self.assertEqual(
|
||||
row["content"],
|
||||
"Called customer about sample status and confirmed next procurement meeting.",
|
||||
)
|
||||
self.assertEqual(row["raw"]["customer"][0]["f_q2q60q7bqbg"], "Acme China")
|
||||
|
||||
def test_arbitrary_offset_fetches_two_pages_and_slices_exactly(self) -> None:
|
||||
class PagedClient(FakeNbClient):
|
||||
def list_records(self, resource, **kwargs):
|
||||
self.list_calls.append((resource, kwargs))
|
||||
if resource != "customers":
|
||||
return super().list_records(resource, **kwargs)
|
||||
page = kwargs["page"]
|
||||
page_size = kwargs["page_size"]
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"data": [
|
||||
{"id": index, "name": f"Customer {index}"}
|
||||
for index in range(start + 1, min(start + page_size, 50) + 1)
|
||||
],
|
||||
"meta": {"count": 50},
|
||||
}
|
||||
|
||||
client = PagedClient()
|
||||
result = CrmService(load_config(None), client).list_customers(limit=10, offset=15)
|
||||
|
||||
self.assertEqual([row["id"] for row in result["results"]], list(range(16, 26)))
|
||||
customer_calls = [kwargs for resource, kwargs in client.list_calls if resource == "customers"]
|
||||
self.assertEqual([call["page"] for call in customer_calls], [2, 3])
|
||||
self.assertEqual(result["next_offset"], 25)
|
||||
|
||||
def test_team_filter_uses_formal_team_members_and_customer_owners(self) -> None:
|
||||
class TeamClient(FakeNbClient):
|
||||
def list_records(self, resource, **kwargs):
|
||||
self.list_calls.append((resource, kwargs))
|
||||
if resource == "t_team_management":
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": 9,
|
||||
"f_team_name": "华东团队",
|
||||
"tdgl_manager": [{"id": 54, "nickname": "Alice Wang"}],
|
||||
"tdgl_team_members": [{"id": 54, "nickname": "Alice Wang"}],
|
||||
}
|
||||
],
|
||||
"meta": {"count": 1},
|
||||
}
|
||||
if resource == "users":
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": 54,
|
||||
"username": "alice",
|
||||
"nickname": "Alice Wang",
|
||||
"yh_status": "nkluuumb1r1",
|
||||
}
|
||||
],
|
||||
"meta": {"count": 1},
|
||||
}
|
||||
return super().list_records(resource, **kwargs)
|
||||
|
||||
client = TeamClient()
|
||||
service = CrmService(load_config(None), client)
|
||||
|
||||
resolved = service.resolve_team("华东团队")
|
||||
result = service.list_customers(team="华东团队")
|
||||
|
||||
self.assertEqual(resolved["results"][0]["name"], "华东团队")
|
||||
customer_call = next(kwargs for resource, kwargs in reversed(client.list_calls) if resource == "customers")
|
||||
self.assertEqual(
|
||||
customer_call["filter"],
|
||||
{"$and": [{"owner": {"id": {"$in": [54]}}}]},
|
||||
)
|
||||
self.assertEqual(result["results"][0]["owners"], [{"id": 54, "name": "Alice Wang"}])
|
||||
|
||||
def test_project_query_and_timeline_use_crm_project_relations(self) -> None:
|
||||
class ProjectClient(FakeNbClient):
|
||||
project = {
|
||||
"id": 41,
|
||||
"f_project_name": "EV inverter",
|
||||
"client_ID": 7,
|
||||
"f_projects_client": {"id": 7, "f_q2q60q7bqbg": "Acme China"},
|
||||
"f_projects_progress": "k4w2vcz819w",
|
||||
"createdAt": "2026-07-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-07-08T00:00:00.000Z",
|
||||
}
|
||||
|
||||
def list_records(self, resource, **kwargs):
|
||||
self.list_calls.append((resource, kwargs))
|
||||
if resource == "t_projects":
|
||||
return {"data": [dict(self.project)], "meta": {"count": 1}}
|
||||
if resource == "t_qxeepjvi39s":
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": 5,
|
||||
"f_6g9fw5oqef9": "2026-07-09T00:00:00.000Z",
|
||||
"f_ei16g0ihwwd": "报价已提交",
|
||||
"createdBy": {"id": 54, "nickname": "Alice Wang"},
|
||||
}
|
||||
],
|
||||
"meta": {"count": 1},
|
||||
}
|
||||
return super().list_records(resource, **kwargs)
|
||||
|
||||
def get_record(self, resource, filter_by_tk, **kwargs):
|
||||
self.get_calls.append((resource, filter_by_tk, kwargs))
|
||||
if resource == "t_projects":
|
||||
return {"data": dict(self.project)}
|
||||
return super().get_record(resource, filter_by_tk, **kwargs)
|
||||
|
||||
client = ProjectClient()
|
||||
service = CrmService(load_config(None), client)
|
||||
|
||||
listed = service.list_projects(stage="已报价")
|
||||
timeline = service.get_project_timeline(41)
|
||||
|
||||
self.assertEqual(listed["results"][0]["stage"], "已报价")
|
||||
self.assertEqual(listed["results"][0]["customers"], [{"id": 7, "name": "Acme China"}])
|
||||
# Relations are projected, so the CLI is never asked to append whole rows.
|
||||
project_call = next(kwargs for resource, kwargs in client.list_calls if resource == "t_projects")
|
||||
self.assertNotIn("appends", project_call)
|
||||
self.assertIn("f_projects_client.f_q2q60q7bqbg", project_call["fields"])
|
||||
self.assertEqual(
|
||||
{event["event_type"] for event in timeline["events"]},
|
||||
{"followup", "project_update"},
|
||||
)
|
||||
followup_event = next(e for e in timeline["events"] if e["event_type"] == "followup")
|
||||
self.assertEqual(followup_event["actors"], ["Alice Wang"])
|
||||
update_event = next(e for e in timeline["events"] if e["event_type"] == "project_update")
|
||||
self.assertEqual(update_event["actors"], ["Alice Wang"])
|
||||
|
||||
def test_stale_customers_uses_last_valid_followup_date(self) -> None:
|
||||
class StaleClient(FakeNbClient):
|
||||
def list_records(self, resource, **kwargs):
|
||||
response = super().list_records(resource, **kwargs)
|
||||
if resource == "customers":
|
||||
response["data"][0]["id"] = 7
|
||||
return response
|
||||
|
||||
result = CrmService(load_config(None), StaleClient()).list_stale_customers(
|
||||
inactive_days=30,
|
||||
as_of="2026-08-10",
|
||||
)
|
||||
|
||||
self.assertEqual(result["results"][0]["customer"]["id"], 7)
|
||||
self.assertEqual(result["results"][0]["days_since_last_followup"], 33)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -297,6 +297,26 @@ class NbClientTests(unittest.TestCase):
|
||||
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 = {}
|
||||
|
||||
|
||||
+10
-1
@@ -156,10 +156,18 @@ class ServerTests(unittest.TestCase):
|
||||
"crm_overview",
|
||||
"crm_filter_options",
|
||||
"resolve_owner",
|
||||
"list_teams",
|
||||
"resolve_team",
|
||||
"get_team",
|
||||
"team_crm_overview",
|
||||
"list_customers",
|
||||
"get_customer",
|
||||
"list_contacts",
|
||||
"list_followups",
|
||||
"list_projects",
|
||||
"get_project",
|
||||
"get_project_timeline",
|
||||
"list_stale_customers",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -188,7 +196,8 @@ class ServerTests(unittest.TestCase):
|
||||
self.assertIn("include_raw", tools["list_customers"].inputSchema["properties"])
|
||||
self.assertIn("include_raw", tools["list_contacts"].inputSchema["properties"])
|
||||
self.assertIn("include_raw", tools["list_followups"].inputSchema["properties"])
|
||||
self.assertIn("include_raw_followups", tools["get_customer"].inputSchema["properties"])
|
||||
self.assertIn("include_raw", tools["get_customer"].inputSchema["properties"])
|
||||
self.assertIn("include_details", tools["list_contacts"].inputSchema["properties"])
|
||||
|
||||
def test_tools_return_structured_error_for_nb_failures(self) -> None:
|
||||
if FastMCP is None:
|
||||
|
||||
Reference in New Issue
Block a user