from __future__ import annotations from app.models import ChatSession, KnowledgeChunk, KnowledgeSource from app.tools import registry from app.tools.registry import ToolContext class FakeShopify: def __init__(self, orders_payload=None, products_payload=None): self.orders_payload = orders_payload or {"orders": {"edges": []}} self.products_payload = products_payload or {"products": {"edges": []}} async def execute(self, query, variables=None): return self.orders_payload if "orders(" in query.lower() else self.products_payload async def _session(db): s = ChatSession(shop="x") db.add(s) await db.flush() return s def test_specs_expose_tools(): names = {s.name for s in registry.specs()} assert { "search_knowledge", "search_products", "create_cart_link", "save_cart", "watch_stock", "lookup_order", "reorder", "cancel_order", "change_shipping_address", "start_return", "escalate_to_human", } <= names async def test_save_cart_requires_optin_and_cart(db_session): from app.models import AbandonedCart from sqlalchemy import select sess = await _session(db_session) # opt-out store: the tool is inert (so the bot never promises an email it can't send) off = ToolContext(db=db_session, session=sess, tenant_id=1, allow_cart_recovery=False) assert (await registry.dispatch("save_cart", {"email": "a@b.com"}, off))["status"] == "unavailable" on = ToolContext(db=db_session, session=sess, tenant_id=1, allow_cart_recovery=True) # needs an email assert (await registry.dispatch("save_cart", {}, on))["status"] == "need_info" # no cart built in this session yet assert (await registry.dispatch("save_cart", {"email": "a@b.com"}, on))["status"] == "no_cart" # build a cart, then saving attaches the email to it on.shop = "s.myshopify.com" await registry.dispatch("create_cart_link", {"items": [{"variant_id": "9"}]}, on) out = await registry.dispatch("save_cart", {"email": "Buyer@X.com", "name": "Leo"}, on) assert out["status"] == "ok" and out["email"] == "buyer@x.com" cart = (await db_session.execute( select(AbandonedCart).where(AbandonedCart.session_id == sess.id))).scalar_one() assert cart.email == "buyer@x.com" and cart.name == "Leo" async def test_create_cart_link_auto_captures_when_recovery_on(db_session): from app.models import AbandonedCart from sqlalchemy import select sess = await _session(db_session) ctx = ToolContext(db=db_session, session=sess, tenant_id=1, shop="s.myshopify.com", allow_cart_recovery=True) await registry.dispatch("create_cart_link", {"items": [{"variant_id": "5"}]}, ctx) cart = (await db_session.execute( select(AbandonedCart).where(AbandonedCart.session_id == sess.id))).scalar_one() assert cart.checkout_url.endswith("5:1") and cart.email is None # captured, awaiting email async def test_cart_link_with_discount(db_session): ctx = ToolContext(db=db_session, session=await _session(db_session), shop="s.myshopify.com") out = await registry.dispatch( "create_cart_link", {"items": [{"variant_id": "9"}], "discount": "VERANO10"}, ctx ) assert out["checkout_url"] == "https://s.myshopify.com/cart/9:1?discount=VERANO10" # the permalink MUST be registered so the output link-guard keeps it verbatim # (else it's dropped as an unverified path on the uncrawled myshopify domain) assert out["checkout_url"] in ctx.web_urls async def test_watch_stock_saves(db_session): from sqlalchemy import select from app.models import StockWatch ctx = ToolContext(db=db_session, session=await _session(db_session), tenant_id=1) miss = await registry.dispatch("watch_stock", {}, ctx) assert miss["status"] == "need_info" out = await registry.dispatch( "watch_stock", {"email": "c@x.com", "product_title": "Cinta X100"}, ctx ) assert out["status"] == "saved" rows = (await db_session.execute(select(StockWatch))).scalars().all() assert rows[0].email == "c@x.com" and rows[0].product_title == "Cinta X100" async def test_dispatch_knowledge_returns_context(db_session, monkeypatch): from app import embeddings as emb async def fake_query(q): return [1.0, 0.0] async def fake_texts(texts, *, kind="passage"): return [[1.0, 0.0] for _ in texts] monkeypatch.setattr(emb, "embed_query", fake_query) monkeypatch.setattr(emb, "embed_texts", fake_texts) src = KnowledgeSource(kind="url", name="FAQ", location="https://e") db_session.add(src) await db_session.flush() db_session.add( KnowledgeChunk( source_id=src.id, ordinal=0, text="Envíos gratis desde 199€", embedding=[1.0, 0.0], meta={"source_name": "FAQ"}, ) ) await db_session.flush() ctx = ToolContext(db=db_session, session=await _session(db_session)) out = await registry.dispatch("search_knowledge", {"query": "envíos"}, ctx) assert "Envíos gratis" in out["context"] assert out["sources"] == ["FAQ"] async def test_knowledge_tool_nudges_proactive_citation_with_real_link(db_session, monkeypatch): """Trust: when the KB match has a real, verified URL, the model is told to proactively CITE it for concrete/checkable claims (returns, shipping, price) -- not just when the shopper explicitly asks for a link. The widget already renders [name](url) as a real clickable link.""" from app import embeddings as emb async def fake_query(q): return [1.0, 0.0] async def fake_texts(texts, *, kind="passage"): return [[1.0, 0.0] for _ in texts] monkeypatch.setattr(emb, "embed_query", fake_query) monkeypatch.setattr(emb, "embed_texts", fake_texts) src = KnowledgeSource(kind="url", name="Política de envíos", location="https://tienda.example/envios") db_session.add(src) await db_session.flush() db_session.add( KnowledgeChunk( source_id=src.id, ordinal=0, text="Envíos gratis desde 199€", embedding=[1.0, 0.0], meta={"source_name": "Política de envíos"}, ) ) await db_session.flush() ctx = ToolContext(db=db_session, session=await _session(db_session)) out = await registry.dispatch("search_knowledge", {"query": "envíos"}, ctx) assert out["links"] == [{"name": "Política de envíos", "url": "https://tienda.example/envios"}] assert "[nombre](url)" in out["hint"] # tells the model the exact citable format assert "verificable" in out["hint"] # nudges WHEN to cite (concrete/checkable claims) assert "EXACTAMENTE una de estas URLs" in out["hint"] # still forbids inventing routes async def test_dispatch_products_surfaces_cards_and_add_url(db_session): payload = { "products": { "edges": [ { "node": { "title": "X100", "onlineStoreUrl": "https://shop/x100", "description": "d", "featuredImage": {"url": "https://img/x.jpg"}, "variants": {"edges": [{"node": {"id": "gid://shopify/ProductVariant/555", "title": "Default", "price": "10.0", "availableForSale": True}}]}, } } ] } } ctx = ToolContext( db=db_session, session=await _session(db_session), shopify=FakeShopify(products_payload=payload), shop="shop.myshopify.com", ) out = await registry.dispatch("search_products", {"query": "x"}, ctx) assert out["status"] == "ok" assert out["products"][0]["title"] == "X100" # surfaced as a visual card with image + one-tap add-to-cart permalink assert len(ctx.cards) == 1 assert ctx.cards[0]["image"] == "https://img/x.jpg" assert ctx.cards[0]["add_url"] == "https://shop.myshopify.com/cart/555:1" async def test_create_cart_link(db_session): ctx = ToolContext(db=db_session, session=await _session(db_session), shop="shop.myshopify.com") out = await registry.dispatch( "create_cart_link", {"items": [{"variant_id": "555", "quantity": 2}, {"variant_id": "gid://shopify/ProductVariant/777"}]}, ctx, ) assert out["status"] == "ok" assert out["checkout_url"] == "https://shop.myshopify.com/cart/555:2,777:1" async def test_order_tool_needs_email_first(db_session): ctx = ToolContext(db=db_session, session=await _session(db_session), shopify=FakeShopify()) out = await registry.dispatch("lookup_order", {}, ctx) assert out["status"] == "need_info" assert out["need"] == ["email"] async def test_order_tool_email_only_asks_for_second_identifier(db_session): ctx = ToolContext(db=db_session, session=await _session(db_session), shopify=FakeShopify()) out = await registry.dispatch("lookup_order", {"email": "a@b.c"}, ctx) assert out["status"] == "need_info" assert out["need"] == ["second_identifier"] assert "postal_code" in out["accepts"] # flexible: not only the order number async def test_order_tool_verified_with_postal_code(db_session): payload = { "orders": { "edges": [ { "node": { "id": "gid://shopify/Order/1", "name": "#1001", "email": "a@b.c", "customer": {"email": "a@b.c", "firstName": "Ana", "lastName": "G"}, "shippingAddress": {"zip": "08480", "city": "X"}, "displayFulfillmentStatus": "IN_PROGRESS", "fulfillments": [ {"displayStatus": "IN_TRANSIT", "trackingInfo": [{"company": "GLS", "number": "T1", "url": "u"}]} ], } } ] } } ctx = ToolContext( db=db_session, session=await _session(db_session), shopify=FakeShopify(orders_payload=payload) ) # customer gives email + postal code (NOT the order number) → still verifies out = await registry.dispatch("lookup_order", {"email": "a@b.c", "postal_code": "08480"}, ctx) assert out["status"] == "verified" assert out["tracking"]["tracking"][0]["number"] == "T1" assert "address" not in out["tracking"] async def test_order_tool_verified_returns_tracking_no_pii(db_session): payload = { "orders": { "edges": [ { "node": { "id": "gid://shopify/Order/1", "name": "#1001", "email": "a@b.c", "displayFinancialStatus": "PAID", "displayFulfillmentStatus": "IN_PROGRESS", "customer": {"email": "a@b.c"}, "fulfillments": [ { "displayStatus": "IN_TRANSIT", "estimatedDeliveryAt": "2026-06-10", "deliveredAt": None, "inTransitAt": "2026-06-05", "trackingInfo": [{"company": "GLS", "number": "T1", "url": "http://t/T1"}], } ], } } ] } } ctx = ToolContext( db=db_session, session=await _session(db_session), shopify=FakeShopify(orders_payload=payload) ) out = await registry.dispatch( "lookup_order", {"email": "a@b.c", "order_number": "1001"}, ctx ) assert out["status"] == "verified" assert out["tracking"]["tracking"][0]["number"] == "T1" assert "address" not in out["tracking"] async def test_order_tool_not_found_is_generic(db_session): ctx = ToolContext(db=db_session, session=await _session(db_session), shopify=FakeShopify()) out = await registry.dispatch( "lookup_order", {"email": "a@b.c", "order_number": "9999"}, ctx ) assert out["status"] == "not_found" # no field-specific leak async def test_order_tool_invalid_email_is_generic(db_session): ctx = ToolContext(db=db_session, session=await _session(db_session), shopify=FakeShopify()) out = await registry.dispatch( "lookup_order", {"email": 'x" OR id:>0', "order_number": "1001"}, ctx ) assert out["status"] == "not_found" # injection attempt → generic, no crash async def test_order_tool_rate_limited_across_sessions(db_session, monkeypatch): # Rotating the session must NOT reset the per-email throttle. from app.ratelimit import get_order_limiter limiter = get_order_limiter() monkeypatch.setattr(limiter, "max_hits", 3) last = None for _ in range(4): ctx = ToolContext( db=db_session, session=await _session(db_session), shopify=FakeShopify() ) last = await registry.dispatch( "lookup_order", {"email": "spam@x.com", "order_number": "9999"}, ctx ) assert last["status"] == "locked" # 4th attempt across fresh sessions is throttled class QueryAwareShopify: """Empty for keyword queries, returns the catalog for the broad ('') query.""" def __init__(self, catalog): self.catalog = catalog async def execute(self, query, variables=None): q = (variables or {}).get("q", "") edges = self.catalog if q == "" else [] return {"products": {"edges": edges, "pageInfo": {"hasNextPage": False}}} async def test_products_tool_cold_semantic_rank_when_corpus_empty_and_keyword_misses(db_session): # No ProductChunk rows (corpus not synced yet) + keyword "garden hose" misses # the Spanish catalog → cold semantic rank must still surface the product. catalog = [{"node": { "id": "gid://shopify/Product/9", "title": "Manguera Plana Gobeflat", "handle": "gf", "onlineStoreUrl": None, "description": "Manguera para riego y achique de agua en el jardín", "productType": "Mangueras", "tags": ["jardin"], "variants": {"edges": [ {"node": {"id": "gid://shopify/ProductVariant/9", "title": "10m", "price": "16.99", "availableForSale": True}}]}}}] ctx = ToolContext( db=db_session, session=await _session(db_session), shop="s.myshopify.com", shopify=QueryAwareShopify(catalog), ) out = await registry.dispatch("search_products", {"query": "garden hose"}, ctx) assert out["status"] == "ok" assert out["products"], "cold semantic rank must surface products, not empty" assert out["products"][0]["title"] == "Manguera Plana Gobeflat" assert ctx.cards # visual cards still surfaced async def test_greeting_never_opens_handoff_form(db_session): ctx = ToolContext( db=db_session, session=await _session(db_session), tenant_id=1, channel="web", user_message="hola", ) out = await registry.dispatch("escalate_to_human", {}, ctx) assert out["status"] == "not_needed" assert ctx.handoff is False # no form on a bare greeting async def test_explicit_human_request_still_escalates(db_session): ctx = ToolContext( db=db_session, session=await _session(db_session), tenant_id=1, channel="web", user_message="quiero hablar con un humano", ) out = await registry.dispatch("escalate_to_human", {}, ctx) assert out["status"] == "show_form" assert ctx.handoff is True def test_bare_greeting_detection(): from app.tools.escalate_tool import _is_bare_greeting assert _is_bare_greeting("hola") assert _is_bare_greeting("Hola buenas") assert _is_bare_greeting("buenos días") assert not _is_bare_greeting("quiero hablar con un humano") assert not _is_bare_greeting("¿qué productos vendéis?") assert not _is_bare_greeting("necesito ayuda con mi pedido") async def test_escalate_tool_stores_request(db_session): from sqlalchemy import select from app.models import HandoffRequest ctx = ToolContext( db=db_session, session=await _session(db_session), tenant_id=1, support_email="support@store.com", ) out = await registry.dispatch( "escalate_to_human", {"email": "c@x.com", "question": "¿devoluciones?"}, ctx ) # No SMTP needed any more: the request is stored for the merchant to follow up. assert out["status"] == "received" rows = (await db_session.execute(select(HandoffRequest))).scalars().all() assert len(rows) == 1 assert rows[0].email == "c@x.com" assert rows[0].message == "¿devoluciones?" async def test_email_typed_in_chat_is_consumed_not_looped(db_session): """The form-loop bug: a customer who types their email in CHAT (model forgot to pass it) must complete the handoff, not get the form re-shown forever.""" from sqlalchemy import select from app.models import HandoffRequest ctx = ToolContext( db=db_session, session=await _session(db_session), tenant_id=1, channel="web", support_email="support@store.com", user_message="vale, mi correo es juan@cliente.com", ) out = await registry.dispatch("escalate_to_human", {}, ctx) # no email in args assert out["status"] == "received" # NOT show_form -> no loop rows = (await db_session.execute(select(HandoffRequest))).scalars().all() assert len(rows) == 1 and rows[0].email == "juan@cliente.com" def test_get_shipping_cost_spec_is_exposed(): assert "get_shipping_cost" in {s.name for s in registry.specs()} class _ShipShopify: """Fake Shopify client that returns one matching shipping zone.""" async def execute(self, query, variables=None): return { "deliveryProfiles": { "edges": [ { "node": { "name": "P", "profileLocationGroups": [ { "locationGroupZones": { "edges": [ { "node": { "zone": { "name": "Islas Canarias", "countries": [ { "name": "Canary Islands", "code": {"countryCode": "IC", "restOfWorld": False}, "provinces": [], } ], }, "methodDefinitions": { "edges": [ { "node": { "name": "Envío Canarias", "active": True, "description": "", "methodConditions": [], "rateProvider": { "__typename": "DeliveryRateDefinition", "price": {"amount": "12.50", "currencyCode": "EUR"}, }, } } ] }, } } ] } } ], } } ] } } class _DeniedShopify: """Fake Shopify client without the read_shipping scope -> ACCESS_DENIED.""" async def execute(self, query, variables=None): from app.shopify.client import ShopifyError raise ShopifyError("graphql errors: [{'extensions': {'code': 'ACCESS_DENIED'}}]") class _PeninsulaOnlyShopify: """A store with NO Canarias zone — only mainland Spain.""" async def execute(self, query, variables=None): return { "deliveryProfiles": { "edges": [ { "node": { "name": "P", "profileLocationGroups": [ { "locationGroupZones": { "edges": [ { "node": { "zone": { "name": "Península", "countries": [ { "name": "Spain", "code": {"countryCode": "ES", "restOfWorld": False}, "provinces": [], } ], }, "methodDefinitions": { "edges": [ { "node": { "name": "Estándar", "active": True, "description": "", "methodConditions": [], "rateProvider": { "__typename": "DeliveryRateDefinition", "price": {"amount": "4.95", "currencyCode": "EUR"}, }, } } ] }, } } ] } } ], } } ] } } async def test_get_shipping_cost_returns_real_zone(db_session): ctx = ToolContext( db=db_session, session=await _session(db_session), shopify=_ShipShopify(), ) out = await registry.dispatch("get_shipping_cost", {"region": "Canarias"}, ctx) assert out["status"] == "ok" assert out["region_matched"] is True z = out["zones"][0] assert z["zone"] == "Islas Canarias" assert z["methods"][0]["price"] == {"amount": "12.50", "currency": "EUR"} async def test_get_shipping_cost_flags_unmatched_region(db_session): # Asking for Canarias on a peninsula-only store must NOT look like a match: # region_matched=False + a hint that forbids assuming coverage. ctx = ToolContext( db=db_session, session=await _session(db_session), shopify=_PeninsulaOnlyShopify(), ) out = await registry.dispatch("get_shipping_cost", {"region": "Canarias"}, ctx) assert out["status"] == "ok" assert out["region_matched"] is False assert "NO asumas" in out["hint"] # the whole zone map is still provided so the model can double-check assert out["zones"][0]["zone"] == "Península" async def test_get_shipping_cost_degrades_when_scope_missing(db_session): ctx = ToolContext( db=db_session, session=await _session(db_session), shopify=_DeniedShopify(), ) out = await registry.dispatch("get_shipping_cost", {"region": "Canarias"}, ctx) # NOT an error to the model: it must fall back to search_knowledge, never invent. assert out["status"] == "unavailable" assert "NUNCA inventes" in out["hint"] async def test_get_shipping_cost_unavailable_without_live_catalog(db_session): ctx = ToolContext(db=db_session, session=await _session(db_session), shopify=None) out = await registry.dispatch("get_shipping_cost", {"region": "x"}, ctx) assert out["status"] == "unavailable"