""" Unit tests for pure helper functions extracted into routes.py. Covers: - Module-level constants: _TRAVEL_INTENTS, _TRAVEL_KEYWORDS_RE, _TERRAIN_KEYWORDS - _build_destination_info_card() - _build_nearby_attractions_card() - _build_travel_tips_card() - _classify_intent_safe() — exception path returns safe fallback - _extract_entities_safe() — exception path returns empty entities dict """ import re import pytest import asyncio from app.api.routes import ( _TRAVEL_INTENTS, _TRAVEL_KEYWORDS_RE, _TERRAIN_KEYWORDS, _build_destination_info_card, _build_nearby_attractions_card, _build_travel_tips_card, _classify_intent_safe, _extract_entities_safe, ) # --------------------------------------------------------------------------- # Module-level constants # --------------------------------------------------------------------------- class TestModuleConstants: def test_travel_intents_is_frozenset(self): assert isinstance(_TRAVEL_INTENTS, frozenset) def test_travel_intents_contains_core_intents(self): for intent in ("plan_trip", "find_hotel", "find_flight", "budget_advice"): assert intent in _TRAVEL_INTENTS, f"Expected '{intent}' in _TRAVEL_INTENTS" def test_travel_keywords_re_is_compiled_pattern(self): assert isinstance(_TRAVEL_KEYWORDS_RE, type(re.compile(""))) def test_travel_keywords_re_matches_english_keywords(self): assert _TRAVEL_KEYWORDS_RE.search("I want to book a hotel") is not None assert _TRAVEL_KEYWORDS_RE.search("looking for flight tickets") is not None def test_travel_keywords_re_matches_vietnamese_keywords(self): assert _TRAVEL_KEYWORDS_RE.search("đặt phòng khách sạn") is not None def test_travel_keywords_re_no_match_for_unrelated(self): # Should not match random non-travel text assert _TRAVEL_KEYWORDS_RE.search("hello world foo bar") is None def test_terrain_keywords_has_vi_and_en(self): assert "vi" in _TERRAIN_KEYWORDS assert "en" in _TERRAIN_KEYWORDS def test_terrain_keywords_vi_has_terrain_types(self): vi = _TERRAIN_KEYWORDS["vi"] for key in ("beach", "mountain", "city"): assert key in vi, f"Expected terrain type '{key}' in vi terrain keywords" # --------------------------------------------------------------------------- # _build_destination_info_card() # --------------------------------------------------------------------------- class TestBuildDestinationInfoCard: def _sample_dest(self, **overrides): base = { "id": "hanoi-001", "name": "Hà Nội", "name_en": "Hanoi", "description": "Capital of Vietnam", "description_en": "Capital of Vietnam", "best_months": [10, 11, 12], "tags": ["city", "heritage", "food"], "highlights": ["Hoan Kiem Lake", "Old Quarter"], "avg_budget_per_day": 500000, "lat": 21.028511, "lon": 105.804817, "image_url": "https://example.com/hanoi.jpg", "region": "North Vietnam", } base.update(overrides) return base def test_returns_correct_type(self): card = _build_destination_info_card(self._sample_dest()) assert card["type"] == "destination_info" def test_data_contains_required_keys(self): card = _build_destination_info_card(self._sample_dest()) data = card["data"] for key in ("id", "name", "name_en", "description", "best_months", "tags", "highlights", "avg_budget_per_day", "lat", "lon", "image_url", "region"): assert key in data, f"Missing key '{key}' in destination_info card data" def test_tags_limited_to_8(self): dest = self._sample_dest(tags=["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8", "t9"]) card = _build_destination_info_card(dest) assert len(card["data"]["tags"]) <= 8 def test_highlights_limited_to_5(self): dest = self._sample_dest(highlights=[f"h{i}" for i in range(10)]) card = _build_destination_info_card(dest) assert len(card["data"]["highlights"]) <= 5 def test_empty_destination_uses_defaults(self): """Empty dict should not raise — all fields fall back to defaults.""" card = _build_destination_info_card({}) assert card["type"] == "destination_info" assert card["data"]["id"] == "" assert card["data"]["best_months"] == [] def test_fallback_description_uses_description_vi(self): dest = {"description_vi": "Mô tả tiếng Việt"} card = _build_destination_info_card(dest) assert card["data"]["description"] == "Mô tả tiếng Việt" def test_budget_per_day_fallback(self): dest = {"budget_per_day": 300000} card = _build_destination_info_card(dest) assert card["data"]["avg_budget_per_day"] == 300000 # --------------------------------------------------------------------------- # _build_nearby_attractions_card() # --------------------------------------------------------------------------- class TestBuildNearbyAttractionsCard: def test_returns_correct_type(self): card = _build_nearby_attractions_card({"id": "x", "name": "Test"}) assert card["type"] == "nearby_attractions" def test_data_contains_required_keys(self): card = _build_nearby_attractions_card({"id": "x", "name": "Test"}) for key in ("destination_id", "destination_name", "attractions", "tags"): assert key in card["data"], f"Missing key '{key}'" def test_dict_activities_normalised_correctly(self): dest = { "id": "d1", "name": "Da Nang", "popular_activities": [ {"name": "Beach walk", "type": "outdoor", "cost_vnd": 0, "duration": "2h", "description": "Walk along the beach"}, ], } card = _build_nearby_attractions_card(dest) attractions = card["data"]["attractions"] assert len(attractions) == 1 assert attractions[0]["name"] == "Beach walk" assert attractions[0]["type"] == "outdoor" def test_string_activities_normalised_to_dict(self): dest = { "id": "d2", "name": "Hoi An", "popular_activities": ["Lantern festival", "Boat ride"], } card = _build_nearby_attractions_card(dest) attractions = card["data"]["attractions"] assert all(isinstance(a, dict) for a in attractions) assert attractions[0]["name"] == "Lantern festival" def test_attractions_limited_to_8(self): dest = { "popular_activities": [{"name": f"act{i}", "type": "tour"} for i in range(12)], } card = _build_nearby_attractions_card(dest) assert len(card["data"]["attractions"]) <= 8 def test_empty_destination_no_crash(self): card = _build_nearby_attractions_card({}) assert card["type"] == "nearby_attractions" assert card["data"]["attractions"] == [] # --------------------------------------------------------------------------- # _build_travel_tips_card() # --------------------------------------------------------------------------- class TestBuildTravelTipsCard: def test_returns_correct_type(self): card = _build_travel_tips_card({"id": "x", "name": "Test"}) assert card["type"] == "travel_tips" def test_data_contains_required_keys(self): card = _build_travel_tips_card({"id": "x", "name": "Test"}) for key in ("destination_id", "destination_name", "best_months", "transport", "visa", "safety_tips", "currency"): assert key in card["data"] def test_transport_as_list_coerced_to_empty_dict(self): """If transport is a list (invalid shape), coerce to empty dict.""" dest = {"transport": ["bus", "taxi"]} card = _build_travel_tips_card(dest) assert isinstance(card["data"]["transport"], dict) assert card["data"]["transport"] == {} def test_transport_as_dict_preserved(self): dest = {"transport": {"bus": "available", "taxi": "cheap"}} card = _build_travel_tips_card(dest) assert card["data"]["transport"] == {"bus": "available", "taxi": "cheap"} def test_visa_info_string_coerced_to_dict(self): dest = {"visa_info": "Visa-free for 30 days"} card = _build_travel_tips_card(dest) assert isinstance(card["data"]["visa"], dict) assert card["data"]["visa"] == {"note": "Visa-free for 30 days"} def test_visa_fallback_to_visa_key(self): dest = {"visa": {"requirement": "e-visa"}} card = _build_travel_tips_card(dest) assert card["data"]["visa"] == {"requirement": "e-visa"} def test_empty_destination_no_crash(self): card = _build_travel_tips_card({}) assert card["type"] == "travel_tips" # --------------------------------------------------------------------------- # _classify_intent_safe() — async helper # --------------------------------------------------------------------------- class TestClassifyIntentSafe: def _run(self, coro): return asyncio.run(coro) def test_normal_classifier_returns_result(self): class FakeClf: def predict(self, text, lang): return {"intent": "find_hotel", "confidence": 0.9, "method": "ml_model"} result = self._run(_classify_intent_safe("book a hotel", "en", FakeClf())) assert result["intent"] == "find_hotel" def test_classifier_raises_returns_fallback(self): class BrokenClf: def predict(self, text, lang): raise RuntimeError("model crashed") result = self._run(_classify_intent_safe("anything", "en", BrokenClf())) assert result["intent"] == "fallback" assert result["confidence"] == 0.0 assert result["method"] == "error_fallback" def test_fallback_dict_has_required_keys(self): class BrokenClf: def predict(self, text, lang): raise ValueError("bad input") result = self._run(_classify_intent_safe("text", "vi", BrokenClf())) assert {"intent", "confidence", "method"} <= set(result.keys()) # --------------------------------------------------------------------------- # _extract_entities_safe() — async helper # --------------------------------------------------------------------------- class TestExtractEntitiesSafe: def _run(self, coro): return asyncio.run(coro) def test_normal_ner_returns_result(self): class FakeNER: def extract(self, text, lang): return {"locations": ["Hanoi"], "destination_locations": []} result = self._run(_extract_entities_safe("trip to Hanoi", "en", FakeNER())) assert result["locations"] == ["Hanoi"] def test_ner_raises_returns_empty_entities(self): class BrokenNER: def extract(self, text, lang): raise RuntimeError("NER model crashed") result = self._run(_extract_entities_safe("text", "en", BrokenNER())) # Should return empty entities dict without raising assert isinstance(result, dict) assert "locations" in result assert result["locations"] == [] def test_empty_entities_has_all_expected_keys(self): class BrokenNER: def extract(self, text, lang): raise Exception("any error") result = self._run(_extract_entities_safe("text", "vi", BrokenNER())) expected_keys = { "locations", "origin", "destination_locations", "duration", "budget", "num_people", "hotel_type", "dietary_restrictions", "cuisine_preferences", "travel_style", "budget_priorities", "transport_preference", "time_preference", "activity_preferences", "is_multi_destination", } assert expected_keys <= set(result.keys())