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_atrelative 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
- Go to https://developers.hubspot.com and sign up / log in.
- Create a developer account if you don't have one (free, no card required).
- Create app → give it any name (e.g. "Boots Dev Local").
- 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.readandcrm.objects.deals.read(these matchlib/integrations/providers.ts).
- 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
- Reload
/settings/integrations. HubSpot's Connect button is now enabled (no more orange warning copy). - Click Connect. You redirect to HubSpot's consent screen.
- Pick a test portal and approve.
- HubSpot redirects back to
http://localhost:3000/api/integrations/hubspot/callback?code=...&state=... - The callback exchanges the code for tokens, writes them to Supabase Vault, flips the row to
active, enqueues afull_syncjob intopgmq.ingestion_queue. - You land on
/settings/integrations?connected=hubspotwith 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_idNOT 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
stateagainst the pending row - Token exchange POSTs to HubSpot's token endpoint
- Vault write via
public.vault_store_secretRPC - pgmq enqueue via
public.ingestion_enqueueRPC
What this still does NOT test
- The worker consuming the pgmq message and writing
source_documents/client_intelligencerows. 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