File size: 18,698 Bytes
8adaf1c
 
 
 
967cab3
8adaf1c
 
 
 
 
 
 
 
 
 
6142843
8adaf1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6142843
 
8adaf1c
 
 
6142843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8adaf1c
 
 
 
 
 
 
 
 
 
 
 
 
6142843
8adaf1c
6142843
 
 
 
 
 
 
 
 
8adaf1c
 
6142843
 
 
 
 
 
 
8adaf1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6142843
 
8adaf1c
6142843
8adaf1c
 
 
 
 
 
 
 
6142843
 
8adaf1c
 
 
6142843
8adaf1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6142843
 
8adaf1c
6142843
8adaf1c
 
 
 
 
 
 
 
 
 
 
 
 
6142843
8adaf1c
6142843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8adaf1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f6c84b
 
 
8adaf1c
967cab3
 
3f6c84b
659f526
 
 
3f6c84b
 
659f526
3f6c84b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
967cab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e0bdd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da45372
4e0bdd1
da45372
 
4e0bdd1
 
 
 
 
 
 
da45372
4e0bdd1
 
da45372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e0bdd1
 
967cab3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
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 == {}  # never reached the repository


@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
    # One row per (user, result); the second vote overwrote the first.
    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}
    # No domain -> generic set: no location-specific "too_far".
    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):
    # Location-anchored domains (restaurant, hotel) offer the distance reason.
    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"  # "others" is always the trailing chip


@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):
    # The POST endpoint validates against the union, not the domain-scoped chip set,
    # so e.g. "already_known" is accepted even though the FE would only offer it for
    # entertainment domains.
    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:
        # Queries run in a fixed order: overall counts, overall reasons,
        # per-domain counts, per-domain reasons.
        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)]),  # overall: total, satisfied, unsatisfied
            _FakeResult([("too_far", 1), ("already_known", 1)]),  # overall reasons
            _FakeResult([("movie", 2, 1, 1), ("restaurant", 3, 2, 1)]),  # per-domain counts
            _FakeResult([("restaurant", "too_far", 1), ("movie", "already_known", 1)]),  # per-domain reasons
        ]
    )

    stats = await PostgresAdminRepository._feedback_stats(session)

    assert stats["total"] == 5
    assert stats["satisfaction_ratio"] == 0.6
    # Each reason carries the stable code plus a humanized label for display.
    assert stats["reasons"] == [
        {"reason": "too_far", "label": "Too far", "count": 1},
        {"reason": "already_known", "label": "Already know these", "count": 1},
    ]

    # Sorted most-feedback-first regardless of query order (restaurant before movie).
    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)]),  # overall counts
            _FakeResult([(None, 1), ("legacy_reason", 1)]),  # overall reasons
            _FakeResult([]),  # per-domain counts (irrelevant here)
            _FakeResult([]),  # per-domain reasons
        ]
    )

    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