Spaces:
Running
Running
| """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, | |
| ) | |
| 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=[("<script>alert(1)</script>", 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 <script> tag must never appear unescaped | |
| assert "<script>alert" not in html.lower() | |