mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
Multigres (high-availability) projects only accept TLS connections with direct SSL negotiation, and they don't support connection pooling at all — neither Supavisor nor the dedicated PgBouncer pooler exists for them. Studio previously showed pooler connection strings that would fail with "server closed the connection unexpectedly". This PR makes every connection-string surface direct-only for HA projects and appends `?sslmode=require&sslnegotiation=direct` to the examples. Non-HA projects are unchanged. Addresses [FE-4019](https://linear.app/supabase/issue/FE-4019/append-ssl-params-to-multigres-connection-string-examples-in-ui) **Changed:** - `buildConnectionStringPooler` gets an HA branch that collapses every slot in the bag to the direct connection string with the SSL params appended (mirroring the existing CLI branch, which also has no pooler) — dedicated slots come back `undefined` and `ipv4SupportedForDedicatedPooler` is forced off. Since HA never reaches the pooler layout anymore, the earlier per-URI SSL-append logic on pooler strings is removed - `useConnectState` coerces `connectionMethod` to `direct` and `useSharedPooler` to `false` for HA projects. The Connect sheet restores the last-used method from localStorage shared across projects, so a "Transaction pooler" selection made on a regular project could otherwise leak pooler-flavored notices, badges, and telemetry into an HA project - Prisma and Drizzle ORM tabs get an HA branch: `DATABASE_URL`/`DIRECT_URL` both use the direct connection, no `?pgbouncer=true` appended, with a comment explaining Multigres doesn't support pooling. The 5-arm nested ternaries in both files are flattened into `getEnvCode` helpers that switch on a shared `resolveOrmConnectionScenario` helper (`OrmConnection.utils.ts`), so the deployment-mode/HA branching lives in one tested place and each file keeps only its own formatting - The PgBouncer and Supavisor config queries are disabled (`enabled: !isHighAvailability`) in the Connect sheet — those endpoints serve pooler config that doesn't exist on Multigres - `parseConnectionParams` keeps the URI's query string in a new `search` field so formats rebuilt from parsed parts can carry it - psql switches from the `-h/-p/-d/-U` flag form to the quoted-URI form when query params are present (flags can't express them; psql still prompts for the password) - JDBC appends the params using pgJDBC's casing (`sslNegotiation`, supported since 42.7.4) - Prisma's `?pgbouncer=true` appends are query-aware (join with `&` when the URI already has a query string) via a new `appendConnectionStringParams` helper - The project home "Direct connection string" copy item also appends the params for HA projects **Added:** - Unit tests for the HA collapse behavior (all slots direct, dedicated config and IPv4 add-on ignored, no SSL params on non-HA output), the `useConnectState` coercion, the psql/JDBC builders (moved from `content.tsx` into `ConnectionString.utils.ts` so they're testable), and `resolveOrmConnectionScenario` (every deployment-mode/HA/pooler branch) **Known gaps (left out deliberately):** - The grid ExportDialog psql/pg_dump commands, the .NET `appsettings.json` (Npgsql only supports direct negotiation from v9 via `SSL Negotiation=Direct`), and the SQLAlchemy keyword-style `.env` are flag/keyword forms that can't carry the URI params — these would still fail against Multigres and need a follow-up - Settings > Database's Connection Pooling section and the pooler logs page have no HA gating yet — they'd still render pooler config UI for a Multigres project and should be hidden in a follow-up ## To test On a **Multigres (HA) project** (staging only supports `us-east-1` for Multigres): - Open the Connect sheet → Direct tab: there's no connection-method picker, and the connection string is the direct one ending with `?sslmode=require&sslnegotiation=direct` for the URI, PHP, and psql (quoted-URI form) types; JDBC includes `&sslmode=require&sslNegotiation=direct` - ORM tab → Prisma: both `DATABASE_URL` and `DIRECT_URL` are the direct connection string with the SSL params, no `pgbouncer=true`, with a "Multigres does not support connection pooling" comment. Drizzle likewise shows the direct string only - Framework tabs (e.g. Next.js): every `DATABASE_URL` carries the direct string with the params exactly once - Open the network tab: no requests to `/config/pgbouncer` or `/config/supavisor` while using the Connect sheet - To check the localStorage coercion: on a **regular** project pick "Transaction pooler" in the Connect sheet, then open the sheet on the Multigres project — no pooler badge/notices, string is still direct - Copy the URI, substitute your password, and `psql "<string>"` — it should connect - Project home → Copy dropdown → "Direct connection string" includes the params On a **regular (non-Multigres) project** — confirm nothing changed: - Connect sheet: direct/session/transaction strings for all connection types (URI, psql flag form, JDBC, PHP) look the same as before, no SSL params appended - Prisma/Drizzle tabs render identically (`?pgbouncer=true` still appended with `?`, dedicated-pooler alternatives still shown per IPv4 add-on state) - Project home copy dropdown is unchanged <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced connection-string generation for high-availability projects, including required SSL settings for direct connections. * Preserved URI query parameters in PostgreSQL, `psql`, JDBC, and generated environment configurations. * Improved ORM environment templates with clearer handling for pooler and high-availability connection scenarios. * **Bug Fixes** * High-availability projects now consistently use direct connections instead of pooler options. * Connection strings and generated templates update correctly when availability settings change. * **Tests** * Expanded coverage for query parameters, high-availability behavior, and connection scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
773 lines
28 KiB
TypeScript
773 lines
28 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' }),
|
|
}))
|
|
|
|
vi.mock('@/data/read-replicas/replicas-query', () => ({
|
|
useReadReplicasQuery: () => ({ data: [] }),
|
|
}))
|
|
|
|
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', () => {
|
|
afterEach(async () => {
|
|
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
|
vi.mocked(useIsHighAvailability).mockReturnValue(false)
|
|
})
|
|
|
|
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'])
|
|
})
|
|
})
|
|
})
|
|
})
|