From 26f761d451885eb8180280886be5ba710bb42372 Mon Sep 17 00:00:00 2001 From: crewcricle <280911048+crewcricle@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:38:32 +0000 Subject: [PATCH 1/3] Phase 3: multi-location bi-directional menu sync, canonical store, Square OAuth, images --- backend/config.py | 7 + backend/jobs/menu_sync.py | 602 ++++++++++++------ backend/main.py | 4 +- backend/pyproject.toml | 1 + backend/routers/approve.py | 33 +- backend/routers/auth.py | 71 +++ backend/routers/locations.py | 81 +++ backend/routers/menu.py | 72 +++ backend/routers/webhooks.py | 200 +++++- backend/services/gbp_media.py | 49 ++ backend/services/menu_images.py | 129 ++++ backend/services/square_catalog.py | 160 +++++ backend/services/square_images.py | 69 ++ backend/services/square_oauth.py | 161 +++++ backend/task_queue.py | 40 +- backend/tests/conftest.py | 4 + backend/tests/unit/test_locations.py | 168 +++++ backend/tests/unit/test_menu_images.py | 128 ++++ backend/tests/unit/test_menu_sync.py | 377 ++++++++++- backend/tests/unit/test_outbound_durable.py | 61 +- backend/tests/unit/test_queue_tasks.py | 2 +- .../tests/unit/test_square_catalog_inbound.py | 221 +++++++ backend/tests/unit/test_square_oauth.py | 134 ++++ backend/uv.lock | 11 + supabase/migrations/017_locations.sql | 46 ++ .../migrations/018_clients_square_oauth.sql | 10 + supabase/migrations/019_menu_items.sql | 29 + supabase/migrations/020_menu_item_links.sql | 22 + supabase/migrations/021_menu_images.sql | 35 + supabase/migrations/022_square_sync_state.sql | 15 + 30 files changed, 2686 insertions(+), 256 deletions(-) create mode 100644 backend/routers/locations.py create mode 100644 backend/routers/menu.py create mode 100644 backend/services/gbp_media.py create mode 100644 backend/services/menu_images.py create mode 100644 backend/services/square_catalog.py create mode 100644 backend/services/square_images.py create mode 100644 backend/services/square_oauth.py create mode 100644 backend/tests/unit/test_locations.py create mode 100644 backend/tests/unit/test_menu_images.py create mode 100644 backend/tests/unit/test_square_catalog_inbound.py create mode 100644 backend/tests/unit/test_square_oauth.py create mode 100644 supabase/migrations/017_locations.sql create mode 100644 supabase/migrations/018_clients_square_oauth.sql create mode 100644 supabase/migrations/019_menu_items.sql create mode 100644 supabase/migrations/020_menu_item_links.sql create mode 100644 supabase/migrations/021_menu_images.sql create mode 100644 supabase/migrations/022_square_sync_state.sql diff --git a/backend/config.py b/backend/config.py index c31022d..699e1ed 100644 --- a/backend/config.py +++ b/backend/config.py @@ -25,8 +25,15 @@ class Settings(BaseSettings): sentry_dsn: str = "" project_id: str = "localmate" environment: str = "prod" + # Square — global token deprecated for production (per-client OAuth is + # authoritative via square_oauth.get_valid_token). Kept for sandbox/dev fallback. square_access_token: str = "" square_environment: str = "sandbox" + square_app_id: str = "" + square_app_secret: str = "" + square_oauth_redirect_path: str = "/auth/square-callback" + square_webhook_signature_key: str = "" + menu_images_bucket: str = "menu-images" supabase_jwt_secret: str = "" # --- Phase 0: queue / worker / billing-portal infra --- diff --git a/backend/jobs/menu_sync.py b/backend/jobs/menu_sync.py index 7993169..a697310 100644 --- a/backend/jobs/menu_sync.py +++ b/backend/jobs/menu_sync.py @@ -1,244 +1,480 @@ -import logging -import uuid -from datetime import datetime +"""Menu sync engine — canonical store + bi-directional reconciliation. + +Rewrite of the original one-way Sheets→GBP/Square pusher into a location-aware, +bi-directional sync engine with a canonical menu store. Loop prevention via +menu_item_links.last_synced_hash. Per-client Square OAuth tokens (no global +settings.square_access_token). Square catalog writes run inside the durable +process_menu_update arq task (C4). +""" + import asyncio +import hashlib +import logging +from datetime import datetime, timezone import httpx from db import get_db -from config import settings logger = logging.getLogger(__name__) GBP_BASE_URL = "https://mybusinessbusinessinformation.googleapis.com/v1" GBP_MENU_ITEMS_ENDPOINT = GBP_BASE_URL + "/accounts/{account_id}/menuItems" -SQUARE_SANDBOX_BASE = "https://connect.squareupsandbox.com" -SQUARE_PRODUCTION_BASE = "https://connect.squareup.com" -SQUARE_CATALOG_ENDPOINT = "/v2/catalog/object" -SQUARE_API_VERSION = "2024-06-19" - RETRY_MAX_ATTEMPTS = 2 RETRY_DELAY_SECONDS = 5 -def _square_base_url() -> str: - if settings.square_environment == "production": - return SQUARE_PRODUCTION_BASE - return SQUARE_SANDBOX_BASE - - -# GBP Menu API v1: POST .../accounts/{account_id}/menuItems - +# --------------------------------------------------------------------------- +# Content hash + canonical store +# --------------------------------------------------------------------------- -async def _sync_gbp(client: dict, item: dict) -> dict: - from services.crypto import decrypt # lazy — avoids circular import at module level +def compute_content_hash(item: dict) -> str: + """sha256 of canonical fields: name|price_cents|description|category|active.""" + canonical = "|".join([ + str(item.get("name", "")), + str(item.get("price_cents", 0)), + str(item.get("description", "")), + str(item.get("category", "")), + str(item.get("active", True)), + ]) + return hashlib.sha256(canonical.encode()).hexdigest() - account_id = client.get("gbp_account_id", "") - if not account_id: - return {"synced": False, "message": "Client missing gbp_account_id"} - gbp_token = client.get("gbp_access_token", "") - if not gbp_token: - return {"synced": False, "message": "Client missing gbp_access_token"} +async def upsert_canonical( + client_id: str, location_id: str, item: dict, origin: str = "sheets" +) -> dict: + """Insert/update menu_items keyed by (location_id, sheet_row_key). - access_token = decrypt(gbp_token) - url = GBP_MENU_ITEMS_ENDPOINT.format(account_id=account_id) + Sets content_hash, updated_at, origin. Returns the canonical row (with id). + """ + db = get_db() + content_hash = compute_content_hash(item) + sheet_row_key = item.get("sheet_row_key") or item.get("name") + + # Try to find existing by (location_id, sheet_row_key) + existing = ( + db.table("menu_items") + .select("*") + .eq("location_id", location_id) + .eq("sheet_row_key", sheet_row_key) + .maybe_single() + .execute() + ) - body = { + now = datetime.now(timezone.utc).isoformat() + row_data = { + "client_id": client_id, + "location_id": location_id, "name": item["name"], "description": item.get("description", ""), - "price": f"{item['price_cents'] / 100.0:.2f}", - "currency": "AUD", + "price_cents": item["price_cents"], + "category": item.get("category", ""), + "active": item.get("active", True), + "content_hash": content_hash, + "origin": origin, + "sheet_row_key": sheet_row_key, + "updated_at": now, } + if existing and existing.data: + resp = ( + db.table("menu_items") + .update(row_data) + .eq("id", existing.data["id"]) + .execute() + ) + return resp.data[0] if resp.data else existing.data + else: + resp = db.table("menu_items").insert(row_data).execute() + return resp.data[0] if resp.data else {} + + +# --------------------------------------------------------------------------- +# Reconciliation (outbound push) +# --------------------------------------------------------------------------- + +async def _retry_push(label: str, coro_fn): + """Run ``coro_fn()`` with bounded retry; return its result or a failure dict. + + Retries on a 5xx ``HTTPStatusError`` (or any other exception) up to + ``RETRY_MAX_ATTEMPTS``. Non-retryable HTTP errors and exhausted retries + return ``{"synced": False, "message": ...}`` with the platform ``label``. + """ for attempt in range(RETRY_MAX_ATTEMPTS): try: - async with httpx.AsyncClient() as http: - resp = await http.post( - url, - json=body, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=30.0, - ) - resp.raise_for_status() - logger.info( - "GBP menu item synced: %s (account=%s)", item["name"], account_id - ) - return {"synced": True, "message": "Synced to GBP"} + return await coro_fn() except httpx.HTTPStatusError as exc: if exc.response.status_code >= 500 and attempt < RETRY_MAX_ATTEMPTS - 1: - logger.warning( - "GBP transient error %s (attempt %d/%d), retrying in %ds", - exc.response.status_code, - attempt + 1, - RETRY_MAX_ATTEMPTS, - RETRY_DELAY_SECONDS, - ) await asyncio.sleep(RETRY_DELAY_SECONDS) continue - logger.error("GBP sync failed for %s: %s", item["name"], exc) - return {"synced": False, "message": f"GBP API error: {exc}"} + return {"synced": False, "message": f"{label} API error: {exc}"} except Exception as exc: if attempt < RETRY_MAX_ATTEMPTS - 1: - logger.warning( - "GBP error on attempt %d/%d: %s, retrying in %ds", - attempt + 1, - RETRY_MAX_ATTEMPTS, - exc, - RETRY_DELAY_SECONDS, - ) await asyncio.sleep(RETRY_DELAY_SECONDS) continue - logger.error("GBP sync failed for %s: %s", item["name"], exc) - return {"synced": False, "message": f"GBP sync error: {exc}"} + return {"synced": False, "message": f"{label} sync error: {exc}"} + return {"synced": False, "message": f"{label} sync failed after retries"} - return {"synced": False, "message": "GBP sync failed after retries"} +async def _push_to_square( + location: dict, client: dict, menu_item: dict, link: dict | None +) -> dict: + """Push a menu item to Square catalog via per-client OAuth token.""" + from services.square_oauth import get_valid_token + from services.square_catalog import upsert_item as square_upsert -async def _sync_square(client: dict, item: dict) -> dict: - base_url = _square_base_url() - token = settings.square_access_token - if not token: - return {"synced": False, "message": "Square access token not configured"} + square_location_id = location.get("square_location_id") + if not square_location_id: + return {"synced": False, "message": "Location missing square_location_id"} - url = f"{base_url}{SQUARE_CATALOG_ENDPOINT}" - idempotency_key = str(uuid.uuid4()) + try: + access_token = await get_valid_token(client) + except Exception as e: + return {"synced": False, "message": f"Square token error: {e}"} + + external_id = link.get("external_id") if link else None + external_version = link.get("external_version") if link else None + + async def _do() -> dict: + result = await square_upsert( + access_token, + square_location_id, + menu_item, + external_id=external_id, + external_version=external_version, + ) + return { + "synced": True, + "message": "Synced to Square", + "external_id": result.get("id"), + "external_version": result.get("version"), + } + + return await _retry_push("Square", _do) + + +async def _push_to_gbp( + location: dict, client: dict, menu_item: dict, link: dict | None +) -> dict: + """Push a menu item to GBP Menu API v1 using location's gbp_account_id.""" + from services.crypto import decrypt + + account_id = location.get("gbp_account_id") + if not account_id: + return {"synced": False, "message": "Location missing gbp_account_id"} + + gbp_token = client.get("gbp_access_token", "") + if not gbp_token: + return {"synced": False, "message": "Client missing gbp_access_token"} + + access_token = decrypt(gbp_token) + url = GBP_MENU_ITEMS_ENDPOINT.format(account_id=account_id) body = { - "idempotency_key": idempotency_key, - "object": { - "type": "ITEM", - "item_data": { - "name": item["name"], - "description": item.get("description", ""), - "variations": [ - { - "type": "ITEM_VARIATION", - "item_variation_data": { - "name": "Regular", - "price_money": { - "amount": item["price_cents"], - "currency": "AUD", - }, - "pricing_type": "FIXED_PRICING", - }, - } - ], - }, - }, + "name": menu_item["name"], + "description": menu_item.get("description", ""), + "price": f"{menu_item['price_cents'] / 100.0:.2f}", + "currency": "AUD", } - headers = { - "Authorization": f"Bearer {token}", - "Square-Version": SQUARE_API_VERSION, - "Content-Type": "application/json", - } + async def _do() -> dict: + async with httpx.AsyncClient() as http: + resp = await http.post( + url, + json=body, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30.0, + ) + resp.raise_for_status() + return {"synced": True, "message": "Synced to GBP"} - for attempt in range(RETRY_MAX_ATTEMPTS): - try: - async with httpx.AsyncClient() as http: - resp = await http.post(url, json=body, headers=headers, timeout=30.0) - resp.raise_for_status() - logger.info("Square catalog item synced: %s", item["name"]) - return {"synced": True, "message": "Synced to Square"} - except httpx.HTTPStatusError as exc: - if exc.response.status_code >= 500 and attempt < RETRY_MAX_ATTEMPTS - 1: - logger.warning( - "Square transient error %s (attempt %d/%d), retrying in %ds", - exc.response.status_code, - attempt + 1, - RETRY_MAX_ATTEMPTS, - RETRY_DELAY_SECONDS, - ) - await asyncio.sleep(RETRY_DELAY_SECONDS) - continue - logger.error("Square sync failed for %s: %s", item["name"], exc) - return {"synced": False, "message": f"Square API error: {exc}"} - except Exception as exc: - if attempt < RETRY_MAX_ATTEMPTS - 1: - logger.warning( - "Square error on attempt %d/%d: %s, retrying in %ds", - attempt + 1, - RETRY_MAX_ATTEMPTS, - exc, - RETRY_DELAY_SECONDS, - ) - await asyncio.sleep(RETRY_DELAY_SECONDS) - continue - logger.error("Square sync failed for %s: %s", item["name"], exc) - return {"synced": False, "message": f"Square sync error: {exc}"} + return await _retry_push("GBP", _do) - return {"synced": False, "message": "Square sync failed after retries"} +async def reconcile_item( + location: dict, + client: dict, + menu_item: dict, + skip_platform: str | None = None, +) -> dict: + """Reconcile a canonical menu item to its configured sync targets. -async def sync_to_platform(platform: str, client: dict, item: dict) -> dict: - """Dispatch menu item sync to target platform. Returns {"synced": bool, "message": str}.""" - try: - if platform == "gbp": - return await _sync_gbp(client, item) - elif platform == "square": - return await _sync_square(client, item) - elif platform == "website": - return { - "synced": False, - "message": "Website CMS sync not yet implemented in MVP", - } - elif platform in ("ubereats", "doordash", "lightspeed"): - return { - "synced": False, - "message": f"{platform.title()} sync not yet implemented in MVP", - } + For each target in ``location['menu_sync_targets']``: + - Read the menu_item_links row for (menu_item, platform) + - Skip if ``last_synced_hash == content_hash`` (loop guard) + - Else push via the platform service and store external_id + last_synced_hash + + ``skip_platform`` excludes a platform from the push (used by + ``apply_square_inbound`` to avoid echoing back to Square). + + Returns ``{"targets": {target: {"synced": bool, "message": str}}}``. + """ + db = get_db() + targets = location.get("menu_sync_targets", []) + content_hash = menu_item.get("content_hash") or compute_content_hash(menu_item) + + results: dict[str, dict] = {} + for target in targets: + if target == skip_platform: + results[target] = {"synced": True, "message": "Skipped (source platform)"} + continue + + # Read existing link + link_resp = ( + db.table("menu_item_links") + .select("*") + .eq("menu_item_id", menu_item["id"]) + .eq("platform", target) + .maybe_single() + .execute() + ) + link = link_resp.data if link_resp.data else None + last_synced_hash = link.get("last_synced_hash") if link else None + + # Loop guard: skip if hash matches + if last_synced_hash == content_hash: + results[target] = {"synced": True, "message": "Already in sync (hash match)"} + continue + + # Push to platform + if target == "square": + result = await _push_to_square(location, client, menu_item, link) + elif target == "gbp": + result = await _push_to_gbp(location, client, menu_item, link) + elif target == "website": + result = {"synced": False, "message": "Website CMS sync not yet implemented"} + elif target in ("ubereats", "doordash", "lightspeed"): + result = {"synced": False, "message": f"{target.title()} sync not yet implemented"} else: - return {"synced": False, "message": f"Unknown target: {platform}"} - except Exception as exc: - logger.error("sync_to_platform failed for %s: %s", platform, exc) - return {"synced": False, "message": str(exc)} + result = {"synced": False, "message": f"Unknown target: {target}"} + + # Store link with external_id + last_synced_hash on success + if result.get("synced"): + link_data: dict = { + "last_synced_hash": content_hash, + "last_synced_at": datetime.now(timezone.utc).isoformat(), + } + if result.get("external_id"): + link_data["external_id"] = result["external_id"] + if result.get("external_version") is not None: + link_data["external_version"] = result["external_version"] + + if link: + db.table("menu_item_links").update(link_data).eq("id", link["id"]).execute() + else: + link_data["menu_item_id"] = menu_item["id"] + link_data["platform"] = target + db.table("menu_item_links").insert(link_data).execute() + + results[target] = result + + # Log results with location_id (C6) + log_sync_results( + client["id"], + location.get("id"), + menu_item, + list(results.keys()), + list(results.values()), + ) + return {"targets": results} -def log_sync_results(client_id: str, item: dict, targets: list, results: list) -> None: + +# --------------------------------------------------------------------------- +# Sync entry points +# --------------------------------------------------------------------------- + +def log_sync_results( + client_id: str, + location_id: str | None, + item: dict, + targets: list, + results: list, +) -> None: + """Write per-target results to menu_sync_log (with location_id per C6).""" db = get_db() for target, result in zip(targets, results): synced = result.get("synced", False) - db.table("menu_sync_log").insert( - { - "client_id": client_id, - "item_name": item.get("name", ""), - "price_cents": item.get("price_cents"), - "target": target, - "status": "synced" if synced else "failed", - "error_message": None if synced else result.get("message", ""), - "synced_at": datetime.utcnow().isoformat(), - } - ).execute() - + row: dict = { + "client_id": client_id, + "item_name": item.get("name", ""), + "price_cents": item.get("price_cents"), + "target": target, + "status": "synced" if synced else "failed", + "error_message": None if synced else result.get("message", ""), + "synced_at": datetime.now(timezone.utc).isoformat(), + } + if location_id: + row["location_id"] = location_id + db.table("menu_sync_log").insert(row).execute() + + +async def sync_menu_item( + client_id: str, + location_id: str | None = None, + item: dict | None = None, + origin: str = "sheets", +) -> dict: + """Sync a menu item: upsert canonical + reconcile to targets. + + New signature: ``sync_menu_item(client_id, location_id, item, origin='sheets')``. + Backward-compat: ``sync_menu_item(client_id, item)`` resolves the client's + ``is_default`` location. + """ + # Backward-compat: sync_menu_item(client_id, item) — 2 positional args + if item is None and location_id is not None: + item = location_id # type: ignore[assignment] + location_id = None -async def sync_menu_item(client_id: str, item: dict) -> dict: db = get_db() + + # Load client resp = ( - db.table("clients").select("*").eq("id", client_id).maybe_single().execute() + db.table("clients") + .select("*") + .eq("id", client_id) + .maybe_single() + .execute() ) client = resp.data if resp.data else None if not client: - logger.error(f"Client not found: {client_id}") + logger.error("Client not found: %s", client_id) return {"status": "failed", "error": "Client not found"} - targets = client.get("menu_sync_targets", []) - if not targets: - logger.info(f"No menu_sync_targets configured for client {client_id}") - return {"status": "skipped", "reason": "No targets configured"} - - results = await asyncio.gather( - *[sync_to_platform(target, client, item) for target in targets], - return_exceptions=True, - ) - - cleaned: list[dict] = [] - for r in results: - if isinstance(r, Exception): - cleaned.append({"synced": False, "message": str(r)}) - else: - cleaned.append(r) + # Resolve location + if location_id is None: + # Default location (backward-compat) + loc_resp = ( + db.table("locations") + .select("*") + .eq("client_id", client_id) + .eq("is_default", True) + .maybe_single() + .execute() + ) + if not loc_resp.data: + # Fall back to first location + loc_resp = ( + db.table("locations") + .select("*") + .eq("client_id", client_id) + .limit(1) + .execute() + ) + if not loc_resp.data: + return {"status": "skipped", "reason": "No location configured"} + location = loc_resp.data[0] if isinstance(loc_resp.data, list) else loc_resp.data + else: + loc_resp = ( + db.table("locations") + .select("*") + .eq("id", location_id) + .maybe_single() + .execute() + ) + location = loc_resp.data if loc_resp.data else None + if not location: + return {"status": "failed", "error": "Location not found"} + + # Upsert canonical + menu_item = await upsert_canonical(client_id, location["id"], item, origin) + + # Reconcile + result = await reconcile_item(location, client, menu_item) + + return {"status": "completed", "targets": result.get("targets", {})} + + +# --------------------------------------------------------------------------- +# Square inbound (bi-directional) +# --------------------------------------------------------------------------- + +async def apply_square_inbound(client_id: str, changed_objects: list[dict]) -> dict: + """Apply Square catalog changes to the canonical store. + + For each changed Square ITEM object: + 1. Find the menu_item_links row by external_id (platform='square') + 2. Resolve the canonical item + location + 3. upsert_canonical(origin='square') with the mapped content + 4. Set Square's last_synced_hash to the new hash (no echo back to Square) + 5. reconcile_item to push to OTHER targets only (skip_platform='square') + + Returns ``{"applied": int}``. + """ + from services.square_catalog import square_object_to_canonical - log_sync_results(client_id, item, targets, cleaned) - return {"status": "completed", "targets": dict(zip(targets, cleaned))} + db = get_db() + applied = 0 + + for obj in changed_objects: + if obj.get("type") != "ITEM": + continue + + external_id = obj.get("id") + if not external_id: + continue + + # Find link by external_id + link_resp = ( + db.table("menu_item_links") + .select("*, menu_items(*)") + .eq("platform", "square") + .eq("external_id", external_id) + .maybe_single() + .execute() + ) + + if not link_resp.data: + logger.info("Square inbound: no existing link for %s, skipping", external_id) + continue + + link = link_resp.data + menu_item = link.get("menu_items") + if not menu_item: + continue + + # Map Square object to canonical item + square_item = square_object_to_canonical(obj) + square_item["sheet_row_key"] = menu_item.get("sheet_row_key") or square_item["name"] + + new_hash = compute_content_hash(square_item) + + # Skip if already synced (echo prevention — should not happen since we + # set last_synced_hash before reconcile, but guard against re-delivery) + if link.get("last_synced_hash") == new_hash: + continue + + location_id = menu_item["location_id"] + + # Upsert canonical with origin='square' + updated_item = await upsert_canonical(client_id, location_id, square_item, origin="square") + + # Set Square's last_synced_hash to the new hash (no echo back) + db.table("menu_item_links").update({ + "last_synced_hash": new_hash, + "last_synced_at": datetime.now(timezone.utc).isoformat(), + "external_version": obj.get("version"), + }).eq("id", link["id"]).execute() + + # Reconcile to OTHER targets only (skip Square) + loc_resp = ( + db.table("locations") + .select("*") + .eq("id", location_id) + .maybe_single() + .execute() + ) + client_resp = ( + db.table("clients") + .select("*") + .eq("id", client_id) + .maybe_single() + .execute() + ) + if loc_resp.data and client_resp.data: + await reconcile_item(loc_resp.data, client_resp.data, updated_item, skip_platform="square") + + applied += 1 + + return {"applied": applied} diff --git a/backend/main.py b/backend/main.py index 271873f..bc6223b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ from config import settings from db import init_db from scheduler import create_scheduler -from routers import auth, webhooks, drafts +from routers import auth, webhooks, drafts, locations, menu logging.basicConfig(level=logging.INFO) @@ -75,6 +75,8 @@ async def lifespan(app: FastAPI): app.include_router(auth.router, prefix="/auth") app.include_router(webhooks.router, prefix="/webhooks") app.include_router(drafts.router, prefix="/drafts") +app.include_router(locations.router, prefix="/locations") +app.include_router(menu.router, prefix="/menu") try: from routers import approve app.include_router(approve.router, prefix="/approve") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d0fd0c2..545ef29 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "sentry-sdk>=1.40.0", "cryptography>=42.0.0", "PyJWT>=2.8.0", + "python-multipart>=0.0.9", ] [tool.uv] diff --git a/backend/routers/approve.py b/backend/routers/approve.py index 51dc654..43605bc 100644 --- a/backend/routers/approve.py +++ b/backend/routers/approve.py @@ -9,7 +9,11 @@ @router.post("/review/{draft_id}") async def approve_review(draft_id: str, edited_text: str | None = None, auth: dict = Depends(require_auth)): - """Approve a draft and post the reply to GBP. If edited_text is provided, post that instead.""" + """Approve a draft and post the reply to GBP. If edited_text is provided, post that instead. + + C2: resolves the GBP location path from the draft's location_id (locations + table), not from clients.gbp_location_id. + """ db = get_db() draft_resp = db.table("drafts").select("*").eq("id", draft_id).single().execute() if not draft_resp.data: @@ -21,19 +25,34 @@ async def approve_review(draft_id: str, edited_text: str | None = None, auth: di reply_text = edited_text if edited_text else draft["draft_text"] - client_resp = db.table("clients").select("gbp_access_token, gbp_location_id").eq("id", draft["client_id"]).single().execute() + client_resp = db.table("clients").select("gbp_access_token").eq("id", draft["client_id"]).single().execute() if not client_resp.data: raise HTTPException(status_code=404, detail="Client not found") - client = client_resp.data - access_token = client.get("gbp_access_token") - location_id = client.get("gbp_location_id") + access_token = client_resp.data.get("gbp_access_token") + + # C2: resolve GBP location path from draft's location_id (locations table) + gbp_location_path = None + draft_location_id = draft.get("location_id") + if draft_location_id: + loc_resp = ( + db.table("locations") + .select("gbp_account_id, gbp_location_id") + .eq("id", draft_location_id) + .maybe_single() + .execute() + ) + if loc_resp.data: + acct = loc_resp.data.get("gbp_account_id") + loc = loc_resp.data.get("gbp_location_id") + if acct and loc: + gbp_location_path = f"accounts/{acct}/locations/{loc}" posted = False - if access_token and location_id and draft.get("source_id"): + if access_token and gbp_location_path and draft.get("source_id"): try: from services.gbp import post_review_reply - posted = await post_review_reply(location_id, draft["source_id"], reply_text, access_token) + posted = await post_review_reply(gbp_location_path, draft["source_id"], reply_text, access_token) except Exception as e: logger.error(f"GBP post failed for draft {draft_id}: {e}") diff --git a/backend/routers/auth.py b/backend/routers/auth.py index cb0dc83..110f092 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -180,3 +180,74 @@ async def billing_setup_intent(payload: dict): except Exception as e: logger.error(f"Stripe SetupIntent failed for {client_id}: {e}") raise HTTPException(status_code=502, detail="Payment setup failed") + + +# --------------------------------------------------------------------------- +# Square OAuth (mirror GBP flow) +# --------------------------------------------------------------------------- + +@router.get("/square-oauth-url") +async def get_square_oauth_url(client_id: str = Query(...)): + """Get Square OAuth URL for a client to connect their Square account.""" + try: + from services.square_oauth import get_square_auth_url + url = get_square_auth_url(client_id) + return {"oauth_url": url} + except Exception as e: + logger.error(f"Square OAuth URL failed for {client_id}: {e}") + raise HTTPException(status_code=500, detail="OAuth URL generation failed") + + +@router.get("/square-callback") +async def square_callback(code: str = Query(...), state: str = Query(...)): + """Square OAuth callback — exchange code, encrypt tokens, store on client, + then list locations and pair square_location_id onto locations rows.""" + client_id = state + try: + from services.square_oauth import exchange_code_for_tokens, list_locations + + token_response = await exchange_code_for_tokens(code) + if "access_token" not in token_response: + raise ValueError("Square token exchange missing access_token") + + encrypted_access = encrypt(token_response["access_token"]) + encrypted_refresh = encrypt(token_response.get("refresh_token", "")) + merchant_id = token_response.get("merchant_id", "") + expires_at = token_response.get("expires_at") + + db = get_db() + db.table("clients").update({ + "square_access_token": encrypted_access, + "square_refresh_token": encrypted_refresh, + "square_merchant_id": merchant_id, + "square_token_expires_at": expires_at, + }).eq("id", client_id).execute() + + # List locations and pair square_location_id onto matching locations rows + locations = await list_locations(token_response["access_token"]) + for sq_loc in locations: + sq_loc_id = sq_loc.get("id") + sq_loc_name = sq_loc.get("name", "") + if not sq_loc_id: + continue + # Match by name to existing location (D11-A auto-map by name) + existing = ( + db.table("locations") + .select("id") + .eq("client_id", client_id) + .eq("name", sq_loc_name) + .maybe_single() + .execute() + ) + if existing and existing.data: + db.table("locations").update({ + "square_location_id": sq_loc_id, + }).eq("id", existing.data["id"]).execute() + + return {"status": "connected", "client_id": client_id, "merchant_id": merchant_id} + except ValueError as e: + logger.error(f"Square OAuth validation failed for {client_id}: {e}") + raise HTTPException(status_code=400, detail=f"Square OAuth failed: {e}") + except Exception as e: + logger.error(f"Square OAuth callback failed for {client_id}: {e}") + raise HTTPException(status_code=400, detail=f"Square OAuth failed: {e}") diff --git a/backend/routers/locations.py b/backend/routers/locations.py new file mode 100644 index 0000000..12318b7 --- /dev/null +++ b/backend/routers/locations.py @@ -0,0 +1,81 @@ +"""Locations management API (Phase 3 — C7 read/write for Phase 5). + +Uses ``client_id`` as a query param since Phase 1 tenant-auth binding is not +merged yet. Once C8 lands, ``client_id`` will be derived from the authenticated +identity. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query + +from db import get_db +from middleware.auth import require_auth + +router = APIRouter() + + +@router.get("") +async def list_locations( + client_id: str = Query(...), + auth: dict = Depends(require_auth), +): + """List all locations for a client.""" + db = get_db() + resp = ( + db.table("locations") + .select("*") + .eq("client_id", client_id) + .is_("deleted_at", "null") + .order("is_default", desc=True) + .execute() + ) + return {"locations": resp.data} + + +@router.post("") +async def create_location(payload: dict, auth: dict = Depends(require_auth)): + """Create a new location for a client.""" + required = ["client_id", "name"] + for field in required: + if field not in payload: + raise HTTPException(status_code=422, detail=f"Missing field: {field}") + + db = get_db() + row = { + "client_id": payload["client_id"], + "name": payload["name"], + "suburb": payload.get("suburb"), + "state": payload.get("state"), + "gbp_account_id": payload.get("gbp_account_id"), + "gbp_location_id": payload.get("gbp_location_id"), + "square_location_id": payload.get("square_location_id"), + "place_id": payload.get("place_id"), + "menu_sync_targets": payload.get("menu_sync_targets", []), + "is_default": payload.get("is_default", False), + } + resp = db.table("locations").insert(row).execute() + return {"location": resp.data[0] if resp.data else {}} + + +@router.patch("/{location_id}") +async def update_location( + location_id: str, payload: dict, auth: dict = Depends(require_auth) +): + """Update a location (target toggles, Square pairing, etc.).""" + db = get_db() + allowed = { + "name", "suburb", "state", "gbp_account_id", "gbp_location_id", + "square_location_id", "place_id", "menu_sync_targets", "is_default", + } + update = {k: v for k, v in payload.items() if k in allowed} + if not update: + raise HTTPException(status_code=422, detail="No valid fields to update") + + resp = ( + db.table("locations") + .update(update) + .eq("id", location_id) + .execute() + ) + if not resp.data: + raise HTTPException(status_code=404, detail="Location not found") + return {"location": resp.data[0]} diff --git a/backend/routers/menu.py b/backend/routers/menu.py new file mode 100644 index 0000000..03e99a8 --- /dev/null +++ b/backend/routers/menu.py @@ -0,0 +1,72 @@ +"""Menu image upload endpoint (Phase 3 — C5 image ingestion contract). + +Authenticated multipart upload endpoint. The alternative ingestion path (optional +Sheets ``image_url`` on the menu-update webhook) is handled by +``services/menu_images.py``. +""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File + +from db import get_db +from middleware.auth import require_auth + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/{location_id}/items/{item_id}/image") +async def upload_menu_image( + location_id: str, + item_id: str, + file: UploadFile = File(...), + auth: dict = Depends(require_auth), +): + """Upload a menu item image (multipart). + + Stores in the Supabase ``menu-images`` bucket, inserts a ``menu_images`` row, + and best-effort propagates to Square + GBP. + """ + from services.menu_images import store_image, sync_image_to_platforms + + image_bytes = await file.read() + if not image_bytes: + raise HTTPException(status_code=422, detail="Empty file") + + # Verify the menu item belongs to this location + db = get_db() + item_resp = ( + db.table("menu_items") + .select("*") + .eq("id", item_id) + .eq("location_id", location_id) + .maybe_single() + .execute() + ) + if not item_resp.data: + raise HTTPException(status_code=404, detail="Menu item not found") + + menu_image = await store_image(item_id, image_bytes, file.filename or "image.jpg") + + # Best-effort platform sync + location = ( + db.table("locations") + .select("*") + .eq("id", location_id) + .maybe_single() + .execute() + ) + client = ( + db.table("clients") + .select("*") + .eq("id", item_resp.data["client_id"]) + .maybe_single() + .execute() + ) + if location.data and client.data: + try: + await sync_image_to_platforms(menu_image, location.data, client.data) + except Exception as e: + logger.error("Image platform sync failed: %s", e) + + return {"status": "uploaded", "menu_image": menu_image} diff --git a/backend/routers/webhooks.py b/backend/routers/webhooks.py index dea0e51..38b939d 100644 --- a/backend/routers/webhooks.py +++ b/backend/routers/webhooks.py @@ -1,5 +1,6 @@ import base64 import hashlib +import hmac import json import logging from datetime import datetime, timezone @@ -324,6 +325,21 @@ async def process_review(payload: dict): logger.warning(f"No client found for GBP location: {name}") return + # C2: resolve location_id from the GBP review name for draft storage + db = get_db() + gbp_loc_id = _extract_gbp_location_id(name) + location_id = None + if gbp_loc_id: + loc_resp = ( + db.table("locations") + .select("id") + .eq("gbp_location_id", gbp_loc_id) + .maybe_single() + .execute() + ) + if loc_resp.data: + location_id = loc_resp.data["id"] + from middleware.trial_gate import check_trial_gate, increment_trial_usage gate = await check_trial_gate(client["id"], "review_drafts") if not gate["allowed"]: @@ -351,9 +367,9 @@ async def process_review(payload: dict): raise review_id = name.split("/")[-1] if name else "" - db = get_db() db.table("drafts").insert({ "client_id": client["id"], + "location_id": location_id, "job": "review_response", "source_id": review_id, "source": "google", @@ -365,18 +381,48 @@ async def process_review(payload: dict): await increment_trial_usage(client["id"], "review_drafts") -def resolve_client_from_location(gbp_name: str) -> dict | None: - """Map GBP `accounts/{accountId}/locations/{locationId}/reviews/{reviewId}` to client.""" - db = get_db() - parts = gbp_name.split("/") - location_id = None +def _extract_gbp_location_id(gbp_name: str) -> str | None: + """Extract the locationId from a GBP resource name. + + Handles ``accounts/{a}/locations/{loc}/reviews/{r}`` (and similar shapes) + by returning the segment immediately after ``locations``. + """ + parts = (gbp_name or "").split("/") for i, part in enumerate(parts): if part == "locations" and i + 1 < len(parts): - location_id = parts[i + 1] - break - if not location_id: + return parts[i + 1] + return None + + +def resolve_client_from_location(gbp_name: str) -> dict | None: + """Map GBP `accounts/{accountId}/locations/{locationId}/reviews/{reviewId}` to client. + + Resolves by ``locations.gbp_location_id`` (the new authoritative table per + C2), not ``clients.gbp_location_id`` (which is deprecated/backfilled). + """ + db = get_db() + gbp_location_id = _extract_gbp_location_id(gbp_name) + if not gbp_location_id: + return None + + # Resolve via locations table (C2 — single source of truth for GBP identity) + loc_resp = ( + db.table("locations") + .select("client_id") + .eq("gbp_location_id", gbp_location_id) + .maybe_single() + .execute() + ) + if not loc_resp.data: return None - resp = db.table("clients").select("*").eq("gbp_location_id", location_id).maybe_single().execute() + + resp = ( + db.table("clients") + .select("*") + .eq("id", loc_resp.data["client_id"]) + .maybe_single() + .execute() + ) return resp.data if resp.data else None @@ -384,12 +430,8 @@ def resolve_client_from_location(gbp_name: str) -> dict | None: # Menu update (Google Sheets webhook) # --------------------------------------------------------------------------- -@router.post("/menu-update/{client_id}") -async def menu_update(client_id: str, payload: dict, request: Request): - """Persist + enqueue a Google Sheets menu change. Returns 200 fast. - - item = {name, price_cents=int(float(price)*100), description, category, active}. - """ +def _build_menu_item(payload: dict) -> dict: + """Build a canonical menu item dict from a Sheets webhook payload.""" item = { "name": payload.get("name"), "price_cents": int(float(payload.get("price", 0)) * 100), @@ -397,16 +439,136 @@ async def menu_update(client_id: str, payload: dict, request: Request): "category": payload.get("category", ""), "active": payload.get("active", True), } - # Synthetic idempotency key: client + item name + minute bucket. + # Optional stable row key from Sheets (defaults to name) + if payload.get("sheet_row_key"): + item["sheet_row_key"] = payload["sheet_row_key"] + # Optional image_url (C5 — menu image ingestion via Sheets) + if payload.get("image_url"): + item["image_url"] = payload["image_url"] + return item + + +async def _persist_menu_update( + request: Request, client_id: str, location_id: str | None, payload: dict +) -> dict: + """Build a canonical item, dedupe-persist, and enqueue a Sheets menu-update. + + The idempotency key is derived from ``client_id`` (+ ``location_id`` when + present), the item name, and a minute bucket, so a re-delivery is deduped. + """ + item = _build_menu_item(payload) bucket = datetime.now(timezone.utc).strftime("%Y%m%d%H%M") - raw = f"{client_id}:{item['name']}:{bucket}" + raw = ":".join(p for p in [client_id, location_id, item["name"], bucket] if p) idempotency_key = hashlib.sha256(raw.encode()).hexdigest() result = _persist_event( - "menu", idempotency_key, "menu-update", {"client_id": client_id, "item": item} + "menu", idempotency_key, "menu-update", + {"client_id": client_id, "location_id": location_id, "item": item, "origin": "sheets"}, ) if result["status"] == "duplicate": return {"status": "duplicate"} await _enqueue(request, "process_menu_update", result["event_id"]) return {"status": "received"} + + +@router.post("/menu-update/{client_id}/{location_id}") +async def menu_update_location( + client_id: str, location_id: str, payload: dict, request: Request +): + """Persist + enqueue a Google Sheets menu change for a specific location.""" + return await _persist_menu_update(request, client_id, location_id, payload) + + +@router.post("/menu-update/{client_id}") +async def menu_update(client_id: str, payload: dict, request: Request): + """Persist + enqueue a Google Sheets menu change (compat: resolves default location).""" + return await _persist_menu_update(request, client_id, None, payload) + + +# --------------------------------------------------------------------------- +# Square catalog inbound webhook +# --------------------------------------------------------------------------- + +def _verify_square_signature(raw_body: bytes, signature: str, notification_url: str) -> bool: + """Verify Square webhook signature (HMAC-SHA256 of notification_url + raw_body).""" + if not settings.square_webhook_signature_key: + return False + key = settings.square_webhook_signature_key.encode() + message = notification_url.encode() + raw_body + expected = hmac.new(key, message, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) + + +@router.post("/square/catalog") +async def square_catalog_webhook(request: Request): + """Square catalog.version.updated webhook. + + Verify HMAC signature, resolve client from merchant_id, read the watermark + from square_sync_state, call SearchCatalogObjects, apply inbound changes, + and persist the new latest_time watermark. + """ + raw_body = await request.body() + signature = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = f"https://{settings.base_domain}/webhooks/square/catalog" + + if not _verify_square_signature(raw_body, signature, notification_url): + raise HTTPException(status_code=400, detail="Invalid signature") + + try: + payload = json.loads(raw_body) + except (json.JSONDecodeError, ValueError): + raise HTTPException(status_code=400, detail="Invalid payload") + + merchant_id = payload.get("merchant_id") + if not merchant_id: + raise HTTPException(status_code=400, detail="Missing merchant_id") + + # Resolve client from square_merchant_id + db = get_db() + client_resp = ( + db.table("clients") + .select("*") + .eq("square_merchant_id", merchant_id) + .maybe_single() + .execute() + ) + if not client_resp.data: + logger.warning("Square webhook: no client for merchant_id %s", merchant_id) + raise HTTPException(status_code=404, detail="Unknown merchant") + + client = client_resp.data + + # Read watermark from square_sync_state + state_resp = ( + db.table("square_sync_state") + .select("latest_time") + .eq("client_id", client["id"]) + .maybe_single() + .execute() + ) + begin_time = state_resp.data.get("latest_time") if state_resp.data else None + + # Search for changed catalog objects + from services.square_oauth import get_valid_token + from services.square_catalog import search_changed + from jobs.menu_sync import apply_square_inbound + + access_token = await get_valid_token(client) + search_result = await search_changed(access_token, begin_time) + changed_objects = search_result.get("objects", []) + new_latest_time = search_result.get("latest_time") + + # Apply inbound changes (bi-directional reconciliation) + if changed_objects: + await apply_square_inbound(client["id"], changed_objects) + + # Persist new watermark + if new_latest_time: + db.table("square_sync_state").upsert({ + "client_id": client["id"], + "latest_time": new_latest_time, + "updated_at": datetime.now(timezone.utc).isoformat(), + }).execute() + + return {"status": "received"} diff --git a/backend/services/gbp_media.py b/backend/services/gbp_media.py new file mode 100644 index 0000000..bc2380b --- /dev/null +++ b/backend/services/gbp_media.py @@ -0,0 +1,49 @@ +"""GBP Media upload service — POST v4 media with sourceUrl (URL method). + +Uses the URL method (sourceUrl) rather than the 3-step byte upload, since +Supabase public bucket URLs are already publicly accessible. +""" + +import logging + +import httpx + +logger = logging.getLogger(__name__) + +GBP_MEDIA_BASE = "https://mybusiness.googleapis.com/v4" + + +async def upload_location_photo( + access_token: str, + gbp_account_id: str, + gbp_location_id: str, + source_url: str, + category: str = "MENU", +) -> str: + """Upload a photo to a GBP location via the Media API (URL method). + + POST v4 ``/accounts/{accountId}/locations/{locationId}/media`` with + ``sourceUrl`` (public Supabase URL). Returns the GBP media resource name. + """ + url = ( + f"{GBP_MEDIA_BASE}/accounts/{gbp_account_id}" + f"/locations/{gbp_location_id}/media" + ) + + body = { + "mediaFormat": "PHOTO", + "locationAssociation": {"category": category}, + "sourceUrl": source_url, + } + + headers = {"Authorization": f"Bearer {access_token}"} + + try: + async with httpx.AsyncClient() as http: + resp = await http.post(url, json=body, headers=headers, timeout=30.0) + resp.raise_for_status() + data = resp.json() + return data.get("name", "") + except httpx.HTTPError as e: + logger.error("GBP upload_location_photo failed: %s", e) + raise diff --git a/backend/services/menu_images.py b/backend/services/menu_images.py new file mode 100644 index 0000000..645c86e --- /dev/null +++ b/backend/services/menu_images.py @@ -0,0 +1,129 @@ +"""Menu image storage + platform propagation service. + +Images live in a Supabase storage bucket (``menu-images``, public read). This +service handles both ingestion paths (C5): the authenticated multipart upload +endpoint and the optional Sheets ``image_url`` on the menu-update webhook. +""" + +import logging +from datetime import datetime, timezone + +from db import get_db +from config import settings + +logger = logging.getLogger(__name__) + + +async def store_image(menu_item_id: str, image_bytes: bytes, filename: str) -> dict: + """Upload an image to the Supabase storage bucket and insert a menu_images row. + + Returns the menu_images row dict. + """ + db = get_db() + bucket = settings.menu_images_bucket + storage_path = f"{menu_item_id}/{filename}" + + # Upload to Supabase storage + db.storage.from_(bucket).upload(storage_path, image_bytes) + + # Build public URL + public_url = db.storage.from_(bucket).get_public_url(storage_path) + + # Insert menu_images row + resp = ( + db.table("menu_images") + .insert({ + "menu_item_id": menu_item_id, + "storage_path": storage_path, + "public_url": public_url, + "is_primary": True, + }) + .execute() + ) + return resp.data[0] if resp.data else {} + + +async def sync_image_to_platforms( + menu_image: dict, location: dict, client: dict +) -> dict: + """Propagate a stored menu image to Square and GBP. + + Calls ``square_images.create_catalog_image`` (needs the item's Square + ``external_id`` from ``menu_item_links``) and + ``gbp_media.upload_location_photo``. Persists ``square_image_id``, + ``gbp_media_name``, ``synced_at`` on the menu_images row. + + Returns ``{"square_image_id": str | None, "gbp_media_name": str | None}``. + """ + from services.square_oauth import get_valid_token + from services.square_images import create_catalog_image + from services.gbp_media import upload_location_photo + from services.crypto import decrypt + + db = get_db() + menu_item_id = menu_image["menu_item_id"] + + result: dict = {"square_image_id": None, "gbp_media_name": None} + + # --- Square image upload --- + square_location_id = location.get("square_location_id") + if square_location_id: + try: + link_resp = ( + db.table("menu_item_links") + .select("external_id") + .eq("menu_item_id", menu_item_id) + .eq("platform", "square") + .maybe_single() + .execute() + ) + external_id = ( + link_resp.data.get("external_id") if link_resp.data else None + ) + + if external_id: + access_token = await get_valid_token(client) + image_bytes = db.storage.from_( + settings.menu_images_bucket + ).download(menu_image["storage_path"]) + square_image_id = await create_catalog_image( + access_token, + external_id, + image_bytes, + menu_image["storage_path"].split("/")[-1], + ) + result["square_image_id"] = square_image_id + except Exception as e: + logger.error( + "Square image sync failed for menu_item %s: %s", menu_item_id, e + ) + + # --- GBP media upload --- + gbp_account_id = location.get("gbp_account_id") + gbp_location_id = location.get("gbp_location_id") + if gbp_account_id and gbp_location_id: + try: + enc_access = client.get("gbp_access_token", "") + if enc_access: + access_token = decrypt(enc_access) + gbp_media_name = await upload_location_photo( + access_token, + gbp_account_id, + gbp_location_id, + menu_image["public_url"], + ) + result["gbp_media_name"] = gbp_media_name + except Exception as e: + logger.error( + "GBP image sync failed for menu_item %s: %s", menu_item_id, e + ) + + # Persist results + update: dict = {"synced_at": datetime.now(timezone.utc).isoformat()} + if result["square_image_id"]: + update["square_image_id"] = result["square_image_id"] + if result["gbp_media_name"]: + update["gbp_media_name"] = result["gbp_media_name"] + db.table("menu_images").update(update).eq("id", menu_image["id"]).execute() + + return result diff --git a/backend/services/square_catalog.py b/backend/services/square_catalog.py new file mode 100644 index 0000000..7ac6d26 --- /dev/null +++ b/backend/services/square_catalog.py @@ -0,0 +1,160 @@ +"""Square Catalog API service — upsert, search, delete (httpx, no SDK). + +Deterministic idempotency keys prevent duplicate catalog objects on re-sync. +search_changed drives the inbound reconciliation watermark flow. +""" + +import logging +import uuid + +import httpx + +from services.square_oauth import square_base_url, SQUARE_API_VERSION + +logger = logging.getLogger(__name__) + + +def _headers(access_token: str) -> dict: + return { + "Authorization": f"Bearer {access_token}", + "Square-Version": SQUARE_API_VERSION, + "Content-Type": "application/json", + } + + +async def upsert_item( + access_token: str, + square_location_id: str, + item: dict, + external_id: str | None = None, + external_version: int | None = None, +) -> dict: + """Upsert a Square Catalog ITEM via POST /v2/catalog/object. + + When ``external_id`` is provided, reuses it as the object ``id`` (update, not + create) and passes ``external_version`` for optimistic concurrency. The + idempotency key is deterministic: ``f"{menu_item_id}:square:{content_hash}"`` + so retries/re-syncs never create duplicates. + + ``present_at_all_locations=false`` + ``present_at_location_ids`` scopes the + item to the venue. + + Returns ``{"id": str, "version": int}`` from the Square response. + """ + url = f"{square_base_url()}/v2/catalog/object" + + menu_item_id = item.get("id", "") + content_hash = item.get("content_hash", "") + if menu_item_id and content_hash: + idempotency_key = f"{menu_item_id}:square:{content_hash}" + elif menu_item_id: + idempotency_key = f"{menu_item_id}:square" + else: + idempotency_key = str(uuid.uuid4()) + + obj: dict = { + "type": "ITEM", + "present_at_all_locations": False, + "present_at_location_ids": [square_location_id], + "item_data": { + "name": item["name"], + "description": item.get("description", ""), + "variations": [ + { + "type": "ITEM_VARIATION", + "item_variation_data": { + "name": "Regular", + "price_money": { + "amount": item["price_cents"], + "currency": "AUD", + }, + "pricing_type": "FIXED_PRICING", + }, + } + ], + }, + } + + if external_id: + obj["id"] = external_id + if external_version is not None: + obj["version"] = external_version + else: + obj["id"] = "#temp" # Square client-side temp id for create + + body = { + "idempotency_key": idempotency_key, + "object": obj, + } + + try: + async with httpx.AsyncClient() as http: + resp = await http.post(url, json=body, headers=_headers(access_token), timeout=30.0) + resp.raise_for_status() + data = resp.json() + catalog_obj = data.get("catalog_object", {}) + return { + "id": catalog_obj.get("id"), + "version": catalog_obj.get("version"), + } + except httpx.HTTPError as e: + logger.error("Square catalog upsert failed for %s: %s", item.get("name"), e) + raise + + +async def search_changed(access_token: str, begin_time: str | None) -> dict: + """Search for catalog changes since ``begin_time`` via POST /v2/catalog/search. + + ``begin_time`` is exclusive. ``include_deleted_objects=true`` catches + deletions. Returns ``{"objects": [...], "latest_time": str | None}``. + """ + url = f"{square_base_url()}/v2/catalog/search" + + body: dict = { + "include_deleted_objects": True, + "include_related_objects": False, + } + if begin_time: + body["begin_time"] = begin_time + + try: + async with httpx.AsyncClient() as http: + resp = await http.post(url, json=body, headers=_headers(access_token), timeout=30.0) + resp.raise_for_status() + data = resp.json() + return { + "objects": data.get("objects", []), + "latest_time": data.get("latest_time"), + } + except httpx.HTTPError as e: + logger.error("Square catalog search failed: %s", e) + raise + + +async def delete_item(access_token: str, external_id: str) -> None: + """Delete a Square Catalog object by id.""" + url = f"{square_base_url()}/v2/catalog/object/{external_id}" + + try: + async with httpx.AsyncClient() as http: + resp = await http.delete(url, headers=_headers(access_token), timeout=30.0) + resp.raise_for_status() + except httpx.HTTPError as e: + logger.error("Square catalog delete failed for %s: %s", external_id, e) + raise + + +def square_object_to_canonical(obj: dict) -> dict: + """Map a Square Catalog ITEM object to our canonical item dict.""" + item_data = obj.get("item_data", {}) + variations = item_data.get("variations", []) + first_variation = variations[0] if variations else {} + price_money = first_variation.get("item_variation_data", {}).get("price_money", {}) + + return { + "name": item_data.get("name", ""), + "description": item_data.get("description", ""), + "price_cents": price_money.get("amount", 0), + "category": "", + "active": not obj.get("is_deleted", False), + } diff --git a/backend/services/square_images.py b/backend/services/square_images.py new file mode 100644 index 0000000..6a87787 --- /dev/null +++ b/backend/services/square_images.py @@ -0,0 +1,69 @@ +"""Square Catalog Image upload service — multipart POST /v2/catalog/images (no SDK). + +Builds multipart/form-data manually with httpx: part ``request`` = JSON, part +``file`` = image bytes. Square-Version 2024-06-19. +""" + +import json +import logging +import uuid + +import httpx + +from services.square_oauth import square_base_url, SQUARE_API_VERSION + +logger = logging.getLogger(__name__) + + +async def create_catalog_image( + access_token: str, + object_id: str, + image_bytes: bytes, + filename: str, + caption: str = "", +) -> str: + """Upload an image to Square and attach it to a CatalogItem. + + POST /v2/catalog/images as multipart/form-data: + - part ``request``: JSON ``{idempotency_key, image:{...}, object_id, is_primary}`` + - part ``file``: image bytes + + Returns the Square CatalogImage id. + """ + url = f"{square_base_url()}/v2/catalog/images" + + request_json = json.dumps({ + "idempotency_key": str(uuid.uuid4()), + "image": { + "type": "IMAGE", + "id": "#temp", + "image_data": { + "name": filename, + "caption": caption, + }, + }, + "object_id": object_id, + "is_primary": True, + }) + + headers = { + "Authorization": f"Bearer {access_token}", + "Square-Version": SQUARE_API_VERSION, + } + + try: + async with httpx.AsyncClient() as http: + resp = await http.post( + url, + headers=headers, + data={"request": request_json}, + files={"file": (filename, image_bytes, "image/jpeg")}, + timeout=60.0, + ) + resp.raise_for_status() + data = resp.json() + image_obj = data.get("image", {}) + return image_obj.get("id") + except httpx.HTTPError as e: + logger.error("Square create_catalog_image failed: %s", e) + raise diff --git a/backend/services/square_oauth.py b/backend/services/square_oauth.py new file mode 100644 index 0000000..c3c7644 --- /dev/null +++ b/backend/services/square_oauth.py @@ -0,0 +1,161 @@ +"""Square OAuth + token management service. + +Mirrors services/gbp.py + routers/auth.py GBP flow. Tokens are seller-scoped +(not per-location), so one token per client is stored on `clients`. The token +is Fernet-encrypted at rest (services/crypto.py). +""" + +import logging +from datetime import datetime, timezone + +import httpx + +from config import settings + +logger = logging.getLogger(__name__) + +SQUARE_API_VERSION = "2024-06-19" + +SQUARE_PRODUCTION_BASE = "https://connect.squareup.com" +SQUARE_SANDBOX_BASE = "https://connect.squareupsandbox.com" + +SQUARE_AUTHORIZE_PATH = "/oauth2/authorize" +SQUARE_TOKEN_PATH = "/oauth2/token" + + +def square_base_url() -> str: + """Return the Square Connect base URL for the configured environment.""" + if settings.square_environment == "production": + return SQUARE_PRODUCTION_BASE + return SQUARE_SANDBOX_BASE + + +def get_square_auth_url(client_id: str) -> str: + """Build Square OAuth authorize URL for a client to connect their Square account. + + Scopes: ITEMS_READ ITEMS_WRITE (menu catalog) + MERCHANT_PROFILE_READ (list + locations). state=client_id (mirrors GBP OAuth flow). Sandbox-aware host. + """ + base = square_base_url() + redirect_uri = f"https://{settings.base_domain}{settings.square_oauth_redirect_path}" + scopes = "ITEMS_READ ITEMS_WRITE MERCHANT_PROFILE_READ" + params = ( + f"client_id={settings.square_app_id}" + f"&scope={scopes}" + f"&redirect_uri={redirect_uri}" + f"&state={client_id}" + f"&session=false" + ) + return f"{base}{SQUARE_AUTHORIZE_PATH}?{params}" + + +async def exchange_code_for_tokens(code: str) -> dict: + """Exchange OAuth authorization code for access + refresh tokens.""" + url = f"{square_base_url()}{SQUARE_TOKEN_PATH}" + data = { + "client_id": settings.square_app_id, + "client_secret": settings.square_app_secret, + "code": code, + "grant_type": "authorization_code", + } + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, data=data, timeout=15) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as e: + logger.error("Square token exchange failed: %s", e) + raise + + +async def refresh_access_token(refresh_token: str) -> dict: + """Refresh an expired Square access token. Returns the full token response.""" + url = f"{square_base_url()}{SQUARE_TOKEN_PATH}" + data = { + "client_id": settings.square_app_id, + "client_secret": settings.square_app_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, data=data, timeout=15) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as e: + logger.error("Square token refresh failed: %s", e) + raise + + +async def list_locations(access_token: str) -> list[dict]: + """List a merchant's Square locations. Used to populate per-venue square_location_ids.""" + url = f"{square_base_url()}/v2/locations" + headers = { + "Authorization": f"Bearer {access_token}", + "Square-Version": SQUARE_API_VERSION, + } + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers=headers, timeout=15) + resp.raise_for_status() + return resp.json().get("locations", []) + except httpx.HTTPError as e: + logger.error("Square list_locations failed: %s", e) + raise + + +async def get_valid_token(client: dict) -> str: + """Resolve a valid Square access token for the client. + + Decrypts the stored token; if square_token_expires_at has passed (or the token + is absent), refreshes via the stored refresh token, re-encrypts, and persists + the new token + expiry on the clients row. Central helper used by the syncer + and image service. Replaces the global settings.square_access_token path. + """ + from services.crypto import decrypt, encrypt + from db import get_db + + enc_access = client.get("square_access_token", "") + enc_refresh = client.get("square_refresh_token", "") + + if not enc_access and not enc_refresh: + raise RuntimeError(f"Client {client.get('id')} has no Square credentials") + + access_token = decrypt(enc_access) if enc_access else "" + expires_at_str = client.get("square_token_expires_at") + + # Check if we need to refresh + needs_refresh = not access_token + if expires_at_str and access_token: + try: + expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) + if datetime.now(timezone.utc) >= expires_at: + needs_refresh = True + except (ValueError, TypeError): + needs_refresh = True # can't parse expiry — refresh to be safe + + if not needs_refresh: + return access_token + + if not enc_refresh: + # No refresh token — return whatever we have (may be expired) + return access_token + + refresh_token = decrypt(enc_refresh) + token_resp = await refresh_access_token(refresh_token) + + new_access = token_resp.get("access_token", "") + new_refresh = token_resp.get("refresh_token", refresh_token) + new_expiry = token_resp.get("expires_at") # Square returns ISO 8601 expires_at + + # Persist refreshed tokens + db = get_db() + update: dict = { + "square_access_token": encrypt(new_access), + "square_refresh_token": encrypt(new_refresh), + } + if new_expiry: + update["square_token_expires_at"] = new_expiry + db.table("clients").update(update).eq("id", client["id"]).execute() + + return new_access diff --git a/backend/task_queue.py b/backend/task_queue.py index 782bd43..a1de82c 100644 --- a/backend/task_queue.py +++ b/backend/task_queue.py @@ -266,8 +266,10 @@ async def process_menu_update(ctx: dict, event_id: str) -> dict: async def dispatch(event: dict) -> None: payload = event.get("payload", {}) client_id = payload.get("client_id") + location_id = payload.get("location_id") item = payload.get("item", {}) - result = await sync_menu_item(client_id, item) + origin = payload.get("origin", "sheets") + result = await sync_menu_item(client_id, location_id, item, origin) # sync_menu_item swallows per-target errors and returns a status dict. # A hard failure (client missing) or any un-synced target must propagate # so the inbound task retries / dead-letters rather than marking done. @@ -378,27 +380,41 @@ async def _coro() -> Any: ) -async def square_sync_task(ctx: dict, client_id: str, item: dict) -> Any: - """Durable Square catalog upsert (migrated in Phase 3: jobs/menu_sync.py). +async def square_sync_task(ctx: dict, client_id: str, location_id: str, item: dict) -> Any: + """Durable Square catalog upsert (Phase 3: per-client OAuth token). - Security (C4/D4): enqueues only ``client_id`` + the item — never the client - record (which carries square_access_token / PMS credentials). The client row - is loaded inside the worker. + Security (C4/D4): enqueues only ``client_id`` + ``location_id`` + the item — + never the client record (which carries square_access_token / PMS credentials). + The client row and Square token are resolved inside the worker via + ``square_oauth.get_valid_token`` (per-client OAuth, not global settings). """ - from jobs.menu_sync import _sync_square + from services.square_oauth import get_valid_token + from services.square_catalog import upsert_item as square_upsert async def _coro() -> Any: - # Load client (which carries the Square credential) INSIDE the coroutine - # so a DB outage / missing client is retried + dead-lettered like any - # other outbound failure (C4), not raised bare before _run_outbound. db = get_db() client = _load_client(db, client_id) if not client: raise RuntimeError(f"square_sync_task: client {client_id} not found") - return await _sync_square(client, item) + loc_resp = ( + db.table("locations") + .select("*") + .eq("id", location_id) + .maybe_single() + .execute() + ) + if not loc_resp.data: + raise RuntimeError(f"square_sync_task: location {location_id} not found") + access_token = await get_valid_token(client) + result = await square_upsert( + access_token, loc_resp.data["square_location_id"], item + ) + return {"synced": True, "external_id": result.get("id"), + "external_version": result.get("version")} return await _run_outbound( - ctx, "square", client_id, {"client_id": client_id, "item": item}, + ctx, "square", client_id, + {"client_id": client_id, "location_id": location_id, "item": item}, _coro, ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1e670db..8d9ee99 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -26,6 +26,10 @@ "WORKER_ROLE": "web", "STRIPE_PORTAL_CONFIG_ID": "", "DASHBOARD_URL": "https://localmate.crewcircle.co", + "SQUARE_APP_ID": "sq0idp_stub", + "SQUARE_APP_SECRET": "sq0cst_stub", + "SQUARE_WEBHOOK_SIGNATURE_KEY": "sq_wh_sig_stub", + "MENU_IMAGES_BUCKET": "menu-images", } for _k, _v in _STUB_ENV.items(): os.environ.setdefault(_k, _v) diff --git a/backend/tests/unit/test_locations.py b/backend/tests/unit/test_locations.py new file mode 100644 index 0000000..c7636f8 --- /dev/null +++ b/backend/tests/unit/test_locations.py @@ -0,0 +1,168 @@ +"""Tests for locations API + menu webhook location routing + backfill.""" +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from datetime import datetime, timezone + + +# --------------------------------------------------------------------------- +# Location-aware menu webhook +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_menu_update_with_location_id_routes_to_location(): + """POST /webhooks/menu-update/{client_id}/{location_id} includes location_id in payload.""" + from routers import webhooks + + payload = {"name": "Latte", "price": "4.50", "description": "", "category": "coffee"} + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock(data=None) + db.table.return_value.insert.return_value.execute.return_value = MagicMock(data=[{"id": "evt1"}]) + + request = MagicMock() + request.app.state.arq = MagicMock() + request.app.state.arq.enqueue_job = AsyncMock() + + with patch("routers.webhooks.get_db", return_value=db): + result = await webhooks.menu_update_location("c1", "loc1", payload, request) + + assert result["status"] == "received" + # The persisted event payload includes location_id + insert_arg = db.table.return_value.insert.call_args[0][0] + assert insert_arg["payload"]["location_id"] == "loc1" + assert insert_arg["payload"]["client_id"] == "c1" + assert insert_arg["payload"]["origin"] == "sheets" + + +@pytest.mark.asyncio +async def test_menu_update_compat_path_has_null_location(): + """POST /webhooks/menu-update/{client_id} (compat) stores location_id=None in payload.""" + from routers import webhooks + + payload = {"name": "Latte", "price": "4.50"} + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock(data=None) + db.table.return_value.insert.return_value.execute.return_value = MagicMock(data=[{"id": "evt1"}]) + + request = MagicMock() + request.app.state.arq = MagicMock() + request.app.state.arq.enqueue_job = AsyncMock() + + with patch("routers.webhooks.get_db", return_value=db): + result = await webhooks.menu_update("c1", payload, request) + + assert result["status"] == "received" + insert_arg = db.table.return_value.insert.call_args[0][0] + assert insert_arg["payload"]["location_id"] is None + + +# --------------------------------------------------------------------------- +# resolve_client_from_location (C2 — locations table) +# --------------------------------------------------------------------------- + +def test_resolve_client_from_location_uses_locations_table(): + """resolve_client_from_location resolves via locations.gbp_location_id (C2).""" + from routers import webhooks + + db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"client_id": "c1"} + ) + elif name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "business_name": "Test"} + ) + return chain + + db.table.side_effect = _table + + with patch("routers.webhooks.get_db", return_value=db): + client = webhooks.resolve_client_from_location( + "accounts/ACCT1/locations/LOC_GBP_1/reviews/REV1" + ) + + assert client is not None + assert client["id"] == "c1" + + +def test_resolve_client_from_location_returns_none_for_unknown(): + """resolve_client_from_location returns None when no location matches.""" + from routers import webhooks + + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock(data=None) + + with patch("routers.webhooks.get_db", return_value=db): + client = webhooks.resolve_client_from_location( + "accounts/ACCT1/locations/UNKNOWN/reviews/REV1" + ) + + assert client is None + + +# --------------------------------------------------------------------------- +# Locations API +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_list_locations_returns_locations_for_client(): + """GET /locations?client_id=c1 returns the client's locations.""" + from routers.locations import list_locations + + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.is_.return_value.order.return_value.execute.return_value = MagicMock( + data=[{"id": "loc1", "name": "Surry Hills", "client_id": "c1"}] + ) + + with patch("routers.locations.get_db", return_value=db): + result = await list_locations(client_id="c1", auth={"sub": "anonymous"}) + + assert len(result["locations"]) == 1 + assert result["locations"][0]["name"] == "Surry Hills" + + +@pytest.mark.asyncio +async def test_create_location_inserts_row(): + """POST /locations creates a new location.""" + from routers.locations import create_location + + db = MagicMock() + db.table.return_value.insert.return_value.execute.return_value = MagicMock( + data=[{"id": "loc_new", "name": "Bondi", "client_id": "c1"}] + ) + + with patch("routers.locations.get_db", return_value=db): + result = await create_location( + {"client_id": "c1", "name": "Bondi", "menu_sync_targets": ["square", "gbp"]}, + auth={"sub": "anonymous"}, + ) + + assert result["location"]["name"] == "Bondi" + insert_data = db.table.return_value.insert.call_args[0][0] + assert insert_data["menu_sync_targets"] == ["square", "gbp"] + + +@pytest.mark.asyncio +async def test_update_location_updates_allowed_fields(): + """PATCH /locations/{id} updates only allowed fields (target toggles, Square pairing).""" + from routers.locations import update_location + + db = MagicMock() + db.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"id": "loc1", "menu_sync_targets": ["square"], "square_location_id": "SQ_NEW"}] + ) + + with patch("routers.locations.get_db", return_value=db): + result = await update_location( + "loc1", + {"menu_sync_targets": ["square"], "square_location_id": "SQ_NEW", "invalid_field": "hack"}, + auth={"sub": "anonymous"}, + ) + + assert result["location"]["square_location_id"] == "SQ_NEW" + update_data = db.table.return_value.update.call_args[0][0] + assert "invalid_field" not in update_data + assert "menu_sync_targets" in update_data diff --git a/backend/tests/unit/test_menu_images.py b/backend/tests/unit/test_menu_images.py new file mode 100644 index 0000000..7d0a0ff --- /dev/null +++ b/backend/tests/unit/test_menu_images.py @@ -0,0 +1,128 @@ +"""Tests for menu image storage + platform propagation.""" +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + + +@pytest.mark.asyncio +async def test_store_image_uploads_to_bucket_and_inserts_row(): + """store_image uploads to Supabase storage, builds public URL, inserts menu_images row.""" + from services.menu_images import store_image + + db = MagicMock() + # storage.from_(bucket).upload(path, bytes) + storage_mock = MagicMock() + bucket_mock = MagicMock() + bucket_mock.upload.return_value = None + bucket_mock.get_public_url.return_value = "https://cdn.supabase.co/menu-images/mi1/img.jpg" + storage_mock.from_.return_value = bucket_mock + db.storage = storage_mock + # table insert + db.table.return_value.insert.return_value.execute.return_value = MagicMock( + data=[{ + "id": "img1", + "menu_item_id": "mi1", + "storage_path": "mi1/img.jpg", + "public_url": "https://cdn.supabase.co/menu-images/mi1/img.jpg", + }] + ) + + with patch("services.menu_images.get_db", return_value=db), \ + patch("services.menu_images.settings") as mock_settings: + mock_settings.menu_images_bucket = "menu-images" + result = await store_image("mi1", b"fake-image-bytes", "img.jpg") + + assert result["id"] == "img1" + assert result["storage_path"] == "mi1/img.jpg" + assert "public_url" in result + # Upload was called with the right path + bytes + bucket_mock.upload.assert_called_once_with("mi1/img.jpg", b"fake-image-bytes") + # Public URL was retrieved + bucket_mock.get_public_url.assert_called_once_with("mi1/img.jpg") + + +@pytest.mark.asyncio +async def test_sync_image_to_platforms_calls_square_and_gbp(): + """sync_image_to_platforms calls Square create_catalog_image + GBP upload_location_photo + and persists square_image_id + gbp_media_name.""" + from services.menu_images import sync_image_to_platforms + + menu_image = { + "id": "img1", + "menu_item_id": "mi1", + "storage_path": "mi1/img.jpg", + "public_url": "https://cdn.supabase.co/menu-images/mi1/img.jpg", + } + location = { + "id": "loc1", + "square_location_id": "SQ_LOC_1", + "gbp_account_id": "acct1", + "gbp_location_id": "loc_gbp_1", + } + client = { + "id": "c1", + "gbp_access_token": "enc_gbp_token", + "square_access_token": "enc_sq_token", + "square_refresh_token": "enc_sq_refresh", + } + + db = MagicMock() + # menu_item_links select → returns external_id + db.table.return_value.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"external_id": "SQ_OBJ_1"} + ) + # menu_images update + db.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + # storage download for Square image upload + storage_mock = MagicMock() + bucket_mock = MagicMock() + bucket_mock.download.return_value = b"image-bytes" + storage_mock.from_.return_value = bucket_mock + db.storage = storage_mock + + with patch("services.menu_images.get_db", return_value=db), \ + patch("services.menu_images.settings") as mock_settings, \ + patch("services.square_oauth.get_valid_token", new_callable=AsyncMock, return_value="sq_tok"), \ + patch("services.square_images.create_catalog_image", new_callable=AsyncMock, return_value="SQ_IMG_1"), \ + patch("services.gbp_media.upload_location_photo", new_callable=AsyncMock, return_value="media/123"), \ + patch("services.crypto.decrypt", return_value="gbp_tok"): + mock_settings.menu_images_bucket = "menu-images" + result = await sync_image_to_platforms(menu_image, location, client) + + assert result["square_image_id"] == "SQ_IMG_1" + assert result["gbp_media_name"] == "media/123" + # menu_images row was updated with the platform ids + synced_at + update_data = db.table.return_value.update.call_args[0][0] + assert update_data["square_image_id"] == "SQ_IMG_1" + assert update_data["gbp_media_name"] == "media/123" + assert "synced_at" in update_data + + +@pytest.mark.asyncio +async def test_sync_image_to_platforms_handles_missing_square_location(): + """When location has no square_location_id, Square sync is skipped gracefully.""" + from services.menu_images import sync_image_to_platforms + + menu_image = { + "id": "img1", + "menu_item_id": "mi1", + "storage_path": "mi1/img.jpg", + "public_url": "https://cdn.supabase.co/menu-images/mi1/img.jpg", + } + location = { + "id": "loc1", + "square_location_id": None, # no Square + "gbp_account_id": "acct1", + "gbp_location_id": "loc_gbp_1", + } + client = {"id": "c1", "gbp_access_token": "enc_gbp_token"} + + db = MagicMock() + db.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + + with patch("services.menu_images.get_db", return_value=db), \ + patch("services.gbp_media.upload_location_photo", new_callable=AsyncMock, return_value="media/123"), \ + patch("services.crypto.decrypt", return_value="gbp_tok"): + result = await sync_image_to_platforms(menu_image, location, client) + + assert result["square_image_id"] is None + assert result["gbp_media_name"] == "media/123" diff --git a/backend/tests/unit/test_menu_sync.py b/backend/tests/unit/test_menu_sync.py index 57d9aa7..13d43ad 100644 --- a/backend/tests/unit/test_menu_sync.py +++ b/backend/tests/unit/test_menu_sync.py @@ -1,35 +1,372 @@ -"""Tests for menu item sync to Square.""" +"""Tests for menu sync — canonical store, reconciliation, loop guard, backward compat.""" import pytest from unittest.mock import patch, MagicMock, AsyncMock +from jobs.menu_sync import compute_content_hash -@pytest.mark.asyncio -async def test_menu_item_synced_to_square(): - """Menu item is synced to Square catalog via API.""" - from jobs.menu_sync import _sync_square +# --------------------------------------------------------------------------- +# compute_content_hash +# --------------------------------------------------------------------------- + +def test_content_hash_stable_for_same_fields(): + """Hash is identical for items with the same canonical fields.""" + item = { + "name": "Flat White", + "price_cents": 450, + "description": "Double shot", + "category": "coffee", + "active": True, + } + h1 = compute_content_hash(item) + h2 = compute_content_hash(dict(item)) + assert h1 == h2 + + +def test_content_hash_changes_when_field_changes(): + """Hash differs when any canonical field changes.""" + item = { + "name": "Flat White", + "price_cents": 450, + "description": "Double shot", + "category": "coffee", + "active": True, + } + h1 = compute_content_hash(item) + item["price_cents"] = 500 + h2 = compute_content_hash(item) + assert h1 != h2 + + +def test_content_hash_ignores_non_canonical_fields(): + """Non-canonical fields (id, content_hash, origin) don't affect the hash.""" item = { - "name": "Teeth Whitening", - "price_cents": 19900, - "description": "Professional teeth whitening treatment", - "category": "dental", + "name": "Latte", + "price_cents": 500, + "description": "", + "category": "", + "active": True, } + h1 = compute_content_hash(item) + item["id"] = "abc-123" + item["content_hash"] = h1 + item["origin"] = "square" + item["sheet_row_key"] = "row1" + h2 = compute_content_hash(item) + assert h1 == h2 + + +# --------------------------------------------------------------------------- +# reconcile_item — loop guard + target filtering +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_reconcile_skips_platform_when_hash_matches(): + """reconcile_item skips a platform when last_synced_hash == content_hash (loop guard).""" + from jobs.menu_sync import reconcile_item + + menu_item = { + "id": "mi1", + "name": "Latte", + "price_cents": 450, + "description": "", + "category": "", + "active": True, + "content_hash": "hash123", + } + location = { + "id": "loc1", + "menu_sync_targets": ["square", "gbp"], + "square_location_id": "SQ1", + "gbp_account_id": "acct1", + } + client = {"id": "c1"} + + # Both links already have the same hash — should skip both + db = MagicMock() + link_chain = MagicMock() + link_chain.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "link1", "last_synced_hash": "hash123"} + ) + db.table.return_value.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value = link_chain.maybe_single.return_value + # For the update call + db.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + # For log_sync_results insert + db.table.return_value.insert.return_value.execute.return_value = MagicMock(data=[]) + + with patch("jobs.menu_sync.get_db", return_value=db): + result = await reconcile_item(location, client, menu_item) + + assert result["targets"]["square"]["synced"] is True + assert "hash match" in result["targets"]["square"]["message"].lower() + assert result["targets"]["gbp"]["synced"] is True + assert "hash match" in result["targets"]["gbp"]["message"].lower() + + +@pytest.mark.asyncio +async def test_reconcile_pushes_only_to_targets_whose_hash_differs(): + """reconcile_item pushes to a platform only when last_synced_hash != content_hash.""" + from jobs.menu_sync import reconcile_item + + menu_item = { + "id": "mi1", + "name": "Latte", + "price_cents": 450, + "description": "", + "category": "", + "active": True, + "content_hash": "new_hash", + } + location = { + "id": "loc1", + "menu_sync_targets": ["square", "gbp"], + "square_location_id": "SQ1", + "gbp_account_id": "acct1", + } + client = {"id": "c1"} + + call_count = {"square": 0, "gbp": 0} + + async def mock_push_square(loc, cli, mi, link): + call_count["square"] += 1 + return {"synced": True, "message": "Synced to Square", + "external_id": "sq_obj1", "external_version": 2} + + async def mock_push_gbp(loc, cli, mi, link): + call_count["gbp"] += 1 + return {"synced": True, "message": "Synced to GBP"} + + db = MagicMock() + + # Square link has old hash → should push + # GBP link has matching hash → should skip + link_data_map = { + ("square",): {"id": "link_sq", "last_synced_hash": "old_hash"}, + ("gbp",): {"id": "link_gbp", "last_synced_hash": "new_hash"}, + } + + def _table(name): + chain = MagicMock() + if name == "menu_item_links": + # Handle both select (read link) and update/insert (store link) + def _eq(field, val): + c = MagicMock() + if (val,) in link_data_map: + c.maybe_single.return_value.execute.return_value = MagicMock( + data=link_data_map[(val,)] + ) + else: + c.maybe_single.return_value.execute.return_value = MagicMock(data=None) + c.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + c.insert.return_value.execute.return_value = MagicMock(data=[]) + return c + chain.select.return_value.eq.return_value.eq = _eq + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock(data=None) + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + chain.insert.return_value.execute.return_value = MagicMock(data=[]) + elif name == "menu_sync_log": + chain.insert.return_value.execute.return_value = MagicMock(data=[]) + return chain + + db.table.side_effect = _table + + with patch("jobs.menu_sync.get_db", return_value=db), \ + patch("jobs.menu_sync._push_to_square", new_callable=AsyncMock, side_effect=mock_push_square), \ + patch("jobs.menu_sync._push_to_gbp", new_callable=AsyncMock, side_effect=mock_push_gbp): + result = await reconcile_item(location, client, menu_item) + + assert call_count["square"] == 1 # pushed (hash differed) + assert call_count["gbp"] == 0 # skipped (hash matched) + + +# --------------------------------------------------------------------------- +# Square uses deterministic idempotency + reuses stored external_id +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_square_upsert_uses_deterministic_key_and_reuses_external_id(): + """square_catalog.upsert_item uses a deterministic idempotency key and reuses + the stored external_id (update, not create) when a link exists.""" + from services.square_catalog import upsert_item mock_response = MagicMock() mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "catalog_object": {"id": "SQ_OBJ_1", "version": 5} + } - mock_client = AsyncMock() - mock_client.post.return_value = mock_response + mock_http = AsyncMock() + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = False + mock_http.post.return_value = mock_response - with patch("jobs.menu_sync.settings") as mock_settings, \ - patch("jobs.menu_sync.httpx.AsyncClient", return_value=mock_client): - mock_settings.square_access_token = "sq0access-token-stub" - mock_settings.square_environment = "sandbox" + item = { + "id": "menu-item-uuid", + "name": "Cappuccino", + "price_cents": 420, + "description": "Single shot", + "content_hash": "abc123hash", + } - result = await _sync_square( - client={"id": "client-1"}, - item=item, + with patch("services.square_catalog.httpx.AsyncClient", return_value=mock_http): + result = await upsert_item( + "sq_token", "SQ_LOC_1", item, + external_id="SQ_OBJ_1", external_version=4, ) - assert result["synced"] is True - assert result["message"] == "Synced to Square" + # Called with the existing id (update, not create) + call_body = mock_http.post.call_args[1]["json"] + assert call_body["object"]["id"] == "SQ_OBJ_1" + assert call_body["object"]["version"] == 4 + # Deterministic idempotency key + assert call_body["idempotency_key"] == "menu-item-uuid:square:abc123hash" + # Scoped to the location + assert call_body["object"]["present_at_all_locations"] is False + assert call_body["object"]["present_at_location_ids"] == ["SQ_LOC_1"] + # Returns id + version + assert result["id"] == "SQ_OBJ_1" + assert result["version"] == 5 + + +# --------------------------------------------------------------------------- +# Backward-compat sync_menu_item(client_id, item) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_backward_compat_sync_menu_item_resolves_default_location(): + """sync_menu_item(client_id, item) resolves the is_default location.""" + from jobs.menu_sync import sync_menu_item + + item = { + "name": "Espresso", + "price_cents": 350, + "description": "", + "category": "", + "active": True, + } + + db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "menu_sync_targets": ["square"]} + ) + elif name == "locations": + # is_default lookup + chain.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "loc1", "client_id": "c1", "is_default": True, + "menu_sync_targets": ["square"], "square_location_id": "SQ1"} + ) + elif name == "menu_items": + chain.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock(data=None) + chain.insert.return_value.execute.return_value = MagicMock( + data=[{"id": "mi1", "name": "Espresso", "price_cents": 350, + "content_hash": compute_content_hash(item)}] + ) + elif name == "menu_item_links": + chain.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock(data=None) + chain.insert.return_value.execute.return_value = MagicMock(data=[]) + elif name == "menu_sync_log": + chain.insert.return_value.execute.return_value = MagicMock(data=[]) + return chain + + db.table.side_effect = _table + + async def mock_push_square(loc, cli, mi, link): + return {"synced": True, "message": "Synced to Square", + "external_id": "sq1", "external_version": 1} + + with patch("jobs.menu_sync.get_db", return_value=db), \ + patch("jobs.menu_sync._push_to_square", new_callable=AsyncMock, side_effect=mock_push_square): + # 2-arg backward-compat call + result = await sync_menu_item("c1", item) + + assert result["status"] == "completed" + assert result["targets"]["square"]["synced"] is True + + +# --------------------------------------------------------------------------- +# apply_square_inbound — no echo back +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_apply_square_inbound_sets_square_hash_no_echo(): + """apply_square_inbound sets Square's last_synced_hash (no echo) and pushes + to OTHER targets only (skip_platform='square').""" + from jobs.menu_sync import apply_square_inbound + + changed_objects = [{ + "type": "ITEM", + "id": "SQ_OBJ_1", + "version": 7, + "item_data": { + "name": "Latte", + "description": "Updated", + "variations": [{ + "item_variation_data": { + "price_money": {"amount": 500, "currency": "AUD"} + } + }], + }, + }] + + db = MagicMock() + canonical_item = { + "id": "mi1", "name": "Latte", "price_cents": 500, + "description": "Updated", "category": "", "active": True, + "content_hash": compute_content_hash({ + "name": "Latte", "price_cents": 500, + "description": "Updated", "category": "", "active": True, + }), + "location_id": "loc1", "sheet_row_key": "Latte", + } + + def _table(name): + chain = MagicMock() + if name == "menu_item_links": + # Read link by external_id + chain.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "link1", "platform": "square", "external_id": "SQ_OBJ_1", + "last_synced_hash": "old_hash", + "menu_items": canonical_item} + ) + # Update link (set last_synced_hash) + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + elif name == "menu_items": + chain.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock(data=None) + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[canonical_item]) + elif name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "loc1", "menu_sync_targets": ["square", "gbp"], + "gbp_account_id": "acct1"} + ) + elif name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "gbp_access_token": "enc"} + ) + elif name == "menu_sync_log": + chain.insert.return_value.execute.return_value = MagicMock(data=[]) + return chain + + db.table.side_effect = _table + + gbp_pushed = False + + async def mock_push_gbp(loc, cli, mi, link): + nonlocal gbp_pushed + gbp_pushed = True + return {"synced": True, "message": "Synced to GBP"} + + async def mock_push_square(loc, cli, mi, link): + # This should NOT be called — Square is the source platform + return {"synced": True, "message": "should not reach here"} + + with patch("jobs.menu_sync.get_db", return_value=db), \ + patch("jobs.menu_sync._push_to_gbp", new_callable=AsyncMock, side_effect=mock_push_gbp), \ + patch("jobs.menu_sync._push_to_square", new_callable=AsyncMock, side_effect=mock_push_square): + result = await apply_square_inbound("c1", changed_objects) + + assert result["applied"] == 1 + assert gbp_pushed is True # GBP was pushed (other target) diff --git a/backend/tests/unit/test_outbound_durable.py b/backend/tests/unit/test_outbound_durable.py index 9842198..644ff57 100644 --- a/backend/tests/unit/test_outbound_durable.py +++ b/backend/tests/unit/test_outbound_durable.py @@ -197,32 +197,67 @@ async def test_post_gbp_reply_task_decrypt_failure_retries_then_dead_letters(): @pytest.mark.asyncio async def test_square_sync_task_loads_client_in_worker(): - """The task takes client_id only and loads the client (with secrets) itself.""" + """The task takes client_id + location_id only and loads both inside the worker. + Uses per-client OAuth via square_oauth.get_valid_token (no global token).""" import task_queue - db = _client_db({"id": "c1", "square_access_token": "sq_secret"}) + db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "square_access_token": "enc_token"} + ) + elif name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "loc1", "square_location_id": "SQ_LOC_1"} + ) + return chain + + db.table.side_effect = _table with patch("task_queue.get_db", return_value=db), \ - patch("jobs.menu_sync._sync_square", new_callable=AsyncMock, - return_value={"synced": True, "message": "Synced to Square"}) as mock_sync: - result = await task_queue.square_sync_task({"job_try": 1}, "c1", {"name": "Latte"}) + patch("services.square_oauth.get_valid_token", new_callable=AsyncMock, return_value="sq_token") as mock_token, \ + patch("services.square_catalog.upsert_item", new_callable=AsyncMock, + return_value={"id": "obj1", "version": 3}) as mock_upsert: + result = await task_queue.square_sync_task({"job_try": 1}, "c1", "loc1", {"name": "Latte"}) assert result["synced"] is True - # the loaded client (not an enqueued arg) is passed to the sync fn - assert mock_sync.call_args[0][0]["id"] == "c1" + assert result["external_id"] == "obj1" + # token was resolved per-client inside the worker + assert mock_token.call_args[0][0]["id"] == "c1" + # upsert used the per-location square_location_id + assert mock_upsert.call_args[0][1] == "SQ_LOC_1" @pytest.mark.asyncio -async def test_square_sync_task_soft_fail_dead_letters_on_final(): +async def test_square_sync_task_hard_fail_dead_letters_on_final(): + """Square upsert raises (hard fail) — on final try it dead-letters.""" import task_queue - db = _client_db({"id": "c1", "square_access_token": "sq"}) + db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "square_access_token": "enc"} + ) + elif name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "loc1", "square_location_id": "SQ_LOC_1"} + ) + return chain + + db.table.side_effect = _table with patch("task_queue.get_db", return_value=db), \ - patch("jobs.menu_sync._sync_square", new_callable=AsyncMock, return_value={"synced": False, "message": "bad"}), \ + patch("services.square_oauth.get_valid_token", new_callable=AsyncMock, return_value="sq"), \ + patch("services.square_catalog.upsert_item", new_callable=AsyncMock, side_effect=RuntimeError("boom")), \ patch("task_queue.record_dead_letter", new_callable=AsyncMock) as mock_dl: with pytest.raises(RuntimeError): - await task_queue.square_sync_task({"job_try": task_queue.MAX_TRIES}, "c1", {"name": "Latte"}) + await task_queue.square_sync_task({"job_try": task_queue.MAX_TRIES}, "c1", "loc1", {"name": "Latte"}) mock_dl.assert_awaited_once() assert mock_dl.call_args[0][0] == "square" @@ -241,13 +276,13 @@ def _boom_db(): with patch("task_queue.get_db", side_effect=_boom_db), \ patch("task_queue.record_dead_letter", new_callable=AsyncMock) as mock_dl: with pytest.raises(Retry): - await task_queue.square_sync_task({"job_try": 1}, "c1", {"name": "Latte"}) + await task_queue.square_sync_task({"job_try": 1}, "c1", "loc1", {"name": "Latte"}) mock_dl.assert_not_awaited() with patch("task_queue.get_db", side_effect=_boom_db), \ patch("task_queue.record_dead_letter", new_callable=AsyncMock) as mock_dl: with pytest.raises(ConnectionError): - await task_queue.square_sync_task({"job_try": task_queue.MAX_TRIES}, "c1", {"name": "Latte"}) + await task_queue.square_sync_task({"job_try": task_queue.MAX_TRIES}, "c1", "loc1", {"name": "Latte"}) mock_dl.assert_awaited_once() assert mock_dl.call_args[0][0] == "square" diff --git a/backend/tests/unit/test_queue_tasks.py b/backend/tests/unit/test_queue_tasks.py index e2219d7..cf9b36d 100644 --- a/backend/tests/unit/test_queue_tasks.py +++ b/backend/tests/unit/test_queue_tasks.py @@ -74,7 +74,7 @@ async def test_process_menu_update_calls_sync_menu_item(): result = await task_queue.process_menu_update({"job_try": 1}, "evt-m") assert result["status"] == "done" - mock_sync.assert_awaited_once_with("c1", {"name": "Latte"}) + mock_sync.assert_awaited_once_with("c1", None, {"name": "Latte"}, "sheets") @pytest.mark.asyncio diff --git a/backend/tests/unit/test_square_catalog_inbound.py b/backend/tests/unit/test_square_catalog_inbound.py new file mode 100644 index 0000000..5e3d308 --- /dev/null +++ b/backend/tests/unit/test_square_catalog_inbound.py @@ -0,0 +1,221 @@ +"""Tests for Square catalog inbound — webhook signature, search_changed, apply_square_inbound.""" +import hashlib +import hmac +import json +from unittest.mock import patch, MagicMock, AsyncMock + +import pytest + + +def _compute_square_sig(notification_url: str, raw_body: bytes, key: str) -> str: + """Compute the Square webhook signature for testing.""" + message = notification_url.encode() + raw_body + return hmac.new(key.encode(), message, hashlib.sha256).hexdigest() + + +# --------------------------------------------------------------------------- +# Signature verification +# --------------------------------------------------------------------------- + +def test_verify_square_signature_rejects_bad_signature(): + """_verify_square_signature returns False for a wrong signature.""" + from routers.webhooks import _verify_square_signature + from config import settings + + raw_body = b'{"merchant_id": "ML1"}' + url = f"https://{settings.base_domain}/webhooks/square/catalog" + bad_sig = "deadbeef" * 8 + + assert _verify_square_signature(raw_body, bad_sig, url) is False + + +def test_verify_square_signature_accepts_valid_signature(): + """_verify_square_signature returns True for a correct signature.""" + from routers.webhooks import _verify_square_signature + from config import settings + + raw_body = b'{"merchant_id": "ML1"}' + url = f"https://{settings.base_domain}/webhooks/square/catalog" + good_sig = _compute_square_sig(url, raw_body, settings.square_webhook_signature_key) + + assert _verify_square_signature(raw_body, good_sig, url) is True + + +# --------------------------------------------------------------------------- +# search_changed uses watermark +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_search_changed_uses_stored_watermark_as_begin_time(): + """search_changed passes the stored latest_time as begin_time.""" + from services.square_catalog import search_changed + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "objects": [{"type": "ITEM", "id": "SQ1"}], + "latest_time": "2026-07-22T10:00:00.000Z", + } + + mock_http = AsyncMock() + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = False + mock_http.post.return_value = mock_resp + + with patch("services.square_catalog.httpx.AsyncClient", return_value=mock_http): + result = await search_changed("sq_token", "2026-07-22T09:00:00.000Z") + + assert len(result["objects"]) == 1 + assert result["latest_time"] == "2026-07-22T10:00:00.000Z" + call_body = mock_http.post.call_args[1]["json"] + assert call_body["begin_time"] == "2026-07-22T09:00:00.000Z" + assert call_body["include_deleted_objects"] is True + + +@pytest.mark.asyncio +async def test_search_changed_without_watermark(): + """search_changed works without a begin_time (initial sync).""" + from services.square_catalog import search_changed + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"objects": [], "latest_time": "2026-07-22T10:00:00.000Z"} + + mock_http = AsyncMock() + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = False + mock_http.post.return_value = mock_resp + + with patch("services.square_catalog.httpx.AsyncClient", return_value=mock_http): + result = await search_changed("sq_token", None) + + call_body = mock_http.post.call_args[1]["json"] + assert "begin_time" not in call_body + assert result["latest_time"] == "2026-07-22T10:00:00.000Z" + + +# --------------------------------------------------------------------------- +# Square webhook handler — bad signature → 400 +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_square_catalog_webhook_rejects_bad_signature(): + """POST /webhooks/square/catalog returns 400 on bad signature.""" + from routers import webhooks + from fastapi import HTTPException + + request = MagicMock() + request.body = AsyncMock(return_value=b'{"merchant_id": "ML1"}') + request.headers = {"x-square-hmacsha256-signature": "badsig"} + + with pytest.raises(HTTPException) as exc_info: + await webhooks.square_catalog_webhook(request) + + assert exc_info.value.status_code == 400 + + +# --------------------------------------------------------------------------- +# Square webhook handler — valid signature → search + apply + persist watermark +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_square_catalog_webhook_searches_and_applies(): + """Valid signature → resolve client, search_changed, apply_square_inbound, + persist new latest_time watermark.""" + from routers import webhooks + from config import settings + + raw_body = json.dumps({"merchant_id": "ML_MERCHANT_1"}).encode() + url = f"https://{settings.base_domain}/webhooks/square/catalog" + sig = _compute_square_sig(url, raw_body, settings.square_webhook_signature_key) + + request = MagicMock() + request.body = AsyncMock(return_value=raw_body) + request.headers = {"x-square-hmacsha256-signature": sig} + + db = MagicMock() + state_chains = [] + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "square_merchant_id": "ML_MERCHANT_1", + "square_access_token": "enc", "square_refresh_token": "enc_r"} + ) + elif name == "square_sync_state": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"latest_time": "2026-07-22T09:00:00.000Z"} + ) + chain.upsert.return_value.execute.return_value = MagicMock(data=[]) + state_chains.append(chain) + return chain + + db.table.side_effect = _table + + with patch("routers.webhooks.get_db", return_value=db), \ + patch("services.square_oauth.get_valid_token", new_callable=AsyncMock, return_value="sq_tok"), \ + patch("services.square_catalog.search_changed", new_callable=AsyncMock, + return_value={"objects": [{"type": "ITEM", "id": "SQ1"}], + "latest_time": "2026-07-22T10:00:00.000Z"}), \ + patch("jobs.menu_sync.apply_square_inbound", new_callable=AsyncMock, + return_value={"applied": 1}) as mock_apply: + result = await webhooks.square_catalog_webhook(request) + + assert result["status"] == "received" + mock_apply.assert_awaited_once() + # Watermark persisted via upsert on square_sync_state + # state_chains[0] = select (read watermark), state_chains[1] = upsert (persist) + upsert_chain = next((c for c in state_chains if c.upsert.called), None) + assert upsert_chain is not None + upsert_data = upsert_chain.upsert.call_args[0][0] + assert upsert_data["latest_time"] == "2026-07-22T10:00:00.000Z" + + +# --------------------------------------------------------------------------- +# square_object_to_canonical mapping +# --------------------------------------------------------------------------- + +def test_square_object_to_canonical_maps_fields(): + """square_object_to_canonical maps Square ITEM to our canonical dict.""" + from services.square_catalog import square_object_to_canonical + + sq_obj = { + "type": "ITEM", + "id": "SQ_OBJ_1", + "item_data": { + "name": "Flat White", + "description": "Double shot", + "variations": [{ + "item_variation_data": { + "price_money": {"amount": 450, "currency": "AUD"} + } + }], + }, + } + + result = square_object_to_canonical(sq_obj) + assert result["name"] == "Flat White" + assert result["description"] == "Double shot" + assert result["price_cents"] == 450 + assert result["active"] is True + + +def test_square_object_to_canonical_handles_deleted(): + """Deleted Square objects map to active=False.""" + from services.square_catalog import square_object_to_canonical + + sq_obj = { + "type": "ITEM", + "id": "SQ_OBJ_1", + "is_deleted": True, + "item_data": { + "name": "Old Item", + "description": "", + "variations": [], + }, + } + + result = square_object_to_canonical(sq_obj) + assert result["active"] is False + assert result["price_cents"] == 0 diff --git a/backend/tests/unit/test_square_oauth.py b/backend/tests/unit/test_square_oauth.py new file mode 100644 index 0000000..1ad19db --- /dev/null +++ b/backend/tests/unit/test_square_oauth.py @@ -0,0 +1,134 @@ +"""Tests for Square OAuth service — auth URL, token exchange, get_valid_token.""" +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from datetime import datetime, timezone, timedelta + + +def test_get_square_auth_url_contains_scopes_and_state(): + """Auth URL includes the required scopes and state=client_id.""" + from services.square_oauth import get_square_auth_url + + url = get_square_auth_url("client-abc") + assert "ITEMS_READ" in url + assert "ITEMS_WRITE" in url + assert "MERCHANT_PROFILE_READ" in url + assert "state=client-abc" in url + # Sandbox host when environment is sandbox + assert "squareupsandbox.com" in url + + +def test_get_square_auth_url_production_host(): + """Production environment uses connect.squareup.com.""" + from services import square_oauth + + with patch.object(square_oauth.settings, "square_environment", "production"): + url = square_oauth.get_square_auth_url("c1") + assert "connect.squareup.com/oauth2/authorize" in url + assert "squareupsandbox.com" not in url + + +@pytest.mark.asyncio +async def test_exchange_code_for_tokens_posts_to_token_endpoint(): + """exchange_code_for_tokens POSTs to /oauth2/token with grant_type=authorization_code.""" + from services import square_oauth + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"access_token": "sq_at", "refresh_token": "sq_rt"} + + mock_http = AsyncMock() + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = False + mock_http.post.return_value = mock_resp + + with patch("services.square_oauth.httpx.AsyncClient", return_value=mock_http): + result = await square_oauth.exchange_code_for_tokens("the_code") + + assert result["access_token"] == "sq_at" + call_args = mock_http.post.call_args + assert "/oauth2/token" in call_args[0][0] + assert call_args[1]["data"]["grant_type"] == "authorization_code" + assert call_args[1]["data"]["code"] == "the_code" + + +@pytest.mark.asyncio +async def test_get_valid_token_refreshes_when_expired_and_persists(): + """get_valid_token refreshes an expired token and re-encrypts + persists.""" + from services import square_oauth + + # Client with expired token + expired = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + client = { + "id": "c1", + "square_access_token": "enc_old_access", + "square_refresh_token": "enc_refresh", + "square_token_expires_at": expired, + } + + mock_db = MagicMock() + + with patch("db.get_db", return_value=mock_db), \ + patch("services.crypto.decrypt", side_effect=["old_access", "refresh_tok"]) as mock_decrypt, \ + patch("services.crypto.encrypt", side_effect=["enc_new_access", "enc_new_refresh"]) as mock_encrypt, \ + patch("services.square_oauth.refresh_access_token", new_callable=AsyncMock, + return_value={"access_token": "new_access", "refresh_token": "new_refresh", + "expires_at": "2026-12-01T00:00:00Z"}): + token = await square_oauth.get_valid_token(client) + + assert token == "new_access" + # Decrypt was called for both access and refresh tokens + assert mock_decrypt.call_count == 2 + # Encrypt was called for the new tokens + assert mock_encrypt.call_count == 2 + # Persisted to clients table + update_call = mock_db.table.return_value.update.call_args + assert update_call is not None + update_data = update_call[0][0] + assert update_data["square_access_token"] == "enc_new_access" + assert update_data["square_refresh_token"] == "enc_new_refresh" + assert update_data["square_token_expires_at"] == "2026-12-01T00:00:00Z" + + +@pytest.mark.asyncio +async def test_get_valid_token_returns_cached_when_not_expired(): + """get_valid_token returns the decrypted token without refreshing when not expired.""" + from services import square_oauth + + future = (datetime.now(timezone.utc) + timedelta(days=5)).isoformat() + client = { + "id": "c1", + "square_access_token": "enc_access", + "square_refresh_token": "enc_refresh", + "square_token_expires_at": future, + } + + with patch("services.crypto.decrypt", return_value="valid_access") as mock_decrypt, \ + patch("services.square_oauth.refresh_access_token", new_callable=AsyncMock) as mock_refresh: + token = await square_oauth.get_valid_token(client) + + assert token == "valid_access" + mock_refresh.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_list_locations_calls_v2_locations(): + """list_locations GETs /v2/locations with the access token.""" + from services import square_oauth + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"locations": [{"id": "SQ_LOC_1", "name": "Surry Hills"}]} + + mock_http = AsyncMock() + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = False + mock_http.get.return_value = mock_resp + + with patch("services.square_oauth.httpx.AsyncClient", return_value=mock_http): + result = await square_oauth.list_locations("sq_token") + + assert len(result) == 1 + assert result[0]["id"] == "SQ_LOC_1" + call = mock_http.get.call_args + assert "/v2/locations" in call[0][0] + assert call[1]["headers"]["Authorization"] == "Bearer sq_token" diff --git a/backend/uv.lock b/backend/uv.lock index 1750d04..c76706b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1041,6 +1041,7 @@ dependencies = [ { name = "httpx" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "python-multipart" }, { name = "pytz" }, { name = "resend" }, { name = "sentry-sdk" }, @@ -1071,6 +1072,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pyjwt", specifier = ">=2.8.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pytz", specifier = ">=2024.1" }, { name = "resend", specifier = ">=2.0.0" }, { name = "sentry-sdk", specifier = ">=1.40.0" }, @@ -1657,6 +1659,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pytz" version = "2026.2" diff --git a/supabase/migrations/017_locations.sql b/supabase/migrations/017_locations.sql new file mode 100644 index 0000000..ab41125 --- /dev/null +++ b/supabase/migrations/017_locations.sql @@ -0,0 +1,46 @@ +-- 017_locations.sql +-- Multi-location table: single source of truth for GBP account/location identity (C2), +-- per-venue Square location, menu sync targets, and place_id (moved from rankings per C6). +-- Backfills a default location for existing clients from clients.gbp_location_id / +-- clients.menu_sync_targets so current single-venue clients keep working. + +create table locations ( + id uuid primary key default gen_random_uuid(), + client_id uuid references clients(id) not null, + name text not null, -- venue label e.g. "Prefecture 48 Surry Hills" + suburb text, + state text, + gbp_account_id text, -- per-venue GBP account id (was missing entirely — latent bug fix) + gbp_location_id text, -- per-venue GBP location id + square_location_id text, -- per-venue Square location id + place_id text, -- stable Google Maps place id (moved from rankings per C6) + menu_sync_targets text[] default '{}', -- per-location sync targets (authoritative; clients.menu_sync_targets deprecated) + is_default boolean default false, -- true for the backfilled single-venue row + created_at timestamptz default now(), + deleted_at timestamptz +); + +create index locations_client_id_idx on locations (client_id); +create unique index locations_gbp_location_id_idx on locations (gbp_location_id) where gbp_location_id is not null; +create unique index locations_square_location_id_idx on locations (square_location_id) where square_location_id is not null; + +alter table locations enable row level security; + +create policy "service_role_all" on locations + using (auth.role() = 'service_role'); + +-- Backfill: for each client with gbp_location_id or menu_sync_targets, insert one +-- is_default location carrying gbp_location_id + menu_sync_targets so existing +-- sync keeps working without manual location setup. +insert into locations (client_id, name, suburb, state, gbp_location_id, menu_sync_targets, is_default) +select + c.id, + c.business_name, + c.suburb, + c.state, + c.gbp_location_id, + coalesce(c.menu_sync_targets, '{}'::text[]), + true +from clients c +where c.gbp_location_id is not null + or (c.menu_sync_targets is not null and array_length(c.menu_sync_targets, 1) > 0); diff --git a/supabase/migrations/018_clients_square_oauth.sql b/supabase/migrations/018_clients_square_oauth.sql new file mode 100644 index 0000000..f16b7be --- /dev/null +++ b/supabase/migrations/018_clients_square_oauth.sql @@ -0,0 +1,10 @@ +-- 018_clients_square_oauth.sql +-- Remaining Square OAuth columns on `clients`. Per C1, `square_access_token` was +-- already added in 012 (Phase 0); this migration adds only the refresh token, +-- merchant id, and token expiry. Do NOT re-add square_access_token. + +alter table clients add column if not exists square_refresh_token text; -- Fernet-encrypted +alter table clients add column if not exists square_merchant_id text; +alter table clients add column if not exists square_token_expires_at timestamptz; + +-- RLS already enabled on clients table (001_clients.sql). diff --git a/supabase/migrations/019_menu_items.sql b/supabase/migrations/019_menu_items.sql new file mode 100644 index 0000000..eee2bed --- /dev/null +++ b/supabase/migrations/019_menu_items.sql @@ -0,0 +1,29 @@ +-- 019_menu_items.sql +-- Canonical menu item store. Every edit (Sheets, Square inbound) upserts here first; +-- the syncer reconciles platform state from canonical. content_hash drives loop +-- prevention (menu_item_links.last_synced_hash). + +create table menu_items ( + id uuid primary key default gen_random_uuid(), + client_id uuid references clients(id) not null, + location_id uuid references locations(id) not null, + name text not null, + description text default '', + price_cents integer not null, + category text default '', + active boolean default true, + content_hash text not null, -- sha256(name|price_cents|description|category|active) + origin text default 'sheets', -- 'sheets'|'square'|'gbp'|'manual' + sheet_row_key text, -- stable key from Sheets for matching + created_at timestamptz default now(), + updated_at timestamptz default now(), + deleted_at timestamptz +); + +create index menu_items_location_id_idx on menu_items (location_id); +create unique index menu_items_location_sheet_row_key_idx on menu_items (location_id, sheet_row_key) where sheet_row_key is not null; + +alter table menu_items enable row level security; + +create policy "service_role_all" on menu_items + using (auth.role() = 'service_role'); diff --git a/supabase/migrations/020_menu_item_links.sql b/supabase/migrations/020_menu_item_links.sql new file mode 100644 index 0000000..bb0773b --- /dev/null +++ b/supabase/migrations/020_menu_item_links.sql @@ -0,0 +1,22 @@ +-- 020_menu_item_links.sql +-- Cross-platform identity + loop guard. Stores the external_id and last_synced_hash +-- per (menu_item, platform). Sync to a platform is skipped when +-- last_synced_hash == content_hash, preventing echo-back loops. + +create table menu_item_links ( + id uuid primary key default gen_random_uuid(), + menu_item_id uuid references menu_items(id) not null, + platform text not null, -- 'gbp'|'square'|'website' + external_id text, -- Square catalog object id / GBP menuItem name + external_version bigint, -- Square object version for optimistic Upsert + last_synced_hash text, -- content_hash last pushed/received for this platform + last_synced_at timestamptz, + unique (menu_item_id, platform) +); + +create index menu_item_links_platform_external_id_idx on menu_item_links (platform, external_id); + +alter table menu_item_links enable row level security; + +create policy "service_role_all" on menu_item_links + using (auth.role() = 'service_role'); diff --git a/supabase/migrations/021_menu_images.sql b/supabase/migrations/021_menu_images.sql new file mode 100644 index 0000000..ca33139 --- /dev/null +++ b/supabase/migrations/021_menu_images.sql @@ -0,0 +1,35 @@ +-- 021_menu_images.sql +-- Menu image store + column additions for C2 (drafts.location_id) and C6 +-- (menu_sync_log.location_id). Images live in a Supabase storage bucket +-- `menu-images` (public read); this table records the storage path, public URL, +-- and platform-specific image ids once propagated. +-- +-- The `menu-images` storage bucket must be provisioned once in the Supabase +-- dashboard (public read). Migration-level bucket creation is project-specific; +-- document as a one-time Supabase setup step if not automated. + +create table menu_images ( + id uuid primary key default gen_random_uuid(), + menu_item_id uuid references menu_items(id) not null, + storage_path text not null, -- path in Supabase bucket 'menu-images' + public_url text not null, -- used by GBP Media.Create sourceUrl + square_image_id text, -- Square CatalogImage id once uploaded + gbp_media_name text, -- GBP media resource name once uploaded + is_primary boolean default true, + synced_at timestamptz, + created_at timestamptz default now() +); + +create index menu_images_menu_item_id_idx on menu_images (menu_item_id); + +alter table menu_images enable row level security; + +create policy "service_role_all" on menu_images + using (auth.role() = 'service_role'); + +-- C2: drafts store the originating location_id; routers/approve.py posts using +-- the draft's location's gbp_location_id. +alter table drafts add column if not exists location_id uuid references locations(id); + +-- C6: menu_sync_log records which location each sync targeted. +alter table menu_sync_log add column if not exists location_id uuid references locations(id); diff --git a/supabase/migrations/022_square_sync_state.sql b/supabase/migrations/022_square_sync_state.sql new file mode 100644 index 0000000..b92b68f --- /dev/null +++ b/supabase/migrations/022_square_sync_state.sql @@ -0,0 +1,15 @@ +-- 022_square_sync_state.sql +-- Reconcile watermark for Square catalog inbound. On each catalog.version.updated +-- webhook, SearchCatalogObjects is called with begin_time = stored latest_time; +-- the response's latest_time becomes the new watermark (persisted after apply). + +create table square_sync_state ( + client_id uuid primary key references clients(id), + latest_time text, -- last SearchCatalogObjects latest_time watermark + updated_at timestamptz default now() +); + +alter table square_sync_state enable row level security; + +create policy "service_role_all" on square_sync_state + using (auth.role() = 'service_role'); From 246dcdf24533511fbc08e9f84a9cfeaa7547d703 Mon Sep 17 00:00:00 2001 From: crewcircle Date: Thu, 23 Jul 2026 05:03:00 +1000 Subject: [PATCH 2/3] Phase 4: Maps Local Pack rank, competitor structured data, GBP provisioning, Yelp posting (#7) Co-authored-by: crewcricle <280911048+crewcricle@users.noreply.github.com> --- backend/config.py | 5 + backend/jobs/competitor_watch.py | 98 ++++- backend/jobs/menu_sync.py | 14 +- backend/jobs/seo_report.py | 58 ++- backend/main.py | 4 +- backend/routers/approve.py | 54 ++- backend/routers/auth.py | 76 +++- backend/routers/seo.py | 82 ++++ backend/routers/webhooks.py | 52 +-- backend/scripts/setup_gbp_notification.py | 146 ++----- backend/services/claude.py | 51 ++- backend/services/dataforseo.py | 189 +++++++-- backend/services/gbp_provisioning.py | 360 ++++++++++++++++ backend/services/square_catalog.py | 2 +- backend/services/structured_extract.py | 207 ++++++++++ backend/task_queue.py | 59 +++ backend/tests/conftest.py | 3 + backend/tests/unit/test_competitor.py | 188 ++++++++- backend/tests/unit/test_competitors_api.py | 95 +++++ backend/tests/unit/test_gbp_provisioning.py | 386 ++++++++++++++++++ backend/tests/unit/test_outbound_durable.py | 82 ++++ backend/tests/unit/test_rankings_api.py | 80 ++++ backend/tests/unit/test_review_guard.py | 122 ++++++ backend/tests/unit/test_seo.py | 205 ++++++++++ backend/tests/unit/test_signup.py | 53 ++- .../migrations/023_rankings_local_pack.sql | 7 + .../migrations/024_competitor_structured.sql | 25 ++ 27 files changed, 2477 insertions(+), 226 deletions(-) create mode 100644 backend/routers/seo.py create mode 100644 backend/services/gbp_provisioning.py create mode 100644 backend/services/structured_extract.py create mode 100644 backend/tests/unit/test_competitors_api.py create mode 100644 backend/tests/unit/test_gbp_provisioning.py create mode 100644 backend/tests/unit/test_rankings_api.py create mode 100644 supabase/migrations/023_rankings_local_pack.sql create mode 100644 supabase/migrations/024_competitor_structured.sql diff --git a/backend/config.py b/backend/config.py index 699e1ed..7dc79bb 100644 --- a/backend/config.py +++ b/backend/config.py @@ -42,6 +42,11 @@ class Settings(BaseSettings): dashboard_url: str = "" # Stripe portal return_url base stripe_portal_config_id: str = "" # Stripe portal configuration id (bpc_...) + # --- Phase 4: GBP Pub/Sub provisioning (D15-B full automation) --- + gcp_project_id: str = "" # GCP project hosting the Pub/Sub topic + gcp_sa_json: str = "" # service-account JSON key (topic-admin) for Pub/Sub REST + gbp_pubsub_topic_name: str = "gbp-reviews" # shared topic short name + class Config: env_file = ".env.local" env_file_encoding = "utf-8" diff --git a/backend/jobs/competitor_watch.py b/backend/jobs/competitor_watch.py index 1c16c6a..a749d0b 100644 --- a/backend/jobs/competitor_watch.py +++ b/backend/jobs/competitor_watch.py @@ -7,16 +7,24 @@ from db import get_db from services.claude import generate_competitor_brief +from services.structured_extract import ( + extract_structured, + detect_prices_from_text, + diff_structured, +) from utils.retry import retry_on_failure logger = logging.getLogger(__name__) @retry_on_failure() -async def snapshot_website(url: str) -> tuple[str, str]: - """Fetch a competitor URL, strip non-content elements, and return (md5_hash, clean_text). +async def snapshot_website(url: str) -> tuple[str, str, str]: + """Fetch a competitor URL, strip non-content elements, and return (md5_hash, clean_text, raw_html). - Returns ("", "") when the key is missing or the response is empty. + The raw HTML is returned so the caller can run ``extract_structured`` on + the original (with JSON-LD ``' + '

