Integrations — Local Dev Testing Guide

This guide walks you through testing the integrations UI (/settings/integrations) in local dev so your testing is real, not wishful. There are three layers to test — each needs different setup.

What ships in Step 8: list of the 4 providers (HubSpot, Google Drive, QuickBooks, Gmail), Connect/Disconnect buttons, status badges, OAuth round-trip wiring. The disabled "Connect" state you see by default is correct behavior when OAuth credentials are not configured — read on.


1. Why the Connect button is disabled

GET /api/integrations runs on the server and checks whether the provider's OAuth env vars (<PROVIDER>_CLIENT_ID + <PROVIDER>_CLIENT_SECRET) are set. If either is missing, it returns oauthConfigured: false, which disables the Connect button in the UI.

This is intentional. Without credentials, the connect route (/api/integrations/hubspot/connect) would return a 500 error — a confused user clicking the button gets nothing useful back. The disabled state is honest: "we literally cannot start the flow right now."

The copy OAuth credentials not yet configured for HubSpot. tells the user exactly what's missing.


2. Testing the UI + Disconnect flow without real OAuth (2 minutes)

If you just want to see the Connect-then-Disconnect flow render correctly — no actual OAuth — insert a fake connected_sources row directly.

Step A — Find your org and profile IDs

In Supabase Dashboard → SQL Editor, run (logged in as your test user):

SELECT p.id AS profile_id, p.active_org_id AS org_id
FROM public.profiles p
JOIN auth.users u ON u.id = p.id
WHERE u.email = 'you@example.com';

Step B — Insert a fake active HubSpot connection

INSERT INTO public.connected_sources (
  org_id,
  connected_by,
  provider,
  display_name,
  status,
  last_synced_at
) VALUES (
  '<your_org_id>',
  '<your_profile_id>',
  'hubspot',
  'HubSpot',
  'active',
  NOW()
);

Step C — Reload the page

/settings/integrations now shows HubSpot with:

  • Green "Active" badge
  • "Last synced Just now · 0 items"
  • Disconnect button (enabled)

Click Disconnect → browser confirm → row flips to status='revoked' → UI returns to "Not connected" state. Refresh the page to confirm the state persisted.

Step D — Simulate error state (optional)

UPDATE public.connected_sources
SET status = 'error',
    status_message = 'refresh_token_invalid:invalid_grant'
WHERE org_id = '<your_org_id>' AND provider = 'hubspot' AND status != 'revoked';

Reload → card shows red "Error" badge with the sanitized status message. Disconnect still works on error-state rows.

