88from contextlib import contextmanager
99from unittest .mock import MagicMock , patch
1010
11+ import pytest
1112from fastapi .testclient import TestClient
1213
13- from reflexio .models .api_schema .ui .entities import AgentPlaybookView , ProfileView
14+ from reflexio .models .api_schema .ui .entities import (
15+ AgentPlaybookView ,
16+ ProfileView ,
17+ UserPlaybookView ,
18+ )
1419from reflexio .server .api import create_app
1520from reflexio .server .usage_metrics import UsageEvent , configure_usage_event_recorder
1621
@@ -29,6 +34,10 @@ def _make_agent_playbook_view() -> AgentPlaybookView:
2934 return AgentPlaybookView (agent_version = "v1" , content = "content" )
3035
3136
37+ def _make_user_playbook_view () -> UserPlaybookView :
38+ return UserPlaybookView (agent_version = "v1" , request_id = "r1" , content = "content" )
39+
40+
3241def _client (caller_type : str ) -> TestClient :
3342 app = create_app (get_org_id = lambda : "test-org" , get_caller_type = lambda : caller_type )
3443 return TestClient (app , raise_server_exceptions = False )
@@ -90,7 +99,9 @@ def test_production_agent_search_meters_surfaced_count() -> None:
9099
91100 applied = [e for e in events if e .event_name == "learning_applied" ]
92101 assert len (applied ) == 1
93- assert applied [0 ].count_value == 3 # 2 profiles + 1 agent_playbook + 0 user_playbooks
102+ assert (
103+ applied [0 ].count_value == 3
104+ ) # 2 profiles + 1 agent_playbook + 0 user_playbooks
94105 assert applied [0 ].caller_type == "production_agent"
95106
96107
@@ -99,7 +110,9 @@ def test_dashboard_search_meters_nothing() -> None:
99110 events = _capture ()
100111 try :
101112 with _patch_unified_search ([_make_profile_view ()], [], []):
102- _client ("dashboard" ).post ("/api/search" , json = {"query" : "x" , "user_id" : "u1" })
113+ _client ("dashboard" ).post (
114+ "/api/search" , json = {"query" : "x" , "user_id" : "u1" }
115+ )
103116 finally :
104117 configure_usage_event_recorder (None )
105118
@@ -140,11 +153,13 @@ def test_metering_failure_does_not_break_search_response() -> None:
140153 mock_response .user_playbooks = []
141154 mock_reflexio_search .unified_search .return_value = mock_response
142155 # Make get_config raise so metering blows up after the search completes.
143- mock_reflexio_search .request_context .configurator .get_config .side_effect = RuntimeError (
144- "boom"
156+ mock_reflexio_search .request_context .configurator .get_config .side_effect = (
157+ RuntimeError ( "boom" )
145158 )
146159
147- with patch ("reflexio.server.api.get_reflexio" , return_value = mock_reflexio_search ):
160+ with patch (
161+ "reflexio.server.api.get_reflexio" , return_value = mock_reflexio_search
162+ ):
148163 resp = _client ("production_agent" ).post (
149164 "/api/search" , json = {"query" : "x" , "user_id" : "u1" }
150165 )
@@ -154,3 +169,142 @@ def test_metering_failure_does_not_break_search_response() -> None:
154169
155170 # Metering failed silently — no learning_applied event should have been emitted.
156171 assert [e for e in events if e .event_name == "learning_applied" ] == []
172+
173+
174+ # --- Per-endpoint metering (the four non-unified routes) -----------------------
175+ #
176+ # Each of these endpoints calls a distinct service method on get_reflexio and
177+ # derives surfaced_count from a distinct response list attribute. The cases below
178+ # exercise the real endpoint handler + view conversion + _meter_applied_learnings
179+ # wiring for each, asserting the emitted surfaced_count matches that route's shape.
180+
181+
182+ @contextmanager
183+ def _patch_service_method (method_name : str , response_attr : str , items : list ):
184+ """Patch get_reflexio so ``method_name`` returns a canned service response.
185+
186+ The response carries ``items`` on ``response_attr`` (e.g. ``user_profiles``)
187+ so the endpoint's view conversion and surfaced_count computation run for real.
188+ get_config() returns None so platform_llm_from_config(None) is True without
189+ iterating a MagicMock.
190+ """
191+ mock_reflexio = MagicMock ()
192+ mock_response = MagicMock ()
193+ mock_response .success = True
194+ mock_response .msg = "OK"
195+ setattr (mock_response , response_attr , items )
196+ getattr (mock_reflexio , method_name ).return_value = mock_response
197+ mock_reflexio .request_context .configurator .get_config .return_value = None
198+
199+ with patch ("reflexio.server.api.get_reflexio" , return_value = mock_reflexio ):
200+ yield
201+
202+
203+ # (path, payload, service method, response attribute, surfaced item factory)
204+ _ENDPOINT_CASES = [
205+ pytest .param (
206+ "/api/search_profiles" ,
207+ {"user_id" : "u1" , "query" : "x" },
208+ "search_user_profiles" ,
209+ "user_profiles" ,
210+ _make_profile_view ,
211+ id = "search_profiles" ,
212+ ),
213+ pytest .param (
214+ "/api/search_user_playbooks" ,
215+ {"query" : "x" },
216+ "search_user_playbooks" ,
217+ "user_playbooks" ,
218+ _make_user_playbook_view ,
219+ id = "search_user_playbooks" ,
220+ ),
221+ pytest .param (
222+ "/api/search_agent_playbooks" ,
223+ {"query" : "x" },
224+ "search_agent_playbooks" ,
225+ "agent_playbooks" ,
226+ _make_agent_playbook_view ,
227+ id = "search_agent_playbooks" ,
228+ ),
229+ pytest .param (
230+ "/api/get_agent_playbooks" ,
231+ {},
232+ "get_agent_playbooks" ,
233+ "agent_playbooks" ,
234+ _make_agent_playbook_view ,
235+ id = "get_agent_playbooks" ,
236+ ),
237+ ]
238+
239+
240+ @pytest .mark .parametrize (
241+ ("path" , "payload" , "method_name" , "response_attr" , "make_item" ),
242+ _ENDPOINT_CASES ,
243+ )
244+ def test_production_agent_per_endpoint_meters_surfaced_count (
245+ path : str ,
246+ payload : dict ,
247+ method_name : str ,
248+ response_attr : str ,
249+ make_item ,
250+ ) -> None :
251+ """Each non-unified route emits one learning_applied event with its own count."""
252+ events = _capture ()
253+ items = [make_item (), make_item ()]
254+ try :
255+ with _patch_service_method (method_name , response_attr , items ):
256+ resp = _client ("production_agent" ).post (path , json = payload )
257+ assert resp .status_code == 200
258+ finally :
259+ configure_usage_event_recorder (None )
260+
261+ applied = [e for e in events if e .event_name == "learning_applied" ]
262+ assert len (applied ) == 1
263+ assert applied [0 ].count_value == 2 # len(items) for this endpoint's response shape
264+ assert applied [0 ].caller_type == "production_agent"
265+
266+
267+ @pytest .mark .parametrize (
268+ ("path" , "payload" , "method_name" , "response_attr" , "make_item" ),
269+ _ENDPOINT_CASES ,
270+ )
271+ def test_dashboard_per_endpoint_meters_nothing (
272+ path : str ,
273+ payload : dict ,
274+ method_name : str ,
275+ response_attr : str ,
276+ make_item ,
277+ ) -> None :
278+ """A dashboard caller never meters, regardless of the route or result size."""
279+ events = _capture ()
280+ try :
281+ with _patch_service_method (method_name , response_attr , [make_item ()]):
282+ resp = _client ("dashboard" ).post (path , json = payload )
283+ assert resp .status_code == 200
284+ finally :
285+ configure_usage_event_recorder (None )
286+
287+ assert [e for e in events if e .event_name == "learning_applied" ] == []
288+
289+
290+ @pytest .mark .parametrize (
291+ ("path" , "payload" , "method_name" , "response_attr" , "make_item" ),
292+ _ENDPOINT_CASES ,
293+ )
294+ def test_empty_result_per_endpoint_meters_nothing (
295+ path : str ,
296+ payload : dict ,
297+ method_name : str ,
298+ response_attr : str ,
299+ make_item ,
300+ ) -> None :
301+ """A production-agent call surfacing zero results meters nothing on any route."""
302+ events = _capture ()
303+ try :
304+ with _patch_service_method (method_name , response_attr , []):
305+ resp = _client ("production_agent" ).post (path , json = payload )
306+ assert resp .status_code == 200
307+ finally :
308+ configure_usage_event_recorder (None )
309+
310+ assert [e for e in events if e .event_name == "learning_applied" ] == []
0 commit comments