Skip to content

Commit a48bd72

Browse files
update: Add tests for DefaultCmabService
1 parent 67a0be8 commit a48bd72

File tree

3 files changed

+167
-1
lines changed

3 files changed

+167
-1
lines changed

optimizely/cmab/cmab_service.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
# Copyright 2025 Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
113
import uuid
214
import json
315
import hashlib
@@ -52,7 +64,7 @@ def get_decision(self, project_config: ProjectConfig, user_context: OptimizelyUs
5264
if cached_value :
5365
if cached_value['attributes_hash'] == attributes_hash:
5466
return CmabDecision(variation_id=cached_value['variation_id'], cmab_uuid=cached_value['cmab_uuid'])
55-
else:
67+
elif OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE not in options:
5668
self.cmab_cache.remove(cache_key)
5769

5870
cmab_decision = self._fetch_decision(rule_id, user_context.user_id, filtered_attributes)

tests/test_cmab_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
# Copyright 2025, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
113
import unittest
214
import json
315
from unittest.mock import MagicMock, patch, call

tests/test_cmab_service.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Copyright 2025, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
import unittest
14+
from unittest.mock import MagicMock
15+
from optimizely.cmab.cmab_service import DefaultCmabService
16+
from optimizely.optimizely_user_context import OptimizelyUserContext
17+
from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption
18+
from optimizely.odp.lru_cache import LRUCache
19+
from optimizely.cmab.cmab_client import DefaultCmabClient
20+
from optimizely.project_config import ProjectConfig
21+
from optimizely.entities import Attribute
22+
23+
class TestDefaultCmabService(unittest.TestCase):
24+
def setUp(self):
25+
self.mock_cmab_cache = MagicMock(spec=LRUCache)
26+
self.mock_cmab_client = MagicMock(spec=DefaultCmabClient)
27+
self.mock_logger = MagicMock()
28+
29+
self.cmab_service = DefaultCmabService(
30+
cmab_cache=self.mock_cmab_cache,
31+
cmab_client=self.mock_cmab_client,
32+
logger=self.mock_logger
33+
)
34+
35+
self.mock_project_config = MagicMock(spec=ProjectConfig)
36+
self.mock_user_context = MagicMock(spec=OptimizelyUserContext)
37+
self.mock_user_context.user_id = 'user123'
38+
self.mock_user_context.get_user_attributes.return_value = {'age': 25, 'location': 'USA'}
39+
40+
# Setup mock experiment and attribute mapping
41+
self.mock_project_config.experiment_id_map = {
42+
'exp1': MagicMock(cmab={'attributeIds': ['66', '77']})
43+
}
44+
attr1 = Attribute(id="66", key="age")
45+
attr2 = Attribute(id="77", key="location")
46+
self.mock_project_config.attribute_id_map = {
47+
"66": attr1,
48+
"77": attr2
49+
}
50+
51+
def test_returns_decision_from_cache_when_valid(self):
52+
self.mock_cmab_cache.lookup.return_value = {
53+
"attributes_hash": self.cmab_service._hash_attributes({"age": 25, "location": "USA"}),
54+
"variation_id": "varA",
55+
"cmab_uuid": "uuid-123"
56+
}
57+
58+
decision = self.cmab_service.get_decision(self.mock_project_config, self.mock_user_context, "exp1", [])
59+
self.assertEqual(decision["variation_id"], "varA")
60+
self.assertEqual(decision["cmab_uuid"], "uuid-123")
61+
62+
def test_ignores_cache_when_option_given(self):
63+
self.mock_cmab_client.fetch_decision.return_value = "varB"
64+
65+
decision = self.cmab_service.get_decision(
66+
self.mock_project_config,
67+
self.mock_user_context,
68+
"exp1",
69+
[OptimizelyDecideOption.IGNORE_CMAB_CACHE]
70+
)
71+
72+
self.assertEqual(decision["variation_id"], "varB")
73+
self.assertIn('cmab_uuid', decision)
74+
self.mock_cmab_client.fetch_decision.assert_called_once()
75+
76+
def test_invalidates_user_cache_when_option_given(self):
77+
self.mock_cmab_client.fetch_decision.return_value = "varC"
78+
79+
self.cmab_service.get_decision(
80+
self.mock_project_config,
81+
self.mock_user_context,
82+
"exp1",
83+
[OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE]
84+
)
85+
86+
key = self.cmab_service._get_cache_key("user123", "exp1")
87+
self.mock_cmab_cache.remove.assert_called_with(key)
88+
self.mock_cmab_cache.remove.assert_called_once()
89+
90+
def test_resets_cache_when_option_given(self):
91+
self.mock_cmab_client.fetch_decision.return_value = "varD"
92+
93+
decision = self.cmab_service.get_decision(
94+
self.mock_project_config,
95+
self.mock_user_context,
96+
"exp1",
97+
[OptimizelyDecideOption.RESET_CMAB_CACHE]
98+
)
99+
100+
self.mock_cmab_cache.reset.assert_called_once()
101+
self.assertEqual(decision["variation_id"], "varD")
102+
self.assertIn('cmab_uuid', decision)
103+
104+
def test_new_decision_when_hash_changes(self):
105+
self.mock_cmab_cache.lookup.return_value = {
106+
"attributes_hash": "old_hash",
107+
"variation_id": "varA",
108+
"cmab_uuid": "uuid-123"
109+
}
110+
self.mock_cmab_client.fetch_decision.return_value = "varE"
111+
112+
user_attrs = {"age": 25, "location": "USA"}
113+
expected_hash = self.cmab_service._hash_attributes(user_attrs)
114+
expected_key = self.cmab_service._get_cache_key("user123", "exp1")
115+
116+
decision = self.cmab_service.get_decision(self.mock_project_config, self.mock_user_context, "exp1", [])
117+
self.mock_cmab_cache.remove.assert_called_once_with(expected_key)
118+
self.mock_cmab_cache.save.assert_called_once_with(
119+
expected_key,
120+
{
121+
"cmab_uuid": decision["cmab_uuid"],
122+
"variation_id": decision["variation_id"],
123+
"attributes_hash": expected_hash
124+
}
125+
)
126+
self.assertEqual(decision["variation_id"], "varE")
127+
128+
def test_filter_attributes_returns_correct_subset(self):
129+
filtered = self.cmab_service._filter_attributes(self.mock_project_config, self.mock_user_context, "exp1")
130+
self.assertEqual(filtered["age"], 25)
131+
self.assertEqual(filtered["location"], "USA")
132+
133+
def test_filter_attributes_empty_when_no_cmab(self):
134+
self.mock_project_config.experiment_id_map["exp1"].cmab = None
135+
filtered = self.cmab_service._filter_attributes(self.mock_project_config, self.mock_user_context, "exp1")
136+
self.assertEqual(filtered, {})
137+
138+
def test_hash_attributes_produces_stable_output(self):
139+
attrs = {"b": 2, "a": 1}
140+
hash1 = self.cmab_service._hash_attributes(attrs)
141+
hash2 = self.cmab_service._hash_attributes({"a": 1, "b": 2})
142+
self.assertEqual(hash1, hash2)

0 commit comments

Comments
 (0)