From f3c0775e5990a5385d24c90ccee84b2f7f3bb530 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:20:06 +0530 Subject: [PATCH] fix: harden RQ Score response parsing --- app/engine/stats/clients.py | 43 +++++++++++++++++++++++++++++++------ tests/test_stats.py | 18 ++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/app/engine/stats/clients.py b/app/engine/stats/clients.py index 95a818d..15069f9 100644 --- a/app/engine/stats/clients.py +++ b/app/engine/stats/clients.py @@ -6,6 +6,8 @@ a missing KPI is omitted by the UI, never faked, and never crashes the dashboard from __future__ import annotations import logging +import math +from collections.abc import Mapping import httpx @@ -14,6 +16,17 @@ from app.config import get_settings logger = logging.getLogger(__name__) +def _score_value(value: object) -> float | None: + """Parse one canonical RQ Score value; malformed values are absent.""" + if isinstance(value, bool) or value is None: + return None + try: + score = float(value) + except (TypeError, ValueError): + return None + return score if math.isfinite(score) else None + + async def fetch_qx(user_uuid: str | None) -> dict | None: """RQ Score for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service).""" if not user_uuid: @@ -25,10 +38,15 @@ async def fetch_qx(user_uuid: str | None) -> dict | None: if r.status_code == 404: # no score computed yet — honest absence return None r.raise_for_status() - d = r.json() - qx = d.get("rq_score") - return {"qx": round(float(qx), 1), "quotients": d.get("quotients") or {}} if qx is not None else None - except (httpx.HTTPError, ValueError) as e: + payload = r.json() + if not isinstance(payload, Mapping): + return None + qx = _score_value(payload.get("rq_score")) + if qx is None: + return None + quotients = payload.get("quotients") + return {"qx": round(qx, 1), "quotients": quotients if isinstance(quotients, Mapping) else {}} + except (httpx.HTTPError, ValueError, TypeError) as e: logger.warning("fetch_qx failed (non-fatal): %s", e) return None @@ -48,10 +66,21 @@ async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None: ) if r.status_code == 404: return None - r.raise_for_status() - pts = [round(float(p["rq_score"]), 1) for p in (r.json().get("points") or []) if p.get("rq_score") is not None] + payload = r.json() + if not isinstance(payload, Mapping): + return None + points = payload.get("points") + if not isinstance(points, list): + return None + pts = [] + for point in points: + if not isinstance(point, Mapping): + continue + score = _score_value(point.get("rq_score")) + if score is not None: + pts.append(round(score, 1)) return pts if len(pts) >= 2 else None # need ≥2 points for a trend - except (httpx.HTTPError, ValueError, TypeError, KeyError) as e: + except (httpx.HTTPError, ValueError, TypeError) as e: logger.warning("fetch_qx_trend failed (non-fatal): %s", e) return None diff --git a/tests/test_stats.py b/tests/test_stats.py index 2626d3c..2ff8231 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -72,6 +72,24 @@ def test_fetch_qx_does_not_alias_legacy_q_score(monkeypatch): assert asyncio.run(clients.fetch_qx("user-uuid")) is None +def test_fetch_qx_treats_malformed_payload_as_absent(monkeypatch): + async def _get(_self, _url, **_kwargs): + return _Response([{"rq_score": 88}]) + + monkeypatch.setattr(httpx.AsyncClient, "get", _get) + + assert asyncio.run(clients.fetch_qx("user-uuid")) is None + + +def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch): + async def _get(_self, _url, **_kwargs): + return _Response({"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]}) + + monkeypatch.setattr(httpx.AsyncClient, "get", _get) + + assert asyncio.run(clients.fetch_qx_trend("user-uuid")) == [35.0, 40.0] + + def test_fetch_qx_trend_reads_strict_rq_score_points(monkeypatch): async def _get(_self, _url, **_kwargs): return _Response({