Files
nocobase-mcp/tests/test_crm_service.py
T

821 lines
37 KiB
Python

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 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:
def __init__(self) -> None:
self.list_calls = []
self.get_calls = []
self.query_calls = []
self.field_calls = []
self.health_calls = []
def health(self, resource, id_field):
self.health_calls.append((resource, id_field))
return {"ok": True, "status": "reachable"}
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": [
{
"id": 54,
"username": "alice",
"nickname": "Alice Wang",
"email": "alice@example.com",
"f_yh_english_name": "Alice Wang",
}
],
"meta": {"count": 1},
}
if resource == "contacts":
return {
"data": [
{
"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",
"updatedAt": "2026-07-08T00:00:00.000Z",
}
],
"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",
"updatedAt": "2026-07-08T09:20:00.000Z",
}
],
"meta": {"count": 1},
}
if resource == "customers":
return {
"data": [
{
"id": 1,
"name": "Acme",
"f_q2q60q7bqbg": "Acme China",
"f_kh_english_name": "Acme",
"f_territorial_name": "US",
"status": "kh_p_inprogress",
"kh_progress": "kh_p_inprogress",
"source": "客户推荐",
"f_225h6n6v654": "客户推荐",
"owner": [{"id": 54, "nickname": "Alice Wang"}],
"kehu_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"kehu_contacts": [{"id": 99, "name": "Nested contact that should stay out of compact results"}],
"createdAt": "2026-07-01T00:00:00.000Z",
"updatedAt": "2026-07-08T00:00:00.000Z",
}
],
"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))
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))
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"},
{"count": 9, "kh_progress": "kh_p_ordered"},
]
}
if alias in {"source", "f_225h6n6v654"}:
return {
"data": [
{"count": 1, "f_225h6n6v654": "https://example.com"},
{"count": 9, "f_225h6n6v654": "客户推荐"},
{"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_crm_health_probes_the_configured_customer_resource(self) -> None:
client = FakeNbClient()
result = CrmService(load_config(None), client).crm_health()
self.assertTrue(result["ok"])
self.assertEqual(client.health_calls, [("customers", "id")])
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"][0]["id"], 1)
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")
# 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(
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["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]["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"],
"Called customer about sample status and confirmed next procurement meeting.",
)
self.assertNotIn("f_19iplxz0nds", result["recent_followups"][0])
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]["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")
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", "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")
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_only(self) -> None:
client = FakeNbClient()
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"collections": {
"followups": {
"ownerField": "gjjl_followup_person",
"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": [{"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):
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_ordered")
self.assertEqual(result["customer_status"][0]["label"], "已下单")
self.assertEqual(result["customer_source"][0]["value"], "客户推荐")
self.assertEqual(result["customer_source"][-1]["value"], "")
self.assertEqual(result["owner_candidates"][0]["id"], 54)
def test_list_customers_can_opt_into_raw_rows(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_customers(limit=1, include_raw=True)
row = result["results"][0]
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()
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_returns_compact_preview_by_default(self) -> None:
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["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)
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"],
"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()