Skip to content

Commit d9c416e

Browse files
committed
RHIDP-14000: update feedback unit and e2e tests
Signed-off-by: Jordan Dubrick <jdubrick@redhat.com>
1 parent 6f0cb03 commit d9c416e

2 files changed

Lines changed: 163 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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
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
59
from typing import Any
610

711
import 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
267337
async 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
288359
async 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

Comments
 (0)