""" Smoke tests for the WhatsApp bot — runs entirely in mock mode (no live Gemini/Twilio). """ from __future__ import annotations import os import sys # Force MOCK_MODE before importing any whatsapp_bot module so settings load mocked os.environ["MOCK_MODE"] = "true" # Ensure project root importable _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, load_real_data from whatsapp_bot.gemini_client import GeminiClient from whatsapp_bot.handlers import ( MISSING_SLOT_PREFIX, OUT_OF_COVERAGE_PREFIX, EngineData, dispatch, ) from whatsapp_bot.intent import ( INTENT_CARI_PEMBELI, INTENT_CARI_PENJUAL, INTENT_FALLBACK, INTENT_HARGA_LOOKUP, classify, ) from whatsapp_bot.twilio_client import make_twiml_response @pytest.fixture(scope="module") def data(): return EngineData(load_all_sample_data()) @pytest.fixture(scope="module") def gemini(): return GeminiClient(mock=True) # ============================================================================= # Intent classification — mock heuristic # ============================================================================= class TestIntentClassification: def test_harga_lookup(self, gemini, data): intent = classify( "Harga cabai di Malang?", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_HARGA_LOOKUP assert intent.commodity == "cabai_merah" assert intent.kabupaten_id is not None assert "malang" in intent.kabupaten_name.lower() def test_cari_pembeli_with_volume(self, gemini, data): intent = classify( "Saya punya 50 ton cabai di Kediri, cari pembeli", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_CARI_PEMBELI assert intent.commodity == "cabai_merah" assert intent.volume_tons == 50.0 def test_cari_penjual(self, gemini, data): intent = classify( "Butuh 100 ton beras untuk Surabaya", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_CARI_PENJUAL assert intent.commodity == "beras_premium" assert intent.volume_tons == 100.0 def test_fallback_for_greeting(self, gemini, data): intent = classify("Halo, apa kabar?", gemini, data.kabupaten, data.komoditas) assert intent.name == INTENT_FALLBACK def test_kabupaten_normalization(self, gemini, data): # "Kota Malang" should resolve same as "Malang" intent = classify( "Harga beras Kota Malang", gemini, data.kabupaten, data.komoditas, ) assert intent.kabupaten_id is not None # ============================================================================= # Bahasa Jawa ngoko — keyword shim (pre-Sahabat-AI / M4) # ============================================================================= class TestJavaneseShim: def test_jawa_harga_lookup(self, gemini, data): # "Pira regane lombok ing Malang?" = "Berapa harga cabai di Malang?" intent = classify( "Pira regane lombok ing Malang?", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_HARGA_LOOKUP assert intent.commodity == "cabai_merah" assert "malang" in intent.kabupaten_name.lower() def test_jawa_cari_pembeli(self, gemini, data): # "Aku duwe 50 ton brambang ing Probolinggo, golek pembeli" # = "Saya punya 50 ton bawang merah di Probolinggo, cari pembeli" intent = classify( "Aku duwe 50 ton brambang ing Probolinggo, golek pembeli", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_CARI_PEMBELI assert intent.commodity == "bawang_merah" assert intent.volume_tons == 50.0 def test_jawa_cari_penjual(self, gemini, data): # "Butuh 100 ton sega kanggo Surabaya" = "...beras untuk Surabaya" intent = classify( "Butuh 100 ton sega kanggo Surabaya", gemini, data.kabupaten, data.komoditas, ) assert intent.name == INTENT_CARI_PENJUAL assert intent.commodity == "beras_premium" assert intent.volume_tons == 100.0 # ============================================================================= # Handler dispatch — full pipeline # ============================================================================= class TestHandlerDispatch: def test_harga_lookup_returns_text(self, gemini, data): intent = classify( "Harga cabai di Malang", gemini, data.kabupaten, data.komoditas, ) reply = dispatch(intent, data, gemini) assert isinstance(reply, str) assert len(reply) > 0 # Should mention either a price or "tidak ada data" assert any(token in reply.lower() for token in ["rp", "median", "tidak ada", "belum ada"]) def test_cari_pembeli_runs_engine(self, gemini, data): intent = classify( "Cari pembeli cabai Kediri", gemini, data.kabupaten, data.komoditas, ) reply = dispatch(intent, data, gemini) assert isinstance(reply, str) # Either some matches or a "no match" message assert any(token in reply.lower() for token in ["pembeli", "ton", "belum ada"]) def test_cari_penjual_runs_engine(self, gemini, data): intent = classify( "Butuh 100 ton beras untuk Surabaya", gemini, data.kabupaten, data.komoditas, ) reply = dispatch(intent, data, gemini) assert isinstance(reply, str) assert any(token in reply.lower() for token in ["supplier", "ton", "belum ada"]) def test_missing_slot_returns_help(self, gemini, data): # No commodity, no kabupaten — should ask for more info intent = classify("Harga?", gemini, data.kabupaten, data.komoditas) reply = dispatch(intent, data, gemini) assert any(token in reply.lower() for token in ["maaf", "format", "contoh", "halo"]) def test_fallback_returns_help_message(self, gemini, data): intent = classify("Halo", gemini, data.kabupaten, data.komoditas) reply = dispatch(intent, data, gemini) assert "agriflow" in reply.lower() # ============================================================================= # Out-of-coverage commodity — real BPS dataset # # The fixtures above load the synthetic file, which carries 13 commodities. # Production serves load_real_data(): 6 commodities from BPS Jatim. So # "kentang" is answerable in the fixture and NOT answerable in production, # and only the real dataset can exercise what a live user actually hits. # ============================================================================= class TestOutOfCoverageCommodity: @pytest.fixture(scope="class") def real_data(self): return EngineData(load_real_data()) def test_names_the_gap_instead_of_asking_again(self, gemini, real_data): intent = classify( "Harga kentang di Malang", gemini, real_data.kabupaten, real_data.komoditas, ) reply = dispatch(intent, real_data, gemini) # The user DID name a commodity, so the reply must not claim otherwise. assert "butuh info tambahan" not in reply.lower() assert "kentang" in reply.lower() assert "belum tercakup" in reply.lower() # And it must point at something that will work next time. assert "cabai" in reply.lower() def test_still_asks_when_no_commodity_named(self, gemini, real_data): intent = classify( "Harga di Malang", gemini, real_data.kabupaten, real_data.komoditas, ) reply = dispatch(intent, real_data, gemini) assert reply.startswith(MISSING_SLOT_PREFIX) def test_out_of_coverage_is_not_billed(self, gemini, real_data): intent = classify( "Cari pembeli 50 ton tomat Kediri", gemini, real_data.kabupaten, real_data.komoditas, ) reply = dispatch(intent, real_data, gemini) assert reply.startswith(OUT_OF_COVERAGE_PREFIX) # ============================================================================= # TwiML response shape # ============================================================================= class TestTwiMLResponse: def test_basic_response(self): xml = make_twiml_response("Hello world") assert xml.startswith('" in xml assert "Hello world" in xml def test_xml_escaping(self): # Critical — user input could contain XML control chars xml = make_twiml_response("Price < 5000 & quantity > 0") assert "<" in xml assert "&" in xml assert ">" in xml def test_empty_message(self): xml = make_twiml_response("") assert "" in xml # ============================================================================= # FastAPI route smoke tests (TestClient) # ============================================================================= class TestFastAPIRoutes: @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 # Context manager triggers lifespan startup → engine data loaded with TestClient(app) as c: yield c def test_health(self, client): r = client.get("/health") assert r.status_code == 200 body = r.json() assert body["status"] == "ok" assert body["mock_mode"] is True assert body["data_loaded"] is True assert body["kabupaten_count"] > 0 def test_chat_debug(self, client): r = client.post("/chat", json={"message": "Harga cabai di Malang"}) assert r.status_code == 200 body = r.json() assert "reply" in body assert len(body["reply"]) > 0 def test_chat_empty_message_rejected(self, client): r = client.post("/chat", json={"message": ""}) assert r.status_code == 400 def test_whatsapp_webhook_returns_twiml(self, client): r = client.post( "/whatsapp", data={"Body": "Harga cabai di Malang", "From": "whatsapp:+6281234567890"}, ) assert r.status_code == 200 assert r.headers["content-type"].startswith("application/xml") assert "" in r.text assert "" in r.text