import hashlib import hmac import json from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any import pytest from app.config import Settings from app.main import create_app from app.models.domain import ToolResult @pytest.fixture def settings() -> Settings: return Settings( supabase_url="https://example.supabase.co", supabase_service_role_key="supabase-secret", jina_api_key="jina-secret", groq_api_key="groq-secret", groq_model="groq-tool-model", openrouter_api_key="openrouter-secret", openrouter_model="openrouter-tool-model", whatsapp_verify_token="verify-token", whatsapp_app_secret="app-secret", whatsapp_access_token="wa-token", whatsapp_phone_number_id="123", admin_api_key="admin-secret", ) def signed_body(payload: dict[str, Any], secret: str) -> tuple[bytes, str]: body = json.dumps(payload, separators=(",", ":")).encode("utf-8") signature = "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return body, signature def whatsapp_payload(message_id: str = "wamid.1", text: str = "Hello") -> dict[str, Any]: return { "entry": [ { "changes": [ { "value": { "metadata": {"phone_number_id": "123"}, "contacts": [ { "wa_id": "967700000001", "profile": {"name": "Test Customer"}, } ], "messages": [ { "from": "967700000001", "id": message_id, "timestamp": "1790000000", "type": "text", "text": {"body": text}, } ], } } ] } ] } class DummyConversation: def __init__(self) -> None: self.calls = [] async def handle_inbound_message(self, inbound: Any) -> str: self.calls.append(inbound) return "ok" class DummyAdmin: async def seed_info(self) -> int: return 2 async def sync_trips(self) -> int: return 3 @pytest.fixture def test_app(settings: Settings) -> Any: container = SimpleNamespace( settings=settings, conversation=DummyConversation(), admin=DummyAdmin(), ) app = create_app(settings=settings, container=container) app.state.test_container = container return app class FakeRepository: def __init__(self) -> None: self.customers_by_remote_jid: dict[str, dict[str, Any]] = {} self.customers_by_phone: dict[str, dict[str, Any]] = {} self.drivers_by_remote_jid: dict[str, dict[str, Any]] = {} self.driver_cars_by_driver: dict[str, list[dict[str, Any]]] = {} self.latest_trips_by_driver: dict[str, dict[str, Any]] = {} self.created_drivers: list[dict[str, Any]] = [] self.created_trips: list[dict[str, Any]] = [] self.trip_embeddings: list[dict[str, Any]] = [] self.messages: list[dict[str, Any]] = [] self.trip_selections: list[dict[str, Any]] = [] self.notification_updates: list[dict[str, Any]] = [] self.trips_by_id: dict[str, dict[str, Any]] = {} self.active_search_results: list[dict[str, Any]] = [] self.info_search_results: list[dict[str, Any]] = [] self.trip_vector_search_results: list[dict[str, Any]] = [] self.vector_trip_search_calls: list[dict[str, Any]] = [] async def upsert_customer( self, *, remote_jid: str | None = None, name: str | None = None, preferred_language: str | None = None, phone_number: str | None = None, registered: bool = True, ) -> dict[str, Any]: # Always check by phone_number first to avoid duplicates if phone_number: existing = await self.get_customer_by_phone_number(phone_number) if existing: if remote_jid and not existing.get("remoteJid"): existing["remoteJid"] = remote_jid if name is not None and not existing.get("name"): existing["name"] = name if registered is not None: existing["registered"] = registered if phone_number is not None: existing["phone_number"] = phone_number return existing jid = remote_jid or phone_number if not jid: raise ValueError("remote_jid or phone_number is required") customer = self.customers_by_remote_jid.get(jid) if customer is None: customer = { "id": f"cust-{len(self.customers_by_remote_jid) + 1}", "remoteJid": remote_jid, "name": name, "preferred_language": preferred_language, "phone_number": phone_number, "user_mode": None, "session_data": {}, "registered": registered, } self.customers_by_remote_jid[jid] = customer if phone_number: self.customers_by_phone[phone_number] = customer elif name: customer["name"] = name if phone_number: customer["phone_number"] = phone_number self.customers_by_phone[phone_number] = customer customer["registered"] = registered return customer async def update_customer_user_mode( self, *, customer_id: str, user_mode: str, ) -> dict[str, Any]: for customer in self.customers_by_remote_jid.values(): if customer["id"] == customer_id: customer["user_mode"] = user_mode return customer raise KeyError(customer_id) async def update_customer_name( self, *, customer_id: str, name: str, ) -> dict[str, Any]: for customer in self.customers_by_remote_jid.values(): if customer["id"] == customer_id: customer["name"] = name return customer raise KeyError(customer_id) async def get_customer_by_phone_number( self, phone_number: str, ) -> dict[str, Any] | None: # Try exact match first result = self.customers_by_phone.get(phone_number) if result: return result # Try each individual phone from /-separated incoming number phones = [p.strip() for p in phone_number.split("/")] if "/" in phone_number else [phone_number] for phone in phones: result = self.customers_by_phone.get(phone) if result: return result # Check if any stored customer has a /-separated phone_number containing our phone for customer in self.customers_by_remote_jid.values(): stored = customer.get("phone_number") or "" for phone in phones: if phone in stored.split("/"): return customer return None async def create_unregistered_driver_entities( self, *, phone_number: str, driver_name: str | None, car_type: str | None, departure: str, destination: str, departure_date: Any, departure_time: str, available_seats: int | None, total_seats: int | None, price: float, ) -> dict[str, Any]: customer = await self.upsert_customer( remote_jid=None, name=driver_name, phone_number=phone_number, registered=False, ) driver = await self.create_driver(customer_id=str(customer["id"])) car = await self.create_driver_car( driver_id=str(driver["id"]), car_type=car_type or "غير معروف", seat_count=total_seats, ) trip = await self.create_driver_trip( driver_id=str(driver["id"]), car_id=str(car["id"]), departure=departure, destination=destination, departure_date=departure_date, departure_time=departure_time, available_seats=available_seats, total_seats=total_seats, price=price, ) return trip async def create_unregistered_driver_trip( self, *, driver_id: str | None, phone_number: str, driver_name: str | None, car_type: str | None, departure: str, destination: str, departure_date: Any, departure_time: str, available_seats: int | None, total_seats: int | None, price: float, ) -> dict[str, Any]: if not driver_id: return await self.create_unregistered_driver_entities( phone_number=phone_number, driver_name=driver_name, car_type=car_type, departure=departure, destination=destination, departure_date=departure_date, departure_time=departure_time, available_seats=available_seats, total_seats=total_seats, price=price, ) cars = self.driver_cars_by_driver.get(driver_id, []) car = cars[0] if cars else None if not car: car = await self.create_driver_car( driver_id=driver_id, car_type=car_type or "غير معروف", seat_count=total_seats, ) trip = await self.create_driver_trip( driver_id=driver_id, car_id=str(car["id"]), departure=departure, destination=destination, departure_date=departure_date, departure_time=departure_time, available_seats=available_seats, total_seats=total_seats, price=price, ) return trip def _find_customer_by_id(self, customer_id: str) -> dict[str, Any] | None: for customer in self.customers_by_remote_jid.values(): if customer["id"] == customer_id: return customer return None async def get_customer_session(self, customer_id: str) -> dict[str, Any]: customer = self._find_customer_by_id(customer_id) if customer: return dict(customer.get("session_data") or {}) return {} async def update_customer_session( self, *, customer_id: str, session_data: dict[str, Any], ) -> dict[str, Any]: customer = self._find_customer_by_id(customer_id) if customer: customer["session_data"] = session_data return customer raise KeyError(customer_id) async def set_customer_session_field( self, *, customer_id: str, key: str, value: Any, ) -> dict[str, Any]: session_data = await self.get_customer_session(customer_id) session_data[key] = value return await self.update_customer_session( customer_id=customer_id, session_data=session_data, ) async def clear_customer_session_field( self, *, customer_id: str, key: str, ) -> dict[str, Any]: session_data = await self.get_customer_session(customer_id) session_data.pop(key, None) return await self.update_customer_session( customer_id=customer_id, session_data=session_data, ) async def message_exists(self, whatsapp_message_id: str) -> bool: return any( message.get("whatsapp_message_id") == whatsapp_message_id for message in self.messages ) async def create_message( self, *, customer_id: str, sender_type: str, message: str, whatsapp_message_id: str | None = None, metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: row = { "id": f"msg-{len(self.messages) + 1}", "customer_id": customer_id, "sender_type": sender_type, "message": message, "whatsapp_message_id": whatsapp_message_id, "metadata": metadata or {}, "created_at": ( datetime(2026, 5, 21, tzinfo=UTC) + timedelta(seconds=len(self.messages)) ).isoformat(), } self.messages.append(row) return row async def get_recent_context_messages( self, *, customer_id: str, current_message_id: str, limit: int = 4, ) -> list[dict[str, Any]]: current_index = next( index for index, row in enumerate(self.messages) if row["id"] == current_message_id ) prior = [ row for row in self.messages[:current_index] if row["customer_id"] == customer_id ][-limit:] return prior + [self.messages[current_index]] async def get_trips_by_ids(self, trip_ids: list[str]) -> list[dict[str, Any]]: return [self.trips_by_id[trip_id] for trip_id in trip_ids if trip_id in self.trips_by_id] async def search_active_trips(self, **_: Any) -> list[dict[str, Any]]: return self.active_search_results async def search_info_chunks_by_vector( self, *, query_embedding: list[float], match_count: int = 5, ) -> list[dict[str, Any]]: return self.info_search_results[:match_count] async def search_trips_by_vector(self, **kwargs: Any) -> list[dict[str, Any]]: self.vector_trip_search_calls.append(kwargs) match_count = int(kwargs.get("match_count") or 10) return self.trip_vector_search_results[:match_count] async def create_trip_selection( self, *, customer_id: str, trip_id: str, requested_seats: int, notes: str | None, ) -> dict[str, Any]: selection = { "id": f"sel-{len(self.trip_selections) + 1}", "customer_id": customer_id, "trip_id": trip_id, "requested_seats": requested_seats, "notes": notes, } self.trip_selections.append(selection) return selection async def update_selection_notification( self, *, selection_id: str, status: str, metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: update = {"selection_id": selection_id, "status": status, "metadata": metadata} self.notification_updates.append(update) return update async def count_trip_selections(self, trip_id: str) -> int: return sum(1 for s in self.trip_selections if s["trip_id"] == trip_id) async def get_driver_by_phone(self, remote_jid: str) -> dict[str, Any] | None: return self.drivers_by_remote_jid.get(remote_jid) async def get_driver_by_remoteJid(self, remote_jid: str) -> dict[str, Any] | None: return self.drivers_by_remote_jid.get(remote_jid) async def get_driver_by_phone_number( self, phone_number: str, ) -> dict[str, Any] | None: """Look up a driver by phone_number column. Supports multiple phones separated by '/': tries each one until a match is found. """ if "/" in phone_number: phones = phone_number.split("/") for phone in phones: result = await self._get_driver_by_single_phone(phone.strip()) if result: return result return None return await self._get_driver_by_single_phone(phone_number) async def _get_driver_by_single_phone( self, phone: str, ) -> dict[str, Any] | None: for customer in self.customers_by_remote_jid.values(): stored_phone = customer.get("phone_number") or "" # Match exact or within /-separated list if phone == stored_phone or phone in stored_phone.split("/"): for driver in self.drivers_by_remote_jid.values(): if driver.get("customer_id") == customer["id"]: return driver return None async def create_driver(self, *, customer_id: str) -> dict[str, Any]: customer = next( (customer for customer in self.customers_by_remote_jid.values() if customer["id"] == customer_id), None, ) if customer is None: raise KeyError(customer_id) driver = { "id": f"driver-{len(self.drivers_by_remote_jid) + 1}", "customer_id": customer_id, "name": customer.get("name"), "status": "active", "customers": customer, } self.drivers_by_remote_jid[customer["remoteJid"]] = driver self.created_drivers.append(driver) return driver async def get_driver_latest_trip(self, driver_id: str) -> dict[str, Any] | None: return self.latest_trips_by_driver.get(driver_id) async def list_driver_cars(self, driver_id: str) -> list[dict[str, Any]]: return self.driver_cars_by_driver.get(driver_id, []) async def list_driver_trips(self, driver_id: str) -> list[dict[str, Any]]: trips = [ trip for trip in self.trips_by_id.values() if str(trip.get("driver_id")) == driver_id and trip.get("status") == "active" ] return sorted( trips, key=lambda trip: ( str(trip.get("departure_date") or ""), {"morning": 0, "noon": 1, "night": 2}.get(str(trip.get("departure_time") or ""), 99), ), ) async def create_driver_car( self, *, driver_id: str, car_type: str, plate_number: str | None = None, seat_count: int | None = None, ) -> dict[str, Any]: car_id = f"car-{sum(len(cars) for cars in self.driver_cars_by_driver.values()) + 1}" car = { "id": car_id, "driver_id": driver_id, "car_type": car_type, "plate_number": plate_number, "seat_count": seat_count, } self.driver_cars_by_driver.setdefault(driver_id, []).append(car) return car async def create_driver_trip(self, **kwargs: Any) -> dict[str, Any]: trip_id = f"trip-{len(self.created_trips) + 1}" driver_id = str(kwargs["driver_id"]) car_id = kwargs.get("car_id") cars = self.driver_cars_by_driver.get(driver_id, []) driver = next( (row for row in self.drivers_by_remote_jid.values() if row["id"] == driver_id), {"name": "Driver"}, ) matched_car = next( (car for car in cars if str(car["id"]) == str(car_id)), {"car_type": "SUV"}, ) trip = { "id": trip_id, "status": "active", "drivers": driver, "driver_cars": matched_car, **kwargs, } self.created_trips.append(trip) self.trips_by_id[trip_id] = trip self.latest_trips_by_driver[driver_id] = trip return trip async def get_trip_by_id(self, trip_id: str) -> dict[str, Any] | None: return self.trips_by_id.get(trip_id) async def update_driver_trip( self, trip_id: str, updates: dict[str, Any], ) -> dict[str, Any]: trip = self.trips_by_id[trip_id] trip.update(updates) return trip async def get_driver_trip_by_datetime( self, *, driver_id: str, departure_date: Any, departure_time: str, ) -> dict[str, Any] | None: for trip in self.trips_by_id.values(): if ( str(trip.get("driver_id")) == str(driver_id) and str(trip.get("departure_date")) == str(departure_date) and str(trip.get("departure_time")) == departure_time ): return trip return None async def cancel_driver_trip(self, trip_id: str) -> dict[str, Any]: trip = self.trips_by_id[trip_id] trip["status"] = "cancelled" return trip async def delete_trip_embedding(self, trip_id: str) -> None: self.trip_embeddings = [ row for row in self.trip_embeddings if row.get("trip_id") != trip_id ] async def upsert_trip_embeddings(self, trip_embeddings: list[dict[str, Any]]) -> int: self.trip_embeddings.extend(trip_embeddings) return len(trip_embeddings) class FakeEmbeddings: def __init__(self) -> None: self.query_texts: list[str] = [] self.passage_texts: list[list[str]] = [] self.query_embedding = [0.1, 0.2, 0.3] async def embed_query(self, text: str) -> list[float]: self.query_texts.append(text) return self.query_embedding async def embed_passages(self, texts: list[str]) -> list[list[float]]: self.passage_texts.append(texts) return [self.query_embedding for _ in texts] class FakeWhatsApp: def __init__(self, *, fail: bool = False) -> None: self.fail = fail self.sent: list[tuple[str, str]] = [] self.interactive_lists: list[tuple[str, dict[str, Any]]] = [] async def send_text(self, to_phone: str, text: str) -> dict[str, Any]: if self.fail: raise RuntimeError("send failed") self.sent.append((to_phone, text)) return {"messages": [{"id": "sent"}]} async def send_interactive_list( self, to_phone: str, interactive: dict[str, Any], ) -> dict[str, Any]: if self.fail: raise RuntimeError("send failed") self.interactive_lists.append((to_phone, interactive)) return {"messages": [{"id": "sent-interactive"}]} class FakeAI: def __init__(self, reply: str = "Here is your reply") -> None: self.reply = reply self.calls: list[dict[str, Any]] = [] async def generate_reply( self, *, messages: list[dict[str, Any]], tools: list[dict[str, Any]], registry: Any, ) -> str: self.calls.append({"messages": messages, "tools": tools, "registry": registry}) return self.reply async def ok_tool(_: dict[str, Any]) -> ToolResult: return ToolResult(ok=True, data={"value": 42})