| from __future__ import annotations |
|
|
| import uuid |
| from datetime import timedelta |
| from types import SimpleNamespace |
|
|
| import pytest |
| from fastapi.testclient import TestClient |
|
|
| from business_models import ( |
| AuthSessionPayload, |
| UserRecord, |
| UserRole, |
| UserSessionRecord, |
| derive_result_id, |
| ensure_uuid, |
| utc_now, |
| ) |
|
|
| REGISTERED_TOKEN = "registered-token" |
| GUEST_TOKEN = "guest-token" |
|
|
|
|
| def _auth_payload(*, token: str, kind: str = "registered") -> AuthSessionPayload: |
| uid = str(uuid.uuid4()) |
| user = UserRecord( |
| id=uid, |
| kind=kind, |
| role=UserRole.USER, |
| email="user@example.com" if kind == "registered" else None, |
| display_name="Registered" if kind == "registered" else "Guest", |
| status="active", |
| ) |
| session = UserSessionRecord( |
| id=str(uuid.uuid4()), |
| user_id=uid, |
| status="active", |
| expires_at=utc_now() + timedelta(days=30), |
| user=user, |
| ) |
| return AuthSessionPayload(token=token, session=session, user=user) |
|
|
|
|
| class FakeAuthRepository: |
| def __init__(self, *payloads: AuthSessionPayload): |
| self._by_token = {p.token: p for p in payloads} |
|
|
| async def session_from_token(self, token: str | None): |
| return self._by_token.get(token) |
|
|
|
|
| class FakeFeedbackRepository: |
| """Mirrors the real ``submit`` contract closely enough for router tests: |
| only known recommendation results can receive feedback.""" |
|
|
| def __init__(self): |
| self.rows: dict[tuple[str, str], dict] = {} |
| self.targets: dict[str, dict] = {} |
|
|
| def allow_result( |
| self, |
| *, |
| user_id: str, |
| result_id: str | None = None, |
| task_id: str | None = None, |
| branch_id: str | None = None, |
| conversation_id: str | None = None, |
| ) -> str: |
| resolved = ensure_uuid(result_id or derive_result_id(task_id or str(uuid.uuid4()), branch_id)) |
| self.targets[resolved] = { |
| "user_id": user_id, |
| "result_id": resolved, |
| "task_id": task_id, |
| "branch_id": branch_id, |
| "conversation_id": conversation_id, |
| } |
| return resolved |
|
|
| async def submit( |
| self, |
| *, |
| user_id, |
| sentiment, |
| reason=None, |
| result_id=None, |
| task_id=None, |
| branch_id=None, |
| conversation_id=None, |
| ui_message_id=None, |
| ): |
| resolved = ensure_uuid(result_id) if (result_id or "").strip() else None |
| if resolved is None and task_id: |
| for target in self.targets.values(): |
| if target["user_id"] != user_id or target["task_id"] != task_id: |
| continue |
| if branch_id is not None and target["branch_id"] != branch_id: |
| continue |
| if conversation_id is not None and target["conversation_id"] != conversation_id: |
| continue |
| resolved = target["result_id"] |
| break |
| if not resolved: |
| raise ValueError("result_id or task_id is required to attach feedback") |
| target = self.targets.get(resolved) |
| if target is None or target["user_id"] != user_id: |
| raise ValueError("feedback target not found") |
| if conversation_id is not None and target["conversation_id"] != conversation_id: |
| raise ValueError("feedback target not found") |
| if branch_id is not None and target["branch_id"] != branch_id: |
| raise ValueError("feedback target not found") |
|
|
| if sentiment == "up": |
| rating, label = 5, None |
| elif sentiment == "down": |
| rating, label = 1, reason or "others" |
| else: |
| raise ValueError("sentiment must be 'up' or 'down'") |
|
|
| key = (user_id, resolved) |
| existing = self.rows.get(key) |
| feedback_id = existing["feedback_id"] if existing else str(uuid.uuid4()) |
| row = { |
| "feedback_id": feedback_id, |
| "result_id": resolved, |
| "sentiment": sentiment, |
| "rating": rating, |
| "reason": label, |
| } |
| self.rows[key] = row |
| return row |
|
|
|
|
| @pytest.fixture |
| def feedback_setup(monkeypatch): |
| import main |
| import internal.feedback.router as feedback_router_mod |
|
|
| registered = _auth_payload(token=REGISTERED_TOKEN, kind="registered") |
| guest = _auth_payload(token=GUEST_TOKEN, kind="guest") |
| fake_auth = FakeAuthRepository(registered, guest) |
| fake_feedback = FakeFeedbackRepository() |
|
|
| monkeypatch.setattr(main, "auth_repository", fake_auth) |
| monkeypatch.setattr(feedback_router_mod, "feedback_repository", fake_feedback) |
| return main, fake_feedback, registered, guest |
|
|
|
|
| def _client_as(main, token): |
| client = TestClient(main.app) |
| if token: |
| client.cookies.set(main.AUTH_COOKIE_NAME, token) |
| return client |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_feedback_requires_authentication(feedback_setup): |
| main, _repo, _reg, _guest = feedback_setup |
| with TestClient(main.app) as client: |
| resp = client.post("/api/feedback", json={"sentiment": "up", "result_id": str(uuid.uuid4())}) |
| assert resp.status_code == 401 |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_guest_feedback_blocked(feedback_setup): |
| main, repo, _reg, _guest = feedback_setup |
| with _client_as(main, GUEST_TOKEN) as client: |
| resp = client.post("/api/feedback", json={"sentiment": "up", "result_id": str(uuid.uuid4())}) |
| assert resp.status_code == 403 |
| assert repo.rows == {} |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_thumb_up_persists_positive_rating(feedback_setup): |
| main, repo, reg, _guest = feedback_setup |
| result_id = repo.allow_result(user_id=reg.user.id) |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post("/api/feedback", json={"sentiment": "up", "result_id": result_id}) |
| assert resp.status_code == 200 |
| feedback = resp.json()["feedback"] |
| assert feedback["rating"] == 5 |
| assert feedback["reason"] is None |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_thumb_down_with_reason(feedback_setup): |
| main, repo, reg, _guest = feedback_setup |
| result_id = repo.allow_result(user_id=reg.user.id) |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post( |
| "/api/feedback", |
| json={"sentiment": "down", "reason": "too_far", "result_id": result_id}, |
| ) |
| assert resp.status_code == 200 |
| feedback = resp.json()["feedback"] |
| assert feedback["rating"] == 1 |
| assert feedback["reason"] == "too_far" |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_invalid_reason_rejected(feedback_setup): |
| main, _repo, _reg, _guest = feedback_setup |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post( |
| "/api/feedback", |
| json={"sentiment": "down", "reason": "made_up", "result_id": str(uuid.uuid4())}, |
| ) |
| assert resp.status_code == 422 |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_thumb_down_without_reason_defaults_to_others(feedback_setup): |
| main, repo, reg, _guest = feedback_setup |
| result_id = repo.allow_result(user_id=reg.user.id) |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post("/api/feedback", json={"sentiment": "down", "result_id": result_id}) |
| assert resp.status_code == 200 |
| assert resp.json()["feedback"]["reason"] == "others" |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_missing_result_reference_returns_400(feedback_setup): |
| main, _repo, _reg, _guest = feedback_setup |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post("/api/feedback", json={"sentiment": "up"}) |
| assert resp.status_code == 400 |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_unknown_result_reference_returns_400(feedback_setup): |
| main, repo, _reg, _guest = feedback_setup |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post("/api/feedback", json={"sentiment": "up", "result_id": str(uuid.uuid4())}) |
| assert resp.status_code == 400 |
| assert resp.json()["detail"] == "feedback target not found" |
| assert repo.rows == {} |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_conversation_mismatch_returns_400(feedback_setup): |
| main, repo, reg, _guest = feedback_setup |
| result_id = repo.allow_result(user_id=reg.user.id, conversation_id=str(uuid.uuid4())) |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post( |
| "/api/feedback", |
| json={"sentiment": "up", "result_id": result_id, "conversation_id": str(uuid.uuid4())}, |
| ) |
| assert resp.status_code == 400 |
| assert resp.json()["detail"] == "feedback target not found" |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_revote_updates_same_row(feedback_setup): |
| main, repo, reg, _guest = feedback_setup |
| result_id = repo.allow_result(user_id=reg.user.id) |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| up = client.post("/api/feedback", json={"sentiment": "up", "result_id": result_id}) |
| down = client.post( |
| "/api/feedback", |
| json={"sentiment": "down", "reason": "not_related", "result_id": result_id}, |
| ) |
| assert up.status_code == 200 and down.status_code == 200 |
| |
| assert len(repo.rows) == 1 |
| assert up.json()["feedback"]["feedback_id"] == down.json()["feedback"]["feedback_id"] |
| assert down.json()["feedback"]["rating"] == 1 |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_options_endpoint_shape(feedback_setup): |
| main, _repo, _reg, _guest = feedback_setup |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.get("/api/feedback/options") |
| assert resp.status_code == 200 |
| reasons = resp.json()["reasons"] |
| codes = {r["code"] for r in reasons} |
| |
| assert {"not_related", "inaccurate", "lack_options", "others"}.issubset(codes) |
| assert "too_far" not in codes |
| assert all(r["label"] for r in reasons) |
|
|
|
|
| @pytest.mark.backend_unit |
| @pytest.mark.parametrize("domain", ["restaurant", "hotel"]) |
| def test_options_endpoint_place_domains_include_too_far(feedback_setup, domain): |
| |
| main, _repo, _reg, _guest = feedback_setup |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.get("/api/feedback/options", params={"domain": domain}) |
| assert resp.status_code == 200 |
| codes = [r["code"] for r in resp.json()["reasons"]] |
| assert "too_far" in codes |
| assert "already_known" not in codes |
| assert codes[-1] == "others" |
|
|
|
|
| @pytest.mark.backend_unit |
| @pytest.mark.parametrize("domain", ["movie", "music", "book"]) |
| def test_options_endpoint_entertainment_swaps_too_far_for_already_known(feedback_setup, domain): |
| main, _repo, _reg, _guest = feedback_setup |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.get("/api/feedback/options", params={"domain": domain}) |
| assert resp.status_code == 200 |
| codes = {r["code"] for r in resp.json()["reasons"]} |
| assert "already_known" in codes |
| assert "too_far" not in codes |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_options_endpoint_unknown_domain_falls_back_to_default(feedback_setup): |
| main, _repo, _reg, _guest = feedback_setup |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.get("/api/feedback/options", params={"domain": "product"}) |
| assert resp.status_code == 200 |
| codes = {r["code"] for r in resp.json()["reasons"]} |
| assert codes == {"not_related", "inaccurate", "lack_options", "others"} |
|
|
|
|
| @pytest.mark.backend_unit |
| def test_submit_accepts_any_union_reason_regardless_of_domain(feedback_setup): |
| |
| |
| |
| main, repo, reg, _guest = feedback_setup |
| result_id = repo.allow_result(user_id=reg.user.id) |
| with _client_as(main, REGISTERED_TOKEN) as client: |
| resp = client.post( |
| "/api/feedback", |
| json={"sentiment": "down", "reason": "already_known", "result_id": result_id}, |
| ) |
| assert resp.status_code == 200 |
| assert resp.json()["feedback"]["reason"] == "already_known" |
|
|
|
|
| @pytest.mark.backend_unit |
| @pytest.mark.asyncio |
| async def test_feedback_resolution_accepts_legacy_unscoped_result_branch(): |
| from business_repositories import PostgresFeedbackRepository |
| from business_orm import RecommendationResultORM |
|
|
| user_id = str(uuid.uuid4()) |
| conversation_id = str(uuid.uuid4()) |
| result_id = str(uuid.uuid4()) |
| result_row = SimpleNamespace( |
| result_id=result_id, |
| user_id=user_id, |
| conversation_id=conversation_id, |
| branch_id=None, |
| task_id=None, |
| ) |
|
|
| class FakeSession: |
| async def get(self, model, key): |
| if model is RecommendationResultORM and key == result_id: |
| return result_row |
| return None |
|
|
| target = await PostgresFeedbackRepository()._resolve_feedback_result( |
| FakeSession(), |
| user_uuid=user_id, |
| result_id=result_id, |
| task_id=None, |
| branch_id="branch-main", |
| conversation_id=conversation_id, |
| ) |
|
|
| assert target is result_row |
|
|
|
|
| @pytest.mark.backend_unit |
| @pytest.mark.asyncio |
| async def test_feedback_stats_builds_per_domain_breakdown(): |
| """The aggregation returns an all-domains rollup plus a per-domain breakdown |
| (sorted by volume) with each slice's own satisfaction ratio and reasons.""" |
| from business_repositories import PostgresAdminRepository |
|
|
| class _FakeResult: |
| def __init__(self, rows): |
| self._rows = rows |
|
|
| def one(self): |
| return self._rows[0] |
|
|
| def all(self): |
| return self._rows |
|
|
| class _FakeSession: |
| |
| |
| def __init__(self, queued): |
| self._queued = list(queued) |
| self.calls = 0 |
|
|
| async def execute(self, _statement): |
| result = self._queued[self.calls] |
| self.calls += 1 |
| return result |
|
|
| session = _FakeSession( |
| [ |
| _FakeResult([(5, 3, 2)]), |
| _FakeResult([("too_far", 1), ("already_known", 1)]), |
| _FakeResult([("movie", 2, 1, 1), ("restaurant", 3, 2, 1)]), |
| _FakeResult([("restaurant", "too_far", 1), ("movie", "already_known", 1)]), |
| ] |
| ) |
|
|
| stats = await PostgresAdminRepository._feedback_stats(session) |
|
|
| assert stats["total"] == 5 |
| assert stats["satisfaction_ratio"] == 0.6 |
| |
| assert stats["reasons"] == [ |
| {"reason": "too_far", "label": "Too far", "count": 1}, |
| {"reason": "already_known", "label": "Already know these", "count": 1}, |
| ] |
|
|
| |
| domains = stats["domains"] |
| assert [d["domain"] for d in domains] == ["restaurant", "movie"] |
| restaurant = domains[0] |
| assert restaurant["total"] == 3 and restaurant["satisfaction_ratio"] == round(2 / 3, 4) |
| assert restaurant["reasons"] == [{"reason": "too_far", "label": "Too far", "count": 1}] |
| movie = domains[1] |
| assert movie["satisfaction_ratio"] == 0.5 |
| assert movie["reasons"] == [{"reason": "already_known", "label": "Already know these", "count": 1}] |
|
|
|
|
| @pytest.mark.backend_unit |
| @pytest.mark.asyncio |
| async def test_feedback_stats_humanizes_unknown_and_missing_reason_codes(): |
| """A null label maps to "Unspecified"; a legacy/unknown code is title-cased.""" |
| from business_repositories import PostgresAdminRepository |
|
|
| class _FakeResult: |
| def __init__(self, rows): |
| self._rows = rows |
|
|
| def one(self): |
| return self._rows[0] |
|
|
| def all(self): |
| return self._rows |
|
|
| class _FakeSession: |
| def __init__(self, queued): |
| self._queued = list(queued) |
| self.calls = 0 |
|
|
| async def execute(self, _statement): |
| result = self._queued[self.calls] |
| self.calls += 1 |
| return result |
|
|
| session = _FakeSession( |
| [ |
| _FakeResult([(2, 0, 2)]), |
| _FakeResult([(None, 1), ("legacy_reason", 1)]), |
| _FakeResult([]), |
| _FakeResult([]), |
| ] |
| ) |
|
|
| stats = await PostgresAdminRepository._feedback_stats(session) |
|
|
| assert stats["reasons"] == [ |
| {"reason": "unspecified", "label": "Unspecified", "count": 1}, |
| {"reason": "legacy_reason", "label": "Legacy reason", "count": 1}, |
| ] |
|
|
|
|
| @pytest.mark.backend_unit |
| @pytest.mark.asyncio |
| async def test_feedback_resolution_falls_back_to_unscoped_result_for_task_branch(): |
| from business_repositories import PostgresFeedbackRepository |
| from business_orm import RecommendationResultORM, RecommendationTaskORM |
|
|
| user_id = str(uuid.uuid4()) |
| conversation_id = str(uuid.uuid4()) |
| task_id = str(uuid.uuid4()) |
| task_row = SimpleNamespace( |
| task_id=task_id, |
| user_id=user_id, |
| conversation_id=conversation_id, |
| branch_id=None, |
| ) |
| result_row = SimpleNamespace( |
| result_id=str(uuid.uuid4()), |
| user_id=user_id, |
| conversation_id=conversation_id, |
| branch_id=None, |
| task_id=task_id, |
| ) |
|
|
| class FakeScalars: |
| def __init__(self, row): |
| self.row = row |
|
|
| def first(self): |
| return self.row |
|
|
| class FakeSession: |
| def __init__(self): |
| self.scalar_calls = 0 |
|
|
| async def get(self, model, key): |
| if model is RecommendationTaskORM and key == task_id: |
| return task_row |
| if model is RecommendationResultORM: |
| return None |
| return None |
|
|
| async def scalars(self, _statement): |
| self.scalar_calls += 1 |
| return FakeScalars(result_row if self.scalar_calls == 2 else None) |
|
|
| session = FakeSession() |
| target = await PostgresFeedbackRepository()._resolve_feedback_result( |
| session, |
| user_uuid=user_id, |
| result_id=None, |
| task_id=task_id, |
| branch_id="branch-main", |
| conversation_id=conversation_id, |
| ) |
|
|
| assert target is result_row |
| assert session.scalar_calls == 2 |
|
|