from __future__ import annotations from pathlib import Path ROOT = Path(__file__).resolve().parent.parent EXT = ROOT / "extension" WIDGET = ROOT / "app" / "static" / "widget.js" BLOCK = EXT / "extensions" / "chat-widget" / "blocks" / "chat.liquid" APP_TOML = EXT / "shopify.app.toml" def test_widget_js_supports_signed_proxy_mode(): js = WIDGET.read_text(encoding="utf-8") assert 'searchParams.get("proxy")' in js # proxy-mode switch (Shopify embed) assert '"/apps/chat"' in js # signed App Proxy base assert "message:" in js and "session_id:" in js assert "localStorage" in js def test_widget_handoff_url_matches_the_real_registered_route(): """CRITICAL regression (found live: a real flexigotech.com lead + Victor's own test submission both silently vanished). Every OTHER endpoint keeps "/chat" in both modes (BACKEND/chat/stream, /apps/chat/stream), but the backend registers the STANDALONE handoff route as plain "/handoff" (no "/chat" prefix) while the PROXY one IS "/apps/chat/handoff". The widget built it as API + "/handoff", which silently 404'd on every non-Shopify site: BACKEND/chat/handoff instead of BACKEND/handoff -- the visitor saw "thanks, we'll reply" and NOTHING was ever saved. Verified live in a real browser: standalone mode -> POST /handoff, proxy mode -> POST /apps/chat/handoff.""" js = WIDGET.read_text(encoding="utf-8") assert 'var API_HANDOFF = PROXY ? "/apps/chat/handoff" : (BACKEND + "/handoff");' in js assert "fetch(API_HANDOFF + QP" in js assert 'fetch(API + "/handoff"' not in js # the broken construction must be gone def test_widget_handoff_checks_response_status_and_offers_mailto_fallback(): """Second bug found in the same incident: fetch() only rejects on a NETWORK failure -- a non-2xx HTTP response (like the 404 above) still resolves with a parseable JSON body ({"detail":"Not Found"}), so without an explicit r.ok check the code fell into the SUCCESS branch and told the visitor "thanks, we'll reply" while nothing was saved. Verified live in a real browser: 3/3 (no false "thanks" on a 404, a mailto: fallback link appears with the merchant's real support_email, correct link text).""" js = WIDGET.read_text(encoding="utf-8") assert "if (!r.ok) throw new Error(" in js assert "cfg.support_email" in js assert "hfErrorMailto" in js assert js.count("hfErrorMailto:") == 6, "hfErrorMailto label must be defined in all 6 languages" # mailto: links must actually render (inline() only handled http/https before) assert "(?:https?|mailto):" in js def test_widget_both_mode_shows_a_single_bubble_with_channel_picker(): """'both' (chat+WhatsApp) must be ONE bubble that opens a home screen letting the visitor pick a channel -- not two separate floating launchers. Verified behaviorally in a real headless browser during development (22/22 assertions: single bubble, no premature greet, correct wa.me resolution, chat/whatsapp-only modes unaffected); this structural test guards the markers that behavior relies on so a future edit can't silently remove them.""" js = WIDGET.read_text(encoding="utf-8") assert 'var showHome = mode === "both" && hasWa' in js assert 'var showWaStandalone = hasWa && !showHome' in js assert 'id="ssb-home"' in js and 'id="ssb-chatarea"' in js assert 'id="ssb-home-chat"' in js and 'id="ssb-home-wa"' in js assert "chooseChat" in js and "chooseWa" in js # every language block carries the picker copy (real i18n, not just Spanish) for key in ("homeTitle", "homeChat", "homeWa"): assert js.count(key + ":") == 6, f"{key} must be defined in all 6 languages" def test_widget_both_mode_can_switch_back_to_the_channel_picker(): """Regression (reported live): once a visitor picked a channel, closing and reopening the panel left them stuck with no way back to the picker. A header button must let them return to it at any point, even after a re-open.""" js = WIDGET.read_text(encoding="utf-8") assert 'id="ssb-switch"' in js assert "backToHome" in js assert js.count("switchChannel:") == 6, "switchChannel label must be defined in all 6 languages" def test_widget_adopts_the_real_conversation_language(): """Regression (real leads found via the portal): the handoff contact form's labels ('Tu nombre'/'Tu email') were pinned to navigator.language forever -- a visitor with a Spanish browser locale chatting in English still saw a Spanish form. The widget must adopt the backend-detected d.lang (verified live: 6/6 assertions in a real browser, form + input placeholder both switch language mid-conversation) before the form renders.""" js = WIDGET.read_text(encoding="utf-8") assert "function adoptConversationLanguage" in js assert "adoptConversationLanguage(d.lang)" in js # called BEFORE the handoff form check, in deliverReply's body i_adopt = js.index("adoptConversationLanguage(d.lang)") i_handoff = js.index("if (d.handoff) renderHandoffForm();") assert i_adopt < i_handoff def test_widget_reports_handoff_funnel_to_host_analytics(): """A lead who asked to talk to a human and then abandoned was completely invisible to the merchant before (verified live: two real flexigotech.com leads, zero trace anywhere). trackHost() passes 'shown'/'submitted' events to the HOST PAGE's own gtag if it has one (generic across tenants, safe no-op if not, NEVER sends PII) so the merchant can see the drop-off going forward. Verified live in a real browser: 6/6, incl. that no event carries the customer's email/name, and a host with no gtag at all doesn't break.""" js = WIDGET.read_text(encoding="utf-8") assert "function trackHost(name)" in js assert "window.gtag" in js assert 'trackHost("atendyo_handoff_shown")' in js assert 'trackHost("atendyo_handoff_submitted")' in js # shown fires when the form renders, submitted only after a successful POST i_shown = js.index('trackHost("atendyo_handoff_shown")') i_render = js.index('box.innerHTML =') assert i_shown < i_render i_submitted = js.index('trackHost("atendyo_handoff_submitted")') i_thanks = js.index("botMsg(T.hfThanks)") assert i_thanks < i_submitted def test_block_is_app_embed_loading_widget_in_proxy_mode(): liquid = BLOCK.read_text(encoding="utf-8") assert "{% schema %}" in liquid assert '"target": "body"' in liquid # app embed (whole storefront) assert "proxy=1" in liquid # loads the widget in signed-proxy mode assert "widget.js" in liquid def test_app_toml_scopes_and_proxy(): toml = APP_TOML.read_text(encoding="utf-8") assert "read_orders" in toml and "read_products" in toml and "read_customers" in toml assert "[app_proxy]" in toml assert 'subpath = "chat"' in toml assert 'prefix = "apps"' in toml # Uninstall + billing webhooks must be SUBSCRIBED, not just handled. assert "app/uninstalled" in toml assert "app_subscriptions/update" in toml