Lots of other text that changes the hash

' + '' + ) + + mock_last_resp = MagicMock() + mock_last_resp.data = [{ + "content_hash": "oldhash", + "content_text": "old text", + "structured_data": prev_structured, + }] + + mock_insert_resp = MagicMock() + mock_insert_resp.data = [{"id": "snap-2"}] + + mock_db = MagicMock() + mock_db.table.return_value.select.return_value.eq.return_value.eq.return_value \ + .order.return_value.limit.return_value.execute.return_value = mock_last_resp + mock_db.table.return_value.insert.return_value.execute.return_value = mock_insert_resp + + with patch("jobs.competitor_watch.get_db", return_value=mock_db), \ + patch("jobs.competitor_watch.snapshot_website", new_callable=AsyncMock) as mock_snap: + # Hash differs, text differs, but the structured diff is the key signal. + mock_snap.return_value = ("newhash", "new text content here", curr_html) + + result = await detect_changes("c1", "https://comp.com") + + assert result is not None + assert result["changed"] is True + # The structured_diff should report the Invisalign price change. + diffs = result["structured_diff"] + changed = [d for d in diffs if d["kind"] == "changed"] + assert len(changed) == 1 + assert changed[0]["name"] == "Invisalign" + assert changed[0]["old"] == "4500" + assert changed[0]["new"] == "3990" + + +# --------------------------------------------------------------------------- +# Structured extraction (services/structured_extract.py) +# --------------------------------------------------------------------------- + +def test_extract_structured_pulls_product_offer_price(): + """extract_structured pulls a Product Offer price from a JSON-LD block.""" + from services.structured_extract import extract_structured + + html = ( + '' + '' + '' + ) + result = extract_structured(html) + assert len(result["prices"]) == 1 + assert result["prices"][0]["name"] == "Teeth Whitening" + assert result["prices"][0]["price"] == "299" + assert result["prices"][0]["currency"] == "AUD" + assert "Product" in result["schema_types"] + + +def test_extract_structured_pulls_menu_item_price(): + """extract_structured pulls a Restaurant/MenuItem price from JSON-LD.""" + from services.structured_extract import extract_structured + + html = ( + '' + '' + '' + ) + result = extract_structured(html) + assert len(result["menu_items"]) == 2 + names = {m["name"] for m in result["menu_items"]} + assert "Latte" in names + assert "Cappuccino" in names + + +def test_diff_structured_reports_changed_price(): + """diff_structured reports a 'changed' price with old/new values.""" + from services.structured_extract import diff_structured + + prev = { + "prices": [{"name": "Invisalign", "price": "4500", "currency": "AUD"}], + "menu_items": [], + } + curr = { + "prices": [{"name": "Invisalign", "price": "3990", "currency": "AUD"}], + "menu_items": [], + } + diffs = diff_structured(prev, curr) + changed = [d for d in diffs if d["kind"] == "changed"] + assert len(changed) == 1 + assert changed[0]["name"] == "Invisalign" + assert changed[0]["old"] == "4500" + assert changed[0]["new"] == "3990" + + +def test_diff_structured_reports_added_and_removed(): + """diff_structured reports added and removed items.""" + from services.structured_extract import diff_structured + + prev = { + "prices": [{"name": "Clean", "price": "120", "currency": "AUD"}], + "menu_items": [], + } + curr = { + "prices": [ + {"name": "Clean", "price": "120", "currency": "AUD"}, + {"name": "Whitening", "price": "299", "currency": "AUD"}, + ], + "menu_items": [], + } + diffs = diff_structured(prev, curr) + added = [d for d in diffs if d["kind"] == "added"] + assert len(added) == 1 + assert added[0]["name"] == "Whitening" + assert added[0]["new"] == "299" + + +def test_detect_prices_from_text_finds_aud_prices(): + """Regex fallback finds $3,990 with a label.""" + from services.structured_extract import detect_prices_from_text + + text = "Our Invisalign treatment is now $3,990. Call us today." + prices = detect_prices_from_text(text) + assert len(prices) >= 1 + # At least one price should be 3,990 + found = any(p["price"] == "3,990" for p in prices) + assert found, f"Expected to find $3,990 in {prices}" + + +def test_extract_structured_empty_html(): + """extract_structured handles empty/None HTML gracefully.""" + from services.structured_extract import extract_structured + + result = extract_structured("") + assert result["prices"] == [] + assert result["menu_items"] == [] + assert result["schema_types"] == [] + assert result["raw_jsonld"] == [] + + +def test_diff_structured_empty_prev(): + """diff_structured with empty prev reports all curr items as added.""" + from services.structured_extract import diff_structured + + curr = {"prices": [{"name": "Clean", "price": "120", "currency": "AUD"}], "menu_items": []} + diffs = diff_structured({}, curr) + added = [d for d in diffs if d["kind"] == "added"] + assert len(added) == 1 + assert added[0]["name"] == "Clean" diff --git a/backend/tests/unit/test_competitors_api.py b/backend/tests/unit/test_competitors_api.py new file mode 100644 index 0000000..54674a7 --- /dev/null +++ b/backend/tests/unit/test_competitors_api.py @@ -0,0 +1,95 @@ +"""Tests for the competitors read API (Phase 4 — C7 for Phase 5 dashboard).""" +import pytest +from unittest.mock import patch, MagicMock + + +def _db_with_location_and_snapshots(loc_client_id="c1", snapshots=None): + """Build a mock db where locations lookup returns a client_id and + competitor_snapshots returns the given snapshot rows.""" + mock_db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"client_id": loc_client_id} + ) + elif name == "competitor_snapshots": + chain.select.return_value.eq.return_value.order.return_value.limit.return_value.execute.return_value = MagicMock( + data=snapshots or [] + ) + return chain + + mock_db.table.side_effect = _table + return mock_db + + +@pytest.mark.asyncio +async def test_get_competitor_snapshots_returns_structured_diff(): + """GET /competitors/snapshots?location_id=... returns snapshots including structured_diff. + + Per C8, client_id is derived from the location_id lookup — not accepted + directly from the query string. + """ + from routers.seo import get_competitor_snapshots + + snapshots = [ + { + "id": "snap-1", + "competitor_url": "https://comp.com", + "content_hash": "abc", + "brief_text": "Competitor dropped prices. Threat: MEDIUM", + "threat_level": "MEDIUM", + "structured_data": { + "prices": [{"name": "Invisalign", "price": "3990", "currency": "AUD"}], + "menu_items": [], + }, + "structured_diff": [ + {"kind": "changed", "name": "Invisalign", "old": "4500", "new": "3990"}, + ], + "captured_at": "2026-07-20T10:00:00Z", + } + ] + mock_db = _db_with_location_and_snapshots(snapshots=snapshots) + + with patch("routers.seo.get_db", return_value=mock_db): + result = await get_competitor_snapshots(location_id="loc-1", auth={"sub": "anonymous"}) + + assert len(result["snapshots"]) == 1 + snap = result["snapshots"][0] + assert snap["structured_diff"] is not None + assert len(snap["structured_diff"]) == 1 + assert snap["structured_diff"][0]["kind"] == "changed" + assert snap["structured_diff"][0]["old"] == "4500" + assert snap["structured_diff"][0]["new"] == "3990" + + +@pytest.mark.asyncio +async def test_get_competitor_snapshots_empty(): + """GET /competitors/snapshots returns empty list when no snapshots exist.""" + from routers.seo import get_competitor_snapshots + + mock_db = _db_with_location_and_snapshots(snapshots=[]) + + with patch("routers.seo.get_db", return_value=mock_db): + result = await get_competitor_snapshots(location_id="loc-1", auth={"sub": "anonymous"}) + + assert result["snapshots"] == [] + + +@pytest.mark.asyncio +async def test_get_competitor_snapshots_404_for_unknown_location(): + """GET /competitors/snapshots with an unknown location_id returns 404 (C8).""" + from routers.seo import get_competitor_snapshots + from fastapi import HTTPException + + mock_db = MagicMock() + mock_db.table.return_value.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data=None + ) + + with patch("routers.seo.get_db", return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await get_competitor_snapshots(location_id="unknown", auth={"sub": "anonymous"}) + + assert exc_info.value.status_code == 404 diff --git a/backend/tests/unit/test_gbp_provisioning.py b/backend/tests/unit/test_gbp_provisioning.py new file mode 100644 index 0000000..345c1a1 --- /dev/null +++ b/backend/tests/unit/test_gbp_provisioning.py @@ -0,0 +1,386 @@ +"""Tests for GBP notification provisioning (Phase 4 — D15-B full automation). + +All GCP REST calls are mocked via httpx — no live API calls. +""" +import json +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + +import httpx + + +# Dummy service-account JSON for tests. +_SA_JSON = json.dumps({ + "client_email": "test-sa@test-project.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n", + "private_key_id": "key123", + "project_id": "test-project", +}) + + +def _mock_httpx_response(status_code=200, json_data=None): + """Create a mock httpx.Response.""" + resp = MagicMock() + resp.status_code = status_code + resp.raise_for_status = MagicMock() + if status_code >= 400 and status_code != 409: + resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "error", request=MagicMock(), response=resp + ) + if json_data is not None: + resp.json.return_value = json_data + return resp + + +def _mock_async_client(responses): + """Create a mock httpx.AsyncClient whose get/put/post/patch return queued responses. + + ``responses`` is a dict mapping method → list of mock responses (consumed in order). + """ + client = AsyncMock() + client.__aenter__.return_value = client + client.__aexit__.return_value = False + + state = {"get": 0, "put": 0, "post": 0, "patch": 0} + + def _make(method): + def _call(*args, **kwargs): + idx = state[method] + state[method] += 1 + if idx < len(responses.get(method, [])): + return responses[method][idx] + return _mock_httpx_response(200, {}) + return _call + + client.get.side_effect = _make("get") + client.put.side_effect = _make("put") + client.post.side_effect = _make("post") + client.patch.side_effect = _make("patch") + return client + + +# --------------------------------------------------------------------------- +# _gcp_access_token +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_gcp_access_token_builds_signed_jwt(): + """_gcp_access_token builds a signed JWT assertion with the SA claims.""" + from services.gbp_provisioning import _gcp_access_token + + token_resp = _mock_httpx_response(200, {"access_token": "ya29.test-token"}) + + with patch("services.gbp_provisioning.settings") as mock_settings, \ + patch("services.gbp_provisioning.jwt") as mock_jwt, \ + patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_settings.gcp_sa_json = _SA_JSON + mock_jwt.encode.return_value = "signed-assertion" + mock_client_cls.return_value = _mock_async_client({"post": [token_resp]}) + + token = await _gcp_access_token() + + assert token == "ya29.test-token" + # Verify PyJWT.encode was called with the SA claims. + encode_args = mock_jwt.encode.call_args + payload = encode_args[0][0] + assert payload["iss"] == "test-sa@test-project.iam.gserviceaccount.com" + assert payload["aud"] == "https://oauth2.googleapis.com/token" + assert "pubsub" in payload["scope"] + assert "cloud-platform" in payload["scope"] + assert encode_args[1]["algorithm"] == "RS256" + + +# --------------------------------------------------------------------------- +# ensure_topic +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ensure_topic_creates_topic(): + """ensure_topic creates a topic and returns the resource name.""" + from services.gbp_provisioning import ensure_topic + + with patch("services.gbp_provisioning._gcp_access_token", new_callable=AsyncMock, return_value="token"), \ + patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"put": [_mock_httpx_response(200)]}) + result = await ensure_topic("my-proj", "gbp-reviews") + + assert result == "projects/my-proj/topics/gbp-reviews" + + +@pytest.mark.asyncio +async def test_ensure_topic_409_is_success(): + """ensure_topic treats HTTP 409 (already exists) as success.""" + from services.gbp_provisioning import ensure_topic + + with patch("services.gbp_provisioning._gcp_access_token", new_callable=AsyncMock, return_value="token"), \ + patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"put": [_mock_httpx_response(409)]}) + result = await ensure_topic("my-proj", "gbp-reviews") + + assert result == "projects/my-proj/topics/gbp-reviews" + + +# --------------------------------------------------------------------------- +# ensure_publisher_binding +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ensure_publisher_binding_adds_when_absent(): + """ensure_publisher_binding adds the binding when it is not present.""" + from services.gbp_provisioning import ensure_publisher_binding + + get_resp = _mock_httpx_response(200, {"bindings": []}) + set_resp = _mock_httpx_response(200, {"bindings": [ + {"role": "roles/pubsub.publisher", "members": ["mybusiness-api-pubsub@system.gserviceaccount.com"]} + ]}) + + with patch("services.gbp_provisioning._gcp_access_token", new_callable=AsyncMock, return_value="token"), \ + patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"get": [get_resp], "post": [set_resp]}) + await ensure_publisher_binding("my-proj", "gbp-reviews") + + # The setIamPolicy POST was called (binding was absent). + # Verify via the mock client's post call. + client = mock_client_cls.return_value + client.post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_ensure_publisher_binding_skips_when_present(): + """ensure_publisher_binding does NOT setIamPolicy when the binding already exists.""" + from services.gbp_provisioning import ensure_publisher_binding + + get_resp = _mock_httpx_response(200, {"bindings": [ + {"role": "roles/pubsub.publisher", "members": ["mybusiness-api-pubsub@system.gserviceaccount.com"]} + ]}) + + with patch("services.gbp_provisioning._gcp_access_token", new_callable=AsyncMock, return_value="token"), \ + patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"get": [get_resp]}) + await ensure_publisher_binding("my-proj", "gbp-reviews") + + # setIamPolicy POST was NOT called (binding was present). + client = mock_client_cls.return_value + client.post.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# register_notification +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_register_notification_skips_when_already_registered(): + """register_notification returns early when the setting already matches.""" + from services.gbp_provisioning import register_notification + + get_resp = _mock_httpx_response(200, { + "pubsubTopic": "projects/p/topics/gbp-reviews", + "notificationTypes": ["NEW_REVIEW", "UPDATED_REVIEW"], + }) + + with patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"get": [get_resp]}) + result = await register_notification("ACCT1", "client-token", "projects/p/topics/gbp-reviews") + + client = mock_client_cls.return_value + client.patch.assert_not_awaited() + assert result["pubsubTopic"] == "projects/p/topics/gbp-reviews" + + +@pytest.mark.asyncio +async def test_register_notification_patches_when_not_registered(): + """register_notification PATCHes when the setting does not match.""" + from services.gbp_provisioning import register_notification + + get_resp = _mock_httpx_response(404, {}) + patch_resp = _mock_httpx_response(200, { + "pubsubTopic": "projects/p/topics/gbp-reviews", + "notificationTypes": ["NEW_REVIEW", "UPDATED_REVIEW"], + }) + + with patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"get": [get_resp], "patch": [patch_resp]}) + result = await register_notification("ACCT1", "client-token", "projects/p/topics/gbp-reviews") + + client = mock_client_cls.return_value + client.patch.assert_awaited_once() + assert result["pubsubTopic"] == "projects/p/topics/gbp-reviews" + + +# --------------------------------------------------------------------------- +# provision_gbp_notifications (orchestration) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_provision_gbp_notifications_success(): + """provision_gbp_notifications persists provisioning_status='active' on success.""" + from services.gbp_provisioning import provision_gbp_notifications + + mock_db = MagicMock() + update_calls = [] + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "gbp_access_token": "enc_token"} + ) + def _update(data, **kwargs): + update_calls.append((name, data)) + return chain + chain.update.side_effect = _update + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[{"id": "c1"}]) + elif name == "locations": + chain.select.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"gbp_account_id": "ACCT1", "gbp_location_id": "LOC1"}] + ) + return chain + + mock_db.table.side_effect = _table + + with patch("db.get_db", return_value=mock_db), \ + patch("services.gbp_provisioning.settings") as mock_settings, \ + patch("services.crypto.decrypt", return_value="plain-token"), \ + patch("services.gbp_provisioning.ensure_topic", new_callable=AsyncMock), \ + patch("services.gbp_provisioning.ensure_publisher_binding", new_callable=AsyncMock), \ + patch("services.gbp_provisioning.ensure_push_subscription", new_callable=AsyncMock), \ + patch("services.gbp_provisioning.register_notification", new_callable=AsyncMock, return_value={"pubsubTopic": "t"}): + mock_settings.gcp_sa_json = _SA_JSON + mock_settings.gcp_project_id = "my-proj" + mock_settings.gbp_pubsub_topic_name = "gbp-reviews" + mock_settings.base_domain = "api.localmate.com.au" + + result = await provision_gbp_notifications("c1") + + assert result["status"] == "active" + assert result["account_id"] == "ACCT1" + # Verify provisioning_status='active' was persisted. + active_updates = [d for _, d in update_calls if d.get("provisioning_status") == "active"] + assert len(active_updates) == 1 + assert active_updates[0]["pubsub_topic"] is not None + + +@pytest.mark.asyncio +async def test_provision_gbp_notifications_failed_on_exception(): + """provision_gbp_notifications persists provisioning_status='failed' + error on exception.""" + from services.gbp_provisioning import provision_gbp_notifications + + mock_db = MagicMock() + update_calls = [] + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "gbp_access_token": "enc_token"} + ) + def _update(data, **kwargs): + update_calls.append((name, data)) + return chain + chain.update.side_effect = _update + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[{"id": "c1"}]) + elif name == "locations": + chain.select.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"gbp_account_id": "ACCT1", "gbp_location_id": "LOC1"}] + ) + return chain + + mock_db.table.side_effect = _table + + with patch("db.get_db", return_value=mock_db), \ + patch("services.gbp_provisioning.settings") as mock_settings, \ + patch("services.crypto.decrypt", return_value="plain-token"), \ + patch("services.gbp_provisioning.ensure_topic", new_callable=AsyncMock, side_effect=RuntimeError("GCP down")), \ + patch("services.gbp_provisioning.ensure_publisher_binding", new_callable=AsyncMock), \ + patch("services.gbp_provisioning.ensure_push_subscription", new_callable=AsyncMock), \ + patch("services.gbp_provisioning.register_notification", new_callable=AsyncMock): + mock_settings.gcp_sa_json = _SA_JSON + mock_settings.gcp_project_id = "my-proj" + mock_settings.gbp_pubsub_topic_name = "gbp-reviews" + mock_settings.base_domain = "api.localmate.com.au" + + result = await provision_gbp_notifications("c1") + + assert result["status"] == "failed" + assert "GCP down" in result["error"] + # Verify provisioning_status='failed' was persisted. + failed_updates = [d for _, d in update_calls if d.get("provisioning_status") == "failed"] + assert len(failed_updates) == 1 + assert "GCP down" in failed_updates[0]["provisioning_error"] + + +@pytest.mark.asyncio +async def test_provision_gbp_notifications_no_account_id(): + """provision_gbp_notifications fails when no GBP account_id is found on locations.""" + from services.gbp_provisioning import provision_gbp_notifications + + mock_db = MagicMock() + update_calls = [] + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "c1", "gbp_access_token": "enc_token"} + ) + def _update(data, **kwargs): + update_calls.append((name, data)) + return chain + chain.update.side_effect = _update + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[{"id": "c1"}]) + elif name == "locations": + chain.select.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"gbp_account_id": None, "gbp_location_id": "LOC1"}] + ) + return chain + + mock_db.table.side_effect = _table + + with patch("db.get_db", return_value=mock_db), \ + patch("services.crypto.decrypt", return_value="plain-token"): + result = await provision_gbp_notifications("c1") + + assert result["status"] == "failed" + assert "account_id" in result["error"] + + +# --------------------------------------------------------------------------- +# ensure_push_subscription (C9 / D15-B) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ensure_push_subscription_creates_when_absent(): + """ensure_push_subscription creates the subscription when it does not exist.""" + from services.gbp_provisioning import ensure_push_subscription + + get_resp = _mock_httpx_response(404, {}) + put_resp = _mock_httpx_response(200, {}) + + with patch("services.gbp_provisioning._gcp_access_token", new_callable=AsyncMock, return_value="token"), \ + patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"get": [get_resp], "put": [put_resp]}) + result = await ensure_push_subscription("my-proj", "gbp-reviews", "https://api.example.com/webhooks/inbound-review") + + assert result == "projects/my-proj/subscriptions/gbp-reviews-push" + client = mock_client_cls.return_value + client.put.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_ensure_push_subscription_skips_when_exists_and_matches(): + """ensure_push_subscription returns early when the subscription exists and matches.""" + from services.gbp_provisioning import ensure_push_subscription + + get_resp = _mock_httpx_response(200, { + "topic": "projects/my-proj/topics/gbp-reviews", + "pushConfig": {"pushEndpoint": "https://api.example.com/webhooks/inbound-review"}, + }) + + with patch("services.gbp_provisioning._gcp_access_token", new_callable=AsyncMock, return_value="token"), \ + patch("services.gbp_provisioning.httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = _mock_async_client({"get": [get_resp]}) + result = await ensure_push_subscription("my-proj", "gbp-reviews", "https://api.example.com/webhooks/inbound-review") + + assert result == "projects/my-proj/subscriptions/gbp-reviews-push" + client = mock_client_cls.return_value + client.put.assert_not_awaited() diff --git a/backend/tests/unit/test_outbound_durable.py b/backend/tests/unit/test_outbound_durable.py index 644ff57..0ecf3c3 100644 --- a/backend/tests/unit/test_outbound_durable.py +++ b/backend/tests/unit/test_outbound_durable.py @@ -322,6 +322,88 @@ async def test_dataforseo_task_not_found_is_success(): mock_dl.assert_not_awaited() +@pytest.mark.asyncio +async def test_dataforseo_maps_task_success(): + """dataforseo_maps_task wraps get_maps_rankings_strict and returns the result.""" + import task_queue + + with patch("services.dataforseo.get_maps_rankings_strict", new_callable=AsyncMock, + return_value={"keyword": "dentist", "map_position": 3, "place_id": "P1", "matched": True}): + result = await task_queue.dataforseo_maps_task( + {"job_try": 1}, "dentist", "Bondi", "Bondi", "Sydney Dental", "P1" + ) + assert result["map_position"] == 3 + assert result["matched"] is True + + +@pytest.mark.asyncio +async def test_dataforseo_maps_task_exception_dead_letters_on_final(): + """dataforseo_maps_task dead-letters on final try when the query raises.""" + import task_queue + + with patch("services.dataforseo.get_maps_rankings_strict", new_callable=AsyncMock, side_effect=TimeoutError("slow")), \ + patch("task_queue.record_dead_letter", new_callable=AsyncMock) as mock_dl: + with pytest.raises(TimeoutError): + await task_queue.dataforseo_maps_task( + {"job_try": task_queue.MAX_TRIES}, "dentist", "Bondi" + ) + mock_dl.assert_awaited_once() + assert mock_dl.call_args[0][0] == "dataforseo" + + +@pytest.mark.asyncio +async def test_provision_gbp_notifications_task_success(): + """provision_gbp_notifications_task returns the result when provisioning succeeds.""" + import task_queue + + with patch("services.gbp_provisioning.provision_gbp_notifications", new_callable=AsyncMock, + return_value={"status": "active", "account_id": "A1"}): + result = await task_queue.provision_gbp_notifications_task({"job_try": 1}, "c1") + assert result["status"] == "active" + + +@pytest.mark.asyncio +async def test_provision_gbp_notifications_task_failed_raises_retry(): + """provision_gbp_notifications_task raises Retry when provisioning fails (non-final).""" + import task_queue + + with patch("services.gbp_provisioning.provision_gbp_notifications", new_callable=AsyncMock, + return_value={"status": "failed", "error": "boom"}), \ + patch("task_queue.record_dead_letter", new_callable=AsyncMock) as mock_dl: + with pytest.raises(Retry): + await task_queue.provision_gbp_notifications_task({"job_try": 1}, "c1") + mock_dl.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_provision_gbp_notifications_task_failed_dead_letters_on_final(): + """provision_gbp_notifications_task dead-letters on final try when provisioning fails.""" + import task_queue + + with patch("services.gbp_provisioning.provision_gbp_notifications", new_callable=AsyncMock, + return_value={"status": "failed", "error": "boom"}), \ + patch("task_queue.record_dead_letter", new_callable=AsyncMock) as mock_dl: + with pytest.raises(RuntimeError): + await task_queue.provision_gbp_notifications_task( + {"job_try": task_queue.MAX_TRIES}, "c1" + ) + mock_dl.assert_awaited_once() + assert mock_dl.call_args[0][0] == "gbp_provisioning" + + +@pytest.mark.asyncio +async def test_provision_gbp_notifications_task_exception_raises_retry(): + """provision_gbp_notifications_task raises Retry on exception (non-final).""" + import task_queue + + with patch("services.gbp_provisioning.provision_gbp_notifications", new_callable=AsyncMock, + side_effect=ConnectionError("net")), \ + patch("task_queue.record_dead_letter", new_callable=AsyncMock) as mock_dl: + with pytest.raises(Retry): + await task_queue.provision_gbp_notifications_task({"job_try": 1}, "c1") + mock_dl.assert_not_awaited() + + def test_credential_task_signatures_take_ids_only(): """Regression guard (item 4): tasks that touch credentials must accept only stable IDs so no secret is ever serialized into Redis/AOF or arq's arg log.""" diff --git a/backend/tests/unit/test_rankings_api.py b/backend/tests/unit/test_rankings_api.py new file mode 100644 index 0000000..235e359 --- /dev/null +++ b/backend/tests/unit/test_rankings_api.py @@ -0,0 +1,80 @@ +"""Tests for the rankings read API (Phase 4 — C7 for Phase 5 dashboard).""" +import pytest +from unittest.mock import patch, MagicMock + + +@pytest.mark.asyncio +async def test_get_rankings_returns_rankings_with_map_position(): + """GET /rankings?location_id=... returns rankings including map_position.""" + from routers.seo import get_rankings + + mock_db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"client_id": "c1"} + ) + elif name == "rankings": + chain.select.return_value.eq.return_value.order.return_value.order.return_value.execute.return_value = MagicMock( + data=[ + {"keyword": "dentist bondi", "position": 3, "map_position": 2, "url": "https://sdc.com.au", "week_start": "2026-07-20"}, + {"keyword": "dental clinic", "position": 7, "map_position": None, "url": "https://sdc.com.au", "week_start": "2026-07-20"}, + ] + ) + return chain + + mock_db.table.side_effect = _table + + with patch("routers.seo.get_db", return_value=mock_db): + result = await get_rankings(location_id="loc-1", auth={"sub": "anonymous"}) + + assert len(result["rankings"]) == 2 + assert result["rankings"][0]["map_position"] == 2 + assert result["rankings"][1]["map_position"] is None + + +@pytest.mark.asyncio +async def test_get_rankings_404_for_unknown_location(): + """GET /rankings with an unknown location_id returns 404.""" + from routers.seo import get_rankings + from fastapi import HTTPException + + mock_db = MagicMock() + mock_db.table.return_value.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data=None + ) + + with patch("routers.seo.get_db", return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await get_rankings(location_id="unknown", auth={"sub": "anonymous"}) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_rankings_empty_for_client_with_no_rankings(): + """GET /rankings returns empty list when the client has no ranking data.""" + from routers.seo import get_rankings + + mock_db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"client_id": "c1"} + ) + elif name == "rankings": + chain.select.return_value.eq.return_value.order.return_value.order.return_value.execute.return_value = MagicMock( + data=[] + ) + return chain + + mock_db.table.side_effect = _table + + with patch("routers.seo.get_db", return_value=mock_db): + result = await get_rankings(location_id="loc-1", auth={"sub": "anonymous"}) + + assert result["rankings"] == [] diff --git a/backend/tests/unit/test_review_guard.py b/backend/tests/unit/test_review_guard.py index 03fbdf3..398c545 100644 --- a/backend/tests/unit/test_review_guard.py +++ b/backend/tests/unit/test_review_guard.py @@ -58,3 +58,125 @@ async def test_claude_response_is_au_english(): assert result == au_response call_kwargs = mock_client_instance.messages.create.call_args.kwargs assert "Australian English" in call_kwargs["system"] + + +# --------------------------------------------------------------------------- +# Yelp guided manual posting (Phase 4) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_approve_yelp_draft_returns_awaiting_manual_post(): + """Approving a source='yelp' draft returns awaiting_manual_post and does NOT call GBP.""" + from routers.approve import approve_review + + mock_db = MagicMock() + mock_db.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={ + "id": "d1", + "client_id": "c1", + "source": "yelp", + "source_id": "yelp-rev-123", + "draft_text": "Thanks for the review!", + "status": "pending_approval", + "metadata": {"url": "https://yelp.com/biz/review/123"}, + } + ) + update_resp = MagicMock() + update_resp.data = [{"id": "d1"}] + mock_db.table.return_value.update.return_value.eq.return_value.execute.return_value = update_resp + + with patch("routers.approve.get_db", return_value=mock_db), \ + patch("services.gbp.post_review_reply", new_callable=AsyncMock) as mock_gbp: + result = await approve_review(draft_id="d1", auth={"sub": "anonymous"}) + + assert result["status"] == "awaiting_manual_post" + assert result["yelp_url"] == "https://yelp.com/biz/review/123" + assert result["reply_text"] == "Thanks for the review!" + # GBP post_review_reply must NOT be called for Yelp drafts. + mock_gbp.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_approve_google_draft_posts_to_gbp(): + """Approving a source='google' draft still posts to GBP (existing path unchanged).""" + from routers.approve import approve_review + + mock_db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "drafts": + chain.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={ + "id": "d1", + "client_id": "c1", + "source": "google", + "source_id": "rev-789", + "draft_text": "Thanks for the review!", + "status": "pending_approval", + "location_id": "loc-1", + } + ) + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[{"id": "d1"}]) + elif name == "clients": + chain.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={"gbp_access_token": "enc_token"} + ) + elif name == "locations": + chain.select.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"gbp_account_id": "ACCT1", "gbp_location_id": "LOC1"} + ) + return chain + + mock_db.table.side_effect = _table + + with patch("routers.approve.get_db", return_value=mock_db), \ + patch("services.gbp.post_review_reply", new_callable=AsyncMock, return_value=True) as mock_gbp: + result = await approve_review(draft_id="d1", auth={"sub": "anonymous"}) + + assert result["status"] == "posted" + mock_gbp.assert_awaited_once() + # Verify the location path was resolved from the locations table (C2). + call_args = mock_gbp.call_args[0] + assert call_args[0] == "accounts/ACCT1/locations/LOC1" + assert call_args[1] == "rev-789" + + +@pytest.mark.asyncio +async def test_mark_posted_transitions_awaiting_to_posted(): + """POST /approve/review/{id}/mark-posted transitions awaiting_manual_post → posted.""" + from routers.approve import mark_review_posted + + mock_db = MagicMock() + mock_db.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={"status": "awaiting_manual_post"} + ) + mock_db.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"id": "d1"}] + ) + + with patch("routers.approve.get_db", return_value=mock_db): + result = await mark_review_posted(draft_id="d1", auth={"sub": "anonymous"}) + + assert result["status"] == "posted" + # Verify the update set status to "posted". + update_data = mock_db.table.return_value.update.call_args[0][0] + assert update_data["status"] == "posted" + + +@pytest.mark.asyncio +async def test_mark_posted_rejects_non_awaiting_draft(): + """mark-posted rejects a draft that is not in awaiting_manual_post state.""" + from routers.approve import mark_review_posted + from fastapi import HTTPException + + mock_db = MagicMock() + mock_db.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={"status": "pending_approval"} + ) + + with patch("routers.approve.get_db", return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await mark_review_posted(draft_id="d1", auth={"sub": "anonymous"}) + + assert exc_info.value.status_code == 409 diff --git a/backend/tests/unit/test_seo.py b/backend/tests/unit/test_seo.py index 3a5a9f4..e403582 100644 --- a/backend/tests/unit/test_seo.py +++ b/backend/tests/unit/test_seo.py @@ -39,6 +39,119 @@ async def test_dataforseo_ranking_stored(): assert result["url"] == "https://sydneydentalcare.com.au" +@pytest.mark.asyncio +async def test_get_maps_rankings_matched_by_place_id(): + """get_maps_rankings returns map_position when the client's place_id matches.""" + from services.dataforseo import get_maps_rankings + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "tasks": [{ + "result": [{ + "items": [ + {"type": "maps_search", "rank_absolute": 1, "title": "Other Clinic", "place_id": "PLACE_OTHER"}, + {"type": "maps_search", "rank_absolute": 5, "title": "Sydney Dental Care", "place_id": "PLACE_OURS"}, + ] + }] + }] + } + + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = False + mock_client.post.return_value = mock_response + + with patch("services.dataforseo.httpx.AsyncClient", return_value=mock_client): + result = await get_maps_rankings( + keyword="dentist bondi", + location="Bondi Junction", + client_suburb="Bondi", + business_name="Sydney Dental Care", + place_id="PLACE_OURS", + ) + + assert result["keyword"] == "dentist bondi" + assert result["map_position"] == 5 + assert result["place_id"] == "PLACE_OURS" + assert result["matched"] is True + + +@pytest.mark.asyncio +async def test_get_maps_rankings_matched_by_business_name(): + """get_maps_rankings matches by normalized title when no place_id is provided.""" + from services.dataforseo import get_maps_rankings + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "tasks": [{ + "result": [{ + "items": [ + {"type": "maps_search", "rank_absolute": 2, "title": "Sydney Dental Care", "place_id": "P1"}, + {"type": "maps_search", "rank_absolute": 3, "title": "Bondi Dental", "place_id": "P2"}, + ] + }] + }] + } + + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = False + mock_client.post.return_value = mock_response + + with patch("services.dataforseo.httpx.AsyncClient", return_value=mock_client): + result = await get_maps_rankings( + keyword="dentist bondi", + location="Bondi", + client_suburb="Bondi", + business_name="sydney dental care", # different case + place_id="", + ) + + assert result["matched"] is True + assert result["map_position"] == 2 + + +@pytest.mark.asyncio +async def test_get_maps_rankings_no_match(): + """get_maps_rankings returns matched=False when no item matches the business.""" + from services.dataforseo import get_maps_rankings + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "tasks": [{ + "result": [{ + "items": [ + {"type": "maps_search", "rank_absolute": 1, "title": "Other Clinic", "place_id": "P1"}, + {"type": "maps_search", "rank_absolute": 2, "title": "Another Clinic", "place_id": "P2"}, + ] + }] + }] + } + + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = False + mock_client.post.return_value = mock_response + + with patch("services.dataforseo.httpx.AsyncClient", return_value=mock_client): + result = await get_maps_rankings( + keyword="dentist bondi", + location="Bondi", + client_suburb="Bondi", + business_name="Sydney Dental Care", + place_id="", + ) + + assert result["matched"] is False + assert result["map_position"] is None + + @pytest.mark.asyncio async def test_seo_report_generates(): """SEO report generation calls Claude and returns report text.""" @@ -72,3 +185,95 @@ async def test_seo_report_generates(): assert result == report_text mock_client_instance.messages.create.assert_called_once() + + +@pytest.mark.asyncio +async def test_seo_report_prompt_includes_organic_and_map_deltas(): + """generate_seo_report prompt payload includes both organic and map deltas.""" + from services.claude import generate_seo_report + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="Report text.")] + mock_client_instance = MagicMock() + mock_client_instance.messages.create.return_value = mock_response + + this_week = [ + {"keyword": "dentist bondi", "position": 3, "map_position": 2}, + ] + last_week = [ + {"keyword": "dentist bondi", "position": 8, "map_position": 5}, + ] + + with patch("services.claude._get_client", return_value=mock_client_instance): + await generate_seo_report( + business_name="Sydney Dental Care", + this_week=this_week, + last_week=last_week, + ) + + call_kwargs = mock_client_instance.messages.create.call_args.kwargs + # The system prompt mentions "search rank" and "map rank" phrasing. + assert "search rank" in call_kwargs["system"] + assert "map rank" in call_kwargs["system"] + # The user content includes both organic and map delta fields. + user_content = call_kwargs["messages"][0]["content"] + assert "search_delta" in user_content + assert "map_delta" in user_content + assert "search_this_week" in user_content + assert "map_this_week" in user_content + + +@pytest.mark.asyncio +async def test_seo_report_upsert_includes_both_positions(): + """seo_report upsert payload includes both position and map_position.""" + from jobs.seo_report import run_seo_rankings_all_clients + + upsert_payloads = [] + + mock_db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.select.return_value.contains.return_value.execute.return_value = MagicMock( + data=[{ + "id": "c1", + "business_name": "Sydney Dental Care", + "email": "test@test.com", + "suburb": "Bondi", + "state": "NSW", + "keywords": ["dentist bondi"], + }] + ) + elif name == "locations": + chain.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data=None + ) + elif name == "rankings": + # Capture upsert payload. + def _upsert(payload, **kwargs): + upsert_payloads.append(payload) + return chain + chain.upsert.side_effect = _upsert + chain.upsert.return_value.execute.return_value = MagicMock(data=[]) + # _fetch_rankings (this week + last week) returns empty. + chain.select.return_value.eq.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + return chain + + mock_db.table.side_effect = _table + + organic_result = {"keyword": "dentist bondi", "position": 3, "url": "https://sdc.com.au"} + maps_result = {"keyword": "dentist bondi", "map_position": 5, "place_id": "P1", "matched": True} + + with patch("jobs.seo_report.get_db", return_value=mock_db), \ + patch("jobs.seo_report._fetch_rankings_from_api", new_callable=AsyncMock, return_value=organic_result), \ + patch("jobs.seo_report._fetch_maps_rankings_from_api", new_callable=AsyncMock, return_value=maps_result), \ + patch("jobs.seo_report._generate_report_safe", new_callable=AsyncMock, return_value="Report"), \ + patch("jobs.seo_report.send_seo_email", new_callable=AsyncMock): + await run_seo_rankings_all_clients() + + assert len(upsert_payloads) == 1 + payload = upsert_payloads[0] + assert payload["position"] == 3 + assert payload["map_position"] == 5 + assert payload["keyword"] == "dentist bondi" diff --git a/backend/tests/unit/test_signup.py b/backend/tests/unit/test_signup.py index 9f98665..876c883 100644 --- a/backend/tests/unit/test_signup.py +++ b/backend/tests/unit/test_signup.py @@ -1,6 +1,6 @@ -"""Tests for client signup endpoint.""" +"""Tests for client signup endpoint and GBP OAuth callback.""" import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, AsyncMock from fastapi.exceptions import HTTPException @@ -84,3 +84,52 @@ async def test_signup_rejects_duplicate_email(): await signup(payload) assert exc_info.value.status_code == 409 + + +# --------------------------------------------------------------------------- +# GBP OAuth callback — resourceName parsing + provisioning enqueue (Phase 4, C4) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_gbp_callback_parses_resource_name_and_enqueues_provisioning(): + """gbp_callback parses account_id + location_id from resourceName, updates + locations, and enqueues provisioning via arq (C4).""" + from routers.auth import gbp_callback + + mock_db = MagicMock() + + def _table(name): + chain = MagicMock() + if name == "clients": + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[{"id": "c1"}]) + elif name == "locations": + # Default location exists → update it. + chain.select.return_value.eq.return_value.eq.return_value.maybe_single.return_value.execute.return_value = MagicMock( + data={"id": "loc-1"} + ) + chain.update.return_value.eq.return_value.execute.return_value = MagicMock(data=[{"id": "loc-1"}]) + return chain + + mock_db.table.side_effect = _table + + mock_request = MagicMock() + mock_arq = MagicMock() + mock_arq.enqueue_job = AsyncMock() + mock_request.app.state.arq = mock_arq + + with patch("routers.auth.get_db", return_value=mock_db), \ + patch("routers.auth.exchange_code_for_tokens", new_callable=AsyncMock, return_value={ + "access_token": "tok", + "refresh_token": "ref", + "resourceName": "accounts/ACCT123/locations/LOC456", + }), \ + patch("routers.auth.encrypt", side_effect=lambda x: f"enc_{x}"): + result = await gbp_callback( + request=mock_request, + code="auth-code", + state="c1", + ) + + assert result["status"] == "connected" + # Provisioning was enqueued via arq. + mock_arq.enqueue_job.assert_awaited_once_with("provision_gbp_notifications_task", "c1") diff --git a/supabase/migrations/023_rankings_local_pack.sql b/supabase/migrations/023_rankings_local_pack.sql new file mode 100644 index 0000000..aa00897 --- /dev/null +++ b/supabase/migrations/023_rankings_local_pack.sql @@ -0,0 +1,7 @@ +-- 023_rankings_local_pack.sql +-- Add Google Maps / Local Pack rank alongside organic rank. +-- place_id lives on locations (per C6), not here — rankings keeps map_position only. +-- position (organic) stays as-is; unique(client_id, keyword, week_start) unchanged. + +alter table rankings + add column if not exists map_position integer; -- null = not in local pack / not matched diff --git a/supabase/migrations/024_competitor_structured.sql b/supabase/migrations/024_competitor_structured.sql new file mode 100644 index 0000000..81cddbc --- /dev/null +++ b/supabase/migrations/024_competitor_structured.sql @@ -0,0 +1,25 @@ +-- 024_competitor_structured.sql +-- Phase 4: structured extraction for competitor snapshots + GBP provisioning-state +-- columns on clients + drafts.external_action_url (Yelp guided posting). +-- +-- competitor_snapshots already has RLS + service_role_all from 005_competitors.sql; +-- these are ALTER TABLE ADD COLUMN so no new policy is needed. + +-- Structured extraction for competitor snapshots. +alter table competitor_snapshots + add column if not exists structured_data jsonb default '{}'::jsonb, -- {prices, menu_items, schema_types, raw_jsonld} + add column if not exists structured_diff jsonb; -- list of {kind, name, old, new} vs previous snapshot + +-- GBP notification provisioning state on clients (D15-B full automation). +-- gbp_account_id lives on locations (C2); these columns track the provisioning +-- workflow outcome so the dashboard / ops can see status at a glance. +alter table clients + add column if not exists provisioning_status text, -- 'pending'|'active'|'failed' + add column if not exists provisioning_error text, -- last provisioning error (nullable) + add column if not exists pubsub_topic text, -- shared pub/sub topic resource name + add column if not exists notification_registered_at timestamptz; -- when notificationSetting was registered + +-- Yelp guided manual posting: store the external action URL (Yelp review deep link) +-- on the draft so the dashboard can show "copy reply + open Yelp". +alter table drafts + add column if not exists external_action_url text; -- e.g. Yelp review reply deep link From caa6c08495abea2c7662a174d376bc29200a4377 Mon Sep 17 00:00:00 2001 From: crewcircle Date: Thu, 23 Jul 2026 05:11:28 +1000 Subject: [PATCH 3/3] Phase 1: billing usage visibility, Stripe portal, tenant-auth binding (#8) Co-authored-by: crewcricle <280911048+crewcricle@users.noreply.github.com> --- DEPLOY.md | 16 ++ backend/.env.example | 2 + backend/jobs/trial_emails.py | 105 ++++++--- backend/main.py | 3 +- backend/middleware/auth.py | 74 +++++- backend/routers/billing.py | 105 +++++++++ backend/task_queue.py | 2 +- backend/tests/unit/test_billing_portal.py | 151 ++++++++++++ backend/tests/unit/test_billing_usage.py | 134 +++++++++++ backend/tests/unit/test_tenant_auth.py | 169 +++++++++++++ .../tests/unit/test_trial_emails_enqueue.py | 222 ++++++++++++++++++ supabase/migrations/013_user_client_map.sql | 23 ++ 12 files changed, 959 insertions(+), 47 deletions(-) create mode 100644 backend/routers/billing.py create mode 100644 backend/tests/unit/test_billing_portal.py create mode 100644 backend/tests/unit/test_billing_usage.py create mode 100644 backend/tests/unit/test_tenant_auth.py create mode 100644 backend/tests/unit/test_trial_emails_enqueue.py create mode 100644 supabase/migrations/013_user_client_map.sql diff --git a/DEPLOY.md b/DEPLOY.md index e7cfb3c..8047a78 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -36,6 +36,7 @@ The backend image runs in three roles, all from `deploy/docker-compose.yml`: - `WORKER_ROLE` — set per-container in `docker-compose.yml` (`web`/`worker`/`scheduler`); no need to set in Doppler. - `STRIPE_PORTAL_CONFIG_ID` — optional `bpc_...`; empty uses the Stripe dashboard default portal config. Used by the Phase 1 billing portal endpoint. Create the portal configuration in Stripe (enable subscription update to the localmate price, payment-method update, cancellation) — one-time dashboard/API step. - `DASHBOARD_URL` — `https://localmate.crewcircle.co`; used as the Stripe portal `return_url` base. +- `SUPABASE_JWT_SECRET` — **Phase 1 (required for billing).** The Supabase project's JWT secret (Settings → API → JWT Settings in the Supabase dashboard). Used to verify dashboard bearer tokens and derive `client_id` from the authenticated user (tenant-auth binding, C8/D20). When unset, the legacy `require_auth` falls back to anonymous for old callers, but the strict `/billing/*` endpoints reject with 401 — so it must be set in prd. ### Migrations (Phase 0) @@ -64,6 +65,21 @@ It asserts, and FAILS the build otherwise: Record the gate's `ALL PHASE 0 STAGING GATE CHECKS PASSED` output on the PR before merge. +### Phase 1 — billing visibility + tenant auth + +**Migration:** apply `supabase/migrations/013_user_client_map.sql` on top of `012`. It creates `user_client_map (user_id text primary key, client_id uuid references clients(id))` with RLS + a `service_role_all` policy (matches `001_clients.sql`). One row per Supabase auth user → owned client; populate it at signup (or backfill existing users to their `clients` row by email). Until a user has a row, `/billing/*` returns 403. + +**Tenant-auth binding (C8/D20):** `GET /billing/usage` and `POST /billing/portal` derive `client_id` from the authenticated identity via `user_client_map` (never from the request body/query), so a user can only reach their own tenant. `SUPABASE_JWT_SECRET` must be set in Doppler — with it empty these endpoints reject 401 (the legacy `require_auth` still falls back to anonymous for old non-client-scoped callers). + +**One-time Stripe Billing Portal configuration:** +1. Stripe Dashboard → Settings → Billing → Customer portal → Add configuration (or via API: `stripe.billing_portal.Configuration.create(...)`). +2. Enable: subscription update (to the localmate price), payment-method update, subscription cancellation. +3. Copy the configuration id (`bpc_...`). +4. Set `STRIPE_PORTAL_CONFIG_ID` in Doppler (`localmate/prd`). Leave empty to use the Stripe dashboard default portal config. +5. `DASHBOARD_URL` controls where the user lands after leaving the portal (the `return_url`). + +Portal-driven subscription changes (plan/card/cancel) flow back through the existing Stripe webhook handlers (`customer.subscription.updated` / `deleted`) which already update `clients.subscription_status` — no new reconciliation path. + --- diff --git a/backend/.env.example b/backend/.env.example index ba9f41d..752f5fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,8 @@ SUPABASE_URL= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= +# Phase 1 — tenant auth (Supabase JWT secret; required for /billing/* client-scoped endpoints) +SUPABASE_JWT_SECRET= STRIPE_SECRET_KEY= STRIPE_PRICE_ID= STRIPE_WEBHOOK_SECRET= diff --git a/backend/jobs/trial_emails.py b/backend/jobs/trial_emails.py index 897e8d9..57edbaa 100644 --- a/backend/jobs/trial_emails.py +++ b/backend/jobs/trial_emails.py @@ -1,8 +1,17 @@ """Trial milestone emails — day1, day7, day13, and expired. -APScheduler-compatible async job. Runs hourly (wired via CronTrigger(hour=9) -in scheduler.py). Uses trial_emails_sent table for idempotency: one email -per (client_id, day_number) pair, ever. +APScheduler-compatible async job. Runs daily (wired via CronTrigger(hour=9) in +scheduler.py, which enqueues the ``run_trial_emails_daily`` arq task). Uses the +``trial_emails_sent`` table for idempotency: one email per (client_id, +day_number) pair, ever. + +Outbound sends are durable (C4): instead of calling the Resend senders directly, +this job enqueues a ``send_email_task`` arq job (the Phase 0 durable wrapper) so +a Resend transport failure retries with backoff and dead-letters on exhaustion +rather than being silently swallowed. The idempotency row is recorded BEFORE the +enqueue and rolled back if the enqueue fails, so a failed enqueue can never leave +a row that would block a future send (which would cause a silent drop); a +permanently-failed send lands in ``dead_letter`` for operator replay. """ import logging @@ -11,22 +20,16 @@ import pytz from db import get_db -from services.resend_email import ( - send_trial_day1_email, - send_trial_day7_email, - send_trial_day13_email, - send_trial_expired_email, -) AEST = pytz.timezone("Australia/Sydney") logger = logging.getLogger(__name__) -# days_since_trial_start -> (send_fn, needs_client_id_arg). -# send_trial_expired_email differs: (to, business_name) only — handled separately. -_DAY_DISPATCH: dict[int, tuple] = { - 1: (send_trial_day1_email, True), - 7: (send_trial_day7_email, True), - 13: (send_trial_day13_email, True), +# days_since_trial_start -> Resend email kind (see services.resend_email._content). +# The expired email differs (no client_id arg) and is handled separately. +_DAY_KIND: dict[int, str] = { + 1: "trial_day1", + 7: "trial_day7", + 13: "trial_day13", } @@ -48,7 +51,28 @@ def _record_send(db, client_id: str, day_number: int) -> None: ).execute() -async def run_trial_emails() -> None: +def _unrecord_send(db, client_id: str, day_number: int) -> None: + """Remove a tentatively-recorded idempotency row (enqueue-failure rollback).""" + ( + db.table("trial_emails_sent") + .delete() + .eq("client_id", client_id) + .eq("day_number", day_number) + .execute() + ) + + +async def run_trial_emails(pool) -> None: + """Enqueue durable trial-milestone emails for all active-trial clients. + + ``pool`` is the arq Redis pool (``ctx["redis"]`` from the + ``run_trial_emails_daily`` cron task). When absent the run is skipped — the + next run with a live pool picks it up. + """ + if pool is None: + logger.warning("run_trial_emails: no arq pool available — skipping this run") + return + db = get_db() now = datetime.now(AEST) @@ -79,22 +103,29 @@ async def run_trial_emails() -> None: ).astimezone(AEST) days_since = (now.date() - trial_start.date()).days - if days_since in _DAY_DISPATCH: - fn, _needs_id = _DAY_DISPATCH[days_since] + if days_since in _DAY_KIND: if not _already_sent(db, client_id, days_since): + # Record the idempotency row FIRST so a crash between + # enqueue-success and record does not cause a duplicate + # delivery on the next run. If the enqueue fails, roll the + # row back so the next run can retry (no silent drop). + _record_send(db, client_id, days_since) try: - await fn(client["email"], client["business_name"], client_id) - _record_send(db, client_id, days_since) + await pool.enqueue_job( + "send_email_task", + _DAY_KIND[days_since], + client["email"], + client["business_name"], + client_id, + ) sent += 1 logger.info( - "Sent day-%d email to %s (%s)", + "Enqueued day-%d email to %s (%s)", days_since, client["email"], client_id, ) - except Exception as exc: - logger.error( - "send_trial_day%d_email failed for %s: %s", - days_since, client_id, exc, - ) + except Exception: + _unrecord_send(db, client_id, days_since) + raise raw_ends = client.get("trial_ends_at") if raw_ends: @@ -102,23 +133,25 @@ async def run_trial_emails() -> None: raw_ends.replace("Z", "+00:00") ).astimezone(AEST) if now > trial_ends and not _already_sent(db, client_id, -1): + # Record-first / rollback-on-enqueue-failure (see above). + _record_send(db, client_id, -1) try: - await send_trial_expired_email( - client["email"], client["business_name"] + await pool.enqueue_job( + "send_email_task", + "trial_expired", + client["email"], + client["business_name"], ) - _record_send(db, client_id, -1) sent += 1 logger.info( - "Sent expired email to %s (%s)", + "Enqueued expired email to %s (%s)", client["email"], client_id, ) - except Exception as exc: - logger.error( - "send_trial_expired_email failed for %s: %s", - client_id, exc, - ) + except Exception: + _unrecord_send(db, client_id, -1) + raise except Exception as exc: logger.error("trial_emails: unexpected error for client %s: %s", client_id, exc) - logger.info("run_trial_emails: processed %d clients, sent %d emails", processed, sent) + logger.info("run_trial_emails: processed %d clients, enqueued %d emails", processed, sent) diff --git a/backend/main.py b/backend/main.py index 271873f..a1ff3f2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ from config import settings from db import init_db from scheduler import create_scheduler -from routers import auth, webhooks, drafts +from routers import auth, webhooks, drafts, billing logging.basicConfig(level=logging.INFO) @@ -75,6 +75,7 @@ async def lifespan(app: FastAPI): app.include_router(auth.router, prefix="/auth") app.include_router(webhooks.router, prefix="/webhooks") app.include_router(drafts.router, prefix="/drafts") +app.include_router(billing.router, prefix="/billing") try: from routers import approve app.include_router(approve.router, prefix="/approve") diff --git a/backend/middleware/auth.py b/backend/middleware/auth.py index 2745fee..d667fa3 100644 --- a/backend/middleware/auth.py +++ b/backend/middleware/auth.py @@ -1,21 +1,18 @@ import logging import jwt -from fastapi import Depends, HTTPException, Request +from fastapi import HTTPException, Request from config import settings +from db import get_db logger = logging.getLogger(__name__) -async def verify_supabase_jwt(request: Request) -> dict: - """Verify Supabase JWT from Authorization Bearer header. Returns decoded payload. +async def _decode_bearer(request: Request) -> dict: + """Extract and decode the Supabase JWT from the Authorization Bearer header. - When supabase_jwt_secret is empty (e.g. CI / local dev stubs), logs a - warning and returns an anonymous payload so tests don't break. + Raises 401 when the header is missing; 403 when the token is invalid. + Shared by :func:`verify_supabase_jwt` and :func:`verify_supabase_jwt_strict`. """ - if not settings.supabase_jwt_secret: - logger.warning("SUPABASE_JWT_SECRET not configured — allowing anonymous access") - return {"sub": "anonymous", "email": None} - auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing auth") @@ -33,4 +30,63 @@ async def verify_supabase_jwt(request: Request) -> dict: return payload +async def verify_supabase_jwt(request: Request) -> dict: + """Verify Supabase JWT from Authorization Bearer header. Returns decoded payload. + + When supabase_jwt_secret is empty (e.g. CI / local dev stubs), logs a + warning and returns an anonymous payload so tests don't break. + """ + if not settings.supabase_jwt_secret: + logger.warning("SUPABASE_JWT_SECRET not configured — allowing anonymous access") + return {"sub": "anonymous", "email": None} + + return await _decode_bearer(request) + + require_auth = verify_supabase_jwt + + +async def verify_supabase_jwt_strict(request: Request) -> dict: + """Strict JWT verification — raises 401 when SUPABASE_JWT_SECRET is unset. + + Unlike :func:`verify_supabase_jwt` (kept for backward compat), this variant + NEVER falls back to an anonymous payload when the secret is empty. It is the + basis for tenant-scoped endpoints (C8/D20) where an unauthenticated caller + must not reach client data. + """ + if not settings.supabase_jwt_secret: + raise HTTPException(status_code=401, detail="Auth not configured") + + return await _decode_bearer(request) + + +async def require_client_id(request: Request) -> str: + """Derive the caller's ``client_id`` from the authenticated identity (C8/D20). + + The client_id is resolved from the JWT subject via the ``user_client_map`` + table — NEVER from the request body or query string — so a user can only + ever reach their own tenant. All Phase 1+ client-scoped endpoints + (``/billing/usage``, ``/billing/portal``, …) inject this as a dependency. + + Raises: + 401 — auth not configured, or missing/invalid token. + 403 — authenticated user has no client binding (cross-tenant denial). + """ + payload = await verify_supabase_jwt_strict(request) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="Invalid identity") + + db = get_db() + resp = ( + db.table("user_client_map") + .select("client_id") + .eq("user_id", user_id) + .maybe_single() + .execute() + ) + if not resp or not resp.data: + # Authenticated but owns no client — deny (cross-tenant protection). + raise HTTPException(status_code=403, detail="No client bound to this user") + + return resp.data["client_id"] diff --git a/backend/routers/billing.py b/backend/routers/billing.py new file mode 100644 index 0000000..429cecf --- /dev/null +++ b/backend/routers/billing.py @@ -0,0 +1,105 @@ +"""Billing router — usage visibility + Stripe Billing Portal (Phase 1). + +All ``/billing/*`` client-scoped routes live here under a single ``/billing`` +prefix (D7-A). ``/auth/billing/setup-*`` stays in ``routers/auth.py`` so the +dashboard's existing SetupIntent calls keep working. + +Tenant scoping (C8/D20): ``client_id`` is derived from the authenticated +identity via :func:`middleware.auth.require_client_id` — never from the request +body/query — so a user can only ever see / act on their own tenant. +""" +import logging + +import stripe +from fastapi import APIRouter, Depends, HTTPException + +from config import settings +from db import get_db +from middleware.auth import require_client_id +from middleware.trial_gate import TRIAL_CAPS + +router = APIRouter() +logger = logging.getLogger(__name__) +stripe.api_key = settings.stripe_secret_key + + +@router.get("/usage") +async def billing_usage(client_id: str = Depends(require_client_id)): + """Per-job-type trial usage (used / cap / remaining) + trial/subscription state. + + Caps are sourced from :data:`middleware.trial_gate.TRIAL_CAPS` (single source + of truth). An active subscription reports unlimited caps (``cap``/``remaining`` + as ``null``) to mirror the :func:`check_trial_gate` bypass. Only the keys in + ``TRIAL_CAPS`` are reported; a job type absent from ``trial_usage`` is treated + as 0 used. + """ + db = get_db() + resp = ( + db.table("clients") + .select("trial_status, trial_ends_at, subscription_status, trial_usage") + .eq("id", client_id) + .single() + .execute() + ) + if not resp.data: + raise HTTPException(status_code=404, detail="Client not found") + + client = resp.data + usage_map = client.get("trial_usage") or {} + active = client.get("subscription_status") == "active" + + usage = {} + for job_type, cap in TRIAL_CAPS.items(): + used = usage_map.get(job_type, 0) + if active: + # Paid = unlimited (D6-A): mirror the trial-gate bypass. + usage[job_type] = {"used": used, "cap": None, "remaining": None} + else: + usage[job_type] = {"used": used, "cap": cap, "remaining": max(cap - used, 0)} + + return { + "trial_status": client.get("trial_status"), + "trial_ends_at": client.get("trial_ends_at"), + "subscription_status": client.get("subscription_status"), + "usage": usage, + } + + +@router.post("/portal") +async def billing_portal(client_id: str = Depends(require_client_id)): + """Create a Stripe Billing Portal session for self-serve plan/card/cancel. + + Returns ``{"url": ...}``. The dashboard redirects to ``url``. Portal-driven + subscription changes flow back through the existing Stripe webhook handlers + (``customer.subscription.updated`` / ``deleted``) — no new reconciliation path. + """ + db = get_db() + resp = ( + db.table("clients") + .select("stripe_customer_id") + .eq("id", client_id) + .single() + .execute() + ) + if not resp.data: + raise HTTPException(status_code=404, detail="Client not found") + + customer_id = resp.data.get("stripe_customer_id") + if not customer_id: + raise HTTPException(status_code=422, detail="No Stripe customer id for this client") + + return_url = settings.dashboard_url or "https://localmate.crewcircle.co/dashboard/settings" + params = {"customer": customer_id, "return_url": return_url} + # `configuration` (NOT `configuration_id`) is the verified Stripe API param. + # Pass it only when a portal config id is configured; omit to use the + # Stripe dashboard default portal config. + if settings.stripe_portal_config_id: + params["configuration"] = settings.stripe_portal_config_id + + try: + session = stripe.billing_portal.Session.create(**params) + except Exception as e: + logger.error("Stripe billing portal session failed for %s: %s", client_id, e) + raise HTTPException(status_code=502, detail="Billing portal unavailable") + + return {"url": session.url} diff --git a/backend/task_queue.py b/backend/task_queue.py index 782bd43..e9b43aa 100644 --- a/backend/task_queue.py +++ b/backend/task_queue.py @@ -457,7 +457,7 @@ async def run_trial_hourly(ctx: dict) -> None: async def run_trial_emails_daily(ctx: dict) -> None: from jobs.trial_emails import run_trial_emails - await run_trial_emails() + await run_trial_emails(ctx.get("redis")) async def reconcile_webhooks(ctx: dict) -> dict: diff --git a/backend/tests/unit/test_billing_portal.py b/backend/tests/unit/test_billing_portal.py new file mode 100644 index 0000000..a701c26 --- /dev/null +++ b/backend/tests/unit/test_billing_portal.py @@ -0,0 +1,151 @@ +"""Tests for POST /billing/portal (Phase 1) — Stripe Billing Portal session. + +Happy path returns a url; no stripe_customer_id -> 422; Stripe error -> 502; +missing client -> 404; client_id derived from auth (cross-tenant). The Stripe +param is `configuration` (not `configuration_id`) and is passed only when +STRIPE_PORTAL_CONFIG_ID is set. +""" +import inspect +from unittest.mock import patch, MagicMock + +import pytest +from fastapi.params import Depends as DependsParam +from fastapi import HTTPException + + +def _clients_db(client_row): + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data=client_row + ) + return db + + +def _settings_mock(*, portal_config_id="", dashboard_url="https://localmate.crewcircle.co"): + s = MagicMock() + s.stripe_portal_config_id = portal_config_id + s.dashboard_url = dashboard_url + return s + + +@pytest.mark.asyncio +async def test_portal_happy_path_returns_url(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + mock_session = MagicMock(url="https://billing.stripe.com/p/session_xyz") + + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock(portal_config_id="")), \ + patch("routers.billing.stripe.billing_portal.Session.create", return_value=mock_session) as mock_create: + result = await billing_portal(client_id="client-1") + + assert result["url"] == "https://billing.stripe.com/p/session_xyz" + call = mock_create.call_args + assert call[1]["customer"] == "cus_abc" + assert call[1]["return_url"] == "https://localmate.crewcircle.co" + # configuration omitted when STRIPE_PORTAL_CONFIG_ID is empty + assert "configuration" not in call[1] + + +@pytest.mark.asyncio +async def test_portal_passes_configuration_when_configured(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + mock_session = MagicMock(url="https://billing.stripe.com/p/session_xyz") + + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock(portal_config_id="bpc_123")), \ + patch("routers.billing.stripe.billing_portal.Session.create", return_value=mock_session) as mock_create: + await billing_portal(client_id="client-1") + + call = mock_create.call_args + # param is `configuration`, NOT `configuration_id` + assert call[1]["configuration"] == "bpc_123" + assert "configuration_id" not in call[1] + + +@pytest.mark.asyncio +async def test_portal_fallback_return_url_when_dashboard_url_empty(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + mock_session = MagicMock(url="https://billing.stripe.com/p/s") + + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock(dashboard_url="")), \ + patch("routers.billing.stripe.billing_portal.Session.create", return_value=mock_session) as mock_create: + await billing_portal(client_id="client-1") + + call = mock_create.call_args + assert call[1]["return_url"] == "https://localmate.crewcircle.co/dashboard/settings" + + +@pytest.mark.asyncio +async def test_portal_no_customer_id_422(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": None}) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-1") + + assert exc_info.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_portal_empty_customer_id_422(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": ""}) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-1") + + assert exc_info.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_portal_stripe_error_502(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()), \ + patch("routers.billing.stripe.billing_portal.Session.create", side_effect=RuntimeError("stripe down")): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-1") + + assert exc_info.value.status_code == 502 + assert "unavailable" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_portal_missing_client_404(): + from routers.billing import billing_portal + + db = _clients_db(None) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-missing") + + assert exc_info.value.status_code == 404 + + +def test_portal_client_id_derived_from_auth_not_request(): + """Cross-tenant protection (C8/D20): client_id is injected by the + require_client_id auth dependency, never accepted from query/body.""" + from routers.billing import billing_portal + from middleware.auth import require_client_id + + sig = inspect.signature(billing_portal) + default = sig.parameters["client_id"].default + assert isinstance(default, DependsParam), "client_id must be injected via Depends" + assert default.dependency is require_client_id, ( + "client_id must be derived from require_client_id (tenant binding), " + "not a query/body param" + ) diff --git a/backend/tests/unit/test_billing_usage.py b/backend/tests/unit/test_billing_usage.py new file mode 100644 index 0000000..be8e065 --- /dev/null +++ b/backend/tests/unit/test_billing_usage.py @@ -0,0 +1,134 @@ +"""Tests for GET /billing/usage (Phase 1) — used/cap/remaining visibility. + +Caps are sourced from middleware.trial_gate.TRIAL_CAPS (single source of truth). +Active subs report unlimited; trial/expired report real caps; missing trial_usage +keys are 0; missing client -> 404; client_id is derived from auth (cross-tenant). +""" +import inspect +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock + +import pytz +import pytest +from fastapi.params import Depends as DependsParam +from fastapi import HTTPException + +from middleware.trial_gate import TRIAL_CAPS + +AEST = pytz.timezone("Australia/Sydney") + + +def _clients_db(client_row): + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data=client_row + ) + return db + + +@pytest.mark.asyncio +async def test_usage_active_subscription_unlimited(): + from routers.billing import billing_usage + + client = { + "trial_status": "converted", + "trial_ends_at": (datetime.now(AEST) + timedelta(days=7)).isoformat(), + "subscription_status": "active", + "trial_usage": {"review_drafts": 250, "seo_reports": 9}, + } + db = _clients_db(client) + with patch("routers.billing.get_db", return_value=db): + result = await billing_usage(client_id="client-active") + + assert result["subscription_status"] == "active" + assert set(result["usage"]) == set(TRIAL_CAPS) + for job_type in TRIAL_CAPS: + entry = result["usage"][job_type] + assert entry["cap"] is None + assert entry["remaining"] is None + # used is still the real count, even when unlimited + assert result["usage"]["review_drafts"]["used"] == 250 + assert result["usage"]["seo_reports"]["used"] == 9 + + +@pytest.mark.asyncio +async def test_usage_trial_within_cap(): + from routers.billing import billing_usage + + client = { + "trial_status": "active", + "trial_ends_at": (datetime.now(AEST) + timedelta(days=10)).isoformat(), + "subscription_status": "trialing", + # competitor_briefs + followup_messages intentionally absent -> 0 used + "trial_usage": {"review_drafts": 10, "seo_reports": 1}, + } + db = _clients_db(client) + with patch("routers.billing.get_db", return_value=db): + result = await billing_usage(client_id="client-trial") + + assert result["subscription_status"] == "trialing" + u = result["usage"] + assert u["review_drafts"] == {"used": 10, "cap": 100, "remaining": 90} + assert u["seo_reports"] == {"used": 1, "cap": 2, "remaining": 1} + # missing keys default to 0 used + assert u["competitor_briefs"] == {"used": 0, "cap": 1, "remaining": 1} + assert u["followup_messages"] == {"used": 0, "cap": 50, "remaining": 50} + + +@pytest.mark.asyncio +async def test_usage_trial_expired_still_reports_caps(): + """An expired trial still returns real caps (only active subs go unlimited).""" + from routers.billing import billing_usage + + client = { + "trial_status": "expired", + "trial_ends_at": (datetime.now(AEST) - timedelta(days=2)).isoformat(), + "subscription_status": "trial_expired", + "trial_usage": {"review_drafts": 100}, + } + db = _clients_db(client) + with patch("routers.billing.get_db", return_value=db): + result = await billing_usage(client_id="client-expired") + + assert result["trial_status"] == "expired" + # caps are NOT null for expired (only active subs are unlimited) + assert result["usage"]["review_drafts"] == {"used": 100, "cap": 100, "remaining": 0} + assert result["usage"]["seo_reports"]["cap"] == 2 + assert result["usage"]["seo_reports"]["remaining"] == 2 + + +@pytest.mark.asyncio +async def test_usage_missing_client_404(): + from routers.billing import billing_usage + + db = _clients_db(None) + with patch("routers.billing.get_db", return_value=db): + with pytest.raises(HTTPException) as exc_info: + await billing_usage(client_id="client-missing") + + assert exc_info.value.status_code == 404 + + +def test_usage_caps_sourced_from_trial_gate(): + """The endpoint must NOT duplicate caps — they come from TRIAL_CAPS.""" + assert set(TRIAL_CAPS) == { + "review_drafts", "seo_reports", "competitor_briefs", "followup_messages" + } + assert TRIAL_CAPS == { + "review_drafts": 100, "seo_reports": 2, "competitor_briefs": 1, "followup_messages": 50, + } + + +def test_usage_client_id_derived_from_auth_not_request(): + """Cross-tenant protection (C8/D20): client_id is injected by the + require_client_id auth dependency, never accepted from query/body.""" + from routers.billing import billing_usage + from middleware.auth import require_client_id + + sig = inspect.signature(billing_usage) + default = sig.parameters["client_id"].default + assert isinstance(default, DependsParam), "client_id must be injected via Depends" + assert default.dependency is require_client_id, ( + "client_id must be derived from require_client_id (tenant binding), " + "not a query/body param" + ) diff --git a/backend/tests/unit/test_tenant_auth.py b/backend/tests/unit/test_tenant_auth.py new file mode 100644 index 0000000..9d551e5 --- /dev/null +++ b/backend/tests/unit/test_tenant_auth.py @@ -0,0 +1,169 @@ +"""Tests for tenant-auth binding (C8/D20) — the strict client-resolving dependency. + +Covers: empty SUPABASE_JWT_SECRET must NOT return anonymous (strict path raises +401), missing token -> 401, and cross-tenant denial (a user's token resolves to +ONLY their bound client via user_client_map — another tenant's client is +unreachable because the lookup is keyed on the JWT subject, not request input). +""" +import inspect +from unittest.mock import patch, MagicMock + +import jwt as pyjwt +import pytest +from fastapi import HTTPException + +SECRET = "test-jwt-secret-must-be-at-least-32-bytes" + + +def _make_jwt(sub: str) -> str: + return pyjwt.encode({"sub": sub}, SECRET, algorithm="HS256") + + +def _request(token: str | None = None) -> MagicMock: + req = MagicMock() + req.headers = {} if token is None else {"Authorization": f"Bearer {token}"} + return req + + +def _map_db(bindings: dict[str, str]) -> MagicMock: + """Mock supabase where user_client_map lookup by user_id returns the bound + client_id (or None when the user has no binding).""" + db = MagicMock() + select = db.table.return_value.select.return_value + + def _eq(col, val): + cid = bindings.get(val) + data = {"client_id": cid} if cid is not None else None + ret = MagicMock() + ret.maybe_single.return_value.execute.return_value = MagicMock(data=data) + return ret + + select.eq.side_effect = _eq + return db + + +@pytest.mark.asyncio +async def test_strict_rejects_empty_secret_not_anonymous(): + """Empty SUPABASE_JWT_SECRET must raise 401, NOT return an anonymous payload.""" + from middleware.auth import verify_supabase_jwt, verify_supabase_jwt_strict + + req = _request(token=None) + with patch("middleware.auth.settings") as s: + s.supabase_jwt_secret = "" + + # Legacy path still returns anonymous (backward compat). + legacy = await verify_supabase_jwt(req) + assert legacy == {"sub": "anonymous", "email": None} + + # Strict path rejects — no anonymous fallback. + with pytest.raises(HTTPException) as exc_info: + await verify_supabase_jwt_strict(req) + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_require_client_id_missing_token_rejected_401(): + from middleware.auth import require_client_id + + req = _request(token=None) + db = _map_db({}) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 401 + db.table.assert_not_called() # never reached the client lookup + + +@pytest.mark.asyncio +async def test_require_client_id_invalid_token_rejected_403(): + from middleware.auth import require_client_id + + req = _request(token="not-a-real-jwt") + db = _map_db({}) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_require_client_id_resolves_bound_client(): + """A valid token resolves to the client bound to its subject.""" + from middleware.auth import require_client_id + + db = _map_db({"userA": "clientA"}) + req = _request(token=_make_jwt("userA")) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + client_id = await require_client_id(req) + + assert client_id == "clientA" + # The lookup is keyed on the JWT subject, NOT any request input. + select = db.table.return_value.select.return_value + select.eq.assert_called_once_with("user_id", "userA") + + +@pytest.mark.asyncio +async def test_require_client_id_denies_user_without_binding(): + """An authenticated user with no client binding is denied (403).""" + from middleware.auth import require_client_id + + db = _map_db({}) # userC has no mapping + req = _request(token=_make_jwt("userC")) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_cross_tenant_user_only_reaches_own_client(): + """User A's token resolves to client A and can NEVER yield client B; user B's + token resolves to client B. Tenant isolation is enforced by the JWT-subject + binding (C8/D20).""" + from middleware.auth import require_client_id + + # userA -> clientA, userB -> clientB + db = _map_db({"userA": "clientA", "userB": "clientB"}) + + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + + cid_a = await require_client_id(_request(token=_make_jwt("userA"))) + cid_b = await require_client_id(_request(token=_make_jwt("userB"))) + + assert cid_a == "clientA" + assert cid_b == "clientB" + # user A's token never yields client B — the other tenant is unreachable. + assert cid_a != "clientB" + # Both lookups were bound to their respective JWT subjects. + eq_calls = [c.args for c in db.table.return_value.select.return_value.eq.call_args_list] + assert ("user_id", "userA") in eq_calls + assert ("user_id", "userB") in eq_calls + + +def test_require_auth_kept_for_backward_compat(): + """The legacy anonymous-fallback dependency is unchanged for existing callers.""" + from middleware.auth import require_auth, verify_supabase_jwt + + assert require_auth is verify_supabase_jwt + + +def test_require_client_id_is_a_fastapi_dependency_signature(): + """Sanity: require_client_id is a plain async callable usable with Depends.""" + from middleware.auth import require_client_id + + sig = inspect.signature(require_client_id) + assert "request" in sig.parameters diff --git a/backend/tests/unit/test_trial_emails_enqueue.py b/backend/tests/unit/test_trial_emails_enqueue.py new file mode 100644 index 0000000..e0f7f2b --- /dev/null +++ b/backend/tests/unit/test_trial_emails_enqueue.py @@ -0,0 +1,222 @@ +"""Tests that trial milestone emails enqueue durable arq jobs (C4 outbound migration). + +run_trial_emails now enqueues `send_email_task` arq jobs (the Phase 0 durable +Resend wrapper) instead of calling the Resend senders directly, so a transport +failure retries + dead-letters rather than being silently swallowed. +""" +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock, AsyncMock + +import pytz +import pytest + +AEST = pytz.timezone("Australia/Sydney") + + +def _trial_emails_db(clients): + """Mock supabase routing .table('clients') and .table('trial_emails_sent') + to separate chain mocks (a single shared chain cannot serve both).""" + db = MagicMock() + + clients_table = MagicMock() + clients_table.select.return_value.eq.return_value.execute.return_value = MagicMock(data=clients) + + sent_table = MagicMock() + # _already_sent: select('id').eq('client_id').eq('day_number').limit(1).execute() -> [] + sent_table.select.return_value.eq.return_value.eq.return_value.limit.return_value.execute.return_value = MagicMock(data=[]) + sent_table.insert.return_value.execute.return_value = MagicMock(data=[{"id": "row-1"}]) + + db.table.side_effect = lambda name: clients_table if name == "clients" else sent_table + return db, sent_table + + +@pytest.mark.asyncio +async def test_day_email_enqueues_send_email_task(): + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), # days_since == 1 + "trial_ends_at": (now + timedelta(days=13)).isoformat(), # not expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + # Enqueued a durable send_email_task for the day-1 email, with the right kind + # and positional args (kind, to, business_name, client_id). + pool.enqueue_job.assert_any_call( + "send_email_task", "trial_day1", "owner@biz1.com.au", "Biz One", "c1" + ) + # Idempotency row recorded after successful enqueue. + sent_table.insert.assert_called() + + +@pytest.mark.asyncio +async def test_expired_email_enqueues_send_email_task(): + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c2", + "email": "owner@biz2.com.au", + "business_name": "Biz Two", + "trial_started_at": (now - timedelta(days=20)).isoformat(), # not a milestone day + "trial_ends_at": (now - timedelta(days=6)).isoformat(), # expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + # Expired email enqueued (kind, to, business_name) — no client_id arg. + pool.enqueue_job.assert_any_call( + "send_email_task", "trial_expired", "owner@biz2.com.au", "Biz Two" + ) + sent_table.insert.assert_called() + + +@pytest.mark.asyncio +async def test_does_not_directly_call_resend_senders(): + """The Resend senders must no longer be called inline — sends go via arq.""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), + "trial_ends_at": (now + timedelta(days=13)).isoformat(), + "trial_status": "active", + } + db, _ = _trial_emails_db([client]) + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db), \ + patch("services.resend_email.send_email_strict", new_callable=AsyncMock) as mock_strict, \ + patch("services.resend_email.send_trial_day1_email", new_callable=AsyncMock) as mock_day1: + await run_trial_emails(pool) + + mock_strict.assert_not_awaited() + mock_day1.assert_not_awaited() + pool.enqueue_job.assert_awaited() + + +@pytest.mark.asyncio +async def test_idempotent_already_sent_not_reenqueued(): + """A milestone already recorded in trial_emails_sent is not re-enqueued.""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), + "trial_ends_at": (now + timedelta(days=13)).isoformat(), + "trial_status": "active", + } + db = MagicMock() + clients_table = MagicMock() + clients_table.select.return_value.eq.return_value.execute.return_value = MagicMock(data=[client]) + sent_table = MagicMock() + # _already_sent returns a row -> already sent + sent_table.select.return_value.eq.return_value.eq.return_value.limit.return_value.execute.return_value = MagicMock( + data=[{"id": "existing"}] + ) + db.table.side_effect = lambda name: clients_table if name == "clients" else sent_table + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + pool.enqueue_job.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_pool_skips_safely(): + """Without an arq pool the run skips rather than crashing.""" + from jobs.trial_emails import run_trial_emails + + db = MagicMock() + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(None) # must not raise + + +@pytest.mark.asyncio +async def test_enqueue_failure_day_rolls_back_idempotency_record(): + """An enqueue failure must roll back the tentatively-recorded idempotency + row so the next run can retry — never leaving a row that blocks future sends + (which would silently drop the email).""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), # days_since == 1 + "trial_ends_at": (now + timedelta(days=13)).isoformat(), # not expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock(side_effect=RuntimeError("redis down")) + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) # per-client handler swallows; run completes + + pool.enqueue_job.assert_awaited() + # The idempotency row was tentatively recorded... + sent_table.insert.assert_called() + # ...then rolled back (delete targeting this client + day) so it does not + # block the next run. + sent_table.delete.assert_called_once() + sent_table.delete.return_value.eq.assert_called_with("client_id", "c1") + sent_table.delete.return_value.eq.return_value.eq.assert_called_with("day_number", 1) + + +@pytest.mark.asyncio +async def test_enqueue_failure_expired_rolls_back_idempotency_record(): + """Same rollback guarantee for the expired-email loop (day_number -1).""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c2", + "email": "owner@biz2.com.au", + "business_name": "Biz Two", + "trial_started_at": (now - timedelta(days=20)).isoformat(), # not a milestone day + "trial_ends_at": (now - timedelta(days=6)).isoformat(), # expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock(side_effect=RuntimeError("redis down")) + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + pool.enqueue_job.assert_awaited() + sent_table.insert.assert_called() + sent_table.delete.assert_called_once() + sent_table.delete.return_value.eq.assert_called_with("client_id", "c2") + sent_table.delete.return_value.eq.return_value.eq.assert_called_with("day_number", -1) diff --git a/supabase/migrations/013_user_client_map.sql b/supabase/migrations/013_user_client_map.sql new file mode 100644 index 0000000..65b582a --- /dev/null +++ b/supabase/migrations/013_user_client_map.sql @@ -0,0 +1,23 @@ +-- 013_user_client_map.sql +-- User -> client ownership binding for tenant authorization (C8/D20). +-- +-- Maps a Supabase auth user (the JWT `sub` claim) to exactly one client/tenant +-- row so that client-scoped endpoints derive `client_id` from the authenticated +-- identity via this table — NEVER from a request body/query param. This is the +-- Phase 1 prerequisite (D20-A) that all later client-scoped endpoints reuse +-- (/billing/usage, /billing/portal, /locations, /practitioners, /rankings, …). +-- +-- One client per user (1:1) today; a later phase may relax to many-if-needed. +-- RLS + service_role_all policy matches 001_clients.sql so the service-role +-- backend can read/write (the backend uses the Supabase service role key). + +create table user_client_map ( + user_id text primary key, -- Supabase auth user id (JWT `sub`) + client_id uuid not null references clients(id), -- owned client/tenant + created_at timestamptz default now() +); + +alter table user_client_map enable row level security; + +create policy "service_role_all" on user_client_map + using (auth.role() = 'service_role');