8b2eb01a65
The card transcription prompt now reads emails/URLs/phones character-by-character, explicitly forbids autocompleting toward a plausible domain (the mara.com -> marac.com failure), and emits labeled lines (which also feeds the field extractor cleaner input). The extractor gains city + linkedin_url. city is a plain field (low-harm if wrong; the human sees it on the card). linkedin_url follows the email-integrity rule: kept only if it literally appears in the source / a revise instruction, never minted -- a wrong profile URL points at the wrong person. Both flow to the contact via the existing log-communication upsert (city also syncs to the grid contact pill). Phone is intentionally NOT included yet: the bot's write path can't store it until a small server-side change lands (next s9pk). See the matrix-intake guide.
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""Tests for the CRM client's payload builder (pure logic, no network)."""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import crm_client # noqa: E402
|
|
|
|
|
|
def test_new_investor_payload():
|
|
p = {"intent": "new_investor", "investor_name": "Acme Capital",
|
|
"contact_name": "Jane Doe", "contact_email": "jane@acme.com",
|
|
"contact_title": "GP", "note": "met at conf"}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["investor_name"] == "Acme Capital"
|
|
assert out["create_investor_if_missing"] is True
|
|
assert "row_id" not in out
|
|
assert out["contact"] == {"name": "Jane Doe", "email": "jane@acme.com", "title": "GP",
|
|
"city": "", "linkedin_url": ""}
|
|
assert out["body"] == "met at conf"
|
|
assert out["source"] == "matrix_intake"
|
|
|
|
|
|
def test_contact_carries_city_and_linkedin_when_present():
|
|
p = {"intent": "new_investor", "investor_name": "Acme Capital", "contact_name": "Jane Doe",
|
|
"contact_email": "jane@acme.com", "city": "New York",
|
|
"linkedin_url": "linkedin.com/in/janedoe", "note": "met at conf"}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["contact"]["city"] == "New York"
|
|
assert out["contact"]["linkedin_url"] == "linkedin.com/in/janedoe"
|
|
|
|
|
|
def test_existing_investor_uses_row_id_not_create():
|
|
p = {"intent": "meeting_note", "investor_name": "Acme Capital",
|
|
"contact_name": "Jane Doe", "contact_email": None, "note": "wants Q3 deck",
|
|
"_match_id": "rowAcme"}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["row_id"] == "rowAcme"
|
|
assert "create_investor_if_missing" not in out
|
|
assert "investor_name" not in out # targeted by row id, never re-matched by name
|
|
assert out["body"] == "wants Q3 deck"
|
|
|
|
|
|
def test_contact_falls_back_to_investor_name_when_no_person():
|
|
p = {"intent": "new_investor", "investor_name": "Delta Fund",
|
|
"contact_name": None, "contact_email": None, "note": None}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["contact"]["name"] == "Delta Fund"
|
|
assert out["body"] == ""
|
|
|
|
|
|
def test_no_email_sends_empty_string_not_none():
|
|
p = {"intent": "new_investor", "investor_name": "Gamma", "contact_name": "Bob",
|
|
"contact_email": None, "note": "x"}
|
|
out = crm_client.build_commit_payload(p)
|
|
assert out["contact"]["email"] == ""
|
|
|
|
|
|
def test_subject_blank_when_note_present_else_provenance_label():
|
|
# The CRM's grid note line uses subject-or-body, so a blank subject lets the note text show.
|
|
with_note = crm_client.build_commit_payload(
|
|
{"intent": "meeting_note", "investor_name": "Acme", "note": "sent the deck", "_match_id": "r1"})
|
|
assert with_note["subject"] == ""
|
|
assert with_note["body"] == "sent the deck"
|
|
# no note text → fall back to a provenance label so the grid line isn't empty
|
|
no_note = crm_client.build_commit_payload(
|
|
{"intent": "new_investor", "investor_name": "Beta", "contact_name": "X", "note": None})
|
|
assert no_note["subject"] == "Intake (Matrix)"
|
|
|
|
|
|
def test_source_defaults_to_intake_and_card_overrides():
|
|
# Provenance: a typed note tags source="matrix_intake"; a scanned card rides in on
|
|
# _source="matrix_card" (set by the bot's image handler) so the audit log distinguishes them.
|
|
typed = crm_client.build_commit_payload(
|
|
{"intent": "new_investor", "investor_name": "Acme", "note": "x"})
|
|
assert typed["source"] == "matrix_intake"
|
|
card = crm_client.build_commit_payload(
|
|
{"intent": "new_investor", "investor_name": "Acme", "note": "x", "_source": "matrix_card"})
|
|
assert card["source"] == "matrix_card"
|
|
|
|
|
|
def _with_stub_authed(reply, capture=None):
|
|
"""Swap crm_client._authed for a canned (status, data); return a restorer."""
|
|
orig = crm_client._authed
|
|
|
|
def fake(method, path, body=None):
|
|
if capture is not None:
|
|
capture["path"] = path
|
|
return reply
|
|
|
|
crm_client._authed = fake
|
|
return orig
|
|
|
|
|
|
def test_match_parses_exact_match():
|
|
cap = {}
|
|
orig = _with_stub_authed((200, {"data": {
|
|
"match": {"id": "rowAcme", "investor_name": "Acme Capital", "matched_on": "name"},
|
|
"candidates": [],
|
|
}}), cap)
|
|
try:
|
|
res = crm_client.match({"investor_name": "Acme Capital", "contact_email": ""})
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["match"] == {"id": "rowAcme", "name": "Acme Capital"}
|
|
assert res["candidates"] == []
|
|
assert "q=Acme" in cap["path"] # the query was forwarded
|
|
|
|
|
|
def test_match_returns_ranked_candidates_when_no_exact():
|
|
orig = _with_stub_authed((200, {"data": {"match": None, "candidates": [
|
|
{"id": "rowCharlie", "investor_name": "Charlie Brown", "score": 0.92, "matched_on": "name"},
|
|
{"id": "rowBeta", "investor_name": "Beta Capital LLC", "score": 0.86, "matched_on": "name"},
|
|
]}}))
|
|
try:
|
|
res = crm_client.match({"investor_name": "Charles Brown"})
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["match"] is None
|
|
assert [c["id"] for c in res["candidates"]] == ["rowCharlie", "rowBeta"]
|
|
assert res["candidates"][0]["name"] == "Charlie Brown"
|
|
assert res["candidates"][0]["matched_on"] == "name"
|
|
|
|
|
|
def test_match_no_query_skips_network():
|
|
def boom(*a, **k):
|
|
raise AssertionError("should not hit the network when there's nothing to match on")
|
|
orig = crm_client._authed
|
|
crm_client._authed = boom
|
|
try:
|
|
res = crm_client.match({"investor_name": None, "contact_name": None, "contact_email": None})
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res == {"match": None, "candidates": []}
|
|
|
|
|
|
def test_nl_query_returns_endpoint_data():
|
|
cap = {}
|
|
orig = _with_stub_authed(
|
|
(200, {"data": {"intent": "top_investors_committed", "rows": [], "summary": "ok"}}), cap)
|
|
try:
|
|
res = crm_client.nl_query("top investors")
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["intent"] == "top_investors_committed"
|
|
assert cap["path"] == "/api/query/nl"
|
|
|
|
|
|
def test_nl_query_passes_through_soft_503():
|
|
# Model-down still carries a structured body (the endpoint 503s with the error in `data`) —
|
|
# return it for the renderer to surface, don't raise.
|
|
orig = _with_stub_authed((503, {"data": {"error": "model_unavailable"}}))
|
|
try:
|
|
res = crm_client.nl_query("anything")
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert res["error"] == "model_unavailable"
|
|
|
|
|
|
def test_nl_query_raises_on_auth_failure():
|
|
orig = _with_stub_authed((403, {"error": "Bot or admin required"}))
|
|
raised = False
|
|
try:
|
|
crm_client.nl_query("x")
|
|
except RuntimeError:
|
|
raised = True
|
|
finally:
|
|
crm_client._authed = orig
|
|
assert raised
|
|
|
|
|
|
if __name__ == "__main__":
|
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
|
for fn in fns:
|
|
fn()
|
|
print(f"ok {fn.__name__}")
|
|
print(f"\n{len(fns)} passed")
|