Files
Alaister YoungandAlaister Young 29493e02d0 [FE-4010] feat(studio): add read-only replica connection option for HA projects (#49485)
For Multigres (HA) projects you can't connect to read replicas directly
— reads go through a read-only load balancer on the primary's host at
port 5433. Since #44695 stripped the pooler UI, HA projects showed no
source option at all in the Connect dialog and still prompted for the
IPv4 add-on. This surfaces it as a first-class, clearly-labeled
read-only source. In the UI it's labeled `Replica (read-only)` rather
than "load balancer" — the primary goes through the same gateway, so
"load balancer" would be confusing from a product perspective
(internally the `load-balancer` source identifier and
`HIGH_AVAILABILITY_LOAD_BALANCER_PORT` constant keep their names).

<img width="883" height="342" alt="Screenshot 2026-08-24 at 11 32 26 PM"
src="https://github.com/user-attachments/assets/3716f6dd-0325-4b9d-adbc-9ece9244de62"
/>

**Added:**
- Source select for HA projects in the Direct tab: `Primary database` +
`Replica (read-only)` (individual replica rows are filtered out —
they're only reachable via the load balancer)
- Replica (load balancer) connection strings on all 9 connection types:
primary host, port `5433`, with the Multigres-required
`sslmode=require&sslnegotiation=direct` params (JDBC gets the
`sslNegotiation` spelling, .NET gets `SSL Negotiation=Direct`)
- `Read-only` badge on the connection code block + note pointing writes
at the primary
- Programmatic labels for the ConnectSheet select/switch/multi-select
fields (the Source combobox previously had no accessible name)

**Changed:**
- The generated-file step (Node.js/Golang/.NET/Python/SQLAlchemy) is now
source-aware — it previously ignored the Source selection entirely (also
affected read replicas on normal projects) and silently rendered the
primary's connection info
- .NET template now emits `Port=` (Npgsql defaults to 5432 when omitted)
and the install step actually installs Npgsql (pinned 9.0.5 — `SSL
Negotiation` requires 9+)
- SQLAlchemy `DATABASE_URL` merges `sslmode=require` into the string's
existing query params instead of a hardcoded suffix that could drop TLS
- Source option labels normalized to sentence case (`Primary database`,
`Read replica (…)`)
- `MultipleCodeBlock` (ui-patterns) accepts an optional `className`
- HA coercion in `useConnectState` extended: a stale replica
`connectionSource` restored from URL/localStorage falls back to the
primary

**Removed:**
- IPv4 add-on admonition for HA projects (the forced-direct method was
tripping it; the add-on doesn't apply to Multigres)

Out of scope (needs platform work): SQL editor / Data API / other
`DatabaseSelector` surfaces — executing against the load balancer
requires a platform-issued connection string, and the load-balancers API
only returns a REST endpoint today. The `5433` port is a client-side
constant (`HIGH_AVAILABILITY_LOAD_BALANCER_PORT`) until the API exposes
it.

## To test

On an HA (Multigres) project:
- Open Connect → Direct: Source shows exactly `Primary database` and
`Replica (read-only)`; selecting the replica shows
`…@<primary-host>:5433/postgres?sslmode=require&sslnegotiation=direct`,
a `Read-only` badge, and the read-only note
- Cycle all 9 connection types with the replica selected — every snippet
carries port 5433 (`.NET` includes `Port=5433;…;SSL
Negotiation=Direct`), badge/note persist
- No "Enable IPv4 add-on" admonition anywhere in the Direct tab
- Switch tabs / hard-reload: source resets to primary with no stale
badge/string combos

On a normal project:
- Direct tab unchanged: no `Replica (read-only)` option, pooler badges
and IPv4 admonitions behave as before, `.NET` now shows `Port=5432` and
no `SSL Negotiation`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added read-only load-balancer connection options for high-availability
projects.
- Added .NET and SQLAlchemy connection examples with required SSL
settings.
- Added clear read-only labels and notices explaining write
restrictions.
- **Bug Fixes**
  - Suppressed IPv4 add-on notices for high-availability connections.
  - Improved connection-source selection and restored-setting handling.
  - Improved connection form identification and accessibility.
- **Style**
  - Added customizable styling support for multi-code-block displays.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-08-28 10:43:55 +01:00

834 lines
30 KiB
TypeScript

import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { useConnectState } from '../useConnectState'
vi.mock('common', () => ({
useParams: () => ({ ref: 'test-ref' }),
}))
const readReplicasMock: {
data: Array<{ identifier: string; region: string; inserted_at: string }>
} = { data: [] }
vi.mock('@/data/read-replicas/replicas-query', () => ({
useReadReplicasQuery: () => readReplicasMock,
}))
vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
useCheckEntitlements: vi.fn().mockImplementation(() => ({ hasAccess: true })),
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useIsHighAvailability: vi.fn().mockImplementation(() => false),
}))
const deploymentModeMock = { isPlatform: true, isCli: false, isSelfHosted: false }
vi.mock('@/hooks/misc/useDeploymentMode', () => ({
useDeploymentMode: () => deploymentModeMock,
}))
describe('useConnectState', () => {
// ============================================================================
// Initial State Tests
// ============================================================================
describe('initial state', () => {
test('should initialize with framework mode by default', () => {
const { result } = renderHook(() => useConnectState())
expect(result.current.state.mode).toBe('framework')
})
test('should initialize with nextjs as default framework', () => {
const { result } = renderHook(() => useConnectState())
expect(result.current.state.framework).toBe('nextjs')
})
test('should initialize with app variant for nextjs', () => {
const { result } = renderHook(() => useConnectState())
expect(result.current.state.frameworkVariant).toBe('app')
})
test('should initialize with supabasejs library', () => {
const { result } = renderHook(() => useConnectState())
expect(result.current.state.library).toBe('supabasejs')
})
test('should accept initial state override', () => {
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
)
expect(result.current.state.mode).toBe('direct')
expect(result.current.state.connectionMethod).toBe('transaction')
})
test('should merge initial state with defaults', () => {
const { result } = renderHook(() => useConnectState({ framework: 'react' }))
expect(result.current.state.mode).toBe('framework')
expect(result.current.state.framework).toBe('react')
})
})
// ============================================================================
// Mode Switching Tests
// ============================================================================
describe('setMode', () => {
test('should switch to direct mode', () => {
const { result } = renderHook(() => useConnectState())
act(() => {
result.current.setMode('direct')
})
expect(result.current.state.mode).toBe('direct')
})
test('should initialize direct mode defaults when switching', () => {
const { result } = renderHook(() => useConnectState())
act(() => {
result.current.setMode('direct')
})
expect(result.current.state.connectionMethod).toBeDefined()
expect(result.current.state.connectionType).toBeDefined()
})
test('should switch to orm mode and initialize defaults', () => {
const { result } = renderHook(() => useConnectState())
act(() => {
result.current.setMode('orm')
})
expect(result.current.state.mode).toBe('orm')
expect(result.current.state.orm).toBe('prisma')
})
test('should switch to mcp mode and initialize defaults', () => {
const { result } = renderHook(() => useConnectState())
act(() => {
result.current.setMode('mcp')
})
expect(result.current.state.mode).toBe('mcp')
expect(result.current.state.mcpClient).toBeDefined()
})
test('should preserve framework state when switching back to framework mode', () => {
const { result } = renderHook(() => useConnectState())
// Change framework
act(() => {
result.current.updateField('framework', 'react')
})
// Switch to direct
act(() => {
result.current.setMode('direct')
})
// Switch back to framework
act(() => {
result.current.setMode('framework')
})
expect(result.current.state.framework).toBe('react')
})
})
// ============================================================================
// Field Update Tests
// ============================================================================
describe('updateField', () => {
test('should update framework selection', () => {
const { result } = renderHook(() => useConnectState())
act(() => {
result.current.updateField('framework', 'react')
})
expect(result.current.state.framework).toBe('react')
})
test('should cascade variant reset when changing framework', () => {
const { result } = renderHook(() => useConnectState())
// Start with nextjs which has variants
expect(result.current.state.frameworkVariant).toBe('app')
// Switch to a framework with multiple variants
act(() => {
result.current.updateField('framework', 'react')
})
// Should have the first variant of react
expect(result.current.state.frameworkVariant).toBeDefined()
})
test('should remove variant when switching to framework without variants', () => {
const { result } = renderHook(() => useConnectState())
// Start with nextjs which has variants
expect(result.current.state.frameworkVariant).toBe('app')
// Switch to remix which has no variants
act(() => {
result.current.updateField('framework', 'remix')
})
expect(result.current.state.frameworkVariant).toBeUndefined()
})
test('should update library when variant changes', () => {
const { result } = renderHook(() => useConnectState())
act(() => {
result.current.updateField('frameworkVariant', 'pages')
})
expect(result.current.state.library).toBe('supabasejs')
})
test('should update connection method', () => {
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
act(() => {
result.current.updateField('connectionMethod', 'transaction')
})
expect(result.current.state.connectionMethod).toBe('transaction')
})
test('should clear useSharedPooler when connectionMethod changes to direct', () => {
const { result } = renderHook(() =>
useConnectState({
mode: 'direct',
connectionMethod: 'transaction',
useSharedPooler: true,
})
)
act(() => {
result.current.updateField('connectionMethod', 'direct')
})
// useSharedPooler is cleared because it depends on connectionMethod: ['transaction']
// When the dependency is not satisfied, the field is removed from state
expect(result.current.state.useSharedPooler).toBeUndefined()
})
test('should update MCP client', () => {
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
act(() => {
result.current.updateField('mcpClient', 'codex')
})
expect(result.current.state.mcpClient).toBe('codex')
})
test('should update boolean fields', () => {
const { result } = renderHook(() => useConnectState())
act(() => {
result.current.updateField('frameworkUi', true)
})
expect(result.current.state.frameworkUi).toBe(true)
})
test('should update ORM selection', () => {
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
act(() => {
result.current.updateField('orm', 'drizzle')
})
expect(result.current.state.orm).toBe('drizzle')
})
})
// ============================================================================
// Active Fields Tests
// ============================================================================
describe('activeFields', () => {
test('should return framework mode fields', () => {
const { result } = renderHook(() => useConnectState())
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('framework')
})
test('should include variant field for nextjs', () => {
const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('frameworkVariant')
})
test('should include frameworkUi field for nextjs', () => {
const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('frameworkUi')
})
test('should not include frameworkUi for non-nextjs/react frameworks', () => {
const { result } = renderHook(() => useConnectState({ framework: 'remix' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).not.toContain('frameworkUi')
})
test('should return direct mode fields', () => {
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('connectionMethod')
expect(fieldIds).toContain('connectionType')
})
test('should show useSharedPooler only for transaction connection method when user has dedicated_pooler entitlement', async () => {
const { useCheckEntitlements } = await import('@/hooks/misc/useCheckEntitlements')
vi.mocked(useCheckEntitlements).mockReturnValue({ hasAccess: true } as any)
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
)
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('useSharedPooler')
})
test('should hide useSharedPooler even if using transaction method when user lacks dedicated_pooler entitlement', async () => {
const { useCheckEntitlements } = await import('@/hooks/misc/useCheckEntitlements')
vi.mocked(useCheckEntitlements).mockReturnValue({ hasAccess: false } as any)
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
)
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).not.toContain('useSharedPooler')
})
test('should hide useSharedPooler for direct connection method', () => {
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionMethod: 'direct' })
)
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).not.toContain('useSharedPooler')
})
test('should return orm mode fields', () => {
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('orm')
})
test('should return mcp mode fields', () => {
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('mcpClient')
expect(fieldIds).toContain('mcpReadonly')
})
})
// ============================================================================
// Resolved Steps Tests
// ============================================================================
describe('resolvedSteps', () => {
test('should resolve steps for framework mode', () => {
const { result } = renderHook(() => useConnectState())
expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
})
test('should have install step for framework mode', () => {
const { result } = renderHook(() => useConnectState())
const stepIds = result.current.resolvedSteps.map((s) => s.id)
expect(stepIds).toContain('install')
})
test('should resolve different steps for mcp mode', () => {
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
const stepIds = result.current.resolvedSteps.map((s) => s.id)
// MCP mode (defaults to claude-code) should have claude-add-server step
expect(stepIds.some((id) => id.includes('claude') || id.includes('mcp'))).toBe(true)
})
test('should resolve different steps for different mcp clients', () => {
const { result: cursorResult } = renderHook(() =>
useConnectState({ mode: 'mcp', mcpClient: 'cursor' })
)
const { result: codexResult } = renderHook(() =>
useConnectState({ mode: 'mcp', mcpClient: 'codex' })
)
// Codex has more steps than cursor
expect(codexResult.current.resolvedSteps.length).toBeGreaterThanOrEqual(
cursorResult.current.resolvedSteps.length
)
})
test('should include skills install step', () => {
const { result } = renderHook(() => useConnectState())
const stepIds = result.current.resolvedSteps.map((s) => s.id)
expect(stepIds).toContain('install-skills')
})
test('should resolve steps for direct mode', () => {
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
})
test('should resolve steps for orm mode', () => {
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
const stepIds = result.current.resolvedSteps.map((s) => s.id)
expect(stepIds).toContain('install')
expect(stepIds).toContain('configure')
})
test('should resolve shadcn steps when frameworkUi is true', () => {
const { result } = renderHook(() =>
useConnectState({ framework: 'nextjs', frameworkUi: true })
)
const stepIds = result.current.resolvedSteps.map((s) => s.id)
expect(stepIds).toContain('shadcn-add')
expect(stepIds).toContain('shadcn-env')
})
})
// ============================================================================
// Field Options Tests
// ============================================================================
describe('getFieldOptions', () => {
test('should return framework options', () => {
const { result } = renderHook(() => useConnectState())
const options = result.current.getFieldOptions('framework')
expect(options.length).toBeGreaterThan(0)
expect(options.some((o) => o.value === 'nextjs')).toBe(true)
expect(options.some((o) => o.value === 'react')).toBe(true)
})
test('should return variant options for nextjs', () => {
const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
const options = result.current.getFieldOptions('frameworkVariant')
expect(options.length).toBeGreaterThan(0)
expect(options.some((o) => o.value === 'app')).toBe(true)
expect(options.some((o) => o.value === 'pages')).toBe(true)
})
test('should return empty variant options for frameworks without variants', () => {
const { result } = renderHook(() => useConnectState({ framework: 'remix' }))
const options = result.current.getFieldOptions('frameworkVariant')
expect(options).toEqual([])
})
test('should return connection method options', () => {
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const options = result.current.getFieldOptions('connectionMethod')
expect(options.length).toBeGreaterThan(0)
expect(options.some((o) => o.value === 'direct')).toBe(true)
expect(options.some((o) => o.value === 'transaction')).toBe(true)
})
test('should return connection type options', () => {
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const options = result.current.getFieldOptions('connectionType')
expect(options.length).toBeGreaterThan(0)
expect(options.some((o) => o.value === 'uri')).toBe(true)
expect(options.some((o) => o.value === 'psql')).toBe(true)
})
test('should return ORM options', () => {
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
const options = result.current.getFieldOptions('orm')
expect(options.length).toBeGreaterThan(0)
expect(options.some((o) => o.value === 'prisma')).toBe(true)
expect(options.some((o) => o.value === 'drizzle')).toBe(true)
})
test('should return MCP client options', () => {
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
const options = result.current.getFieldOptions('mcpClient')
expect(options.length).toBeGreaterThan(0)
expect(options.some((o) => o.value === 'cursor')).toBe(true)
})
test('should return empty array for unknown field', () => {
const { result } = renderHook(() => useConnectState())
const options = result.current.getFieldOptions('unknownField')
expect(options).toEqual([])
})
test('should return library options for selected framework', () => {
const { result } = renderHook(() =>
useConnectState({ framework: 'nextjs', frameworkVariant: 'app' })
)
const options = result.current.getFieldOptions('library')
expect(options.length).toBeGreaterThan(0)
})
})
// ============================================================================
// High Availability Tests
// ============================================================================
describe('high availability projects', () => {
const PRIMARY_DATABASE = {
identifier: 'test-ref',
region: 'ap-southeast-1',
inserted_at: '2024-01-01T00:00:00Z',
}
const REPLICA_DATABASE = {
identifier: 'test-ref-rr-abcdefgh',
region: 'ap-southeast-1',
inserted_at: '2024-01-02T00:00:00Z',
}
afterEach(async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(false)
readReplicasMock.data = []
})
test('should offer only the primary and the load balancer as sources for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
readReplicasMock.data = [PRIMARY_DATABASE, REPLICA_DATABASE]
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const options = result.current.getFieldOptions('connectionSource')
expect(options).toEqual([
{ value: 'test-ref', label: 'Primary database' },
{ value: 'load-balancer', label: 'Replica (read-only)' },
])
})
test('should keep replicas as sources and omit the load balancer for non-HA projects', () => {
readReplicasMock.data = [PRIMARY_DATABASE, REPLICA_DATABASE]
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const options = result.current.getFieldOptions('connectionSource')
expect(options.map((o) => o.value)).toEqual(['test-ref', 'test-ref-rr-abcdefgh'])
expect(options.some((o) => o.value === 'load-balancer')).toBe(false)
})
test('should coerce a replica connection source to the primary for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionSource: 'test-ref-rr-abcdefgh' })
)
expect(result.current.state.connectionSource).toBe('test-ref')
})
test('should preserve the load balancer as a valid connection source for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionSource: 'load-balancer' })
)
expect(result.current.state.connectionSource).toBe('load-balancer')
})
test('should hide connectionMethod field for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).not.toContain('connectionMethod')
})
test('should hide useSharedPooler field for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
)
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).not.toContain('useSharedPooler')
})
test('should rename connectionType label to "Connection Type" for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const connectionTypeField = result.current.activeFields.find((f) => f.id === 'connectionType')
expect(connectionTypeField?.label).toBe('Connection Type')
})
test('should coerce pooler-flavored initial state to the direct method for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
// Simulates pooler selections restored from the URL or localStorage
const { result } = renderHook(() =>
useConnectState({ mode: 'direct', connectionMethod: 'transaction', useSharedPooler: true })
)
expect(result.current.state.connectionMethod).toBe('direct')
expect(result.current.state.useSharedPooler).toBe(false)
})
test('should coerce connectionMethod updates to the direct method for HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(true)
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
act(() => {
result.current.updateField('connectionMethod', 'session')
result.current.updateField('useSharedPooler', true)
})
expect(result.current.state.connectionMethod).toBe('direct')
expect(result.current.state.useSharedPooler).toBe(false)
})
test('should not affect non-HA projects', async () => {
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
vi.mocked(useIsHighAvailability).mockReturnValue(false)
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).toContain('connectionMethod')
expect(fieldIds).toContain('connectionType')
const connectionTypeField = result.current.activeFields.find((f) => f.id === 'connectionType')
expect(connectionTypeField?.label).toBe('Type')
})
})
// ============================================================================
// Schema Access Tests
// ============================================================================
describe('schema', () => {
test('should expose the connect schema', () => {
const { result } = renderHook(() => useConnectState())
expect(result.current.schema).toBeDefined()
expect(result.current.schema.modes).toBeDefined()
expect(result.current.schema.fields).toBeDefined()
expect(result.current.schema.steps).toBeDefined()
})
test('should have all expected modes in schema', () => {
const { result } = renderHook(() => useConnectState())
const modeIds = result.current.schema.modes.map((m) => m.id)
expect(modeIds).toContain('framework')
expect(modeIds).toContain('direct')
expect(modeIds).toContain('orm')
expect(modeIds).toContain('mcp')
})
})
// ============================================================================
// Deployment-Mode-Aware Tests
// ============================================================================
describe('deployment mode', () => {
beforeEach(() => {
Object.assign(deploymentModeMock, {
isPlatform: true,
isCli: false,
isSelfHosted: false,
})
})
afterEach(() => {
Object.assign(deploymentModeMock, {
isPlatform: true,
isCli: false,
isSelfHosted: false,
})
})
describe('connectionMethod options filtering', () => {
test('platform exposes direct, transaction and session', () => {
Object.assign(deploymentModeMock, {
isPlatform: true,
isCli: false,
isSelfHosted: false,
})
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const values = result.current.getFieldOptions('connectionMethod').map((o) => o.value)
expect(values).toEqual(['direct', 'transaction', 'session'])
})
test('CLI exposes only direct', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: true,
isSelfHosted: false,
})
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const values = result.current.getFieldOptions('connectionMethod').map((o) => o.value)
expect(values).toEqual(['direct'])
})
test('self-hosted exposes session, transaction, direct (session first)', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: false,
isSelfHosted: true,
})
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const values = result.current.getFieldOptions('connectionMethod').map((o) => o.value)
expect(values).toEqual(['session', 'transaction', 'direct'])
})
test('self-hosted overrides direct method description', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: false,
isSelfHosted: true,
})
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const directOption = result.current
.getFieldOptions('connectionMethod')
.find((o) => o.value === 'direct')
expect(directOption?.description).toBe('Manually configurable for self-hosted Supabase.')
})
test('self-hosted overrides session method description', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: false,
isSelfHosted: true,
})
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
const sessionOption = result.current
.getFieldOptions('connectionMethod')
.find((o) => o.value === 'session')
expect(sessionOption?.description).toBe(
'Supavisor (default pooler for self-hosted Supabase).'
)
})
})
describe('setMode default for connectionMethod', () => {
test('platform defaults to direct', () => {
Object.assign(deploymentModeMock, {
isPlatform: true,
isCli: false,
isSelfHosted: false,
})
const { result } = renderHook(() => useConnectState({ mode: 'framework' }))
act(() => {
result.current.setMode('direct')
})
expect(result.current.state.connectionMethod).toBe('direct')
})
test('CLI defaults to direct', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: true,
isSelfHosted: false,
})
const { result } = renderHook(() => useConnectState({ mode: 'framework' }))
act(() => {
result.current.setMode('direct')
})
expect(result.current.state.connectionMethod).toBe('direct')
})
test('self-hosted defaults to session', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: false,
isSelfHosted: true,
})
const { result } = renderHook(() => useConnectState({ mode: 'framework' }))
act(() => {
result.current.setMode('direct')
})
expect(result.current.state.connectionMethod).toBe('session')
})
})
describe('mcp features for deployment mode', () => {
test('self-hosted defaults and options stay within the non-platform subset', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: false,
isSelfHosted: true,
})
const { result } = renderHook(() => useConnectState({ mode: 'framework' }))
act(() => {
result.current.setMode('mcp')
})
const features = result.current.state.mcpFeatures
expect(Array.isArray(features)).toBe(true)
expect(features).toEqual(
expect.arrayContaining(['docs', 'database', 'development', 'debugging'])
)
expect(features).not.toContain('storage')
expect(features).not.toContain('account')
expect(features).not.toContain('auth')
const options = result.current.getFieldOptions('mcpFeatures').map((o) => o.value)
expect(options).toEqual(['docs', 'database', 'debugging', 'development'])
})
test('self-hosted strips unsupported features when re-entering mcp mode', () => {
Object.assign(deploymentModeMock, {
isPlatform: false,
isCli: false,
isSelfHosted: true,
})
const { result } = renderHook(() =>
useConnectState({ mode: 'mcp', mcpFeatures: ['docs', 'account', 'database'] })
)
act(() => {
result.current.setMode('mcp')
})
expect(result.current.state.mcpFeatures).toEqual(['docs', 'database'])
})
})
})
})