Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/sim/app/api/knowledge/slack/setup/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {
}
})

import { createSlackSearchManifest } from '@/lib/slack-search/manifest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { POST as start } from '@/app/api/knowledge/slack/oauth/route'
import { POST as prepare } from '@/app/api/knowledge/slack/setup/route'

Expand All @@ -35,13 +35,13 @@ describe.each([
['prepare', prepare, mocks.prepare],
['OAuth', start, mocks.start],
] as const)('Slack %s route errors', (_name, route, execute) => {
it('returns an actionable 400 for a non-HTTPS app URL', async () => {
execute.mockImplementation(() =>
createSlackSearchManifest(input.name, input.description, 'http://localhost:3000')
it('returns application validation errors', async () => {
execute.mockRejectedValue(
new OrchestrationError('validation', 'Slack app credentials are required')
)
const response = await route(createMockRequest('POST', input))
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ error: expect.stringContaining('public HTTPS') })
expect(await response.json()).toMatchObject({ error: 'Slack app credentials are required' })
expect(execute).toHaveBeenCalledOnce()
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @vitest-environment jsdom */
import { act, type ReactNode } from 'react'
import { ToastProvider } from '@sim/emcn'
import { createRoot, type Root } from 'react-dom/client'
Comment thread
TheodoreSpeaks marked this conversation as resolved.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SlackSearchInstallationView } from '@/lib/api/contracts/knowledge/slack'
Expand All @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
refetch: vi.fn(),
copy: vi.fn(),
removeError: null as Error | null,
installError: null as Error | null,
}))
vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] }))
vi.mock('@/components/settings/settings-panel', () => ({
Expand All @@ -32,7 +34,12 @@ vi.mock('@/hooks/queries/slack-search', () => ({
error: mocks.removeError,
reset: vi.fn(),
}),
useStartSlackSearchOAuth: () => ({ mutate: mocks.install, isPending: false, reset: vi.fn() }),
useStartSlackSearchOAuth: () => ({
mutate: mocks.install,
isPending: false,
error: mocks.installError,
reset: vi.fn(),
}),
}))

import { OrganizationSearchSlack } from '@/app/o/[organizationId]/settings/components/organization-search-slack'
Expand All @@ -41,6 +48,7 @@ const installation: SlackSearchInstallationView = {
id: 'installation-1',
credentialId: 'credential-1',
appId: 'A1',
appKind: 'custom',
teamId: 'T1',
teamName: 'Test workspace',
enabled: true,
Expand All @@ -57,13 +65,16 @@ beforeEach(() => {
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } })
mocks.copy.mockReset().mockResolvedValue(undefined)
mocks.context.mockReturnValue({ organization: { id: 'org-1' }, viewer: { isAdmin: true } })
mocks.list.mockReturnValue({ data: { installations: [], bots: [] } })
mocks.list.mockReturnValue({
data: { sharedAppAvailable: false, installations: [], bots: [] },
})
mocks.manifest.mockReturnValue({
data: { manifest: '{}', existingApp: null, createAppUrl: 'https://api.slack.com/apps' },
isPending: false,
refetch: mocks.refetch,
})
mocks.removeError = null
mocks.installError = null
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
Expand All @@ -77,15 +88,23 @@ async function render(installed = false) {
if (installed) {
mocks.list.mockReturnValue({
data: {
sharedAppAvailable: false,
installations: [installation],
bots: [{ id: 'credential-1', displayName: 'Sim Search' }],
},
})
}
await act(async () => root.render(<OrganizationSearchSlack />))
await act(async () =>
root.render(
<ToastProvider>
<OrganizationSearchSlack />
</ToastProvider>
)
)
}
function button(label: string) {
const element = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(
const scope = document.querySelector('[role="dialog"]') ?? document
const element = Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(
(element) => element.textContent?.trim() === label
)
expect(element, label).toBeDefined()
Expand All @@ -94,8 +113,8 @@ function button(label: string) {
async function click(label: string) {
await act(async () => button(label).click())
}
async function action(label: string) {
const trigger = container.querySelector<HTMLButtonElement>('[aria-label="Sim Search actions"]')!
async function action(label: string, name = 'Sim Search (custom bot)') {
const trigger = container.querySelector<HTMLButtonElement>(`[aria-label="${name} actions"]`)!
await act(async () => {
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
Expand All @@ -107,6 +126,174 @@ async function action(label: string) {
}

describe('Slack Search settings and shared wizard', () => {
it.each([
{ state: 'no bots', installations: [] },
{ state: 'custom bots', installations: [installation] },
])(
'installs the official app explicitly with $state and a custom source app',
async ({ installations }) => {
mocks.list.mockReturnValue({
data: { sharedAppAvailable: true, installations, bots: [] },
})
mocks.manifest.mockReturnValue({
data: {
manifest: '{}',
existingApp: { appId: 'A1', teamId: 'T1' },
sharedAppId: 'A_SHARED',
createAppUrl: 'https://api.slack.com/apps',
},
})
await render()
if (installations.length) {
expect(container).toHaveTextContent('Reconnect required')
expect(container).toHaveTextContent('Sim Search (custom bot)')
await action('Install Sim Search')
} else {
await click('Install Sim Search')
}
expect(document.querySelector('[role="dialog"]')).toHaveTextContent(
'Install the Sim Search app'
)
expect(document.querySelectorAll('input')).toHaveLength(0)
expect(mocks.install).not.toHaveBeenCalled()
await click('Continue with Slack')
expect(mocks.install).toHaveBeenCalledExactlyOnceWith(
{
organizationId: 'org-1',
installationId: installations[0]?.id,
name: 'Sim Search',
description: expect.any(String),
mode: 'shared',
},
expect.any(Object)
)
mocks.installError = new Error('Slack authorization failed. Try again.')
await render()
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
'Slack authorization failed'
)
expect(mocks.configure).not.toHaveBeenCalled()
expect(mocks.remove).not.toHaveBeenCalled()
}
)

it('reconnects an installed official app without offering a duplicate installation', async () => {
mocks.list.mockReturnValue({
data: {
sharedAppAvailable: true,
installations: [{ ...installation, appId: 'A_SHARED', appKind: 'shared' }],
bots: [{ id: 'credential-1', displayName: 'Sim Search' }],
},
})
mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } })
await render()
expect(container).not.toHaveTextContent('Install Sim Search')
await action('Reconnect', 'Sim Search')
await click('Continue with Slack')
expect(mocks.install).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'shared', installationId: installation.id }),
expect.any(Object)
)
})

it('does not switch to custom setup when shared installation becomes unavailable', async () => {
mocks.list.mockReturnValue({
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
})
mocks.manifest.mockReturnValue({
data: { sharedAppId: null, existingApp: null },
refetch: mocks.refetch,
})
await render()
await action('Install Sim Search')
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
'Sim Search installation is unavailable'
)
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Create Slack app')
expect(button('Continue with Slack')).toBeDisabled()
expect(mocks.install).not.toHaveBeenCalled()
await click('Retry')
expect(mocks.refetch).toHaveBeenCalledOnce()
})

it('allows retrying shared setup after a preparation error with cached data', async () => {
mocks.list.mockReturnValue({
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
})
mocks.manifest.mockReturnValue({
data: { sharedAppId: 'A_SHARED', existingApp: null },
error: new Error('Could not load Slack setup'),
refetch: mocks.refetch,
})
await render()
await action('Install Sim Search')
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
'Could not load Slack setup'
)
expect(button('Continue with Slack')).toBeDisabled()
await click('Retry')
expect(mocks.refetch).toHaveBeenCalledOnce()
expect(mocks.install).not.toHaveBeenCalled()
})

it('does not show official installation when it is unavailable', async () => {
await render(true)
expect(container).not.toHaveTextContent('Install Sim Search')
expect(container).toHaveTextContent('Open in Slack')
})

it('prompts the existing custom bot to reconnect when the feature becomes available', async () => {
await render(true)
expect(container).toHaveTextContent('Sim Search (custom bot)')
expect(container).toHaveTextContent('Enabled')
expect(container).not.toHaveTextContent('Reconnect required')
mocks.list.mockReturnValue({
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
})
mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } })
await render()
expect(container).toHaveTextContent('Reconnect required')
expect(container).not.toHaveTextContent('Install Sim Search')
expect(mocks.install).not.toHaveBeenCalled()
expect(mocks.configure).not.toHaveBeenCalled()
await action('Install Sim Search')
expect(document.querySelector('[role="dialog"]')).toHaveTextContent(
'Install the Sim Search app'
)
expect(button('Continue with Slack')).not.toBeDisabled()
await click('Cancel')
expect(mocks.install).not.toHaveBeenCalled()
expect(mocks.remove).not.toHaveBeenCalled()
})

