import json import re import tempfile import unittest 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["contacts_pagination"], {"total": 1, "returned": 1, "limit": 50, "offset": 0, "has_more": False, "next_offset": None}, ) self.assertEqual(result["recent_followups"][0]["id"], 1) self.assertEqual(result["recent_followups_pagination"]["limit"], 5) self.assertFalse(result["recent_followups_pagination"]["has_more"]) 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_team_user_resolution_pages_past_one_hundred_members(self) -> None: class PagedUsersClient(FakeNbClient): def list_records(self, resource, **kwargs): self.list_calls.append((resource, kwargs)) if resource != "users": return super().list_records(resource, **kwargs) page = kwargs["page"] page_size = kwargs["page_size"] start = (page - 1) * page_size rows = [ {"id": index, "nickname": f"User {index}", "yh_status": "nkluuumb1r1"} for index in range(start + 1, min(start + page_size, 150) + 1) ] return {"data": rows, "meta": {"count": 150}} client = PagedUsersClient() service = CrmService(load_config(None), client) team_row = {"tdgl_team_members": [{"id": index} for index in range(1, 151)]} users = service._team_users(team_row, "tdgl_team_members", formal_only=True) self.assertEqual(len(users), 150) user_calls = [kwargs for resource, kwargs in client.list_calls if resource == "users"] self.assertEqual([call["page"] for call in user_calls], [1, 2]) 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="已报价") detail = service.get_project(41) 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(detail["followups_pagination"]["total"], 1) self.assertFalse(detail["followups_pagination"]["has_more"]) 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) def test_scan_limit_returns_explicit_continuation_metadata(self) -> None: class ThreeRowClient(FakeNbClient): def list_records(self, resource, **kwargs): page = kwargs["page"] page_size = kwargs["page_size"] start = (page - 1) * page_size return { "data": [{"id": index} for index in range(start + 1, min(start + page_size, 3) + 1)], "meta": {"count": 3}, } rows, truncation = CrmService(load_config(None), ThreeRowClient())._scan_records( "customers", page_size=2, max_records=2, ) self.assertEqual([row["id"] for row in rows], [1, 2]) self.assertEqual( truncation, {"total": 3, "returned": 2, "limit": 2, "next_offset": 2}, ) if __name__ == "__main__": unittest.main()