From bc4c42e9de10b0d10048f5a220d4e8e0b7c8e041 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 07:37:03 +0000 Subject: [PATCH] test: add comprehensive tests for Slack add-reaction tool - Add route.test.ts for the add-reaction API route - Tests successful reaction addition - Tests authentication handling - Tests validation of required fields (access token, channel, timestamp, emoji) - Tests Slack API error handling (missing_scope, channel_not_found, message_not_found, invalid_name, already_reacted) - Tests network error handling - Tests various emoji names - Add slack.test.ts for the Slack block configuration - Tests block configuration and tool access - Tests tools.config.tool function returns correct tool names - Tests tools.config.params correctly maps react operation params - Tests subBlocks configuration for react operation - Tests inputs configuration Validates that the Slack add-reaction tool works correctly. Co-authored-by: Emir Karabeg --- .../tools/slack/add-reaction/route.test.ts | 518 ++++++++++++++++++ apps/sim/blocks/blocks/slack.test.ts | 222 ++++++++ 2 files changed, 740 insertions(+) create mode 100644 apps/sim/app/api/tools/slack/add-reaction/route.test.ts create mode 100644 apps/sim/blocks/blocks/slack.test.ts diff --git a/apps/sim/app/api/tools/slack/add-reaction/route.test.ts b/apps/sim/app/api/tools/slack/add-reaction/route.test.ts new file mode 100644 index 000000000..d1bc7f321 --- /dev/null +++ b/apps/sim/app/api/tools/slack/add-reaction/route.test.ts @@ -0,0 +1,518 @@ +/** + * Tests for Slack Add Reaction API route + * + * @vitest-environment node + */ +import { + createMockFetch, + createMockLogger, + createMockRequest, + createMockResponse, +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('Slack Add Reaction API Route', () => { + const mockLogger = createMockLogger() + const mockCheckInternalAuth = vi.fn() + + beforeEach(() => { + vi.resetModules() + + vi.doMock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkInternalAuth: mockCheckInternalAuth, + })) + }) + + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + }) + + it('should add reaction successfully', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: true }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.content).toBe('Successfully added :thumbsup: reaction') + expect(data.output.metadata).toEqual({ + channel: 'C1234567890', + timestamp: '1405894322.002768', + reaction: 'thumbsup', + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://slack.com/api/reactions.add', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: 'Bearer xoxb-test-token', + }), + body: JSON.stringify({ + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }), + }) + ) + }) + + it('should handle emoji name without colons', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: true }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'eyes', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.content).toBe('Successfully added :eyes: reaction') + }) + + it('should handle unauthenticated request', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: false, + error: 'Authentication required', + }) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.success).toBe(false) + expect(data.error).toBe('Authentication required') + }) + + it('should handle missing access token', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const req = createMockRequest( + 'POST', + { + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toBe('Invalid request data') + }) + + it('should handle missing channel', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toBe('Invalid request data') + }) + + it('should handle missing timestamp', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toBe('Invalid request data') + }) + + it('should handle missing emoji name', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toBe('Invalid request data') + }) + + it('should handle Slack API missing_scope error', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: false, error: 'missing_scope' }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(data.success).toBe(false) + expect(data.error).toBe('missing_scope') + }) + + it('should handle Slack API channel_not_found error', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: false, error: 'channel_not_found' }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'CINVALID', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(data.success).toBe(false) + expect(data.error).toBe('channel_not_found') + }) + + it('should handle Slack API message_not_found error', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: false, error: 'message_not_found' }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '0000000000.000000', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(data.success).toBe(false) + expect(data.error).toBe('message_not_found') + }) + + it('should handle Slack API invalid_name error for invalid emoji', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: false, error: 'invalid_name' }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'not_a_valid_emoji', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(data.success).toBe(false) + expect(data.error).toBe('invalid_name') + }) + + it('should handle Slack API already_reacted error', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: false, error: 'already_reacted' }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(data.success).toBe(false) + expect(data.error).toBe('already_reacted') + }) + + it('should handle network error when calling Slack API', async () => { + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockFetch = vi.fn().mockRejectedValueOnce(new Error('Network error')) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toBe('Network error') + }) + + it('should handle various emoji names correctly', async () => { + const emojiNames = ['heart', 'fire', 'rocket', '+1', '-1', 'tada', 'eyes', 'thinking_face'] + + for (const emojiName of emojiNames) { + vi.resetModules() + + vi.doMock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkInternalAuth: mockCheckInternalAuth, + })) + + mockCheckInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'api-key', + }) + + const mockSlackResponse = createMockResponse({ + status: 200, + json: { ok: true }, + }) + + const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse) + vi.stubGlobal('fetch', mockFetch) + + const req = createMockRequest( + 'POST', + { + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: emojiName, + }, + {}, + 'http://localhost:3000/api/tools/slack/add-reaction' + ) + + const { POST } = await import('@/app/api/tools/slack/add-reaction/route') + const response = await POST(req as any) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.metadata.reaction).toBe(emojiName) + } + }) +}) diff --git a/apps/sim/blocks/blocks/slack.test.ts b/apps/sim/blocks/blocks/slack.test.ts new file mode 100644 index 000000000..cba2cb739 --- /dev/null +++ b/apps/sim/blocks/blocks/slack.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for Slack Block configuration + * + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { SlackBlock } from './slack' + +describe('SlackBlock', () => { + describe('basic configuration', () => { + it('should have correct type and name', () => { + expect(SlackBlock.type).toBe('slack') + expect(SlackBlock.name).toBe('Slack') + }) + + it('should have slack_add_reaction in tools access', () => { + expect(SlackBlock.tools.access).toContain('slack_add_reaction') + }) + + it('should have tools.config.tool function', () => { + expect(typeof SlackBlock.tools.config?.tool).toBe('function') + }) + + it('should have tools.config.params function', () => { + expect(typeof SlackBlock.tools.config?.params).toBe('function') + }) + }) + + describe('tools.config.tool', () => { + const getToolName = SlackBlock.tools.config?.tool + + it('should return slack_add_reaction for react operation', () => { + expect(getToolName?.({ operation: 'react' })).toBe('slack_add_reaction') + }) + + it('should return slack_message for send operation', () => { + expect(getToolName?.({ operation: 'send' })).toBe('slack_message') + }) + + it('should return slack_delete_message for delete operation', () => { + expect(getToolName?.({ operation: 'delete' })).toBe('slack_delete_message') + }) + + it('should return slack_update_message for update operation', () => { + expect(getToolName?.({ operation: 'update' })).toBe('slack_update_message') + }) + }) + + describe('tools.config.params for react operation', () => { + const getParams = SlackBlock.tools.config?.params + + it('should map reaction params correctly with OAuth auth', () => { + const inputParams = { + operation: 'react', + authMethod: 'oauth', + credential: 'oauth-credential-123', + channel: 'C1234567890', + reactionTimestamp: '1405894322.002768', + emojiName: 'thumbsup', + } + + const result = getParams?.(inputParams) + + expect(result).toEqual({ + credential: 'oauth-credential-123', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'thumbsup', + }) + }) + + it('should map reaction params correctly with bot token auth', () => { + const inputParams = { + operation: 'react', + authMethod: 'bot_token', + botToken: 'xoxb-test-token', + channel: 'C1234567890', + reactionTimestamp: '1405894322.002768', + emojiName: 'eyes', + } + + const result = getParams?.(inputParams) + + expect(result).toEqual({ + accessToken: 'xoxb-test-token', + channel: 'C1234567890', + timestamp: '1405894322.002768', + name: 'eyes', + }) + }) + + it('should handle various emoji names', () => { + const emojiNames = ['heart', 'fire', 'rocket', '+1', '-1', 'tada', 'thinking_face'] + + for (const emoji of emojiNames) { + const inputParams = { + operation: 'react', + authMethod: 'bot_token', + botToken: 'xoxb-test-token', + channel: 'C1234567890', + reactionTimestamp: '1405894322.002768', + emojiName: emoji, + } + + const result = getParams?.(inputParams) + + expect(result?.name).toBe(emoji) + } + }) + + it('should handle channel from trigger data', () => { + const inputParams = { + operation: 'react', + authMethod: 'bot_token', + botToken: 'xoxb-test-token', + channel: 'C9876543210', + reactionTimestamp: '1234567890.123456', + emojiName: 'white_check_mark', + } + + const result = getParams?.(inputParams) + + expect(result?.channel).toBe('C9876543210') + expect(result?.timestamp).toBe('1234567890.123456') + }) + + it('should trim whitespace from channel', () => { + const inputParams = { + operation: 'react', + authMethod: 'bot_token', + botToken: 'xoxb-test-token', + channel: ' C1234567890 ', + reactionTimestamp: '1405894322.002768', + emojiName: 'thumbsup', + } + + const result = getParams?.(inputParams) + + expect(result?.channel).toBe('C1234567890') + }) + }) + + describe('subBlocks for react operation', () => { + it('should have reactionTimestamp subBlock with correct condition', () => { + const reactionTimestampSubBlock = SlackBlock.subBlocks.find( + (sb) => sb.id === 'reactionTimestamp' + ) + + expect(reactionTimestampSubBlock).toBeDefined() + expect(reactionTimestampSubBlock?.type).toBe('short-input') + expect(reactionTimestampSubBlock?.required).toBe(true) + expect(reactionTimestampSubBlock?.condition).toEqual({ + field: 'operation', + value: 'react', + }) + }) + + it('should have emojiName subBlock with correct condition', () => { + const emojiNameSubBlock = SlackBlock.subBlocks.find((sb) => sb.id === 'emojiName') + + expect(emojiNameSubBlock).toBeDefined() + expect(emojiNameSubBlock?.type).toBe('short-input') + expect(emojiNameSubBlock?.required).toBe(true) + expect(emojiNameSubBlock?.condition).toEqual({ + field: 'operation', + value: 'react', + }) + }) + + it('should have channel subBlock that shows for react operation', () => { + const channelSubBlock = SlackBlock.subBlocks.find((sb) => sb.id === 'channel') + + expect(channelSubBlock).toBeDefined() + + const condition = channelSubBlock?.condition as { + field: string + value: string[] + not: boolean + and: { field: string; value: string; not: boolean } + } + + expect(condition.field).toBe('operation') + expect(condition.value).not.toContain('react') + expect(condition.not).toBe(true) + }) + }) + + describe('inputs configuration', () => { + it('should have timestamp input for reaction', () => { + expect(SlackBlock.inputs.timestamp).toBeDefined() + expect(SlackBlock.inputs.timestamp.type).toBe('string') + }) + + it('should have name input for emoji', () => { + expect(SlackBlock.inputs.name).toBeDefined() + expect(SlackBlock.inputs.name.type).toBe('string') + }) + + it('should have reactionTimestamp input', () => { + expect(SlackBlock.inputs.reactionTimestamp).toBeDefined() + expect(SlackBlock.inputs.reactionTimestamp.type).toBe('string') + }) + + it('should have emojiName input', () => { + expect(SlackBlock.inputs.emojiName).toBeDefined() + expect(SlackBlock.inputs.emojiName.type).toBe('string') + }) + }) + + describe('operation dropdown', () => { + it('should include Add Reaction option', () => { + const operationSubBlock = SlackBlock.subBlocks.find((sb) => sb.id === 'operation') + expect(operationSubBlock?.type).toBe('dropdown') + + const options = operationSubBlock?.options as Array<{ label: string; id: string }> + const reactOption = options?.find((opt) => opt.id === 'react') + + expect(reactOption).toBeDefined() + expect(reactOption?.label).toBe('Add Reaction') + }) + }) +})