diff --git a/DEPLOY.md b/DEPLOY.md index e7cfb3c..8047a78 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -36,6 +36,7 @@ The backend image runs in three roles, all from `deploy/docker-compose.yml`: - `WORKER_ROLE` — set per-container in `docker-compose.yml` (`web`/`worker`/`scheduler`); no need to set in Doppler. - `STRIPE_PORTAL_CONFIG_ID` — optional `bpc_...`; empty uses the Stripe dashboard default portal config. Used by the Phase 1 billing portal endpoint. Create the portal configuration in Stripe (enable subscription update to the localmate price, payment-method update, cancellation) — one-time dashboard/API step. - `DASHBOARD_URL` — `https://localmate.crewcircle.co`; used as the Stripe portal `return_url` base. +- `SUPABASE_JWT_SECRET` — **Phase 1 (required for billing).** The Supabase project's JWT secret (Settings → API → JWT Settings in the Supabase dashboard). Used to verify dashboard bearer tokens and derive `client_id` from the authenticated user (tenant-auth binding, C8/D20). When unset, the legacy `require_auth` falls back to anonymous for old callers, but the strict `/billing/*` endpoints reject with 401 — so it must be set in prd. ### Migrations (Phase 0) @@ -64,6 +65,21 @@ It asserts, and FAILS the build otherwise: Record the gate's `ALL PHASE 0 STAGING GATE CHECKS PASSED` output on the PR before merge. +### Phase 1 — billing visibility + tenant auth + +**Migration:** apply `supabase/migrations/013_user_client_map.sql` on top of `012`. It creates `user_client_map (user_id text primary key, client_id uuid references clients(id))` with RLS + a `service_role_all` policy (matches `001_clients.sql`). One row per Supabase auth user → owned client; populate it at signup (or backfill existing users to their `clients` row by email). Until a user has a row, `/billing/*` returns 403. + +**Tenant-auth binding (C8/D20):** `GET /billing/usage` and `POST /billing/portal` derive `client_id` from the authenticated identity via `user_client_map` (never from the request body/query), so a user can only reach their own tenant. `SUPABASE_JWT_SECRET` must be set in Doppler — with it empty these endpoints reject 401 (the legacy `require_auth` still falls back to anonymous for old non-client-scoped callers). + +**One-time Stripe Billing Portal configuration:** +1. Stripe Dashboard → Settings → Billing → Customer portal → Add configuration (or via API: `stripe.billing_portal.Configuration.create(...)`). +2. Enable: subscription update (to the localmate price), payment-method update, subscription cancellation. +3. Copy the configuration id (`bpc_...`). +4. Set `STRIPE_PORTAL_CONFIG_ID` in Doppler (`localmate/prd`). Leave empty to use the Stripe dashboard default portal config. +5. `DASHBOARD_URL` controls where the user lands after leaving the portal (the `return_url`). + +Portal-driven subscription changes (plan/card/cancel) flow back through the existing Stripe webhook handlers (`customer.subscription.updated` / `deleted`) which already update `clients.subscription_status` — no new reconciliation path. + --- diff --git a/backend/.env.example b/backend/.env.example index ba9f41d..752f5fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,8 @@ SUPABASE_URL= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= +# Phase 1 — tenant auth (Supabase JWT secret; required for /billing/* client-scoped endpoints) +SUPABASE_JWT_SECRET= STRIPE_SECRET_KEY= STRIPE_PRICE_ID= STRIPE_WEBHOOK_SECRET= diff --git a/backend/config.py b/backend/config.py index c31022d..7dc79bb 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 --- @@ -35,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_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..0ecf3c3 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" @@ -287,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_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_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/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/tests/unit/test_tenant_auth.py b/backend/tests/unit/test_tenant_auth.py new file mode 100644 index 0000000..9d551e5 --- /dev/null +++ b/backend/tests/unit/test_tenant_auth.py @@ -0,0 +1,169 @@ +"""Tests for tenant-auth binding (C8/D20) — the strict client-resolving dependency. + +Covers: empty SUPABASE_JWT_SECRET must NOT return anonymous (strict path raises +401), missing token -> 401, and cross-tenant denial (a user's token resolves to +ONLY their bound client via user_client_map — another tenant's client is +unreachable because the lookup is keyed on the JWT subject, not request input). +""" +import inspect +from unittest.mock import patch, MagicMock + +import jwt as pyjwt +import pytest +from fastapi import HTTPException + +SECRET = "test-jwt-secret-must-be-at-least-32-bytes" + + +def _make_jwt(sub: str) -> str: + return pyjwt.encode({"sub": sub}, SECRET, algorithm="HS256") + + +def _request(token: str | None = None) -> MagicMock: + req = MagicMock() + req.headers = {} if token is None else {"Authorization": f"Bearer {token}"} + return req + + +def _map_db(bindings: dict[str, str]) -> MagicMock: + """Mock supabase where user_client_map lookup by user_id returns the bound + client_id (or None when the user has no binding).""" + db = MagicMock() + select = db.table.return_value.select.return_value + + def _eq(col, val): + cid = bindings.get(val) + data = {"client_id": cid} if cid is not None else None + ret = MagicMock() + ret.maybe_single.return_value.execute.return_value = MagicMock(data=data) + return ret + + select.eq.side_effect = _eq + return db + + +@pytest.mark.asyncio +async def test_strict_rejects_empty_secret_not_anonymous(): + """Empty SUPABASE_JWT_SECRET must raise 401, NOT return an anonymous payload.""" + from middleware.auth import verify_supabase_jwt, verify_supabase_jwt_strict + + req = _request(token=None) + with patch("middleware.auth.settings") as s: + s.supabase_jwt_secret = "" + + # Legacy path still returns anonymous (backward compat). + legacy = await verify_supabase_jwt(req) + assert legacy == {"sub": "anonymous", "email": None} + + # Strict path rejects — no anonymous fallback. + with pytest.raises(HTTPException) as exc_info: + await verify_supabase_jwt_strict(req) + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_require_client_id_missing_token_rejected_401(): + from middleware.auth import require_client_id + + req = _request(token=None) + db = _map_db({}) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 401 + db.table.assert_not_called() # never reached the client lookup + + +@pytest.mark.asyncio +async def test_require_client_id_invalid_token_rejected_403(): + from middleware.auth import require_client_id + + req = _request(token="not-a-real-jwt") + db = _map_db({}) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_require_client_id_resolves_bound_client(): + """A valid token resolves to the client bound to its subject.""" + from middleware.auth import require_client_id + + db = _map_db({"userA": "clientA"}) + req = _request(token=_make_jwt("userA")) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + client_id = await require_client_id(req) + + assert client_id == "clientA" + # The lookup is keyed on the JWT subject, NOT any request input. + select = db.table.return_value.select.return_value + select.eq.assert_called_once_with("user_id", "userA") + + +@pytest.mark.asyncio +async def test_require_client_id_denies_user_without_binding(): + """An authenticated user with no client binding is denied (403).""" + from middleware.auth import require_client_id + + db = _map_db({}) # userC has no mapping + req = _request(token=_make_jwt("userC")) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_cross_tenant_user_only_reaches_own_client(): + """User A's token resolves to client A and can NEVER yield client B; user B's + token resolves to client B. Tenant isolation is enforced by the JWT-subject + binding (C8/D20).""" + from middleware.auth import require_client_id + + # userA -> clientA, userB -> clientB + db = _map_db({"userA": "clientA", "userB": "clientB"}) + + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + + cid_a = await require_client_id(_request(token=_make_jwt("userA"))) + cid_b = await require_client_id(_request(token=_make_jwt("userB"))) + + assert cid_a == "clientA" + assert cid_b == "clientB" + # user A's token never yields client B — the other tenant is unreachable. + assert cid_a != "clientB" + # Both lookups were bound to their respective JWT subjects. + eq_calls = [c.args for c in db.table.return_value.select.return_value.eq.call_args_list] + assert ("user_id", "userA") in eq_calls + assert ("user_id", "userB") in eq_calls + + +def test_require_auth_kept_for_backward_compat(): + """The legacy anonymous-fallback dependency is unchanged for existing callers.""" + from middleware.auth import require_auth, verify_supabase_jwt + + assert require_auth is verify_supabase_jwt + + +def test_require_client_id_is_a_fastapi_dependency_signature(): + """Sanity: require_client_id is a plain async callable usable with Depends.""" + from middleware.auth import require_client_id + + sig = inspect.signature(require_client_id) + assert "request" in sig.parameters diff --git a/backend/tests/unit/test_trial_emails_enqueue.py b/backend/tests/unit/test_trial_emails_enqueue.py new file mode 100644 index 0000000..e0f7f2b --- /dev/null +++ b/backend/tests/unit/test_trial_emails_enqueue.py @@ -0,0 +1,222 @@ +"""Tests that trial milestone emails enqueue durable arq jobs (C4 outbound migration). + +run_trial_emails now enqueues `send_email_task` arq jobs (the Phase 0 durable +Resend wrapper) instead of calling the Resend senders directly, so a transport +failure retries + dead-letters rather than being silently swallowed. +""" +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock, AsyncMock + +import pytz +import pytest + +AEST = pytz.timezone("Australia/Sydney") + + +def _trial_emails_db(clients): + """Mock supabase routing .table('clients') and .table('trial_emails_sent') + to separate chain mocks (a single shared chain cannot serve both).""" + db = MagicMock() + + clients_table = MagicMock() + clients_table.select.return_value.eq.return_value.execute.return_value = MagicMock(data=clients) + + sent_table = MagicMock() + # _already_sent: select('id').eq('client_id').eq('day_number').limit(1).execute() -> [] + sent_table.select.return_value.eq.return_value.eq.return_value.limit.return_value.execute.return_value = MagicMock(data=[]) + sent_table.insert.return_value.execute.return_value = MagicMock(data=[{"id": "row-1"}]) + + db.table.side_effect = lambda name: clients_table if name == "clients" else sent_table + return db, sent_table + + +@pytest.mark.asyncio +async def test_day_email_enqueues_send_email_task(): + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), # days_since == 1 + "trial_ends_at": (now + timedelta(days=13)).isoformat(), # not expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + # Enqueued a durable send_email_task for the day-1 email, with the right kind + # and positional args (kind, to, business_name, client_id). + pool.enqueue_job.assert_any_call( + "send_email_task", "trial_day1", "owner@biz1.com.au", "Biz One", "c1" + ) + # Idempotency row recorded after successful enqueue. + sent_table.insert.assert_called() + + +@pytest.mark.asyncio +async def test_expired_email_enqueues_send_email_task(): + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c2", + "email": "owner@biz2.com.au", + "business_name": "Biz Two", + "trial_started_at": (now - timedelta(days=20)).isoformat(), # not a milestone day + "trial_ends_at": (now - timedelta(days=6)).isoformat(), # expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + # Expired email enqueued (kind, to, business_name) — no client_id arg. + pool.enqueue_job.assert_any_call( + "send_email_task", "trial_expired", "owner@biz2.com.au", "Biz Two" + ) + sent_table.insert.assert_called() + + +@pytest.mark.asyncio +async def test_does_not_directly_call_resend_senders(): + """The Resend senders must no longer be called inline — sends go via arq.""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), + "trial_ends_at": (now + timedelta(days=13)).isoformat(), + "trial_status": "active", + } + db, _ = _trial_emails_db([client]) + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db), \ + patch("services.resend_email.send_email_strict", new_callable=AsyncMock) as mock_strict, \ + patch("services.resend_email.send_trial_day1_email", new_callable=AsyncMock) as mock_day1: + await run_trial_emails(pool) + + mock_strict.assert_not_awaited() + mock_day1.assert_not_awaited() + pool.enqueue_job.assert_awaited() + + +@pytest.mark.asyncio +async def test_idempotent_already_sent_not_reenqueued(): + """A milestone already recorded in trial_emails_sent is not re-enqueued.""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), + "trial_ends_at": (now + timedelta(days=13)).isoformat(), + "trial_status": "active", + } + db = MagicMock() + clients_table = MagicMock() + clients_table.select.return_value.eq.return_value.execute.return_value = MagicMock(data=[client]) + sent_table = MagicMock() + # _already_sent returns a row -> already sent + sent_table.select.return_value.eq.return_value.eq.return_value.limit.return_value.execute.return_value = MagicMock( + data=[{"id": "existing"}] + ) + db.table.side_effect = lambda name: clients_table if name == "clients" else sent_table + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + pool.enqueue_job.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_pool_skips_safely(): + """Without an arq pool the run skips rather than crashing.""" + from jobs.trial_emails import run_trial_emails + + db = MagicMock() + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(None) # must not raise + + +@pytest.mark.asyncio +async def test_enqueue_failure_day_rolls_back_idempotency_record(): + """An enqueue failure must roll back the tentatively-recorded idempotency + row so the next run can retry — never leaving a row that blocks future sends + (which would silently drop the email).""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), # days_since == 1 + "trial_ends_at": (now + timedelta(days=13)).isoformat(), # not expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock(side_effect=RuntimeError("redis down")) + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) # per-client handler swallows; run completes + + pool.enqueue_job.assert_awaited() + # The idempotency row was tentatively recorded... + sent_table.insert.assert_called() + # ...then rolled back (delete targeting this client + day) so it does not + # block the next run. + sent_table.delete.assert_called_once() + sent_table.delete.return_value.eq.assert_called_with("client_id", "c1") + sent_table.delete.return_value.eq.return_value.eq.assert_called_with("day_number", 1) + + +@pytest.mark.asyncio +async def test_enqueue_failure_expired_rolls_back_idempotency_record(): + """Same rollback guarantee for the expired-email loop (day_number -1).""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c2", + "email": "owner@biz2.com.au", + "business_name": "Biz Two", + "trial_started_at": (now - timedelta(days=20)).isoformat(), # not a milestone day + "trial_ends_at": (now - timedelta(days=6)).isoformat(), # expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock(side_effect=RuntimeError("redis down")) + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + pool.enqueue_job.assert_awaited() + sent_table.insert.assert_called() + sent_table.delete.assert_called_once() + sent_table.delete.return_value.eq.assert_called_with("client_id", "c2") + sent_table.delete.return_value.eq.return_value.eq.assert_called_with("day_number", -1) diff --git a/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/013_user_client_map.sql b/supabase/migrations/013_user_client_map.sql new file mode 100644 index 0000000..65b582a --- /dev/null +++ b/supabase/migrations/013_user_client_map.sql @@ -0,0 +1,23 @@ +-- 013_user_client_map.sql +-- User -> client ownership binding for tenant authorization (C8/D20). +-- +-- Maps a Supabase auth user (the JWT `sub` claim) to exactly one client/tenant +-- row so that client-scoped endpoints derive `client_id` from the authenticated +-- identity via this table — NEVER from a request body/query param. This is the +-- Phase 1 prerequisite (D20-A) that all later client-scoped endpoints reuse +-- (/billing/usage, /billing/portal, /locations, /practitioners, /rankings, …). +-- +-- One client per user (1:1) today; a later phase may relax to many-if-needed. +-- RLS + service_role_all policy matches 001_clients.sql so the service-role +-- backend can read/write (the backend uses the Supabase service role key). + +create table user_client_map ( + user_id text primary key, -- Supabase auth user id (JWT `sub`) + client_id uuid not null references clients(id), -- owned client/tenant + created_at timestamptz default now() +); + +alter table user_client_map enable row level security; + +create policy "service_role_all" on user_client_map + using (auth.role() = 'service_role'); 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'); 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