"""Tests for shopstack.services.analytics (Phase 8 — analytics dashboard).""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta import pytest from shopstack.services.analytics import ( HouseholdAnalytics, aggregate_analytics, render_analytics_html, ) @dataclass class FakeTrace: action_type: str created_at: datetime user_id: str = "hh-1" payload: dict | None = None # ── aggregate_analytics basics ──────────────────────────────────── def test_aggregate_analytics_empty_input(): a = aggregate_analytics([]) assert a.purchase_count == 0 assert a.spend_this_month == 0.0 assert a.spend_this_year == 0.0 assert a.waste_rate_pct == 0.0 def test_aggregate_analytics_counts_purchases(): today = datetime(2026, 6, 13, 10, 0, 0) traces = [ FakeTrace("add_purchase", today, payload={"total": 100}), FakeTrace("add_purchase", today, payload={"total": 50}), ] a = aggregate_analytics(traces, today=today) assert a.purchase_count == 2 assert a.spend_this_month == 150.0 assert a.spend_this_year == 150.0 def test_aggregate_analytics_separates_month_year(): today = datetime(2026, 6, 13) # Use 2026-01-15 (in 2026, this year) for the year-trace so it's counted in_2026 = datetime(2026, 1, 15) traces = [ FakeTrace("add_purchase", today, payload={"total": 200}), FakeTrace("add_purchase", in_2026, payload={"total": 100}), ] a = aggregate_analytics(traces, today=today) assert a.spend_this_year == 300.0 assert a.spend_this_month == 200.0 def test_aggregate_analytics_excludes_previous_year(): today = datetime(2026, 6, 13) last_year = datetime(2025, 12, 1) traces = [ FakeTrace("add_purchase", today, payload={"total": 200}), FakeTrace("add_purchase", last_year, payload={"total": 100}), ] a = aggregate_analytics(traces, today=today) # 2025-12 is not in "this year" (2026) assert a.spend_this_year == 200.0 assert a.spend_this_month == 200.0 def test_aggregate_analytics_groups_by_store(): today = datetime(2026, 6, 13) traces = [ FakeTrace("add_purchase", today, payload={"total": 100, "store": "DMart"}), FakeTrace("add_purchase", today, payload={"total": 200, "store": "Reliance"}), ] a = aggregate_analytics(traces, today=today) assert a.spend_by_store == {"Reliance": 200.0, "DMart": 100.0} def test_aggregate_analytics_groups_by_category(): today = datetime(2026, 6, 13) traces = [ FakeTrace("add_purchase", today, payload={"total": 100, "category": "dairy"}), FakeTrace("add_purchase", today, payload={"total": 50, "category": "snacks"}), ] a = aggregate_analytics(traces, today=today) assert a.spend_by_category == {"dairy": 100.0, "snacks": 50.0} def test_aggregate_analytics_top_items(): today = datetime(2026, 6, 13) traces = [ FakeTrace("add_purchase", today, payload={"total": 100, "canonical_name": "milk"}), FakeTrace("add_purchase", today, payload={"total": 200, "canonical_name": "bread"}), FakeTrace("add_purchase", today, payload={"total": 50, "canonical_name": "egg"}), ] a = aggregate_analytics(traces, today=today) assert a.top_items[0] == ("bread", 200.0) assert a.top_items[1] == ("milk", 100.0) def test_aggregate_analytics_consume_count(): today = datetime(2026, 6, 13) traces = [ FakeTrace("consume_item", today), FakeTrace("consume", today), FakeTrace("add_purchase", today, payload={"total": 100}), ] a = aggregate_analytics(traces, today=today) assert a.consume_count == 2 assert a.purchase_count == 1 def test_aggregate_analytics_use_soon_count(): today = datetime(2026, 6, 13) traces = [ FakeTrace("use_soon", today), FakeTrace("waste", today), ] a = aggregate_analytics(traces, today=today) assert a.use_soon_count == 2 def test_aggregate_analytics_waste_rate(): today = datetime(2026, 6, 13) traces = [ FakeTrace("consume", today), FakeTrace("consume", today), FakeTrace("use_soon", today), ] a = aggregate_analytics(traces, today=today) assert a.waste_rate_pct == 50.0 # 1 of 2 consumptions was use-soon def test_aggregate_analytics_waste_rate_zero_consume(): a = aggregate_analytics([]) assert a.waste_rate_pct == 0.0 def test_aggregate_analytics_spend_trend_six_months(): today = datetime(2026, 6, 13) traces = [ FakeTrace("add_purchase", today, payload={"total": 100}), ] a = aggregate_analytics(traces, today=today) assert len(a.spend_trend) == 6 # The most recent month (2026-06) has 100 assert a.spend_trend[-1] == ("2026-06", 100.0) def test_aggregate_analytics_top_merchants(): today = datetime(2026, 6, 13) traces = [ FakeTrace("add_purchase", today, payload={"total": 50, "store": "DMart"}), FakeTrace("add_purchase", today, payload={"total": 50, "store": "DMart"}), FakeTrace("add_purchase", today, payload={"total": 50, "store": "Reliance"}), ] a = aggregate_analytics(traces, today=today) assert a.top_merchants[0] == ("DMart", 2) def test_aggregate_analytics_new_items_added(): today = datetime(2026, 6, 13) traces = [ FakeTrace("add_inventory_item", today), FakeTrace("add_inventory_item", today), FakeTrace("add_purchase", today, payload={"total": 50}), ] a = aggregate_analytics(traces, today=today) assert a.new_items_added == 2 def test_aggregate_analytics_handles_dict_traces(): today = datetime(2026, 6, 13) traces = [ {"action_type": "add_purchase", "created_at": today, "payload": {"total": 100, "store": "DMart"}}, ] a = aggregate_analytics(traces, today=today) assert a.purchase_count == 1 assert a.spend_this_month == 100.0 def test_aggregate_analytics_handles_string_timestamp(): today = datetime(2026, 6, 13) traces = [ {"action_type": "add_purchase", "created_at": "2026-06-13T10:00:00", "payload": {"total": 50}}, ] a = aggregate_analytics(traces, today=today) assert a.spend_this_month == 50.0 def test_aggregate_analytics_respects_window(): today = datetime(2026, 6, 13) old = today - timedelta(days=400) traces = [ FakeTrace("add_purchase", today, payload={"total": 100}), FakeTrace("add_purchase", old, payload={"total": 200}), # outside window ] a = aggregate_analytics(traces, window_days=180, today=today) # Only the in-window one counts assert a.purchase_count == 1 assert a.spend_this_month == 100.0 # ── render_analytics_html ────────────────────────────────────────── def test_render_analytics_html_empty(): a = HouseholdAnalytics() html = render_analytics_html(a) assert "No purchase history" in html def test_render_analytics_html_basic(): a = HouseholdAnalytics( spend_this_month=500.0, spend_this_year=2500.0, purchase_count=12, new_items_added=5, spend_trend=[("2026-01", 100), ("2026-02", 200), ("2026-03", 300)], top_items=[("milk", 150.0), ("bread", 100.0)], top_merchants=[("DMart", 8)], ) html = render_analytics_html(a) assert "ha-block" in html assert "₹500" in html assert "₹2,500" in html assert "12" in html assert "5" in html assert "Monthly trend" in html assert "Top items" in html assert "Top merchants" in html def test_render_analytics_html_waste_rate_badge(): a = HouseholdAnalytics( spend_this_month=100.0, purchase_count=2, consume_count=10, use_soon_count=3, ) html = render_analytics_html(a) assert "Waste rate" in html # The renderer formats the rate with one decimal, so "30.0%" assert "30.0%" in html # 3 / 10 = 30% def test_render_analytics_html_escapes_xss(): a = HouseholdAnalytics( spend_this_month=100.0, purchase_count=1, top_items=[("", 50.0)], ) html = render_analytics_html(a) # The canonical name is title-cased; check for the escaped form assert "<script>" in html.lower() # The raw