whFalsa / tests /conftest.py
codeBOKER's picture
Fix trip seat filtering and enable null seats display
64075d7
Raw
History Blame Contribute Delete
22.9 kB
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})