Add Matrix intake bot (M1+M2): typed message → approved fundraising-grid write
New backend/matrix_intake/ runs as its own process (matrix-nio isolated from the stdlib CRM): local-Qwen parse via Spark Control → in-thread human approval (yes/edit/no) → write through the CRM's own log-communication endpoint, tagged source=matrix_intake. Adds read-only GET /api/intake/match (returns grid row id, no-duplicate contract); threads provenance through handle_log_fundraising_communication. Reviewer-passed: pop-before-commit closes a double-approve race; edit-grammar fix. Text-only v1; business-card photo (M3) deferred (no Spark vision model). 26/26 tests green; live Matrix smoke pending deploy.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""Tests for the proposal store + approval state machine (pure logic, no network)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import proposals # noqa: E402
|
||||
|
||||
SAMPLE = {"intent": "new_investor", "investor_name": "Acme Capital",
|
||||
"contact_name": "Jane Doe", "contact_email": "jane@acme.com",
|
||||
"contact_title": None, "note": "met at conf"}
|
||||
|
||||
|
||||
def test_store_put_get_pop():
|
||||
s = proposals.ProposalStore()
|
||||
assert not s.has("$root")
|
||||
s.put("$root", SAMPLE)
|
||||
assert s.has("$root")
|
||||
assert s.get("$root")["investor_name"] == "Acme Capital"
|
||||
assert s.pop("$root")["investor_name"] == "Acme Capital"
|
||||
assert not s.has("$root")
|
||||
assert s.pop("$missing") is None
|
||||
|
||||
|
||||
def test_interpret_yes_variants():
|
||||
for t in ("yes", "Y", "approve", " ok ", "👍"):
|
||||
assert proposals.interpret_reply(t)[0] == "approve", t
|
||||
|
||||
|
||||
def test_interpret_no_variants():
|
||||
for t in ("no", "N", "cancel", "discard", "❌"):
|
||||
assert proposals.interpret_reply(t)[0] == "reject", t
|
||||
|
||||
|
||||
def test_interpret_edit_equals():
|
||||
action, payload = proposals.interpret_reply("edit email=new@acme.com")
|
||||
assert action == "edit"
|
||||
assert payload == ("contact_email", "new@acme.com")
|
||||
|
||||
|
||||
def test_interpret_edit_colon_and_alias():
|
||||
action, payload = proposals.interpret_reply("firm: Acme Capital LLC")
|
||||
assert action == "edit"
|
||||
assert payload == ("investor_name", "Acme Capital LLC")
|
||||
|
||||
|
||||
def test_interpret_unknown():
|
||||
assert proposals.interpret_reply("maybe later")[0] == "unknown"
|
||||
|
||||
|
||||
def test_interpret_edit_colon_value_contains_equals():
|
||||
# the '=' inside the value must not break parsing — split on ':' first, keep the rest
|
||||
action, payload = proposals.interpret_reply("note: see deck=v2")
|
||||
assert action == "edit"
|
||||
assert payload == ("note", "see deck=v2")
|
||||
|
||||
|
||||
def test_claim_once_pop_guards_double_approve():
|
||||
# the double-approve guard relies on pop() yielding the proposal exactly once;
|
||||
# a second claim returns None so a racing second 'yes' is a no-op
|
||||
s = proposals.ProposalStore()
|
||||
s.put("$r", SAMPLE)
|
||||
assert s.pop("$r") is not None
|
||||
assert s.pop("$r") is None
|
||||
|
||||
|
||||
def test_edit_with_unknown_field_is_not_an_edit():
|
||||
# an unknown field name must not silently become an edit
|
||||
assert proposals.interpret_reply("edit zipcode=90210")[0] == "unknown"
|
||||
|
||||
|
||||
def test_apply_edit_is_nondestructive():
|
||||
updated = proposals.apply_edit(SAMPLE, "contact_email", "x@y.com")
|
||||
assert updated["contact_email"] == "x@y.com"
|
||||
assert SAMPLE["contact_email"] == "jane@acme.com" # original untouched
|
||||
|
||||
|
||||
def test_render_includes_fields_and_instructions():
|
||||
text = proposals.render(SAMPLE)
|
||||
assert "Acme Capital" in text
|
||||
assert "jane@acme.com" in text
|
||||
assert "yes" in text.lower() and "no" in text.lower()
|
||||
|
||||
|
||||
def test_render_meeting_note_variant():
|
||||
note = dict(SAMPLE, intent="meeting_note")
|
||||
assert "meeting note" in proposals.render(note).lower()
|
||||
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user