Files
maskanx_crm_frontend/tests/test_properties.py
T
AFFAANhandClaude Opus 5 9d6aa64ad6 feat(properties): add custom fields without a migration
Modelled on HubSpot's custom properties: the definition lives in a table,
the values live in each record's existing JSON attributes. Adding a field
is an INSERT, so it can happen mid-conversation through MCP and the next
record can use it immediately.

The cost of that is that the database enforces nothing about the values,
so data_type is enforced in application code and every write path has to
come through it — otherwise the type on a definition is decoration.
Values are coerced rather than merely checked, because callers are agents
and HTTP clients and "42" is a number expressed loosely; what is rejected
is genuinely ambiguous, like "quite large" for a number.

A value whose property has no definition is rejected rather than stored.
A typo sitting in the database looks exactly like data, and a registry
whose set of fields is not actually the set of fields is worse than none.
Workspaces with nothing defined keep the old free-form behaviour, so this
does not break attributes already in use.

name and data_type are not updatable: renaming orphans every value stored
under the old key, and retyping leaves values that no longer satisfy the
type. Deleting a definition leaves existing values alone rather than
rewriting every record, so undoing a mistaken delete is just recreating
the property.

Two ways in, sharing one validator: a logged-in person, and an integration
key for MaskanX. An agent gets no shortcut around the rules a person is
held to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:07:50 +05:30

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