22
33"""Unit tests for the /feedback REST API endpoint."""
44
5+ import asyncio
6+ import json
7+ import threading
8+ from pathlib import Path
59from typing import Any
610
711import pytest
@@ -82,6 +86,34 @@ async def test_assert_feedback_enabled(mocker: MockerFixture) -> None:
8286 await assert_feedback_enabled (mocker .Mock ())
8387
8488
89+ @pytest .mark .asyncio
90+ async def test_assert_feedback_enabled_disabled_full_config_chain (
91+ mocker : MockerFixture ,
92+ ) -> None :
93+ """Test the full config-to-exception chain when feedback is disabled.
94+
95+ Unlike test_assert_feedback_enabled_disabled (which mocks is_feedback_enabled),
96+ this test sets up real UserDataCollection config and verifies the complete path:
97+ configuration -> is_feedback_enabled() -> assert_feedback_enabled() -> HTTPException.
98+ """
99+ mock_config = AppConfig ()
100+ mock_config ._configuration = mocker .Mock ()
101+ mock_config ._configuration .user_data_collection = UserDataCollection (
102+ feedback_enabled = False ,
103+ feedback_storage = None ,
104+ transcripts_enabled = False ,
105+ transcripts_storage = None ,
106+ )
107+ mocker .patch ("app.endpoints.feedback.configuration" , mock_config )
108+
109+ with pytest .raises (HTTPException ) as exc_info :
110+ await assert_feedback_enabled (mocker .Mock ())
111+
112+ assert exc_info .value .status_code == status .HTTP_403_FORBIDDEN
113+ assert exc_info .value .detail ["response" ] == "Storing feedback is disabled" # type: ignore
114+ assert exc_info .value .detail ["cause" ] == "Storing feedback is disabled." # type: ignore
115+
116+
85117@pytest .mark .parametrize (
86118 "feedback_request_data" ,
87119 [
@@ -263,9 +295,48 @@ def test_store_feedback_on_io_error(
263295 assert "Failed to store feedback at directory" in detail ["cause" ] # type: ignore
264296
265297
298+ def test_store_feedback_real_filesystem (tmp_path : Path , mocker : MockerFixture ) -> None :
299+ """Test that store_feedback writes valid JSON to the real filesystem.
300+
301+ Unlike test_store_feedback (which mocks builtins.open, Path, and json.dump),
302+ this test exercises real filesystem I/O via tmp_path to verify actual file
303+ creation and valid JSON content.
304+ """
305+ configuration .user_data_collection_configuration .feedback_storage = str (tmp_path )
306+
307+ fake_uuid = "test-feedback-uuid"
308+ mocker .patch ("app.endpoints.feedback.get_suid" , return_value = fake_uuid )
309+
310+ feedback_data = {
311+ "conversation_id" : "12345678-abcd-0000-0123-456789abcdef" ,
312+ "user_question" : "What is Kubernetes?" ,
313+ "llm_response" : "Kubernetes is a container orchestration platform." ,
314+ "sentiment" : 1 ,
315+ }
316+
317+ store_feedback ("test_user_id" , feedback_data )
318+
319+ feedback_file = tmp_path / f"{ fake_uuid } .json"
320+ assert feedback_file .exists (), f"Expected file { feedback_file } to exist"
321+
322+ with open (feedback_file , encoding = "utf-8" ) as f :
323+ stored_data = json .load (f )
324+
325+ assert stored_data ["user_id" ] == "test_user_id"
326+ assert "timestamp" in stored_data
327+ assert stored_data ["conversation_id" ] == "12345678-abcd-0000-0123-456789abcdef"
328+ assert stored_data ["user_question" ] == "What is Kubernetes?"
329+ assert (
330+ stored_data ["llm_response" ]
331+ == "Kubernetes is a container orchestration platform."
332+ )
333+ assert stored_data ["sentiment" ] == 1
334+
335+
266336@pytest .mark .asyncio
267337async def test_update_feedback_status_different (mocker : MockerFixture ) -> None :
268338 """Test that update_feedback_status returns the correct status with an update."""
339+ mock_authorization_resolvers (mocker )
269340 configuration .user_data_collection_configuration .feedback_enabled = True
270341
271342 # Authorization tuple required by URL endpoint handler
@@ -287,6 +358,7 @@ async def test_update_feedback_status_different(mocker: MockerFixture) -> None:
287358@pytest .mark .asyncio
288359async def test_update_feedback_status_no_change (mocker : MockerFixture ) -> None :
289360 """Test that update_feedback_status returns the correct status with no update."""
361+ mock_authorization_resolvers (mocker )
290362 configuration .user_data_collection_configuration .feedback_enabled = True
291363
292364 # Authorization tuple required by URL endpoint handler
@@ -305,6 +377,41 @@ async def test_update_feedback_status_no_change(mocker: MockerFixture) -> None:
305377 }
306378
307379
380+ def test_update_feedback_status_concurrent (mocker : MockerFixture ) -> None :
381+ """Test that concurrent calls to update_feedback_status do not raise errors."""
382+ mock_authorization_resolvers (mocker )
383+ configuration .user_data_collection_configuration .feedback_enabled = True
384+
385+ auth : AuthTuple = ("test_user_id" , "test_user" , True , "test_token" )
386+ results : list [Any ] = [None , None , None ]
387+ errors : list [Exception | None ] = [None , None , None ]
388+
389+ def worker (index : int , desired_status : bool ) -> None :
390+ """Thread worker that calls update_feedback_status."""
391+ req = FeedbackStatusUpdateRequest (status = desired_status )
392+ try :
393+ results [index ] = asyncio .run (update_feedback_status (req , auth = auth ))
394+ except Exception as exc : # pylint: disable=broad-exception-caught
395+ errors [index ] = exc
396+
397+ threads = [
398+ threading .Thread (target = worker , args = (0 , False )),
399+ threading .Thread (target = worker , args = (1 , True )),
400+ threading .Thread (target = worker , args = (2 , False )),
401+ ]
402+ for t in threads :
403+ t .start ()
404+ for t in threads :
405+ t .join ()
406+
407+ for i , err in enumerate (errors ):
408+ assert err is None , f"Thread { i } raised: { err } "
409+ for i , result in enumerate (results ):
410+ assert result is not None , f"Thread { i } returned None"
411+ assert "previous_status" in result .status
412+ assert "updated_status" in result .status
413+
414+
308415@pytest .mark .parametrize (
309416 "payload" ,
310417 [
0 commit comments