""" tests/test_forecast_anomaly_api.py Tests for the new /api/v1/forecast and /api/v1/anomalies endpoints, plus intent classification and handler dispatch for INTENT_FORECAST and INTENT_ANOMALI. All tests run in mock mode (no Gemini, no Twilio, no TimesFM at runtime). Forecast file: sample_data/forecasts/forecast_all.json (precomputed offline). Anomaly file: sample_data/anomalies/anomalies_all.json (precomputed offline). """ from __future__ import annotations import json import os import sys from pathlib import Path os.environ["MOCK_MODE"] = "true" _HERE = os.path.dirname(os.path.abspath(__file__)) _ROOT = os.path.dirname(_HERE) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) import pytest from sample_data.loader import load_all_sample_data from whatsapp_bot.gemini_client import GeminiClient from whatsapp_bot.handlers import EngineData, dispatch from whatsapp_bot.intent import ( INTENT_FORECAST, INTENT_ANOMALI, classify, ) PRICE_DIR = Path(_ROOT) / "sample_data" / "price_history" FORECASTS_PATH = Path(_ROOT) / "sample_data" / "forecasts" / "forecast_all.json" ANOMALIES_PATH = Path(_ROOT) / "sample_data" / "anomalies" / "anomalies_all.json" # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture(scope="module") def data(): return EngineData(load_all_sample_data()) @pytest.fixture(scope="module") def gemini(): return GeminiClient(mock=True) # ============================================================================= # A. Precomputed file integrity # ============================================================================= class TestPrecomputedFiles: """Verify the JSON files exist and have correct schema.""" def test_forecast_file_exists(self): assert FORECASTS_PATH.exists(), ( f"Forecast file missing: {FORECASTS_PATH}\n" "Run: python analysis/forecast_timesfm.py" ) def test_anomaly_file_exists(self): assert ANOMALIES_PATH.exists(), ( f"Anomaly file missing: {ANOMALIES_PATH}\n" "Run: python analysis/precompute_anomalies.py" ) def test_forecast_file_nonempty(self): with FORECASTS_PATH.open(encoding="utf-8") as fh: data = json.load(fh) assert isinstance(data, list) and len(data) > 0 def test_anomaly_file_nonempty(self): with ANOMALIES_PATH.open(encoding="utf-8") as fh: data = json.load(fh) assert isinstance(data, list) and len(data) > 0 def test_forecast_record_schema(self): """Each forecast record must carry required keys.""" with FORECASTS_PATH.open(encoding="utf-8") as fh: records = json.load(fh) required = { "commodity_code", "city_id", "city_name", "method", "generated_at", "horizon_days", "history_end_date", "forecasts", } for r in records[:5]: missing = required - set(r.keys()) assert not missing, f"Forecast record missing keys: {missing}" def test_forecast_point_schema(self): """Each forecast point must have date, point, p10, p90.""" with FORECASTS_PATH.open(encoding="utf-8") as fh: records = json.load(fh) for r in records[:3]: for pt in r["forecasts"][:3]: assert "date" in pt assert "point" in pt assert "p10" in pt assert "p90" in pt assert pt["p10"] <= pt["point"] <= pt["p90"], ( f"CI ordering violated: p10={pt['p10']} point={pt['point']} p90={pt['p90']}" ) def test_anomaly_record_schema(self): """Each anomaly record must carry required keys.""" with ANOMALIES_PATH.open(encoding="utf-8") as fh: records = json.load(fh) required = { "date", "price", "rolling_median", "deviation_pct", "type", "score", "commodity_code", "city_id", "city_name", "persistent", } for r in records[:10]: missing = required - set(r.keys()) assert not missing, f"Anomaly record missing keys: {missing}" def test_anomaly_types_valid(self): with ANOMALIES_PATH.open(encoding="utf-8") as fh: records = json.load(fh) for r in records[:50]: assert r["type"] in ("SPIKE", "DROP") def test_forecast_method_labelled(self): """method must be 'timesfm_2.0' or 'seasonal_naive_baseline' — never blank.""" with FORECASTS_PATH.open(encoding="utf-8") as fh: records = json.load(fh) valid_methods = {"timesfm_2.0", "seasonal_naive_baseline"} for r in records: assert r["method"] in valid_methods, ( f"Unexpected method label: {r['method']!r}" ) def test_forecast_horizon_is_30(self): with FORECASTS_PATH.open(encoding="utf-8") as fh: records = json.load(fh) for r in records[:5]: assert len(r["forecasts"]) == 30 assert r["horizon_days"] == 30 def test_forecast_cabai_rawit_surabaya_present(self): """Key series must be present.""" with FORECASTS_PATH.open(encoding="utf-8") as fh: records = json.load(fh) match = next( (r for r in records if r["commodity_code"] == "cabai_rawit" and r["city_id"] == "3578"), None, ) assert match is not None, "cabai_rawit / Surabaya (3578) forecast missing" def test_anomaly_bawang_merah_present(self): with ANOMALIES_PATH.open(encoding="utf-8") as fh: records = json.load(fh) bm = [r for r in records if r["commodity_code"] == "bawang_merah"] assert len(bm) > 0 # ============================================================================= # B. FastAPI endpoint tests (TestClient) # ============================================================================= class TestForecastEndpoint: @pytest.fixture(scope="class") def client(self): try: from fastapi.testclient import TestClient except ImportError: pytest.skip("fastapi not installed") from whatsapp_bot.server import app with TestClient(app) as c: yield c def test_forecast_returns_200_for_valid_pair(self, client): r = client.get("/api/v1/forecast?commodity=cabai_rawit&city=3578") assert r.status_code == 200, r.text def test_forecast_response_schema(self, client): r = client.get("/api/v1/forecast?commodity=bawang_merah&city=3509") assert r.status_code == 200 body = r.json() assert "commodity_code" in body assert "city_id" in body assert "method" in body assert "forecasts" in body assert isinstance(body["forecasts"], list) assert len(body["forecasts"]) == 30 def test_forecast_point_ci_ordering(self, client): r = client.get("/api/v1/forecast?commodity=telur_ayam&city=3578") assert r.status_code == 200 for pt in r.json()["forecasts"]: assert pt["p10"] <= pt["point"] <= pt["p90"] def test_forecast_404_for_unknown_pair(self, client): r = client.get("/api/v1/forecast?commodity=nangka&city=9999") assert r.status_code == 404 def test_forecast_missing_params_422(self, client): r = client.get("/api/v1/forecast?commodity=cabai_rawit") assert r.status_code == 422 # FastAPI validation def test_forecast_method_field_present(self, client): r = client.get("/api/v1/forecast?commodity=beras_medium&city=3573") assert r.status_code == 200 assert r.json()["method"] in ("timesfm_2.0", "seasonal_naive_baseline") class TestAnomaliesEndpoint: @pytest.fixture(scope="class") def client(self): try: from fastapi.testclient import TestClient except ImportError: pytest.skip("fastapi not installed") from whatsapp_bot.server import app with TestClient(app) as c: yield c def test_anomalies_no_filter_returns_200(self, client): r = client.get("/api/v1/anomalies") assert r.status_code == 200 def test_anomalies_response_schema(self, client): r = client.get("/api/v1/anomalies?limit=10") assert r.status_code == 200 body = r.json() assert "count" in body assert "method" in body assert "anomalies" in body assert body["method"] == "shesd_v2" assert isinstance(body["anomalies"], list) assert len(body["anomalies"]) <= 10 def test_anomalies_commodity_filter(self, client): r = client.get("/api/v1/anomalies?commodity=cabai_rawit&limit=20") assert r.status_code == 200 for a in r.json()["anomalies"]: assert a["commodity_code"] == "cabai_rawit" def test_anomalies_city_filter(self, client): r = client.get("/api/v1/anomalies?city=3578&limit=20") assert r.status_code == 200 for a in r.json()["anomalies"]: assert a["city_id"] == "3578" def test_anomalies_since_filter(self, client): r = client.get("/api/v1/anomalies?since=2024-01-01&limit=50") assert r.status_code == 200 for a in r.json()["anomalies"]: assert a["date"] >= "2024-01-01" def test_anomalies_record_keys(self, client): r = client.get("/api/v1/anomalies?limit=5") required = { "date", "price", "deviation_pct", "type", "score", "commodity_code", "city_id", } for a in r.json()["anomalies"]: missing = required - set(a.keys()) assert not missing, f"Anomaly record missing keys: {missing}" def test_anomalies_limit_respected(self, client): r = client.get("/api/v1/anomalies?limit=7") assert r.status_code == 200 assert len(r.json()["anomalies"]) <= 7 def test_anomalies_nonzero_count(self, client): r = client.get("/api/v1/anomalies?limit=100") assert r.status_code == 200 assert r.json()["count"] > 0 # ============================================================================= # C. Intent classification — forecast and anomali # ============================================================================= class TestNewIntentClassification: def test_forecast_intent_prediksi(self, gemini, data): intent = classify( "Prediksi harga cabai rawit Surabaya bulan depan", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_FORECAST assert intent.commodity is not None def test_forecast_intent_ramalan(self, gemini, data): intent = classify( "Ramalan harga bawang merah Malang", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_FORECAST def test_forecast_intent_perkiraan(self, gemini, data): intent = classify( "Perkiraan harga telur Kediri minggu depan", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_FORECAST def test_anomali_intent_lonjakan(self, gemini, data): intent = classify( "Lonjakan harga bawang merah Surabaya", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_ANOMALI def test_anomali_intent_anomali_keyword(self, gemini, data): intent = classify( "Anomali harga cabai rawit", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_ANOMALI def test_anomali_intent_spike(self, gemini, data): intent = classify( "Ada spike harga beras Malang?", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_ANOMALI def test_forecast_commodity_extracted(self, gemini, data): intent = classify( "Forecast cabai rawit Madiun", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_FORECAST # commodity may or may not resolve through the engine lookup; # test that we at least get a commodity-like string # (the mock heuristic resolves cabai rawit → cabai_rawit via _COMMODITY_KEYWORDS) # ============================================================================= # D. Handler dispatch — forecast and anomali # ============================================================================= class TestNewHandlerDispatch: def test_forecast_dispatch_returns_text(self, gemini, data): intent = classify( "Prediksi harga cabai rawit Surabaya", gemini, data.kabupaten, data.komoditas, ) reply = dispatch(intent, data, gemini) assert isinstance(reply, str) and len(reply) > 0 def test_forecast_dispatch_contains_price(self, gemini, data): intent = classify( "Prediksi harga bawang merah Surabaya", gemini, data.kabupaten, data.komoditas, ) reply = dispatch(intent, data, gemini) # Should mention a price (Rp ...) or a help message has_price_or_help = any( tok in reply.lower() for tok in ["rp ", "prediksi", "tersedia", "kota"] ) assert has_price_or_help, f"Unexpected reply: {reply}" def test_anomali_dispatch_returns_text(self, gemini, data): intent = classify( "Anomali harga cabai rawit", gemini, data.kabupaten, data.komoditas, ) reply = dispatch(intent, data, gemini) assert isinstance(reply, str) and len(reply) > 0 def test_anomali_dispatch_contains_anomaly_info(self, gemini, data): from whatsapp_bot.intent import Intent, INTENT_ANOMALI # Use direct Intent construction to guarantee the anomali handler path intent = Intent( name=INTENT_ANOMALI, slots={"commodity": "bawang_merah", "kabupaten_id": "3578", "kabupaten_name": "Surabaya"}, raw_message="Lonjakan harga bawang merah Surabaya", ) reply = dispatch(intent, data, gemini) has_info = any( tok in reply.lower() for tok in ["spike", "drop", "anomali", "deviasi", "total"] ) assert has_info, f"Reply should mention anomaly info: {reply}" def test_forecast_no_commodity_returns_help(self, gemini, data): """If commodity missing, reply should guide user.""" from whatsapp_bot.intent import Intent, INTENT_FORECAST intent = Intent( name=INTENT_FORECAST, slots={"commodity": None, "kabupaten_id": None, "kabupaten_name": None}, raw_message="Prediksi harga", ) reply = dispatch(intent, data, gemini) assert isinstance(reply, str) and len(reply) > 0 def test_anomali_no_filter_returns_results(self, gemini, data): """Anomali without commodity filter should return data from precomputed file.""" from whatsapp_bot.intent import Intent, INTENT_ANOMALI intent = Intent( name=INTENT_ANOMALI, slots={"commodity": None, "kabupaten_id": None, "kabupaten_name": None}, raw_message="Anomali harga", ) reply = dispatch(intent, data, gemini) # Should return anomaly data or empty message — never crash assert isinstance(reply, str) and len(reply) > 0 # ============================================================================= # E. TimesFM import guard (CI safety) # ============================================================================= class TestTimesFMImportGuard: def test_timesfm_importorskip(self): """ This test is skipped in CI if timesfm is not installed. Guards against the CI failure pattern documented in CLAUDE.md (pandas lesson). """ timesfm = pytest.importorskip( "timesfm", reason="timesfm not installed — skip TimesFM-specific tests in CI", ) # If we get here, timesfm is available; verify it has the expected API assert hasattr(timesfm, "TimesFm") or hasattr(timesfm, "TimesFM"), ( "timesfm module found but missing expected TimesFm/TimesFM class" )