from __future__ import annotations from datetime import UTC, datetime, timedelta from sqlalchemy import select from app import carts, mailer from app.models import AbandonedCart, ChatSession, Tenant async def _session(db, tenant_id, lang="es"): s = ChatSession(tenant_id=tenant_id, lang=lang) db.add(s) await db.flush() return s async def test_capture_one_per_session_and_updates(db_session): s = await _session(db_session, 1) c1 = await carts.capture( db_session, 1, session_id=s.id, checkout_url="https://x/cart/1:1", items_text="1 art." ) # same session -> SAME row updated (no duplicate), latest link wins c2 = await carts.capture( db_session, 1, session_id=s.id, checkout_url="https://x/cart/2:1", items_text="2 art." ) assert c2.id == c1.id assert c2.checkout_url.endswith("2:1") and c2.items_text == "2 art." assert c2.email is None # nothing captured yet async def test_capture_autofills_known_email(db_session): s = await _session(db_session, 1) c = await carts.capture( db_session, 1, session_id=s.id, checkout_url="https://x/cart/9:1", email="Buyer@X.com", name="Leo", ) assert c.email == "buyer@x.com" and c.name == "Leo" # normalized async def test_attach_contact_requires_existing_cart(db_session): s = await _session(db_session, 1) # no cart built yet in this session -> nothing to attach assert await carts.attach_contact(db_session, 1, session_id=s.id, email="a@b.com") is None await carts.capture(db_session, 1, session_id=s.id, checkout_url="https://x/cart/3:1") c = await carts.attach_contact(db_session, 1, session_id=s.id, email="a@b.com", name="Ann") assert c is not None and c.email == "a@b.com" and c.name == "Ann" async def test_capture_is_tenant_isolated(db_session): sa = await _session(db_session, 1) sb = await _session(db_session, 2) a = await carts.capture(db_session, 1, session_id=sa.id, checkout_url="https://a/cart/1:1") b = await carts.capture(db_session, 2, session_id=sb.id, checkout_url="https://b/cart/1:1") assert a.id != b.id and a.tenant_id == 1 and b.tenant_id == 2 def test_recovery_email_language(): es_subject, es_body = carts._recovery_email( AbandonedCart(name="María García", lang="es", checkout_url="https://x/cart/1:1"), "MiTienda" ) assert "MiTienda" in es_subject and "María" in es_body and "https://x/cart/1:1" in es_body en_subject, en_body = carts._recovery_email( AbandonedCart(name="John", lang="en", checkout_url="https://x/cart/1:1"), "MyShop" ) assert "saved" in en_subject.lower() and en_body.startswith("Hi John") async def test_recover_due_carts_sends_and_skips(db_session, monkeypatch): import app.shopify.orders as orders_mod import app.tenancy as tenancy_mod db_session.add(Tenant(slug="cr", name="CR", brand_name="CartShop", allow_cart_recovery=True, support_email="shop@cr.com")) db_session.add(Tenant(slug="off", name="OFF", allow_cart_recovery=False)) await db_session.flush() cr = (await db_session.execute( select(Tenant).where(Tenant.slug == "cr"))).scalar_one() off = (await db_session.execute( select(Tenant).where(Tenant.slug == "off"))).scalar_one() old = datetime.now(UTC) - timedelta(hours=5) # past the 4h delay, within 48h # due: opted-in tenant, has email, old enough db_session.add(AbandonedCart(tenant_id=cr.id, email="a@b.com", checkout_url="https://x/cart/1:1", created_at=old)) # not due: no email captured db_session.add(AbandonedCart(tenant_id=cr.id, checkout_url="https://x/cart/2:1", created_at=old)) # not due: too recent db_session.add(AbandonedCart(tenant_id=cr.id, email="c@d.com", checkout_url="https://x/cart/3:1", created_at=datetime.now(UTC))) # opted-out tenant must NEVER be emailed db_session.add(AbandonedCart(tenant_id=off.id, email="e@f.com", checkout_url="https://x/cart/4:1", created_at=old)) await db_session.flush() # opted-in tenants have Shopify connected; no orders => nobody has bought yet monkeypatch.setattr(tenancy_mod, "tenant_shopify_client", lambda tenant: object()) async def no_orders(client, email): return [] monkeypatch.setattr(orders_mod, "find_orders_by_email", no_orders) sent: list[tuple] = [] async def fake_send(settings, to, subject, body, **kw): sent.append((to, subject, kw.get("reply_to"))) return True monkeypatch.setattr(mailer, "send_email", fake_send) n = await carts.recover_due_carts(db_session, object(), now=datetime.now(UTC)) assert n == 1 assert sent == [("a@b.com", sent[0][1], "shop@cr.com")] # only the one due cart, with reply-to async def test_recover_fails_closed_without_shopify_client(db_session, monkeypatch): """Fail closed: a tenant whose Shopify client can't be resolved (no creds) must NOT be emailed — we can't check whether the shopper already bought.""" import app.tenancy as tenancy_mod db_session.add(Tenant(slug="noshop", name="NS", allow_cart_recovery=True)) await db_session.flush() t = (await db_session.execute(select(Tenant).where(Tenant.slug == "noshop"))).scalar_one() db_session.add(AbandonedCart(tenant_id=t.id, email="a@b.com", checkout_url="https://x/cart/1:1", created_at=datetime.now(UTC) - timedelta(hours=5))) await db_session.flush() monkeypatch.setattr(tenancy_mod, "tenant_shopify_client", lambda tenant: None) sent = [] monkeypatch.setattr(mailer, "send_email", lambda *a, **k: sent.append(a)) n = await carts.recover_due_carts(db_session, object(), now=datetime.now(UTC)) assert n == 0 and sent == [] async def test_recover_dedupes_per_recipient(db_session, monkeypatch): """Many carts (sessions) with the SAME email get at most ONE recovery email.""" import app.shopify.orders as orders_mod import app.tenancy as tenancy_mod db_session.add(Tenant(slug="dd", name="DD", brand_name="DDShop", allow_cart_recovery=True)) await db_session.flush() t = (await db_session.execute(select(Tenant).where(Tenant.slug == "dd"))).scalar_one() old = datetime.now(UTC) - timedelta(hours=5) for i in range(3): # three abandoned carts, same victim address db_session.add(AbandonedCart(tenant_id=t.id, email="victim@x.com", checkout_url=f"https://x/cart/{i}:1", created_at=old)) await db_session.flush() monkeypatch.setattr(tenancy_mod, "tenant_shopify_client", lambda tenant: object()) async def no_orders(client, email): return [] monkeypatch.setattr(orders_mod, "find_orders_by_email", no_orders) sent = [] async def fake_send(settings, to, *a, **k): sent.append(to) return True monkeypatch.setattr(mailer, "send_email", fake_send) n = await carts.recover_due_carts(db_session, object(), now=datetime.now(UTC)) assert n == 1 and sent == ["victim@x.com"] # ONE email despite 3 carts async def test_recover_only_stamps_nudged_on_success(db_session, monkeypatch): """A transient send failure leaves nudged_at NULL so the cart is retried, not silently dropped.""" import app.shopify.orders as orders_mod import app.tenancy as tenancy_mod db_session.add(Tenant(slug="tx", name="TX", allow_cart_recovery=True)) await db_session.flush() t = (await db_session.execute(select(Tenant).where(Tenant.slug == "tx"))).scalar_one() cart = AbandonedCart(tenant_id=t.id, email="a@b.com", checkout_url="https://x/cart/1:1", created_at=datetime.now(UTC) - timedelta(hours=5)) db_session.add(cart) await db_session.flush() monkeypatch.setattr(tenancy_mod, "tenant_shopify_client", lambda tenant: object()) async def no_orders(client, email): return [] monkeypatch.setattr(orders_mod, "find_orders_by_email", no_orders) async def fail_send(*a, **k): return False # transient Brevo failure (no exception) monkeypatch.setattr(mailer, "send_email", fail_send) n = await carts.recover_due_carts(db_session, object(), now=datetime.now(UTC)) assert n == 0 and cart.nudged_at is None # not consumed; will retry async def test_purge_stale_carts(db_session): db_session.add(Tenant(slug="pg", name="PG", allow_cart_recovery=True)) await db_session.flush() t = (await db_session.execute(select(Tenant).where(Tenant.slug == "pg"))).scalar_one() db_session.add(AbandonedCart(tenant_id=t.id, email="old@x.com", checkout_url="u", created_at=datetime.now(UTC) - timedelta(hours=60))) # past 48h db_session.add(AbandonedCart(tenant_id=t.id, email="fresh@x.com", checkout_url="u", created_at=datetime.now(UTC) - timedelta(hours=2))) await db_session.flush() purged = await carts.purge_stale_carts(db_session, now=datetime.now(UTC)) assert purged == 1 rows = (await db_session.execute( select(AbandonedCart).where(AbandonedCart.tenant_id == t.id))).scalars().all() assert [r.email for r in rows] == ["fresh@x.com"] async def test_recover_due_skips_already_bought(db_session, monkeypatch): import app.shopify.orders as orders_mod import app.tenancy as tenancy_mod db_session.add(Tenant(slug="cr2", name="CR2", brand_name="Shop2", allow_cart_recovery=True)) await db_session.flush() t = (await db_session.execute( select(Tenant).where(Tenant.slug == "cr2"))).scalar_one() old = datetime.now(UTC) - timedelta(hours=5) cart = AbandonedCart(tenant_id=t.id, email="paid@x.com", checkout_url="https://x/cart/1:1", created_at=old) db_session.add(cart) await db_session.flush() monkeypatch.setattr(tenancy_mod, "tenant_shopify_client", lambda tenant: object()) async def fake_orders(client, email): # an order placed AFTER the cart was built, by THIS shopper -> already bought return [{"created_at": datetime.now(UTC).isoformat(), "email": "paid@x.com"}] monkeypatch.setattr(orders_mod, "find_orders_by_email", fake_orders) sent = [] async def fake_send(*a, **k): sent.append(a) return True monkeypatch.setattr(mailer, "send_email", fake_send) n = await carts.recover_due_carts(db_session, object(), now=datetime.now(UTC)) assert n == 0 and sent == [] # no email — sale already closed # same identity-mapped row recover_due_carts mutated (caller commits; don't refresh # here or the un-flushed change is discarded) assert cart.recovered is True and cart.nudged_at is None