373 lines
12 KiB
Python
373 lines
12 KiB
Python
"""Custom properties: fields added without a migration.
|
|||
|
|
|
||
|
|
The definitions live in a table and the values in each record's JSON
|
||
|
|
`attributes`, so nothing in the database enforces a type. These tests are
|
||
|
|
mostly about the validation that stands in for that, and about the two ways
|
||
|
|
in — a logged-in person, and an integration key, which is what an agent
|
||
|
|
reaches through MCP.
|
||
|
|
"""
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
from app.properties import PropertyError, coerce_value, validate_definition
|
||
|
|
|
||
|
|
|
||
|
|
class _Definition:
|
||
|
|
"""Stands in for a stored definition in the pure-function tests."""
|
||
|
|
|
||
|
|
def __init__(self, data_type, options=None, label="Field", is_required=False):
|
||
|
|
self.data_type = data_type
|
||
|
|
self.options = options or []
|
||
|
|
self.label = label
|
||
|
|
self.is_required = is_required
|
||
|
|
self.name = "field"
|
||
|
|
|
||
|
|
|
||
|
|
# --- defining ---
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_name_must_be_a_usable_key():
|
||
|
|
"""It becomes a JSON key and a query parameter, not just a label."""
|
||
|
|
for bad in ("Budget Range", "1st_field", "budget-range", "", "A"):
|
||
|
|
with pytest.raises(PropertyError):
|
||
|
|
validate_definition(
|
||
|
|
name=bad, object_type="contact", data_type="string", options=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_good_name_is_accepted():
|
||
|
|
validate_definition(
|
||
|
|
name="budget_range", object_type="contact", data_type="string", options=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_enumeration_with_no_options_is_refused():
|
||
|
|
"""No value could ever be valid for it."""
|
||
|
|
with pytest.raises(PropertyError, match="at least one option"):
|
||
|
|
validate_definition(
|
||
|
|
name="stage", object_type="lead", data_type="enumeration", options=[],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_options_on_a_non_enumeration_are_refused():
|
||
|
|
with pytest.raises(PropertyError, match="only apply to an enumeration"):
|
||
|
|
validate_definition(
|
||
|
|
name="notes", object_type="lead", data_type="string", options=["a"],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unknown_object_type_lists_the_real_ones():
|
||
|
|
with pytest.raises(PropertyError, match="contact"):
|
||
|
|
validate_definition(
|
||
|
|
name="x", object_type="spaceship", data_type="string", options=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# --- values ---
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_number_arriving_as_a_string_is_accepted():
|
||
|
|
"""Callers are agents and HTTP clients; "42" is a number loosely typed."""
|
||
|
|
assert coerce_value(_Definition("number"), "42") == 42
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_whole_number_stays_whole():
|
||
|
|
assert coerce_value(_Definition("number"), "3.0") == 3
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_decimal_survives():
|
||
|
|
assert coerce_value(_Definition("number"), "3.5") == 3.5
|
||
|
|
|
||
|
|
|
||
|
|
def test_something_genuinely_unnumeric_is_refused():
|
||
|
|
with pytest.raises(PropertyError, match="expects a number"):
|
||
|
|
coerce_value(_Definition("number"), "quite large")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("value", ["true", "Yes", "1", True])
|
||
|
|
def test_truthy_spellings_of_boolean(value):
|
||
|
|
assert coerce_value(_Definition("boolean"), value) is True
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("value", ["false", "No", "0", False])
|
||
|
|
def test_falsy_spellings_of_boolean(value):
|
||
|
|
assert coerce_value(_Definition("boolean"), value) is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_ambiguous_boolean_is_refused():
|
||
|
|
with pytest.raises(PropertyError, match="true or false"):
|
||
|
|
coerce_value(_Definition("boolean"), "maybe")
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_date_is_normalised_to_iso():
|
||
|
|
assert coerce_value(_Definition("date"), "2026-08-04") == "2026-08-04"
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_datetime_string_keeps_only_the_date():
|
||
|
|
assert coerce_value(_Definition("date"), "2026-08-04T10:30:00Z") == "2026-08-04"
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unparseable_date_says_what_it_wanted():
|
||
|
|
with pytest.raises(PropertyError, match="YYYY-MM-DD"):
|
||
|
|
coerce_value(_Definition("date"), "next tuesday")
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_enumeration_value_must_be_offered():
|
||
|
|
definition = _Definition("enumeration", options=["hot", "warm"], label="Temp")
|
||
|
|
|
||
|
|
assert coerce_value(definition, "hot") == "hot"
|
||
|
|
with pytest.raises(PropertyError, match="hot, warm"):
|
||
|
|
coerce_value(definition, "lukewarm")
|
||
|
|
|
||
|
|
|
||
|
|
def test_none_always_clears_a_value():
|
||
|
|
"""Whether it may be empty is is_required's business, checked elsewhere."""
|
||
|
|
assert coerce_value(_Definition("number", is_required=True), None) is None
|
||
|
|
|
||
|
|
|
||
|
|
# --- the API ---
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture()
|
||
|
|
def service_key(client: TestClient, auth_headers: dict[str, str]) -> str:
|
||
|
|
created = client.post(
|
||
|
|
"/api/v1/integrations/credentials",
|
||
|
|
headers=auth_headers,
|
||
|
|
json={"name": "MaskanX"},
|
||
|
|
)
|
||
|
|
return created.json()["key"]
|
||
|
|
|
||
|
|
|
||
|
|
def _definition(**overrides) -> dict:
|
||
|
|
payload = {
|
||
|
|
"object_type": "contact",
|
||
|
|
"name": "budget_range",
|
||
|
|
"label": "Budget range",
|
||
|
|
"data_type": "enumeration",
|
||
|
|
"options": ["50-70 lakh", "70-90 lakh", "1 crore+"],
|
||
|
|
}
|
||
|
|
payload.update(overrides)
|
||
|
|
return payload
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_property_can_be_created_and_listed(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
created = client.post(
|
||
|
|
"/api/v1/properties", headers=auth_headers, json=_definition(),
|
||
|
|
)
|
||
|
|
assert created.status_code == 201, created.text
|
||
|
|
|
||
|
|
listed = client.get("/api/v1/properties", headers=auth_headers).json()
|
||
|
|
assert [p["name"] for p in listed] == ["budget_range"]
|
||
|
|
assert listed[0]["options"] == ["50-70 lakh", "70-90 lakh", "1 crore+"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_properties_can_be_filtered_by_object_type(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
|
||
|
|
client.post(
|
||
|
|
"/api/v1/properties",
|
||
|
|
headers=auth_headers,
|
||
|
|
json=_definition(
|
||
|
|
object_type="lead", name="site_visit_done",
|
||
|
|
label="Site visit done", data_type="boolean", options=[],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
contacts = client.get(
|
||
|
|
"/api/v1/properties?object_type=contact", headers=auth_headers,
|
||
|
|
).json()
|
||
|
|
|
||
|
|
assert [p["name"] for p in contacts] == ["budget_range"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_same_property_twice_is_a_readable_conflict(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
"""An agent re-running a request needs to know it already exists."""
|
||
|
|
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
|
||
|
|
again = client.post(
|
||
|
|
"/api/v1/properties", headers=auth_headers, json=_definition(),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert again.status_code == 409
|
||
|
|
assert "already exists" in again.json()["error"]["message"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_invalid_definition_is_refused_with_a_reason(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/properties",
|
||
|
|
headers=auth_headers,
|
||
|
|
json=_definition(name="Budget Range"),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 422
|
||
|
|
assert "budget_range" in response.json()["error"]["message"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_label_can_be_changed_without_orphaning_values(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
created = client.post(
|
||
|
|
"/api/v1/properties", headers=auth_headers, json=_definition(),
|
||
|
|
).json()
|
||
|
|
|
||
|
|
updated = client.patch(
|
||
|
|
f"/api/v1/properties/{created['id']}",
|
||
|
|
headers=auth_headers,
|
||
|
|
json={"label": "Buying budget"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert updated.status_code == 200
|
||
|
|
assert updated.json()["label"] == "Buying budget"
|
||
|
|
# The key values are stored under is untouched.
|
||
|
|
assert updated.json()["name"] == "budget_range"
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_property_can_be_deleted(client: TestClient, auth_headers: dict) -> None:
|
||
|
|
created = client.post(
|
||
|
|
"/api/v1/properties", headers=auth_headers, json=_definition(),
|
||
|
|
).json()
|
||
|
|
|
||
|
|
assert (
|
||
|
|
client.delete(
|
||
|
|
f"/api/v1/properties/{created['id']}", headers=auth_headers,
|
||
|
|
).status_code
|
||
|
|
== 204
|
||
|
|
)
|
||
|
|
assert client.get("/api/v1/properties", headers=auth_headers).json() == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_reading_properties_needs_a_login(client: TestClient) -> None:
|
||
|
|
assert client.get("/api/v1/properties").status_code == 401
|
||
|
|
|
||
|
|
|
||
|
|
# --- through an integration key, which is what MCP uses ---
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_integration_can_create_a_property(
|
||
|
|
client: TestClient, auth_headers: dict, service_key: str,
|
||
|
|
) -> None:
|
||
|
|
created = client.post(
|
||
|
|
"/api/v1/integrations/properties",
|
||
|
|
headers={"X-Integration-Key": service_key},
|
||
|
|
json=_definition(),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert created.status_code == 201, created.text
|
||
|
|
# Marked as machine-made, so a human can tell which fields a
|
||
|
|
# conversation produced.
|
||
|
|
assert created.json()["created_by"].startswith("integration:")
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_integration_gets_no_shortcut_around_validation(
|
||
|
|
client: TestClient, service_key: str,
|
||
|
|
) -> None:
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/integrations/properties",
|
||
|
|
headers={"X-Integration-Key": service_key},
|
||
|
|
json=_definition(data_type="enumeration", options=[]),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_integration_property_is_visible_to_people(
|
||
|
|
client: TestClient, auth_headers: dict, service_key: str,
|
||
|
|
) -> None:
|
||
|
|
"""A field created in chat has to show up in the CRM's own screens."""
|
||
|
|
client.post(
|
||
|
|
"/api/v1/integrations/properties",
|
||
|
|
headers={"X-Integration-Key": service_key},
|
||
|
|
json=_definition(),
|
||
|
|
)
|
||
|
|
|
||
|
|
listed = client.get("/api/v1/properties", headers=auth_headers).json()
|
||
|
|
|
||
|
|
assert [p["name"] for p in listed] == ["budget_range"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_creating_a_property_needs_a_valid_key(client: TestClient) -> None:
|
||
|
|
response = client.post("/api/v1/integrations/properties", json=_definition())
|
||
|
|
|
||
|
|
assert response.status_code == 401
|
||
|
|
|
||
|
|
|
||
|
|
# --- values on records ---
|
||
|
|
|
||
|
|
|
||
|
|
def _contact(client, auth_headers, attributes):
|
||
|
|
return client.post(
|
||
|
|
"/api/v1/contacts",
|
||
|
|
headers=auth_headers,
|
||
|
|
json={
|
||
|
|
"first_name": "Asha",
|
||
|
|
"last_name": "Menon",
|
||
|
|
"primary_email": "asha@example.com",
|
||
|
|
"attributes": attributes,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_value_is_validated_against_its_definition(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
|
||
|
|
|
||
|
|
good = _contact(client, auth_headers, {"budget_range": "70-90 lakh"})
|
||
|
|
assert good.status_code == 201, good.text
|
||
|
|
assert good.json()["attributes"]["budget_range"] == "70-90 lakh"
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_value_outside_the_enumeration_is_refused(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
|
||
|
|
|
||
|
|
response = _contact(client, auth_headers, {"budget_range": "a few rupees"})
|
||
|
|
|
||
|
|
assert response.status_code == 422
|
||
|
|
assert "70-90 lakh" in response.json()["error"]["message"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_undefined_property_is_refused_rather_than_stored(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
"""A typo left in the database looks exactly like data."""
|
||
|
|
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
|
||
|
|
|
||
|
|
response = _contact(client, auth_headers, {"budjet_range": "70-90 lakh"})
|
||
|
|
|
||
|
|
assert response.status_code == 422
|
||
|
|
assert "budjet_range" in response.json()["error"]["message"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_attributes_stay_free_form_when_nothing_is_defined(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
"""Adding this validation must not break workspaces already using them."""
|
||
|
|
response = _contact(client, auth_headers, {"anything": "at all"})
|
||
|
|
|
||
|
|
assert response.status_code == 201
|
||
|
|
assert response.json()["attributes"] == {"anything": "at all"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_number_property_is_coerced_on_the_way_in(
|
||
|
|
client: TestClient, auth_headers: dict,
|
||
|
|
) -> None:
|
||
|
|
client.post(
|
||
|
|
"/api/v1/properties",
|
||
|
|
headers=auth_headers,
|
||
|
|
json=_definition(
|
||
|
|
name="bedrooms", label="Bedrooms", data_type="number", options=[],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
response = _contact(client, auth_headers, {"bedrooms": "3"})
|
||
|
|
|
||
|
|
assert response.json()["attributes"]["bedrooms"] == 3
|