it('shows the native app alongside the retained custom bot after installing', async () => {
mocks.list.mockReturnValue({
data: {
sharedAppAvailable: true,
installations: [
{ ...installation, enabled: false },
{
...installation,
id: 'native-installation',
credentialId: 'native-credential',
appId: 'A_SHARED',
appKind: 'shared',
},
],
bots: [],
},
})
await render()
expect(container.querySelector('[aria-label="Sim Search (custom bot) actions"]')).not.toBeNull()
expect(container.querySelector('[aria-label="Sim Search actions"]')).not.toBeNull()
expect(container).toHaveTextContent('Disabled')
expect(container).toHaveTextContent('Enabled')
expect(container).not.toHaveTextContent('Reconnect required')
expect(container).not.toHaveTextContent('Install Sim Search')
expect(container.querySelectorAll('a[href*="slack.com/app_redirect"]')).toHaveLength(2)
expect(mocks.install).not.toHaveBeenCalled()
})

it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => {
await render()
expect(container.querySelectorAll('button')).toHaveLength(1)
Expand All @@ -124,13 +311,15 @@ describe('Slack Search settings and shared wizard', () => {

it('shows setup errors and blocks progression until the manifest loads', async () => {
mocks.manifest.mockReturnValue({
error: new Error('Slack needs a public HTTPS URL to send messages to Sim.'),
error: new Error('Slack app configuration is unavailable.'),
refetch: mocks.refetch,
isPending: false,
})
await render()
await click('Set up')
expect(document.querySelector('[role="alert"]')).toHaveTextContent('public HTTPS')
expect(document.querySelector('[role="alert"]')).toHaveTextContent(
'Slack app configuration is unavailable.'
)
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1')
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Continue')
await click('Retry')
Expand Down Expand Up @@ -172,7 +361,7 @@ describe('Slack Search settings and shared wizard', () => {
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Loading Slack setup')
if (mode === 'shared') {
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1')
await click('Install Sim Search')
await click('Continue with Slack')
expect(mocks.install).toHaveBeenCalledWith(
expect.objectContaining({ organizationId: 'org-1', mode: 'shared' }),
expect.any(Object)
Expand Down
Loading
Loading