diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx index f161df2b25a..9b7b66e0ddf 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DestinationPanelSchemaType } from './DestinationForm.schema' import { useDestinationForm } from './useDestinationForm' +import { useDestinationValidation } from './useDestinationValidation' import { replicationKeys } from '@/data/replication/keys' import { PipelineRequestStatusProvider, @@ -18,6 +19,8 @@ import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' type ValidationResponse = components['schemas']['ValidatePipelineResponse_Output'] const updateRequests: unknown[] = [] const validationRequests: unknown[] = [] +const destinationRequests: unknown[] = [] +let destinationResponse: ValidationResponse const startRequests = vi.fn() const createRequests = vi.fn() let validationResponse: ValidationResponse @@ -71,6 +74,8 @@ describe('useDestinationForm', () => { }) updateRequests.length = 0 validationRequests.length = 0 + destinationRequests.length = 0 + destinationResponse = { validation_failures: [] } startRequests.mockClear() createRequests.mockClear() validationResponse = { validation_failures: [] } @@ -105,10 +110,12 @@ describe('useDestinationForm', () => { addAPIMock({ method: 'post', path: '/platform/replication/:ref/destinations/validate', - response: () => - HttpResponse.json({ - validation_failures: [], - }), + response: async ({ request }) => { + destinationRequests.push(await request.json()) + return HttpResponse.json( + destinationResponse + ) + }, }) addAPIMock({ method: 'post', @@ -180,6 +187,151 @@ describe('useDestinationForm', () => { ]) }) + it('sends the same pipeline settings to both validation endpoints', async () => { + const expectedPipelineConfig = { + publication_name: 'analytics', + table_sync_copy: { type: 'include_tables', table_ids: [101] }, + batch: { max_fill_ms: 500 }, + max_table_sync_workers: 4, + max_copy_connections_per_table: 1, + } + const { result } = await renderDestinationForm() + await act(async () => { + await result.current.validateConfiguration({ + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + onValidationFail: vi.fn(), + }) + }) + expect(destinationRequests).toEqual([ + { + config: { + big_query: { + project_id: 'example-project', + dataset_id: 'analytics', + service_account_key: '{"type":"service_account"}', + connection_pool_size: 5, + max_staleness_mins: 0, + }, + }, + source_id: 42, + pipeline_config: expectedPipelineConfig, + }, + ]) + expect(validationRequests).toEqual([{ source_id: 42, config: expectedPipelineConfig }]) + }) + + it.each<{ failure_type: 'critical' | 'warning' }>([ + { failure_type: 'critical' }, + { failure_type: 'warning' }, + ])('preserves destination $failure_type handling', async ({ failure_type }) => { + const failure = { failure_type, name: 'Connection issue', reason: 'Check credentials.' } + destinationResponse = { validation_failures: [failure] } + const onValidationFail = vi.fn() + const { result } = await renderDestinationForm() + await act(async () => { + expect( + await result.current.validateConfiguration({ + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + onValidationFail, + }) + ).toEqual({ + canContinue: failure_type === 'warning', + warnings: failure_type === 'warning' ? [failure] : [], + }) + }) + expect(result.current.destinationValidationFailures).toEqual([failure]) + expect(onValidationFail).toHaveBeenCalledOnce() + act(() => result.current.resetValidation()) + expect(result.current.hasRunValidation).toBe(false) + expect(result.current.destinationValidationFailures).toEqual([]) + }) + + it('recovers after a destination validation request fails', async () => { + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations/validate', + response: () => HttpResponse.json({ message: 'Unavailable' }, { status: 503 }), + }) + const { result } = await renderDestinationForm() + const onValidationFail = vi.fn() + const options = { + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + onValidationFail, + } + await act(async () => { + expect(await result.current.validateConfiguration(options)).toEqual({ + canContinue: false, + warnings: [], + }) + }) + expect(result.current.hasRunValidation).toBe(false) + expect(onValidationFail).not.toHaveBeenCalled() + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations/validate', + response: () => HttpResponse.json({ validation_failures: [] }), + }) + await act(async () => { + expect(await result.current.validateConfiguration(options)).toEqual({ + canContinue: true, + warnings: [], + }) + }) + expect(result.current.hasRunValidation).toBe(true) + expect(result.current.destinationValidationFailures).toEqual([]) + }) + + it('reports destination validation as pending until the request completes', async () => { + const response = Promise.withResolvers() + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations/validate', + response: async () => { + await response.promise + return HttpResponse.json({ validation_failures: [] }) + }, + }) + const { result } = customRenderHook(() => + useDestinationValidation({ projectRef: 'default', selectedType: 'BigQuery' }) + ) + let request: Promise + act(() => { + request = result.current.validateDestination({ + data: { ...formData, serviceAccountKey: '{}' }, + }) + }) + await waitFor(() => expect(result.current.isValidatingDestination).toBe(true)) + await act(async () => { + response.resolve() + await request + }) + await waitFor(() => expect(result.current.isValidatingDestination).toBe(false)) + }) + + it('validates a connection without source or pipeline settings', async () => { + const { result } = customRenderHook(() => + useDestinationValidation({ projectRef: 'default', selectedType: 'BigQuery' }) + ) + await act(async () => { + expect( + await result.current.validateDestination({ + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + }) + ).toEqual({ validation_failures: [] }) + }) + expect(destinationRequests).toEqual([ + { + config: expect.objectContaining({ + big_query: expect.objectContaining({ + project_id: 'example-project', + dataset_id: 'analytics', + }), + }), + }, + ]) + expect(validationRequests).toEqual([]) + }) + it('blocks creation when pipeline validation returns a critical failure', async () => { validationResponse = { validation_failures: [ diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts index f84b944f9d0..4de45a0c231 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts @@ -9,19 +9,16 @@ import { DestinationPanelFormSchema as FormSchema } from './DestinationForm.sche import { buildBatchConfig, buildDestinationConfig, - buildDestinationConfigForValidation, buildTableSyncCopyConfig, } from './DestinationForm.utils' +import { useDestinationValidation } from './useDestinationValidation' import { useCreateDestinationPipelineMutation } from '@/data/replication/create-destination-pipeline-mutation' import type { ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query' import { useReplicationSourcesQuery } from '@/data/replication/sources-query' import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' import { type BatchConfig } from '@/data/replication/types' import { useUpdateDestinationPipelineMutation } from '@/data/replication/update-destination-pipeline-mutation' -import { - useValidateDestinationMutation, - type ValidationFailure, -} from '@/data/replication/validate-destination-mutation' +import type { ValidationFailure } from '@/data/replication/validate-destination-mutation' import { useValidatePipelineMutation } from '@/data/replication/validate-pipeline-mutation' import { useIcebergNamespaceCreateMutation } from '@/data/storage/iceberg-namespace-create-mutation' import { useS3AccessKeyCreateMutation } from '@/data/storage/s3-access-key-create-mutation' @@ -46,8 +43,10 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination const { data: sourcesData } = useReplicationSourcesQuery({ projectRef }) const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id - const { mutateAsync: validateDestination, isPending: isValidatingDestination } = - useValidateDestinationMutation() + const { validateDestination, isValidatingDestination } = useDestinationValidation({ + projectRef, + selectedType, + }) const { mutateAsync: validatePipeline, isPending: isValidatingPipeline } = useValidatePipelineMutation() @@ -131,12 +130,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination // even if one fails - this makes the validation feel like a single operation const results = await Promise.allSettled([ validateDestination({ - projectRef, - destinationConfig: buildDestinationConfigForValidation({ - projectRef, - selectedType, - data, - }), + data, sourceId, publicationName: data.publicationName, maxFillMs: data.maxFillMs, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationValidation.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationValidation.ts new file mode 100644 index 00000000000..d050259ad1d --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationValidation.ts @@ -0,0 +1,31 @@ +import type { DestinationType } from '../DestinationPanel.types' +import type { DestinationPanelSchemaType } from './DestinationForm.schema' +import { buildDestinationConfigForValidation } from './DestinationForm.utils' +import { useValidateDestinationMutation } from '@/data/replication/validate-destination-mutation' + +export const useDestinationValidation = ({ + projectRef, + selectedType, +}: { + projectRef?: string + selectedType: DestinationType +}) => { + const { mutateAsync, isPending: isValidatingDestination } = useValidateDestinationMutation() + + const validateDestination = ({ + data, + ...pipelineOptions + }: { + data: DestinationPanelSchemaType + } & Omit[0], 'projectRef' | 'destinationConfig'>) => { + if (!projectRef) throw new Error('Project ref is required') + + return mutateAsync({ + projectRef, + destinationConfig: buildDestinationConfigForValidation({ projectRef, selectedType, data }), + ...pipelineOptions, + }) + } + + return { validateDestination, isValidatingDestination } +}