What this tests

  • List API returns correct structure per provider
  • Status badges render (active, error)
  • last_synced_at relative time formatter
  • Disconnect happy path
  • UNIQUE constraint handling (reconnect-cycle) — insert active, disconnect, insert active again, disconnect again: should not 23505
  • RLS scoping (only the current org's rows visible)

What this does NOT test

  • The OAuth redirect to the provider
  • The callback route exchanging a code for tokens
  • Vault writes
  • Worker pulling real data

3. Testing the full OAuth round-trip with HubSpot (10 minutes)

HubSpot is the fastest provider to wire up: free dev sandbox, instant app creation, no review required. Use it to validate the full Connect → provider consent → callback → active path locally.

Step A — Create the HubSpot developer app

  1. Go to https://developers.hubspot.com and sign up / log in.
  2. Create a developer account if you don't have one (free, no card required).
  3. Create app → give it any name (e.g. "Boots Dev Local").
  4. In the app's Auth tab:
    • Install URL (OAuth) — copy your Client ID and Client Secret.
    • Redirect URLs — add exactly: http://localhost:3000/api/integrations/hubspot/callback
    • Scopes — add crm.objects.contacts.read and crm.objects.deals.read (these match lib/integrations/providers.ts).
  5. Save.

Step B — Add creds to .env.local

# .env.local
NEXT_PUBLIC_APP_URL=http://localhost:3000
HUBSPOT_CLIENT_ID=<your_client_id>
HUBSPOT_CLIENT_SECRET=<your_client_secret>

.env.local is gitignored. Never commit these values.

Step C — Restart the dev server

Env vars are read at boot. npm run dev again.

Step D — Run the flow

  1. Reload /settings/integrations. HubSpot's Connect button is now enabled (no more orange warning copy).
  2. Click Connect. You redirect to HubSpot's consent screen.
  3. Pick a test portal and approve.
  4. HubSpot redirects back to http://localhost:3000/api/integrations/hubspot/callback?code=...&state=...
  5. The callback exchanges the code for tokens, writes them to Supabase Vault, flips the row to active, enqueues a full_sync job into pgmq.ingestion_queue.
  6. You land on /settings/integrations?connected=hubspot with a green success toast.

Step E — Verify the state

SELECT id, provider, status, status_message, last_synced_at,
       access_token_vault_id IS NOT NULL AS has_access_token
FROM public.connected_sources
WHERE org_id = '<your_org_id>' AND provider = 'hubspot';

-- And the queued sync job:
SELECT * FROM pgmq.q_ingestion_queue;

You should see:

  • status = 'active'
  • access_token_vault_id NOT NULL
  • A message in the pgmq queue with {source_id, job_id, provider, org_id}

Step F — Disconnect

Click Disconnect on the card. Row flips to revoked, vault IDs nulled.

What this tests

Everything in §2, plus:

  • OAuth redirect includes the correct client_id, scope, redirect_uri, state
  • Callback validates state against the pending row
  • Token exchange POSTs to HubSpot's token endpoint
  • Vault write via public.vault_store_secret RPC
  • pgmq enqueue via public.ingestion_enqueue RPC

What this still does NOT test

  • The worker consuming the pgmq message and writing source_documents / client_intelligence rows. That runs on Fly.io, not locally.

4. Testing the worker pulling data (optional, needs Docker)

The worker polls pgmq and writes to source_documents. To test it locally:

cd worker

# .env for worker (create, gitignored)
SUPABASE_URL=<your_supabase_project_url>
SUPABASE_SERVICE_ROLE_KEY=<service_role_key_from_supabase_dashboard>
ANTHROPIC_API_KEY=<your_key>
BOOTS_BRAIN_URL=<brain_fly_url_or_localhost_if_running_brain_locally>
BRAIN_API_SECRET=<shared_secret>
INTEGRATIONS_USE_FIXTURES=true  # reads from worker/__fixtures__/ instead of real providers

npm install
npm run dev

With INTEGRATIONS_USE_FIXTURES=true, the worker skips real MCP calls and ingests canned JSON from worker/__fixtures__/hubspot/*.json. Connect HubSpot via §3, watch the worker log pick up the pgmq message, then query:

SELECT document_type, COUNT(*)
FROM public.source_documents
WHERE org_id = '<your_org_id>'
GROUP BY document_type;

You should see hubspot.contact, hubspot.company, hubspot.deal rows. client_intelligence should also populate (HubSpot is a rollup provider).


5. Quick reference — required env vars per provider

Set these in Vercel (production) and/or .env.local (dev) to enable each provider's Connect button.

Provider Env Vars Dev Console
HubSpot HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET developers.hubspot.com
Google Drive GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET console.cloud.google.com → APIs & Services → Credentials
QuickBooks QUICKBOOKS_CLIENT_ID, QUICKBOOKS_CLIENT_SECRET developer.intuit.com
Gmail GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET Same Google console as Drive — separate OAuth client

Redirect URI for all providers: http://localhost:3000/api/integrations/<provider>/callback in dev, https://<your-domain>/api/integrations/<provider>/callback in prod.

Google quirk: Drive and Gmail are the same vendor but use separate OAuth clients because the scopes differ. You can share the client if you approve both scopes on one app, but separate is cleaner.


6. Troubleshooting

Symptom Likely cause Fix
Connect button stays disabled after setting creds Dev server was not restarted Ctrl+C then npm run dev again
OAuth popup returns ?error=invalid_state NEXT_PUBLIC_APP_URL mismatch between the one the connect route used and the one the callback uses Make sure both POST /connect and the browser use the same origin
Callback redirects with ?error=exchange_failed Client secret typo, or the redirect URI on the provider side doesn't match exactly Recheck provider console; the redirect URI is character-exact
?error=cross_org You switched orgs between starting the flow and completing it Stay in one org for the full flow
Row inserted fine but callback says invalid_state after 10+ min State token expired (10-min TTL) Start the flow again
Connect works but disconnect says Database error Check the Postgres logs — if it's 23505 connected_sources_unique_active, the prior-revoked cleanup DELETE didn't run. File a bug against the disconnect route.

Ready to create AI-powered proposals?

Start Free