Skip to content

Commit 9e71ccc

Browse files
authored
Merge pull request #1875 from Jdubrick/update-feedback-tests
RHIDP-14000: update feedback unit and e2e tests
2 parents 82f6ca4 + 9320871 commit 9e71ccc

2 files changed

Lines changed: 179 additions & 1 deletion

File tree

tests/e2e/features/feedback.feature

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,4 +310,59 @@ Feature: feedback endpoint API tests
310310
"cause": "Failed to store feedback at directory: /invalid"
311311
}
312312
}
313-
"""
313+
"""
314+
315+
Scenario: Check if sequential feedback status toggling maintains consistency
316+
When The feedback is enabled
317+
Then The status code of the response is 200
318+
When The feedback is disabled
319+
Then The status code of the response is 200
320+
When The feedback is enabled
321+
Then The status code of the response is 200
322+
When I retreive the current feedback status
323+
Then The status code of the response is 200
324+
And The body of the response is the following
325+
"""
326+
{
327+
"functionality": "feedback",
328+
"status": {
329+
"enabled": true
330+
}
331+
}
332+
"""
333+
334+
Scenario: Check if submitting duplicate feedback succeeds
335+
And A new conversation is initialized
336+
And The feedback is enabled
337+
When I submit the following feedback for the conversation created before
338+
"""
339+
{
340+
"llm_response": "bar",
341+
"sentiment": -1,
342+
"user_feedback": "Not satisfied",
343+
"user_question": "Sample Question"
344+
}
345+
"""
346+
Then The status code of the response is 200
347+
And The body of the response is the following
348+
"""
349+
{
350+
"response": "feedback received"
351+
}
352+
"""
353+
When I submit the following feedback for the conversation created before
354+
"""
355+
{
356+
"llm_response": "bar",
357+
"sentiment": -1,
358+
"user_feedback": "Not satisfied",
359+
"user_question": "Sample Question"
360+
}
361+
"""
362+
Then The status code of the response is 200
363+
And The body of the response is the following
364+
"""
365+
{
366+
"response": "feedback received"
367+
}
368+
"""

tests/unit/app/endpoints/test_feedback.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
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
510
from typing import Any
611

712
import pytest
@@ -23,6 +28,21 @@
2328
from tests.unit.utils.auth_helpers import mock_authorization_resolvers
2429

2530
MOCK_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+
2646
VALID_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
267353
async 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
288375
async 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

Comments
 (0)