Files
supabase/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx
Gildas GarciaandAlaister Young 737b8595f2 Update API types (#50234)
## Problem

platform, v1 and v2 have been already completely migrated and introduced
some changes.

Some types have been renamed, some outputs and inputs updated.

## Solution

- Update the API types
- Fix the TS errors

## Update

Taking this over to unblock #50134, which needs the new scoped token
permission ids from the regenerated types.

- Merged `master`.
- Regenerated `api-v2.d.ts` from the production spec. The previous files
came from a local API that exposed a webhook events endpoint production
doesn't have yet. Production has since added standardized 400 error
responses on the v2 organization endpoints. `api-v1.d.ts` and
`platform.d.ts` already matched production.
- Fixed `verify-production-types`. It formatted the regenerated files in
a temp directory outside the repository, so Prettier fell back to its
defaults and the comparison could never match the committed files. It
now passes the repository config explicitly. `pnpm api:verify-types`
passes on this branch.
- Verified locally: `pnpm typecheck`, `pnpm api:verify-types`, Studio
unit tests.

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

* **Bug Fixes**
* Preserved descriptions when saving, sharing, moving, or unsharing
notebooks, reports, SQL snippets, and saved queries.
* Improved handling of empty or null values across notebook
descriptions, billing usage, pooler settings, and infrastructure fields.
* Improved read-replica connection handling, including read-only
connection strings.
* Updated storage configuration and capability handling to match current
settings.

* **API and Compatibility**
* Updated organization, project, storage, OAuth, billing, and
infrastructure data handling to match current API responses.
  * OAuth app creation and updates now require scopes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-09-11 12:17:49 +08:00

265 lines
9.3 KiB
TypeScript

import { acceptUntrustedSql, untrustedSql, type SafeSqlFragment } from '@supabase/pg-meta'
import { act, waitFor } from '@testing-library/react'
import { HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSqlEditorExecution } from './useSqlEditorExecution'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { createDatabaseSelectorState } from '@/state/database-selector'
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
import { addAPIMock } from '@/tests/lib/msw'
import {
renderSqlEditorHook,
resetSqlEditorStores,
seedSnippet,
setupSqlEditorMocks,
} from '@/tests/lib/sql-editor-test-utils'
const SNIPPET_ID = 'execution-snippet'
/** Promote raw text to the `SafeSqlFragment` the run pipeline expects, exactly
* as the toolbar/editor-panel promote it right at the user action. */
const sql = (text: string): SafeSqlFragment => acceptUntrustedSql(untrustedSql(text))
type ExecutionArgs = Parameters<typeof useSqlEditorExecution>[0]
/**
* Wraps the hook with the two queries it depends on so tests can wait for the
* project + read-replicas data to load before firing a run (an unloaded project
* or connection string silently short-circuits `executeQuery`).
*/
function useExecutionHarness(args: ExecutionArgs) {
const { data: project } = useSelectedProjectQuery()
const { data: databases } = useReadReplicasQuery({ projectRef: 'default' })
const execution = useSqlEditorExecution(args)
return { execution, isReady: !!project && !!databases }
}
/** Registers a query-endpoint resolver that records every executed SQL body. */
function captureExecutedQueries(rows: unknown[] = []) {
const queries: string[] = []
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const body = (await request.json()) as { query: string }
queries.push(body.query)
return HttpResponse.json<any>(rows)
},
})
return queries
}
function renderExecution(
args: Partial<ExecutionArgs> = {},
{ selectedDatabaseId = 'default' }: { selectedDatabaseId?: string } = {}
) {
const databaseSelectorState = createDatabaseSelectorState()
databaseSelectorState.setSelectedDatabaseId(selectedDatabaseId)
const setAiTitle = vi.fn()
const initialProps: ExecutionArgs = {
id: SNIPPET_ID,
isDiffOpen: false,
hasSelection: false,
setAiTitle,
...args,
}
const utils = renderSqlEditorHook((props: ExecutionArgs) => useExecutionHarness(props), {
initialProps,
databaseSelectorState,
})
return { ...utils, setAiTitle }
}
beforeEach(() => {
resetSqlEditorStores()
setupSqlEditorMocks()
seedSnippet({ id: SNIPPET_ID, name: 'My query', sql: 'select 1;' })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useSqlEditorExecution', () => {
it('runs a non-destructive query and writes the result to the session store', async () => {
const rows = [{ id: 1, name: 'row-1' }]
const queries = captureExecutedQueries(rows)
const { result } = renderExecution()
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('select 1'))
})
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined())
expect(sqlEditorSessionState.results[SNIPPET_ID][0].rows).toEqual(rows)
expect(queries.some((q) => /select 1/i.test(q))).toBe(true)
})
it('appends the auto-limit to a bare SELECT and records it on the result', async () => {
const queries = captureExecutedQueries([])
const { result } = renderExecution()
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('select 1'))
})
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined())
// The session store's `limit` defaults to 100 (see resetSqlEditorStores).
expect(sqlEditorSessionState.results[SNIPPET_ID][0].autoLimit).toBe(100)
expect(queries.some((q) => /limit 100/i.test(q))).toBe(true)
})
it('gates a destructive query behind potentialIssues instead of running it', async () => {
const queries = captureExecutedQueries([])
const { result } = renderExecution()
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('drop table foo;'))
})
// The warning-modal gate fired: issues are surfaced and nothing was executed.
await waitFor(() => expect(result.current.execution.potentialIssues).toBeDefined())
expect(result.current.execution.potentialIssues?.hasDestructiveOperations).toBe(true)
expect(queries.some((q) => /drop table foo/i.test(q))).toBe(false)
expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeUndefined()
})
it('runs a destructive query when forced', async () => {
const queries = captureExecutedQueries([])
const { result } = renderExecution()
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('drop table foo;'), true)
})
await waitFor(() => expect(queries.some((q) => /drop table foo/i.test(q))).toBe(true))
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined())
})
it('highlights the error line and records the error on failure', async () => {
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const body = (await request.json()) as { query: string }
// The event-triggers probe shares this endpoint; only fail the real run.
if (/select 1/i.test(body.query)) {
return HttpResponse.json<any>(
{
message: 'syntax error',
position: '8',
formattedError:
'ERROR: syntax error at or near "slect"\nLINE 3: slect 1;\n ^',
},
{ status: 400 }
)
}
return HttpResponse.json<any>([])
},
})
const { result, inMemoryEditor } = renderExecution()
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('select 1'))
})
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0].error).toBeDefined())
// No selection → base line 0 + parsed `LINE 3` = 3.
expect(inMemoryEditor.getHighlightedLine()).toBe(3)
expect(inMemoryEditor.getRevealedLine()).toBe(3)
})
it("sends the selected database's connection string as the connection header", async () => {
const connectionString = 'postgresql://postgres@replica.example:5432/postgres'
const connectionHeaders: (string | null)[] = []
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const body = (await request.json()) as { query: string }
if (/select 1/i.test(body.query)) {
connectionHeaders.push(request.headers.get('x-connection-encrypted'))
}
return HttpResponse.json<any>([])
},
})
// Point the read-replicas list + selector at a replica with its own conn string.
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/databases',
response: [
{
identifier: 'replica-1',
connectionString,
connection_string_read_only: connectionString,
cloud_provider: 'AWS',
db_host: 'db.replica.supabase.co',
db_name: 'postgres',
db_port: 5432,
db_user: 'postgres',
inserted_at: '2024-01-01T00:00:00Z',
region: 'us-east-1',
restUrl: 'https://replica.supabase.co/rest/v1/',
size: 'ci_micro',
status: 'ACTIVE_HEALTHY',
},
],
})
const { result } = renderExecution({}, { selectedDatabaseId: 'replica-1' })
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('select 1'))
})
await waitFor(() => expect(connectionHeaders.length).toBeGreaterThan(0))
expect(connectionHeaders[0]).toBe(connectionString)
})
it('short-circuits while a diff is open', async () => {
const queries = captureExecutedQueries([])
const { result } = renderExecution({ isDiffOpen: true })
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('select 1'))
})
await new Promise((r) => setTimeout(r, 20))
expect(queries.some((q) => /select 1/i.test(q))).toBe(false)
expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeUndefined()
})
it('does not auto-generate a title for an already-named snippet', async () => {
captureExecutedQueries([])
const { result, setAiTitle } = renderExecution()
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.execution.executeQuery(sql('select 1'))
})
await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined())
expect(setAiTitle).not.toHaveBeenCalled()
})
})