22
33"""Unit tests for the /feedback REST API endpoint."""
44
5+ import asyncio
6+ import json
7+ import threading
8+ from collections .abc import Generator
9+ from pathlib import Path
510from typing import Any
611
712import pytest
2328from tests .unit .utils .auth_helpers import mock_authorization_resolvers
2429
2530MOCK_AUTH = ("mock_user_id" , "mock_username" , False , "mock_token" )
31+
32+
33+ @pytest .fixture (autouse = True )
34+ def _reset_feedback_config () -> Generator [None ]:
35+ """Save and restore feedback configuration so tests don't leak state."""
36+ if configuration ._configuration is None :
37+ yield
38+ return
39+ original_enabled = configuration .user_data_collection_configuration .feedback_enabled
40+ original_storage = configuration .user_data_collection_configuration .feedback_storage
41+ yield
42+ configuration .user_data_collection_configuration .feedback_enabled = original_enabled
43+ configuration .user_data_collection_configuration .feedback_storage = original_storage
44+
45+
2646VALID_BASE = {
2747 "conversation_id" : "12345678-abcd-0000-0123-456789abcdef" ,
2848 "user_question" : "What is Kubernetes?" ,
@@ -82,6 +102,34 @@ async def test_assert_feedback_enabled(mocker: MockerFixture) -> None:
82102 await assert_feedback_enabled (mocker .Mock ())
83103
84104
105+ @pytest .mark .asyncio
106+ async def test_assert_feedback_enabled_disabled_full_config_chain (
107+ mocker : MockerFixture ,
108+ ) -> None :
109+ """Test the full config-to-exception chain when feedback is disabled.
110+
111+ Unlike test_assert_feedback_enabled_disabled (which mocks is_feedback_enabled),
112+ this test sets up real UserDataCollection config and verifies the complete path:
113+ configuration -> is_feedback_enabled() -> assert_feedback_enabled() -> HTTPException.
114+ """
115+ mock_config = AppConfig ()
116+ mock_config ._configuration = mocker .Mock ()
117+ mock_config ._configuration .user_data_collection = UserDataCollection (
118+ feedback_enabled = False ,
119+ feedback_storage = None ,
120+ transcripts_enabled = False ,
121+ transcripts_storage = None ,
122+ )
123+ mocker .patch ("app.endpoints.feedback.configuration" , mock_config )
124+
125+ with pytest .raises (HTTPException ) as exc_info :
126+ await assert_feedback_enabled (mocker .Mock ())
127+
128+ assert exc_info .value .status_code == status .HTTP_403_FORBIDDEN
129+ assert exc_info .value .detail ["response" ] == "Storing feedback is disabled" # type: ignore
130+ assert exc_info .value .detail ["cause" ] == "Storing feedback is disabled." # type: ignore
131+
132+
85133@pytest .mark .parametrize (
86134 "feedback_request_data" ,
87135 [
@@ -263,9 +311,48 @@ def test_store_feedback_on_io_error(
263311 assert "Failed to store feedback at directory" in detail ["cause" ] # type: ignore
264312
265313
314+ def test_store_feedback_real_filesystem (tmp_path : Path , mocker : MockerFixture ) -> None :
315+ """Test that store_feedback writes valid JSON to the real filesystem.
316+
317+ Unlike test_store_feedback (which mocks builtins.open, Path, and json.dump),
318+ this test exercises real filesystem I/O via tmp_path to verify actual file
319+ creation and valid JSON content.
320+ """
321+ configuration .user_data_collection_configuration .feedback_storage = str (tmp_path )
322+
323+ fake_uuid = "test-feedback-uuid"
324+ mocker .patch ("app.endpoints.feedback.get_suid" , return_value = fake_uuid )
325+
326+ feedback_data = {
327+ "conversation_id" : "12345678-abcd-0000-0123-456789abcdef" ,
328+ "user_question" : "What is Kubernetes?" ,
329+ "llm_response" : "Kubernetes is a container orchestration platform." ,
330+ "sentiment" : 1 ,
331+ }
332+
333+ store_feedback ("test_user_id" , feedback_data )
334+
335+ feedback_file = tmp_path / f"{ fake_uuid } .json"
336+ assert feedback_file .exists (), f"Expected file { feedback_file } to exist"
337+
338+ with open (feedback_file , encoding = "utf-8" ) as f :
339+ stored_data = json .load (f )
340+
341+ assert stored_data ["user_id" ] == "test_user_id"
342+ assert "timestamp" in stored_data
343+ assert stored_data ["conversation_id" ] == "12345678-abcd-0000-0123-456789abcdef"
344+ assert stored_data ["user_question" ] == "What is Kubernetes?"
345+ assert (
346+ stored_data ["llm_response" ]
347+ == "Kubernetes is a container orchestration platform."
348+ )
349+ assert stored_data ["sentiment" ] == 1
350+
351+
266352@pytest .mark .asyncio
267353async def test_update_feedback_status_different (mocker : MockerFixture ) -> None :
268354 """Test that update_feedback_status returns the correct status with an update."""
355+ mock_authorization_resolvers (mocker )
269356 configuration .user_data_collection_configuration .feedback_enabled = True
270357
271358 # Authorization tuple required by URL endpoint handler
@@ -287,6 +374,7 @@ async def test_update_feedback_status_different(mocker: MockerFixture) -> None:
287374@pytest .mark .asyncio
288375async def test_update_feedback_status_no_change (mocker : MockerFixture ) -> None :
289376 """Test that update_feedback_status returns the correct status with no update."""
377+ mock_authorization_resolvers (mocker )
290378 configuration .user_data_collection_configuration .feedback_enabled = True
291379
292380 # Authorization tuple required by URL endpoint handler
@@ -305,6 +393,41 @@ async def test_update_feedback_status_no_change(mocker: MockerFixture) -> None:
305393 }
306394
307395
396+ def test_update_feedback_status_concurrent (mocker : MockerFixture ) -> None :
397+ """Test that concurrent calls to update_feedback_status do not raise errors."""
398+ mock_authorization_resolvers (mocker )
399+ configuration .user_data_collection_configuration .feedback_enabled = True
400+
401+ auth : AuthTuple = ("test_user_id" , "test_user" , True , "test_token" )
402+ thread_args = [(0 , False ), (1 , True ), (2 , False )]
403+ results : list [Any ] = [None ] * len (thread_args )
404+ errors : list [Exception | None ] = [None ] * len (thread_args )
405+ barrier = threading .Barrier (len (thread_args ) + 1 )
406+
407+ def worker (index : int , desired_status : bool ) -> None :
408+ """Thread worker that calls update_feedback_status."""
409+ req = FeedbackStatusUpdateRequest (status = desired_status )
410+ try :
411+ barrier .wait ()
412+ results [index ] = asyncio .run (update_feedback_status (req , auth = auth ))
413+ except Exception as exc : # pylint: disable=broad-exception-caught
414+ errors [index ] = exc
415+
416+ threads = [threading .Thread (target = worker , args = args ) for args in thread_args ]
417+ for t in threads :
418+ t .start ()
419+ barrier .wait ()
420+ for t in threads :
421+ t .join ()
422+
423+ for i , err in enumerate (errors ):
424+ assert err is None , f"Thread { i } raised: { err } "
425+ for i , result in enumerate (results ):
426+ assert result is not None , f"Thread { i } returned None"
427+ assert "previous_status" in result .status
428+ assert "updated_status" in result .status
429+
430+
308431@pytest .mark .parametrize (
309432 "payload" ,
310433 [
0 commit comments