diff --git a/packages/backend/app/extensions.py b/packages/backend/app/extensions.py index bad98fae7..50ae3dbec 100644 --- a/packages/backend/app/extensions.py +++ b/packages/backend/app/extensions.py @@ -1,11 +1,36 @@ -from flask_sqlalchemy import SQLAlchemy -from flask_jwt_extended import JWTManager -import redis -from .config import Settings +""" +FinMind — Flask extensions +Add cache and limiter here so the route can import them cleanly. +Requires: + pip install Flask-Caching Flask-Limiter redis +""" + +from flask_caching import Cache +from flask_jwt_extended import JWTManager +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() jwt = JWTManager() -_settings = Settings() -redis_client = redis.Redis.from_url(_settings.redis_url, decode_responses=True) +# Fix #9 — rate limiting (storage_uri → Redis in production) +limiter = Limiter( + key_func=get_remote_address, + default_limits=[], + storage_uri="memory://", # swap to "redis://localhost:6379/0" in prod +) + +# Fix #9 — response cache (CACHE_TYPE configured in app config) +cache = Cache() + + +def init_extensions(app): + db.init_app(app) + jwt.init_app(app) + limiter.init_app(app) + cache.init_app(app, config={ + "CACHE_TYPE": "SimpleCache", # swap to RedisCache in prod + "CACHE_DEFAULT_TIMEOUT": 3600, + }) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..319c6e45d 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -4,6 +4,7 @@ from .bills import bp as bills_bp from .reminders import bp as reminders_bp from .insights import bp as insights_bp +from .weekly_summary import bp as weekly_summary_bp from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp @@ -15,6 +16,7 @@ def register_routes(app: Flask): app.register_blueprint(bills_bp, url_prefix="/bills") app.register_blueprint(reminders_bp, url_prefix="/reminders") app.register_blueprint(insights_bp, url_prefix="/insights") + app.register_blueprint(weekly_summary_bp, url_prefix="/insights") app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") diff --git a/packages/backend/app/routes/weekly_summary.py b/packages/backend/app/routes/weekly_summary.py new file mode 100644 index 000000000..0a0a893df --- /dev/null +++ b/packages/backend/app/routes/weekly_summary.py @@ -0,0 +1,108 @@ +""" +FinMind — Weekly Summary Route + +Fixes applied: + 2. X-Gemini-Api-Key header removed — key is server-side only + 6. ref_date validated: future dates rejected with 400 + 9. Flask-Limiter rate limiting + Flask-Caching response cache +""" + +import logging +from datetime import date + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import cache, limiter +from ..services.weekly_summary import weekly_summary + +bp = Blueprint("weekly_summary", __name__) +logger = logging.getLogger("finmind.weekly_summary") + +# --------------------------------------------------------------------------- +# Cache key: scoped to (user_id, ref_date) so users never see each other's data +# --------------------------------------------------------------------------- + +def _cache_key() -> str: + uid = get_jwt_identity() + ref = request.args.get("ref_date") or date.today().isoformat() + return f"wsummary:uid={uid}:week={ref}" + + +# --------------------------------------------------------------------------- +# Fix #6 — ref_date validation helper +# --------------------------------------------------------------------------- + +def _parse_ref_date(raw: str | None) -> tuple[date | None, str | None]: + """Return (date, None) on success or (None, error_message) on failure.""" + if raw is None: + return date.today(), None + try: + ref = date.fromisoformat(raw) + except ValueError: + return None, f"Invalid date format: '{raw}'. Use YYYY-MM-DD." + if ref > date.today(): + return None, "ref_date cannot be in the future." + return ref, None + + +# --------------------------------------------------------------------------- +# Route +# Fix #2: X-Gemini-Api-Key header is no longer read or forwarded +# Fix #6: future ref_date → 400 +# Fix #9: @limiter.limit + @cache.cached applied +# --------------------------------------------------------------------------- + +@bp.get("/weekly-summary") +@jwt_required() +@limiter.limit("20/minute") # Fix #9 — rate limiting +@cache.cached( # Fix #9 — response cache (TTL 1 h) + timeout=3600, + key_prefix=_cache_key, + unless=lambda: request.args.get("no_cache") == "1", +) +def get_weekly_summary(): + uid = int(get_jwt_identity()) + + # Fix #6 — validate ref_date + ref_date, err = _parse_ref_date(request.args.get("ref_date")) + if err: + return jsonify({"error": err}), 400 + + # Fix #8 — forward Accept-Language for locale-aware AI responses + locale = _parse_locale(request.headers.get("Accept-Language", "en")) + + # Fix #9 — allow caller to specify a different Gemini model variant, + # but the API key is never sourced from the request + user_model = (request.headers.get("X-Gemini-Model") or "").strip() or None + + summary = weekly_summary( + uid, + ref_date=ref_date, + gemini_model=user_model, + locale=locale, + ) + + logger.info( + "Weekly summary served uid=%s week=%s method=%s", + uid, + ref_date.isoformat(), + summary.get("method"), + ) + return jsonify(summary) + + +# --------------------------------------------------------------------------- +# Locale helper +# --------------------------------------------------------------------------- + +_SUPPORTED_LOCALES = {"en", "zh", "es"} + + +def _parse_locale(accept_language: str) -> str: + """Extract the best supported locale from an Accept-Language header.""" + for segment in accept_language.split(","): + lang = segment.split(";")[0].strip().split("-")[0].lower() + if lang in _SUPPORTED_LOCALES: + return lang + return "en" diff --git a/packages/backend/app/services/weekly_summary.py b/packages/backend/app/services/weekly_summary.py new file mode 100644 index 000000000..b67eaae29 --- /dev/null +++ b/packages/backend/app/services/weekly_summary.py @@ -0,0 +1,409 @@ +""" +FinMind — Weekly Financial Summary Service + +Generates AI-powered weekly summaries highlighting spending trends, +budget insights, and actionable recommendations. + +Fixes applied: + 1. N+1 queries → single CASE-WHEN aggregation per date range + 2. API key no longer accepted from client headers — server-side only + 3. urllib replaced with httpx (explicit SSL verify=True) + 4. Broad `except Exception` split into precise exception types + 5. Date boundary: `spent_at <= week_end` → `spent_at < next_day` + 6. ref_date future-date validation moved to route layer + 7. Test isolation via db_session fixture (see tests/) + 8. WEEKLY_PERSONA now locale-aware via _build_persona() + 9. Response caching + rate-limiting hooks (applied in route layer) +""" + +import json +import logging +from datetime import date, timedelta + +import httpx +from sqlalchemy import case, func, text + +from ..config import Settings +from ..extensions import db +from ..models import Expense + +logger = logging.getLogger("finmind.weekly_summary") +_settings = Settings() + +# --------------------------------------------------------------------------- +# Fix #8 — locale-aware persona builder +# --------------------------------------------------------------------------- + +_PERSONAS: dict[str, str] = { + "en": ( + "You are FinMind's pragmatic financial coach. Be concise, non-judgmental, " + "data-driven, and action-oriented. Return actionable, realistic guidance " + "for a weekly financial summary." + ), + "zh": ( + "你是 FinMind 的理财教练,请用简体中文回答。风格简洁、客观、以数据为依据," + "给出切实可行的每周财务建议。" + ), + "es": ( + "Eres el coach financiero de FinMind. Sé conciso, objetivo y orientado " + "a datos. Devuelve orientación realista y accionable en español." + ), +} + + +def _build_persona(locale: str = "en") -> str: + return _PERSONAS.get(locale, _PERSONAS["en"]) + + +# --------------------------------------------------------------------------- +# Week range helpers +# --------------------------------------------------------------------------- + +def _week_range(ref: date | None = None) -> tuple[date, date]: + """Return (monday, sunday) of the ISO week containing *ref*.""" + if ref is None: + ref = date.today() + start = ref - timedelta(days=ref.weekday()) + return start, start + timedelta(days=6) + + +def _previous_week_range(ref: date | None = None) -> tuple[date, date]: + """Return (monday, sunday) of the week before *ref*'s week.""" + if ref is None: + ref = date.today() + this_monday = ref - timedelta(days=ref.weekday()) + last_monday = this_monday - timedelta(days=7) + return last_monday, last_monday + timedelta(days=6) + + +# --------------------------------------------------------------------------- +# Fix #1 — single aggregation query replaces 3 separate scalar queries +# Fix #5 — date boundary uses `< next_day` instead of `<= week_end` +# --------------------------------------------------------------------------- + +def _week_totals( + uid: int, week_start: date, week_end: date +) -> tuple[float, float, int]: + """Return (income, expenses, transaction_count) using ONE SQL query.""" + next_day = week_end + timedelta(days=1) + + row = ( + db.session.query( + func.coalesce( + func.sum( + case( + (Expense.expense_type == "INCOME", Expense.amount), + else_=0, + ) + ), + 0, + ).label("income"), + func.coalesce( + func.sum( + case( + (Expense.expense_type != "INCOME", Expense.amount), + else_=0, + ) + ), + 0, + ).label("expenses"), + func.count(Expense.id).label("txn_count"), + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= week_start, + Expense.spent_at < next_day, # Fix #5: exclusive upper bound + ) + .one() + ) + + return float(row.income), float(row.expenses), int(row.txn_count) + + +def _week_category_spend( + uid: int, week_start: date, week_end: date +) -> dict[str, float]: + """Return {category_id: total_amount} for expense rows in the week.""" + next_day = week_end + timedelta(days=1) # Fix #5 + + rows = ( + db.session.query( + Expense.category_id, + func.coalesce(func.sum(Expense.amount), 0), + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= week_start, + Expense.spent_at < next_day, # Fix #5 + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id) + .all() + ) + return {str(k or "uncat"): float(v) for k, v in rows} + + +def _daily_breakdown( + uid: int, week_start: date, week_end: date +) -> list[dict]: + """Return daily spend totals for the week.""" + next_day = week_end + timedelta(days=1) # Fix #5 + + rows = ( + db.session.query( + Expense.spent_at, + func.coalesce(func.sum(Expense.amount), 0), + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= week_start, + Expense.spent_at < next_day, # Fix #5 + Expense.expense_type != "INCOME", + ) + .group_by(Expense.spent_at) + .order_by(Expense.spent_at) + .all() + ) + return [{"date": str(d), "amount": round(float(a), 2)} for d, a in rows] + + +# --------------------------------------------------------------------------- +# Analytics builder — now makes only 3 DB round-trips instead of 8 +# (week_totals=1, category_spend=1, daily_breakdown=1) +# --------------------------------------------------------------------------- + +def _build_weekly_analytics(uid: int, week_start: date, week_end: date) -> dict: + income, expenses, txn_count = _week_totals(uid, week_start, week_end) + cats = _week_category_spend(uid, week_start, week_end) + top_cats = sorted(cats.items(), key=lambda x: x[1], reverse=True)[:5] + daily = _daily_breakdown(uid, week_start, week_end) + + return { + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "total_income": round(income, 2), + "total_expenses": round(expenses, 2), + "net_flow": round(income - expenses, 2), + "transaction_count": txn_count, + "daily_spend": daily, + "top_categories": [ + {"category_id": k, "amount": round(v, 2)} for k, v in top_cats + ], + } + + +# --------------------------------------------------------------------------- +# Heuristic fallback +# --------------------------------------------------------------------------- + +def _heuristic_weekly_summary( + uid: int, + this_week: tuple[date, date], + last_week: tuple[date, date], + warnings: list[str] | None = None, +) -> dict: + """Rule-based summary — used when AI is unavailable.""" + current = _build_weekly_analytics(uid, *this_week) + previous = _build_weekly_analytics(uid, *last_week) + + prev_exp = previous["total_expenses"] + curr_exp = current["total_expenses"] + wow = _calc_wow(curr_exp, prev_exp) + + tips: list[str] = [] + if prev_exp > 0: + if curr_exp > prev_exp: + tips.append( + f"Spending increased {wow:.1f}% week-over-week. " + "Review discretionary categories." + ) + else: + tips.append( + f"Great work — spending dropped {abs(wow):.1f}% vs last week." + ) + + if current["top_categories"]: + top = current["top_categories"][0] + tips.append( + f"Highest spend: {top['category_id']} at ${top['amount']:.2f}. " + "Consider setting a weekly limit." + ) + + payload: dict = { + "type": "weekly_summary", + "this_week": current, + "previous_week": previous, + "week_over_week_change_pct": wow, + "tips": tips or ["Track daily expenses to spot patterns."], + "method": "heuristic", + } + if warnings: + payload["warnings"] = warnings + return payload + + +# --------------------------------------------------------------------------- +# Fix #3 — httpx with explicit SSL verification replaces urllib +# Fix #4 — precise exception types instead of bare `except Exception` +# Fix #8 — locale forwarded to prompt builder +# --------------------------------------------------------------------------- + +def _extract_json_object(raw: str) -> dict: + """Extract a JSON object from model output, stripping markdown fences.""" + text = (raw or "").strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].strip() + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end <= start: + raise json.JSONDecodeError("no JSON object found in model output", text, 0) + return json.loads(text[start : end + 1]) # raises json.JSONDecodeError on bad JSON + + +def _ai_weekly_summary( + uid: int, + this_week: tuple[date, date], + last_week: tuple[date, date], + model: str, + locale: str = "en", +) -> dict: + """Generate weekly summary using Gemini AI. + + Fix #2: API key is read exclusively from server-side settings. + Fix #3: httpx replaces urllib; SSL verification is always enabled. + """ + api_key = (_settings.gemini_api_key or "").strip() + if not api_key: + raise ValueError("GEMINI_API_KEY is not configured on the server") + + current = _build_weekly_analytics(uid, *this_week) + previous = _build_weekly_analytics(uid, *last_week) + + prompt = ( + f"{_build_persona(locale)}\n" + "Use this week's financial data and return STRICT JSON only — no markdown " + "fences, no extra keys. Required keys:\n" + " summary (string), highlights (array ≤3), concerns (array ≤3), tips (array ≤3)\n\n" + f"This week ({this_week[0]} → {this_week[1]}):\n" + f" income={current['total_income']}, expenses={current['total_expenses']}, " + f"transactions={current['transaction_count']}\n" + f" top_categories={current['top_categories']}\n" + f" daily_spend={current['daily_spend']}\n\n" + f"Previous week ({last_week[0]} → {last_week[1]}):\n" + f" income={previous['total_income']}, expenses={previous['total_expenses']}\n" + ) + + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{model}:generateContent?key={api_key}" + ) + body = { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.2}, + } + + # Fix #3: httpx with verify=True (default) — no urllib, no nosec workarounds + with httpx.Client(timeout=10.0, verify=True) as client: + response = client.post( + url, + json=body, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() # raises httpx.HTTPStatusError on 4xx/5xx + payload = response.json() + + raw_text = ( + payload.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + + # Fix #4: let json.JSONDecodeError propagate — caller handles it specifically + ai_part = _extract_json_object(raw_text) + + return { + "type": "weekly_summary", + "this_week": current, + "previous_week": previous, + "week_over_week_change_pct": _calc_wow( + current["total_expenses"], previous["total_expenses"] + ), + **ai_part, + "method": "gemini", + } + + +def _calc_wow(current: float, previous: float) -> float: + if previous > 0: + return round(((current - previous) / previous) * 100, 2) + return 0.0 + + +# --------------------------------------------------------------------------- +# Public entry point +# Fix #2: gemini_api_key parameter removed — key never comes from caller +# Fix #8: locale parameter added +# --------------------------------------------------------------------------- + +def weekly_summary( + uid: int, + ref_date: date | None = None, + gemini_model: str | None = None, + locale: str = "en", +) -> dict: + """Generate a weekly financial summary for *uid*. + + Returns a dict with: + type, this_week, previous_week, week_over_week_change_pct, + tips / summary / highlights / concerns, method, [warnings] + """ + if ref_date is None: + ref_date = date.today() + + this_week = _week_range(ref_date) + last_week = _previous_week_range(ref_date) + model = gemini_model or _settings.gemini_model + has_key = bool((_settings.gemini_api_key or "").strip()) + + if has_key: + try: + return _ai_weekly_summary(uid, this_week, last_week, model, locale) + + except json.JSONDecodeError as exc: + # AI returned malformed JSON — degrade gracefully + logger.warning("AI response parse error for uid=%s: %s", uid, exc) + return _heuristic_weekly_summary( + uid, this_week, last_week, + warnings=[f"ai_parse_error: {exc}"], + ) + + except httpx.HTTPStatusError as exc: + # 4xx / 5xx from Gemini endpoint + logger.warning( + "Gemini HTTP %s for uid=%s: %s", + exc.response.status_code, uid, exc, + ) + return _heuristic_weekly_summary( + uid, this_week, last_week, + warnings=[f"gemini_http_error: {exc.response.status_code}"], + ) + + except httpx.TimeoutException as exc: + logger.warning("Gemini timeout for uid=%s: %s", uid, exc) + return _heuristic_weekly_summary( + uid, this_week, last_week, + warnings=["gemini_timeout"], + ) + + except (httpx.RequestError, ValueError) as exc: + # Network issues or missing key config + logger.warning("Gemini request error for uid=%s: %s", uid, exc) + return _heuristic_weekly_summary( + uid, this_week, last_week, + warnings=[f"gemini_unavailable: {exc}"], + ) + + return _heuristic_weekly_summary(uid, this_week, last_week) diff --git a/packages/backend/tests/test_weekly_summary.py b/packages/backend/tests/test_weekly_summary.py new file mode 100644 index 000000000..dcbf5834d --- /dev/null +++ b/packages/backend/tests/test_weekly_summary.py @@ -0,0 +1,317 @@ +""" +FinMind — Weekly Summary Tests + +Fixes applied: + 7. autouse db_session fixture ensures each test runs in an isolated + transaction that is rolled back on teardown — no cross-test pollution. +""" + +from datetime import date, timedelta + +import pytest + + +# --------------------------------------------------------------------------- +# Fix #7 — per-test database isolation +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def isolated_db(db_session): + """Wrap every test in a savepoint; roll back after the test finishes.""" + db_session.begin_nested() + yield + db_session.rollback() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _week_start(ref: date | None = None) -> date: + d = ref or date.today() + return d - timedelta(days=d.weekday()) + + +def _prev_week_start(ref: date | None = None) -> date: + return _week_start(ref) - timedelta(days=7) + + +def _post_expense(client, auth_header, *, amount: float, spent_at: date, expense_type: str = "EXPENSE"): + return client.post( + "/expenses", + json={ + "amount": amount, + "description": f"Test expense {amount}", + "date": spent_at.isoformat(), + "expense_type": expense_type, + }, + headers=auth_header, + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_weekly_summary_returns_analytics(client, auth_header): + """Basic heuristic summary returns all required analytics fields.""" + today = date.today() + ws = _week_start(today) + we = ws + timedelta(days=6) + + r = _post_expense(client, auth_header, amount=75.50, spent_at=ws) + assert r.status_code == 201 + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["type"] == "weekly_summary" + assert "this_week" in payload + assert "previous_week" in payload + assert "week_over_week_change_pct" in payload + assert payload["method"] in ("heuristic", "gemini") + + tw = payload["this_week"] + assert tw["week_start"] == ws.isoformat() + assert tw["week_end"] == we.isoformat() + assert tw["total_expenses"] >= 75.0 + assert tw["transaction_count"] >= 1 + assert len(tw["daily_spend"]) >= 1 + + +def test_weekly_summary_with_ref_date(client, auth_header): + """A specific ref_date produces the correct ISO week range.""" + ref = date(2026, 5, 20) # Wednesday → week of Mon 18 May + ws = _week_start(ref) + we = ws + timedelta(days=6) + + r = client.get( + f"/insights/weekly-summary?ref_date={ref.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + payload = r.get_json() + assert payload["this_week"]["week_start"] == ws.isoformat() + assert payload["this_week"]["week_end"] == we.isoformat() + + +# Fix #6 — future date must be rejected +def test_weekly_summary_rejects_future_ref_date(client, auth_header): + """ref_date in the future returns HTTP 400.""" + future = (date.today() + timedelta(days=30)).isoformat() + r = client.get( + f"/insights/weekly-summary?ref_date={future}", + headers=auth_header, + ) + assert r.status_code == 400 + body = r.get_json() + assert "error" in body + assert "future" in body["error"].lower() + + +def test_weekly_summary_rejects_invalid_date_format(client, auth_header): + """Malformed ref_date returns HTTP 400.""" + r = client.get( + "/insights/weekly-summary?ref_date=not-a-date", + headers=auth_header, + ) + assert r.status_code == 400 + + +# Fix #2 — client-supplied API key header must be ignored +def test_weekly_summary_ignores_client_gemini_key(client, auth_header, monkeypatch): + """X-Gemini-Api-Key header must NOT be forwarded to the AI service.""" + calls = [] + + def _fake_ai(uid, this_week, last_week, model, locale="en"): + calls.append({"uid": uid, "model": model}) + return { + "type": "weekly_summary", + "this_week": {"total_expenses": 0, "total_income": 0, + "net_flow": 0, "transaction_count": 0, + "daily_spend": [], "top_categories": [], + "week_start": this_week[0].isoformat(), + "week_end": this_week[1].isoformat()}, + "previous_week": {"total_expenses": 0}, + "week_over_week_change_pct": 0.0, + "summary": "AI summary", + "highlights": [], + "concerns": [], + "tips": [], + "method": "gemini", + } + + monkeypatch.setattr( + "app.services.weekly_summary._ai_weekly_summary", _fake_ai + ) + # Patch settings so server thinks it has a key configured + monkeypatch.setattr( + "app.services.weekly_summary._settings.gemini_api_key", "server-secret" + ) + + r = client.get( + "/insights/weekly-summary", + headers={**auth_header, "X-Gemini-Api-Key": "client-injected-key"}, + ) + assert r.status_code == 200 + # The fake was called (server key exists), but the client key is not accessible + # — the route never passes it down; _ai_weekly_summary reads from _settings only + assert len(calls) == 1 + + +def test_weekly_summary_uses_gemini_when_server_key_configured( + client, auth_header, monkeypatch +): + """When the server has a Gemini key, the AI path is used.""" + def _fake_ai(uid, this_week, last_week, model, locale="en"): + return { + "type": "weekly_summary", + "this_week": {"total_expenses": 100, "total_income": 0, + "net_flow": -100, "transaction_count": 2, + "daily_spend": [], "top_categories": [], + "week_start": this_week[0].isoformat(), + "week_end": this_week[1].isoformat()}, + "previous_week": {"total_expenses": 80}, + "week_over_week_change_pct": 25.0, + "summary": "AI summary", + "highlights": ["Good"], + "concerns": [], + "tips": ["Save more"], + "method": "gemini", + } + + monkeypatch.setattr("app.services.weekly_summary._ai_weekly_summary", _fake_ai) + monkeypatch.setattr( + "app.services.weekly_summary._settings.gemini_api_key", "server-key" + ) + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert payload["method"] == "gemini" + assert payload["summary"] == "AI summary" + + +# Fix #4 — each exception type produces the correct warning tag +@pytest.mark.parametrize("exc_class,warning_prefix", [ + ("json.JSONDecodeError", "ai_parse_error"), + ("httpx.HTTPStatusError", "gemini_http_error"), + ("httpx.TimeoutException", "gemini_timeout"), + ("httpx.RequestError", "gemini_unavailable"), +]) +def test_weekly_summary_falls_back_on_specific_exceptions( + client, auth_header, monkeypatch, exc_class, warning_prefix +): + """Each specific AI exception degrades to heuristic with the right warning tag.""" + import httpx, json as _json + + exc_map = { + "json.JSONDecodeError": _json.JSONDecodeError("boom", "", 0), + "httpx.HTTPStatusError": httpx.HTTPStatusError( + "boom", request=None, + response=type("R", (), {"status_code": 503})(), + ), + "httpx.TimeoutException": httpx.TimeoutException("timeout"), + "httpx.RequestError": httpx.RequestError("conn refused"), + } + + def _boom(*_args, **_kwargs): + raise exc_map[exc_class] + + monkeypatch.setattr("app.services.weekly_summary._ai_weekly_summary", _boom) + monkeypatch.setattr( + "app.services.weekly_summary._settings.gemini_api_key", "server-key" + ) + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert payload["method"] == "heuristic" + assert "warnings" in payload + assert any(warning_prefix in w for w in payload["warnings"]), ( + f"Expected warning starting with '{warning_prefix}', got: {payload['warnings']}" + ) + + +def test_weekly_summary_includes_top_categories(client, auth_header): + """Multiple expenses produce a non-empty top_categories list. + + Fix #7: isolated_db fixture prevents data from other tests leaking in. + """ + ws = _week_start() + for amt in [50.0, 30.0, 20.0]: + resp = _post_expense(client, auth_header, amount=amt, spent_at=ws) + assert resp.status_code == 201 + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + cats = r.get_json()["this_week"]["top_categories"] + assert len(cats) >= 1 + # Amounts should be sorted descending + amounts = [c["amount"] for c in cats] + assert amounts == sorted(amounts, reverse=True) + + +# Fix #5 — expenses on the last day of the week must be counted +def test_weekly_summary_includes_week_end_day_expenses(client, auth_header): + """An expense on Sunday (week_end) is included in the weekly total.""" + ws = _week_start() + sunday = ws + timedelta(days=6) + + r = _post_expense(client, auth_header, amount=99.0, spent_at=sunday) + assert r.status_code == 201 + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + tw = r.get_json()["this_week"] + assert tw["total_expenses"] >= 99.0, ( + "Sunday expense was not counted — date boundary bug not fixed" + ) + + +# Fix #9 — rate limiting +def test_weekly_summary_rate_limited(client, auth_header): + """More than 20 rapid requests in one minute should return 429.""" + responses = [ + client.get("/insights/weekly-summary", headers=auth_header) + for _ in range(25) + ] + status_codes = [r.status_code for r in responses] + assert 429 in status_codes, ( + "Expected at least one 429 after 25 rapid requests (rate limit not enforced)" + ) + + +# Fix #8 — locale forwarding +def test_weekly_summary_locale_forwarded(client, auth_header, monkeypatch): + """Accept-Language header selects the matching AI persona.""" + captured = {} + + def _fake_ai(uid, this_week, last_week, model, locale="en"): + captured["locale"] = locale + return { + "type": "weekly_summary", + "this_week": {"total_expenses": 0, "total_income": 0, + "net_flow": 0, "transaction_count": 0, + "daily_spend": [], "top_categories": [], + "week_start": this_week[0].isoformat(), + "week_end": this_week[1].isoformat()}, + "previous_week": {"total_expenses": 0}, + "week_over_week_change_pct": 0.0, + "summary": "摘要", + "highlights": [], "concerns": [], "tips": [], + "method": "gemini", + } + + monkeypatch.setattr("app.services.weekly_summary._ai_weekly_summary", _fake_ai) + monkeypatch.setattr( + "app.services.weekly_summary._settings.gemini_api_key", "server-key" + ) + + r = client.get( + "/insights/weekly-summary", + headers={**auth_header, "Accept-Language": "zh-CN,zh;q=0.9"}, + ) + assert r.status_code == 200 + assert captured.get("locale") == "zh"