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/2] 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/2] 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