Repository navigation
fix(profiles): persist hyperlink profile values and verify the write - #26
Conversation
Fixes #22: accelo_set_profile_value returned ok but silently failed to persist a value for hyperlink (and other custom) profile fields. Root cause: the write was driven through the parent object's update endpoint (PUT /{object}/{id} with a profile.{id} key). That form is only Accelo's create-company shortcut, not a general profile-value write — the company PUT succeeds and echoes the company, but the profile value is never applied, and ok is still returned. - Write through the documented dedicated endpoint POST /{object}/{id}/profiles/fields/{field_id}. - Shape the body by the field's resolved field_type: value for text/hyperlink/decimal/currency/select/multi_select, value_int for integer, value_date for date/date_time, and value_id + value_type for lookup (new optional value_type parameter). The field_type is resolved via GET /{object}/profiles/fields. - Add a mandatory read-back verify: after writing, re-read via GET /{object}/{id}/profiles/values?_fields=_ALL and confirm the value changed. A missing row or mismatch returns a verify_failed error instead of ok, so the tool can never report a silent no-op as success again. - Rewrite the docstring and remove the incorrect claim that the profiles/fields POST path is rejected by Accelo. Tests mock the Accelo HTTP layer (respx) and cover the hyperlink happy path (asserting the dedicated POST, not the parent PUT), the silent-no-op regression guard (verify_failed on an unchanged read-back and on a missing row), lookup, integer, and the structured-error case. 8 profile tests pass; ruff check and format clean. No version bump (release batched).
There was a problem hiding this comment.
Review Summary
The core bug fix is sound: replacing the PUT /{object}/{id} + profile.{id} approach with the dedicated POST /{object}/{id}/profiles/fields/{field_id} endpoint, and adding a read-back verification step, correctly addresses the silent no-op for hyperlink fields. The test coverage for the regression scenarios is thorough and well-structured.
There are four blocking issues to address before merge:
-
int(value)crash (line 138,profiles.py) —ValueErroris not caught byexcept AcceloAPIError, so a non-integer string passed to anintegerfield will raise an unhandled exception instead of returning a structured error. -
int(value)crash in_value_matches(line 19,profiles.py) — The sameValueErrorexposure exists in the read-back verification helper; it is outside thetry/except AcceloAPIErrorblock entirely. -
Noneserialised as"None"forlookupvalue_type(line 142,profiles.py) — Whenvalue_typeis omitted (defaults toNone), it is still included in the form-encoded body as the literal string"None", which is an invalid Accelo API payload. This should be excluded whenNoneand the field type islookup(or an explicit early error should be raised). -
No pagination for field-type resolution (line 120,
profiles.py) — TheGET /{object_type}/profiles/fieldscall uses no_limitor pagination, so the target field will silently producefield_not_foundif it lives beyond the default page. The test mock always returns a single item so this is not caught by existing tests.
You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.
|
|
||
| # Shape the write body by field type. | ||
| if field_type == "integer": | ||
| body: dict = {"value_int": int(value)} |
There was a problem hiding this comment.
🛑 Crash Risk: int(value) raises ValueError if value is not a valid integer string (e.g. "abc", "3.5"), but this is not caught by the surrounding except AcceloAPIError handler. A non-integer value passed to an integer field will propagate as an unhandled exception rather than returning a structured error. Wrap the coercion in a try/except ValueError and return an error dict with a meaningful status (e.g. "invalid_value") to match the tool's existing error-response contract.
| elif field_type in ("date", "date_time"): | ||
| body = {"value_date": value} | ||
| elif field_type == "lookup": | ||
| body = {"value_id": value, "value_type": value_type} |
There was a problem hiding this comment.
🛑 Logic Error: The lookup field body includes "value_type": value_type even when value_type is None (the default). Sending value_type=None in the form-encoded body will serialise as the string "None", which is an invalid payload for the Accelo API and may cause the write to fail or produce unexpected behaviour. Add a guard to only include value_type in the body when it is not None, and consider returning an early error when it is absent for a lookup field.
| because Accelo may normalise the stored date format. | ||
| """ | ||
| if field_type == "integer": | ||
| return str(row.get("value_int", row.get("value"))) == str(int(value)) |
There was a problem hiding this comment.
🛑 Logic Error: The _value_matches helper for the integer case also calls int(value) without guarding against ValueError. If value is not a valid integer string, the read-back verification step will raise an unhandled exception instead of returning verify_failed. This path is reachable if the Accelo API accepted the write for a field that was stored with value_int despite a non-integer value being submitted.
| f"/{object_type}/{object_id}", | ||
| data={f"profile.{profile_field_id}": value}, | ||
| # Resolve the field's type so the body can be shaped correctly. | ||
| fields_result = await client.get(f"/{object_type}/profiles/fields") |
There was a problem hiding this comment.
🛑 Logic Error: The field-type resolution at line 120 fetches /{object_type}/profiles/fields without any pagination parameters. If the object type has more profile fields than fit in the default page size, the target profile_field_id may fall on a subsequent page and never be found, causing field_not_found to be returned even though the field exists. The _fields_route test helper always returns a single-item list so this gap is not covered by the test suite.
Resolves the CodeQL py/incomplete-url-substring-sanitization high alert on the hyperlink happy-path test. Replace the loose 'example.com' in body check with a precise parse_qs assertion that the decoded 'value' form field equals the submitted URL - both CodeQL-clean and a stronger assertion.
Addresses four defensive-robustness findings from the PR #26 review: - Guard int(value) on the integer write path: a non-integer value now returns a structured invalid_value error before any write, instead of raising an unhandled ValueError (which the except AcceloAPIError handler did not catch). - Guard int(value) in the _value_matches read-back helper so it can never raise — returns False (non-match) on a non-integer value. - Require value_type for a lookup field: return a value_type_required error when it is missing, so a None value_type is never form-encoded as the literal string 'None' into the write body. - Resolve the field type with _limit=100 (Accelo's documented max page size) so a profile field beyond the default first page is found rather than falsely reported as field_not_found. Docstring documents the new invalid_value/value_type_required statuses and the lookup value_type requirement. Four new respx tests (parsed-value assertions, no URL substrings): non-integer rejected with no write POST, lookup missing value_type rejected with no write POST, lookup happy path asserting exact value_id/value_type, and _limit=100 pagination proven. 12 profile tests pass; ruff check and format clean. No version bump (release batched).
|
Thanks — all four findings were valid and are fixed in 49a08c6:
Four new tests cover each path (parsed-value assertions, no URL substrings): non-integer rejected with no write POST, lookup-missing-value_type rejected with no write POST, lookup happy path asserting exact (The CodeQL annotation still shown at |
Summary
Fixes #22.
accelo_set_profile_valuereturnedstatus: okbut silently failed to persist the value for a company hyperlink profile field — a false-positive write that automation would trust and move on from.Root cause
The write was driven through the parent object's update endpoint —
PUT /{object}/{id}with the value keyed asprofile.{profile_field_id}. Per the Accelo API reference, thatprofile.{id}form is only the create-company shortcut (documented forPOST /companies), not a general profile-value write. For a hyperlink field the parent PUT is accepted (the company updates and echoes back), but theprofile.{id}value is never applied — andokis still returned. The previous docstring's claim that the dedicated/profiles/fields/{id}POST path is "rejected by Accelo" was incorrect and was the source of the bug.What changed (
src/accelo_mcp/tools/profiles.py)POST /{object}/{id}/profiles/fields/{field_id}("Set a Profile Field Value").field_type. The field's type is resolved viaGET /{object}/profiles/fields, then the body is shaped accordingly:integer→value_intdate/date_time→value_datelookup→value_id+value_type(new optionalvalue_typeparameter)hyperlink,text,decimal,currency,select,multi_select→value(string)GET /{object}/{id}/profiles/values?_fields=_ALLand confirms the value actually changed. A missing row or a mismatch returns averify_failederror instead ofok— so the tool can never report a silent no-op as success again, regardless of field type or future API quirks.Testing
Tests mock the Accelo HTTP layer with respx (
tests/test_tools/test_profiles.py):value=<url>in the body.verify_failed, notok. This is the core proof the false positive can't recur.value_id+value_type), integer (value_int), and the structured-error case.8 profile tests pass;
ruff checkandruff format --checkboth clean.Release
No version bump in this PR — the release is batched. A single consolidated version bump + release note will be cut once #16, #22 and #23 have all merged.
Closes #22.