Skip to content
13 changes: 11 additions & 2 deletions devcycle_python_sdk/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,31 +101,40 @@ def create_user_from_context(
) -> "DevCycleUser":
"""
Builds a DevCycleUser instance from the evaluation context. Will raise a TargetingKeyMissingError if
the context does not contain a valid targeting key or user_id attribute
the context does not contain a valid targeting key, user_id, or userId attribute

:param context: The evaluation context to build the user from
:return: A DevCycleUser instance
"""
user_id = None
user_id_source = None

if context:
if context.targeting_key:
user_id = context.targeting_key
user_id_source = "targeting_key"
elif context.attributes and "user_id" in context.attributes.keys():
user_id = context.attributes["user_id"]
user_id_source = "user_id"
elif context.attributes and "userId" in context.attributes.keys():
user_id = context.attributes["userId"]
user_id_source = "userId"

if not user_id or not isinstance(user_id, str):
raise TargetingKeyMissingError(
"DevCycle: Evaluation context does not contain a valid targeting key or user_id attribute"
"DevCycle: Evaluation context does not contain a valid targeting key, user_id, or userId attribute"
)

user = DevCycleUser(user_id=user_id)
custom_data: Dict[str, Any] = {}
private_custom_data: Dict[str, Any] = {}
if context and context.attributes:
for key, value in context.attributes.items():
# Skip user_id and userId - these are reserved for user ID mapping
if key == "user_id":
continue
if key == "userId":
continue

if value is not None:
if key == "email" and isinstance(value, str):
Expand Down
103 changes: 103 additions & 0 deletions test/models/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,109 @@ def test_create_user_from_context_only_user_id(self):
self.assertIsNotNone(user)
self.assertEqual(user.user_id, test_user_id)

def test_create_user_from_context_with_userId(self):
test_user_id = "userId-12345"

# Test userId as the only user ID source
context = EvaluationContext(
targeting_key=None, attributes={"userId": test_user_id}
)
user = DevCycleUser.create_user_from_context(context)
self.assertIsNotNone(user)
self.assertEqual(user.user_id, test_user_id)

# Test that userId is excluded from custom data when used as user ID
self.assertIsNone(user.customData)

def test_create_user_from_context_user_id_priority(self):
targeting_key_id = "targeting-12345"
user_id = "user_id-12345"
userId = "userId-12345"

# Test targeting_key takes precedence over user_id and userId
context = EvaluationContext(
targeting_key=targeting_key_id,
attributes={"user_id": user_id, "userId": userId}
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, targeting_key_id)

# Test user_id takes precedence over userId
context = EvaluationContext(
targeting_key=None,
attributes={"user_id": user_id, "userId": userId}
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, user_id)

# Test userId is used when targeting_key and user_id are not available
context = EvaluationContext(
targeting_key=None,
attributes={"userId": userId}
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, userId)

def test_create_user_from_context_user_id_fields_always_excluded(self):
targeting_key_id = "targeting-12345"
userId = "userId-12345"
user_id = "user_id-12345"

# When targeting_key is used, both user_id and userId should be excluded from custom data
context = EvaluationContext(
targeting_key=targeting_key_id,
attributes={"user_id": user_id, "userId": userId, "other_field": "value"}
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, targeting_key_id)
self.assertIsNotNone(user.customData)
self.assertNotIn("user_id", user.customData)
self.assertNotIn("userId", user.customData)
self.assertEqual(user.customData["other_field"], "value")

# When user_id is used, userId should still be excluded from custom data
context = EvaluationContext(
targeting_key=None,
attributes={"user_id": user_id, "userId": userId, "other_field": "value"}
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, user_id)
self.assertIsNotNone(user.customData)
self.assertNotIn("user_id", user.customData)
self.assertNotIn("userId", user.customData)
self.assertEqual(user.customData["other_field"], "value")

def test_create_user_from_context_userId_excluded_when_used(self):
userId = "userId-12345"

# When userId is used as user ID, it should be excluded from custom data
context = EvaluationContext(
targeting_key=None,
attributes={"userId": userId, "other_field": "value"}
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, userId)
self.assertIsNotNone(user.customData)
self.assertNotIn("userId", user.customData)
self.assertEqual(user.customData["other_field"], "value")

def test_create_user_from_context_invalid_userId_types(self):
# Test non-string userId values are ignored and cause error
with self.assertRaises(TargetingKeyMissingError):
DevCycleUser.create_user_from_context(
EvaluationContext(targeting_key=None, attributes={"userId": 12345})
)

with self.assertRaises(TargetingKeyMissingError):
DevCycleUser.create_user_from_context(
EvaluationContext(targeting_key=None, attributes={"userId": None})
)

with self.assertRaises(TargetingKeyMissingError):
DevCycleUser.create_user_from_context(
EvaluationContext(targeting_key=None, attributes={"userId": True})
)

def test_create_user_from_context_with_attributes(self):
test_user_id = "12345"
context = EvaluationContext(
Expand Down
41 changes: 41 additions & 0 deletions test/openfeature_test/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,47 @@ def test_resolve_object_details(self):
self.assertDictEqual(details.value, variable_value)
self.assertEqual(details.reason, Reason.TARGETING_MATCH)

def test_resolve_details_with_userId_priority(self):
"""Test that userId is used with proper priority: targeting_key > user_id > userId"""
key = "test-flag"
default_value = False

self.client.variable.return_value = Variable.create_default_variable(
key=key, default_value=default_value
)

# Test 1: targeting_key takes precedence over user_id and userId
context = EvaluationContext(
targeting_key="targeting-key-user",
attributes={"user_id": "user_id-user", "userId": "userId-user"}
)
details = self.provider.resolve_boolean_details(key, default_value, context)

# Verify the call was made with the right user
self.client.variable.assert_called()
called_user = self.client.variable.call_args[1]['user']
self.assertEqual(called_user.user_id, "targeting-key-user")

# Test 2: user_id takes precedence over userId when no targeting_key
context = EvaluationContext(
targeting_key=None,
attributes={"user_id": "user_id-user", "userId": "userId-user"}
)
details = self.provider.resolve_boolean_details(key, default_value, context)

called_user = self.client.variable.call_args[1]['user']
self.assertEqual(called_user.user_id, "user_id-user")

# Test 3: userId is used when no targeting_key or user_id
context = EvaluationContext(
targeting_key=None,
attributes={"userId": "userId-user"}
)
details = self.provider.resolve_boolean_details(key, default_value, context)

called_user = self.client.variable.call_args[1]['user']
self.assertEqual(called_user.user_id, "userId-user")


if __name__ == "__main__":
unittest.main()
Loading