test(sql-editor): add mock-free hook tests (Step 4) (#48214)

## What

Step 4 of the SQL editor testability plan: **mock-free hook tests** for
the extracted SQL editor hooks, built on the Step 3 renderHook harness
(`tests/lib/sql-editor-test-utils.tsx`) — in-memory editor port + real
valtio stores + MSW. **Zero `vi.mock`.**

| File | Tests | Covers |
|------|-------|--------|
| `useSqlEditorExecution.test.tsx` | 8 | destructive-query gating
(`potentialIssues` vs. forced run), auto-limit suffixing,
connection-string → `x-connection-encrypted` header,
`onSuccess`/`onError` session-store writes, error-line highlight,
diff-open short-circuit |
| `useSqlEditorAi.test.tsx` | 7 | one-shot diff-request drain (empty vs.
non-empty editor), drain-exactly-once across remounts, accept/discard
diff, `onDebug` opening the assistant chat + debug prompt |
| `usePrettifyQuery.test.tsx` | 2 | in-place format + write-back,
diff-open no-op |
| `useSnippetIdentity.test.tsx` | 2 | generated identity + store-driven
loading state |
| `useSnippetTitleGenerator.test.tsx` | 2 | untitled-snippet naming via
the title endpoint |

Every test exercises real dependencies at the seam where they're real:
network via MSW, stores used real and reset per test, Monaco via the
in-memory editor port.

## Test plan

- [x] `pnpm test:studio -- SQLEditor` → **286/286 passing** (21 new
tests included)
- [x] `pnpm --filter studio typecheck` clean
- [x] Confirmed zero `vi.mock` in the new files


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

## Summary by CodeRabbit

* **Tests**
* Added comprehensive automated coverage for SQL query formatting,
snippet identity, and AI-generated titles.
* Added coverage for AI-assisted SQL editing, including diff acceptance,
rejection, debugging, and request handling.
* Added coverage for query execution, result persistence, safety checks,
replica selection, error highlighting, and diff-state behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-07-22 14:53:46 -04:00
1 parent 359974d071
commit 08c4f64c42
5 files changed
+574

No files matched your search

@@ -0,0 +1,67 @@
import { act, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { usePrettifyQuery } from './usePrettifyQuery'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { formatSql } from '@/lib/formatSql'
import { sqlEditorState } from '@/state/sql-editor/sql-editor-state'
import {
createInMemoryEditor,
renderSqlEditorHook,
resetSqlEditorStores,
seedSnippet,
setupSqlEditorMocks,
} from '@/tests/lib/sql-editor-test-utils'
const SNIPPET_ID = 'prettify-snippet'
const MESSY_SQL = 'select id,name from users'
function usePrettifyHarness({ isDiffOpen }: { isDiffOpen: boolean }) {
const { data: project } = useSelectedProjectQuery()
const prettifyQuery = usePrettifyQuery({ id: SNIPPET_ID, isDiffOpen })
return { prettifyQuery, isReady: !!project }
}
beforeEach(() => {
resetSqlEditorStores()
setupSqlEditorMocks()
seedSnippet({ id: SNIPPET_ID, name: 'My query', sql: MESSY_SQL })
})
afterEach(() => {
resetSqlEditorStores()
})
describe('usePrettifyQuery', () => {
it('formats the editor SQL in place and writes it back to the snippet store', async () => {
const inMemoryEditor = createInMemoryEditor(MESSY_SQL)
const { result } = renderSqlEditorHook(
(props: { isDiffOpen: boolean }) => usePrettifyHarness(props),
{ inMemoryEditor, initialProps: { isDiffOpen: false } }
)
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.prettifyQuery()
})
const expected = formatSql(MESSY_SQL)
expect(inMemoryEditor.editor.getValue()).toBe(expected)
expect(sqlEditorState.snippets[SNIPPET_ID].snippet.content?.unchecked_sql).toBe(expected)
})
it('is a no-op while a diff is open', async () => {
const inMemoryEditor = createInMemoryEditor(MESSY_SQL)
const { result } = renderSqlEditorHook(
(props: { isDiffOpen: boolean }) => usePrettifyHarness(props),
{ inMemoryEditor, initialProps: { isDiffOpen: true } }
)
await waitFor(() => expect(result.current.isReady).toBe(true))
await act(async () => {
await result.current.prettifyQuery()
})
expect(inMemoryEditor.editor.getValue()).toBe(MESSY_SQL)
})
})
@@ -0,0 +1,42 @@
import { waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useSnippetIdentity } from './useSnippetIdentity'
import {
renderSqlEditorHook,
resetSqlEditorStores,
seedSnippet,
} from '@/tests/lib/sql-editor-test-utils'
/**
* `useParams` is globally stubbed to `{ ref: 'default' }` (no snippet id), so
* these tests exercise the generated-id branch. The pure id/loading derivation
* itself is proven exhaustively against `deriveSnippetIdentity` in
* `SQLEditor.utils.test`; here we assert the hook wires the store in.
*/
describe('useSnippetIdentity', () => {
beforeEach(() => {
resetSqlEditorStores()
})
it('generates a fresh snippet id + name when there is no URL id', () => {
const { result } = renderSqlEditorHook(useSnippetIdentity)
expect(result.current.urlId).toBeUndefined()
expect(result.current.id).toEqual(expect.any(String))
expect(result.current.id.length).toBeGreaterThan(0)
expect(result.current.generatedNewSnippetName).toEqual(expect.any(String))
expect(result.current.generatedNewSnippetName.length).toBeGreaterThan(0)
})
it('reports loading until the snippet content is present in the store', async () => {
const { result } = renderSqlEditorHook(useSnippetIdentity)
// The generated snippet has no content in the store yet.
expect(result.current.isLoading).toBe(true)
seedSnippet({ id: result.current.id, name: 'My query', sql: 'select 1;' })
await waitFor(() => expect(result.current.isLoading).toBe(false))
})
})
@@ -0,0 +1,50 @@
import { act, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useSnippetTitleGenerator } from './useSnippetTitleGenerator'
import { sqlEditorState } from '@/state/sql-editor/sql-editor-state'
import {
renderSqlEditorHook,
resetSqlEditorStores,
seedSnippet,
setupSqlEditorMocks,
} from '@/tests/lib/sql-editor-test-utils'
const SNIPPET_ID = 'title-snippet'
beforeEach(() => {
resetSqlEditorStores()
// The title endpoint (POST /ai/sql/title-v2) returns { title: 'Generated title' }.
setupSqlEditorMocks()
seedSnippet({ id: SNIPPET_ID, name: 'Untitled query', sql: 'select 1;' })
})
afterEach(() => {
resetSqlEditorStores()
})
describe('useSnippetTitleGenerator', () => {
it('names an untitled snippet from the generated title and queues it for saving', async () => {
const { result } = renderSqlEditorHook(useSnippetTitleGenerator)
await act(async () => {
await result.current.setAiTitle(SNIPPET_ID, 'select 1;')
})
await waitFor(() =>
expect(sqlEditorState.snippets[SNIPPET_ID].snippet.name).toBe('Generated title')
)
expect(sqlEditorState.needsSaving.has(SNIPPET_ID)).toBe(true)
})
it('returns the generated title from generateSqlTitle', async () => {
const { result } = renderSqlEditorHook(useSnippetTitleGenerator)
let title: string | undefined
await act(async () => {
title = (await result.current.generateSqlTitle({ sql: 'select 1;' })).title
})
expect(title).toBe('Generated title')
})
})
@@ -0,0 +1,152 @@
import { act, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import { DiffType } from './SQLEditor.types'
import { useSqlEditorAi } from './useSqlEditorAi'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { sidebarManagerState } from '@/state/sidebar-manager-state'
import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request'
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
import {
createInMemoryEditor,
renderSqlEditorHook,
resetSqlEditorStores,
seedSnippet,
setupSqlEditorMocks,
} from '@/tests/lib/sql-editor-test-utils'
const SNIPPET_ID = 'ai-snippet'
/**
* Composes the diff + prompt state hooks the AI hook depends on (production
* wires these together in `SQLEditorControllers`), so tests drive the real
* accept/discard/drain flows end to end.
*/
function useAiHarness({ editorMountCount = 1 }: { editorMountCount?: number } = {}) {
const diff = useSqlEditorDiff()
const prompt = useSqlEditorPrompt()
const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt })
return { ai, diff, prompt }
}
beforeEach(() => {
resetSqlEditorStores()
setupSqlEditorMocks()
})
afterEach(() => {
resetSqlEditorStores()
})
describe('useSqlEditorAi — diff-request drain', () => {
it('copies the requested SQL straight into an empty editor and consumes the request', async () => {
sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification)
const inMemoryEditor = createInMemoryEditor('')
const { result } = renderSqlEditorHook(useAiHarness, { inMemoryEditor })
await waitFor(() => expect(inMemoryEditor.editor.getValue()).toBe('select 100;'))
// One-shot: the request is drained so it can't re-apply to a later editor.
expect(sqlEditorDiffRequestState.pending).toBeUndefined()
expect(result.current.diff.isDiffOpen).toBe(false)
})
it('opens a diff between existing and requested SQL when the editor is non-empty', async () => {
sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification)
const inMemoryEditor = createInMemoryEditor('select 1;')
const { result } = renderSqlEditorHook(useAiHarness, { inMemoryEditor })
await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true))
// The diff-sync effect pushes the resolved diff into the (in-memory) diff editor.
expect(inMemoryEditor.getDiffOriginal()).toBe('select 1;')
expect(inMemoryEditor.getDiffModified()).toBe('select 42;')
expect(sqlEditorDiffRequestState.pending).toBeUndefined()
// The editor's own contents are untouched until the diff is accepted.
expect(inMemoryEditor.editor.getValue()).toBe('select 1;')
})
it('drains a pending request exactly once across editor remounts', async () => {
sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification)
const inMemoryEditor = createInMemoryEditor('')
const { rerender } = renderSqlEditorHook(useAiHarness, {
inMemoryEditor,
initialProps: { editorMountCount: 1 },
})
await waitFor(() => expect(inMemoryEditor.editor.getValue()).toBe('select 100;'))
// Simulate a fresh editor mount: the consumed request must not re-apply.
inMemoryEditor.setValue('edited by user')
rerender({ editorMountCount: 2 })
await new Promise((r) => setTimeout(r, 20))
expect(inMemoryEditor.editor.getValue()).toBe('edited by user')
})
})
describe('useSqlEditorAi — accept / discard diff', () => {
async function openModificationDiff() {
sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification)
const inMemoryEditor = createInMemoryEditor('select 1;')
const utils = renderSqlEditorHook(useAiHarness, { inMemoryEditor })
await waitFor(() => expect(utils.result.current.diff.isDiffOpen).toBe(true))
return { ...utils, inMemoryEditor }
}
it('accepting a modification writes the diff result back into the editor and closes the diff', async () => {
const { result, inMemoryEditor } = await openModificationDiff()
await act(async () => {
await result.current.ai.acceptAiHandler()
})
await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(false))
expect(inMemoryEditor.editor.getValue()).toBe('select 42;')
})
it('discarding a diff leaves the editor untouched and closes the diff', async () => {
const { result, inMemoryEditor } = await openModificationDiff()
await act(async () => {
result.current.ai.discardAiHandler()
})
await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(false))
expect(inMemoryEditor.editor.getValue()).toBe('select 1;')
})
})
describe('useSqlEditorAi — debug', () => {
it('onDebug opens the assistant sidebar and starts a debug chat from the failing snippet', async () => {
seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' })
sqlEditorSessionState.addResultError(SNIPPET_ID, { message: 'syntax error at or near "selct"' })
const { result, aiAssistantState } = renderSqlEditorHook(useAiHarness)
await act(async () => {
await result.current.ai.onDebug()
})
// No sidebar is registered in the test tree, so opening queues a pending open.
expect(sidebarManagerState.pendingSidebarOpen).toBe(SIDEBAR_KEYS.AI_ASSISTANT)
const activeChat = aiAssistantState.chats[aiAssistantState.activeChatId ?? '']
expect(activeChat?.name).toBe('Debug SQL snippet')
expect(aiAssistantState.sqlSnippets).toEqual(['selct 1;'])
expect(aiAssistantState.initialInput).toContain('syntax error at or near "selct"')
})
it('buildDebugPrompt embeds the snippet SQL and its error message', async () => {
seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' })
sqlEditorSessionState.addResultError(SNIPPET_ID, { message: 'boom' })
const { result } = renderSqlEditorHook(useAiHarness)
const promptText = result.current.ai.buildDebugPrompt()
expect(promptText).toContain('boom')
expect(promptText).toContain('selct 1;')
})
})
@@ -0,0 +1,263 @@
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,
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()
})
})