Files
purchases-ios/Tests/UnitTests/RulesEngine/PredicateFixtures/unresolved_variable.json
T
Antonio PallaresandCursor 66659ab8ef Raise an error for unresolved variables instead of degrading to null (#7460)
* Raise an error for unresolved variables instead of degrading to null

A `var` lookup that found nothing returned `null`, following the JSON
Logic spec. That null then coerces: `0` in a numeric comparison, `""`
in a string one. A rule that reads a dimension the SDK never supplied
therefore still produces an answer, and often the wrong one — a missing
`now` turns `{">": [expires, now]}` into `expires > 0`, which is true
for every subscription that ever existed.

An absent variable means "unknown", and unknown is not the same answer
as "no". The lookup now throws `EvaluationError.unresolvedVariable`
carrying the path, so the rule fails loudly and the SDK decides what an
unanswerable rule means. `LocalRulesEvaluator` already distinguishes an
error from a non-match: it keeps walking later rules and only throws if
none matched.

Absence a rule genuinely tolerates is opted into with the spec's own
escape hatch, `{"var": ["path", default]}`. A key that is present but
holds `null` is a known value and still resolves. `missing` and
`missing_some` exist to probe for absence and keep reporting it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop pinning the rendered path for the oversized float fixture

iOS renders `1e19` as `1e+19` and Android as `1.0E19`, so pinning the
path would keep this fixture from being shared verbatim between the two
engines. The fixture is about the formatter not trapping on a value past
Int64.max, not about how that magnitude is spelled.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Apply the unresolved-variable contract to rc.lower and rc.upper

`rc.lower` arrived on main while this branch was open, carrying a
fixture that pinned a missing variable as a `typeMismatch` from the
operator. The lookup now fails first, so the error names the unresolved
path instead. Its companion pins that an explicit null still reaches
`rc.lower` and is refused there.

The operator's rationale for not coercing cited a missing `var`
resolving to null, which is no longer how that ends. It refuses
non-strings because an explicit null would lower to "null" and compare
equal to it, which is the part that still holds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the warn doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Name the expected-error path field for the case it belongs to

`path` sits next to `kind` and `operator` with nothing to say which
error it qualifies. The JSON key stays `path`, since there it appears
alongside `"error": "unresolvedVariable"` and reads unambiguously.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Describe the rc.rootVar fixture by what it does today

The description narrated the previous null-degrading behavior instead of
the case the fixture pins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Give the absent-attribute predicates a default

Two non-matching predicates read attribute paths that resolve to nothing,
which is now an error rather than a false-y null. The Android counterpart
already carried this change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Report an unanswerable audience as unavailable rather than no match

Two checkpoint tests pinned the null-degrading behavior: an audience
reading a dimension no provider supplies was merely false, so it read as
noMatch, and under negation it matched everyone.

The lookup throws now, which routes both through the resolver's existing
decision that an audience the SDK failed to evaluate is not the same
answer as an audience the customer falls outside of.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 12:38:49 +02:00

160 lines
5.9 KiB
JSON

{
"fixtures": [
{
"id": "unresolved_numeric_comparison_is_rejected_instead_of_coercing_to_zero",
"description": "The motivating case. With a null-degrading lookup this becomes expires > 0, which is true for every subscription that ever existed, so an audience meant for active subscribers silently matches everyone.",
"predicate": {
">": [{ "var": "expires" }, { "var": "now" }]
},
"variables": { "expires": 1000 },
"expected": { "error": "unresolvedVariable", "path": "now" }
},
{
"id": "unresolved_string_comparison_is_rejected_instead_of_coercing_to_empty",
"description": "The same hazard in string form: null stringifies to \"\", so a rule gating on an absent country would answer for a reason unrelated to where the user is",
"predicate": {
"==": [{ "var": "country" }, ""]
},
"variables": {},
"expected": { "error": "unresolvedVariable", "path": "country" }
},
{
"id": "unresolved_variable_reports_the_first_failing_path",
"description": "Evaluation stops at the first name it cannot resolve, so the error points at one concrete path rather than a set",
"predicate": {
"and": [{ "var": "first_missing" }, { "var": "second_missing" }]
},
"variables": {},
"expected": { "error": "unresolvedVariable", "path": "first_missing" }
},
{
"id": "present_but_null_value_is_known_and_does_not_raise",
"description": "An explicit null is an answer the scope actually gave, so it resolves normally; only an absent path is unknown",
"predicate": {
"===": [{ "var": "country" }, null]
},
"variables": { "country": null },
"expected": true
},
{
"id": "present_but_null_leaf_on_a_dot_path_is_known_and_does_not_raise",
"predicate": {
"===": [{ "var": "subscriber.country" }, null]
},
"variables": { "subscriber": { "country": null } },
"expected": true
},
{
"id": "default_marks_absence_as_expected_and_suppresses_the_error",
"description": "The spec's own escape hatch is how a rule opts into tolerating absence, which keeps the intent visible in the rule itself",
"predicate": {
"===": [{ "var": ["country", "unknown"] }, "unknown"]
},
"variables": {},
"expected": true
},
{
"id": "undefined_default_suppresses_the_error_and_yields_null",
"description": "{\"var\": [path]} with an explicitly undefined default still counts as opting in, matching json-logic-js coercing that default to null",
"predicate": {
"===": [{ "var": ["country", null] }, null]
},
"variables": {},
"expected": true
},
{
"id": "unevaluated_or_branch_never_raises",
"description": "or short-circuits on the first truthy operand, so a missing name in a branch that never runs cannot fail the rule",
"predicate": {
"or": [true, { "var": "never_read" }]
},
"variables": {},
"expected": true
},
{
"id": "unevaluated_and_branch_never_raises",
"predicate": {
"==": [{ "and": [false, { "var": "never_read" }] }, false]
},
"variables": {},
"expected": true
},
{
"id": "untaken_if_branch_never_raises",
"description": "Only the selected branch is evaluated, so a rule can guard a name it is not sure about",
"predicate": {
"===": [{ "if": [false, { "var": "never_read" }, "fallback"] }, "fallback"]
},
"variables": {},
"expected": true
},
{
"id": "missing_operator_still_probes_absence_without_raising",
"description": "missing exists to ask whether a name is there, so it keeps reporting absence rather than raising on it",
"predicate": {
"==": [{ "missing": ["country"] }, "country"]
},
"variables": {},
"expected": true
},
{
"id": "missing_some_still_probes_absence_without_raising",
"predicate": {
"==": [{ "missing_some": [1, ["a", "b"]] }, ""]
},
"variables": { "a": 1 },
"expected": true
},
{
"id": "missing_guard_lets_a_rule_branch_on_presence",
"description": "The full authoring pattern for a genuinely optional dimension: probe with missing, then read it only on the branch where it exists",
"predicate": {
"if": [
{ "missing": ["country"] },
false,
{ "==": [{ "var": "country" }, "ES"] }
]
},
"variables": {},
"expected": false
},
{
"id": "unresolved_mid_path_break_reports_the_full_path",
"description": "The walk fails partway through, and the error names the path as written rather than the segment that broke",
"predicate": {
"===": [{ "var": "subscriber.address.city" }, null]
},
"variables": { "subscriber": { "address": {} } },
"expected": { "error": "unresolvedVariable", "path": "subscriber.address.city" }
},
{
"id": "unresolved_variable_inside_iteration_aborts_the_whole_rule",
"description": "An item that lacks the field the predicate reads makes that item unanswerable, and one unanswerable item makes the some unanswerable too",
"predicate": {
"some": [
{ "var": "subscriptions" },
{ "==": [{ "var": "tier" }, "gold"] }
]
},
"variables": {
"subscriptions": [{ "tier": "silver" }, { "plan": "basic" }]
},
"expected": { "error": "unresolvedVariable", "path": "tier" }
},
{
"id": "iteration_over_heterogeneous_items_opts_in_with_a_default",
"description": "Same shape as above, written the way a rule should be when items legitimately vary",
"predicate": {
"some": [
{ "var": "subscriptions" },
{ "==": [{ "var": ["tier", "none"] }, "gold"] }
]
},
"variables": {
"subscriptions": [{ "tier": "silver" }, { "plan": "basic" }]
},
"expected": false
}
]
}