import { QueryClient } from '@tanstack/react-query'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { DestinationRow as DestinationRowComponent } from './DestinationRow'
import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
import { routerMock } from '@/tests/lib/route-mock'
type ReplicationPipelinesResponse = components['schemas']['PipelinesResponse_Output']
type ReplicationDestinationResponse = components['schemas']['DestinationResponse_Output']
type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output']
type ReplicationPipelineStatusResponse = components['schemas']['PipelineStatusResponse_Output']
type ReplicationPipelineReplicationStatusResponse =
components['schemas']['PipelineReplicationStatusResponse_Output']
type ReplicationPipelineVersionResponse = components['schemas']['PipelineVersionResponse_Output']
// Tooltip/Popover descendants use Web Animations
mockAnimationsApi()
const DestinationRow = (props: { destinationId: number }) => (
)
const DESTINATION_ID = 1
const PIPELINE_ID = 42
const addSourcesMock = () =>
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/sources',
response: () =>
HttpResponse.json({
sources: [
{
tenant_id: 't',
id: 1,
name: 'default',
config: { host: 'db.internal', port: 5432, name: 'main-db', username: 'etl_user' },
},
],
}),
})
const addDestinationMock = () =>
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/destinations/:destination_id',
response: () =>
HttpResponse.json({
tenant_id: 't',
id: DESTINATION_ID,
name: 'My BigQuery Destination',
config: {
big_query: {
project_id: 'gcp-proj',
dataset_id: 'analytics',
connection_pool_size: 5,
},
},
}),
})
const addPipelinesMock = () =>
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines',
response: () =>
HttpResponse.json({
pipelines: [
{
id: PIPELINE_ID,
tenant_id: 't',
source_id: 1,
source_name: 'main-db',
destination_id: DESTINATION_ID,
destination_name: 'My BigQuery Destination',
replicator_id: 9001,
config: {
publication_name: 'supabase_realtime',
table_sync_copy: { type: 'include_all_tables' },
},
},
],
}),
})
const addPipelineStatusMock = (statusName: ReplicationPipelineStatusResponse['status']['name']) =>
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/status',
response: () =>
HttpResponse.json({
pipeline_id: PIPELINE_ID,
status: { name: statusName },
}),
})
const addReplicationStatusMock = (
confirmedFlushLsnBytes: number,
tableStatuses: ReplicationPipelineReplicationStatusResponse['table_statuses'] = []
) =>
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
response: () =>
HttpResponse.json({
pipeline_id: PIPELINE_ID,
apply_lag: {
active: true,
wal_status: 'reserved',
restart_lsn_bytes: 0,
confirmed_flush_lsn_bytes: confirmedFlushLsnBytes,
safe_wal_size_bytes: null,
},
table_statuses: tableStatuses,
}),
})
const addVersionMock = () =>
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/version',
response: () =>
HttpResponse.json({
pipeline_id: PIPELINE_ID,
version: { id: 1, name: 'v0.3.0' },
}),
})
describe('DestinationRow', () => {
const addAllMocks = () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addReplicationStatusMock(0)
addVersionMock()
}
test('waits for asynchronous shutdown before deleting the pipeline', async () => {
addAllMocks()
routerMock.setCurrentUrl('/project/default/database/replication')
let isStopping = false
let completeShutdown: () => void = () => {}
const shutdown = new Promise((resolve) => {
completeShutdown = resolve
})
const shutdownStatusRequested = vi.fn()
const deleted = vi.fn()
addAPIMock({
method: 'post',
path: '/platform/replication/:ref/pipelines/:pipeline_id/stop',
response: () => {
isStopping = true
return HttpResponse.json>({}, { status: 202 })
},
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/status',
response: async () => {
if (isStopping) {
shutdownStatusRequested()
await shutdown
}
return HttpResponse.json({
pipeline_id: PIPELINE_ID,
status: { name: isStopping ? 'stopped' : 'started' },
})
},
})
addAPIMock({
method: 'delete',
path: '/platform/replication/:ref/destinations-pipelines/:destination_id/:pipeline_id',
response: () => {
deleted()
return HttpResponse.json(
{ destination_deleted: true, destination_id: DESTINATION_ID, pipeline_id: PIPELINE_ID }
)
},
})
customRender()
await screen.findByText('supabase_realtime')
await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete pipeline' }))
await userEvent.type(
screen.getByPlaceholderText('Type the pipeline name'),
'My BigQuery Destination'
)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Delete pipeline' })).toBeEnabled()
)
// jsdom does not reliably submit portalled forms through button activation.
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!)
await waitFor(() => expect(shutdownStatusRequested).toHaveBeenCalledOnce())
expect(deleted).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Deleting…' })).toBeDisabled()
await act(async () => {
completeShutdown()
})
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Deleting…' })).not.toBeInTheDocument()
)
})
test('keeps deletion retryable when shutdown status cannot be verified', async () => {
addAllMocks()
routerMock.setCurrentUrl('/project/default/database/replication')
let isStopping = false
const deleted = vi.fn()
addAPIMock({
method: 'post',
path: '/platform/replication/:ref/pipelines/:pipeline_id/stop',
response: () => {
isStopping = true
return HttpResponse.json>({}, { status: 202 })
},
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/status',
response: () => {
if (isStopping)
return HttpResponse.json({ message: 'Status unavailable' }, { status: 503 })
return HttpResponse.json({
pipeline_id: PIPELINE_ID,
status: { name: 'started' },
})
},
})
addAPIMock({
method: 'delete',
path: '/platform/replication/:ref/destinations-pipelines/:destination_id/:pipeline_id',
response: () => {
deleted()
return HttpResponse.json(
{ destination_deleted: true, destination_id: DESTINATION_ID, pipeline_id: PIPELINE_ID }
)
},
})
const queryClient = new QueryClient()
customRender(, { queryClient })
await screen.findByText('supabase_realtime')
await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete pipeline' }))
await userEvent.type(
screen.getByPlaceholderText('Type the pipeline name'),
'My BigQuery Destination'
)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Delete pipeline' })).toBeEnabled()
)
// jsdom does not reliably submit portalled forms through button activation.
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!)
await waitFor(() =>
expect(
queryClient
.getMutationCache()
.getAll()
.some((mutation) => mutation.state.status === 'error')
).toBe(true)
)
expect(deleted).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Delete pipeline' })).toBeEnabled()
})
test('navigates to the pipeline when the row is clicked', async () => {
addAllMocks()
routerMock.setCurrentUrl('/project/default/database/replication')
customRender()
const row = (await screen.findByText('supabase_realtime')).closest('tr')
expect(row).not.toBeNull()
await userEvent.click(row!)
expect(routerMock.asPath).toBe(`/project/default/database/replication/${PIPELINE_ID}`)
})
test('does not navigate when the row overflow menu is opened', async () => {
addAllMocks()
routerMock.setCurrentUrl('/project/default/database/replication')
customRender()
await screen.findByText('supabase_realtime')
await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
expect(routerMock.asPath).toBe('/project/default/database/replication')
})
test('shows "Caught up" when confirmed_flush_lsn_bytes is 0', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addReplicationStatusMock(0)
addVersionMock()
customRender()
const lag = await screen.findByText('Caught up')
expect(lag).toHaveClass('text-foreground-light')
expect(lag.closest('[aria-live="polite"]')).toHaveAttribute('aria-atomic', 'true')
})
test('shows formatted lag value when confirmed_flush_lsn_bytes is non-zero', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addReplicationStatusMock(2048)
addVersionMock()
customRender()
expect(await screen.findByText('2 KB')).toHaveClass('text-foreground-light')
})
test('shows initial sync with the same lag text treatment', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addReplicationStatusMock(0, [
{
id: 1,
schema: 'public',
name: 'orders',
table_id: 1,
table_name: 'public.orders',
state: { name: 'copying_table' },
},
])
addVersionMock()
customRender()
expect(await screen.findByText('Initial sync')).toHaveClass('text-foreground-light')
})
test('shows initial sync when apply lag is unavailable', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
response: () =>
HttpResponse.json({
pipeline_id: PIPELINE_ID,
apply_lag: null,
table_statuses: [
{
id: 1,
schema: 'public',
name: 'orders',
table_id: 1,
table_name: 'public.orders',
state: { name: 'queued' },
},
],
}),
})
addVersionMock()
customRender()
expect(await screen.findByText('Initial sync')).toBeInTheDocument()
expect(screen.queryByText('Lag unavailable')).not.toBeInTheDocument()
})
test('announces lag loading and replaces it with the resolved value', async () => {
let resolveReplicationStatus: (
response: ReplicationPipelineReplicationStatusResponse
) => void = () => {}
const replicationStatusResponse = new Promise(
(resolve) => {
resolveReplicationStatus = resolve
}
)
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
response: async () =>
HttpResponse.json(
await replicationStatusResponse
),
})
addVersionMock()
customRender()
expect(await screen.findByText('Loading lag')).toBeInTheDocument()
resolveReplicationStatus({
pipeline_id: PIPELINE_ID,
apply_lag: {
active: true,
wal_status: 'reserved',
restart_lsn_bytes: 0,
confirmed_flush_lsn_bytes: 0,
safe_wal_size_bytes: null,
},
table_statuses: [],
})
expect(await screen.findByText('Caught up')).toBeInTheDocument()
expect(screen.queryByText('Loading lag')).not.toBeInTheDocument()
})
test('announces when lag is unavailable', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
response: () =>
HttpResponse.json({ message: 'Pipeline unavailable' }, { status: 500 }),
})
addVersionMock()
customRender()
expect(await screen.findByText('Lag unavailable')).toBeInTheDocument()
})
test('shows publication name from pipeline config', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addReplicationStatusMock(0)
addVersionMock()
customRender()
expect(await screen.findByText('supabase_realtime')).toBeInTheDocument()
})
test('shows Running badge when pipeline is started', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addReplicationStatusMock(0)
addVersionMock()
customRender()
const status = await screen.findByText('Running')
const liveRegion = status.closest('[aria-live="polite"]')
expect(status).toBeInTheDocument()
expect(liveRegion).toHaveAttribute('aria-atomic', 'true')
})
test('shows Failed badge when pipeline has failed', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('failed')
addReplicationStatusMock(0)
addVersionMock()
customRender()
expect(await screen.findByText('Failed')).toBeInTheDocument()
})
test('shows Stopped badge when pipeline is stopped', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('stopped')
addReplicationStatusMock(0)
addVersionMock()
customRender()
expect(await screen.findByText('Stopped')).toBeInTheDocument()
})
test('shows table errors in the subtext and on the logo when tables have replication errors', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('started')
addReplicationStatusMock(0, [
{
id: 1,
schema: 'public',
name: 'orders',
table_id: 1,
table_name: 'public.orders',
state: { name: 'error', reason: 'table not found', retry_policy: { policy: 'no_retry' } },
},
])
addVersionMock()
const { container } = customRender()
expect(await screen.findByText('1 table error')).toBeInTheDocument()
// StatusIcon destructive on the logo corner (packages/ui/src/components/StatusIcon.tsx)
expect(container.querySelector('.bg-destructive-600')).toBeInTheDocument()
})
test('suppresses table error signals when pipeline is stopped even with table errors', async () => {
addSourcesMock()
addDestinationMock()
addPipelinesMock()
addPipelineStatusMock('stopped')
addReplicationStatusMock(0, [
{
id: 1,
schema: 'public',
name: 'orders',
table_id: 1,
table_name: 'public.orders',
state: { name: 'error', reason: 'table not found', retry_policy: { policy: 'no_retry' } },
},
])
addVersionMock()
const { container } = customRender()
await screen.findByText('Stopped')
expect(screen.queryByText(/table error/)).not.toBeInTheDocument()
expect(container.querySelector('.bg-destructive-600')).not.toBeInTheDocument()
})
test('shows error when the pipelines API fails', async () => {
addSourcesMock()
addDestinationMock()
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines',
response: () =>
HttpResponse.json({ message: 'Internal server error' }, { status: 500 }),
})
customRender(, {
queryClient: new QueryClient({ defaultOptions: { queries: { retryDelay: 0 } } }),
})
expect(await screen.findByText('Failed to retrieve pipeline information')).toBeInTheDocument()
})
})