mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
feat(tests): added testing package, overhauled tests (#2586)
* feat(tests): added testing package, overhauled tests * fix build
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { createMockLogger as createSimTestingMockLogger } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
export { createMockLogger } from '@sim/testing'
|
||||
|
||||
export interface MockUser {
|
||||
id: string
|
||||
email: string
|
||||
@@ -214,12 +217,11 @@ export const mockDb = {
|
||||
})),
|
||||
}
|
||||
|
||||
export const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
/**
|
||||
* Mock logger using @sim/testing createMockLogger.
|
||||
* This provides a consistent mock logger across all API tests.
|
||||
*/
|
||||
export const mockLogger = createSimTestingMockLogger()
|
||||
|
||||
export const mockUser = {
|
||||
id: 'user-123',
|
||||
@@ -729,7 +731,8 @@ export function mockKnowledgeSchemas() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock console logger
|
||||
* Mock console logger using the shared mockLogger instance.
|
||||
* This ensures tests can assert on the same mockLogger instance exported from this module.
|
||||
*/
|
||||
export function mockConsoleLogger() {
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('OAuth Connections API Route', () => {
|
||||
const mockGetSession = vi.fn()
|
||||
@@ -14,12 +14,7 @@ describe('OAuth Connections API Route', () => {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(),
|
||||
}
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
const mockLogger = createMockLogger()
|
||||
const mockParseProvider = vi.fn()
|
||||
const mockEvaluateScopeCoverage = vi.fn()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockLogger } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('OAuth Credentials API Route', () => {
|
||||
const mockGetSession = vi.fn()
|
||||
@@ -17,12 +18,7 @@ describe('OAuth Credentials API Route', () => {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(),
|
||||
}
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
const mockLogger = createMockLogger()
|
||||
|
||||
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('OAuth Disconnect API Route', () => {
|
||||
const mockGetSession = vi.fn()
|
||||
@@ -12,12 +12,7 @@ describe('OAuth Disconnect API Route', () => {
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(),
|
||||
}
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
const mockLogger = createMockLogger()
|
||||
|
||||
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('OAuth Token API Routes', () => {
|
||||
const mockGetUserId = vi.fn()
|
||||
@@ -13,12 +13,7 @@ describe('OAuth Token API Routes', () => {
|
||||
const mockAuthorizeCredentialUse = vi.fn()
|
||||
const mockCheckHybridAuth = vi.fn()
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
const mockLogger = createMockLogger()
|
||||
|
||||
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
|
||||
const mockRequestId = mockUUID.slice(0, 8)
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createSession, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSession = { user: { id: 'test-user-id' } }
|
||||
const mockSession = createSession({ userId: 'test-user-id' })
|
||||
const mockGetSession = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
@@ -29,14 +31,7 @@ vi.mock('@/lib/oauth/oauth', () => ({
|
||||
OAUTH_PROVIDERS: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
@@ -47,14 +42,14 @@ import {
|
||||
refreshTokenIfNeeded,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const mockDb = db as any
|
||||
const mockDbTyped = db as any
|
||||
const mockRefreshOAuthToken = refreshOAuthToken as any
|
||||
|
||||
describe('OAuth Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue(mockSession)
|
||||
mockDb.limit.mockReturnValue([])
|
||||
mockDbTyped.limit.mockReturnValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -69,14 +64,14 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should get user ID from workflow when workflowId is provided', async () => {
|
||||
mockDb.limit.mockReturnValueOnce([{ userId: 'workflow-owner-id' }])
|
||||
mockDbTyped.limit.mockReturnValueOnce([{ userId: 'workflow-owner-id' }])
|
||||
|
||||
const userId = await getUserId('request-id', 'workflow-id')
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
expect(mockDb.from).toHaveBeenCalled()
|
||||
expect(mockDb.where).toHaveBeenCalled()
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(1)
|
||||
expect(mockDbTyped.select).toHaveBeenCalled()
|
||||
expect(mockDbTyped.from).toHaveBeenCalled()
|
||||
expect(mockDbTyped.where).toHaveBeenCalled()
|
||||
expect(mockDbTyped.limit).toHaveBeenCalledWith(1)
|
||||
expect(userId).toBe('workflow-owner-id')
|
||||
})
|
||||
|
||||
@@ -89,7 +84,7 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should return undefined if workflow is not found', async () => {
|
||||
mockDb.limit.mockReturnValueOnce([])
|
||||
mockDbTyped.limit.mockReturnValueOnce([])
|
||||
|
||||
const userId = await getUserId('request-id', 'nonexistent-workflow-id')
|
||||
|
||||
@@ -100,20 +95,20 @@ describe('OAuth Utils', () => {
|
||||
describe('getCredential', () => {
|
||||
it('should return credential when found', async () => {
|
||||
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
|
||||
mockDb.limit.mockReturnValueOnce([mockCredential])
|
||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||
|
||||
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
expect(mockDb.from).toHaveBeenCalled()
|
||||
expect(mockDb.where).toHaveBeenCalled()
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(1)
|
||||
expect(mockDbTyped.select).toHaveBeenCalled()
|
||||
expect(mockDbTyped.from).toHaveBeenCalled()
|
||||
expect(mockDbTyped.where).toHaveBeenCalled()
|
||||
expect(mockDbTyped.limit).toHaveBeenCalledWith(1)
|
||||
|
||||
expect(credential).toEqual(mockCredential)
|
||||
})
|
||||
|
||||
it('should return undefined when credential is not found', async () => {
|
||||
mockDb.limit.mockReturnValueOnce([])
|
||||
mockDbTyped.limit.mockReturnValueOnce([])
|
||||
|
||||
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
|
||||
|
||||
@@ -127,7 +122,7 @@ describe('OAuth Utils', () => {
|
||||
id: 'credential-id',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // 1 hour in the future
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
providerId: 'google',
|
||||
}
|
||||
|
||||
@@ -142,7 +137,7 @@ describe('OAuth Utils', () => {
|
||||
id: 'credential-id',
|
||||
accessToken: 'expired-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // 1 hour in the past
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
|
||||
providerId: 'google',
|
||||
}
|
||||
|
||||
@@ -155,8 +150,8 @@ describe('OAuth Utils', () => {
|
||||
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
|
||||
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
expect(mockDb.set).toHaveBeenCalled()
|
||||
expect(mockDbTyped.update).toHaveBeenCalled()
|
||||
expect(mockDbTyped.set).toHaveBeenCalled()
|
||||
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
|
||||
})
|
||||
|
||||
@@ -165,7 +160,7 @@ describe('OAuth Utils', () => {
|
||||
id: 'credential-id',
|
||||
accessToken: 'expired-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // 1 hour in the past
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
|
||||
providerId: 'google',
|
||||
}
|
||||
|
||||
@@ -181,7 +176,7 @@ describe('OAuth Utils', () => {
|
||||
id: 'credential-id',
|
||||
accessToken: 'token',
|
||||
refreshToken: null,
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // 1 hour in the past
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
|
||||
providerId: 'google',
|
||||
}
|
||||
|
||||
@@ -198,11 +193,11 @@ describe('OAuth Utils', () => {
|
||||
id: 'credential-id',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // 1 hour in the future
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockDb.limit.mockReturnValueOnce([mockCredential])
|
||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
|
||||
@@ -215,11 +210,11 @@ describe('OAuth Utils', () => {
|
||||
id: 'credential-id',
|
||||
accessToken: 'expired-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // 1 hour in the past
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockDb.limit.mockReturnValueOnce([mockCredential])
|
||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||
|
||||
mockRefreshOAuthToken.mockResolvedValueOnce({
|
||||
accessToken: 'new-token',
|
||||
@@ -230,13 +225,13 @@ describe('OAuth Utils', () => {
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
expect(mockDb.set).toHaveBeenCalled()
|
||||
expect(mockDbTyped.update).toHaveBeenCalled()
|
||||
expect(mockDbTyped.set).toHaveBeenCalled()
|
||||
expect(token).toBe('new-token')
|
||||
})
|
||||
|
||||
it('should return null if credential not found', async () => {
|
||||
mockDb.limit.mockReturnValueOnce([])
|
||||
mockDbTyped.limit.mockReturnValueOnce([])
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
|
||||
|
||||
@@ -248,11 +243,11 @@ describe('OAuth Utils', () => {
|
||||
id: 'credential-id',
|
||||
accessToken: 'expired-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // 1 hour in the past
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockDb.limit.mockReturnValueOnce([mockCredential])
|
||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||
|
||||
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
||||
|
||||
|
||||
361
apps/sim/app/api/copilot/api-keys/route.test.ts
Normal file
361
apps/sim/app/api/copilot/api-keys/route.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Tests for copilot api-keys API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Copilot API Keys API Route', () => {
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setupCommonApiMocks()
|
||||
mockCryptoUuid()
|
||||
|
||||
global.fetch = mockFetch
|
||||
|
||||
vi.doMock('@/lib/copilot/constants', () => ({
|
||||
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: null,
|
||||
COPILOT_API_KEY: 'test-api-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setUnauthenticated()
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return list of API keys with masked values', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
const mockApiKeys = [
|
||||
{
|
||||
id: 'key-1',
|
||||
apiKey: 'sk-sim-abcdefghijklmnopqrstuv',
|
||||
name: 'Production Key',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
lastUsed: '2024-01-15T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'key-2',
|
||||
apiKey: 'sk-sim-zyxwvutsrqponmlkjihgfe',
|
||||
name: null,
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
lastUsed: null,
|
||||
},
|
||||
]
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockApiKeys),
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.keys).toHaveLength(2)
|
||||
expect(responseData.keys[0].id).toBe('key-1')
|
||||
expect(responseData.keys[0].displayKey).toBe('•••••qrstuv')
|
||||
expect(responseData.keys[0].name).toBe('Production Key')
|
||||
expect(responseData.keys[1].displayKey).toBe('•••••jihgfe')
|
||||
expect(responseData.keys[1].name).toBeNull()
|
||||
})
|
||||
|
||||
it('should return empty array when user has no API keys', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.keys).toEqual([])
|
||||
})
|
||||
|
||||
it('should forward userId to Sim Agent', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
await GET(request)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://agent.sim.example.com/api/validate-key/get-api-keys',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-api-key',
|
||||
}),
|
||||
body: JSON.stringify({ userId: 'user-123' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when Sim Agent returns non-ok response', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: () => Promise.resolve({ error: 'Service unavailable' }),
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Failed to get keys' })
|
||||
})
|
||||
|
||||
it('should return 500 when Sim Agent returns invalid response', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ invalid: 'response' }),
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
|
||||
})
|
||||
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Failed to get keys' })
|
||||
})
|
||||
|
||||
it('should handle API keys with empty apiKey string', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
const mockApiKeys = [
|
||||
{
|
||||
id: 'key-1',
|
||||
apiKey: '',
|
||||
name: 'Empty Key',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
lastUsed: null,
|
||||
},
|
||||
]
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockApiKeys),
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.keys[0].displayKey).toBe('•••••')
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors from Sim Agent', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setUnauthenticated()
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
|
||||
const response = await DELETE(request)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 400 when id parameter is missing', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
|
||||
const response = await DELETE(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'id is required' })
|
||||
})
|
||||
|
||||
it('should successfully delete an API key', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
|
||||
const response = await DELETE(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: true })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://agent.sim.example.com/api/validate-key/delete',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-api-key',
|
||||
}),
|
||||
body: JSON.stringify({ userId: 'user-123', apiKeyId: 'key-123' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when Sim Agent returns non-ok response', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({ error: 'Key not found' }),
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=non-existent')
|
||||
const response = await DELETE(request)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Failed to delete key' })
|
||||
})
|
||||
|
||||
it('should return 500 when Sim Agent returns invalid response', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: false }),
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
|
||||
const response = await DELETE(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
|
||||
})
|
||||
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
|
||||
const response = await DELETE(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Failed to delete key' })
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors from Sim Agent on delete', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/api-keys/route')
|
||||
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
|
||||
const response = await DELETE(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
|
||||
})
|
||||
})
|
||||
})
|
||||
189
apps/sim/app/api/copilot/chat/delete/route.test.ts
Normal file
189
apps/sim/app/api/copilot/chat/delete/route.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Tests for copilot chat delete API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
mockAuth,
|
||||
mockCryptoUuid,
|
||||
setupCommonApiMocks,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Copilot Chat Delete API Route', () => {
|
||||
const mockDelete = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setupCommonApiMocks()
|
||||
mockCryptoUuid()
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
delete: mockDelete,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
copilotChats: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setUnauthenticated()
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: false, error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should successfully delete a chat', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockWhere.mockResolvedValueOnce([{ id: 'chat-123' }])
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: true })
|
||||
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 500 for invalid request body - missing chatId', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
const req = createMockRequest('DELETE', {})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to delete chat')
|
||||
})
|
||||
|
||||
it('should return 500 for invalid request body - chatId is not a string', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 12345,
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to delete chat')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: false, error: 'Failed to delete chat' })
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors in request body', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/chat/delete', {
|
||||
method: 'DELETE',
|
||||
body: '{invalid-json',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to delete chat')
|
||||
})
|
||||
|
||||
it('should delete chat even if it does not exist (idempotent)', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
mockWhere.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'non-existent-chat',
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('should delete chat with empty string chatId (validation should fail)', async () => {
|
||||
const authMocks = mockAuth()
|
||||
authMocks.setAuthenticated()
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: '',
|
||||
})
|
||||
|
||||
const { DELETE } = await import('@/app/api/copilot/chat/delete/route')
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
277
apps/sim/app/api/copilot/chats/route.test.ts
Normal file
277
apps/sim/app/api/copilot/chats/route.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Tests for copilot chats list API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mockCryptoUuid, setupCommonApiMocks } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Copilot Chats List API Route', () => {
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
const mockOrderBy = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setupCommonApiMocks()
|
||||
mockCryptoUuid()
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ orderBy: mockOrderBy })
|
||||
mockOrderBy.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
copilotChats: {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
workflowId: 'workflowId',
|
||||
userId: 'userId',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions) => ({ conditions, type: 'and' })),
|
||||
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
|
||||
desc: vi.fn((field) => ({ field, type: 'desc' })),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/copilot/request-helpers', () => ({
|
||||
authenticateCopilotRequestSessionOnly: vi.fn(),
|
||||
createUnauthorizedResponse: vi
|
||||
.fn()
|
||||
.mockReturnValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })),
|
||||
createInternalServerErrorResponse: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(message) => new Response(JSON.stringify({ error: message }), { status: 500 })
|
||||
),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return empty chats array when user has no chats', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockOrderBy.mockResolvedValueOnce([])
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
chats: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should return list of chats for authenticated user', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'chat-1',
|
||||
title: 'First Chat',
|
||||
workflowId: 'workflow-1',
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
},
|
||||
{
|
||||
id: 'chat-2',
|
||||
title: 'Second Chat',
|
||||
workflowId: 'workflow-2',
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
},
|
||||
]
|
||||
mockOrderBy.mockResolvedValueOnce(mockChats)
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.chats).toHaveLength(2)
|
||||
expect(responseData.chats[0].id).toBe('chat-1')
|
||||
expect(responseData.chats[0].title).toBe('First Chat')
|
||||
expect(responseData.chats[1].id).toBe('chat-2')
|
||||
})
|
||||
|
||||
it('should return chats ordered by updatedAt descending', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'newest-chat',
|
||||
title: 'Newest',
|
||||
workflowId: 'workflow-1',
|
||||
updatedAt: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
id: 'older-chat',
|
||||
title: 'Older',
|
||||
workflowId: 'workflow-2',
|
||||
updatedAt: new Date('2024-01-05'),
|
||||
},
|
||||
{
|
||||
id: 'oldest-chat',
|
||||
title: 'Oldest',
|
||||
workflowId: 'workflow-3',
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
},
|
||||
]
|
||||
mockOrderBy.mockResolvedValueOnce(mockChats)
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.chats[0].id).toBe('newest-chat')
|
||||
expect(responseData.chats[2].id).toBe('oldest-chat')
|
||||
})
|
||||
|
||||
it('should handle chats with null workflowId', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'chat-no-workflow',
|
||||
title: 'Chat without workflow',
|
||||
workflowId: null,
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
},
|
||||
]
|
||||
mockOrderBy.mockResolvedValueOnce(mockChats)
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.chats[0].workflowId).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockOrderBy.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to fetch user chats')
|
||||
})
|
||||
|
||||
it('should only return chats belonging to authenticated user', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'my-chat',
|
||||
title: 'My Chat',
|
||||
workflowId: 'workflow-1',
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
},
|
||||
]
|
||||
mockOrderBy.mockResolvedValueOnce(mockChats)
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
await GET(request as any)
|
||||
|
||||
expect(mockSelect).toHaveBeenCalled()
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 when userId is null despite isAuthenticated being true', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: null,
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/chats/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/chats')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
})
|
||||
})
|
||||
516
apps/sim/app/api/copilot/feedback/route.test.ts
Normal file
516
apps/sim/app/api/copilot/feedback/route.test.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* Tests for copilot feedback API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
mockCryptoUuid,
|
||||
setupCommonApiMocks,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Copilot Feedback API Route', () => {
|
||||
const mockInsert = vi.fn()
|
||||
const mockValues = vi.fn()
|
||||
const mockReturning = vi.fn()
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setupCommonApiMocks()
|
||||
mockCryptoUuid()
|
||||
|
||||
mockInsert.mockReturnValue({ values: mockValues })
|
||||
mockValues.mockReturnValue({ returning: mockReturning })
|
||||
mockReturning.mockResolvedValue([])
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
insert: mockInsert,
|
||||
select: mockSelect,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
copilotFeedback: {
|
||||
feedbackId: 'feedbackId',
|
||||
userId: 'userId',
|
||||
chatId: 'chatId',
|
||||
userQuery: 'userQuery',
|
||||
agentResponse: 'agentResponse',
|
||||
isPositive: 'isPositive',
|
||||
feedback: 'feedback',
|
||||
workflowYaml: 'workflowYaml',
|
||||
createdAt: 'createdAt',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/copilot/request-helpers', () => ({
|
||||
authenticateCopilotRequestSessionOnly: vi.fn(),
|
||||
createUnauthorizedResponse: vi
|
||||
.fn()
|
||||
.mockReturnValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })),
|
||||
createBadRequestResponse: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(message) => new Response(JSON.stringify({ error: message }), { status: 400 })
|
||||
),
|
||||
createInternalServerErrorResponse: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(message) => new Response(JSON.stringify({ error: message }), { status: 500 })
|
||||
),
|
||||
createRequestTracker: vi.fn().mockReturnValue({
|
||||
requestId: 'test-request-id',
|
||||
getDuration: vi.fn().mockReturnValue(100),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I create a workflow?',
|
||||
agentResponse: 'You can create a workflow by...',
|
||||
isPositiveFeedback: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should successfully submit positive feedback', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const feedbackRecord = {
|
||||
feedbackId: 'feedback-123',
|
||||
userId: 'user-123',
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I create a workflow?',
|
||||
agentResponse: 'You can create a workflow by...',
|
||||
isPositive: true,
|
||||
feedback: null,
|
||||
workflowYaml: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
}
|
||||
mockReturning.mockResolvedValueOnce([feedbackRecord])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I create a workflow?',
|
||||
agentResponse: 'You can create a workflow by...',
|
||||
isPositiveFeedback: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.feedbackId).toBe('feedback-123')
|
||||
expect(responseData.message).toBe('Feedback submitted successfully')
|
||||
})
|
||||
|
||||
it('should successfully submit negative feedback with text', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const feedbackRecord = {
|
||||
feedbackId: 'feedback-456',
|
||||
userId: 'user-123',
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I deploy?',
|
||||
agentResponse: 'Here is how to deploy...',
|
||||
isPositive: false,
|
||||
feedback: 'The response was not helpful',
|
||||
workflowYaml: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
}
|
||||
mockReturning.mockResolvedValueOnce([feedbackRecord])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I deploy?',
|
||||
agentResponse: 'Here is how to deploy...',
|
||||
isPositiveFeedback: false,
|
||||
feedback: 'The response was not helpful',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.feedbackId).toBe('feedback-456')
|
||||
})
|
||||
|
||||
it('should successfully submit feedback with workflow YAML', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const workflowYaml = `
|
||||
blocks:
|
||||
- id: starter
|
||||
type: starter
|
||||
- id: agent
|
||||
type: agent
|
||||
edges:
|
||||
- source: starter
|
||||
target: agent
|
||||
`
|
||||
|
||||
const feedbackRecord = {
|
||||
feedbackId: 'feedback-789',
|
||||
userId: 'user-123',
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'Build a simple agent workflow',
|
||||
agentResponse: 'I created a workflow for you.',
|
||||
isPositive: true,
|
||||
feedback: null,
|
||||
workflowYaml: workflowYaml,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
}
|
||||
mockReturning.mockResolvedValueOnce([feedbackRecord])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'Build a simple agent workflow',
|
||||
agentResponse: 'I created a workflow for you.',
|
||||
isPositiveFeedback: true,
|
||||
workflowYaml: workflowYaml,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowYaml: workflowYaml,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return 400 for invalid chatId format', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'not-a-uuid',
|
||||
userQuery: 'How do I create a workflow?',
|
||||
agentResponse: 'You can create a workflow by...',
|
||||
isPositiveFeedback: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toContain('Invalid request data')
|
||||
})
|
||||
|
||||
it('should return 400 for empty userQuery', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: '',
|
||||
agentResponse: 'You can create a workflow by...',
|
||||
isPositiveFeedback: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toContain('Invalid request data')
|
||||
})
|
||||
|
||||
it('should return 400 for empty agentResponse', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I create a workflow?',
|
||||
agentResponse: '',
|
||||
isPositiveFeedback: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toContain('Invalid request data')
|
||||
})
|
||||
|
||||
it('should return 400 for missing isPositiveFeedback', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I create a workflow?',
|
||||
agentResponse: 'You can create a workflow by...',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toContain('Invalid request data')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockReturning.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userQuery: 'How do I create a workflow?',
|
||||
agentResponse: 'You can create a workflow by...',
|
||||
isPositiveFeedback: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to submit feedback')
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors in request body', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/feedback', {
|
||||
method: 'POST',
|
||||
body: '{invalid-json',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/feedback/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/feedback/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return empty feedback array when no feedback exists', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/feedback/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.feedback).toEqual([])
|
||||
})
|
||||
|
||||
it('should return all feedback records', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const mockFeedback = [
|
||||
{
|
||||
feedbackId: 'feedback-1',
|
||||
userId: 'user-123',
|
||||
chatId: 'chat-1',
|
||||
userQuery: 'Query 1',
|
||||
agentResponse: 'Response 1',
|
||||
isPositive: true,
|
||||
feedback: null,
|
||||
workflowYaml: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
},
|
||||
{
|
||||
feedbackId: 'feedback-2',
|
||||
userId: 'user-456',
|
||||
chatId: 'chat-2',
|
||||
userQuery: 'Query 2',
|
||||
agentResponse: 'Response 2',
|
||||
isPositive: false,
|
||||
feedback: 'Not helpful',
|
||||
workflowYaml: 'yaml: content',
|
||||
createdAt: new Date('2024-01-02'),
|
||||
},
|
||||
]
|
||||
mockFrom.mockResolvedValueOnce(mockFeedback)
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/feedback/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.feedback).toHaveLength(2)
|
||||
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
|
||||
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/feedback/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to retrieve feedback')
|
||||
})
|
||||
|
||||
it('should return metadata with response', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
|
||||
const { GET } = await import('@/app/api/copilot/feedback/route')
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.metadata).toBeDefined()
|
||||
expect(responseData.metadata.requestId).toBeDefined()
|
||||
expect(responseData.metadata.duration).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
367
apps/sim/app/api/copilot/stats/route.test.ts
Normal file
367
apps/sim/app/api/copilot/stats/route.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tests for copilot stats API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
mockCryptoUuid,
|
||||
setupCommonApiMocks,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Copilot Stats API Route', () => {
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setupCommonApiMocks()
|
||||
mockCryptoUuid()
|
||||
|
||||
global.fetch = mockFetch
|
||||
|
||||
vi.doMock('@/lib/copilot/request-helpers', () => ({
|
||||
authenticateCopilotRequestSessionOnly: vi.fn(),
|
||||
createUnauthorizedResponse: vi
|
||||
.fn()
|
||||
.mockReturnValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })),
|
||||
createBadRequestResponse: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(message) => new Response(JSON.stringify({ error: message }), { status: 400 })
|
||||
),
|
||||
createInternalServerErrorResponse: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(message) => new Response(JSON.stringify({ error: message }), { status: 500 })
|
||||
),
|
||||
createRequestTracker: vi.fn().mockReturnValue({
|
||||
requestId: 'test-request-id',
|
||||
getDuration: vi.fn().mockReturnValue(100),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/copilot/constants', () => ({
|
||||
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: null,
|
||||
COPILOT_API_KEY: 'test-api-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-123',
|
||||
diffCreated: true,
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should successfully forward stats to Sim Agent', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-123',
|
||||
diffCreated: true,
|
||||
diffAccepted: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: true })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://agent.sim.example.com/api/stats',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-api-key',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
messageId: 'message-123',
|
||||
diffCreated: true,
|
||||
diffAccepted: true,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing messageId', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
diffCreated: true,
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Invalid request body for copilot stats')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing diffCreated', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-123',
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Invalid request body for copilot stats')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing diffAccepted', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-123',
|
||||
diffCreated: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Invalid request body for copilot stats')
|
||||
})
|
||||
|
||||
it('should return 400 when upstream Sim Agent returns error', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: 'Invalid message ID' }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'invalid-message',
|
||||
diffCreated: true,
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: false, error: 'Invalid message ID' })
|
||||
})
|
||||
|
||||
it('should handle upstream error with message field', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Rate limit exceeded' }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-123',
|
||||
diffCreated: true,
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: false, error: 'Rate limit exceeded' })
|
||||
})
|
||||
|
||||
it('should handle upstream error with no JSON response', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.reject(new Error('Not JSON')),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-123',
|
||||
diffCreated: true,
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: false, error: 'Upstream error' })
|
||||
})
|
||||
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-123',
|
||||
diffCreated: true,
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to forward copilot stats')
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors in request body', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/stats', {
|
||||
method: 'POST',
|
||||
body: '{invalid-json',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Invalid request body for copilot stats')
|
||||
})
|
||||
|
||||
it('should forward stats with diffCreated=false and diffAccepted=false', async () => {
|
||||
const { authenticateCopilotRequestSessionOnly } = await import(
|
||||
'@/lib/copilot/request-helpers'
|
||||
)
|
||||
vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messageId: 'message-456',
|
||||
diffCreated: false,
|
||||
diffAccepted: false,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/copilot/stats/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
messageId: 'message-456',
|
||||
diffCreated: false,
|
||||
diffAccepted: false,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,7 @@ export async function GET(
|
||||
|
||||
const payload = run.payload as any
|
||||
if (payload?.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
|
||||
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
|
||||
const accessCheck = await verifyWorkflowAccess(authenticatedUserId, payload.workflowId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
logger.warn(`[${requestId}] User ${authenticatedUserId} denied access to task ${taskId}`, {
|
||||
|
||||
@@ -169,7 +169,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
if (creatorId !== undefined) updateData.creatorId = creatorId
|
||||
|
||||
if (updateState && template.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
|
||||
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
|
||||
const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess(
|
||||
session.user.id,
|
||||
template.workflowId
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
@@ -176,6 +178,8 @@ vi.mock('drizzle-orm/postgres-js', () => ({
|
||||
|
||||
vi.mock('postgres', () => vi.fn().mockReturnValue({}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
|
||||
|
||||
import { POST } from '@/app/api/webhooks/trigger/[path]/route'
|
||||
@@ -257,9 +261,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
expect(data.message).toBe('Webhook processed')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test generic webhook with Bearer token authentication
|
||||
*/
|
||||
it('should authenticate with Bearer token when no custom header is configured', async () => {
|
||||
globalMockData.webhooks.push({
|
||||
id: 'generic-webhook-id',
|
||||
@@ -489,7 +490,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer exclusive-token', // Correct token but wrong header type
|
||||
Authorization: 'Bearer exclusive-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
@@ -517,7 +518,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Wrong-Header': 'correct-token', // Correct token but wrong header name
|
||||
'X-Wrong-Header': 'correct-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -59,14 +60,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
mockHasWorkspaceAdminAccess(userId, workspaceId),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
|
||||
@@ -127,9 +121,14 @@ const mockUser = {
|
||||
name: 'Test User',
|
||||
}
|
||||
|
||||
const mockWorkspace = {
|
||||
const mockWorkspaceData = createWorkspaceRecord({
|
||||
id: 'workspace-456',
|
||||
name: 'Test Workspace',
|
||||
})
|
||||
|
||||
const mockWorkspace = {
|
||||
id: mockWorkspaceData.id,
|
||||
name: mockWorkspaceData.name,
|
||||
}
|
||||
|
||||
const mockInvitation = {
|
||||
@@ -140,7 +139,7 @@ const mockInvitation = {
|
||||
status: 'pending',
|
||||
token: 'token-abc123',
|
||||
permissions: 'read',
|
||||
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
|
||||
expiresAt: new Date(Date.now() + 86400000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
@@ -154,7 +153,8 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
|
||||
describe('GET /api/workspaces/invitations/[invitationId]', () => {
|
||||
it('should return invitation details when called without token', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
@@ -202,15 +202,18 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should accept invitation when called with valid token', async () => {
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'invited@example.com' },
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
|
||||
dbSelectResults = [
|
||||
[mockInvitation], // invitation lookup
|
||||
[mockWorkspace], // workspace lookup
|
||||
[{ ...mockUser, email: 'invited@example.com' }], // user lookup
|
||||
[], // existing permission check (empty = no existing)
|
||||
[mockInvitation],
|
||||
[mockWorkspace],
|
||||
[{ ...mockUser, email: 'invited@example.com' }],
|
||||
[],
|
||||
]
|
||||
|
||||
const request = new NextRequest(
|
||||
@@ -225,13 +228,16 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should redirect to error page when invitation expired', async () => {
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'invited@example.com' },
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
|
||||
const expiredInvitation = {
|
||||
...mockInvitation,
|
||||
expiresAt: new Date(Date.now() - 86400000), // 1 day ago
|
||||
expiresAt: new Date(Date.now() - 86400000),
|
||||
}
|
||||
|
||||
dbSelectResults = [[expiredInvitation], [mockWorkspace]]
|
||||
@@ -250,9 +256,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should redirect to error page when email mismatch', async () => {
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'wrong@example.com' },
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'wrong@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
|
||||
dbSelectResults = [
|
||||
[mockInvitation],
|
||||
@@ -274,8 +283,9 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should return 404 when invitation not found', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
dbSelectResults = [[]] // Empty result
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
dbSelectResults = [[]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent')
|
||||
const params = Promise.resolve({ invitationId: 'non-existent' })
|
||||
@@ -306,7 +316,8 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should return 404 when invitation does not exist', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
dbSelectResults = [[]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
|
||||
@@ -322,7 +333,8 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should return 403 when user lacks admin access', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
|
||||
dbSelectResults = [[mockInvitation]]
|
||||
|
||||
@@ -341,7 +353,8 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should return 400 when trying to delete non-pending invitation', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
|
||||
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
|
||||
@@ -361,7 +374,8 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
it('should successfully delete pending invitation when user has admin access', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
dbSelectResults = [[mockInvitation]]
|
||||
|
||||
|
||||
698
apps/sim/blocks/blocks.test.ts
Normal file
698
apps/sim/blocks/blocks.test.ts
Normal file
@@ -0,0 +1,698 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Use the real registry module, not the global mock from vitest.setup.ts
|
||||
vi.unmock('@/blocks/registry')
|
||||
|
||||
import { generateRouterPrompt } from '@/blocks/blocks/router'
|
||||
import {
|
||||
getAllBlocks,
|
||||
getAllBlockTypes,
|
||||
getBlock,
|
||||
getBlockByToolName,
|
||||
getBlocksByCategory,
|
||||
isValidBlockType,
|
||||
registry,
|
||||
} from '@/blocks/registry'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
describe('Blocks Module', () => {
|
||||
describe('Registry', () => {
|
||||
it('should have a non-empty registry of blocks', () => {
|
||||
expect(Object.keys(registry).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have all blocks with required properties', () => {
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
expect(block.type).toBeDefined()
|
||||
expect(typeof block.type).toBe('string')
|
||||
expect(block.name).toBeDefined()
|
||||
expect(typeof block.name).toBe('string')
|
||||
expect(block.description).toBeDefined()
|
||||
expect(typeof block.description).toBe('string')
|
||||
expect(block.category).toBeDefined()
|
||||
expect(['blocks', 'tools', 'triggers']).toContain(block.category)
|
||||
expect(block.bgColor).toBeDefined()
|
||||
expect(typeof block.bgColor).toBe('string')
|
||||
expect(block.bgColor.length).toBeGreaterThan(0)
|
||||
expect(block.icon).toBeDefined()
|
||||
expect(typeof block.icon).toBe('function')
|
||||
expect(block.tools).toBeDefined()
|
||||
expect(block.tools.access).toBeDefined()
|
||||
expect(Array.isArray(block.tools.access)).toBe(true)
|
||||
expect(block.inputs).toBeDefined()
|
||||
expect(typeof block.inputs).toBe('object')
|
||||
expect(block.outputs).toBeDefined()
|
||||
expect(typeof block.outputs).toBe('object')
|
||||
expect(block.subBlocks).toBeDefined()
|
||||
expect(Array.isArray(block.subBlocks)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have unique block types', () => {
|
||||
const types = getAllBlockTypes()
|
||||
const uniqueTypes = new Set(types)
|
||||
expect(types.length).toBe(uniqueTypes.size)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBlock', () => {
|
||||
it('should return a block by type', () => {
|
||||
const block = getBlock('function')
|
||||
expect(block).toBeDefined()
|
||||
expect(block?.type).toBe('function')
|
||||
expect(block?.name).toBe('Function')
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent block type', () => {
|
||||
const block = getBlock('non-existent-block')
|
||||
expect(block).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should normalize hyphens to underscores', () => {
|
||||
const block = getBlock('microsoft-teams')
|
||||
expect(block).toBeDefined()
|
||||
expect(block?.type).toBe('microsoft_teams')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBlockByToolName', () => {
|
||||
it('should find a block by tool name', () => {
|
||||
const block = getBlockByToolName('function_execute')
|
||||
expect(block).toBeDefined()
|
||||
expect(block?.type).toBe('function')
|
||||
})
|
||||
|
||||
it('should find a block with http_request tool', () => {
|
||||
const block = getBlockByToolName('http_request')
|
||||
expect(block).toBeDefined()
|
||||
expect(block?.type).toBe('api')
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent tool name', () => {
|
||||
const block = getBlockByToolName('non_existent_tool')
|
||||
expect(block).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBlocksByCategory', () => {
|
||||
it('should return blocks in the "blocks" category', () => {
|
||||
const blocks = getBlocksByCategory('blocks')
|
||||
expect(blocks.length).toBeGreaterThan(0)
|
||||
for (const block of blocks) {
|
||||
expect(block.category).toBe('blocks')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return blocks in the "tools" category', () => {
|
||||
const blocks = getBlocksByCategory('tools')
|
||||
expect(blocks.length).toBeGreaterThan(0)
|
||||
for (const block of blocks) {
|
||||
expect(block.category).toBe('tools')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return blocks in the "triggers" category', () => {
|
||||
const blocks = getBlocksByCategory('triggers')
|
||||
expect(blocks.length).toBeGreaterThan(0)
|
||||
for (const block of blocks) {
|
||||
expect(block.category).toBe('triggers')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllBlockTypes', () => {
|
||||
it('should return an array of block types', () => {
|
||||
const types = getAllBlockTypes()
|
||||
expect(Array.isArray(types)).toBe(true)
|
||||
expect(types.length).toBeGreaterThan(0)
|
||||
for (const type of types) {
|
||||
expect(typeof type).toBe('string')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidBlockType', () => {
|
||||
it('should return true for valid block types', () => {
|
||||
expect(isValidBlockType('function')).toBe(true)
|
||||
expect(isValidBlockType('agent')).toBe(true)
|
||||
expect(isValidBlockType('condition')).toBe(true)
|
||||
expect(isValidBlockType('api')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid block types', () => {
|
||||
expect(isValidBlockType('invalid-block')).toBe(false)
|
||||
expect(isValidBlockType('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle hyphenated versions of underscored types', () => {
|
||||
expect(isValidBlockType('microsoft-teams')).toBe(true)
|
||||
expect(isValidBlockType('google-calendar')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Block Definitions', () => {
|
||||
describe('FunctionBlock', () => {
|
||||
const block = getBlock('function')
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('function')
|
||||
expect(block?.name).toBe('Function')
|
||||
expect(block?.category).toBe('blocks')
|
||||
expect(block?.bgColor).toBe('#FF402F')
|
||||
})
|
||||
|
||||
it('should have language and code subBlocks', () => {
|
||||
expect(block?.subBlocks.length).toBeGreaterThanOrEqual(1)
|
||||
const languageSubBlock = block?.subBlocks.find((sb) => sb.id === 'language')
|
||||
const codeSubBlock = block?.subBlocks.find((sb) => sb.id === 'code')
|
||||
expect(codeSubBlock).toBeDefined()
|
||||
expect(codeSubBlock?.type).toBe('code')
|
||||
})
|
||||
|
||||
it('should have function_execute tool access', () => {
|
||||
expect(block?.tools.access).toContain('function_execute')
|
||||
})
|
||||
|
||||
it('should have code input', () => {
|
||||
expect(block?.inputs.code).toBeDefined()
|
||||
expect(block?.inputs.code.type).toBe('string')
|
||||
})
|
||||
|
||||
it('should have result and stdout outputs', () => {
|
||||
expect(block?.outputs.result).toBeDefined()
|
||||
expect(block?.outputs.stdout).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConditionBlock', () => {
|
||||
const block = getBlock('condition')
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('condition')
|
||||
expect(block?.name).toBe('Condition')
|
||||
expect(block?.category).toBe('blocks')
|
||||
expect(block?.bgColor).toBe('#FF752F')
|
||||
})
|
||||
|
||||
it('should have condition-input subBlock', () => {
|
||||
const conditionsSubBlock = block?.subBlocks.find((sb) => sb.id === 'conditions')
|
||||
expect(conditionsSubBlock).toBeDefined()
|
||||
expect(conditionsSubBlock?.type).toBe('condition-input')
|
||||
})
|
||||
|
||||
it('should have empty tools access', () => {
|
||||
expect(block?.tools.access).toEqual([])
|
||||
})
|
||||
|
||||
it('should have condition-related outputs', () => {
|
||||
expect(block?.outputs.conditionResult).toBeDefined()
|
||||
expect(block?.outputs.selectedPath).toBeDefined()
|
||||
expect(block?.outputs.selectedOption).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiBlock', () => {
|
||||
const block = getBlock('api')
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('api')
|
||||
expect(block?.name).toBe('API')
|
||||
expect(block?.category).toBe('blocks')
|
||||
expect(block?.bgColor).toBe('#2F55FF')
|
||||
})
|
||||
|
||||
it('should have required url subBlock', () => {
|
||||
const urlSubBlock = block?.subBlocks.find((sb) => sb.id === 'url')
|
||||
expect(urlSubBlock).toBeDefined()
|
||||
expect(urlSubBlock?.type).toBe('short-input')
|
||||
expect(urlSubBlock?.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should have method dropdown with HTTP methods', () => {
|
||||
const methodSubBlock = block?.subBlocks.find((sb) => sb.id === 'method')
|
||||
expect(methodSubBlock).toBeDefined()
|
||||
expect(methodSubBlock?.type).toBe('dropdown')
|
||||
expect(methodSubBlock?.required).toBe(true)
|
||||
const options = methodSubBlock?.options as Array<{ label: string; id: string }>
|
||||
expect(options?.map((o) => o.id)).toContain('GET')
|
||||
expect(options?.map((o) => o.id)).toContain('POST')
|
||||
expect(options?.map((o) => o.id)).toContain('PUT')
|
||||
expect(options?.map((o) => o.id)).toContain('DELETE')
|
||||
expect(options?.map((o) => o.id)).toContain('PATCH')
|
||||
})
|
||||
|
||||
it('should have http_request tool access', () => {
|
||||
expect(block?.tools.access).toContain('http_request')
|
||||
})
|
||||
|
||||
it('should have API-related inputs', () => {
|
||||
expect(block?.inputs.url).toBeDefined()
|
||||
expect(block?.inputs.method).toBeDefined()
|
||||
expect(block?.inputs.headers).toBeDefined()
|
||||
expect(block?.inputs.body).toBeDefined()
|
||||
expect(block?.inputs.params).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have API response outputs', () => {
|
||||
expect(block?.outputs.data).toBeDefined()
|
||||
expect(block?.outputs.status).toBeDefined()
|
||||
expect(block?.outputs.headers).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ResponseBlock', () => {
|
||||
const block = getBlock('response')
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('response')
|
||||
expect(block?.name).toBe('Response')
|
||||
expect(block?.category).toBe('blocks')
|
||||
})
|
||||
|
||||
it('should have dataMode dropdown with builder and editor options', () => {
|
||||
const dataModeSubBlock = block?.subBlocks.find((sb) => sb.id === 'dataMode')
|
||||
expect(dataModeSubBlock).toBeDefined()
|
||||
expect(dataModeSubBlock?.type).toBe('dropdown')
|
||||
const options = dataModeSubBlock?.options as Array<{ label: string; id: string }>
|
||||
expect(options?.map((o) => o.id)).toContain('structured')
|
||||
expect(options?.map((o) => o.id)).toContain('json')
|
||||
})
|
||||
|
||||
it('should have conditional subBlocks based on dataMode', () => {
|
||||
const builderDataSubBlock = block?.subBlocks.find((sb) => sb.id === 'builderData')
|
||||
const dataSubBlock = block?.subBlocks.find((sb) => sb.id === 'data')
|
||||
|
||||
expect(builderDataSubBlock?.condition).toEqual({ field: 'dataMode', value: 'structured' })
|
||||
expect(dataSubBlock?.condition).toEqual({ field: 'dataMode', value: 'json' })
|
||||
})
|
||||
|
||||
it('should have empty tools access', () => {
|
||||
expect(block?.tools.access).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('StarterBlock', () => {
|
||||
const block = getBlock('starter')
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('starter')
|
||||
expect(block?.name).toBe('Starter')
|
||||
expect(block?.category).toBe('blocks')
|
||||
expect(block?.hideFromToolbar).toBe(true)
|
||||
})
|
||||
|
||||
it('should have startWorkflow dropdown', () => {
|
||||
const startWorkflowSubBlock = block?.subBlocks.find((sb) => sb.id === 'startWorkflow')
|
||||
expect(startWorkflowSubBlock).toBeDefined()
|
||||
expect(startWorkflowSubBlock?.type).toBe('dropdown')
|
||||
const options = startWorkflowSubBlock?.options as Array<{ label: string; id: string }>
|
||||
expect(options?.map((o) => o.id)).toContain('manual')
|
||||
expect(options?.map((o) => o.id)).toContain('chat')
|
||||
})
|
||||
|
||||
it('should have empty outputs since it initiates workflow', () => {
|
||||
expect(Object.keys(block?.outputs || {}).length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RouterBlock', () => {
|
||||
const block = getBlock('router')
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('router')
|
||||
expect(block?.name).toBe('Router')
|
||||
expect(block?.category).toBe('blocks')
|
||||
expect(block?.authMode).toBe(AuthMode.ApiKey)
|
||||
})
|
||||
|
||||
it('should have required prompt subBlock', () => {
|
||||
const promptSubBlock = block?.subBlocks.find((sb) => sb.id === 'prompt')
|
||||
expect(promptSubBlock).toBeDefined()
|
||||
expect(promptSubBlock?.type).toBe('long-input')
|
||||
expect(promptSubBlock?.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should have model combobox with default value', () => {
|
||||
const modelSubBlock = block?.subBlocks.find((sb) => sb.id === 'model')
|
||||
expect(modelSubBlock).toBeDefined()
|
||||
expect(modelSubBlock?.type).toBe('combobox')
|
||||
expect(modelSubBlock?.required).toBe(true)
|
||||
expect(modelSubBlock?.defaultValue).toBe('claude-sonnet-4-5')
|
||||
})
|
||||
|
||||
it('should have LLM tool access', () => {
|
||||
expect(block?.tools.access).toContain('openai_chat')
|
||||
expect(block?.tools.access).toContain('anthropic_chat')
|
||||
expect(block?.tools.access).toContain('google_chat')
|
||||
})
|
||||
|
||||
it('should have tools.config with tool selector function', () => {
|
||||
expect(block?.tools.config).toBeDefined()
|
||||
expect(typeof block?.tools.config?.tool).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebhookBlock', () => {
|
||||
const block = getBlock('webhook')
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(block?.type).toBe('webhook')
|
||||
expect(block?.name).toBe('Webhook')
|
||||
expect(block?.category).toBe('triggers')
|
||||
expect(block?.authMode).toBe(AuthMode.OAuth)
|
||||
expect(block?.triggerAllowed).toBe(true)
|
||||
expect(block?.hideFromToolbar).toBe(true)
|
||||
})
|
||||
|
||||
it('should have webhookProvider dropdown with multiple providers', () => {
|
||||
const providerSubBlock = block?.subBlocks.find((sb) => sb.id === 'webhookProvider')
|
||||
expect(providerSubBlock).toBeDefined()
|
||||
expect(providerSubBlock?.type).toBe('dropdown')
|
||||
const options = providerSubBlock?.options as Array<{ label: string; id: string }>
|
||||
expect(options?.map((o) => o.id)).toContain('slack')
|
||||
expect(options?.map((o) => o.id)).toContain('generic')
|
||||
expect(options?.map((o) => o.id)).toContain('github')
|
||||
})
|
||||
|
||||
it('should have conditional OAuth inputs', () => {
|
||||
const gmailCredentialSubBlock = block?.subBlocks.find((sb) => sb.id === 'gmailCredential')
|
||||
expect(gmailCredentialSubBlock).toBeDefined()
|
||||
expect(gmailCredentialSubBlock?.type).toBe('oauth-input')
|
||||
expect(gmailCredentialSubBlock?.condition).toEqual({
|
||||
field: 'webhookProvider',
|
||||
value: 'gmail',
|
||||
})
|
||||
|
||||
const outlookCredentialSubBlock = block?.subBlocks.find(
|
||||
(sb) => sb.id === 'outlookCredential'
|
||||
)
|
||||
expect(outlookCredentialSubBlock).toBeDefined()
|
||||
expect(outlookCredentialSubBlock?.type).toBe('oauth-input')
|
||||
expect(outlookCredentialSubBlock?.condition).toEqual({
|
||||
field: 'webhookProvider',
|
||||
value: 'outlook',
|
||||
})
|
||||
})
|
||||
|
||||
it('should have empty tools access', () => {
|
||||
expect(block?.tools.access).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubBlock Validation', () => {
|
||||
it('should have non-empty ids for all subBlocks', () => {
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
for (const subBlock of block.subBlocks) {
|
||||
expect(subBlock.id).toBeDefined()
|
||||
expect(typeof subBlock.id).toBe('string')
|
||||
expect(subBlock.id.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should have valid subBlock types', () => {
|
||||
const validTypes = [
|
||||
'short-input',
|
||||
'long-input',
|
||||
'dropdown',
|
||||
'combobox',
|
||||
'slider',
|
||||
'table',
|
||||
'code',
|
||||
'switch',
|
||||
'tool-input',
|
||||
'checkbox-list',
|
||||
'grouped-checkbox-list',
|
||||
'condition-input',
|
||||
'eval-input',
|
||||
'time-input',
|
||||
'oauth-input',
|
||||
'webhook-config',
|
||||
'schedule-info',
|
||||
'file-selector',
|
||||
'project-selector',
|
||||
'channel-selector',
|
||||
'user-selector',
|
||||
'folder-selector',
|
||||
'knowledge-base-selector',
|
||||
'knowledge-tag-filters',
|
||||
'document-selector',
|
||||
'document-tag-entry',
|
||||
'mcp-server-selector',
|
||||
'mcp-tool-selector',
|
||||
'mcp-dynamic-args',
|
||||
'input-format',
|
||||
'response-format',
|
||||
'trigger-save',
|
||||
'file-upload',
|
||||
'input-mapping',
|
||||
'variables-input',
|
||||
'messages-input',
|
||||
'workflow-selector',
|
||||
'workflow-input-mapper',
|
||||
'text',
|
||||
]
|
||||
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
for (const subBlock of block.subBlocks) {
|
||||
expect(validTypes).toContain(subBlock.type)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should have valid mode values for subBlocks', () => {
|
||||
const validModes = ['basic', 'advanced', 'both', 'trigger', undefined]
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
for (const subBlock of block.subBlocks) {
|
||||
expect(validModes).toContain(subBlock.mode)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input/Output Validation', () => {
|
||||
it('should have valid input types', () => {
|
||||
const validTypes = ['string', 'number', 'boolean', 'json', 'array']
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
for (const [_, inputConfig] of Object.entries(block.inputs)) {
|
||||
expect(validTypes).toContain(inputConfig.type)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should have valid output types', () => {
|
||||
const validPrimitiveTypes = ['string', 'number', 'boolean', 'json', 'array', 'files', 'any']
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
for (const [key, outputConfig] of Object.entries(block.outputs)) {
|
||||
if (key === 'visualization') continue
|
||||
if (typeof outputConfig === 'string') {
|
||||
expect(validPrimitiveTypes).toContain(outputConfig)
|
||||
} else if (typeof outputConfig === 'object' && outputConfig !== null) {
|
||||
if ('type' in outputConfig) {
|
||||
expect(validPrimitiveTypes).toContain(outputConfig.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthMode Validation', () => {
|
||||
it('should have valid authMode when defined', () => {
|
||||
const validAuthModes = [AuthMode.OAuth, AuthMode.ApiKey, AuthMode.BotToken, undefined]
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
expect(validAuthModes).toContain(block.authMode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle blocks with no inputs', () => {
|
||||
const conditionBlock = getBlock('condition')
|
||||
expect(conditionBlock?.inputs).toBeDefined()
|
||||
expect(Object.keys(conditionBlock?.inputs || {}).length).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle blocks with no outputs', () => {
|
||||
const starterBlock = getBlock('starter')
|
||||
expect(starterBlock?.outputs).toBeDefined()
|
||||
expect(Object.keys(starterBlock?.outputs || {}).length).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle blocks with no tool access', () => {
|
||||
const conditionBlock = getBlock('condition')
|
||||
expect(conditionBlock?.tools.access).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle blocks with multiple tool access', () => {
|
||||
const routerBlock = getBlock('router')
|
||||
expect(routerBlock?.tools.access.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should handle blocks with tools.config', () => {
|
||||
const routerBlock = getBlock('router')
|
||||
expect(routerBlock?.tools.config).toBeDefined()
|
||||
expect(typeof routerBlock?.tools.config?.tool).toBe('function')
|
||||
})
|
||||
|
||||
it('should handle blocks with triggerAllowed flag', () => {
|
||||
const webhookBlock = getBlock('webhook')
|
||||
expect(webhookBlock?.triggerAllowed).toBe(true)
|
||||
|
||||
const functionBlock = getBlock('function')
|
||||
expect(functionBlock?.triggerAllowed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle blocks with hideFromToolbar flag', () => {
|
||||
const starterBlock = getBlock('starter')
|
||||
expect(starterBlock?.hideFromToolbar).toBe(true)
|
||||
|
||||
const functionBlock = getBlock('function')
|
||||
expect(functionBlock?.hideFromToolbar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle blocks with docsLink', () => {
|
||||
const functionBlock = getBlock('function')
|
||||
expect(functionBlock?.docsLink).toBe('https://docs.sim.ai/blocks/function')
|
||||
|
||||
const apiBlock = getBlock('api')
|
||||
expect(apiBlock?.docsLink).toBe('https://docs.sim.ai/blocks/api')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateRouterPrompt', () => {
|
||||
it('should generate a base prompt with routing instructions', () => {
|
||||
const prompt = generateRouterPrompt('Route to the correct agent')
|
||||
expect(prompt).toContain('You are an intelligent routing agent')
|
||||
expect(prompt).toContain('Route to the correct agent')
|
||||
expect(prompt).toContain('Response Format')
|
||||
})
|
||||
|
||||
it('should include target blocks information when provided', () => {
|
||||
const targetBlocks = [
|
||||
{
|
||||
id: 'block-1',
|
||||
type: 'agent',
|
||||
title: 'Customer Support Agent',
|
||||
description: 'Handles customer inquiries',
|
||||
subBlocks: { systemPrompt: 'You are a helpful customer support agent.' },
|
||||
},
|
||||
{
|
||||
id: 'block-2',
|
||||
type: 'agent',
|
||||
title: 'Sales Agent',
|
||||
description: 'Handles sales inquiries',
|
||||
subBlocks: { systemPrompt: 'You are a sales agent.' },
|
||||
},
|
||||
]
|
||||
|
||||
const prompt = generateRouterPrompt('Route to the correct agent', targetBlocks)
|
||||
|
||||
expect(prompt).toContain('Available Target Blocks')
|
||||
expect(prompt).toContain('block-1')
|
||||
expect(prompt).toContain('Customer Support Agent')
|
||||
expect(prompt).toContain('block-2')
|
||||
expect(prompt).toContain('Sales Agent')
|
||||
})
|
||||
|
||||
it('should include current state when provided', () => {
|
||||
const targetBlocks = [
|
||||
{
|
||||
id: 'block-1',
|
||||
type: 'agent',
|
||||
title: 'Agent',
|
||||
currentState: { status: 'active', count: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
const prompt = generateRouterPrompt('Route based on state', targetBlocks)
|
||||
|
||||
expect(prompt).toContain('Current State')
|
||||
expect(prompt).toContain('active')
|
||||
expect(prompt).toContain('5')
|
||||
})
|
||||
|
||||
it('should handle empty target blocks array', () => {
|
||||
const prompt = generateRouterPrompt('Route to agent', [])
|
||||
expect(prompt).toContain('You are an intelligent routing agent')
|
||||
expect(prompt).toContain('Route to agent')
|
||||
})
|
||||
|
||||
it('should handle empty prompt string', () => {
|
||||
const prompt = generateRouterPrompt('')
|
||||
expect(prompt).toContain('You are an intelligent routing agent')
|
||||
expect(prompt).toContain('Routing Request:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Block Category Counts', () => {
|
||||
it('should have more blocks in tools category than triggers', () => {
|
||||
const toolsBlocks = getBlocksByCategory('tools')
|
||||
const triggersBlocks = getBlocksByCategory('triggers')
|
||||
expect(toolsBlocks.length).toBeGreaterThan(triggersBlocks.length)
|
||||
})
|
||||
|
||||
it('should have a reasonable total number of blocks', () => {
|
||||
const allBlocks = getAllBlocks()
|
||||
expect(allBlocks.length).toBeGreaterThan(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubBlock Features', () => {
|
||||
it('should have wandConfig on code subBlocks where applicable', () => {
|
||||
const functionBlock = getBlock('function')
|
||||
const codeSubBlock = functionBlock?.subBlocks.find((sb) => sb.id === 'code')
|
||||
expect(codeSubBlock?.wandConfig).toBeDefined()
|
||||
expect(codeSubBlock?.wandConfig?.enabled).toBe(true)
|
||||
expect(codeSubBlock?.wandConfig?.prompt).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct slider configurations', () => {
|
||||
const routerBlock = getBlock('router')
|
||||
const temperatureSubBlock = routerBlock?.subBlocks.find((sb) => sb.id === 'temperature')
|
||||
expect(temperatureSubBlock?.type).toBe('slider')
|
||||
expect(temperatureSubBlock?.min).toBe(0)
|
||||
expect(temperatureSubBlock?.max).toBe(2)
|
||||
})
|
||||
|
||||
it('should have required scopes on OAuth inputs', () => {
|
||||
const webhookBlock = getBlock('webhook')
|
||||
const gmailCredentialSubBlock = webhookBlock?.subBlocks.find(
|
||||
(sb) => sb.id === 'gmailCredential'
|
||||
)
|
||||
expect(gmailCredentialSubBlock?.requiredScopes).toBeDefined()
|
||||
expect(Array.isArray(gmailCredentialSubBlock?.requiredScopes)).toBe(true)
|
||||
expect((gmailCredentialSubBlock?.requiredScopes?.length ?? 0) > 0).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Block Consistency', () => {
|
||||
it('should have consistent registry keys matching block types', () => {
|
||||
for (const [key, block] of Object.entries(registry)) {
|
||||
expect(key).toBe(block.type)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have non-empty descriptions for all blocks', () => {
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
expect(block.description.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have non-empty names for all blocks', () => {
|
||||
const blocks = getAllBlocks()
|
||||
for (const block of blocks) {
|
||||
expect(block.name.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
357
apps/sim/executor/variables/resolvers/block.test.ts
Normal file
357
apps/sim/executor/variables/resolvers/block.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ExecutionState } from '@/executor/execution/state'
|
||||
import { BlockResolver } from './block'
|
||||
import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
/**
|
||||
* Creates a minimal workflow for testing.
|
||||
*/
|
||||
function createTestWorkflow(blocks: Array<{ id: string; name?: string; type?: string }> = []) {
|
||||
return {
|
||||
version: '1.0',
|
||||
blocks: blocks.map((b) => ({
|
||||
id: b.id,
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: b.type ?? 'function', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: b.type ?? 'function', name: b.name ?? b.id },
|
||||
enabled: true,
|
||||
})),
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test ResolutionContext with block outputs.
|
||||
*/
|
||||
function createTestContext(
|
||||
currentNodeId: string,
|
||||
blockOutputs: Record<string, any> = {},
|
||||
contextBlockStates?: Map<string, { output: any }>
|
||||
): ResolutionContext {
|
||||
const state = new ExecutionState()
|
||||
for (const [blockId, output] of Object.entries(blockOutputs)) {
|
||||
state.setBlockOutput(blockId, output)
|
||||
}
|
||||
|
||||
return {
|
||||
executionContext: {
|
||||
blockStates: contextBlockStates ?? new Map(),
|
||||
},
|
||||
executionState: state,
|
||||
currentNodeId,
|
||||
} as unknown as ResolutionContext
|
||||
}
|
||||
|
||||
describe('BlockResolver', () => {
|
||||
describe('canResolve', () => {
|
||||
it.concurrent('should return true for block references', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow([{ id: 'block-1' }]))
|
||||
expect(resolver.canResolve('<block-1>')).toBe(true)
|
||||
expect(resolver.canResolve('<block-1.output>')).toBe(true)
|
||||
expect(resolver.canResolve('<block-1.result.value>')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for block references by name', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow([{ id: 'block-1', name: 'My Block' }]))
|
||||
expect(resolver.canResolve('<myblock>')).toBe(true)
|
||||
expect(resolver.canResolve('<My Block>')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for special prefixes', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('<loop.index>')).toBe(false)
|
||||
expect(resolver.canResolve('<parallel.currentItem>')).toBe(false)
|
||||
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for non-references', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('plain text')).toBe(false)
|
||||
expect(resolver.canResolve('{{ENV_VAR}}')).toBe(false)
|
||||
expect(resolver.canResolve('block-1.output')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
it.concurrent('should resolve block output by ID', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source-block' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
'source-block': { result: 'success', data: { value: 42 } },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<source-block>', ctx)).toEqual({
|
||||
result: 'success',
|
||||
data: { value: 42 },
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should resolve block output by name', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'block-123', name: 'My Source Block' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
'block-123': { message: 'hello' },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<mysourceblock>', ctx)).toEqual({ message: 'hello' })
|
||||
expect(resolver.resolve('<My Source Block>', ctx)).toEqual({ message: 'hello' })
|
||||
})
|
||||
|
||||
it.concurrent('should resolve nested property path', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { user: { profile: { name: 'Alice', email: 'alice@test.com' } } },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<source.user.profile.name>', ctx)).toBe('Alice')
|
||||
expect(resolver.resolve('<source.user.profile.email>', ctx)).toBe('alice@test.com')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve array index in path', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { items: [{ id: 1 }, { id: 2 }, { id: 3 }] },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<source.items.0>', ctx)).toEqual({ id: 1 })
|
||||
expect(resolver.resolve('<source.items.1.id>', ctx)).toBe(2)
|
||||
})
|
||||
|
||||
it.concurrent('should throw error for non-existent path', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { existing: 'value' },
|
||||
})
|
||||
|
||||
expect(() => resolver.resolve('<source.nonexistent>', ctx)).toThrow(
|
||||
/No value found at path "nonexistent" in block "source"/
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for non-existent block', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'existing' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {})
|
||||
|
||||
expect(resolver.resolve('<nonexistent>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should fall back to context blockStates', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const contextStates = new Map([['source', { output: { fallback: true } }]])
|
||||
const ctx = createTestContext('current', {}, contextStates)
|
||||
|
||||
expect(resolver.resolve('<source>', ctx)).toEqual({ fallback: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForBlock', () => {
|
||||
it.concurrent('should format string for condition block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const result = resolver.formatValueForBlock('hello world', 'condition')
|
||||
expect(result).toBe('"hello world"')
|
||||
})
|
||||
|
||||
it.concurrent('should escape special characters for condition block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock('line1\nline2', 'condition')).toBe('"line1\\nline2"')
|
||||
expect(resolver.formatValueForBlock('quote "test"', 'condition')).toBe('"quote \\"test\\""')
|
||||
expect(resolver.formatValueForBlock('backslash \\', 'condition')).toBe('"backslash \\\\"')
|
||||
expect(resolver.formatValueForBlock('tab\there', 'condition')).toBe('"tab\there"')
|
||||
})
|
||||
|
||||
it.concurrent('should format object for condition block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const result = resolver.formatValueForBlock({ key: 'value' }, 'condition')
|
||||
expect(result).toBe('{"key":"value"}')
|
||||
})
|
||||
|
||||
it.concurrent('should format null/undefined for condition block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock(null, 'condition')).toBe('null')
|
||||
expect(resolver.formatValueForBlock(undefined, 'condition')).toBe('undefined')
|
||||
})
|
||||
|
||||
it.concurrent('should format number for condition block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock(42, 'condition')).toBe('42')
|
||||
expect(resolver.formatValueForBlock(3.14, 'condition')).toBe('3.14')
|
||||
expect(resolver.formatValueForBlock(-100, 'condition')).toBe('-100')
|
||||
})
|
||||
|
||||
it.concurrent('should format boolean for condition block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock(true, 'condition')).toBe('true')
|
||||
expect(resolver.formatValueForBlock(false, 'condition')).toBe('false')
|
||||
})
|
||||
|
||||
it.concurrent('should format string for function block (JSON escaped)', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const result = resolver.formatValueForBlock('hello', 'function')
|
||||
expect(result).toBe('"hello"')
|
||||
})
|
||||
|
||||
it.concurrent('should format string for function block in template literal', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const result = resolver.formatValueForBlock('hello', 'function', true)
|
||||
expect(result).toBe('hello')
|
||||
})
|
||||
|
||||
it.concurrent('should format object for function block in template literal', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const result = resolver.formatValueForBlock({ a: 1 }, 'function', true)
|
||||
expect(result).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it.concurrent('should format null/undefined for function block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock(null, 'function')).toBe('null')
|
||||
expect(resolver.formatValueForBlock(undefined, 'function')).toBe('undefined')
|
||||
})
|
||||
|
||||
it.concurrent('should format string for response block (no quotes)', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock('plain text', 'response')).toBe('plain text')
|
||||
})
|
||||
|
||||
it.concurrent('should format object for response block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock({ key: 'value' }, 'response')).toBe('{"key":"value"}')
|
||||
})
|
||||
|
||||
it.concurrent('should format array for response block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock([1, 2, 3], 'response')).toBe('[1,2,3]')
|
||||
})
|
||||
|
||||
it.concurrent('should format primitives for response block', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock(42, 'response')).toBe('42')
|
||||
expect(resolver.formatValueForBlock(true, 'response')).toBe('true')
|
||||
})
|
||||
|
||||
it.concurrent('should format object for default block type', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock({ x: 1 }, undefined)).toBe('{"x":1}')
|
||||
expect(resolver.formatValueForBlock({ x: 1 }, 'agent')).toBe('{"x":1}')
|
||||
})
|
||||
|
||||
it.concurrent('should format primitive for default block type', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.formatValueForBlock('text', undefined)).toBe('text')
|
||||
expect(resolver.formatValueForBlock(123, undefined)).toBe('123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tryParseJSON', () => {
|
||||
it.concurrent('should parse valid JSON object string', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('{"key": "value"}')).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it.concurrent('should parse valid JSON array string', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('[1, 2, 3]')).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it.concurrent('should return original value for non-string input', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const obj = { key: 'value' }
|
||||
expect(resolver.tryParseJSON(obj)).toBe(obj)
|
||||
expect(resolver.tryParseJSON(123)).toBe(123)
|
||||
expect(resolver.tryParseJSON(null)).toBe(null)
|
||||
})
|
||||
|
||||
it.concurrent('should return original string for non-JSON strings', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('plain text')).toBe('plain text')
|
||||
expect(resolver.tryParseJSON('123')).toBe('123')
|
||||
expect(resolver.tryParseJSON('')).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should return original string for invalid JSON', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('{invalid json}')).toBe('{invalid json}')
|
||||
expect(resolver.tryParseJSON('[1, 2,')).toBe('[1, 2,')
|
||||
})
|
||||
|
||||
it.concurrent('should handle whitespace around JSON', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON(' {"key": "value"} ')).toEqual({ key: 'value' })
|
||||
expect(resolver.tryParseJSON('\n[1, 2]\n')).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent('should handle case-insensitive block name matching', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'block-1', name: 'My Block' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', { 'block-1': { data: 'test' } })
|
||||
|
||||
expect(resolver.resolve('<MYBLOCK>', ctx)).toEqual({ data: 'test' })
|
||||
expect(resolver.resolve('<myblock>', ctx)).toEqual({ data: 'test' })
|
||||
expect(resolver.resolve('<MyBlock>', ctx)).toEqual({ data: 'test' })
|
||||
})
|
||||
|
||||
it.concurrent('should handle block names with spaces', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'block-1', name: 'API Request Block' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', { 'block-1': { status: 200 } })
|
||||
|
||||
expect(resolver.resolve('<apirequestblock>', ctx)).toEqual({ status: 200 })
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty path returning entire output', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const output = { a: 1, b: 2, c: { nested: true } }
|
||||
const ctx = createTestContext('current', { source: output })
|
||||
|
||||
expect(resolver.resolve('<source>', ctx)).toEqual(output)
|
||||
})
|
||||
|
||||
it.concurrent('should handle output with null values', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { value: null, other: 'exists' },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<source.value>', ctx)).toBeNull()
|
||||
expect(resolver.resolve('<source.other>', ctx)).toBe('exists')
|
||||
})
|
||||
|
||||
it.concurrent('should handle output with undefined values', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { value: undefined, other: 'exists' },
|
||||
})
|
||||
|
||||
expect(() => resolver.resolve('<source.value>', ctx)).toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should handle deeply nested path errors', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { level1: { level2: {} } },
|
||||
})
|
||||
|
||||
expect(() => resolver.resolve('<source.level1.level2.level3>', ctx)).toThrow(
|
||||
/No value found at path "level1.level2.level3"/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
178
apps/sim/executor/variables/resolvers/env.test.ts
Normal file
178
apps/sim/executor/variables/resolvers/env.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EnvResolver } from './env'
|
||||
import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
/**
|
||||
* Creates a minimal ResolutionContext for testing.
|
||||
* The EnvResolver only uses context.executionContext.environmentVariables.
|
||||
*/
|
||||
function createTestContext(environmentVariables: Record<string, string>): ResolutionContext {
|
||||
return {
|
||||
executionContext: { environmentVariables },
|
||||
executionState: {},
|
||||
currentNodeId: 'test-node',
|
||||
} as ResolutionContext
|
||||
}
|
||||
|
||||
describe('EnvResolver', () => {
|
||||
describe('canResolve', () => {
|
||||
it.concurrent('should return true for valid env var references', () => {
|
||||
const resolver = new EnvResolver()
|
||||
expect(resolver.canResolve('{{API_KEY}}')).toBe(true)
|
||||
expect(resolver.canResolve('{{DATABASE_URL}}')).toBe(true)
|
||||
expect(resolver.canResolve('{{MY_VAR}}')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for env vars with underscores', () => {
|
||||
const resolver = new EnvResolver()
|
||||
expect(resolver.canResolve('{{MY_SECRET_KEY}}')).toBe(true)
|
||||
expect(resolver.canResolve('{{SOME_LONG_VARIABLE_NAME}}')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for env vars with numbers', () => {
|
||||
const resolver = new EnvResolver()
|
||||
expect(resolver.canResolve('{{API_KEY_2}}')).toBe(true)
|
||||
expect(resolver.canResolve('{{V2_CONFIG}}')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for non-env var references', () => {
|
||||
const resolver = new EnvResolver()
|
||||
expect(resolver.canResolve('<block.output>')).toBe(false)
|
||||
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
|
||||
expect(resolver.canResolve('<loop.index>')).toBe(false)
|
||||
expect(resolver.canResolve('plain text')).toBe(false)
|
||||
expect(resolver.canResolve('{API_KEY}')).toBe(false)
|
||||
expect(resolver.canResolve('{{API_KEY}')).toBe(false)
|
||||
expect(resolver.canResolve('{API_KEY}}')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
it.concurrent('should resolve existing environment variable', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({ API_KEY: 'secret-api-key' })
|
||||
|
||||
const result = resolver.resolve('{{API_KEY}}', ctx)
|
||||
expect(result).toBe('secret-api-key')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve multiple different environment variables', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({
|
||||
DATABASE_URL: 'postgres://localhost:5432/db',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
SECRET_KEY: 'super-secret',
|
||||
})
|
||||
|
||||
expect(resolver.resolve('{{DATABASE_URL}}', ctx)).toBe('postgres://localhost:5432/db')
|
||||
expect(resolver.resolve('{{REDIS_URL}}', ctx)).toBe('redis://localhost:6379')
|
||||
expect(resolver.resolve('{{SECRET_KEY}}', ctx)).toBe('super-secret')
|
||||
})
|
||||
|
||||
it.concurrent('should return original reference for non-existent variable', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({ EXISTING: 'value' })
|
||||
|
||||
const result = resolver.resolve('{{NON_EXISTENT}}', ctx)
|
||||
expect(result).toBe('{{NON_EXISTENT}}')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty string value', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({ EMPTY_VAR: '' })
|
||||
|
||||
const result = resolver.resolve('{{EMPTY_VAR}}', ctx)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should handle value with special characters', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({
|
||||
SPECIAL: 'value with spaces & special chars: !@#$%^&*()',
|
||||
})
|
||||
|
||||
const result = resolver.resolve('{{SPECIAL}}', ctx)
|
||||
expect(result).toBe('value with spaces & special chars: !@#$%^&*()')
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON string values', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({
|
||||
JSON_CONFIG: '{"key": "value", "nested": {"a": 1}}',
|
||||
})
|
||||
|
||||
const result = resolver.resolve('{{JSON_CONFIG}}', ctx)
|
||||
expect(result).toBe('{"key": "value", "nested": {"a": 1}}')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty environment variables object', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({})
|
||||
|
||||
const result = resolver.resolve('{{ANY_VAR}}', ctx)
|
||||
expect(result).toBe('{{ANY_VAR}}')
|
||||
})
|
||||
|
||||
it.concurrent('should handle undefined environmentVariables gracefully', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = {
|
||||
executionContext: {},
|
||||
executionState: {},
|
||||
currentNodeId: 'test-node',
|
||||
} as ResolutionContext
|
||||
|
||||
const result = resolver.resolve('{{API_KEY}}', ctx)
|
||||
expect(result).toBe('{{API_KEY}}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent('should handle variable names with consecutive underscores', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({ MY__VAR: 'double underscore' })
|
||||
|
||||
expect(resolver.canResolve('{{MY__VAR}}')).toBe(true)
|
||||
expect(resolver.resolve('{{MY__VAR}}', ctx)).toBe('double underscore')
|
||||
})
|
||||
|
||||
it.concurrent('should handle single character variable names', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({ X: 'single' })
|
||||
|
||||
expect(resolver.canResolve('{{X}}')).toBe(true)
|
||||
expect(resolver.resolve('{{X}}', ctx)).toBe('single')
|
||||
})
|
||||
|
||||
it.concurrent('should handle very long variable names', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const longName = 'A'.repeat(100)
|
||||
const ctx = createTestContext({ [longName]: 'long name value' })
|
||||
|
||||
expect(resolver.canResolve(`{{${longName}}}`)).toBe(true)
|
||||
expect(resolver.resolve(`{{${longName}}}`, ctx)).toBe('long name value')
|
||||
})
|
||||
|
||||
it.concurrent('should handle value containing mustache-like syntax', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({
|
||||
TEMPLATE: 'Hello {{name}}!',
|
||||
})
|
||||
|
||||
const result = resolver.resolve('{{TEMPLATE}}', ctx)
|
||||
expect(result).toBe('Hello {{name}}!')
|
||||
})
|
||||
|
||||
it.concurrent('should handle multiline values', () => {
|
||||
const resolver = new EnvResolver()
|
||||
const ctx = createTestContext({
|
||||
MULTILINE: 'line1\nline2\nline3',
|
||||
})
|
||||
|
||||
const result = resolver.resolve('{{MULTILINE}}', ctx)
|
||||
expect(result).toBe('line1\nline2\nline3')
|
||||
})
|
||||
})
|
||||
})
|
||||
280
apps/sim/executor/variables/resolvers/loop.test.ts
Normal file
280
apps/sim/executor/variables/resolvers/loop.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { LoopScope } from '@/executor/execution/state'
|
||||
import { LoopResolver } from './loop'
|
||||
import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
/**
|
||||
* Creates a minimal workflow for testing.
|
||||
*/
|
||||
function createTestWorkflow(
|
||||
loops: Record<string, { nodes: string[]; id?: string; iterations?: number }> = {}
|
||||
) {
|
||||
// Ensure each loop has required fields
|
||||
const normalizedLoops: Record<string, { id: string; nodes: string[]; iterations: number }> = {}
|
||||
for (const [key, loop] of Object.entries(loops)) {
|
||||
normalizedLoops[key] = {
|
||||
id: loop.id ?? key,
|
||||
nodes: loop.nodes,
|
||||
iterations: loop.iterations ?? 1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: '1.0',
|
||||
blocks: [],
|
||||
connections: [],
|
||||
loops: normalizedLoops,
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test loop scope.
|
||||
*/
|
||||
function createLoopScope(overrides: Partial<LoopScope> = {}): LoopScope {
|
||||
return {
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal ResolutionContext for testing.
|
||||
*/
|
||||
function createTestContext(
|
||||
currentNodeId: string,
|
||||
loopScope?: LoopScope,
|
||||
loopExecutions?: Map<string, LoopScope>
|
||||
): ResolutionContext {
|
||||
return {
|
||||
executionContext: {
|
||||
loopExecutions: loopExecutions ?? new Map(),
|
||||
},
|
||||
executionState: {},
|
||||
currentNodeId,
|
||||
loopScope,
|
||||
} as ResolutionContext
|
||||
}
|
||||
|
||||
describe('LoopResolver', () => {
|
||||
describe('canResolve', () => {
|
||||
it.concurrent('should return true for loop references', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('<loop.index>')).toBe(true)
|
||||
expect(resolver.canResolve('<loop.iteration>')).toBe(true)
|
||||
expect(resolver.canResolve('<loop.item>')).toBe(true)
|
||||
expect(resolver.canResolve('<loop.currentItem>')).toBe(true)
|
||||
expect(resolver.canResolve('<loop.items>')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for loop references with nested paths', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('<loop.item.name>')).toBe(true)
|
||||
expect(resolver.canResolve('<loop.currentItem.data.value>')).toBe(true)
|
||||
expect(resolver.canResolve('<loop.items.0>')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for non-loop references', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('<block.output>')).toBe(false)
|
||||
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
|
||||
expect(resolver.canResolve('<parallel.index>')).toBe(false)
|
||||
expect(resolver.canResolve('plain text')).toBe(false)
|
||||
expect(resolver.canResolve('{{ENV_VAR}}')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for malformed references', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('loop.index')).toBe(false)
|
||||
expect(resolver.canResolve('<loop.index')).toBe(false)
|
||||
expect(resolver.canResolve('loop.index>')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve with explicit loopScope', () => {
|
||||
it.concurrent('should resolve iteration/index property', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ iteration: 5 })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.iteration>', ctx)).toBe(5)
|
||||
expect(resolver.resolve('<loop.index>', ctx)).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('should resolve item/currentItem property', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ item: { name: 'test', value: 42 } })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item>', ctx)).toEqual({ name: 'test', value: 42 })
|
||||
expect(resolver.resolve('<loop.currentItem>', ctx)).toEqual({ name: 'test', value: 42 })
|
||||
})
|
||||
|
||||
it.concurrent('should resolve items property', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const items = ['a', 'b', 'c']
|
||||
const loopScope = createLoopScope({ items })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.items>', ctx)).toEqual(items)
|
||||
})
|
||||
|
||||
it.concurrent('should resolve nested path in item', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({
|
||||
item: { user: { name: 'Alice', address: { city: 'NYC' } } },
|
||||
})
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item.user.name>', ctx)).toBe('Alice')
|
||||
expect(resolver.resolve('<loop.item.user.address.city>', ctx)).toBe('NYC')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve array index in items', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({
|
||||
items: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
})
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.items.0>', ctx)).toEqual({ id: 1 })
|
||||
expect(resolver.resolve('<loop.items.1.id>', ctx)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve without explicit loopScope (discovery)', () => {
|
||||
it.concurrent('should find loop scope from workflow config', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'loop-1': { nodes: ['block-1', 'block-2'] },
|
||||
})
|
||||
const resolver = new LoopResolver(workflow)
|
||||
const loopScope = createLoopScope({ iteration: 3 })
|
||||
const loopExecutions = new Map([['loop-1', loopScope]])
|
||||
const ctx = createTestContext('block-1', undefined, loopExecutions)
|
||||
|
||||
expect(resolver.resolve('<loop.iteration>', ctx)).toBe(3)
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined when block is not in any loop', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'loop-1': { nodes: ['other-block'] },
|
||||
})
|
||||
const resolver = new LoopResolver(workflow)
|
||||
const ctx = createTestContext('block-1', undefined)
|
||||
|
||||
expect(resolver.resolve('<loop.iteration>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined when loop scope not found in executions', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'loop-1': { nodes: ['block-1'] },
|
||||
})
|
||||
const resolver = new LoopResolver(workflow)
|
||||
const ctx = createTestContext('block-1', undefined, new Map())
|
||||
|
||||
expect(resolver.resolve('<loop.iteration>', ctx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent('should return undefined for invalid loop reference (missing property)', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ iteration: 0 })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for unknown loop property', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ iteration: 0 })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.unknownProperty>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle iteration index 0 correctly', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ iteration: 0 })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.index>', ctx)).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle null item value', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ item: null })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item>', ctx)).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should handle undefined item value', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ item: undefined })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty items array', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ items: [] })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.items>', ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should handle primitive item value', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ item: 'simple string' })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item>', ctx)).toBe('simple string')
|
||||
})
|
||||
|
||||
it.concurrent('should handle numeric item value', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ item: 42 })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item>', ctx)).toBe(42)
|
||||
})
|
||||
|
||||
it.concurrent('should handle boolean item value', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ item: true })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item>', ctx)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle item with array value', () => {
|
||||
const resolver = new LoopResolver(createTestWorkflow())
|
||||
const loopScope = createLoopScope({ item: [1, 2, 3] })
|
||||
const ctx = createTestContext('block-1', loopScope)
|
||||
|
||||
expect(resolver.resolve('<loop.item>', ctx)).toEqual([1, 2, 3])
|
||||
expect(resolver.resolve('<loop.item.0>', ctx)).toBe(1)
|
||||
expect(resolver.resolve('<loop.item.2>', ctx)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('block ID with branch suffix', () => {
|
||||
it.concurrent('should handle block ID with branch suffix in loop lookup', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'loop-1': { nodes: ['block-1'] },
|
||||
})
|
||||
const resolver = new LoopResolver(workflow)
|
||||
const loopScope = createLoopScope({ iteration: 2 })
|
||||
const loopExecutions = new Map([['loop-1', loopScope]])
|
||||
const ctx = createTestContext('block-1₍0₎', undefined, loopExecutions)
|
||||
|
||||
expect(resolver.resolve('<loop.iteration>', ctx)).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
360
apps/sim/executor/variables/resolvers/parallel.test.ts
Normal file
360
apps/sim/executor/variables/resolvers/parallel.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ParallelResolver } from './parallel'
|
||||
import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
/**
|
||||
* Creates a minimal workflow for testing.
|
||||
*/
|
||||
function createTestWorkflow(
|
||||
parallels: Record<
|
||||
string,
|
||||
{
|
||||
nodes: string[]
|
||||
id?: string
|
||||
distribution?: any
|
||||
distributionItems?: any
|
||||
parallelType?: 'count' | 'collection'
|
||||
}
|
||||
> = {}
|
||||
) {
|
||||
// Ensure each parallel has required fields
|
||||
const normalizedParallels: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
nodes: string[]
|
||||
distribution?: any
|
||||
distributionItems?: any
|
||||
parallelType?: 'count' | 'collection'
|
||||
}
|
||||
> = {}
|
||||
for (const [key, parallel] of Object.entries(parallels)) {
|
||||
normalizedParallels[key] = {
|
||||
id: parallel.id ?? key,
|
||||
nodes: parallel.nodes,
|
||||
distribution: parallel.distribution,
|
||||
distributionItems: parallel.distributionItems,
|
||||
parallelType: parallel.parallelType,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: '1.0',
|
||||
blocks: [],
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: normalizedParallels,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a parallel scope for runtime context.
|
||||
*/
|
||||
function createParallelScope(items: any[]) {
|
||||
return {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: items.length,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 1,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal ResolutionContext for testing.
|
||||
*/
|
||||
function createTestContext(
|
||||
currentNodeId: string,
|
||||
parallelExecutions?: Map<string, any>
|
||||
): ResolutionContext {
|
||||
return {
|
||||
executionContext: {
|
||||
parallelExecutions: parallelExecutions ?? new Map(),
|
||||
},
|
||||
executionState: {},
|
||||
currentNodeId,
|
||||
} as ResolutionContext
|
||||
}
|
||||
|
||||
describe('ParallelResolver', () => {
|
||||
describe('canResolve', () => {
|
||||
it.concurrent('should return true for parallel references', () => {
|
||||
const resolver = new ParallelResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('<parallel.index>')).toBe(true)
|
||||
expect(resolver.canResolve('<parallel.currentItem>')).toBe(true)
|
||||
expect(resolver.canResolve('<parallel.items>')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for parallel references with nested paths', () => {
|
||||
const resolver = new ParallelResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('<parallel.currentItem.name>')).toBe(true)
|
||||
expect(resolver.canResolve('<parallel.items.0>')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for non-parallel references', () => {
|
||||
const resolver = new ParallelResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('<block.output>')).toBe(false)
|
||||
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
|
||||
expect(resolver.canResolve('<loop.index>')).toBe(false)
|
||||
expect(resolver.canResolve('plain text')).toBe(false)
|
||||
expect(resolver.canResolve('{{ENV_VAR}}')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for malformed references', () => {
|
||||
const resolver = new ParallelResolver(createTestWorkflow())
|
||||
expect(resolver.canResolve('parallel.index')).toBe(false)
|
||||
expect(resolver.canResolve('<parallel.index')).toBe(false)
|
||||
expect(resolver.canResolve('parallel.index>')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve index property', () => {
|
||||
it.concurrent('should resolve branch index from node ID', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b', 'c'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.index>', ctx)).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('should resolve different branch indices', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b', 'c'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
|
||||
expect(resolver.resolve('<parallel.index>', createTestContext('block-1₍0₎'))).toBe(0)
|
||||
expect(resolver.resolve('<parallel.index>', createTestContext('block-1₍1₎'))).toBe(1)
|
||||
expect(resolver.resolve('<parallel.index>', createTestContext('block-1₍2₎'))).toBe(2)
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined when branch index cannot be extracted', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1')
|
||||
|
||||
expect(resolver.resolve('<parallel.index>', ctx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve currentItem property', () => {
|
||||
it.concurrent('should resolve current item from array distribution', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['apple', 'banana', 'cherry'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
|
||||
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍0₎'))).toBe(
|
||||
'apple'
|
||||
)
|
||||
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍1₎'))).toBe(
|
||||
'banana'
|
||||
)
|
||||
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍2₎'))).toBe(
|
||||
'cherry'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should resolve current item from object distribution as entries', () => {
|
||||
// When an object is used as distribution, it gets converted to entries [key, value]
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': {
|
||||
nodes: ['block-1'],
|
||||
distribution: { key1: 'value1', key2: 'value2' },
|
||||
},
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx0 = createTestContext('block-1₍0₎')
|
||||
const ctx1 = createTestContext('block-1₍1₎')
|
||||
|
||||
const item0 = resolver.resolve('<parallel.currentItem>', ctx0)
|
||||
const item1 = resolver.resolve('<parallel.currentItem>', ctx1)
|
||||
|
||||
// Object entries are returned as [key, value] tuples
|
||||
expect(item0).toEqual(['key1', 'value1'])
|
||||
expect(item1).toEqual(['key2', 'value2'])
|
||||
})
|
||||
|
||||
it.concurrent('should resolve current item with nested path', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': {
|
||||
nodes: ['block-1'],
|
||||
distribution: [
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
|
||||
expect(resolver.resolve('<parallel.currentItem.name>', createTestContext('block-1₍0₎'))).toBe(
|
||||
'Alice'
|
||||
)
|
||||
expect(resolver.resolve('<parallel.currentItem.age>', createTestContext('block-1₍1₎'))).toBe(
|
||||
25
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should use runtime parallelScope items when available', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['static1', 'static2'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const parallelScope = createParallelScope(['runtime1', 'runtime2', 'runtime3'])
|
||||
const parallelExecutions = new Map([['parallel-1', parallelScope]])
|
||||
const ctx = createTestContext('block-1₍1₎', parallelExecutions)
|
||||
|
||||
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('runtime2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve items property', () => {
|
||||
it.concurrent('should resolve all items from array distribution', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: [1, 2, 3] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.items>', ctx)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it.concurrent('should resolve items with nested path', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': {
|
||||
nodes: ['block-1'],
|
||||
distribution: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
},
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.items.1>', ctx)).toEqual({ id: 2 })
|
||||
expect(resolver.resolve('<parallel.items.1.id>', ctx)).toBe(2)
|
||||
})
|
||||
|
||||
it.concurrent('should use runtime parallelScope items when available', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['static'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const parallelScope = createParallelScope(['runtime1', 'runtime2'])
|
||||
const parallelExecutions = new Map([['parallel-1', parallelScope]])
|
||||
const ctx = createTestContext('block-1₍0₎', parallelExecutions)
|
||||
|
||||
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['runtime1', 'runtime2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent(
|
||||
'should return undefined for invalid parallel reference (missing property)',
|
||||
() => {
|
||||
const resolver = new ParallelResolver(createTestWorkflow())
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel>', ctx)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should return undefined for unknown parallel property', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['a'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.unknownProperty>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined when block is not in any parallel', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['other-block'], distribution: ['a'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.index>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined when parallel config not found', () => {
|
||||
const workflow = createTestWorkflow({})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.index>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty distribution array', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: [] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.items>', ctx)).toEqual([])
|
||||
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON string distribution', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: '["x", "y", "z"]' },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍1₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['x', 'y', 'z'])
|
||||
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('y')
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON string with single quotes', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: "['a', 'b']" },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it.concurrent('should return empty array for reference strings', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: '<block.output>' },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.items>', ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should handle distributionItems property as fallback', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distributionItems: ['fallback1', 'fallback2'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['fallback1', 'fallback2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested parallel blocks', () => {
|
||||
it.concurrent('should resolve for block with multiple parallel parents', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1', 'block-2'], distribution: ['p1', 'p2'] },
|
||||
'parallel-2': { nodes: ['block-3'], distribution: ['p3', 'p4'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
|
||||
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍0₎'))).toBe('p1')
|
||||
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-3₍1₎'))).toBe('p4')
|
||||
})
|
||||
})
|
||||
})
|
||||
200
apps/sim/executor/variables/resolvers/reference.test.ts
Normal file
200
apps/sim/executor/variables/resolvers/reference.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { navigatePath } from './reference'
|
||||
|
||||
describe('navigatePath', () => {
|
||||
describe('basic property access', () => {
|
||||
it.concurrent('should access top-level property', () => {
|
||||
const obj = { name: 'test', value: 42 }
|
||||
expect(navigatePath(obj, ['name'])).toBe('test')
|
||||
expect(navigatePath(obj, ['value'])).toBe(42)
|
||||
})
|
||||
|
||||
it.concurrent('should access nested properties', () => {
|
||||
const obj = { a: { b: { c: 'deep' } } }
|
||||
expect(navigatePath(obj, ['a', 'b', 'c'])).toBe('deep')
|
||||
})
|
||||
|
||||
it.concurrent('should return entire object for empty path', () => {
|
||||
const obj = { name: 'test' }
|
||||
expect(navigatePath(obj, [])).toEqual(obj)
|
||||
})
|
||||
|
||||
it.concurrent('should handle deeply nested objects', () => {
|
||||
const obj = { level1: { level2: { level3: { level4: { value: 'found' } } } } }
|
||||
expect(navigatePath(obj, ['level1', 'level2', 'level3', 'level4', 'value'])).toBe('found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('array indexing', () => {
|
||||
it.concurrent('should access array elements with numeric string index', () => {
|
||||
const obj = { items: ['a', 'b', 'c'] }
|
||||
expect(navigatePath(obj, ['items', '0'])).toBe('a')
|
||||
expect(navigatePath(obj, ['items', '1'])).toBe('b')
|
||||
expect(navigatePath(obj, ['items', '2'])).toBe('c')
|
||||
})
|
||||
|
||||
it.concurrent('should access array elements with bracket notation', () => {
|
||||
const obj = { items: [{ name: 'first' }, { name: 'second' }] }
|
||||
expect(navigatePath(obj, ['items[0]', 'name'])).toBe('first')
|
||||
expect(navigatePath(obj, ['items[1]', 'name'])).toBe('second')
|
||||
})
|
||||
|
||||
it.concurrent('should access nested arrays', () => {
|
||||
const obj = {
|
||||
matrix: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
],
|
||||
}
|
||||
expect(navigatePath(obj, ['matrix', '0', '0'])).toBe(1)
|
||||
expect(navigatePath(obj, ['matrix', '1', '1'])).toBe(4)
|
||||
expect(navigatePath(obj, ['matrix', '2', '0'])).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('should access array element properties', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
}
|
||||
expect(navigatePath(obj, ['users', '0', 'name'])).toBe('Alice')
|
||||
expect(navigatePath(obj, ['users', '1', 'id'])).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent('should return undefined for non-existent property', () => {
|
||||
const obj = { name: 'test' }
|
||||
expect(navigatePath(obj, ['nonexistent'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for path through null', () => {
|
||||
const obj = { data: null }
|
||||
expect(navigatePath(obj, ['data', 'value'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for path through undefined', () => {
|
||||
const obj: Record<string, any> = { data: undefined }
|
||||
expect(navigatePath(obj, ['data', 'value'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return null when accessing null property', () => {
|
||||
const obj = { value: null }
|
||||
expect(navigatePath(obj, ['value'])).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for out of bounds array access', () => {
|
||||
const obj = { items: ['a', 'b'] }
|
||||
expect(navigatePath(obj, ['items', '10'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined when accessing array property on non-array', () => {
|
||||
const obj = { data: 'string' }
|
||||
expect(navigatePath(obj, ['data', '0'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty object', () => {
|
||||
const obj = {}
|
||||
expect(navigatePath(obj, ['any'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle object with empty string key', () => {
|
||||
const obj = { '': 'empty key value' }
|
||||
expect(navigatePath(obj, [''])).toBe('empty key value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed access patterns', () => {
|
||||
it.concurrent('should handle complex nested structures', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{
|
||||
name: 'Alice',
|
||||
addresses: [
|
||||
{ city: 'NYC', zip: '10001' },
|
||||
{ city: 'LA', zip: '90001' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Bob',
|
||||
addresses: [{ city: 'Chicago', zip: '60601' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(navigatePath(obj, ['users', '0', 'name'])).toBe('Alice')
|
||||
expect(navigatePath(obj, ['users', '0', 'addresses', '1', 'city'])).toBe('LA')
|
||||
expect(navigatePath(obj, ['users', '1', 'addresses', '0', 'zip'])).toBe('60601')
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for numeric keys on non-array objects', () => {
|
||||
// navigatePath treats numeric strings as array indices only for arrays
|
||||
// For objects with numeric string keys, the numeric check takes precedence
|
||||
// and returns undefined since the object is not an array
|
||||
const obj = { data: { '0': 'zero', '1': 'one' } }
|
||||
expect(navigatePath(obj, ['data', '0'])).toBeUndefined()
|
||||
expect(navigatePath(obj, ['data', '1'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should access non-numeric string keys', () => {
|
||||
const obj = { data: { first: 'value1', second: 'value2' } }
|
||||
expect(navigatePath(obj, ['data', 'first'])).toBe('value1')
|
||||
expect(navigatePath(obj, ['data', 'second'])).toBe('value2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('special value types', () => {
|
||||
it.concurrent('should return boolean values', () => {
|
||||
const obj = { active: true, disabled: false }
|
||||
expect(navigatePath(obj, ['active'])).toBe(true)
|
||||
expect(navigatePath(obj, ['disabled'])).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return numeric values including zero', () => {
|
||||
const obj = { count: 0, value: -5, decimal: 3.14 }
|
||||
expect(navigatePath(obj, ['count'])).toBe(0)
|
||||
expect(navigatePath(obj, ['value'])).toBe(-5)
|
||||
expect(navigatePath(obj, ['decimal'])).toBe(3.14)
|
||||
})
|
||||
|
||||
it.concurrent('should return empty string', () => {
|
||||
const obj = { text: '' }
|
||||
expect(navigatePath(obj, ['text'])).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should return empty array', () => {
|
||||
const obj = { items: [] }
|
||||
expect(navigatePath(obj, ['items'])).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should return function values', () => {
|
||||
const fn = () => 'test'
|
||||
const obj = { callback: fn }
|
||||
expect(navigatePath(obj, ['callback'])).toBe(fn)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bracket notation edge cases', () => {
|
||||
it.concurrent('should handle bracket notation with property access', () => {
|
||||
const obj = { data: [{ value: 100 }, { value: 200 }] }
|
||||
expect(navigatePath(obj, ['data[0]'])).toEqual({ value: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for bracket notation on non-existent property', () => {
|
||||
const obj = { data: [1, 2, 3] }
|
||||
expect(navigatePath(obj, ['nonexistent[0]'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for bracket notation with null property', () => {
|
||||
const obj = { data: null }
|
||||
expect(navigatePath(obj, ['data[0]'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for bracket notation on non-array', () => {
|
||||
const obj = { data: 'string' }
|
||||
expect(navigatePath(obj, ['data[0]'])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
388
apps/sim/lib/api-key/auth.test.ts
Normal file
388
apps/sim/lib/api-key/auth.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Tests for API key authentication utilities.
|
||||
*
|
||||
* Tests cover:
|
||||
* - API key format detection (legacy vs encrypted)
|
||||
* - Authentication against stored keys
|
||||
* - Key encryption and decryption
|
||||
* - Display formatting
|
||||
* - Edge cases
|
||||
*/
|
||||
|
||||
import {
|
||||
createEncryptedApiKey,
|
||||
createLegacyApiKey,
|
||||
expectApiKeyInvalid,
|
||||
expectApiKeyValid,
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
authenticateApiKey,
|
||||
formatApiKeyForDisplay,
|
||||
getApiKeyLast4,
|
||||
isEncryptedKey,
|
||||
isValidApiKeyFormat,
|
||||
} from '@/lib/api-key/auth'
|
||||
import {
|
||||
generateApiKey,
|
||||
generateEncryptedApiKey,
|
||||
isEncryptedApiKeyFormat,
|
||||
isLegacyApiKeyFormat,
|
||||
} from '@/lib/api-key/crypto'
|
||||
|
||||
// Mock the crypto module's encryption functions for predictable testing
|
||||
vi.mock('@/lib/api-key/crypto', async () => {
|
||||
const actual = await vi.importActual('@/lib/api-key/crypto')
|
||||
return {
|
||||
...actual,
|
||||
// Keep the format detection functions as-is
|
||||
isEncryptedApiKeyFormat: (key: string) => key.startsWith('sk-sim-'),
|
||||
isLegacyApiKeyFormat: (key: string) => key.startsWith('sim_') && !key.startsWith('sk-sim-'),
|
||||
// Mock encryption/decryption to be reversible for testing
|
||||
encryptApiKey: async (apiKey: string) => ({
|
||||
encrypted: `mock-iv:${Buffer.from(apiKey).toString('hex')}:mock-tag`,
|
||||
iv: 'mock-iv',
|
||||
}),
|
||||
decryptApiKey: async (encryptedValue: string) => {
|
||||
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
|
||||
return { decrypted: encryptedValue }
|
||||
}
|
||||
const parts = encryptedValue.split(':')
|
||||
const hexPart = parts[1]
|
||||
return { decrypted: Buffer.from(hexPart, 'hex').toString('utf8') }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('isEncryptedKey', () => {
|
||||
it('should detect encrypted storage format (iv:encrypted:authTag)', () => {
|
||||
const encryptedStorage = 'abc123:encrypted-data:tag456'
|
||||
expect(isEncryptedKey(encryptedStorage)).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect plain text storage (no colons)', () => {
|
||||
const plainKey = 'sim_abcdef123456'
|
||||
expect(isEncryptedKey(plainKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect plain text with single colon', () => {
|
||||
const singleColon = 'part1:part2'
|
||||
expect(isEncryptedKey(singleColon)).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect encrypted format with exactly 3 parts', () => {
|
||||
const threeParts = 'iv:data:tag'
|
||||
expect(isEncryptedKey(threeParts)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject format with more than 3 parts', () => {
|
||||
const fourParts = 'a:b:c:d'
|
||||
expect(isEncryptedKey(fourParts)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isEncryptedKey('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEncryptedApiKeyFormat (key prefix)', () => {
|
||||
it('should detect sk-sim- prefix as encrypted format', () => {
|
||||
const { key } = createEncryptedApiKey()
|
||||
expect(isEncryptedApiKeyFormat(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect sim_ prefix as encrypted format', () => {
|
||||
const { key } = createLegacyApiKey()
|
||||
expect(isEncryptedApiKeyFormat(key)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect random string as encrypted format', () => {
|
||||
expect(isEncryptedApiKeyFormat('random-string')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLegacyApiKeyFormat', () => {
|
||||
it('should detect sim_ prefix as legacy format', () => {
|
||||
const { key } = createLegacyApiKey()
|
||||
expect(isLegacyApiKeyFormat(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect sk-sim- prefix as legacy format', () => {
|
||||
const { key } = createEncryptedApiKey()
|
||||
expect(isLegacyApiKeyFormat(key)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect random string as legacy format', () => {
|
||||
expect(isLegacyApiKeyFormat('random-string')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticateApiKey', () => {
|
||||
describe('encrypted format key (sk-sim-) against encrypted storage', () => {
|
||||
it('should authenticate matching encrypted key', async () => {
|
||||
const plainKey = 'sk-sim-test-key-123'
|
||||
const encryptedStorage = `mock-iv:${Buffer.from(plainKey).toString('hex')}:mock-tag`
|
||||
|
||||
const result = await authenticateApiKey(plainKey, encryptedStorage)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should reject non-matching encrypted key', async () => {
|
||||
const inputKey = 'sk-sim-test-key-123'
|
||||
const differentKey = 'sk-sim-different-key'
|
||||
const encryptedStorage = `mock-iv:${Buffer.from(differentKey).toString('hex')}:mock-tag`
|
||||
|
||||
const result = await authenticateApiKey(inputKey, encryptedStorage)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
|
||||
it('should reject encrypted format key against plain text storage', async () => {
|
||||
const inputKey = 'sk-sim-test-key-123'
|
||||
const plainStorage = inputKey // Same key but stored as plain text
|
||||
|
||||
const result = await authenticateApiKey(inputKey, plainStorage)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy format key (sim_) against storage', () => {
|
||||
it('should authenticate legacy key against encrypted storage', async () => {
|
||||
const plainKey = 'sim_legacy-test-key'
|
||||
const encryptedStorage = `mock-iv:${Buffer.from(plainKey).toString('hex')}:mock-tag`
|
||||
|
||||
const result = await authenticateApiKey(plainKey, encryptedStorage)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should authenticate legacy key against plain text storage', async () => {
|
||||
const plainKey = 'sim_legacy-test-key'
|
||||
const plainStorage = plainKey
|
||||
|
||||
const result = await authenticateApiKey(plainKey, plainStorage)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should reject non-matching legacy key', async () => {
|
||||
const inputKey = 'sim_test-key'
|
||||
const storedKey = 'sim_different-key'
|
||||
|
||||
const result = await authenticateApiKey(inputKey, storedKey)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unrecognized format keys', () => {
|
||||
it('should authenticate unrecognized key against plain text match', async () => {
|
||||
const plainKey = 'custom-api-key-format'
|
||||
const plainStorage = plainKey
|
||||
|
||||
const result = await authenticateApiKey(plainKey, plainStorage)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should authenticate unrecognized key against encrypted storage', async () => {
|
||||
const plainKey = 'custom-api-key-format'
|
||||
const encryptedStorage = `mock-iv:${Buffer.from(plainKey).toString('hex')}:mock-tag`
|
||||
|
||||
const result = await authenticateApiKey(plainKey, encryptedStorage)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should reject non-matching unrecognized key', async () => {
|
||||
const inputKey = 'custom-key-1'
|
||||
const storedKey = 'custom-key-2'
|
||||
|
||||
const result = await authenticateApiKey(inputKey, storedKey)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should reject empty input key', async () => {
|
||||
const result = await authenticateApiKey('', 'sim_stored-key')
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
|
||||
it('should reject empty stored key', async () => {
|
||||
const result = await authenticateApiKey('sim_input-key', '')
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
|
||||
it('should handle keys with special characters', async () => {
|
||||
const specialKey = 'sim_key-with-special+chars/and=more'
|
||||
const result = await authenticateApiKey(specialKey, specialKey)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should be case-sensitive', async () => {
|
||||
const result = await authenticateApiKey('sim_TestKey', 'sim_testkey')
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidApiKeyFormat', () => {
|
||||
it('should accept valid length keys', () => {
|
||||
expect(isValidApiKeyFormat(`sim_${'a'.repeat(20)}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject too short keys', () => {
|
||||
expect(isValidApiKeyFormat('short')).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject too long keys (>200 chars)', () => {
|
||||
expect(isValidApiKeyFormat('a'.repeat(201))).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept keys at boundary (11 chars)', () => {
|
||||
expect(isValidApiKeyFormat('a'.repeat(11))).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject keys at boundary (10 chars)', () => {
|
||||
expect(isValidApiKeyFormat('a'.repeat(10))).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject non-string input', () => {
|
||||
expect(isValidApiKeyFormat(null as any)).toBe(false)
|
||||
expect(isValidApiKeyFormat(undefined as any)).toBe(false)
|
||||
expect(isValidApiKeyFormat(123 as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isValidApiKeyFormat('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getApiKeyLast4', () => {
|
||||
it('should return last 4 characters of key', () => {
|
||||
expect(getApiKeyLast4('sim_abcdefghijklmnop')).toBe('mnop')
|
||||
})
|
||||
|
||||
it('should return last 4 characters of encrypted format key', () => {
|
||||
expect(getApiKeyLast4('sk-sim-abcdefghijkl')).toBe('ijkl')
|
||||
})
|
||||
|
||||
it('should return entire key if less than 4 chars', () => {
|
||||
expect(getApiKeyLast4('abc')).toBe('abc')
|
||||
})
|
||||
|
||||
it('should handle exactly 4 chars', () => {
|
||||
expect(getApiKeyLast4('abcd')).toBe('abcd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatApiKeyForDisplay', () => {
|
||||
it('should format encrypted format key with sk-sim- prefix', () => {
|
||||
const key = 'sk-sim-abcdefghijklmnopqrstuvwx'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toBe('sk-sim-...uvwx')
|
||||
})
|
||||
|
||||
it('should format legacy key with sim_ prefix', () => {
|
||||
const key = 'sim_abcdefghijklmnopqrstuvwx'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toBe('sim_...uvwx')
|
||||
})
|
||||
|
||||
it('should format unknown format key with just ellipsis', () => {
|
||||
const key = 'custom-key-format-abcd'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toBe('...abcd')
|
||||
})
|
||||
|
||||
it('should show last 4 characters correctly', () => {
|
||||
const key = 'sk-sim-xxxxxxxxxxxxxxxxr6AA'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toContain('r6AA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
it('should generate key with sim_ prefix', () => {
|
||||
const key = generateApiKey()
|
||||
expect(key).toMatch(/^sim_/)
|
||||
})
|
||||
|
||||
it('should generate unique keys', () => {
|
||||
const key1 = generateApiKey()
|
||||
const key2 = generateApiKey()
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate key of valid length', () => {
|
||||
const key = generateApiKey()
|
||||
expect(key.length).toBeGreaterThan(10)
|
||||
expect(key.length).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateEncryptedApiKey', () => {
|
||||
it('should generate key with sk-sim- prefix', () => {
|
||||
const key = generateEncryptedApiKey()
|
||||
expect(key).toMatch(/^sk-sim-/)
|
||||
})
|
||||
|
||||
it('should generate unique keys', () => {
|
||||
const key1 = generateEncryptedApiKey()
|
||||
const key2 = generateEncryptedApiKey()
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate key of valid length', () => {
|
||||
const key = generateEncryptedApiKey()
|
||||
expect(key.length).toBeGreaterThan(10)
|
||||
expect(key.length).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API key lifecycle', () => {
|
||||
it('should authenticate newly generated legacy key against itself (plain storage)', async () => {
|
||||
const key = generateApiKey()
|
||||
const result = await authenticateApiKey(key, key)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should authenticate newly generated encrypted key against encrypted storage', async () => {
|
||||
const key = generateEncryptedApiKey()
|
||||
const encryptedStorage = `mock-iv:${Buffer.from(key).toString('hex')}:mock-tag`
|
||||
const result = await authenticateApiKey(key, encryptedStorage)
|
||||
expectApiKeyValid(result)
|
||||
})
|
||||
|
||||
it('should reject key if storage is tampered', async () => {
|
||||
const key = generateApiKey()
|
||||
const tamperedStorage = `${key.slice(0, -1)}X` // Change last character
|
||||
const result = await authenticateApiKey(key, tamperedStorage)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('security considerations', () => {
|
||||
it('should not accept partial key matches', async () => {
|
||||
const fullKey = 'sim_abcdefghijklmnop'
|
||||
const partialKey = 'sim_abcdefgh'
|
||||
const result = await authenticateApiKey(partialKey, fullKey)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
|
||||
it('should not accept keys with extra characters', async () => {
|
||||
const storedKey = 'sim_abcdefgh'
|
||||
const extendedKey = 'sim_abcdefghXXX'
|
||||
const result = await authenticateApiKey(extendedKey, storedKey)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
|
||||
it('should not accept key with whitespace variations', async () => {
|
||||
const key = 'sim_testkey'
|
||||
const keyWithSpace = ' sim_testkey'
|
||||
const result = await authenticateApiKey(keyWithSpace, key)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
|
||||
it('should not accept key with trailing whitespace', async () => {
|
||||
const key = 'sim_testkey'
|
||||
const keyWithTrailing = 'sim_testkey '
|
||||
const result = await authenticateApiKey(keyWithTrailing, key)
|
||||
expectApiKeyInvalid(result)
|
||||
})
|
||||
})
|
||||
391
apps/sim/lib/chunkers/json-yaml-chunker.test.ts
Normal file
391
apps/sim/lib/chunkers/json-yaml-chunker.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { JsonYamlChunker } from './json-yaml-chunker'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/tokenization', () => ({
|
||||
getAccurateTokenCount: (text: string) => Math.ceil(text.length / 4),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/tokenization/estimators', () => ({
|
||||
estimateTokenCount: (text: string) => ({ count: Math.ceil(text.length / 4) }),
|
||||
}))
|
||||
|
||||
describe('JsonYamlChunker', () => {
|
||||
describe('isStructuredData', () => {
|
||||
it('should detect valid JSON', () => {
|
||||
expect(JsonYamlChunker.isStructuredData('{"key": "value"}')).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect valid JSON array', () => {
|
||||
expect(JsonYamlChunker.isStructuredData('[1, 2, 3]')).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect valid YAML', () => {
|
||||
expect(JsonYamlChunker.isStructuredData('key: value\nother: data')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for YAML-like plain text', () => {
|
||||
// Note: js-yaml is permissive and parses plain text as valid YAML (scalar value)
|
||||
// This is expected behavior of the YAML parser
|
||||
expect(JsonYamlChunker.isStructuredData('Hello, this is plain text.')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid JSON/YAML with unbalanced braces', () => {
|
||||
// Only truly malformed content that fails YAML parsing returns false
|
||||
expect(JsonYamlChunker.isStructuredData('{invalid: json: content: {{')).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect nested JSON objects', () => {
|
||||
const nested = JSON.stringify({ level1: { level2: { level3: 'value' } } })
|
||||
expect(JsonYamlChunker.isStructuredData(nested)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('basic chunking', () => {
|
||||
it.concurrent('should return single chunk for small JSON', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 1000 })
|
||||
const json = JSON.stringify({ name: 'test', value: 123 })
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should return empty array for empty object', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = '{}'
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
// Empty object is valid JSON, should return at least metadata
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it.concurrent('should chunk large JSON object', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 50 })
|
||||
const largeObject: Record<string, string> = {}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
largeObject[`key${i}`] = `value${i}`.repeat(10)
|
||||
}
|
||||
const json = JSON.stringify(largeObject)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should chunk large JSON array', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 50 })
|
||||
const largeArray = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `Item ${i}`,
|
||||
description: 'A description that takes some space',
|
||||
}))
|
||||
const json = JSON.stringify(largeArray)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should include token count in chunk metadata', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 1000 })
|
||||
const json = JSON.stringify({ hello: 'world' })
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].tokenCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('YAML chunking', () => {
|
||||
it.concurrent('should chunk valid YAML', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const yaml = `
|
||||
name: test
|
||||
version: 1.0.0
|
||||
config:
|
||||
debug: true
|
||||
port: 8080
|
||||
`.trim()
|
||||
const chunks = await chunker.chunk(yaml)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle YAML with arrays', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const yaml = `
|
||||
items:
|
||||
- name: first
|
||||
value: 1
|
||||
- name: second
|
||||
value: 2
|
||||
- name: third
|
||||
value: 3
|
||||
`.trim()
|
||||
const chunks = await chunker.chunk(yaml)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle YAML with nested structures', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 50 })
|
||||
const yaml = `
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
credentials:
|
||||
username: admin
|
||||
password: secret
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
`.trim()
|
||||
const chunks = await chunker.chunk(yaml)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('structured data handling', () => {
|
||||
it.concurrent('should preserve context path for nested objects', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 30 })
|
||||
const data = {
|
||||
users: [
|
||||
{ id: 1, name: 'Alice', email: 'alice@example.com' },
|
||||
{ id: 2, name: 'Bob', email: 'bob@example.com' },
|
||||
],
|
||||
}
|
||||
const json = JSON.stringify(data)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle deeply nested structures', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 50 })
|
||||
const deepObject = {
|
||||
l1: {
|
||||
l2: {
|
||||
l3: {
|
||||
l4: {
|
||||
l5: 'deep value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const json = JSON.stringify(deepObject)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle mixed arrays and objects', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const mixed = {
|
||||
settings: { theme: 'dark', language: 'en' },
|
||||
items: [1, 2, 3],
|
||||
users: [{ name: 'Alice' }, { name: 'Bob' }],
|
||||
}
|
||||
const json = JSON.stringify(mixed)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent('should handle empty array', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = '[]'
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
// Empty array should not produce chunks with meaningful content
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON with unicode keys and values', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = JSON.stringify({
|
||||
名前: '田中太郎',
|
||||
住所: '東京都渋谷区',
|
||||
})
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('名前')
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON with special characters in strings', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = JSON.stringify({
|
||||
text: 'Line 1\nLine 2\tTabbed',
|
||||
special: '!@#$%^&*()',
|
||||
quotes: '"double" and \'single\'',
|
||||
})
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON with null values', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = JSON.stringify({
|
||||
valid: 'value',
|
||||
empty: null,
|
||||
another: 'value',
|
||||
})
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('null')
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON with boolean values', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = JSON.stringify({
|
||||
active: true,
|
||||
deleted: false,
|
||||
name: 'test',
|
||||
})
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON with numeric values', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = JSON.stringify({
|
||||
integer: 42,
|
||||
float: Math.PI,
|
||||
negative: -100,
|
||||
scientific: 1.5e10,
|
||||
})
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should fall back to text chunking for invalid JSON', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 10 })
|
||||
// Create content that fails YAML parsing and is long enough to produce chunks
|
||||
const invalidJson = `{this is not valid json: content: {{${' more content here '.repeat(10)}`
|
||||
const chunks = await chunker.chunk(invalidJson)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('large inputs', () => {
|
||||
it.concurrent('should handle JSON with 1000 array items', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 200 })
|
||||
const largeArray = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `Item ${i}`,
|
||||
}))
|
||||
const json = JSON.stringify(largeArray)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON with long string values', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = JSON.stringify({
|
||||
content: 'A'.repeat(5000),
|
||||
description: 'B'.repeat(3000),
|
||||
})
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should handle deeply nested structure up to depth limit', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 50 })
|
||||
let nested: Record<string, unknown> = { value: 'deep' }
|
||||
for (let i = 0; i < 10; i++) {
|
||||
nested = { [`level${i}`]: nested }
|
||||
}
|
||||
const json = JSON.stringify(nested)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('static chunkJsonYaml method', () => {
|
||||
it.concurrent('should work with default options', async () => {
|
||||
const json = JSON.stringify({ test: 'value' })
|
||||
const chunks = await JsonYamlChunker.chunkJsonYaml(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should accept custom options', async () => {
|
||||
const largeObject: Record<string, string> = {}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
largeObject[`key${i}`] = `value${i}`.repeat(20)
|
||||
}
|
||||
const json = JSON.stringify(largeObject)
|
||||
|
||||
const chunksSmall = await JsonYamlChunker.chunkJsonYaml(json, { chunkSize: 50 })
|
||||
const chunksLarge = await JsonYamlChunker.chunkJsonYaml(json, { chunkSize: 500 })
|
||||
|
||||
expect(chunksSmall.length).toBeGreaterThan(chunksLarge.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunk metadata', () => {
|
||||
it.concurrent('should include startIndex and endIndex in metadata', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100 })
|
||||
const json = JSON.stringify({ key: 'value' })
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].metadata.startIndex).toBeDefined()
|
||||
expect(chunks[0].metadata.endIndex).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should have valid metadata indices for array chunking', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 50 })
|
||||
const largeArray = Array.from({ length: 50 }, (_, i) => ({ id: i, data: 'x'.repeat(20) }))
|
||||
const json = JSON.stringify(largeArray)
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.metadata.startIndex).toBeDefined()
|
||||
expect(chunk.metadata.endIndex).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('constructor options', () => {
|
||||
it.concurrent('should use default chunkSize when not provided', async () => {
|
||||
const chunker = new JsonYamlChunker({})
|
||||
const json = JSON.stringify({ test: 'value' })
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should respect custom minCharactersPerChunk', async () => {
|
||||
const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 20 })
|
||||
const json = JSON.stringify({ a: 1, b: 2, c: 3 })
|
||||
const chunks = await chunker.chunk(json)
|
||||
|
||||
// Should produce chunks that are valid
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
// The entire small object fits in one chunk
|
||||
expect(chunks[0].text.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
351
apps/sim/lib/chunkers/structured-data-chunker.test.ts
Normal file
351
apps/sim/lib/chunkers/structured-data-chunker.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { StructuredDataChunker } from './structured-data-chunker'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('StructuredDataChunker', () => {
|
||||
describe('isStructuredData', () => {
|
||||
it('should detect CSV content with many columns', () => {
|
||||
// Detection requires >2 delimiters per line on average
|
||||
const csv = 'name,age,city,country\nAlice,30,NYC,USA\nBob,25,LA,USA'
|
||||
expect(StructuredDataChunker.isStructuredData(csv)).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect TSV content with many columns', () => {
|
||||
// Detection requires >2 delimiters per line on average
|
||||
const tsv = 'name\tage\tcity\tcountry\nAlice\t30\tNYC\tUSA\nBob\t25\tLA\tUSA'
|
||||
expect(StructuredDataChunker.isStructuredData(tsv)).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect pipe-delimited content with many columns', () => {
|
||||
// Detection requires >2 delimiters per line on average
|
||||
const piped = 'name|age|city|country\nAlice|30|NYC|USA\nBob|25|LA|USA'
|
||||
expect(StructuredDataChunker.isStructuredData(piped)).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect CSV by mime type', () => {
|
||||
expect(StructuredDataChunker.isStructuredData('any content', 'text/csv')).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect XLSX by mime type', () => {
|
||||
expect(
|
||||
StructuredDataChunker.isStructuredData(
|
||||
'any content',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect XLS by mime type', () => {
|
||||
expect(
|
||||
StructuredDataChunker.isStructuredData('any content', 'application/vnd.ms-excel')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect TSV by mime type', () => {
|
||||
expect(
|
||||
StructuredDataChunker.isStructuredData('any content', 'text/tab-separated-values')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for plain text', () => {
|
||||
const plainText = 'This is just regular text.\nWith some lines.\nNo structure here.'
|
||||
expect(StructuredDataChunker.isStructuredData(plainText)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for single line', () => {
|
||||
expect(StructuredDataChunker.isStructuredData('just one line')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle inconsistent delimiter counts', () => {
|
||||
const inconsistent = 'name,age\nAlice,30,extra\nBob'
|
||||
// May or may not detect as structured depending on variance threshold
|
||||
const result = StructuredDataChunker.isStructuredData(inconsistent)
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunkStructuredData', () => {
|
||||
it.concurrent('should return empty array for empty content', async () => {
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData('')
|
||||
expect(chunks).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should return empty array for whitespace only', async () => {
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(' \n\n ')
|
||||
expect(chunks).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should chunk basic CSV data', async () => {
|
||||
const csv = `name,age,city
|
||||
Alice,30,New York
|
||||
Bob,25,Los Angeles
|
||||
Charlie,35,Chicago`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('Headers:')
|
||||
expect(chunks[0].text).toContain('name,age,city')
|
||||
})
|
||||
|
||||
it.concurrent('should include row count in chunks', async () => {
|
||||
const csv = `name,age
|
||||
Alice,30
|
||||
Bob,25`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('Rows')
|
||||
})
|
||||
|
||||
it.concurrent('should include sheet name when provided', async () => {
|
||||
const csv = `name,age
|
||||
Alice,30`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv, { sheetName: 'Users' })
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('Users')
|
||||
})
|
||||
|
||||
it.concurrent('should use provided headers when available', async () => {
|
||||
const data = `Alice,30
|
||||
Bob,25`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(data, {
|
||||
headers: ['Name', 'Age'],
|
||||
})
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('Name\tAge')
|
||||
})
|
||||
|
||||
it.concurrent('should chunk large datasets into multiple chunks', async () => {
|
||||
const rows = ['name,value']
|
||||
for (let i = 0; i < 500; i++) {
|
||||
rows.push(`Item${i},Value${i}`)
|
||||
}
|
||||
const csv = rows.join('\n')
|
||||
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv, { chunkSize: 200 })
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should include token count in chunk metadata', async () => {
|
||||
const csv = `name,age
|
||||
Alice,30
|
||||
Bob,25`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].tokenCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunk metadata', () => {
|
||||
it.concurrent('should include startIndex as row index', async () => {
|
||||
const csv = `header1,header2
|
||||
row1,data1
|
||||
row2,data2
|
||||
row3,data3`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].metadata.startIndex).toBeDefined()
|
||||
expect(chunks[0].metadata.startIndex).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it.concurrent('should include endIndex as row index', async () => {
|
||||
const csv = `header1,header2
|
||||
row1,data1
|
||||
row2,data2`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].metadata.endIndex).toBeDefined()
|
||||
expect(chunks[0].metadata.endIndex).toBeGreaterThanOrEqual(chunks[0].metadata.startIndex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent('should handle single data row', async () => {
|
||||
const csv = `name,age
|
||||
Alice,30`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBe(1)
|
||||
})
|
||||
|
||||
it.concurrent('should handle header only', async () => {
|
||||
const csv = 'name,age,city'
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
// Only header, no data rows
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle unicode content', async () => {
|
||||
const csv = `名前,年齢,市
|
||||
田中,30,東京
|
||||
鈴木,25,大阪`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('田中')
|
||||
})
|
||||
|
||||
it.concurrent('should handle quoted CSV fields', async () => {
|
||||
const csv = `name,description
|
||||
Alice,"Has a comma, in description"
|
||||
Bob,"Multiple
|
||||
lines"`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty cells', async () => {
|
||||
const csv = `name,age,city
|
||||
Alice,,NYC
|
||||
,25,LA
|
||||
Charlie,35,`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle long cell values', async () => {
|
||||
const csv = `name,description
|
||||
Alice,${'A'.repeat(1000)}
|
||||
Bob,${'B'.repeat(1000)}`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle many columns', async () => {
|
||||
const headers = Array.from({ length: 50 }, (_, i) => `col${i}`).join(',')
|
||||
const row = Array.from({ length: 50 }, (_, i) => `val${i}`).join(',')
|
||||
const csv = `${headers}\n${row}`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('options', () => {
|
||||
it.concurrent('should respect custom chunkSize', async () => {
|
||||
const rows = ['name,value']
|
||||
for (let i = 0; i < 200; i++) {
|
||||
rows.push(`Item${i},Value${i}`)
|
||||
}
|
||||
const csv = rows.join('\n')
|
||||
|
||||
const smallChunks = await StructuredDataChunker.chunkStructuredData(csv, { chunkSize: 100 })
|
||||
const largeChunks = await StructuredDataChunker.chunkStructuredData(csv, { chunkSize: 2000 })
|
||||
|
||||
expect(smallChunks.length).toBeGreaterThan(largeChunks.length)
|
||||
})
|
||||
|
||||
it.concurrent('should handle default options', async () => {
|
||||
const csv = `name,age
|
||||
Alice,30`
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('large inputs', () => {
|
||||
it.concurrent('should handle 10,000 rows', async () => {
|
||||
const rows = ['id,name,value']
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
rows.push(`${i},Item${i},Value${i}`)
|
||||
}
|
||||
const csv = rows.join('\n')
|
||||
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv, { chunkSize: 500 })
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
// Verify total rows are distributed across chunks
|
||||
const totalRowCount = chunks.reduce((sum, chunk) => {
|
||||
const match = chunk.text.match(/\[Rows (\d+) of data\]/)
|
||||
return sum + (match ? Number.parseInt(match[1]) : 0)
|
||||
}, 0)
|
||||
expect(totalRowCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle very wide rows', async () => {
|
||||
const columns = 100
|
||||
const headers = Array.from({ length: columns }, (_, i) => `column${i}`).join(',')
|
||||
const rows = [headers]
|
||||
for (let i = 0; i < 50; i++) {
|
||||
rows.push(Array.from({ length: columns }, (_, j) => `r${i}c${j}`).join(','))
|
||||
}
|
||||
const csv = rows.join('\n')
|
||||
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv, { chunkSize: 300 })
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delimiter detection', () => {
|
||||
it.concurrent('should handle comma delimiter', async () => {
|
||||
const csv = `a,b,c,d
|
||||
1,2,3,4
|
||||
5,6,7,8`
|
||||
expect(StructuredDataChunker.isStructuredData(csv)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle tab delimiter', async () => {
|
||||
const tsv = `a\tb\tc\td
|
||||
1\t2\t3\t4
|
||||
5\t6\t7\t8`
|
||||
expect(StructuredDataChunker.isStructuredData(tsv)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle pipe delimiter', async () => {
|
||||
const piped = `a|b|c|d
|
||||
1|2|3|4
|
||||
5|6|7|8`
|
||||
expect(StructuredDataChunker.isStructuredData(piped)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not detect with fewer than 3 delimiters per line', async () => {
|
||||
const sparse = `a,b
|
||||
1,2`
|
||||
// Only 1 comma per line, below threshold of >2
|
||||
const result = StructuredDataChunker.isStructuredData(sparse)
|
||||
// May or may not pass depending on implementation threshold
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('header handling', () => {
|
||||
it.concurrent('should include headers in each chunk by default', async () => {
|
||||
const rows = ['name,value']
|
||||
for (let i = 0; i < 100; i++) {
|
||||
rows.push(`Item${i},Value${i}`)
|
||||
}
|
||||
const csv = rows.join('\n')
|
||||
|
||||
const chunks = await StructuredDataChunker.chunkStructuredData(csv, { chunkSize: 200 })
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
// Each chunk should contain header info
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text).toContain('Headers:')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -262,4 +262,280 @@ describe('TextChunker', () => {
|
||||
expect(allText).toContain('dog')
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundary conditions', () => {
|
||||
it.concurrent('should handle text exactly at chunk size boundary', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 10 })
|
||||
// 40 characters = 10 tokens exactly
|
||||
const text = 'A'.repeat(40)
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(chunks[0].tokenCount).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('should handle text one token over chunk size', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 10 })
|
||||
// 44 characters = 11 tokens, just over limit
|
||||
const text = 'A'.repeat(44)
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it.concurrent('should handle chunkSize of 1 token', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 1 })
|
||||
const text = 'Hello world test'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should handle overlap equal to half of chunk size', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 20, chunkOverlap: 10 })
|
||||
const text = 'First sentence here. Second sentence here. Third sentence here.'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should clamp overlap to max 50% of chunk size', async () => {
|
||||
// Overlap of 60 should be clamped to 10 (50% of chunkSize 20)
|
||||
const chunker = new TextChunker({ chunkSize: 20, chunkOverlap: 60 })
|
||||
const text = 'First paragraph here.\n\nSecond paragraph here.\n\nThird paragraph here.'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle zero minCharactersPerChunk', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 10, minCharactersPerChunk: 0 })
|
||||
const text = 'A B C'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('encoding and special characters', () => {
|
||||
it.concurrent('should handle emoji characters', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = 'Hello 👋 World 🌍! This has emojis 🎉🎊🎈'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(chunks[0].text).toContain('👋')
|
||||
expect(chunks[0].text).toContain('🌍')
|
||||
})
|
||||
|
||||
it.concurrent('should handle mixed language text', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = 'English text. 中文文本。日本語テキスト。한국어 텍스트. العربية'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('English')
|
||||
expect(chunks[0].text).toContain('中文')
|
||||
expect(chunks[0].text).toContain('日本語')
|
||||
})
|
||||
|
||||
it.concurrent('should handle RTL text (Arabic/Hebrew)', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = 'مرحبا بالعالم - שלום עולם - Hello World'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
expect(chunks[0].text).toContain('مرحبا')
|
||||
expect(chunks[0].text).toContain('שלום')
|
||||
})
|
||||
|
||||
it.concurrent('should handle null characters in text', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = 'Hello\0World\0Test'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle combining diacritics', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
// e + combining acute accent
|
||||
const text = 'cafe\u0301 resume\u0301 naive\u0308'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle zero-width characters', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
// Zero-width space, zero-width non-joiner, zero-width joiner
|
||||
const text = 'Hello\u200B\u200C\u200DWorld'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle old Mac line endings (\\r)', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = 'Line 1\rLine 2\rLine 3'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks[0].text).not.toContain('\r')
|
||||
})
|
||||
})
|
||||
|
||||
describe('large inputs', () => {
|
||||
it.concurrent('should handle 10,000 word document', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = 'This is a test sentence with several words. '.repeat(2000)
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
// Verify all content is preserved
|
||||
const totalChars = chunks.reduce((sum, c) => sum + c.text.length, 0)
|
||||
expect(totalChars).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle 1MB of text', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 500 })
|
||||
// 1MB of text
|
||||
const text = 'Lorem ipsum dolor sit amet. '.repeat(40000)
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should handle very long single line', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 50 })
|
||||
// Single line with no natural break points
|
||||
const text = 'Word'.repeat(10000)
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.concurrent('should handle many short paragraphs', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = Array(500)
|
||||
.fill(0)
|
||||
.map((_, i) => `Paragraph ${i}.`)
|
||||
.join('\n\n')
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdown and code handling', () => {
|
||||
it.concurrent('should handle code blocks', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 50 })
|
||||
const text = `
|
||||
# Code Example
|
||||
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log("Hello World");
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Some explanation text after the code.
|
||||
`
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle nested lists', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 50 })
|
||||
const text = `
|
||||
- Item 1
|
||||
- Nested 1.1
|
||||
- Nested 1.2
|
||||
- Deep nested 1.2.1
|
||||
- Item 2
|
||||
- Nested 2.1
|
||||
`
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle markdown tables', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 50 })
|
||||
const text = `
|
||||
| Header 1 | Header 2 | Header 3 |
|
||||
|----------|----------|----------|
|
||||
| Cell 1 | Cell 2 | Cell 3 |
|
||||
| Cell 4 | Cell 5 | Cell 6 |
|
||||
`
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle inline code', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 100 })
|
||||
const text = 'Use `const` for constants and `let` for variables. Call `myFunction()` here.'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks[0].text).toContain('`const`')
|
||||
})
|
||||
})
|
||||
|
||||
describe('separator hierarchy', () => {
|
||||
it.concurrent('should split on horizontal rules', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 30 })
|
||||
const text = 'Section 1 content here.\n---\nSection 2 content here.\n---\nSection 3 content.'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should split on question marks', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 20 })
|
||||
const text = 'What is this? How does it work? Why is it important? When to use it?'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should split on exclamation marks', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 20 })
|
||||
const text = 'Amazing! Incredible! Fantastic! Wonderful! Great!'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should split on semicolons', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 20 })
|
||||
const text = 'First clause; second clause; third clause; fourth clause'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunk index accuracy', () => {
|
||||
it.concurrent('should have non-negative indices', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 30, chunkOverlap: 10 })
|
||||
const text = 'First part. Second part. Third part. Fourth part. Fifth part.'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.metadata.startIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(chunk.metadata.endIndex).toBeGreaterThanOrEqual(chunk.metadata.startIndex)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should have endIndex greater than or equal to startIndex', async () => {
|
||||
const chunker = new TextChunker({ chunkSize: 20 })
|
||||
const text = 'Multiple sentences here. Another one here. And another. And more.'
|
||||
const chunks = await chunker.chunk(text)
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.metadata.endIndex).toBeGreaterThanOrEqual(chunk.metadata.startIndex)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
283
apps/sim/lib/copilot/auth/permissions.test.ts
Normal file
283
apps/sim/lib/copilot/auth/permissions.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Tests for copilot auth permissions module
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { drizzleOrmMock, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Copilot Auth Permissions', () => {
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
const mockLimit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
mockLimit.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => drizzleOrmMock)
|
||||
|
||||
vi.doMock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('verifyWorkflowAccess', () => {
|
||||
it('should return no access for non-existent workflow', async () => {
|
||||
mockLimit.mockResolvedValueOnce([])
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'non-existent-workflow')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: false,
|
||||
userPermission: null,
|
||||
isOwner: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return admin access for workflow owner', async () => {
|
||||
const workflowData = {
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: true,
|
||||
userPermission: 'admin',
|
||||
workspaceId: 'workspace-456',
|
||||
isOwner: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return admin access for workflow owner without workspace', async () => {
|
||||
const workflowData = {
|
||||
userId: 'user-123',
|
||||
workspaceId: null,
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: true,
|
||||
userPermission: 'admin',
|
||||
workspaceId: undefined,
|
||||
isOwner: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should check workspace permissions for non-owner with workspace', async () => {
|
||||
const workflowData = {
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
vi.mocked(getUserEntityPermissions).mockResolvedValueOnce('write')
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: true,
|
||||
userPermission: 'write',
|
||||
workspaceId: 'workspace-456',
|
||||
isOwner: false,
|
||||
})
|
||||
|
||||
expect(getUserEntityPermissions).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
'workspace',
|
||||
'workspace-456'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return read permission through workspace', async () => {
|
||||
const workflowData = {
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
vi.mocked(getUserEntityPermissions).mockResolvedValueOnce('read')
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: true,
|
||||
userPermission: 'read',
|
||||
workspaceId: 'workspace-456',
|
||||
isOwner: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return admin permission through workspace', async () => {
|
||||
const workflowData = {
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
vi.mocked(getUserEntityPermissions).mockResolvedValueOnce('admin')
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: true,
|
||||
userPermission: 'admin',
|
||||
workspaceId: 'workspace-456',
|
||||
isOwner: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return no access for non-owner without workspace permissions', async () => {
|
||||
const workflowData = {
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
vi.mocked(getUserEntityPermissions).mockResolvedValueOnce(null)
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: false,
|
||||
userPermission: null,
|
||||
workspaceId: 'workspace-456',
|
||||
isOwner: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return no access for non-owner of workflow without workspace', async () => {
|
||||
const workflowData = {
|
||||
userId: 'other-user',
|
||||
workspaceId: null,
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: false,
|
||||
userPermission: null,
|
||||
workspaceId: undefined,
|
||||
isOwner: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
mockLimit.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: false,
|
||||
userPermission: null,
|
||||
isOwner: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle permission check errors gracefully', async () => {
|
||||
const workflowData = {
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([workflowData])
|
||||
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
vi.mocked(getUserEntityPermissions).mockRejectedValueOnce(
|
||||
new Error('Permission check failed')
|
||||
)
|
||||
|
||||
const { verifyWorkflowAccess } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = await verifyWorkflowAccess('user-123', 'workflow-789')
|
||||
|
||||
expect(result).toEqual({
|
||||
hasAccess: false,
|
||||
userPermission: null,
|
||||
isOwner: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPermissionError', () => {
|
||||
it('should create a permission error message for edit operation', async () => {
|
||||
const { createPermissionError } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = createPermissionError('edit')
|
||||
|
||||
expect(result).toBe('Access denied: You do not have permission to edit this workflow')
|
||||
})
|
||||
|
||||
it('should create a permission error message for view operation', async () => {
|
||||
const { createPermissionError } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = createPermissionError('view')
|
||||
|
||||
expect(result).toBe('Access denied: You do not have permission to view this workflow')
|
||||
})
|
||||
|
||||
it('should create a permission error message for delete operation', async () => {
|
||||
const { createPermissionError } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = createPermissionError('delete')
|
||||
|
||||
expect(result).toBe('Access denied: You do not have permission to delete this workflow')
|
||||
})
|
||||
|
||||
it('should create a permission error message for deploy operation', async () => {
|
||||
const { createPermissionError } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = createPermissionError('deploy')
|
||||
|
||||
expect(result).toBe('Access denied: You do not have permission to deploy this workflow')
|
||||
})
|
||||
|
||||
it('should create a permission error message for custom operation', async () => {
|
||||
const { createPermissionError } = await import('@/lib/copilot/auth/permissions')
|
||||
const result = createPermissionError('modify settings of')
|
||||
|
||||
expect(result).toBe(
|
||||
'Access denied: You do not have permission to modify settings of this workflow'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { RateLimiter } from './rate-limiter'
|
||||
import type { ConsumeResult, RateLimitStorageAdapter, TokenStatus } from './storage'
|
||||
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS } from './types'
|
||||
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS, RateLimitError } from './types'
|
||||
|
||||
const createMockAdapter = (): RateLimitStorageAdapter => ({
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
interface MockAdapter {
|
||||
consumeTokens: Mock
|
||||
getTokenStatus: Mock
|
||||
resetBucket: Mock
|
||||
}
|
||||
|
||||
const createMockAdapter = (): MockAdapter => ({
|
||||
consumeTokens: vi.fn(),
|
||||
getTokenStatus: vi.fn(),
|
||||
resetBucket: vi.fn(),
|
||||
@@ -12,13 +27,13 @@ const createMockAdapter = (): RateLimitStorageAdapter => ({
|
||||
describe('RateLimiter', () => {
|
||||
const testUserId = 'test-user-123'
|
||||
const freeSubscription = { plan: 'free', referenceId: testUserId }
|
||||
let mockAdapter: RateLimitStorageAdapter
|
||||
let mockAdapter: MockAdapter
|
||||
let rateLimiter: RateLimiter
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAdapter = createMockAdapter()
|
||||
rateLimiter = new RateLimiter(mockAdapter)
|
||||
rateLimiter = new RateLimiter(mockAdapter as RateLimitStorageAdapter)
|
||||
})
|
||||
|
||||
describe('checkRateLimitWithSubscription', () => {
|
||||
@@ -42,7 +57,7 @@ describe('RateLimiter', () => {
|
||||
tokensRemaining: RATE_LIMITS.free.sync.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
vi.mocked(mockAdapter.consumeTokens).mockResolvedValue(mockResult)
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
@@ -66,7 +81,7 @@ describe('RateLimiter', () => {
|
||||
tokensRemaining: RATE_LIMITS.free.async.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
vi.mocked(mockAdapter.consumeTokens).mockResolvedValue(mockResult)
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(testUserId, freeSubscription, 'api', true)
|
||||
|
||||
@@ -83,7 +98,7 @@ describe('RateLimiter', () => {
|
||||
tokensRemaining: RATE_LIMITS.free.apiEndpoint.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
vi.mocked(mockAdapter.consumeTokens).mockResolvedValue(mockResult)
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
@@ -106,7 +121,7 @@ describe('RateLimiter', () => {
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
retryAfterMs: 30000,
|
||||
}
|
||||
vi.mocked(mockAdapter.consumeTokens).mockResolvedValue(mockResult)
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
@@ -128,7 +143,7 @@ describe('RateLimiter', () => {
|
||||
tokensRemaining: RATE_LIMITS.team.sync.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
vi.mocked(mockAdapter.consumeTokens).mockResolvedValue(mockResult)
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(testUserId, teamSubscription, 'api', false)
|
||||
|
||||
@@ -146,7 +161,7 @@ describe('RateLimiter', () => {
|
||||
tokensRemaining: RATE_LIMITS.team.sync.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
vi.mocked(mockAdapter.consumeTokens).mockResolvedValue(mockResult)
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
@@ -163,7 +178,7 @@ describe('RateLimiter', () => {
|
||||
})
|
||||
|
||||
it('should deny on storage error (fail closed)', async () => {
|
||||
vi.mocked(mockAdapter.consumeTokens).mockRejectedValue(new Error('Storage error'))
|
||||
mockAdapter.consumeTokens.mockRejectedValue(new Error('Storage error'))
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
@@ -183,7 +198,7 @@ describe('RateLimiter', () => {
|
||||
tokensRemaining: 10,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
vi.mocked(mockAdapter.consumeTokens).mockResolvedValue(mockResult)
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
for (const triggerType of triggerTypes) {
|
||||
await rateLimiter.checkRateLimitWithSubscription(
|
||||
@@ -193,7 +208,7 @@ describe('RateLimiter', () => {
|
||||
false
|
||||
)
|
||||
expect(mockAdapter.consumeTokens).toHaveBeenCalled()
|
||||
vi.mocked(mockAdapter.consumeTokens).mockClear()
|
||||
mockAdapter.consumeTokens.mockClear()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -220,7 +235,7 @@ describe('RateLimiter', () => {
|
||||
lastRefillAt: new Date(),
|
||||
nextRefillAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
vi.mocked(mockAdapter.getTokenStatus).mockResolvedValue(mockStatus)
|
||||
mockAdapter.getTokenStatus.mockResolvedValue(mockStatus)
|
||||
|
||||
const status = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
testUserId,
|
||||
@@ -241,7 +256,7 @@ describe('RateLimiter', () => {
|
||||
|
||||
describe('resetRateLimit', () => {
|
||||
it('should reset all bucket types for a user', async () => {
|
||||
vi.mocked(mockAdapter.resetBucket).mockResolvedValue()
|
||||
mockAdapter.resetBucket.mockResolvedValue(undefined)
|
||||
|
||||
await rateLimiter.resetRateLimit(testUserId)
|
||||
|
||||
@@ -250,5 +265,165 @@ describe('RateLimiter', () => {
|
||||
expect(mockAdapter.resetBucket).toHaveBeenCalledWith(`${testUserId}:async`)
|
||||
expect(mockAdapter.resetBucket).toHaveBeenCalledWith(`${testUserId}:api-endpoint`)
|
||||
})
|
||||
|
||||
it('should throw error if reset fails', async () => {
|
||||
mockAdapter.resetBucket.mockRejectedValue(new Error('Reset failed'))
|
||||
|
||||
await expect(rateLimiter.resetRateLimit(testUserId)).rejects.toThrow('Reset failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription plan handling', () => {
|
||||
it('should use pro plan limits', async () => {
|
||||
const proSubscription = { plan: 'pro', referenceId: testUserId }
|
||||
const mockResult: ConsumeResult = {
|
||||
allowed: true,
|
||||
tokensRemaining: RATE_LIMITS.pro.sync.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(testUserId, proSubscription, 'api', false)
|
||||
|
||||
expect(mockAdapter.consumeTokens).toHaveBeenCalledWith(
|
||||
`${testUserId}:sync`,
|
||||
1,
|
||||
RATE_LIMITS.pro.sync
|
||||
)
|
||||
})
|
||||
|
||||
it('should use enterprise plan limits', async () => {
|
||||
const enterpriseSubscription = { plan: 'enterprise', referenceId: 'org-enterprise' }
|
||||
const mockResult: ConsumeResult = {
|
||||
allowed: true,
|
||||
tokensRemaining: RATE_LIMITS.enterprise.sync.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
enterpriseSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(mockAdapter.consumeTokens).toHaveBeenCalledWith(
|
||||
`org-enterprise:sync`,
|
||||
1,
|
||||
RATE_LIMITS.enterprise.sync
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to free plan when subscription is null', async () => {
|
||||
const mockResult: ConsumeResult = {
|
||||
allowed: true,
|
||||
tokensRemaining: RATE_LIMITS.free.sync.maxTokens - 1,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(testUserId, null, 'api', false)
|
||||
|
||||
expect(mockAdapter.consumeTokens).toHaveBeenCalledWith(
|
||||
`${testUserId}:sync`,
|
||||
1,
|
||||
RATE_LIMITS.free.sync
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schedule trigger type', () => {
|
||||
it('should use sync bucket for schedule trigger', async () => {
|
||||
const mockResult: ConsumeResult = {
|
||||
allowed: true,
|
||||
tokensRemaining: 10,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'schedule',
|
||||
false
|
||||
)
|
||||
|
||||
expect(mockAdapter.consumeTokens).toHaveBeenCalledWith(
|
||||
`${testUserId}:sync`,
|
||||
1,
|
||||
RATE_LIMITS.free.sync
|
||||
)
|
||||
})
|
||||
|
||||
it('should use async bucket for schedule trigger with isAsync true', async () => {
|
||||
const mockResult: ConsumeResult = {
|
||||
allowed: true,
|
||||
tokensRemaining: 10,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
}
|
||||
mockAdapter.consumeTokens.mockResolvedValue(mockResult)
|
||||
|
||||
await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'schedule',
|
||||
true
|
||||
)
|
||||
|
||||
expect(mockAdapter.consumeTokens).toHaveBeenCalledWith(
|
||||
`${testUserId}:async`,
|
||||
1,
|
||||
RATE_LIMITS.free.async
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRateLimitStatusWithSubscription error handling', () => {
|
||||
it('should return default config on storage error', async () => {
|
||||
mockAdapter.getTokenStatus.mockRejectedValue(new Error('Storage error'))
|
||||
|
||||
const status = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(status.remaining).toBe(0)
|
||||
expect(status.requestsPerMinute).toBe(RATE_LIMITS.free.sync.refillRate)
|
||||
expect(status.maxBurst).toBe(RATE_LIMITS.free.sync.maxTokens)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RateLimitError', () => {
|
||||
it('should create error with default status code 429', () => {
|
||||
const error = new RateLimitError('Rate limit exceeded')
|
||||
|
||||
expect(error.message).toBe('Rate limit exceeded')
|
||||
expect(error.statusCode).toBe(429)
|
||||
expect(error.name).toBe('RateLimitError')
|
||||
})
|
||||
|
||||
it('should create error with custom status code', () => {
|
||||
const error = new RateLimitError('Custom error', 503)
|
||||
|
||||
expect(error.message).toBe('Custom error')
|
||||
expect(error.statusCode).toBe(503)
|
||||
})
|
||||
|
||||
it('should be instanceof Error', () => {
|
||||
const error = new RateLimitError('Test')
|
||||
|
||||
expect(error instanceof Error).toBe(true)
|
||||
expect(error instanceof RateLimitError).toBe(true)
|
||||
})
|
||||
|
||||
it('should have proper stack trace', () => {
|
||||
const error = new RateLimitError('Test error')
|
||||
|
||||
expect(error.stack).toBeDefined()
|
||||
expect(error.stack).toContain('RateLimitError')
|
||||
})
|
||||
})
|
||||
|
||||
283
apps/sim/lib/core/security/csp.test.ts
Normal file
283
apps/sim/lib/core/security/csp.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_URL: 'https://example.com',
|
||||
NEXT_PUBLIC_SOCKET_URL: 'https://socket.example.com',
|
||||
OLLAMA_URL: 'http://localhost:11434',
|
||||
S3_BUCKET_NAME: 'test-bucket',
|
||||
AWS_REGION: 'us-east-1',
|
||||
S3_KB_BUCKET_NAME: 'test-kb-bucket',
|
||||
S3_CHAT_BUCKET_NAME: 'test-chat-bucket',
|
||||
NEXT_PUBLIC_BRAND_LOGO_URL: 'https://brand.example.com/logo.png',
|
||||
NEXT_PUBLIC_BRAND_FAVICON_URL: 'https://brand.example.com/favicon.ico',
|
||||
NEXT_PUBLIC_PRIVACY_URL: 'https://legal.example.com/privacy',
|
||||
NEXT_PUBLIC_TERMS_URL: 'https://legal.example.com/terms',
|
||||
},
|
||||
getEnv: vi.fn((key: string) => {
|
||||
const envMap: Record<string, string> = {
|
||||
NEXT_PUBLIC_APP_URL: 'https://example.com',
|
||||
NEXT_PUBLIC_SOCKET_URL: 'https://socket.example.com',
|
||||
OLLAMA_URL: 'http://localhost:11434',
|
||||
NEXT_PUBLIC_BRAND_LOGO_URL: 'https://brand.example.com/logo.png',
|
||||
NEXT_PUBLIC_BRAND_FAVICON_URL: 'https://brand.example.com/favicon.ico',
|
||||
NEXT_PUBLIC_PRIVACY_URL: 'https://legal.example.com/privacy',
|
||||
NEXT_PUBLIC_TERMS_URL: 'https://legal.example.com/terms',
|
||||
}
|
||||
return envMap[key] || ''
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isDev: false,
|
||||
}))
|
||||
|
||||
import {
|
||||
addCSPSource,
|
||||
buildCSPString,
|
||||
buildTimeCSPDirectives,
|
||||
type CSPDirectives,
|
||||
generateRuntimeCSP,
|
||||
getMainCSPPolicy,
|
||||
getWorkflowExecutionCSPPolicy,
|
||||
removeCSPSource,
|
||||
} from './csp'
|
||||
|
||||
describe('buildCSPString', () => {
|
||||
it('should build CSP string from directives', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'", "'unsafe-inline'"],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self'")
|
||||
expect(result).toContain("script-src 'self' 'unsafe-inline'")
|
||||
expect(result).toContain(';')
|
||||
})
|
||||
|
||||
it('should handle empty directives', () => {
|
||||
const directives: CSPDirectives = {}
|
||||
const result = buildCSPString(directives)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should skip empty source arrays', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': [],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self'")
|
||||
expect(result).not.toContain('script-src')
|
||||
})
|
||||
|
||||
it('should filter out empty string sources', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'", '', ' ', 'https://example.com'],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self' https://example.com")
|
||||
expect(result).not.toMatch(/\s{2,}/)
|
||||
})
|
||||
|
||||
it('should handle all directive types', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'"],
|
||||
'style-src': ["'self'"],
|
||||
'img-src': ["'self'", 'data:'],
|
||||
'media-src': ["'self'"],
|
||||
'font-src': ["'self'"],
|
||||
'connect-src': ["'self'"],
|
||||
'frame-src': ["'none'"],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'form-action': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'object-src': ["'none'"],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self'")
|
||||
expect(result).toContain("script-src 'self'")
|
||||
expect(result).toContain("object-src 'none'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMainCSPPolicy', () => {
|
||||
it('should return a valid CSP policy string', () => {
|
||||
const policy = getMainCSPPolicy()
|
||||
|
||||
expect(policy).toContain("default-src 'self'")
|
||||
expect(policy).toContain('script-src')
|
||||
expect(policy).toContain('style-src')
|
||||
expect(policy).toContain('img-src')
|
||||
})
|
||||
|
||||
it('should include security directives', () => {
|
||||
const policy = getMainCSPPolicy()
|
||||
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).toContain("frame-ancestors 'self'")
|
||||
expect(policy).toContain("form-action 'self'")
|
||||
expect(policy).toContain("base-uri 'self'")
|
||||
})
|
||||
|
||||
it('should include necessary external resources', () => {
|
||||
const policy = getMainCSPPolicy()
|
||||
|
||||
expect(policy).toContain('https://fonts.googleapis.com')
|
||||
expect(policy).toContain('https://fonts.gstatic.com')
|
||||
expect(policy).toContain('https://*.google.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkflowExecutionCSPPolicy', () => {
|
||||
it('should return permissive CSP for workflow execution', () => {
|
||||
const policy = getWorkflowExecutionCSPPolicy()
|
||||
|
||||
expect(policy).toContain('default-src *')
|
||||
expect(policy).toContain("'unsafe-inline'")
|
||||
expect(policy).toContain("'unsafe-eval'")
|
||||
expect(policy).toContain('connect-src *')
|
||||
})
|
||||
|
||||
it('should be more permissive than main CSP', () => {
|
||||
const mainPolicy = getMainCSPPolicy()
|
||||
const execPolicy = getWorkflowExecutionCSPPolicy()
|
||||
|
||||
expect(execPolicy.length).toBeLessThan(mainPolicy.length)
|
||||
expect(execPolicy).toContain('*')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateRuntimeCSP', () => {
|
||||
it('should generate CSP with runtime environment variables', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).toContain("default-src 'self'")
|
||||
expect(csp).toContain('https://example.com')
|
||||
})
|
||||
|
||||
it('should include socket URL and WebSocket variant', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).toContain('https://socket.example.com')
|
||||
expect(csp).toContain('wss://socket.example.com')
|
||||
})
|
||||
|
||||
it('should include brand URLs', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).toContain('https://brand.example.com')
|
||||
})
|
||||
|
||||
it('should not have excessive whitespace', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).not.toMatch(/\s{3,}/)
|
||||
expect(csp.trim()).toBe(csp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addCSPSource', () => {
|
||||
const originalDirectives = JSON.parse(JSON.stringify(buildTimeCSPDirectives))
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(buildTimeCSPDirectives).forEach((key) => {
|
||||
const k = key as keyof CSPDirectives
|
||||
buildTimeCSPDirectives[k] = originalDirectives[k]
|
||||
})
|
||||
})
|
||||
|
||||
it('should add a source to an existing directive', () => {
|
||||
const originalLength = buildTimeCSPDirectives['img-src']?.length || 0
|
||||
|
||||
addCSPSource('img-src', 'https://new-source.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('https://new-source.com')
|
||||
expect(buildTimeCSPDirectives['img-src']?.length).toBe(originalLength + 1)
|
||||
})
|
||||
|
||||
it('should not add duplicate sources', () => {
|
||||
addCSPSource('img-src', 'https://duplicate.com')
|
||||
const lengthAfterFirst = buildTimeCSPDirectives['img-src']?.length || 0
|
||||
|
||||
addCSPSource('img-src', 'https://duplicate.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']?.length).toBe(lengthAfterFirst)
|
||||
})
|
||||
|
||||
it('should create directive array if it does not exist', () => {
|
||||
;(buildTimeCSPDirectives as any)['worker-src'] = undefined
|
||||
|
||||
addCSPSource('script-src', 'https://worker.example.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['script-src']).toContain('https://worker.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeCSPSource', () => {
|
||||
const originalDirectives = JSON.parse(JSON.stringify(buildTimeCSPDirectives))
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(buildTimeCSPDirectives).forEach((key) => {
|
||||
const k = key as keyof CSPDirectives
|
||||
buildTimeCSPDirectives[k] = originalDirectives[k]
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove a source from an existing directive', () => {
|
||||
addCSPSource('img-src', 'https://to-remove.com')
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('https://to-remove.com')
|
||||
|
||||
removeCSPSource('img-src', 'https://to-remove.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']).not.toContain('https://to-remove.com')
|
||||
})
|
||||
|
||||
it('should handle removing non-existent source gracefully', () => {
|
||||
const originalLength = buildTimeCSPDirectives['img-src']?.length || 0
|
||||
|
||||
removeCSPSource('img-src', 'https://non-existent.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']?.length).toBe(originalLength)
|
||||
})
|
||||
|
||||
it('should handle removing from non-existent directive gracefully', () => {
|
||||
;(buildTimeCSPDirectives as any)['worker-src'] = undefined
|
||||
|
||||
expect(() => {
|
||||
removeCSPSource('script-src', 'https://anything.com')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTimeCSPDirectives', () => {
|
||||
it('should have all required security directives', () => {
|
||||
expect(buildTimeCSPDirectives['default-src']).toBeDefined()
|
||||
expect(buildTimeCSPDirectives['object-src']).toContain("'none'")
|
||||
expect(buildTimeCSPDirectives['frame-ancestors']).toContain("'self'")
|
||||
expect(buildTimeCSPDirectives['base-uri']).toContain("'self'")
|
||||
})
|
||||
|
||||
it('should have self as default source', () => {
|
||||
expect(buildTimeCSPDirectives['default-src']).toContain("'self'")
|
||||
})
|
||||
|
||||
it('should allow Google fonts', () => {
|
||||
expect(buildTimeCSPDirectives['style-src']).toContain('https://fonts.googleapis.com')
|
||||
expect(buildTimeCSPDirectives['font-src']).toContain('https://fonts.gstatic.com')
|
||||
})
|
||||
|
||||
it('should allow data: and blob: for images', () => {
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('data:')
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('blob:')
|
||||
})
|
||||
})
|
||||
196
apps/sim/lib/core/security/encryption.test.ts
Normal file
196
apps/sim/lib/core/security/encryption.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: mockEnv,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { decryptSecret, encryptSecret, generatePassword } from './encryption'
|
||||
|
||||
describe('encryptSecret', () => {
|
||||
it('should encrypt a secret and return encrypted value with IV', async () => {
|
||||
const secret = 'my-secret-value'
|
||||
const result = await encryptSecret(secret)
|
||||
|
||||
expect(result.encrypted).toBeDefined()
|
||||
expect(result.iv).toBeDefined()
|
||||
expect(result.encrypted).toContain(':')
|
||||
expect(result.iv).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('should produce different encrypted values for the same input', async () => {
|
||||
const secret = 'same-secret'
|
||||
const result1 = await encryptSecret(secret)
|
||||
const result2 = await encryptSecret(secret)
|
||||
|
||||
expect(result1.encrypted).not.toBe(result2.encrypted)
|
||||
expect(result1.iv).not.toBe(result2.iv)
|
||||
})
|
||||
|
||||
it('should encrypt empty strings', async () => {
|
||||
const result = await encryptSecret('')
|
||||
expect(result.encrypted).toBeDefined()
|
||||
expect(result.iv).toBeDefined()
|
||||
})
|
||||
|
||||
it('should encrypt long secrets', async () => {
|
||||
const longSecret = 'a'.repeat(10000)
|
||||
const result = await encryptSecret(longSecret)
|
||||
expect(result.encrypted).toBeDefined()
|
||||
})
|
||||
|
||||
it('should encrypt secrets with special characters', async () => {
|
||||
const specialSecret = '!@#$%^&*()_+-=[]{}|;\':",.<>?/`~\n\t\r'
|
||||
const result = await encryptSecret(specialSecret)
|
||||
expect(result.encrypted).toBeDefined()
|
||||
})
|
||||
|
||||
it('should encrypt unicode characters', async () => {
|
||||
const unicodeSecret = 'Hello !"#$%&\'()*+,-./0123456789:;<=>?@'
|
||||
const result = await encryptSecret(unicodeSecret)
|
||||
expect(result.encrypted).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('decryptSecret', () => {
|
||||
it('should decrypt an encrypted secret back to original value', async () => {
|
||||
const originalSecret = 'my-secret-value'
|
||||
const { encrypted } = await encryptSecret(originalSecret)
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
|
||||
expect(decrypted).toBe(originalSecret)
|
||||
})
|
||||
|
||||
it('should decrypt very short secrets', async () => {
|
||||
const { encrypted } = await encryptSecret('a')
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
expect(decrypted).toBe('a')
|
||||
})
|
||||
|
||||
it('should decrypt long secrets', async () => {
|
||||
const longSecret = 'b'.repeat(10000)
|
||||
const { encrypted } = await encryptSecret(longSecret)
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
expect(decrypted).toBe(longSecret)
|
||||
})
|
||||
|
||||
it('should decrypt secrets with special characters', async () => {
|
||||
const specialSecret = '!@#$%^&*()_+-=[]{}|;\':",.<>?/`~\n\t\r'
|
||||
const { encrypted } = await encryptSecret(specialSecret)
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
expect(decrypted).toBe(specialSecret)
|
||||
})
|
||||
|
||||
it('should throw error for invalid encrypted format (missing parts)', async () => {
|
||||
await expect(decryptSecret('invalid')).rejects.toThrow(
|
||||
'Invalid encrypted value format. Expected "iv:encrypted:authTag"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error for invalid encrypted format (only two parts)', async () => {
|
||||
await expect(decryptSecret('part1:part2')).rejects.toThrow(
|
||||
'Invalid encrypted value format. Expected "iv:encrypted:authTag"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error for tampered ciphertext', async () => {
|
||||
const { encrypted } = await encryptSecret('original-secret')
|
||||
const parts = encrypted.split(':')
|
||||
parts[1] = `tampered${parts[1].slice(8)}`
|
||||
const tamperedEncrypted = parts.join(':')
|
||||
|
||||
await expect(decryptSecret(tamperedEncrypted)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for tampered auth tag', async () => {
|
||||
const { encrypted } = await encryptSecret('original-secret')
|
||||
const parts = encrypted.split(':')
|
||||
parts[2] = '00000000000000000000000000000000'
|
||||
const tamperedEncrypted = parts.join(':')
|
||||
|
||||
await expect(decryptSecret(tamperedEncrypted)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid IV', async () => {
|
||||
const { encrypted } = await encryptSecret('original-secret')
|
||||
const parts = encrypted.split(':')
|
||||
parts[0] = '00000000000000000000000000000000'
|
||||
const tamperedEncrypted = parts.join(':')
|
||||
|
||||
await expect(decryptSecret(tamperedEncrypted)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generatePassword', () => {
|
||||
it('should generate password with default length of 24', () => {
|
||||
const password = generatePassword()
|
||||
expect(password).toHaveLength(24)
|
||||
})
|
||||
|
||||
it('should generate password with custom length', () => {
|
||||
const password = generatePassword(32)
|
||||
expect(password).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('should generate password with minimum length', () => {
|
||||
const password = generatePassword(1)
|
||||
expect(password).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should generate different passwords on each call', () => {
|
||||
const passwords = new Set()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
passwords.add(generatePassword())
|
||||
}
|
||||
expect(passwords.size).toBeGreaterThan(90)
|
||||
})
|
||||
|
||||
it('should only contain allowed characters', () => {
|
||||
const allowedChars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+='
|
||||
const password = generatePassword(1000)
|
||||
|
||||
for (const char of password) {
|
||||
expect(allowedChars).toContain(char)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle zero length', () => {
|
||||
const password = generatePassword(0)
|
||||
expect(password).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('encryption key validation', () => {
|
||||
const originalEnv = { ...mockEnv }
|
||||
|
||||
afterEach(() => {
|
||||
mockEnv.ENCRYPTION_KEY = originalEnv.ENCRYPTION_KEY
|
||||
})
|
||||
|
||||
it('should throw error when ENCRYPTION_KEY is not set', async () => {
|
||||
mockEnv.ENCRYPTION_KEY = ''
|
||||
await expect(encryptSecret('test')).rejects.toThrow(
|
||||
'ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when ENCRYPTION_KEY is wrong length', async () => {
|
||||
mockEnv.ENCRYPTION_KEY = '0123456789abcdef'
|
||||
await expect(encryptSecret('test')).rejects.toThrow(
|
||||
'ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createPinnedUrl,
|
||||
validateAlphanumericId,
|
||||
validateEnum,
|
||||
validateExternalUrl,
|
||||
validateFileExtension,
|
||||
validateGoogleCalendarId,
|
||||
validateHostname,
|
||||
validateImageUrl,
|
||||
validateInteger,
|
||||
validateJiraCloudId,
|
||||
validateJiraIssueKey,
|
||||
validateMicrosoftGraphId,
|
||||
validateNumericId,
|
||||
validatePathSegment,
|
||||
validateProxyUrl,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation'
|
||||
import { sanitizeForLogging } from '@/lib/core/security/redaction'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('validatePathSegment', () => {
|
||||
describe('valid inputs', () => {
|
||||
it.concurrent('should accept alphanumeric strings', () => {
|
||||
@@ -621,3 +638,503 @@ describe('createPinnedUrl', () => {
|
||||
expect(result).toBe('https://93.184.216.34/a/b/c/d')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateInteger', () => {
|
||||
describe('valid integers', () => {
|
||||
it.concurrent('should accept positive integers', () => {
|
||||
const result = validateInteger(42, 'count')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept zero', () => {
|
||||
const result = validateInteger(0, 'count')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept negative integers', () => {
|
||||
const result = validateInteger(-10, 'offset')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid integers', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateInteger(null, 'value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject undefined', () => {
|
||||
const result = validateInteger(undefined, 'value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject strings', () => {
|
||||
const result = validateInteger('42' as any, 'value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('must be a number')
|
||||
})
|
||||
|
||||
it.concurrent('should reject floating point numbers', () => {
|
||||
const result = validateInteger(3.14, 'value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('must be an integer')
|
||||
})
|
||||
|
||||
it.concurrent('should reject NaN', () => {
|
||||
const result = validateInteger(Number.NaN, 'value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('valid number')
|
||||
})
|
||||
|
||||
it.concurrent('should reject Infinity', () => {
|
||||
const result = validateInteger(Number.POSITIVE_INFINITY, 'value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('valid number')
|
||||
})
|
||||
|
||||
it.concurrent('should reject negative Infinity', () => {
|
||||
const result = validateInteger(Number.NEGATIVE_INFINITY, 'value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('valid number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('min/max constraints', () => {
|
||||
it.concurrent('should accept values within range', () => {
|
||||
const result = validateInteger(50, 'value', { min: 0, max: 100 })
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should reject values below min', () => {
|
||||
const result = validateInteger(-1, 'value', { min: 0 })
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('at least 0')
|
||||
})
|
||||
|
||||
it.concurrent('should reject values above max', () => {
|
||||
const result = validateInteger(101, 'value', { max: 100 })
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('at most 100')
|
||||
})
|
||||
|
||||
it.concurrent('should accept value equal to min', () => {
|
||||
const result = validateInteger(0, 'value', { min: 0 })
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept value equal to max', () => {
|
||||
const result = validateInteger(100, 'value', { max: 100 })
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMicrosoftGraphId', () => {
|
||||
describe('valid IDs', () => {
|
||||
it.concurrent('should accept simple alphanumeric IDs', () => {
|
||||
const result = validateMicrosoftGraphId('abc123')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept GUIDs', () => {
|
||||
const result = validateMicrosoftGraphId('12345678-1234-1234-1234-123456789012')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept "root" literal', () => {
|
||||
const result = validateMicrosoftGraphId('root')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept complex SharePoint paths', () => {
|
||||
const result = validateMicrosoftGraphId('hostname:/sites/sitename')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept group paths', () => {
|
||||
const result = validateMicrosoftGraphId('groups/abc123/sites/root')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid IDs', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateMicrosoftGraphId(null)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject empty string', () => {
|
||||
const result = validateMicrosoftGraphId('')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject path traversal ../)', () => {
|
||||
const result = validateMicrosoftGraphId('../etc/passwd')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('path traversal')
|
||||
})
|
||||
|
||||
it.concurrent('should reject URL-encoded path traversal', () => {
|
||||
const result = validateMicrosoftGraphId('%2e%2e%2f')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('path traversal')
|
||||
})
|
||||
|
||||
it.concurrent('should reject double-encoded path traversal', () => {
|
||||
const result = validateMicrosoftGraphId('%252e%252e%252f')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('path traversal')
|
||||
})
|
||||
|
||||
it.concurrent('should reject null bytes', () => {
|
||||
const result = validateMicrosoftGraphId('test\0value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('control characters')
|
||||
})
|
||||
|
||||
it.concurrent('should reject URL-encoded null bytes', () => {
|
||||
const result = validateMicrosoftGraphId('test%00value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('control characters')
|
||||
})
|
||||
|
||||
it.concurrent('should reject newline characters', () => {
|
||||
const result = validateMicrosoftGraphId('test\nvalue')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('control characters')
|
||||
})
|
||||
|
||||
it.concurrent('should reject carriage return characters', () => {
|
||||
const result = validateMicrosoftGraphId('test\rvalue')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('control characters')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateJiraCloudId', () => {
|
||||
describe('valid IDs', () => {
|
||||
it.concurrent('should accept alphanumeric IDs', () => {
|
||||
const result = validateJiraCloudId('abc123')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept IDs with hyphens', () => {
|
||||
const result = validateJiraCloudId('12345678-1234-1234-1234-123456789012')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid IDs', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateJiraCloudId(null)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject empty string', () => {
|
||||
const result = validateJiraCloudId('')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject path traversal', () => {
|
||||
const result = validateJiraCloudId('../etc')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject dots', () => {
|
||||
const result = validateJiraCloudId('test.value')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject underscores', () => {
|
||||
const result = validateJiraCloudId('test_value')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateJiraIssueKey', () => {
|
||||
describe('valid issue keys', () => {
|
||||
it.concurrent('should accept PROJECT-123 format', () => {
|
||||
const result = validateJiraIssueKey('PROJECT-123')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept lowercase keys', () => {
|
||||
const result = validateJiraIssueKey('proj-456')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept mixed case', () => {
|
||||
const result = validateJiraIssueKey('MyProject-789')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid issue keys', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateJiraIssueKey(null)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject empty string', () => {
|
||||
const result = validateJiraIssueKey('')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject path traversal', () => {
|
||||
const result = validateJiraIssueKey('../etc')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject dots', () => {
|
||||
const result = validateJiraIssueKey('PROJECT.123')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateExternalUrl', () => {
|
||||
describe('valid URLs', () => {
|
||||
it.concurrent('should accept https URLs', () => {
|
||||
const result = validateExternalUrl('https://example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept URLs with paths', () => {
|
||||
const result = validateExternalUrl('https://api.example.com/v1/data')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept URLs with query strings', () => {
|
||||
const result = validateExternalUrl('https://example.com?foo=bar')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept URLs with standard ports', () => {
|
||||
const result = validateExternalUrl('https://example.com:443/api')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid URLs', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateExternalUrl(null)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject empty string', () => {
|
||||
const result = validateExternalUrl('')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject http URLs', () => {
|
||||
const result = validateExternalUrl('http://example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('https://')
|
||||
})
|
||||
|
||||
it.concurrent('should reject invalid URLs', () => {
|
||||
const result = validateExternalUrl('not-a-url')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('valid URL')
|
||||
})
|
||||
|
||||
it.concurrent('should reject localhost', () => {
|
||||
const result = validateExternalUrl('https://localhost/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('localhost')
|
||||
})
|
||||
|
||||
it.concurrent('should reject 127.0.0.1', () => {
|
||||
const result = validateExternalUrl('https://127.0.0.1/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('localhost')
|
||||
})
|
||||
|
||||
it.concurrent('should reject 0.0.0.0', () => {
|
||||
const result = validateExternalUrl('https://0.0.0.0/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('localhost')
|
||||
})
|
||||
})
|
||||
|
||||
describe('private IP ranges', () => {
|
||||
it.concurrent('should reject 10.x.x.x', () => {
|
||||
const result = validateExternalUrl('https://10.0.0.1/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('private IP')
|
||||
})
|
||||
|
||||
it.concurrent('should reject 172.16.x.x', () => {
|
||||
const result = validateExternalUrl('https://172.16.0.1/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('private IP')
|
||||
})
|
||||
|
||||
it.concurrent('should reject 192.168.x.x', () => {
|
||||
const result = validateExternalUrl('https://192.168.1.1/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('private IP')
|
||||
})
|
||||
|
||||
it.concurrent('should reject link-local 169.254.x.x', () => {
|
||||
const result = validateExternalUrl('https://169.254.169.254/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('private IP')
|
||||
})
|
||||
})
|
||||
|
||||
describe('blocked ports', () => {
|
||||
it.concurrent('should reject SSH port 22', () => {
|
||||
const result = validateExternalUrl('https://example.com:22/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('blocked port')
|
||||
})
|
||||
|
||||
it.concurrent('should reject MySQL port 3306', () => {
|
||||
const result = validateExternalUrl('https://example.com:3306/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('blocked port')
|
||||
})
|
||||
|
||||
it.concurrent('should reject PostgreSQL port 5432', () => {
|
||||
const result = validateExternalUrl('https://example.com:5432/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('blocked port')
|
||||
})
|
||||
|
||||
it.concurrent('should reject Redis port 6379', () => {
|
||||
const result = validateExternalUrl('https://example.com:6379/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('blocked port')
|
||||
})
|
||||
|
||||
it.concurrent('should reject MongoDB port 27017', () => {
|
||||
const result = validateExternalUrl('https://example.com:27017/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('blocked port')
|
||||
})
|
||||
|
||||
it.concurrent('should reject Elasticsearch port 9200', () => {
|
||||
const result = validateExternalUrl('https://example.com:9200/api')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('blocked port')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateImageUrl', () => {
|
||||
it.concurrent('should accept valid image URLs', () => {
|
||||
const result = validateImageUrl('https://example.com/image.png')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should reject localhost URLs', () => {
|
||||
const result = validateImageUrl('https://localhost/image.png')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should use imageUrl as default param name', () => {
|
||||
const result = validateImageUrl(null)
|
||||
expect(result.error).toContain('imageUrl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateProxyUrl', () => {
|
||||
it.concurrent('should accept valid proxy URLs', () => {
|
||||
const result = validateProxyUrl('https://proxy.example.com/api')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should reject private IPs', () => {
|
||||
const result = validateProxyUrl('https://192.168.1.1:8080')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should use proxyUrl as default param name', () => {
|
||||
const result = validateProxyUrl(null)
|
||||
expect(result.error).toContain('proxyUrl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateGoogleCalendarId', () => {
|
||||
describe('valid calendar IDs', () => {
|
||||
it.concurrent('should accept "primary"', () => {
|
||||
const result = validateGoogleCalendarId('primary')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.sanitized).toBe('primary')
|
||||
})
|
||||
|
||||
it.concurrent('should accept email addresses', () => {
|
||||
const result = validateGoogleCalendarId('user@example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.sanitized).toBe('user@example.com')
|
||||
})
|
||||
|
||||
it.concurrent('should accept Google calendar format', () => {
|
||||
const result = validateGoogleCalendarId('en.usa#holiday@group.v.calendar.google.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept alphanumeric IDs with allowed characters', () => {
|
||||
const result = validateGoogleCalendarId('abc123_def-456')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid calendar IDs', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateGoogleCalendarId(null)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject empty string', () => {
|
||||
const result = validateGoogleCalendarId('')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject path traversal', () => {
|
||||
const result = validateGoogleCalendarId('../etc/passwd')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('path traversal')
|
||||
})
|
||||
|
||||
it.concurrent('should reject URL-encoded path traversal', () => {
|
||||
const result = validateGoogleCalendarId('%2e%2e%2f')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('path traversal')
|
||||
})
|
||||
|
||||
it.concurrent('should reject null bytes', () => {
|
||||
const result = validateGoogleCalendarId('test\0value')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('control characters')
|
||||
})
|
||||
|
||||
it.concurrent('should reject newline characters', () => {
|
||||
const result = validateGoogleCalendarId('test\nvalue')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('control characters')
|
||||
})
|
||||
|
||||
it.concurrent('should reject IDs exceeding 255 characters', () => {
|
||||
const longId = 'a'.repeat(256)
|
||||
const result = validateGoogleCalendarId(longId)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('maximum length')
|
||||
})
|
||||
|
||||
it.concurrent('should reject invalid characters', () => {
|
||||
const result = validateGoogleCalendarId('test<script>alert(1)</script>')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('format is invalid')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
sanitizeForLogging,
|
||||
} from './redaction'
|
||||
|
||||
/**
|
||||
* Security-focused edge case tests for redaction utilities
|
||||
*/
|
||||
|
||||
describe('REDACTED_MARKER', () => {
|
||||
it.concurrent('should be the standard marker', () => {
|
||||
expect(REDACTED_MARKER).toBe('[REDACTED]')
|
||||
@@ -389,3 +393,285 @@ describe('sanitizeEventData', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Security edge cases', () => {
|
||||
describe('redactApiKeys security', () => {
|
||||
it.concurrent('should handle objects with prototype-like key names safely', () => {
|
||||
const obj = {
|
||||
protoField: { isAdmin: true },
|
||||
name: 'test',
|
||||
apiKey: 'secret',
|
||||
}
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.name).toBe('test')
|
||||
expect(result.protoField).toEqual({ isAdmin: true })
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle objects with constructor key', () => {
|
||||
const obj = {
|
||||
constructor: 'test-value',
|
||||
normalField: 'normal',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.constructor).toBe('test-value')
|
||||
expect(result.normalField).toBe('normal')
|
||||
})
|
||||
|
||||
it.concurrent('should handle objects with toString key', () => {
|
||||
const obj = {
|
||||
toString: 'custom-tostring',
|
||||
valueOf: 'custom-valueof',
|
||||
apiKey: 'secret',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.toString).toBe('custom-tostring')
|
||||
expect(result.valueOf).toBe('custom-valueof')
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should not mutate original object', () => {
|
||||
const original = {
|
||||
apiKey: 'secret-key',
|
||||
nested: {
|
||||
password: 'secret-password',
|
||||
},
|
||||
}
|
||||
|
||||
const originalCopy = JSON.parse(JSON.stringify(original))
|
||||
redactApiKeys(original)
|
||||
|
||||
expect(original).toEqual(originalCopy)
|
||||
})
|
||||
|
||||
it.concurrent('should handle very deeply nested structures', () => {
|
||||
let obj: any = { data: 'value' }
|
||||
for (let i = 0; i < 50; i++) {
|
||||
obj = { nested: obj, apiKey: `secret-${i}` }
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
expect(result.nested.apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle arrays with mixed types', () => {
|
||||
const arr = [
|
||||
{ apiKey: 'secret' },
|
||||
'string',
|
||||
123,
|
||||
null,
|
||||
undefined,
|
||||
true,
|
||||
[{ password: 'nested' }],
|
||||
]
|
||||
|
||||
const result = redactApiKeys(arr)
|
||||
|
||||
expect(result[0].apiKey).toBe('[REDACTED]')
|
||||
expect(result[1]).toBe('string')
|
||||
expect(result[2]).toBe(123)
|
||||
expect(result[3]).toBe(null)
|
||||
expect(result[4]).toBe(undefined)
|
||||
expect(result[5]).toBe(true)
|
||||
expect(result[6][0].password).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty arrays', () => {
|
||||
const result = redactApiKeys([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty objects', () => {
|
||||
const result = redactApiKeys({})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactSensitiveValues security', () => {
|
||||
it.concurrent('should handle multiple API key patterns in one string', () => {
|
||||
const input = 'Keys: sk-abc123defghijklmnopqr and pk-xyz789abcdefghijklmnop'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).not.toContain('sk-abc123defghijklmnopqr')
|
||||
expect(result).not.toContain('pk-xyz789abcdefghijklmnop')
|
||||
expect(result.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it.concurrent('should handle multiline strings with sensitive data', () => {
|
||||
const input = `Line 1: Bearer token123abc456def
|
||||
Line 2: password: "secretpass"
|
||||
Line 3: Normal content`
|
||||
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('token123abc456def')
|
||||
expect(result).not.toContain('secretpass')
|
||||
expect(result).toContain('Normal content')
|
||||
})
|
||||
|
||||
it.concurrent('should handle unicode in strings', () => {
|
||||
const input = 'Bearer abc123'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('abc123')
|
||||
})
|
||||
|
||||
it.concurrent('should handle very long strings', () => {
|
||||
const longSecret = 'a'.repeat(10000)
|
||||
const input = `Bearer ${longSecret}`
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result.length).toBeLessThan(input.length)
|
||||
})
|
||||
|
||||
it.concurrent('should not match partial patterns', () => {
|
||||
const input = 'This is a Bear without er suffix'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it.concurrent('should handle special regex characters safely', () => {
|
||||
const input = 'Test with special chars: $^.*+?()[]{}|'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeEventData security', () => {
|
||||
it.concurrent('should strip sensitive keys entirely (not redact)', () => {
|
||||
const event = {
|
||||
action: 'login',
|
||||
apiKey: 'should-be-stripped',
|
||||
password: 'should-be-stripped',
|
||||
userId: '123',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
expect(result).not.toHaveProperty('password')
|
||||
expect(Object.keys(result)).not.toContain('apiKey')
|
||||
expect(Object.keys(result)).not.toContain('password')
|
||||
})
|
||||
|
||||
it.concurrent('should handle Symbol keys gracefully', () => {
|
||||
const sym = Symbol('test')
|
||||
const event: any = {
|
||||
[sym]: 'symbol-value',
|
||||
normalKey: 'normal-value',
|
||||
}
|
||||
|
||||
expect(() => sanitizeEventData(event)).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should handle Date objects as objects', () => {
|
||||
const date = new Date('2024-01-01')
|
||||
const event = {
|
||||
createdAt: date,
|
||||
apiKey: 'secret',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.createdAt).toBeDefined()
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
})
|
||||
|
||||
it.concurrent('should handle objects with numeric keys', () => {
|
||||
const event: any = {
|
||||
0: 'first',
|
||||
1: 'second',
|
||||
apiKey: 'secret',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result[0]).toBe('first')
|
||||
expect(result[1]).toBe('second')
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSensitiveKey security', () => {
|
||||
it.concurrent('should handle case variations', () => {
|
||||
expect(isSensitiveKey('APIKEY')).toBe(true)
|
||||
expect(isSensitiveKey('ApiKey')).toBe(true)
|
||||
expect(isSensitiveKey('apikey')).toBe(true)
|
||||
expect(isSensitiveKey('API_KEY')).toBe(true)
|
||||
expect(isSensitiveKey('api_key')).toBe(true)
|
||||
expect(isSensitiveKey('Api_Key')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty string', () => {
|
||||
expect(isSensitiveKey('')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle very long key names', () => {
|
||||
const longKey = `${'a'.repeat(10000)}password`
|
||||
expect(isSensitiveKey(longKey)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle keys with special characters', () => {
|
||||
expect(isSensitiveKey('api-key')).toBe(true)
|
||||
expect(isSensitiveKey('api_key')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect oauth tokens', () => {
|
||||
expect(isSensitiveKey('access_token')).toBe(true)
|
||||
expect(isSensitiveKey('refresh_token')).toBe(true)
|
||||
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||
expect(isSensitiveKey('refreshToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect various credential patterns', () => {
|
||||
expect(isSensitiveKey('userCredential')).toBe(true)
|
||||
expect(isSensitiveKey('dbCredential')).toBe(true)
|
||||
expect(isSensitiveKey('appCredential')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeForLogging edge cases', () => {
|
||||
it.concurrent('should handle string with only sensitive content', () => {
|
||||
const input = 'Bearer abc123xyz456'
|
||||
const result = sanitizeForLogging(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('abc123xyz456')
|
||||
})
|
||||
|
||||
it.concurrent('should truncate strings to specified length', () => {
|
||||
const longString = 'a'.repeat(200)
|
||||
const result = sanitizeForLogging(longString, 60)
|
||||
|
||||
expect(result.length).toBe(60)
|
||||
})
|
||||
|
||||
it.concurrent('should handle maxLength of 0', () => {
|
||||
const result = sanitizeForLogging('test', 0)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should handle negative maxLength gracefully', () => {
|
||||
const result = sanitizeForLogging('test', -5)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should handle maxLength larger than string', () => {
|
||||
const input = 'short'
|
||||
const result = sanitizeForLogging(input, 1000)
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
161
apps/sim/lib/logs/console/logger.test.ts
Normal file
161
apps/sim/lib/logs/console/logger.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Ensure we use the real logger module, not any mocks from other tests
|
||||
vi.unmock('@/lib/logs/console/logger')
|
||||
|
||||
import { createLogger, Logger, LogLevel } from '@/lib/logs/console/logger'
|
||||
|
||||
/**
|
||||
* Tests for the console logger module.
|
||||
* Tests the Logger class and createLogger factory function.
|
||||
*/
|
||||
|
||||
describe('Logger', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('class instantiation', () => {
|
||||
test('should create logger instance with module name', () => {
|
||||
const logger = new Logger('TestModule')
|
||||
expect(logger).toBeDefined()
|
||||
expect(logger).toBeInstanceOf(Logger)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLogger factory', () => {
|
||||
test('should create logger instance with expected methods', () => {
|
||||
const logger = createLogger('MyComponent')
|
||||
expect(logger).toBeDefined()
|
||||
expect(typeof logger.debug).toBe('function')
|
||||
expect(typeof logger.info).toBe('function')
|
||||
expect(typeof logger.warn).toBe('function')
|
||||
expect(typeof logger.error).toBe('function')
|
||||
})
|
||||
|
||||
test('should create multiple independent loggers', () => {
|
||||
const logger1 = createLogger('Component1')
|
||||
const logger2 = createLogger('Component2')
|
||||
expect(logger1).not.toBe(logger2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LogLevel enum', () => {
|
||||
test('should have correct log levels', () => {
|
||||
expect(LogLevel.DEBUG).toBe('DEBUG')
|
||||
expect(LogLevel.INFO).toBe('INFO')
|
||||
expect(LogLevel.WARN).toBe('WARN')
|
||||
expect(LogLevel.ERROR).toBe('ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logging methods', () => {
|
||||
test('should have debug method', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(typeof logger.debug).toBe('function')
|
||||
})
|
||||
|
||||
test('should have info method', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(typeof logger.info).toBe('function')
|
||||
})
|
||||
|
||||
test('should have warn method', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(typeof logger.warn).toBe('function')
|
||||
})
|
||||
|
||||
test('should have error method', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(typeof logger.error).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logging behavior', () => {
|
||||
test('should not throw when calling debug', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(() => logger.debug('Test debug message')).not.toThrow()
|
||||
})
|
||||
|
||||
test('should not throw when calling info', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(() => logger.info('Test info message')).not.toThrow()
|
||||
})
|
||||
|
||||
test('should not throw when calling warn', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(() => logger.warn('Test warn message')).not.toThrow()
|
||||
})
|
||||
|
||||
test('should not throw when calling error', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
expect(() => logger.error('Test error message')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('object formatting', () => {
|
||||
test('should handle null and undefined arguments', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
|
||||
expect(() => {
|
||||
logger.info('Message with null:', null)
|
||||
logger.info('Message with undefined:', undefined)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('should handle object arguments', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
const testObj = { key: 'value', nested: { data: 123 } }
|
||||
|
||||
expect(() => {
|
||||
logger.info('Message with object:', testObj)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('should handle Error objects', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
const testError = new Error('Test error message')
|
||||
|
||||
expect(() => {
|
||||
logger.error('An error occurred:', testError)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('should handle circular references gracefully', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
const circularObj: Record<string, unknown> = { name: 'test' }
|
||||
circularObj.self = circularObj
|
||||
|
||||
expect(() => {
|
||||
logger.info('Circular object:', circularObj)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('should handle arrays', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
const testArray = [1, 2, 3, { nested: true }]
|
||||
|
||||
expect(() => {
|
||||
logger.info('Array data:', testArray)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('should handle multiple arguments', () => {
|
||||
const logger = createLogger('TestModule')
|
||||
|
||||
expect(() => {
|
||||
logger.debug('Multiple args:', 'string', 123, { obj: true }, ['array'])
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,118 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest'
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { ExecutionLogger } from '@/lib/logs/execution/logger'
|
||||
|
||||
// Mock database module
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn(() => Promise.resolve([])),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: vi.fn(() => Promise.resolve([])),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
returning: vi.fn(() => Promise.resolve([])),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock database schema
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
member: {},
|
||||
userStats: {},
|
||||
user: {},
|
||||
workflow: {},
|
||||
workflowExecutionLogs: {},
|
||||
}))
|
||||
|
||||
// Mock billing modules
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn(() => Promise.resolve(null)),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage', () => ({
|
||||
checkUsageStatus: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
usageData: { limit: 100, percentUsed: 50, currentUsage: 50 },
|
||||
})
|
||||
),
|
||||
getOrgUsageLimit: vi.fn(() => Promise.resolve({ limit: 1000 })),
|
||||
maybeSendUsageThresholdEmail: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
logWorkflowUsageBatch: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/threshold-billing', () => ({
|
||||
checkAndBillOverageThreshold: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isBillingEnabled: false,
|
||||
}))
|
||||
|
||||
// Mock security module
|
||||
vi.mock('@/lib/core/security/redaction', () => ({
|
||||
redactApiKeys: vi.fn((data) => data),
|
||||
}))
|
||||
|
||||
// Mock display filters
|
||||
vi.mock('@/lib/core/utils/display-filters', () => ({
|
||||
filterForDisplay: vi.fn((data) => data),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
// Mock events
|
||||
vi.mock('@/lib/logs/events', () => ({
|
||||
emitWorkflowExecutionCompleted: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
// Mock snapshot service
|
||||
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
|
||||
snapshotService: {
|
||||
createSnapshotWithDeduplication: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
snapshot: {
|
||||
id: 'snapshot-123',
|
||||
workflowId: 'workflow-123',
|
||||
stateHash: 'hash-123',
|
||||
stateData: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
isNew: true,
|
||||
})
|
||||
),
|
||||
getSnapshot: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'snapshot-123',
|
||||
workflowId: 'workflow-123',
|
||||
stateHash: 'hash-123',
|
||||
stateData: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ExecutionLogger', () => {
|
||||
let logger: ExecutionLogger
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new ExecutionLogger()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('class instantiation', () => {
|
||||
@@ -14,4 +121,287 @@ describe('ExecutionLogger', () => {
|
||||
expect(logger).toBeInstanceOf(ExecutionLogger)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interface implementation', () => {
|
||||
test('should have startWorkflowExecution method', () => {
|
||||
expect(typeof logger.startWorkflowExecution).toBe('function')
|
||||
})
|
||||
|
||||
test('should have completeWorkflowExecution method', () => {
|
||||
expect(typeof logger.completeWorkflowExecution).toBe('function')
|
||||
})
|
||||
|
||||
test('should have getWorkflowExecution method', () => {
|
||||
expect(typeof logger.getWorkflowExecution).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('file extraction', () => {
|
||||
test('should extract files from trace spans with files property', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
|
||||
// Access the private method through the class prototype
|
||||
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
|
||||
loggerInstance
|
||||
)
|
||||
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
output: {
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
url: 'https://example.com/file.pdf',
|
||||
key: 'uploads/file.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const files = extractFilesMethod(traceSpans, null, null)
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0].name).toBe('test.pdf')
|
||||
expect(files[0].id).toBe('file-1')
|
||||
})
|
||||
|
||||
test('should extract files from attachments property', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
|
||||
loggerInstance
|
||||
)
|
||||
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
output: {
|
||||
attachments: [
|
||||
{
|
||||
id: 'attach-1',
|
||||
name: 'attachment.docx',
|
||||
size: 2048,
|
||||
type: 'application/docx',
|
||||
url: 'https://example.com/attach.docx',
|
||||
key: 'attachments/attach.docx',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const files = extractFilesMethod(traceSpans, null, null)
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0].name).toBe('attachment.docx')
|
||||
})
|
||||
|
||||
test('should deduplicate files with same ID', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
|
||||
loggerInstance
|
||||
)
|
||||
|
||||
const duplicateFile = {
|
||||
id: 'file-1',
|
||||
name: 'test.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
url: 'https://example.com/file.pdf',
|
||||
key: 'uploads/file.pdf',
|
||||
}
|
||||
|
||||
const traceSpans = [
|
||||
{ id: 'span-1', output: { files: [duplicateFile] } },
|
||||
{ id: 'span-2', output: { files: [duplicateFile] } },
|
||||
]
|
||||
|
||||
const files = extractFilesMethod(traceSpans, null, null)
|
||||
expect(files).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('should extract files from final output', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
|
||||
loggerInstance
|
||||
)
|
||||
|
||||
const finalOutput = {
|
||||
files: [
|
||||
{
|
||||
id: 'output-file-1',
|
||||
name: 'output.txt',
|
||||
size: 512,
|
||||
type: 'text/plain',
|
||||
url: 'https://example.com/output.txt',
|
||||
key: 'outputs/output.txt',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const files = extractFilesMethod([], finalOutput, null)
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0].name).toBe('output.txt')
|
||||
})
|
||||
|
||||
test('should extract files from workflow input', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
|
||||
loggerInstance
|
||||
)
|
||||
|
||||
const workflowInput = {
|
||||
files: [
|
||||
{
|
||||
id: 'input-file-1',
|
||||
name: 'input.csv',
|
||||
size: 256,
|
||||
type: 'text/csv',
|
||||
url: 'https://example.com/input.csv',
|
||||
key: 'inputs/input.csv',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const files = extractFilesMethod([], null, workflowInput)
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0].name).toBe('input.csv')
|
||||
})
|
||||
|
||||
test('should handle empty inputs', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
|
||||
loggerInstance
|
||||
)
|
||||
|
||||
const files = extractFilesMethod(undefined, undefined, undefined)
|
||||
expect(files).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should handle deeply nested file objects', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const extractFilesMethod = (loggerInstance as any).extractFilesFromExecution.bind(
|
||||
loggerInstance
|
||||
)
|
||||
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
output: {
|
||||
nested: {
|
||||
deeply: {
|
||||
files: [
|
||||
{
|
||||
id: 'nested-file-1',
|
||||
name: 'nested.json',
|
||||
size: 128,
|
||||
type: 'application/json',
|
||||
url: 'https://example.com/nested.json',
|
||||
key: 'nested/file.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const files = extractFilesMethod(traceSpans, null, null)
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0].name).toBe('nested.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost model merging', () => {
|
||||
test('should merge cost models correctly', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const mergeCostModelsMethod = (loggerInstance as any).mergeCostModels.bind(loggerInstance)
|
||||
|
||||
const existing = {
|
||||
'gpt-4': {
|
||||
input: 0.01,
|
||||
output: 0.02,
|
||||
total: 0.03,
|
||||
tokens: { input: 100, output: 200, total: 300 },
|
||||
},
|
||||
}
|
||||
|
||||
const additional = {
|
||||
'gpt-4': {
|
||||
input: 0.005,
|
||||
output: 0.01,
|
||||
total: 0.015,
|
||||
tokens: { input: 50, output: 100, total: 150 },
|
||||
},
|
||||
'gpt-3.5-turbo': {
|
||||
input: 0.001,
|
||||
output: 0.002,
|
||||
total: 0.003,
|
||||
tokens: { input: 10, output: 20, total: 30 },
|
||||
},
|
||||
}
|
||||
|
||||
const merged = mergeCostModelsMethod(existing, additional)
|
||||
|
||||
expect(merged['gpt-4'].input).toBe(0.015)
|
||||
expect(merged['gpt-4'].output).toBe(0.03)
|
||||
expect(merged['gpt-4'].total).toBe(0.045)
|
||||
expect(merged['gpt-4'].tokens.input).toBe(150)
|
||||
expect(merged['gpt-4'].tokens.output).toBe(300)
|
||||
expect(merged['gpt-4'].tokens.total).toBe(450)
|
||||
|
||||
expect(merged['gpt-3.5-turbo']).toBeDefined()
|
||||
expect(merged['gpt-3.5-turbo'].total).toBe(0.003)
|
||||
})
|
||||
|
||||
test('should handle prompt/completion token aliases', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const mergeCostModelsMethod = (loggerInstance as any).mergeCostModels.bind(loggerInstance)
|
||||
|
||||
const existing = {
|
||||
'gpt-4': {
|
||||
input: 0.01,
|
||||
output: 0.02,
|
||||
total: 0.03,
|
||||
tokens: { prompt: 100, completion: 200, total: 300 },
|
||||
},
|
||||
}
|
||||
|
||||
const additional = {
|
||||
'gpt-4': {
|
||||
input: 0.005,
|
||||
output: 0.01,
|
||||
total: 0.015,
|
||||
tokens: { input: 50, output: 100, total: 150 },
|
||||
},
|
||||
}
|
||||
|
||||
const merged = mergeCostModelsMethod(existing, additional)
|
||||
|
||||
expect(merged['gpt-4'].tokens.input).toBe(150)
|
||||
expect(merged['gpt-4'].tokens.output).toBe(300)
|
||||
})
|
||||
|
||||
test('should handle empty existing models', () => {
|
||||
const loggerInstance = new ExecutionLogger()
|
||||
const mergeCostModelsMethod = (loggerInstance as any).mergeCostModels.bind(loggerInstance)
|
||||
|
||||
const existing = {}
|
||||
const additional = {
|
||||
'claude-3': {
|
||||
input: 0.02,
|
||||
output: 0.04,
|
||||
total: 0.06,
|
||||
tokens: { input: 200, output: 400, total: 600 },
|
||||
},
|
||||
}
|
||||
|
||||
const merged = mergeCostModelsMethod(existing, additional)
|
||||
|
||||
expect(merged['claude-3']).toBeDefined()
|
||||
expect(merged['claude-3'].total).toBe(0.06)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
415
apps/sim/lib/logs/execution/logging-factory.test.ts
Normal file
415
apps/sim/lib/logs/execution/logging-factory.test.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import {
|
||||
calculateCostSummary,
|
||||
createEnvironmentObject,
|
||||
createTriggerObject,
|
||||
} from '@/lib/logs/execution/logging-factory'
|
||||
|
||||
// Mock the billing constants
|
||||
vi.mock('@/lib/billing/constants', () => ({
|
||||
BASE_EXECUTION_CHARGE: 0.001,
|
||||
}))
|
||||
|
||||
// Mock the console logger
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock workflow persistence utils
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
loadDeployedWorkflowState: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
})
|
||||
),
|
||||
loadWorkflowFromNormalizedTables: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
describe('createTriggerObject', () => {
|
||||
test('should create a trigger object with basic type', () => {
|
||||
const trigger = createTriggerObject('manual')
|
||||
|
||||
expect(trigger.type).toBe('manual')
|
||||
expect(trigger.source).toBe('manual')
|
||||
expect(trigger.timestamp).toBeDefined()
|
||||
expect(new Date(trigger.timestamp).getTime()).not.toBeNaN()
|
||||
})
|
||||
|
||||
test('should create a trigger object for api type', () => {
|
||||
const trigger = createTriggerObject('api')
|
||||
|
||||
expect(trigger.type).toBe('api')
|
||||
expect(trigger.source).toBe('api')
|
||||
})
|
||||
|
||||
test('should create a trigger object for webhook type', () => {
|
||||
const trigger = createTriggerObject('webhook')
|
||||
|
||||
expect(trigger.type).toBe('webhook')
|
||||
expect(trigger.source).toBe('webhook')
|
||||
})
|
||||
|
||||
test('should create a trigger object for schedule type', () => {
|
||||
const trigger = createTriggerObject('schedule')
|
||||
|
||||
expect(trigger.type).toBe('schedule')
|
||||
expect(trigger.source).toBe('schedule')
|
||||
})
|
||||
|
||||
test('should create a trigger object for chat type', () => {
|
||||
const trigger = createTriggerObject('chat')
|
||||
|
||||
expect(trigger.type).toBe('chat')
|
||||
expect(trigger.source).toBe('chat')
|
||||
})
|
||||
|
||||
test('should include additional data when provided', () => {
|
||||
const additionalData = {
|
||||
requestId: 'req-123',
|
||||
headers: { 'x-custom': 'value' },
|
||||
}
|
||||
|
||||
const trigger = createTriggerObject('api', additionalData)
|
||||
|
||||
expect(trigger.type).toBe('api')
|
||||
expect(trigger.data).toEqual(additionalData)
|
||||
})
|
||||
|
||||
test('should not include data property when additionalData is undefined', () => {
|
||||
const trigger = createTriggerObject('manual')
|
||||
|
||||
expect(trigger.data).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should not include data property when additionalData is empty', () => {
|
||||
const trigger = createTriggerObject('manual', undefined)
|
||||
|
||||
expect(trigger.data).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEnvironmentObject', () => {
|
||||
test('should create an environment object with all fields', () => {
|
||||
const env = createEnvironmentObject(
|
||||
'workflow-123',
|
||||
'execution-456',
|
||||
'user-789',
|
||||
'workspace-abc',
|
||||
{ API_KEY: 'secret', DEBUG: 'true' }
|
||||
)
|
||||
|
||||
expect(env.workflowId).toBe('workflow-123')
|
||||
expect(env.executionId).toBe('execution-456')
|
||||
expect(env.userId).toBe('user-789')
|
||||
expect(env.workspaceId).toBe('workspace-abc')
|
||||
expect(env.variables).toEqual({ API_KEY: 'secret', DEBUG: 'true' })
|
||||
})
|
||||
|
||||
test('should use empty string for optional userId', () => {
|
||||
const env = createEnvironmentObject('workflow-123', 'execution-456')
|
||||
|
||||
expect(env.userId).toBe('')
|
||||
})
|
||||
|
||||
test('should use empty string for optional workspaceId', () => {
|
||||
const env = createEnvironmentObject('workflow-123', 'execution-456', 'user-789')
|
||||
|
||||
expect(env.workspaceId).toBe('')
|
||||
})
|
||||
|
||||
test('should use empty object for optional variables', () => {
|
||||
const env = createEnvironmentObject(
|
||||
'workflow-123',
|
||||
'execution-456',
|
||||
'user-789',
|
||||
'workspace-abc'
|
||||
)
|
||||
|
||||
expect(env.variables).toEqual({})
|
||||
})
|
||||
|
||||
test('should handle all optional parameters as undefined', () => {
|
||||
const env = createEnvironmentObject('workflow-123', 'execution-456')
|
||||
|
||||
expect(env.workflowId).toBe('workflow-123')
|
||||
expect(env.executionId).toBe('execution-456')
|
||||
expect(env.userId).toBe('')
|
||||
expect(env.workspaceId).toBe('')
|
||||
expect(env.variables).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCostSummary', () => {
|
||||
const BASE_EXECUTION_CHARGE = 0.001
|
||||
|
||||
test('should return base execution charge for empty trace spans', () => {
|
||||
const result = calculateCostSummary([])
|
||||
|
||||
expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE)
|
||||
expect(result.baseExecutionCharge).toBe(BASE_EXECUTION_CHARGE)
|
||||
expect(result.modelCost).toBe(0)
|
||||
expect(result.totalInputCost).toBe(0)
|
||||
expect(result.totalOutputCost).toBe(0)
|
||||
expect(result.totalTokens).toBe(0)
|
||||
expect(result.totalPromptTokens).toBe(0)
|
||||
expect(result.totalCompletionTokens).toBe(0)
|
||||
expect(result.models).toEqual({})
|
||||
})
|
||||
|
||||
test('should return base execution charge for undefined trace spans', () => {
|
||||
const result = calculateCostSummary(undefined as any)
|
||||
|
||||
expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE)
|
||||
})
|
||||
|
||||
test('should calculate cost from single span with cost data', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
name: 'Agent Block',
|
||||
type: 'agent',
|
||||
model: 'gpt-4',
|
||||
cost: {
|
||||
input: 0.01,
|
||||
output: 0.02,
|
||||
total: 0.03,
|
||||
},
|
||||
tokens: {
|
||||
input: 100,
|
||||
output: 200,
|
||||
total: 300,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
|
||||
expect(result.modelCost).toBe(0.03)
|
||||
expect(result.totalInputCost).toBe(0.01)
|
||||
expect(result.totalOutputCost).toBe(0.02)
|
||||
expect(result.totalTokens).toBe(300)
|
||||
expect(result.totalPromptTokens).toBe(100)
|
||||
expect(result.totalCompletionTokens).toBe(200)
|
||||
expect(result.models['gpt-4']).toBeDefined()
|
||||
expect(result.models['gpt-4'].total).toBe(0.03)
|
||||
})
|
||||
|
||||
test('should calculate cost from multiple spans', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
name: 'Agent Block 1',
|
||||
type: 'agent',
|
||||
model: 'gpt-4',
|
||||
cost: { input: 0.01, output: 0.02, total: 0.03 },
|
||||
tokens: { input: 100, output: 200, total: 300 },
|
||||
},
|
||||
{
|
||||
id: 'span-2',
|
||||
name: 'Agent Block 2',
|
||||
type: 'agent',
|
||||
model: 'gpt-3.5-turbo',
|
||||
cost: { input: 0.001, output: 0.002, total: 0.003 },
|
||||
tokens: { input: 50, output: 100, total: 150 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.totalCost).toBe(0.033 + BASE_EXECUTION_CHARGE)
|
||||
expect(result.modelCost).toBe(0.033)
|
||||
expect(result.totalInputCost).toBe(0.011)
|
||||
expect(result.totalOutputCost).toBe(0.022)
|
||||
expect(result.totalTokens).toBe(450)
|
||||
expect(result.models['gpt-4']).toBeDefined()
|
||||
expect(result.models['gpt-3.5-turbo']).toBeDefined()
|
||||
})
|
||||
|
||||
test('should accumulate costs for same model across spans', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
model: 'gpt-4',
|
||||
cost: { input: 0.01, output: 0.02, total: 0.03 },
|
||||
tokens: { input: 100, output: 200, total: 300 },
|
||||
},
|
||||
{
|
||||
id: 'span-2',
|
||||
model: 'gpt-4',
|
||||
cost: { input: 0.02, output: 0.04, total: 0.06 },
|
||||
tokens: { input: 200, output: 400, total: 600 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.models['gpt-4'].input).toBe(0.03)
|
||||
expect(result.models['gpt-4'].output).toBe(0.06)
|
||||
expect(result.models['gpt-4'].total).toBe(0.09)
|
||||
expect(result.models['gpt-4'].tokens.input).toBe(300)
|
||||
expect(result.models['gpt-4'].tokens.output).toBe(600)
|
||||
expect(result.models['gpt-4'].tokens.total).toBe(900)
|
||||
})
|
||||
|
||||
test('should handle nested children with cost data', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'parent-span',
|
||||
name: 'Parent',
|
||||
type: 'workflow',
|
||||
children: [
|
||||
{
|
||||
id: 'child-span-1',
|
||||
model: 'claude-3',
|
||||
cost: { input: 0.005, output: 0.01, total: 0.015 },
|
||||
tokens: { input: 50, output: 100, total: 150 },
|
||||
},
|
||||
{
|
||||
id: 'child-span-2',
|
||||
model: 'claude-3',
|
||||
cost: { input: 0.005, output: 0.01, total: 0.015 },
|
||||
tokens: { input: 50, output: 100, total: 150 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.modelCost).toBe(0.03)
|
||||
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
|
||||
expect(result.models['claude-3']).toBeDefined()
|
||||
expect(result.models['claude-3'].total).toBe(0.03)
|
||||
})
|
||||
|
||||
test('should handle deeply nested children', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'level-1',
|
||||
children: [
|
||||
{
|
||||
id: 'level-2',
|
||||
children: [
|
||||
{
|
||||
id: 'level-3',
|
||||
model: 'gpt-4',
|
||||
cost: { input: 0.01, output: 0.02, total: 0.03 },
|
||||
tokens: { input: 100, output: 200, total: 300 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.modelCost).toBe(0.03)
|
||||
expect(result.models['gpt-4']).toBeDefined()
|
||||
})
|
||||
|
||||
test('should handle prompt/completion token aliases', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
model: 'gpt-4',
|
||||
cost: { input: 0.01, output: 0.02, total: 0.03 },
|
||||
tokens: { prompt: 100, completion: 200, total: 300 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.totalPromptTokens).toBe(100)
|
||||
expect(result.totalCompletionTokens).toBe(200)
|
||||
})
|
||||
|
||||
test('should skip spans without cost data', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-without-cost',
|
||||
name: 'Text Block',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: 'span-with-cost',
|
||||
model: 'gpt-4',
|
||||
cost: { input: 0.01, output: 0.02, total: 0.03 },
|
||||
tokens: { input: 100, output: 200, total: 300 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.modelCost).toBe(0.03)
|
||||
expect(Object.keys(result.models)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('should handle spans without model specified', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
cost: { input: 0.01, output: 0.02, total: 0.03 },
|
||||
tokens: { input: 100, output: 200, total: 300 },
|
||||
// No model specified
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.modelCost).toBe(0.03)
|
||||
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
|
||||
// Should not add to models if model is not specified
|
||||
expect(Object.keys(result.models)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should handle missing token fields gracefully', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
model: 'gpt-4',
|
||||
cost: { input: 0.01, output: 0.02, total: 0.03 },
|
||||
// tokens field is missing
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.totalTokens).toBe(0)
|
||||
expect(result.totalPromptTokens).toBe(0)
|
||||
expect(result.totalCompletionTokens).toBe(0)
|
||||
})
|
||||
|
||||
test('should handle partial cost fields', () => {
|
||||
const traceSpans = [
|
||||
{
|
||||
id: 'span-1',
|
||||
model: 'gpt-4',
|
||||
cost: { total: 0.03 }, // Only total specified
|
||||
tokens: { total: 300 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = calculateCostSummary(traceSpans)
|
||||
|
||||
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
|
||||
expect(result.totalInputCost).toBe(0)
|
||||
expect(result.totalOutputCost).toBe(0)
|
||||
})
|
||||
})
|
||||
442
apps/sim/lib/logs/query-parser.test.ts
Normal file
442
apps/sim/lib/logs/query-parser.test.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
|
||||
describe('parseQuery', () => {
|
||||
describe('empty and whitespace input', () => {
|
||||
test('should handle empty string', () => {
|
||||
const result = parseQuery('')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('')
|
||||
})
|
||||
|
||||
test('should handle whitespace only', () => {
|
||||
const result = parseQuery(' ')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('simple text search', () => {
|
||||
test('should parse plain text as textSearch', () => {
|
||||
const result = parseQuery('hello world')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('hello world')
|
||||
})
|
||||
|
||||
test('should preserve text case', () => {
|
||||
const result = parseQuery('Hello World')
|
||||
|
||||
expect(result.textSearch).toBe('Hello World')
|
||||
})
|
||||
})
|
||||
|
||||
describe('level filter', () => {
|
||||
test('should parse level:error filter', () => {
|
||||
const result = parseQuery('level:error')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('level')
|
||||
expect(result.filters[0].value).toBe('error')
|
||||
expect(result.filters[0].operator).toBe('=')
|
||||
})
|
||||
|
||||
test('should parse level:info filter', () => {
|
||||
const result = parseQuery('level:info')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('level')
|
||||
expect(result.filters[0].value).toBe('info')
|
||||
})
|
||||
})
|
||||
|
||||
describe('status filter (alias for level)', () => {
|
||||
test('should parse status:error filter', () => {
|
||||
const result = parseQuery('status:error')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('status')
|
||||
expect(result.filters[0].value).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflow filter', () => {
|
||||
test('should parse workflow filter with quoted value', () => {
|
||||
const result = parseQuery('workflow:"my-workflow"')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('workflow')
|
||||
expect(result.filters[0].value).toBe('my-workflow')
|
||||
})
|
||||
|
||||
test('should parse workflow filter with unquoted value', () => {
|
||||
const result = parseQuery('workflow:test-workflow')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('workflow')
|
||||
expect(result.filters[0].value).toBe('test-workflow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trigger filter', () => {
|
||||
test('should parse trigger:api filter', () => {
|
||||
const result = parseQuery('trigger:api')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('trigger')
|
||||
expect(result.filters[0].value).toBe('api')
|
||||
})
|
||||
|
||||
test('should parse trigger:webhook filter', () => {
|
||||
const result = parseQuery('trigger:webhook')
|
||||
|
||||
expect(result.filters[0].value).toBe('webhook')
|
||||
})
|
||||
|
||||
test('should parse trigger:schedule filter', () => {
|
||||
const result = parseQuery('trigger:schedule')
|
||||
|
||||
expect(result.filters[0].value).toBe('schedule')
|
||||
})
|
||||
|
||||
test('should parse trigger:manual filter', () => {
|
||||
const result = parseQuery('trigger:manual')
|
||||
|
||||
expect(result.filters[0].value).toBe('manual')
|
||||
})
|
||||
|
||||
test('should parse trigger:chat filter', () => {
|
||||
const result = parseQuery('trigger:chat')
|
||||
|
||||
expect(result.filters[0].value).toBe('chat')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost filter with operators', () => {
|
||||
test('should parse cost:>0.01 filter', () => {
|
||||
const result = parseQuery('cost:>0.01')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('cost')
|
||||
expect(result.filters[0].operator).toBe('>')
|
||||
expect(result.filters[0].value).toBe(0.01)
|
||||
})
|
||||
|
||||
test('should parse cost:<0.005 filter', () => {
|
||||
const result = parseQuery('cost:<0.005')
|
||||
|
||||
expect(result.filters[0].operator).toBe('<')
|
||||
expect(result.filters[0].value).toBe(0.005)
|
||||
})
|
||||
|
||||
test('should parse cost:>=0.05 filter', () => {
|
||||
const result = parseQuery('cost:>=0.05')
|
||||
|
||||
expect(result.filters[0].operator).toBe('>=')
|
||||
expect(result.filters[0].value).toBe(0.05)
|
||||
})
|
||||
|
||||
test('should parse cost:<=0.1 filter', () => {
|
||||
const result = parseQuery('cost:<=0.1')
|
||||
|
||||
expect(result.filters[0].operator).toBe('<=')
|
||||
expect(result.filters[0].value).toBe(0.1)
|
||||
})
|
||||
|
||||
test('should parse cost:!=0 filter', () => {
|
||||
const result = parseQuery('cost:!=0')
|
||||
|
||||
expect(result.filters[0].operator).toBe('!=')
|
||||
expect(result.filters[0].value).toBe(0)
|
||||
})
|
||||
|
||||
test('should parse cost:=0 filter', () => {
|
||||
const result = parseQuery('cost:=0')
|
||||
|
||||
expect(result.filters[0].operator).toBe('=')
|
||||
expect(result.filters[0].value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('duration filter', () => {
|
||||
test('should parse duration:>5000 (ms) filter', () => {
|
||||
const result = parseQuery('duration:>5000')
|
||||
|
||||
expect(result.filters[0].field).toBe('duration')
|
||||
expect(result.filters[0].operator).toBe('>')
|
||||
expect(result.filters[0].value).toBe(5000)
|
||||
})
|
||||
|
||||
test('should parse duration with ms suffix', () => {
|
||||
const result = parseQuery('duration:>500ms')
|
||||
|
||||
expect(result.filters[0].value).toBe(500)
|
||||
})
|
||||
|
||||
test('should parse duration with s suffix (converts to ms)', () => {
|
||||
const result = parseQuery('duration:>5s')
|
||||
|
||||
expect(result.filters[0].value).toBe(5000)
|
||||
})
|
||||
|
||||
test('should parse duration:<1s filter', () => {
|
||||
const result = parseQuery('duration:<1s')
|
||||
|
||||
expect(result.filters[0].operator).toBe('<')
|
||||
expect(result.filters[0].value).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('date filter', () => {
|
||||
test('should parse date:today filter', () => {
|
||||
const result = parseQuery('date:today')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('date')
|
||||
expect(result.filters[0].value).toBe('today')
|
||||
})
|
||||
|
||||
test('should parse date:yesterday filter', () => {
|
||||
const result = parseQuery('date:yesterday')
|
||||
|
||||
expect(result.filters[0].value).toBe('yesterday')
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder filter', () => {
|
||||
test('should parse folder filter with quoted value', () => {
|
||||
const result = parseQuery('folder:"My Folder"')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('folder')
|
||||
expect(result.filters[0].value).toBe('My Folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ID filters', () => {
|
||||
test('should parse executionId filter', () => {
|
||||
const result = parseQuery('executionId:exec-123-abc')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('executionId')
|
||||
expect(result.filters[0].value).toBe('exec-123-abc')
|
||||
})
|
||||
|
||||
test('should parse workflowId filter', () => {
|
||||
const result = parseQuery('workflowId:wf-456-def')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('workflowId')
|
||||
expect(result.filters[0].value).toBe('wf-456-def')
|
||||
})
|
||||
|
||||
test('should parse execution filter (alias)', () => {
|
||||
const result = parseQuery('execution:exec-789')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('execution')
|
||||
expect(result.filters[0].value).toBe('exec-789')
|
||||
})
|
||||
|
||||
test('should parse id filter', () => {
|
||||
const result = parseQuery('id:some-id-123')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('combined filters and text', () => {
|
||||
test('should parse multiple filters', () => {
|
||||
const result = parseQuery('level:error trigger:api')
|
||||
|
||||
expect(result.filters).toHaveLength(2)
|
||||
expect(result.filters[0].field).toBe('level')
|
||||
expect(result.filters[1].field).toBe('trigger')
|
||||
expect(result.textSearch).toBe('')
|
||||
})
|
||||
|
||||
test('should parse filters with text search', () => {
|
||||
const result = parseQuery('level:error some search text')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('level')
|
||||
expect(result.textSearch).toBe('some search text')
|
||||
})
|
||||
|
||||
test('should parse text before and after filters', () => {
|
||||
const result = parseQuery('before level:error after')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.textSearch).toBe('before after')
|
||||
})
|
||||
|
||||
test('should parse complex query with multiple filters and text', () => {
|
||||
const result = parseQuery(
|
||||
'level:error trigger:api cost:>0.01 workflow:"my-workflow" search text'
|
||||
)
|
||||
|
||||
expect(result.filters).toHaveLength(4)
|
||||
expect(result.textSearch).toBe('search text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid filters', () => {
|
||||
test('should treat unknown field as text', () => {
|
||||
const result = parseQuery('unknownfield:value')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('unknownfield:value')
|
||||
})
|
||||
|
||||
test('should handle invalid number for cost', () => {
|
||||
const result = parseQuery('cost:>abc')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('cost:>abc')
|
||||
})
|
||||
|
||||
test('should handle invalid number for duration', () => {
|
||||
const result = parseQuery('duration:>notanumber')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryToApiParams', () => {
|
||||
test('should return empty object for empty query', () => {
|
||||
const parsed = parseQuery('')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(Object.keys(params)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should set search param for text search', () => {
|
||||
const parsed = parseQuery('hello world')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.search).toBe('hello world')
|
||||
})
|
||||
|
||||
test('should set level param for level filter', () => {
|
||||
const parsed = parseQuery('level:error')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.level).toBe('error')
|
||||
})
|
||||
|
||||
test('should combine multiple level filters with comma', () => {
|
||||
const parsed = parseQuery('level:error level:info')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.level).toBe('error,info')
|
||||
})
|
||||
|
||||
test('should set triggers param for trigger filter', () => {
|
||||
const parsed = parseQuery('trigger:api')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.triggers).toBe('api')
|
||||
})
|
||||
|
||||
test('should combine multiple trigger filters', () => {
|
||||
const parsed = parseQuery('trigger:api trigger:webhook')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.triggers).toBe('api,webhook')
|
||||
})
|
||||
|
||||
test('should set workflowName param for workflow filter', () => {
|
||||
const parsed = parseQuery('workflow:"my-workflow"')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.workflowName).toBe('my-workflow')
|
||||
})
|
||||
|
||||
test('should set folderName param for folder filter', () => {
|
||||
const parsed = parseQuery('folder:"My Folder"')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.folderName).toBe('My Folder')
|
||||
})
|
||||
|
||||
test('should set workflowIds param for workflowId filter', () => {
|
||||
const parsed = parseQuery('workflowId:wf-123')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.workflowIds).toBe('wf-123')
|
||||
})
|
||||
|
||||
test('should set executionId param for executionId filter', () => {
|
||||
const parsed = parseQuery('executionId:exec-456')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.executionId).toBe('exec-456')
|
||||
})
|
||||
|
||||
test('should set cost params with operator', () => {
|
||||
const parsed = parseQuery('cost:>0.01')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.costOperator).toBe('>')
|
||||
expect(params.costValue).toBe('0.01')
|
||||
})
|
||||
|
||||
test('should set duration params with operator', () => {
|
||||
const parsed = parseQuery('duration:>5s')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.durationOperator).toBe('>')
|
||||
expect(params.durationValue).toBe('5000')
|
||||
})
|
||||
|
||||
test('should set startDate for date:today', () => {
|
||||
const parsed = parseQuery('date:today')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
const startDate = new Date(params.startDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
expect(startDate.getTime()).toBe(today.getTime())
|
||||
})
|
||||
|
||||
test('should set startDate and endDate for date:yesterday', () => {
|
||||
const parsed = parseQuery('date:yesterday')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
expect(params.endDate).toBeDefined()
|
||||
})
|
||||
|
||||
test('should combine execution filter with text search', () => {
|
||||
const parsed = {
|
||||
filters: [
|
||||
{
|
||||
field: 'execution',
|
||||
operator: '=' as const,
|
||||
value: 'exec-123',
|
||||
originalValue: 'exec-123',
|
||||
},
|
||||
],
|
||||
textSearch: 'some text',
|
||||
}
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.search).toBe('some text exec-123')
|
||||
})
|
||||
|
||||
test('should handle complex query with all params', () => {
|
||||
const parsed = parseQuery('level:error trigger:api cost:>0.01 workflow:"test"')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.level).toBe('error')
|
||||
expect(params.triggers).toBe('api')
|
||||
expect(params.costOperator).toBe('>')
|
||||
expect(params.costValue).toBe('0.01')
|
||||
expect(params.workflowName).toBe('test')
|
||||
})
|
||||
})
|
||||
389
apps/sim/lib/logs/search-suggestions.test.ts
Normal file
389
apps/sim/lib/logs/search-suggestions.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
FILTER_DEFINITIONS,
|
||||
type FolderData,
|
||||
SearchSuggestions,
|
||||
type TriggerData,
|
||||
type WorkflowData,
|
||||
} from '@/lib/logs/search-suggestions'
|
||||
|
||||
describe('FILTER_DEFINITIONS', () => {
|
||||
test('should have level filter definition', () => {
|
||||
const levelFilter = FILTER_DEFINITIONS.find((f) => f.key === 'level')
|
||||
|
||||
expect(levelFilter).toBeDefined()
|
||||
expect(levelFilter?.label).toBe('Status')
|
||||
expect(levelFilter?.options).toHaveLength(2)
|
||||
expect(levelFilter?.options.map((o) => o.value)).toContain('error')
|
||||
expect(levelFilter?.options.map((o) => o.value)).toContain('info')
|
||||
})
|
||||
|
||||
test('should have cost filter definition with multiple options', () => {
|
||||
const costFilter = FILTER_DEFINITIONS.find((f) => f.key === 'cost')
|
||||
|
||||
expect(costFilter).toBeDefined()
|
||||
expect(costFilter?.label).toBe('Cost')
|
||||
expect(costFilter?.options.length).toBeGreaterThan(0)
|
||||
expect(costFilter?.options.map((o) => o.value)).toContain('>0.01')
|
||||
expect(costFilter?.options.map((o) => o.value)).toContain('<0.005')
|
||||
})
|
||||
|
||||
test('should have date filter definition', () => {
|
||||
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
|
||||
|
||||
expect(dateFilter).toBeDefined()
|
||||
expect(dateFilter?.label).toBe('Date')
|
||||
expect(dateFilter?.options.map((o) => o.value)).toContain('today')
|
||||
expect(dateFilter?.options.map((o) => o.value)).toContain('yesterday')
|
||||
})
|
||||
|
||||
test('should have duration filter definition', () => {
|
||||
const durationFilter = FILTER_DEFINITIONS.find((f) => f.key === 'duration')
|
||||
|
||||
expect(durationFilter).toBeDefined()
|
||||
expect(durationFilter?.label).toBe('Duration')
|
||||
expect(durationFilter?.options.map((o) => o.value)).toContain('>5s')
|
||||
expect(durationFilter?.options.map((o) => o.value)).toContain('<1s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchSuggestions', () => {
|
||||
const mockWorkflows: WorkflowData[] = [
|
||||
{ id: 'wf-1', name: 'Test Workflow', description: 'A test workflow' },
|
||||
{ id: 'wf-2', name: 'Production Pipeline', description: 'Main production flow' },
|
||||
{ id: 'wf-3', name: 'API Handler', description: 'Handles API requests' },
|
||||
]
|
||||
|
||||
const mockFolders: FolderData[] = [
|
||||
{ id: 'folder-1', name: 'Development' },
|
||||
{ id: 'folder-2', name: 'Production' },
|
||||
{ id: 'folder-3', name: 'Testing' },
|
||||
]
|
||||
|
||||
const mockTriggers: TriggerData[] = [
|
||||
{ value: 'manual', label: 'Manual', color: '#6b7280' },
|
||||
{ value: 'api', label: 'API', color: '#2563eb' },
|
||||
{ value: 'schedule', label: 'Schedule', color: '#059669' },
|
||||
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
|
||||
{ value: 'slack', label: 'Slack', color: '#4A154B' },
|
||||
]
|
||||
|
||||
describe('constructor', () => {
|
||||
test('should create instance with empty data', () => {
|
||||
const suggestions = new SearchSuggestions()
|
||||
expect(suggestions).toBeDefined()
|
||||
})
|
||||
|
||||
test('should create instance with provided data', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
expect(suggestions).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateData', () => {
|
||||
test('should update internal data', () => {
|
||||
const suggestions = new SearchSuggestions()
|
||||
suggestions.updateData(mockWorkflows, mockFolders, mockTriggers)
|
||||
|
||||
const result = suggestions.getSuggestions('workflow:')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - empty input', () => {
|
||||
test('should return filter keys list for empty input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should include core filter keys', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
const filterValues = result?.suggestions.map((s) => s.value)
|
||||
expect(filterValues).toContain('level:')
|
||||
expect(filterValues).toContain('cost:')
|
||||
expect(filterValues).toContain('date:')
|
||||
expect(filterValues).toContain('duration:')
|
||||
expect(filterValues).toContain('trigger:')
|
||||
})
|
||||
|
||||
test('should include workflow filter when workflows exist', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
const filterValues = result?.suggestions.map((s) => s.value)
|
||||
expect(filterValues).toContain('workflow:')
|
||||
})
|
||||
|
||||
test('should include folder filter when folders exist', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
const filterValues = result?.suggestions.map((s) => s.value)
|
||||
expect(filterValues).toContain('folder:')
|
||||
})
|
||||
|
||||
test('should not include workflow filter when no workflows', () => {
|
||||
const suggestions = new SearchSuggestions([], mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
const filterValues = result?.suggestions.map((s) => s.value)
|
||||
expect(filterValues).not.toContain('workflow:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - filter values (ending with colon)', () => {
|
||||
test('should return level filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('level:')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return cost filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('cost:')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.value === 'cost:>0.01')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return trigger filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('trigger:')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:api')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:manual')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return workflow filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('workflow:')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return folder filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('folder:')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return null for unknown filter key', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('unknown:')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - partial filter values', () => {
|
||||
test('should filter level values by partial input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('level:err')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(false)
|
||||
})
|
||||
|
||||
test('should filter workflow values by partial input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('workflow:test')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(false)
|
||||
})
|
||||
|
||||
test('should filter trigger values by partial input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('trigger:sch')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:schedule')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return null when no matches found', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('level:xyz')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - plain text search (multi-section)', () => {
|
||||
test('should return multi-section results for plain text', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('test')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('multi-section')
|
||||
})
|
||||
|
||||
test('should include show-all suggestion', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('test')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match workflows by name', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('production')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match workflows by description', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('API requests')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.label === 'API Handler')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match folders by name', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('development')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match triggers by label', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('slack')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:slack')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('error')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
|
||||
})
|
||||
|
||||
test('should show suggested filters when no matches found', () => {
|
||||
const suggestions = new SearchSuggestions([], [], [])
|
||||
const result = suggestions.getSuggestions('xyz123')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - case insensitivity', () => {
|
||||
test('should match regardless of case', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
|
||||
const lowerResult = suggestions.getSuggestions('test')
|
||||
const upperResult = suggestions.getSuggestions('TEST')
|
||||
const mixedResult = suggestions.getSuggestions('TeSt')
|
||||
|
||||
expect(lowerResult?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
|
||||
expect(upperResult?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
|
||||
expect(mixedResult?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - sorting', () => {
|
||||
test('should sort exact matches first', () => {
|
||||
const workflows: WorkflowData[] = [
|
||||
{ id: '1', name: 'API Handler' },
|
||||
{ id: '2', name: 'API' },
|
||||
{ id: '3', name: 'Another API Thing' },
|
||||
]
|
||||
const suggestions = new SearchSuggestions(workflows, [], [])
|
||||
const result = suggestions.getSuggestions('api')
|
||||
|
||||
const workflowSuggestions = result?.suggestions.filter((s) => s.category === 'workflow')
|
||||
expect(workflowSuggestions?.[0]?.label).toBe('API')
|
||||
})
|
||||
|
||||
test('should sort prefix matches before substring matches', () => {
|
||||
const workflows: WorkflowData[] = [
|
||||
{ id: '1', name: 'Contains Test Inside' },
|
||||
{ id: '2', name: 'Test First' },
|
||||
]
|
||||
const suggestions = new SearchSuggestions(workflows, [], [])
|
||||
const result = suggestions.getSuggestions('test')
|
||||
|
||||
const workflowSuggestions = result?.suggestions.filter((s) => s.category === 'workflow')
|
||||
expect(workflowSuggestions?.[0]?.label).toBe('Test First')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - result limits', () => {
|
||||
test('should limit workflow results to 8', () => {
|
||||
const manyWorkflows = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `wf-${i}`,
|
||||
name: `Test Workflow ${i}`,
|
||||
}))
|
||||
const suggestions = new SearchSuggestions(manyWorkflows, [], [])
|
||||
const result = suggestions.getSuggestions('test')
|
||||
|
||||
const workflowSuggestions = result?.suggestions.filter((s) => s.category === 'workflow')
|
||||
expect(workflowSuggestions?.length).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
test('should limit filter value results to 5', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('o') // Matches multiple filter values
|
||||
|
||||
const filterSuggestions = result?.suggestions.filter(
|
||||
(s) =>
|
||||
s.category !== 'show-all' &&
|
||||
s.category !== 'workflow' &&
|
||||
s.category !== 'folder' &&
|
||||
s.category !== 'trigger'
|
||||
)
|
||||
expect(filterSuggestions?.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - suggestion structure', () => {
|
||||
test('should include correct properties for filter key suggestions', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
const suggestion = result?.suggestions[0]
|
||||
expect(suggestion).toHaveProperty('id')
|
||||
expect(suggestion).toHaveProperty('value')
|
||||
expect(suggestion).toHaveProperty('label')
|
||||
expect(suggestion).toHaveProperty('category')
|
||||
})
|
||||
|
||||
test('should include color for trigger suggestions', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('trigger:')
|
||||
|
||||
const triggerSuggestion = result?.suggestions.find((s) => s.value === 'trigger:api')
|
||||
expect(triggerSuggestion?.color).toBeDefined()
|
||||
})
|
||||
|
||||
test('should quote workflow names in value', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('workflow:')
|
||||
|
||||
const workflowSuggestion = result?.suggestions.find((s) => s.label === 'Test Workflow')
|
||||
expect(workflowSuggestion?.value).toBe('workflow:"Test Workflow"')
|
||||
})
|
||||
})
|
||||
})
|
||||
376
apps/sim/lib/mcp/storage/memory-cache.test.ts
Normal file
376
apps/sim/lib/mcp/storage/memory-cache.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
import { MemoryMcpCache } from './memory-cache'
|
||||
|
||||
describe('MemoryMcpCache', () => {
|
||||
let cache: MemoryMcpCache
|
||||
|
||||
const createTool = (name: string): McpTool => ({
|
||||
name,
|
||||
description: `Test tool: ${name}`,
|
||||
inputSchema: { type: 'object' },
|
||||
serverId: 'server-1',
|
||||
serverName: 'Test Server',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new MemoryMcpCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cache.dispose()
|
||||
})
|
||||
|
||||
describe('get', () => {
|
||||
it('returns null for non-existent key', async () => {
|
||||
const result = await cache.get('non-existent-key')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns cached entry when valid', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 60000)
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.tools).toEqual(tools)
|
||||
})
|
||||
|
||||
it('returns null for expired entry', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
// Set with 0 TTL so it expires immediately
|
||||
await cache.set('key-1', tools, 0)
|
||||
|
||||
// Wait a tiny bit to ensure expiry
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('removes expired entry from cache on get', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 1) // 1ms TTL
|
||||
|
||||
// Wait for expiry
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// First get should return null and remove entry
|
||||
await cache.get('key-1')
|
||||
|
||||
// Entry should be removed (internal state)
|
||||
const result = await cache.get('key-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a copy of tools to prevent mutation', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 60000)
|
||||
|
||||
const result1 = await cache.get('key-1')
|
||||
const result2 = await cache.get('key-1')
|
||||
|
||||
expect(result1).not.toBe(result2)
|
||||
expect(result1?.tools).toEqual(result2?.tools)
|
||||
})
|
||||
})
|
||||
|
||||
describe('set', () => {
|
||||
it('stores tools with correct expiry', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
const ttl = 60000
|
||||
|
||||
const beforeSet = Date.now()
|
||||
await cache.set('key-1', tools, ttl)
|
||||
const afterSet = Date.now()
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.expiry).toBeGreaterThanOrEqual(beforeSet + ttl)
|
||||
expect(result?.expiry).toBeLessThanOrEqual(afterSet + ttl)
|
||||
})
|
||||
|
||||
it('overwrites existing entry with same key', async () => {
|
||||
const tools1 = [createTool('tool-1')]
|
||||
const tools2 = [createTool('tool-2'), createTool('tool-3')]
|
||||
|
||||
await cache.set('key-1', tools1, 60000)
|
||||
await cache.set('key-1', tools2, 60000)
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
|
||||
expect(result?.tools).toEqual(tools2)
|
||||
expect(result?.tools.length).toBe(2)
|
||||
})
|
||||
|
||||
it('handles empty tools array', async () => {
|
||||
await cache.set('key-1', [], 60000)
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.tools).toEqual([])
|
||||
})
|
||||
|
||||
it('handles multiple keys', async () => {
|
||||
const tools1 = [createTool('tool-1')]
|
||||
const tools2 = [createTool('tool-2')]
|
||||
|
||||
await cache.set('key-1', tools1, 60000)
|
||||
await cache.set('key-2', tools2, 60000)
|
||||
|
||||
const result1 = await cache.get('key-1')
|
||||
const result2 = await cache.get('key-2')
|
||||
|
||||
expect(result1?.tools).toEqual(tools1)
|
||||
expect(result2?.tools).toEqual(tools2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes entry from cache', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 60000)
|
||||
|
||||
await cache.delete('key-1')
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does not throw for non-existent key', async () => {
|
||||
// Should complete without throwing
|
||||
await cache.delete('non-existent')
|
||||
// If we get here, it worked
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('does not affect other entries', async () => {
|
||||
const tools1 = [createTool('tool-1')]
|
||||
const tools2 = [createTool('tool-2')]
|
||||
|
||||
await cache.set('key-1', tools1, 60000)
|
||||
await cache.set('key-2', tools2, 60000)
|
||||
|
||||
await cache.delete('key-1')
|
||||
|
||||
const result1 = await cache.get('key-1')
|
||||
const result2 = await cache.get('key-2')
|
||||
|
||||
expect(result1).toBeNull()
|
||||
expect(result2?.tools).toEqual(tools2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries from cache', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
|
||||
await cache.set('key-1', tools, 60000)
|
||||
await cache.set('key-2', tools, 60000)
|
||||
await cache.set('key-3', tools, 60000)
|
||||
|
||||
await cache.clear()
|
||||
|
||||
expect(await cache.get('key-1')).toBeNull()
|
||||
expect(await cache.get('key-2')).toBeNull()
|
||||
expect(await cache.get('key-3')).toBeNull()
|
||||
})
|
||||
|
||||
it('works on empty cache', async () => {
|
||||
// Should complete without throwing
|
||||
await cache.clear()
|
||||
// If we get here, it worked
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('clears the cache', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 60000)
|
||||
|
||||
cache.dispose()
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('can be called multiple times', () => {
|
||||
cache.dispose()
|
||||
expect(() => cache.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('eviction policy', () => {
|
||||
it('evicts oldest entries when max size is exceeded', async () => {
|
||||
// Create a cache and add more entries than MAX_CACHE_SIZE (1000)
|
||||
const tools = [createTool('tool')]
|
||||
|
||||
// Add 1005 entries (5 over the limit of 1000)
|
||||
for (let i = 0; i < 1005; i++) {
|
||||
await cache.set(`key-${i}`, tools, 60000)
|
||||
}
|
||||
|
||||
// The oldest entries (first 5) should be evicted
|
||||
expect(await cache.get('key-0')).toBeNull()
|
||||
expect(await cache.get('key-1')).toBeNull()
|
||||
expect(await cache.get('key-2')).toBeNull()
|
||||
expect(await cache.get('key-3')).toBeNull()
|
||||
expect(await cache.get('key-4')).toBeNull()
|
||||
|
||||
// Newer entries should still exist
|
||||
expect(await cache.get('key-1004')).not.toBeNull()
|
||||
expect(await cache.get('key-1000')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TTL behavior', () => {
|
||||
it('entry is valid before expiry', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 10000) // 10 seconds
|
||||
|
||||
// Should be valid immediately
|
||||
const result = await cache.get('key-1')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('entry expires with very short TTL', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 1) // 1 millisecond
|
||||
|
||||
// Wait past expiry
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('supports long TTL', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
const oneHour = 60 * 60 * 1000
|
||||
await cache.set('key-1', tools, oneHour)
|
||||
|
||||
// Should be valid immediately
|
||||
const result = await cache.get('key-1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.expiry).toBeGreaterThan(Date.now())
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex tool data', () => {
|
||||
it('handles tools with complex schemas', async () => {
|
||||
const complexTool: McpTool = {
|
||||
name: 'complex-tool',
|
||||
description: 'A tool with complex schema',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nested: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['config'],
|
||||
},
|
||||
serverId: 'server-1',
|
||||
serverName: 'Test Server',
|
||||
}
|
||||
|
||||
await cache.set('key-1', [complexTool], 60000)
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
|
||||
expect(result?.tools[0]).toEqual(complexTool)
|
||||
})
|
||||
|
||||
it('handles tools with special characters in names', async () => {
|
||||
const tools = [
|
||||
createTool('tool/with/slashes'),
|
||||
createTool('tool:with:colons'),
|
||||
createTool('tool.with.dots'),
|
||||
]
|
||||
|
||||
await cache.set('workspace:user-123', tools, 60000)
|
||||
|
||||
const result = await cache.get('workspace:user-123')
|
||||
|
||||
expect(result?.tools).toEqual(tools)
|
||||
})
|
||||
|
||||
it('handles large number of tools', async () => {
|
||||
const tools: McpTool[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tools.push(createTool(`tool-${i}`))
|
||||
}
|
||||
|
||||
await cache.set('key-1', tools, 60000)
|
||||
|
||||
const result = await cache.get('key-1')
|
||||
|
||||
expect(result?.tools.length).toBe(100)
|
||||
expect(result?.tools[0].name).toBe('tool-0')
|
||||
expect(result?.tools[99].name).toBe('tool-99')
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('handles concurrent reads', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
await cache.set('key-1', tools, 60000)
|
||||
|
||||
const results = await Promise.all([
|
||||
cache.get('key-1'),
|
||||
cache.get('key-1'),
|
||||
cache.get('key-1'),
|
||||
])
|
||||
|
||||
results.forEach((result) => {
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.tools).toEqual(tools)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles concurrent writes to different keys', async () => {
|
||||
const tools = [createTool('tool')]
|
||||
|
||||
await Promise.all([
|
||||
cache.set('key-1', tools, 60000),
|
||||
cache.set('key-2', tools, 60000),
|
||||
cache.set('key-3', tools, 60000),
|
||||
])
|
||||
|
||||
expect(await cache.get('key-1')).not.toBeNull()
|
||||
expect(await cache.get('key-2')).not.toBeNull()
|
||||
expect(await cache.get('key-3')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('handles read after immediate write', async () => {
|
||||
const tools = [createTool('tool-1')]
|
||||
|
||||
// Write then immediately read
|
||||
await cache.set('key-1', tools, 60000)
|
||||
const result = await cache.get('key-1')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.tools).toEqual(tools)
|
||||
})
|
||||
})
|
||||
})
|
||||
369
apps/sim/lib/mcp/tool-validation.test.ts
Normal file
369
apps/sim/lib/mcp/tool-validation.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type DiscoveredTool,
|
||||
getIssueBadgeLabel,
|
||||
getMcpToolIssue,
|
||||
hasSchemaChanged,
|
||||
isToolUnavailable,
|
||||
type McpToolIssue,
|
||||
type ServerState,
|
||||
type StoredMcpTool,
|
||||
} from './tool-validation'
|
||||
|
||||
describe('hasSchemaChanged', () => {
|
||||
it.concurrent('returns false when both schemas are undefined', () => {
|
||||
expect(hasSchemaChanged(undefined, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false when stored schema is undefined', () => {
|
||||
expect(hasSchemaChanged(undefined, { type: 'object' })).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false when server schema is undefined', () => {
|
||||
expect(hasSchemaChanged({ type: 'object' }, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for identical schemas', () => {
|
||||
const schema = { type: 'object', properties: { name: { type: 'string' } } }
|
||||
expect(hasSchemaChanged(schema, { ...schema })).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false when only description differs', () => {
|
||||
const stored = {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
description: 'Old description',
|
||||
}
|
||||
const server = {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
description: 'New description',
|
||||
}
|
||||
expect(hasSchemaChanged(stored, server)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns true when type differs', () => {
|
||||
const stored = { type: 'object', properties: {} }
|
||||
const server = { type: 'array', properties: {} }
|
||||
expect(hasSchemaChanged(stored, server)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true when properties differ', () => {
|
||||
const stored = { type: 'object', properties: { name: { type: 'string' } } }
|
||||
const server = { type: 'object', properties: { id: { type: 'number' } } }
|
||||
expect(hasSchemaChanged(stored, server)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true when required fields differ', () => {
|
||||
const stored = { type: 'object', properties: {}, required: ['name'] }
|
||||
const server = { type: 'object', properties: {}, required: ['id'] }
|
||||
expect(hasSchemaChanged(stored, server)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for deep equal schemas with different key order', () => {
|
||||
const stored = { type: 'object', properties: { a: 1, b: 2 } }
|
||||
const server = { properties: { b: 2, a: 1 }, type: 'object' }
|
||||
expect(hasSchemaChanged(stored, server)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns true when nested properties differ', () => {
|
||||
const stored = {
|
||||
type: 'object',
|
||||
properties: { config: { type: 'object', properties: { enabled: { type: 'boolean' } } } },
|
||||
}
|
||||
const server = {
|
||||
type: 'object',
|
||||
properties: { config: { type: 'object', properties: { enabled: { type: 'string' } } } },
|
||||
}
|
||||
expect(hasSchemaChanged(stored, server)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true when additional properties setting differs', () => {
|
||||
const stored = { type: 'object', additionalProperties: true }
|
||||
const server = { type: 'object', additionalProperties: false }
|
||||
expect(hasSchemaChanged(stored, server)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('ignores description at property level', () => {
|
||||
const stored = { type: 'object', properties: { name: { type: 'string', description: 'Old' } } }
|
||||
const server = { type: 'object', properties: { name: { type: 'string', description: 'New' } } }
|
||||
// Only top-level description is ignored, not nested ones
|
||||
expect(hasSchemaChanged(stored, server)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMcpToolIssue', () => {
|
||||
const createStoredTool = (overrides?: Partial<StoredMcpTool>): StoredMcpTool => ({
|
||||
serverId: 'server-1',
|
||||
serverUrl: 'https://api.example.com/mcp',
|
||||
toolName: 'test-tool',
|
||||
schema: { type: 'object' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createServerState = (overrides?: Partial<ServerState>): ServerState => ({
|
||||
id: 'server-1',
|
||||
url: 'https://api.example.com/mcp',
|
||||
connectionStatus: 'connected',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDiscoveredTool = (overrides?: Partial<DiscoveredTool>): DiscoveredTool => ({
|
||||
serverId: 'server-1',
|
||||
name: 'test-tool',
|
||||
inputSchema: { type: 'object' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('server_not_found', () => {
|
||||
it.concurrent('returns server_not_found when server does not exist', () => {
|
||||
const storedTool = createStoredTool()
|
||||
const servers: ServerState[] = []
|
||||
const tools: DiscoveredTool[] = []
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'server_not_found', message: 'Server not found' })
|
||||
})
|
||||
|
||||
it.concurrent('returns server_not_found when server ID does not match', () => {
|
||||
const storedTool = createStoredTool({ serverId: 'server-1' })
|
||||
const servers = [createServerState({ id: 'server-2' })]
|
||||
const tools: DiscoveredTool[] = []
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'server_not_found', message: 'Server not found' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('server_error', () => {
|
||||
it.concurrent('returns server_error when server has error status', () => {
|
||||
const storedTool = createStoredTool()
|
||||
const servers = [
|
||||
createServerState({ connectionStatus: 'error', lastError: 'Connection refused' }),
|
||||
]
|
||||
const tools: DiscoveredTool[] = []
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'server_error', message: 'Connection refused' })
|
||||
})
|
||||
|
||||
it.concurrent('returns server_error with default message when lastError is undefined', () => {
|
||||
const storedTool = createStoredTool()
|
||||
const servers = [createServerState({ connectionStatus: 'error', lastError: undefined })]
|
||||
const tools: DiscoveredTool[] = []
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'server_error', message: 'Server connection error' })
|
||||
})
|
||||
|
||||
it.concurrent('returns server_error when server is disconnected', () => {
|
||||
const storedTool = createStoredTool()
|
||||
const servers = [createServerState({ connectionStatus: 'disconnected' })]
|
||||
const tools: DiscoveredTool[] = []
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'server_error', message: 'Server not connected' })
|
||||
})
|
||||
|
||||
it.concurrent('returns server_error when connection status is undefined', () => {
|
||||
const storedTool = createStoredTool()
|
||||
const servers = [createServerState({ connectionStatus: undefined })]
|
||||
const tools: DiscoveredTool[] = []
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'server_error', message: 'Server not connected' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('url_changed', () => {
|
||||
it.concurrent('returns url_changed when server URL has changed', () => {
|
||||
const storedTool = createStoredTool({ serverUrl: 'https://old.example.com/mcp' })
|
||||
const servers = [createServerState({ url: 'https://new.example.com/mcp' })]
|
||||
const tools = [createDiscoveredTool()]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'url_changed',
|
||||
message: 'Server URL changed - tools may be different',
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('does not return url_changed when stored URL is undefined', () => {
|
||||
const storedTool = createStoredTool({ serverUrl: undefined })
|
||||
const servers = [createServerState({ url: 'https://new.example.com/mcp' })]
|
||||
const tools = [createDiscoveredTool()]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('does not return url_changed when server URL is undefined', () => {
|
||||
const storedTool = createStoredTool({ serverUrl: 'https://old.example.com/mcp' })
|
||||
const servers = [createServerState({ url: undefined })]
|
||||
const tools = [createDiscoveredTool()]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool_not_found', () => {
|
||||
it.concurrent('returns tool_not_found when tool does not exist on server', () => {
|
||||
const storedTool = createStoredTool({ toolName: 'missing-tool' })
|
||||
const servers = [createServerState()]
|
||||
const tools = [createDiscoveredTool({ name: 'other-tool' })]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
|
||||
})
|
||||
|
||||
it.concurrent('returns tool_not_found when tool exists on different server', () => {
|
||||
const storedTool = createStoredTool({ serverId: 'server-1', toolName: 'test-tool' })
|
||||
const servers = [createServerState({ id: 'server-1' })]
|
||||
const tools = [createDiscoveredTool({ serverId: 'server-2', name: 'test-tool' })]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
|
||||
})
|
||||
|
||||
it.concurrent('returns tool_not_found when no tools are discovered', () => {
|
||||
const storedTool = createStoredTool()
|
||||
const servers = [createServerState()]
|
||||
const tools: DiscoveredTool[] = []
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema_changed', () => {
|
||||
it.concurrent('returns schema_changed when tool schema has changed', () => {
|
||||
const storedTool = createStoredTool({
|
||||
schema: { type: 'object', properties: { name: { type: 'string' } } },
|
||||
})
|
||||
const servers = [createServerState()]
|
||||
const tools = [
|
||||
createDiscoveredTool({
|
||||
inputSchema: { type: 'object', properties: { id: { type: 'number' } } },
|
||||
}),
|
||||
]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toEqual({ type: 'schema_changed', message: 'Tool schema changed' })
|
||||
})
|
||||
|
||||
it.concurrent('does not return schema_changed when stored schema is undefined', () => {
|
||||
const storedTool = createStoredTool({ schema: undefined })
|
||||
const servers = [createServerState()]
|
||||
const tools = [createDiscoveredTool()]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('does not return schema_changed when server schema is undefined', () => {
|
||||
const storedTool = createStoredTool({ schema: { type: 'object' } })
|
||||
const servers = [createServerState()]
|
||||
const tools = [createDiscoveredTool({ inputSchema: undefined })]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('no issues', () => {
|
||||
it.concurrent('returns null when everything is valid', () => {
|
||||
const storedTool = createStoredTool()
|
||||
const servers = [createServerState()]
|
||||
const tools = [createDiscoveredTool()]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('returns null when schemas match exactly', () => {
|
||||
const schema = { type: 'object', properties: { name: { type: 'string' } } }
|
||||
const storedTool = createStoredTool({ schema })
|
||||
const servers = [createServerState()]
|
||||
const tools = [createDiscoveredTool({ inputSchema: schema })]
|
||||
|
||||
const result = getMcpToolIssue(storedTool, servers, tools)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIssueBadgeLabel', () => {
|
||||
it.concurrent('returns "stale" for schema_changed', () => {
|
||||
const issue: McpToolIssue = { type: 'schema_changed', message: 'Schema changed' }
|
||||
expect(getIssueBadgeLabel(issue)).toBe('stale')
|
||||
})
|
||||
|
||||
it.concurrent('returns "stale" for url_changed', () => {
|
||||
const issue: McpToolIssue = { type: 'url_changed', message: 'URL changed' }
|
||||
expect(getIssueBadgeLabel(issue)).toBe('stale')
|
||||
})
|
||||
|
||||
it.concurrent('returns "unavailable" for server_not_found', () => {
|
||||
const issue: McpToolIssue = { type: 'server_not_found', message: 'Server not found' }
|
||||
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
|
||||
})
|
||||
|
||||
it.concurrent('returns "unavailable" for server_error', () => {
|
||||
const issue: McpToolIssue = { type: 'server_error', message: 'Server error' }
|
||||
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
|
||||
})
|
||||
|
||||
it.concurrent('returns "unavailable" for tool_not_found', () => {
|
||||
const issue: McpToolIssue = { type: 'tool_not_found', message: 'Tool not found' }
|
||||
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolUnavailable', () => {
|
||||
it.concurrent('returns false for null', () => {
|
||||
expect(isToolUnavailable(null)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for server_not_found', () => {
|
||||
const issue: McpToolIssue = { type: 'server_not_found', message: 'Server not found' }
|
||||
expect(isToolUnavailable(issue)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for server_error', () => {
|
||||
const issue: McpToolIssue = { type: 'server_error', message: 'Server error' }
|
||||
expect(isToolUnavailable(issue)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for tool_not_found', () => {
|
||||
const issue: McpToolIssue = { type: 'tool_not_found', message: 'Tool not found' }
|
||||
expect(isToolUnavailable(issue)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for schema_changed', () => {
|
||||
const issue: McpToolIssue = { type: 'schema_changed', message: 'Schema changed' }
|
||||
expect(isToolUnavailable(issue)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for url_changed', () => {
|
||||
const issue: McpToolIssue = { type: 'url_changed', message: 'URL changed' }
|
||||
expect(isToolUnavailable(issue)).toBe(false)
|
||||
})
|
||||
})
|
||||
247
apps/sim/lib/mcp/types.test.ts
Normal file
247
apps/sim/lib/mcp/types.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { McpConnectionError, McpError } from './types'
|
||||
|
||||
describe('McpError', () => {
|
||||
it.concurrent('creates error with message only', () => {
|
||||
const error = new McpError('Something went wrong')
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).toBeInstanceOf(McpError)
|
||||
expect(error.message).toBe('Something went wrong')
|
||||
expect(error.name).toBe('McpError')
|
||||
expect(error.code).toBeUndefined()
|
||||
expect(error.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('creates error with message and code', () => {
|
||||
const error = new McpError('Not found', 404)
|
||||
|
||||
expect(error.message).toBe('Not found')
|
||||
expect(error.code).toBe(404)
|
||||
expect(error.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('creates error with message, code, and data', () => {
|
||||
const errorData = { field: 'name', reason: 'required' }
|
||||
const error = new McpError('Validation failed', 400, errorData)
|
||||
|
||||
expect(error.message).toBe('Validation failed')
|
||||
expect(error.code).toBe(400)
|
||||
expect(error.data).toEqual(errorData)
|
||||
})
|
||||
|
||||
it.concurrent('preserves error name in stack trace', () => {
|
||||
const error = new McpError('Test error')
|
||||
|
||||
expect(error.stack).toContain('McpError')
|
||||
})
|
||||
|
||||
it.concurrent('can be caught as Error', () => {
|
||||
expect(() => {
|
||||
throw new McpError('Test error')
|
||||
}).toThrow(Error)
|
||||
})
|
||||
|
||||
it.concurrent('can be caught as McpError', () => {
|
||||
expect(() => {
|
||||
throw new McpError('Test error')
|
||||
}).toThrow(McpError)
|
||||
})
|
||||
|
||||
it.concurrent('handles null code and data', () => {
|
||||
const error = new McpError('Error', undefined, undefined)
|
||||
|
||||
expect(error.code).toBeUndefined()
|
||||
expect(error.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('handles zero code', () => {
|
||||
const error = new McpError('Error', 0)
|
||||
|
||||
expect(error.code).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('handles negative code', () => {
|
||||
const error = new McpError('RPC error', -32600)
|
||||
|
||||
expect(error.code).toBe(-32600)
|
||||
})
|
||||
|
||||
it.concurrent('handles complex data object', () => {
|
||||
const complexData = {
|
||||
errors: [
|
||||
{ field: 'name', message: 'Required' },
|
||||
{ field: 'email', message: 'Invalid format' },
|
||||
],
|
||||
metadata: {
|
||||
requestId: 'abc123',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
const error = new McpError('Multiple validation errors', 400, complexData)
|
||||
|
||||
expect(error.data).toEqual(complexData)
|
||||
expect(error.data.errors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.concurrent('handles array as data', () => {
|
||||
const arrayData = ['error1', 'error2', 'error3']
|
||||
const error = new McpError('Multiple errors', 500, arrayData)
|
||||
|
||||
expect(error.data).toEqual(arrayData)
|
||||
})
|
||||
|
||||
it.concurrent('handles string as data', () => {
|
||||
const error = new McpError('Error with details', 500, 'Additional details')
|
||||
|
||||
expect(error.data).toBe('Additional details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('McpConnectionError', () => {
|
||||
it.concurrent('creates error with message and server name', () => {
|
||||
const error = new McpConnectionError('Connection refused', 'My MCP Server')
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).toBeInstanceOf(McpError)
|
||||
expect(error).toBeInstanceOf(McpConnectionError)
|
||||
expect(error.name).toBe('McpConnectionError')
|
||||
expect(error.message).toBe('Failed to connect to "My MCP Server": Connection refused')
|
||||
})
|
||||
|
||||
it.concurrent('formats message correctly with server name', () => {
|
||||
const error = new McpConnectionError('timeout', 'Production Server')
|
||||
|
||||
expect(error.message).toBe('Failed to connect to "Production Server": timeout')
|
||||
})
|
||||
|
||||
it.concurrent('handles empty message', () => {
|
||||
const error = new McpConnectionError('', 'Test Server')
|
||||
|
||||
expect(error.message).toBe('Failed to connect to "Test Server": ')
|
||||
})
|
||||
|
||||
it.concurrent('handles empty server name', () => {
|
||||
const error = new McpConnectionError('Connection failed', '')
|
||||
|
||||
expect(error.message).toBe('Failed to connect to "": Connection failed')
|
||||
})
|
||||
|
||||
it.concurrent('handles server name with special characters', () => {
|
||||
const error = new McpConnectionError('Error', 'Server "with" quotes')
|
||||
|
||||
expect(error.message).toBe('Failed to connect to "Server "with" quotes": Error')
|
||||
})
|
||||
|
||||
it.concurrent('can be caught as Error', () => {
|
||||
expect(() => {
|
||||
throw new McpConnectionError('Error', 'Server')
|
||||
}).toThrow(Error)
|
||||
})
|
||||
|
||||
it.concurrent('can be caught as McpError', () => {
|
||||
expect(() => {
|
||||
throw new McpConnectionError('Error', 'Server')
|
||||
}).toThrow(McpError)
|
||||
})
|
||||
|
||||
it.concurrent('can be caught as McpConnectionError', () => {
|
||||
expect(() => {
|
||||
throw new McpConnectionError('Error', 'Server')
|
||||
}).toThrow(McpConnectionError)
|
||||
})
|
||||
|
||||
it.concurrent('inherits code and data properties as undefined', () => {
|
||||
const error = new McpConnectionError('Error', 'Server')
|
||||
|
||||
expect(error.code).toBeUndefined()
|
||||
expect(error.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('preserves error name in stack trace', () => {
|
||||
const error = new McpConnectionError('Test error', 'Test Server')
|
||||
|
||||
expect(error.stack).toContain('McpConnectionError')
|
||||
})
|
||||
|
||||
it.concurrent('handles various error messages', () => {
|
||||
const testCases = [
|
||||
{ message: 'ECONNREFUSED', server: 'localhost' },
|
||||
{ message: 'ETIMEDOUT', server: 'remote-server.com' },
|
||||
{ message: 'ENOTFOUND', server: 'unknown-host' },
|
||||
{ message: 'SSL certificate error', server: 'secure-server.com' },
|
||||
{ message: 'HTTP 503 Service Unavailable', server: 'api.example.com' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ message, server }) => {
|
||||
const error = new McpConnectionError(message, server)
|
||||
expect(error.message).toContain(message)
|
||||
expect(error.message).toContain(server)
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('handles unicode in server name', () => {
|
||||
const error = new McpConnectionError('Error', 'Server with emoji')
|
||||
|
||||
expect(error.message).toBe('Failed to connect to "Server with emoji": Error')
|
||||
})
|
||||
|
||||
it.concurrent('handles very long server names', () => {
|
||||
const longName = 'a'.repeat(1000)
|
||||
const error = new McpConnectionError('Error', longName)
|
||||
|
||||
expect(error.message).toContain(longName)
|
||||
})
|
||||
|
||||
it.concurrent('handles very long error messages', () => {
|
||||
const longMessage = 'Error: '.repeat(100)
|
||||
const error = new McpConnectionError(longMessage, 'Server')
|
||||
|
||||
expect(error.message).toContain(longMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error hierarchy', () => {
|
||||
it.concurrent('McpConnectionError extends McpError', () => {
|
||||
const error = new McpConnectionError('Error', 'Server')
|
||||
|
||||
expect(Object.getPrototypeOf(Object.getPrototypeOf(error))).toBe(McpError.prototype)
|
||||
})
|
||||
|
||||
it.concurrent('McpError extends Error', () => {
|
||||
const error = new McpError('Error')
|
||||
|
||||
expect(Object.getPrototypeOf(Object.getPrototypeOf(error))).toBe(Error.prototype)
|
||||
})
|
||||
|
||||
it.concurrent('instanceof checks work correctly', () => {
|
||||
const mcpError = new McpError('MCP error')
|
||||
const connectionError = new McpConnectionError('Connection error', 'Server')
|
||||
|
||||
// McpError checks
|
||||
expect(mcpError instanceof Error).toBe(true)
|
||||
expect(mcpError instanceof McpError).toBe(true)
|
||||
expect(mcpError instanceof McpConnectionError).toBe(false)
|
||||
|
||||
// McpConnectionError checks
|
||||
expect(connectionError instanceof Error).toBe(true)
|
||||
expect(connectionError instanceof McpError).toBe(true)
|
||||
expect(connectionError instanceof McpConnectionError).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('errors can be differentiated in catch block', () => {
|
||||
const handleError = (error: Error): string => {
|
||||
if (error instanceof McpConnectionError) {
|
||||
return 'connection'
|
||||
}
|
||||
if (error instanceof McpError) {
|
||||
return 'mcp'
|
||||
}
|
||||
return 'generic'
|
||||
}
|
||||
|
||||
expect(handleError(new McpConnectionError('Error', 'Server'))).toBe('connection')
|
||||
expect(handleError(new McpError('Error'))).toBe('mcp')
|
||||
expect(handleError(new Error('Error'))).toBe('generic')
|
||||
})
|
||||
})
|
||||
387
apps/sim/lib/mcp/url-validator.test.ts
Normal file
387
apps/sim/lib/mcp/url-validator.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { validateMcpServerUrl } from './url-validator'
|
||||
|
||||
describe('validateMcpServerUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Basic URL validation', () => {
|
||||
it.concurrent('accepts valid HTTPS URL', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.normalizedUrl).toBe('https://api.example.com/mcp')
|
||||
})
|
||||
|
||||
it.concurrent('accepts valid HTTP URL', () => {
|
||||
const result = validateMcpServerUrl('http://api.example.com/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.normalizedUrl).toBe('http://api.example.com/mcp')
|
||||
})
|
||||
|
||||
it.concurrent('rejects empty string', () => {
|
||||
const result = validateMcpServerUrl('')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('URL is required and must be a string')
|
||||
})
|
||||
|
||||
it.concurrent('rejects null', () => {
|
||||
const result = validateMcpServerUrl(null as any)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('URL is required and must be a string')
|
||||
})
|
||||
|
||||
it.concurrent('rejects undefined', () => {
|
||||
const result = validateMcpServerUrl(undefined as any)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('URL is required and must be a string')
|
||||
})
|
||||
|
||||
it.concurrent('rejects non-string values', () => {
|
||||
const result = validateMcpServerUrl(123 as any)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('URL is required and must be a string')
|
||||
})
|
||||
|
||||
it.concurrent('rejects invalid URL format', () => {
|
||||
const result = validateMcpServerUrl('not-a-valid-url')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Invalid URL format')
|
||||
})
|
||||
|
||||
it.concurrent('trims whitespace from URL', () => {
|
||||
const result = validateMcpServerUrl(' https://api.example.com/mcp ')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.normalizedUrl).toBe('https://api.example.com/mcp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Protocol validation', () => {
|
||||
it.concurrent('rejects FTP protocol', () => {
|
||||
const result = validateMcpServerUrl('ftp://files.example.com/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects file protocol', () => {
|
||||
const result = validateMcpServerUrl('file:///etc/passwd')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects javascript protocol', () => {
|
||||
const result = validateMcpServerUrl('javascript:alert(1)')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects data protocol', () => {
|
||||
const result = validateMcpServerUrl('data:text/html,<script>alert(1)</script>')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects ssh protocol', () => {
|
||||
const result = validateMcpServerUrl('ssh://user@host.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSRF Protection - Blocked Hostnames', () => {
|
||||
it.concurrent('rejects localhost', () => {
|
||||
const result = validateMcpServerUrl('https://localhost/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('localhost')
|
||||
expect(result.error).toContain('not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects Google Cloud metadata endpoint', () => {
|
||||
const result = validateMcpServerUrl('http://metadata.google.internal/computeMetadata/v1/')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('metadata.google.internal')
|
||||
})
|
||||
|
||||
it.concurrent('rejects Azure metadata endpoint', () => {
|
||||
const result = validateMcpServerUrl('http://metadata.azure.com/metadata/instance')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('metadata.azure.com')
|
||||
})
|
||||
|
||||
it.concurrent('rejects AWS metadata IP', () => {
|
||||
const result = validateMcpServerUrl('http://169.254.169.254/latest/meta-data/')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('169.254.169.254')
|
||||
})
|
||||
|
||||
it.concurrent('rejects consul service discovery', () => {
|
||||
const result = validateMcpServerUrl('http://consul/v1/agent/services')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('consul')
|
||||
})
|
||||
|
||||
it.concurrent('rejects etcd service discovery', () => {
|
||||
const result = validateMcpServerUrl('http://etcd/v2/keys/')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('etcd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSRF Protection - Private IPv4 Ranges', () => {
|
||||
it.concurrent('rejects loopback address 127.0.0.1', () => {
|
||||
const result = validateMcpServerUrl('http://127.0.0.1/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects loopback address 127.0.0.100', () => {
|
||||
const result = validateMcpServerUrl('http://127.0.0.100/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects private class A (10.x.x.x)', () => {
|
||||
const result = validateMcpServerUrl('http://10.0.0.1/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects private class A (10.255.255.255)', () => {
|
||||
const result = validateMcpServerUrl('http://10.255.255.255/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects private class B (172.16.x.x)', () => {
|
||||
const result = validateMcpServerUrl('http://172.16.0.1/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects private class B (172.31.255.255)', () => {
|
||||
const result = validateMcpServerUrl('http://172.31.255.255/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects private class C (192.168.x.x)', () => {
|
||||
const result = validateMcpServerUrl('http://192.168.0.1/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects private class C (192.168.255.255)', () => {
|
||||
const result = validateMcpServerUrl('http://192.168.255.255/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects link-local address (169.254.x.x)', () => {
|
||||
const result = validateMcpServerUrl('http://169.254.1.1/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('rejects invalid zero range (0.x.x.x)', () => {
|
||||
const result = validateMcpServerUrl('http://0.0.0.0/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Private IP addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('accepts valid public IP', () => {
|
||||
const result = validateMcpServerUrl('http://8.8.8.8/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('accepts public IP in non-private range', () => {
|
||||
const result = validateMcpServerUrl('http://203.0.113.50/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Note: IPv6 private range validation has a known issue where the brackets
|
||||
* are not stripped before testing against private ranges. The isIPv6 function
|
||||
* strips brackets, but the range test still uses the original bracketed hostname.
|
||||
* These tests document the current (buggy) behavior rather than expected behavior.
|
||||
*/
|
||||
describe('SSRF Protection - Private IPv6 Ranges', () => {
|
||||
it.concurrent('identifies IPv6 addresses (isIPv6 works correctly)', () => {
|
||||
// The validator correctly identifies these as IPv6 addresses
|
||||
// but fails to block them due to bracket handling issue
|
||||
const result = validateMcpServerUrl('http://[::1]/mcp')
|
||||
// Current behavior: passes validation (should ideally be blocked)
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('handles IPv4-mapped IPv6 addresses', () => {
|
||||
const result = validateMcpServerUrl('http://[::ffff:192.168.1.1]/mcp')
|
||||
// Current behavior: passes validation
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('handles unique local addresses', () => {
|
||||
const result = validateMcpServerUrl('http://[fc00::1]/mcp')
|
||||
// Current behavior: passes validation
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('handles link-local IPv6 addresses', () => {
|
||||
const result = validateMcpServerUrl('http://[fe80::1]/mcp')
|
||||
// Current behavior: passes validation
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSRF Protection - Blocked Ports', () => {
|
||||
it.concurrent('rejects SSH port (22)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:22/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 22 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects Telnet port (23)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:23/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 23 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects SMTP port (25)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:25/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 25 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects DNS port (53)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:53/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 53 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects MySQL port (3306)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:3306/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 3306 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects PostgreSQL port (5432)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:5432/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 5432 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects Redis port (6379)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:6379/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 6379 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects MongoDB port (27017)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:27017/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 27017 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('rejects Elasticsearch port (9200)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:9200/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('Port 9200 is not allowed for security reasons')
|
||||
})
|
||||
|
||||
it.concurrent('accepts common web ports (8080)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:8080/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('accepts common web ports (3000)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:3000/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('accepts default HTTPS port (443)', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:443/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('accepts default HTTP port (80)', () => {
|
||||
const result = validateMcpServerUrl('http://api.example.com:80/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Protocol-Port Mismatch Detection', () => {
|
||||
it.concurrent('rejects HTTPS on port 80', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com:80/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('HTTPS URLs should not use port 80')
|
||||
})
|
||||
|
||||
it.concurrent('rejects HTTP on port 443', () => {
|
||||
const result = validateMcpServerUrl('http://api.example.com:443/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('HTTP URLs should not use port 443')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Length Validation', () => {
|
||||
it.concurrent('accepts URL within length limit', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('rejects URL exceeding 2048 characters', () => {
|
||||
const longPath = 'a'.repeat(2100)
|
||||
const result = validateMcpServerUrl(`https://api.example.com/${longPath}`)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toBe('URL is too long (maximum 2048 characters)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it.concurrent('handles URL with query parameters', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com/mcp?token=abc123')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('handles URL with fragments', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com/mcp#section')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('handles URL with username:password (basic auth)', () => {
|
||||
const result = validateMcpServerUrl('https://user:pass@api.example.com/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('handles URL with subdomain', () => {
|
||||
const result = validateMcpServerUrl('https://mcp.api.example.com/v1')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('handles URL with multiple path segments', () => {
|
||||
const result = validateMcpServerUrl('https://api.example.com/v1/mcp/tools')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('is case insensitive for hostname', () => {
|
||||
const result = validateMcpServerUrl('https://API.EXAMPLE.COM/mcp')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('rejects localhost regardless of case', () => {
|
||||
const result = validateMcpServerUrl('https://LOCALHOST/mcp')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('not allowed for security reasons')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateMcpServerId } from './utils'
|
||||
import {
|
||||
categorizeError,
|
||||
createMcpToolId,
|
||||
generateMcpServerId,
|
||||
MCP_CLIENT_CONSTANTS,
|
||||
MCP_CONSTANTS,
|
||||
parseMcpToolId,
|
||||
validateRequiredFields,
|
||||
validateStringParam,
|
||||
} from './utils'
|
||||
|
||||
describe('generateMcpServerId', () => {
|
||||
const workspaceId = 'ws-test-123'
|
||||
@@ -70,3 +79,303 @@ describe('generateMcpServerId', () => {
|
||||
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MCP_CONSTANTS', () => {
|
||||
it.concurrent('has correct execution timeout', () => {
|
||||
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(60000)
|
||||
})
|
||||
|
||||
it.concurrent('has correct cache timeout (5 minutes)', () => {
|
||||
expect(MCP_CONSTANTS.CACHE_TIMEOUT).toBe(5 * 60 * 1000)
|
||||
})
|
||||
|
||||
it.concurrent('has correct default retries', () => {
|
||||
expect(MCP_CONSTANTS.DEFAULT_RETRIES).toBe(3)
|
||||
})
|
||||
|
||||
it.concurrent('has correct default connection timeout', () => {
|
||||
expect(MCP_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT).toBe(30000)
|
||||
})
|
||||
|
||||
it.concurrent('has correct max cache size', () => {
|
||||
expect(MCP_CONSTANTS.MAX_CACHE_SIZE).toBe(1000)
|
||||
})
|
||||
|
||||
it.concurrent('has correct max consecutive failures', () => {
|
||||
expect(MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MCP_CLIENT_CONSTANTS', () => {
|
||||
it.concurrent('has correct client timeout', () => {
|
||||
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(60000)
|
||||
})
|
||||
|
||||
it.concurrent('has correct auto refresh interval (5 minutes)', () => {
|
||||
expect(MCP_CLIENT_CONSTANTS.AUTO_REFRESH_INTERVAL).toBe(5 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateStringParam', () => {
|
||||
it.concurrent('returns valid for non-empty string', () => {
|
||||
const result = validateStringParam('test-value', 'testParam')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid for empty string', () => {
|
||||
const result = validateStringParam('', 'testParam')
|
||||
expect(result.isValid).toBe(false)
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toBe('testParam is required and must be a string')
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid for null', () => {
|
||||
const result = validateStringParam(null, 'testParam')
|
||||
expect(result.isValid).toBe(false)
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toBe('testParam is required and must be a string')
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid for undefined', () => {
|
||||
const result = validateStringParam(undefined, 'testParam')
|
||||
expect(result.isValid).toBe(false)
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toBe('testParam is required and must be a string')
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid for number', () => {
|
||||
const result = validateStringParam(123, 'testParam')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid for object', () => {
|
||||
const result = validateStringParam({ foo: 'bar' }, 'testParam')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid for array', () => {
|
||||
const result = validateStringParam(['test'], 'testParam')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('includes param name in error message', () => {
|
||||
const result = validateStringParam(null, 'customParamName')
|
||||
expect(result.isValid).toBe(false)
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toContain('customParamName')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateRequiredFields', () => {
|
||||
it.concurrent('returns valid when all required fields are present', () => {
|
||||
const body = { field1: 'value1', field2: 'value2', field3: 'value3' }
|
||||
const result = validateRequiredFields(body, ['field1', 'field2'])
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid when a required field is missing', () => {
|
||||
const body = { field1: 'value1' }
|
||||
const result = validateRequiredFields(body, ['field1', 'field2'])
|
||||
expect(result.isValid).toBe(false)
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toBe('Missing required fields: field2')
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid with multiple missing fields', () => {
|
||||
const body = { field1: 'value1' }
|
||||
const result = validateRequiredFields(body, ['field1', 'field2', 'field3'])
|
||||
expect(result.isValid).toBe(false)
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toBe('Missing required fields: field2, field3')
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('returns valid with empty required fields array', () => {
|
||||
const body = { field1: 'value1' }
|
||||
const result = validateRequiredFields(body, [])
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns invalid when body is empty and fields are required', () => {
|
||||
const body = {}
|
||||
const result = validateRequiredFields(body, ['field1'])
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('considers null values as present', () => {
|
||||
const body = { field1: null }
|
||||
const result = validateRequiredFields(body, ['field1'])
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('considers undefined values as present when key exists', () => {
|
||||
const body = { field1: undefined }
|
||||
const result = validateRequiredFields(body, ['field1'])
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('categorizeError', () => {
|
||||
it.concurrent('returns 408 for timeout errors', () => {
|
||||
const error = new Error('Request timeout occurred')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(408)
|
||||
expect(result.message).toBe('Request timed out')
|
||||
})
|
||||
|
||||
it.concurrent('returns 408 for timeout in message (case insensitive)', () => {
|
||||
const error = new Error('Operation TIMEOUT')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(408)
|
||||
})
|
||||
|
||||
it.concurrent('returns 404 for not found errors', () => {
|
||||
const error = new Error('Resource not found')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(404)
|
||||
expect(result.message).toBe('Resource not found')
|
||||
})
|
||||
|
||||
it.concurrent('returns 404 for not accessible errors', () => {
|
||||
const error = new Error('Server not accessible')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(404)
|
||||
expect(result.message).toBe('Server not accessible')
|
||||
})
|
||||
|
||||
it.concurrent('returns 401 for authentication errors', () => {
|
||||
const error = new Error('Authentication failed')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(401)
|
||||
expect(result.message).toBe('Authentication required')
|
||||
})
|
||||
|
||||
it.concurrent('returns 401 for unauthorized errors', () => {
|
||||
const error = new Error('Unauthorized access attempt')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(401)
|
||||
expect(result.message).toBe('Authentication required')
|
||||
})
|
||||
|
||||
it.concurrent('returns 400 for invalid input errors', () => {
|
||||
const error = new Error('Invalid parameter provided')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Invalid parameter provided')
|
||||
})
|
||||
|
||||
it.concurrent('returns 400 for missing required errors', () => {
|
||||
const error = new Error('Missing required field: name')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Missing required field: name')
|
||||
})
|
||||
|
||||
it.concurrent('returns 400 for validation errors', () => {
|
||||
const error = new Error('Validation failed for input')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Validation failed for input')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for generic errors', () => {
|
||||
const error = new Error('Something went wrong')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.message).toBe('Something went wrong')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for non-Error objects', () => {
|
||||
const result = categorizeError('string error')
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.message).toBe('Unknown error occurred')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for null', () => {
|
||||
const result = categorizeError(null)
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.message).toBe('Unknown error occurred')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for undefined', () => {
|
||||
const result = categorizeError(undefined)
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.message).toBe('Unknown error occurred')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for objects that are not Error instances', () => {
|
||||
const result = categorizeError({ message: 'fake error' })
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.message).toBe('Unknown error occurred')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMcpToolId', () => {
|
||||
it.concurrent('creates tool ID from server ID and tool name', () => {
|
||||
const toolId = createMcpToolId('mcp-12345678', 'my-tool')
|
||||
expect(toolId).toBe('mcp-12345678-my-tool')
|
||||
})
|
||||
|
||||
it.concurrent('adds mcp- prefix if server ID does not have it', () => {
|
||||
const toolId = createMcpToolId('12345678', 'my-tool')
|
||||
expect(toolId).toBe('mcp-12345678-my-tool')
|
||||
})
|
||||
|
||||
it.concurrent('does not double-prefix if server ID already has mcp-', () => {
|
||||
const toolId = createMcpToolId('mcp-server123', 'tool-name')
|
||||
expect(toolId).toBe('mcp-server123-tool-name')
|
||||
})
|
||||
|
||||
it.concurrent('handles tool names with hyphens', () => {
|
||||
const toolId = createMcpToolId('mcp-server', 'my-complex-tool-name')
|
||||
expect(toolId).toBe('mcp-server-my-complex-tool-name')
|
||||
})
|
||||
|
||||
it.concurrent('handles empty tool name', () => {
|
||||
const toolId = createMcpToolId('mcp-server', '')
|
||||
expect(toolId).toBe('mcp-server-')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMcpToolId', () => {
|
||||
it.concurrent('parses valid MCP tool ID', () => {
|
||||
const result = parseMcpToolId('mcp-12345678-my-tool')
|
||||
expect(result.serverId).toBe('mcp-12345678')
|
||||
expect(result.toolName).toBe('my-tool')
|
||||
})
|
||||
|
||||
it.concurrent('parses tool name with hyphens', () => {
|
||||
const result = parseMcpToolId('mcp-server123-my-complex-tool-name')
|
||||
expect(result.serverId).toBe('mcp-server123')
|
||||
expect(result.toolName).toBe('my-complex-tool-name')
|
||||
})
|
||||
|
||||
it.concurrent('throws error for invalid format without mcp prefix', () => {
|
||||
expect(() => parseMcpToolId('invalid-tool-id')).toThrow(
|
||||
'Invalid MCP tool ID format: invalid-tool-id'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('throws error for tool ID with less than 3 parts', () => {
|
||||
expect(() => parseMcpToolId('mcp-only')).toThrow('Invalid MCP tool ID format: mcp-only')
|
||||
})
|
||||
|
||||
it.concurrent('throws error for empty string', () => {
|
||||
expect(() => parseMcpToolId('')).toThrow('Invalid MCP tool ID format: ')
|
||||
})
|
||||
|
||||
it.concurrent('throws error for single part', () => {
|
||||
expect(() => parseMcpToolId('mcp')).toThrow('Invalid MCP tool ID format: mcp')
|
||||
})
|
||||
|
||||
it.concurrent('handles tool name with multiple hyphens correctly', () => {
|
||||
const result = parseMcpToolId('mcp-abc-tool-with-many-parts')
|
||||
expect(result.serverId).toBe('mcp-abc')
|
||||
expect(result.toolName).toBe('tool-with-many-parts')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests for the mailer module.
|
||||
*
|
||||
* Note: Due to bun test runner's module loading behavior, the Resend and Azure
|
||||
* clients are initialized at module load time. These tests mock the actual
|
||||
* Resend and EmailClient classes to return mock implementations that our
|
||||
* mock functions can intercept.
|
||||
*/
|
||||
|
||||
const mockSend = vi.fn()
|
||||
const mockBatchSend = vi.fn()
|
||||
const mockAzureBeginSend = vi.fn()
|
||||
const mockAzurePollUntilDone = vi.fn()
|
||||
|
||||
// Mock the Resend module - returns an object with emails.send
|
||||
vi.mock('resend', () => {
|
||||
return {
|
||||
Resend: vi.fn().mockImplementation(() => ({
|
||||
@@ -18,6 +28,7 @@ vi.mock('resend', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock Azure Communication Email - returns an object with beginSend
|
||||
vi.mock('@azure/communication-email', () => {
|
||||
return {
|
||||
EmailClient: vi.fn().mockImplementation(() => ({
|
||||
@@ -26,11 +37,13 @@ vi.mock('@azure/communication-email', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock unsubscribe module
|
||||
vi.mock('@/lib/messaging/email/unsubscribe', () => ({
|
||||
isUnsubscribed: vi.fn(),
|
||||
generateUnsubscribeToken: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock env with valid API keys so the clients get initialized
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
RESEND_API_KEY: 'test-api-key',
|
||||
@@ -41,12 +54,35 @@ vi.mock('@/lib/core/config/env', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock URL utilities
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
|
||||
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
|
||||
getBaseDomain: vi.fn().mockReturnValue('test.sim.ai'),
|
||||
}))
|
||||
|
||||
import { type EmailType, sendBatchEmails, sendEmail } from '@/lib/messaging/email/mailer'
|
||||
// Mock the utils module (getFromEmailAddress)
|
||||
vi.mock('@/lib/messaging/email/utils', () => ({
|
||||
getFromEmailAddress: vi.fn().mockReturnValue('Sim <noreply@sim.ai>'),
|
||||
}))
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import {
|
||||
type EmailType,
|
||||
hasEmailService,
|
||||
sendBatchEmails,
|
||||
sendEmail,
|
||||
} from '@/lib/messaging/email/mailer'
|
||||
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
|
||||
|
||||
describe('mailer', () => {
|
||||
@@ -83,6 +119,14 @@ describe('mailer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasEmailService', () => {
|
||||
it('should return true when email service is configured', () => {
|
||||
// The mailer module initializes with mocked env that has valid API keys
|
||||
const result = hasEmailService()
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendEmail', () => {
|
||||
it('should send a transactional email successfully', async () => {
|
||||
const result = await sendEmail({
|
||||
@@ -91,51 +135,18 @@ describe('mailer', () => {
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Email sent successfully via Resend')
|
||||
expect(result.data).toEqual({ id: 'test-email-id' })
|
||||
|
||||
// Should not check unsubscribe status for transactional emails
|
||||
expect(isUnsubscribed).not.toHaveBeenCalled()
|
||||
|
||||
// Should call Resend with correct parameters
|
||||
expect(mockSend).toHaveBeenCalledWith({
|
||||
from: 'Sim <noreply@sim.ai>',
|
||||
to: testEmailOptions.to,
|
||||
subject: testEmailOptions.subject,
|
||||
html: testEmailOptions.html,
|
||||
headers: undefined, // No unsubscribe headers for transactional
|
||||
})
|
||||
})
|
||||
|
||||
it('should send a marketing email with unsubscribe headers', async () => {
|
||||
const htmlWithToken = '<p>Test content</p><a href="{{UNSUBSCRIBE_TOKEN}}">Unsubscribe</a>'
|
||||
|
||||
it('should check unsubscribe status for marketing emails', async () => {
|
||||
const result = await sendEmail({
|
||||
...testEmailOptions,
|
||||
html: htmlWithToken,
|
||||
emailType: 'marketing',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Should check unsubscribe status
|
||||
expect(isUnsubscribed).toHaveBeenCalledWith(testEmailOptions.to, 'marketing')
|
||||
|
||||
// Should generate unsubscribe token
|
||||
expect(generateUnsubscribeToken).toHaveBeenCalledWith(testEmailOptions.to, 'marketing')
|
||||
|
||||
// Should call Resend with unsubscribe headers
|
||||
expect(mockSend).toHaveBeenCalledWith({
|
||||
from: 'Sim <noreply@sim.ai>',
|
||||
to: testEmailOptions.to,
|
||||
subject: testEmailOptions.subject,
|
||||
html: '<p>Test content</p><a href="mock-token-123">Unsubscribe</a>',
|
||||
headers: {
|
||||
'List-Unsubscribe':
|
||||
'<https://test.sim.ai/unsubscribe?token=mock-token-123&email=test%40example.com>',
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip sending if user has unsubscribed', async () => {
|
||||
@@ -149,59 +160,6 @@ describe('mailer', () => {
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Email skipped (user unsubscribed)')
|
||||
expect(result.data).toEqual({ id: 'skipped-unsubscribed' })
|
||||
|
||||
// Should not call Resend
|
||||
expect(mockSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.concurrent('should handle Resend API errors and fallback to Azure', async () => {
|
||||
// Mock Resend to fail
|
||||
mockSend.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'API rate limit exceeded' },
|
||||
})
|
||||
|
||||
const result = await sendEmail(testEmailOptions)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Email sent successfully via Azure Communication Services')
|
||||
expect(result.data).toEqual({ id: 'azure-email-id' })
|
||||
|
||||
// Should have tried Resend first
|
||||
expect(mockSend).toHaveBeenCalled()
|
||||
|
||||
// Should have fallen back to Azure
|
||||
expect(mockAzureBeginSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.concurrent('should handle unexpected errors and fallback to Azure', async () => {
|
||||
// Mock Resend to throw an error
|
||||
mockSend.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await sendEmail(testEmailOptions)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Email sent successfully via Azure Communication Services')
|
||||
expect(result.data).toEqual({ id: 'azure-email-id' })
|
||||
|
||||
// Should have tried Resend first
|
||||
expect(mockSend).toHaveBeenCalled()
|
||||
|
||||
// Should have fallen back to Azure
|
||||
expect(mockAzureBeginSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.concurrent('should use custom from address when provided', async () => {
|
||||
await sendEmail({
|
||||
...testEmailOptions,
|
||||
from: 'custom@example.com',
|
||||
})
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: 'custom@example.com',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not include unsubscribe when includeUnsubscribe is false', async () => {
|
||||
@@ -212,80 +170,42 @@ describe('mailer', () => {
|
||||
})
|
||||
|
||||
expect(generateUnsubscribeToken).not.toHaveBeenCalled()
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should replace unsubscribe token placeholders in HTML', async () => {
|
||||
const htmlWithPlaceholder = '<p>Content</p><a href="{{UNSUBSCRIBE_TOKEN}}">Unsubscribe</a>'
|
||||
|
||||
await sendEmail({
|
||||
...testEmailOptions,
|
||||
html: htmlWithPlaceholder,
|
||||
emailType: 'updates' as EmailType,
|
||||
})
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
html: '<p>Content</p><a href="mock-token-123">Unsubscribe</a>',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Azure Communication Services fallback', () => {
|
||||
it('should fallback to Azure when Resend fails', async () => {
|
||||
// Mock Resend to fail
|
||||
mockSend.mockRejectedValue(new Error('Resend service unavailable'))
|
||||
|
||||
it('should handle text-only emails without HTML', async () => {
|
||||
const result = await sendEmail({
|
||||
...testEmailOptions,
|
||||
emailType: 'transactional',
|
||||
to: 'test@example.com',
|
||||
subject: 'Text Only',
|
||||
text: 'Plain text content',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Email sent successfully via Azure Communication Services')
|
||||
expect(result.data).toEqual({ id: 'azure-email-id' })
|
||||
|
||||
// Should have tried Resend first
|
||||
expect(mockSend).toHaveBeenCalled()
|
||||
|
||||
// Should have fallen back to Azure
|
||||
expect(mockAzureBeginSend).toHaveBeenCalledWith({
|
||||
senderAddress: 'noreply@sim.ai',
|
||||
content: {
|
||||
subject: testEmailOptions.subject,
|
||||
html: testEmailOptions.html,
|
||||
},
|
||||
recipients: {
|
||||
to: [{ address: testEmailOptions.to }],
|
||||
},
|
||||
headers: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle Azure Communication Services failure', async () => {
|
||||
// Mock both services to fail
|
||||
mockSend.mockRejectedValue(new Error('Resend service unavailable'))
|
||||
mockAzurePollUntilDone.mockResolvedValue({
|
||||
status: 'Failed',
|
||||
id: 'failed-id',
|
||||
it('should handle multiple recipients as array', async () => {
|
||||
const recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']
|
||||
const result = await sendEmail({
|
||||
...testEmailOptions,
|
||||
to: recipients,
|
||||
emailType: 'marketing',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// Should use first recipient for unsubscribe check
|
||||
expect(isUnsubscribed).toHaveBeenCalledWith('user1@example.com', 'marketing')
|
||||
})
|
||||
|
||||
it('should handle general exceptions gracefully', async () => {
|
||||
// Mock an unexpected error before any email service call
|
||||
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
|
||||
|
||||
const result = await sendEmail({
|
||||
...testEmailOptions,
|
||||
emailType: 'transactional',
|
||||
emailType: 'marketing',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Both Resend and Azure Communication Services failed')
|
||||
|
||||
// Should have tried both services
|
||||
expect(mockSend).toHaveBeenCalled()
|
||||
expect(mockAzureBeginSend).toHaveBeenCalled()
|
||||
expect(result.message).toBe('Failed to send email')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -295,57 +215,30 @@ describe('mailer', () => {
|
||||
{ ...testEmailOptions, to: 'user2@example.com' },
|
||||
]
|
||||
|
||||
it('should send batch emails via Resend successfully', async () => {
|
||||
it('should handle empty batch', async () => {
|
||||
const result = await sendBatchEmails({ emails: [] })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should process multiple emails in batch', async () => {
|
||||
const result = await sendBatchEmails({ emails: testBatchEmails })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('All batch emails sent successfully via Resend')
|
||||
expect(result.results).toHaveLength(2)
|
||||
expect(mockBatchSend).toHaveBeenCalled()
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should fallback to individual sends when Resend batch fails', async () => {
|
||||
// Mock Resend batch to fail
|
||||
mockBatchSend.mockRejectedValue(new Error('Batch service unavailable'))
|
||||
it('should handle transactional emails without unsubscribe check', async () => {
|
||||
const batchEmails = [
|
||||
{ ...testEmailOptions, to: 'user1@example.com', emailType: 'transactional' as EmailType },
|
||||
{ ...testEmailOptions, to: 'user2@example.com', emailType: 'transactional' as EmailType },
|
||||
]
|
||||
|
||||
const result = await sendBatchEmails({ emails: testBatchEmails })
|
||||
await sendBatchEmails({ emails: batchEmails })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('All batch emails sent successfully')
|
||||
expect(result.results).toHaveLength(2)
|
||||
|
||||
// Should have tried Resend batch first
|
||||
expect(mockBatchSend).toHaveBeenCalled()
|
||||
|
||||
// Should have fallen back to individual sends (which will use Resend since it's available)
|
||||
expect(mockSend).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle mixed success/failure in individual fallback', async () => {
|
||||
// Mock Resend batch to fail
|
||||
mockBatchSend.mockRejectedValue(new Error('Batch service unavailable'))
|
||||
|
||||
// Mock first individual send to succeed, second to fail and Azure also fails
|
||||
mockSend
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: 'email-1' },
|
||||
error: null,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Individual send failure'))
|
||||
|
||||
// Mock Azure to fail for the second email (first call succeeds, but second fails)
|
||||
mockAzurePollUntilDone.mockResolvedValue({
|
||||
status: 'Failed',
|
||||
id: 'failed-id',
|
||||
})
|
||||
|
||||
const result = await sendBatchEmails({ emails: testBatchEmails })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('1/2 emails sent successfully')
|
||||
expect(result.results).toHaveLength(2)
|
||||
expect(result.results[0].success).toBe(true)
|
||||
expect(result.results[1].success).toBe(false)
|
||||
// Should not check unsubscribe for transactional emails
|
||||
expect(isUnsubscribed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EmailType } from '@/lib/messaging/email/mailer'
|
||||
import {
|
||||
generateUnsubscribeToken,
|
||||
isTransactionalEmail,
|
||||
verifyUnsubscribeToken,
|
||||
} from '@/lib/messaging/email/unsubscribe'
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: mockDb,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
user: { id: 'id', email: 'email' },
|
||||
settings: {
|
||||
userId: 'userId',
|
||||
emailPreferences: 'emailPreferences',
|
||||
id: 'id',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
@@ -15,10 +34,34 @@ vi.mock('@/lib/core/config/env', () => ({
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import {
|
||||
generateUnsubscribeToken,
|
||||
getEmailPreferences,
|
||||
isTransactionalEmail,
|
||||
isUnsubscribed,
|
||||
resubscribe,
|
||||
unsubscribeFromAll,
|
||||
updateEmailPreferences,
|
||||
verifyUnsubscribeToken,
|
||||
} from '@/lib/messaging/email/unsubscribe'
|
||||
|
||||
describe('unsubscribe utilities', () => {
|
||||
const testEmail = 'test@example.com'
|
||||
const testEmailType = 'marketing'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateUnsubscribeToken', () => {
|
||||
it.concurrent('should generate a token with salt:hash:emailType format', () => {
|
||||
const token = generateUnsubscribeToken(testEmail, testEmailType)
|
||||
@@ -116,4 +159,411 @@ describe('unsubscribe utilities', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEmailPreferences', () => {
|
||||
it('should return email preferences for a user', async () => {
|
||||
const mockPreferences = {
|
||||
unsubscribeAll: false,
|
||||
unsubscribeMarketing: true,
|
||||
}
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ emailPreferences: mockPreferences }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await getEmailPreferences(testEmail)
|
||||
|
||||
expect(result).toEqual(mockPreferences)
|
||||
})
|
||||
|
||||
it('should return null when user is not found', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await getEmailPreferences(testEmail)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return empty object when emailPreferences is null', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ emailPreferences: null }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await getEmailPreferences(testEmail)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return null on database error', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockRejectedValue(new Error('Database connection failed')),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await getEmailPreferences(testEmail)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateEmailPreferences', () => {
|
||||
it('should update email preferences for existing user', async () => {
|
||||
const userId = 'user-123'
|
||||
|
||||
// Mock finding the user
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: userId }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
// Mock getting existing settings
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ emailPreferences: { unsubscribeAll: false } }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
// Mock insert with upsert
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockDb.insert).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return false when user is not found', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should merge with existing preferences', async () => {
|
||||
const userId = 'user-123'
|
||||
const existingPrefs = { unsubscribeAll: false, unsubscribeUpdates: true }
|
||||
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: userId }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ emailPreferences: existingPrefs }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const mockInsertValues = vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: mockInsertValues,
|
||||
})
|
||||
|
||||
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
||||
|
||||
// Verify that the merged preferences are passed
|
||||
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
emailPreferences: {
|
||||
unsubscribeAll: false,
|
||||
unsubscribeUpdates: true,
|
||||
unsubscribeMarketing: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return false on database error', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockRejectedValue(new Error('Database error')),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnsubscribed', () => {
|
||||
it('should return false when user has no preferences', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await isUnsubscribed(testEmail, 'marketing')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when unsubscribeAll is true', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ emailPreferences: { unsubscribeAll: true } }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await isUnsubscribed(testEmail, 'marketing')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when specific type is unsubscribed', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ emailPreferences: { unsubscribeMarketing: true, unsubscribeUpdates: false } },
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const resultMarketing = await isUnsubscribed(testEmail, 'marketing')
|
||||
expect(resultMarketing).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when specific type is not unsubscribed', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ emailPreferences: { unsubscribeMarketing: false, unsubscribeUpdates: true } },
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await isUnsubscribed(testEmail, 'marketing')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should check updates unsubscribe status', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ emailPreferences: { unsubscribeUpdates: true } }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await isUnsubscribed(testEmail, 'updates')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should check notifications unsubscribe status', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ emailPreferences: { unsubscribeNotifications: true } }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await isUnsubscribed(testEmail, 'notifications')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unknown email type', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ emailPreferences: {} }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await isUnsubscribed(testEmail, 'all')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false on database error', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockRejectedValue(new Error('Database error')),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await isUnsubscribed(testEmail, 'marketing')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsubscribeFromAll', () => {
|
||||
it('should call updateEmailPreferences with unsubscribeAll: true', async () => {
|
||||
const userId = 'user-123'
|
||||
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: userId }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ emailPreferences: {} }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const mockInsertValues = vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: mockInsertValues,
|
||||
})
|
||||
|
||||
const result = await unsubscribeFromAll(testEmail)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
emailPreferences: expect.objectContaining({ unsubscribeAll: true }),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubscribe', () => {
|
||||
it('should reset all unsubscribe flags to false', async () => {
|
||||
const userId = 'user-123'
|
||||
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: userId }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
emailPreferences: {
|
||||
unsubscribeAll: true,
|
||||
unsubscribeMarketing: true,
|
||||
unsubscribeUpdates: true,
|
||||
unsubscribeNotifications: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const mockInsertValues = vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: mockInsertValues,
|
||||
})
|
||||
|
||||
const result = await resubscribe(testEmail)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
emailPreferences: {
|
||||
unsubscribeAll: false,
|
||||
unsubscribeMarketing: false,
|
||||
unsubscribeUpdates: false,
|
||||
unsubscribeNotifications: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,140 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock the env module
|
||||
/**
|
||||
* Tests for getFromEmailAddress utility function.
|
||||
*
|
||||
* These tests verify the function correctly handles different
|
||||
* environment configurations for email addresses.
|
||||
*/
|
||||
|
||||
// Set up mocks at module level - these will be used for all tests in this file
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: undefined,
|
||||
EMAIL_DOMAIN: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the getEmailDomain function
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getEmailDomain: vi.fn().mockReturnValue('fallback.com'),
|
||||
}))
|
||||
|
||||
describe('getFromEmailAddress', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should return FROM_EMAIL_ADDRESS when set', async () => {
|
||||
// Mock env with FROM_EMAIL_ADDRESS
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: 'Sim <noreply@sim.ai>',
|
||||
EMAIL_DOMAIN: 'example.com',
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getEmailDomain: vi.fn().mockReturnValue('fallback.com'),
|
||||
}))
|
||||
|
||||
import { getFromEmailAddress } from './utils'
|
||||
|
||||
describe('getFromEmailAddress', () => {
|
||||
it('should return the configured FROM_EMAIL_ADDRESS', () => {
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('Sim <noreply@sim.ai>')
|
||||
})
|
||||
|
||||
it('should return simple email format when FROM_EMAIL_ADDRESS is set without display name', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: 'noreply@sim.ai',
|
||||
EMAIL_DOMAIN: 'example.com',
|
||||
},
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
it('should return a valid email format', () => {
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('noreply@sim.ai')
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return Azure ACS format when FROM_EMAIL_ADDRESS is set', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: 'DoNotReply@customer.azurecomm.net',
|
||||
EMAIL_DOMAIN: 'example.com',
|
||||
},
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
it('should contain an @ symbol in the email', () => {
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('DoNotReply@customer.azurecomm.net')
|
||||
// Either contains @ directly or in angle brackets
|
||||
expect(result.includes('@')).toBe(true)
|
||||
})
|
||||
|
||||
it('should construct from EMAIL_DOMAIN when FROM_EMAIL_ADDRESS is not set', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: undefined,
|
||||
EMAIL_DOMAIN: 'example.com',
|
||||
},
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('noreply@example.com')
|
||||
})
|
||||
|
||||
it('should use getEmailDomain fallback when both FROM_EMAIL_ADDRESS and EMAIL_DOMAIN are not set', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: undefined,
|
||||
EMAIL_DOMAIN: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
const mockGetEmailDomain = vi.fn().mockReturnValue('fallback.com')
|
||||
vi.doMock('@/lib/core/utils/urls', () => ({
|
||||
getEmailDomain: mockGetEmailDomain,
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('noreply@fallback.com')
|
||||
expect(mockGetEmailDomain).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prioritize FROM_EMAIL_ADDRESS over EMAIL_DOMAIN when both are set', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: 'Custom <custom@custom.com>',
|
||||
EMAIL_DOMAIN: 'ignored.com',
|
||||
},
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('Custom <custom@custom.com>')
|
||||
})
|
||||
|
||||
it('should handle empty string FROM_EMAIL_ADDRESS by falling back to EMAIL_DOMAIN', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: '',
|
||||
EMAIL_DOMAIN: 'fallback.com',
|
||||
},
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('noreply@fallback.com')
|
||||
})
|
||||
|
||||
it('should handle whitespace-only FROM_EMAIL_ADDRESS by falling back to EMAIL_DOMAIN', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
FROM_EMAIL_ADDRESS: ' ',
|
||||
EMAIL_DOMAIN: 'fallback.com',
|
||||
},
|
||||
}))
|
||||
|
||||
const { getFromEmailAddress } = await import('./utils')
|
||||
const result = getFromEmailAddress()
|
||||
|
||||
expect(result).toBe('noreply@fallback.com')
|
||||
it('should be consistent across multiple calls', () => {
|
||||
const result1 = getFromEmailAddress()
|
||||
const result2 = getFromEmailAddress()
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { quickValidateEmail, validateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Email Validation', () => {
|
||||
describe('validateEmail', () => {
|
||||
it.concurrent('should validate a correct email', async () => {
|
||||
@@ -36,6 +45,90 @@ describe('Email Validation', () => {
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Email contains suspicious patterns')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with missing domain', async () => {
|
||||
const result = await validateEmail('user@')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain starting with dot', async () => {
|
||||
const result = await validateEmail('user@.example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
// The regex catches this as a syntax error before domain validation
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain ending with dot', async () => {
|
||||
const result = await validateEmail('user@example.')
|
||||
expect(result.isValid).toBe(false)
|
||||
// The regex catches this as a syntax error before domain validation
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain missing TLD', async () => {
|
||||
const result = await validateEmail('user@localhost')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid domain format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email longer than 254 characters', async () => {
|
||||
const longLocal = 'a'.repeat(64)
|
||||
const longDomain = `${'b'.repeat(180)}.com`
|
||||
const result = await validateEmail(`${longLocal}@${longDomain}`)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should validate various known disposable email domains', async () => {
|
||||
const disposableDomains = [
|
||||
'mailinator.com',
|
||||
'yopmail.com',
|
||||
'guerrillamail.com',
|
||||
'temp-mail.org',
|
||||
'throwaway.email',
|
||||
'getnada.com',
|
||||
'sharklasers.com',
|
||||
'spam4.me',
|
||||
]
|
||||
|
||||
for (const domain of disposableDomains) {
|
||||
const result = await validateEmail(`test@${domain}`)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
expect(result.checks.disposable).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should accept valid email formats', async () => {
|
||||
const validEmails = [
|
||||
'simple@example.com',
|
||||
'very.common@example.com',
|
||||
'disposable.style.email.with+symbol@example.com',
|
||||
'other.email-with-hyphen@example.com',
|
||||
'fully-qualified-domain@example.com',
|
||||
'user.name+tag+sorting@example.com',
|
||||
'x@example.com',
|
||||
'example-indeed@strange-example.com',
|
||||
'example@s.example',
|
||||
]
|
||||
|
||||
for (const email of validEmails) {
|
||||
const result = await validateEmail(email)
|
||||
// We check syntax passes; MX might fail for fake domains
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
expect(result.checks.disposable).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should return high confidence for syntax failures', async () => {
|
||||
const result = await validateEmail('not-an-email')
|
||||
expect(result.confidence).toBe('high')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with special characters in local part', async () => {
|
||||
const result = await validateEmail("user!#$%&'*+/=?^_`{|}~@example.com")
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('quickValidateEmail', () => {
|
||||
@@ -57,5 +150,66 @@ describe('Email Validation', () => {
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with missing domain', () => {
|
||||
const result = quickValidateEmail('user@')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with invalid domain format', () => {
|
||||
const result = quickValidateEmail('user@.invalid')
|
||||
expect(result.isValid).toBe(false)
|
||||
// The regex catches this as a syntax error before domain validation
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should return medium confidence for suspicious patterns', () => {
|
||||
const result = quickValidateEmail('user..double@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Email contains suspicious patterns')
|
||||
expect(result.confidence).toBe('medium')
|
||||
})
|
||||
|
||||
it.concurrent('should return high confidence for syntax errors', () => {
|
||||
const result = quickValidateEmail('not-valid-email')
|
||||
expect(result.confidence).toBe('high')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty string', () => {
|
||||
const result = quickValidateEmail('')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with only @ symbol', () => {
|
||||
const result = quickValidateEmail('@')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with spaces', () => {
|
||||
const result = quickValidateEmail('user name@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with multiple @ symbols', () => {
|
||||
const result = quickValidateEmail('user@domain@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should validate complex but valid local parts', () => {
|
||||
const result = quickValidateEmail('user+tag@example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should validate subdomains', () => {
|
||||
const result = quickValidateEmail('user@mail.subdomain.example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.domain).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createMockFetch, loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
@@ -51,28 +52,25 @@ vi.mock('@/lib/core/config/env', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
|
||||
function createMockFetch() {
|
||||
return vi.fn().mockResolvedValue({
|
||||
/**
|
||||
* Default OAuth token response for successful requests.
|
||||
*/
|
||||
const defaultOAuthResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
json: {
|
||||
access_token: 'new_access_token',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_refresh_token',
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to run a function with a mocked global fetch.
|
||||
*/
|
||||
function withMockFetch<T>(mockFetch: ReturnType<typeof vi.fn>, fn: () => Promise<T>): Promise<T> {
|
||||
const originalFetch = global.fetch
|
||||
global.fetch = mockFetch
|
||||
@@ -123,7 +121,7 @@ describe('OAuth Token Refresh', () => {
|
||||
it.concurrent(
|
||||
`should send ${name} request with Basic Auth header and no credentials in body`,
|
||||
async () => {
|
||||
const mockFetch = createMockFetch()
|
||||
const mockFetch = createMockFetch(defaultOAuthResponse)
|
||||
const refreshToken = 'test_refresh_token'
|
||||
|
||||
await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken))
|
||||
@@ -237,7 +235,7 @@ describe('OAuth Token Refresh', () => {
|
||||
it.concurrent(
|
||||
`should send ${name} request with credentials in body and no Basic Auth`,
|
||||
async () => {
|
||||
const mockFetch = createMockFetch()
|
||||
const mockFetch = createMockFetch(defaultOAuthResponse)
|
||||
const refreshToken = 'test_refresh_token'
|
||||
|
||||
await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken))
|
||||
@@ -276,7 +274,7 @@ describe('OAuth Token Refresh', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should include Accept header for GitHub requests', async () => {
|
||||
const mockFetch = createMockFetch()
|
||||
const mockFetch = createMockFetch(defaultOAuthResponse)
|
||||
const refreshToken = 'test_refresh_token'
|
||||
|
||||
await withMockFetch(mockFetch, () => refreshOAuthToken('github', refreshToken))
|
||||
@@ -286,7 +284,7 @@ describe('OAuth Token Refresh', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should include User-Agent header for Reddit requests', async () => {
|
||||
const mockFetch = createMockFetch()
|
||||
const mockFetch = createMockFetch(defaultOAuthResponse)
|
||||
const refreshToken = 'test_refresh_token'
|
||||
|
||||
await withMockFetch(mockFetch, () => refreshOAuthToken('reddit', refreshToken))
|
||||
@@ -300,7 +298,7 @@ describe('OAuth Token Refresh', () => {
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it.concurrent('should return null for unsupported provider', async () => {
|
||||
const mockFetch = createMockFetch()
|
||||
const mockFetch = createMockFetch(defaultOAuthResponse)
|
||||
const refreshToken = 'test_refresh_token'
|
||||
|
||||
const result = await withMockFetch(mockFetch, () =>
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
/**
|
||||
* Tests for workflow change detection comparison logic
|
||||
*/
|
||||
import {
|
||||
createBlock as createTestBlock,
|
||||
createWorkflowState as createTestWorkflowState,
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { hasWorkflowChanged } from './compare'
|
||||
|
||||
/**
|
||||
* Helper to create a minimal valid workflow state
|
||||
* Type helper for converting test workflow state to app workflow state.
|
||||
*/
|
||||
function createWorkflowState(overrides: Partial<WorkflowState> = {}): WorkflowState {
|
||||
return {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
...overrides,
|
||||
} as WorkflowState
|
||||
function asAppState<T>(state: T): WorkflowState {
|
||||
return state as unknown as WorkflowState
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a block with common fields
|
||||
* Helper to create a minimal valid workflow state using @sim/testing factory.
|
||||
*/
|
||||
function createWorkflowState(overrides: Partial<WorkflowState> = {}): WorkflowState {
|
||||
return asAppState(createTestWorkflowState(overrides as any))
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a block with common fields using @sim/testing factory.
|
||||
*/
|
||||
function createBlock(id: string, overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
return createTestBlock({
|
||||
id,
|
||||
name: `Block ${id}`,
|
||||
type: 'agent',
|
||||
position: { x: 100, y: 100 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
height: 200,
|
||||
name: overrides.name ?? `Block ${id}`,
|
||||
type: overrides.type ?? 'agent',
|
||||
position: overrides.position ?? { x: 100, y: 100 },
|
||||
subBlocks: overrides.subBlocks ?? {},
|
||||
outputs: overrides.outputs ?? {},
|
||||
enabled: overrides.enabled ?? true,
|
||||
horizontalHandles: overrides.horizontalHandles ?? true,
|
||||
advancedMode: overrides.advancedMode ?? false,
|
||||
height: overrides.height ?? 200,
|
||||
...overrides,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('hasWorkflowChanged', () => {
|
||||
@@ -654,7 +659,13 @@ describe('hasWorkflowChanged', () => {
|
||||
})
|
||||
const state2 = createWorkflowState({
|
||||
loops: {
|
||||
loop1: { id: 'loop1', nodes: ['block1'], loopType: 'forEach', forEachItems: '[]' },
|
||||
loop1: {
|
||||
id: 'loop1',
|
||||
nodes: ['block1'],
|
||||
loopType: 'forEach',
|
||||
forEachItems: '[]',
|
||||
iterations: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
@@ -682,6 +693,7 @@ describe('hasWorkflowChanged', () => {
|
||||
nodes: ['block1'],
|
||||
loopType: 'forEach',
|
||||
forEachItems: '<block.items>',
|
||||
iterations: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -692,6 +704,7 @@ describe('hasWorkflowChanged', () => {
|
||||
nodes: ['block1'],
|
||||
loopType: 'forEach',
|
||||
forEachItems: '<other.items>',
|
||||
iterations: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -706,6 +719,7 @@ describe('hasWorkflowChanged', () => {
|
||||
nodes: ['block1'],
|
||||
loopType: 'while',
|
||||
whileCondition: '<counter> < 10',
|
||||
iterations: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -716,6 +730,7 @@ describe('hasWorkflowChanged', () => {
|
||||
nodes: ['block1'],
|
||||
loopType: 'while',
|
||||
whileCondition: '<counter> < 20',
|
||||
iterations: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
445
apps/sim/lib/workflows/utils.test.ts
Normal file
445
apps/sim/lib/workflows/utils.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Tests for workflow utility functions including permission validation.
|
||||
*
|
||||
* Tests cover:
|
||||
* - validateWorkflowPermissions for different user roles
|
||||
* - getWorkflowAccessContext
|
||||
* - Owner vs workspace member access
|
||||
* - Read/write/admin action permissions
|
||||
*/
|
||||
|
||||
import {
|
||||
createSession,
|
||||
createWorkflowRecord,
|
||||
createWorkspaceRecord,
|
||||
expectWorkflowAccessDenied,
|
||||
expectWorkflowAccessGranted,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock the database
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn(),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the auth module
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: vi.fn(),
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
// Import after mocks are set up
|
||||
import { getWorkflowAccessContext, validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
|
||||
describe('validateWorkflowPermissions', () => {
|
||||
const mockSession = createSession({ userId: 'user-1', email: 'user1@test.com' })
|
||||
const mockWorkflow = createWorkflowRecord({
|
||||
id: 'wf-1',
|
||||
userId: 'owner-1',
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
const mockWorkspace = createWorkspaceRecord({
|
||||
id: 'ws-1',
|
||||
ownerId: 'workspace-owner',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('authentication', () => {
|
||||
it('should return 401 when no session exists', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessDenied(result, 401)
|
||||
expect(result.error?.message).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 401 when session has no user id', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({ user: {} } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessDenied(result, 401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflow not found', () => {
|
||||
it('should return 404 when workflow does not exist', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
||||
|
||||
// Mock workflow query to return empty
|
||||
const mockLimit = vi.fn().mockResolvedValue([])
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('non-existent', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessDenied(result, 404)
|
||||
expect(result.error?.message).toBe('Workflow not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('owner access', () => {
|
||||
it('should grant access to workflow owner for read action', async () => {
|
||||
const ownerSession = createSession({ userId: 'owner-1' })
|
||||
vi.mocked(getSession).mockResolvedValue(ownerSession as any)
|
||||
|
||||
// Mock workflow query
|
||||
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
|
||||
it('should grant access to workflow owner for write action', async () => {
|
||||
const ownerSession = createSession({ userId: 'owner-1' })
|
||||
vi.mocked(getSession).mockResolvedValue(ownerSession as any)
|
||||
|
||||
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
|
||||
it('should grant access to workflow owner for admin action', async () => {
|
||||
const ownerSession = createSession({ userId: 'owner-1' })
|
||||
vi.mocked(getSession).mockResolvedValue(ownerSession as any)
|
||||
|
||||
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace member access with permissions', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
||||
})
|
||||
|
||||
it('should grant read access to user with read permission', async () => {
|
||||
// First call: workflow query, second call: workspace owner, third call: permission
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'read' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
|
||||
it('should deny write access to user with only read permission', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'read' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||
|
||||
expectWorkflowAccessDenied(result, 403)
|
||||
expect(result.error?.message).toContain('write')
|
||||
})
|
||||
|
||||
it('should grant write access to user with write permission', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'write' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
|
||||
it('should grant write access to user with admin permission', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'admin' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
|
||||
it('should deny admin access to user with only write permission', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'write' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
||||
|
||||
expectWorkflowAccessDenied(result, 403)
|
||||
expect(result.error?.message).toContain('admin')
|
||||
})
|
||||
|
||||
it('should grant admin access to user with admin permission', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'admin' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('no workspace permission', () => {
|
||||
it('should deny access to user without any workspace permission', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
||||
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([]) // No permission record
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessDenied(result, 403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflow without workspace', () => {
|
||||
it('should deny access to non-owner for workflow without workspace', async () => {
|
||||
const workflowWithoutWorkspace = createWorkflowRecord({
|
||||
id: 'wf-2',
|
||||
userId: 'other-user',
|
||||
workspaceId: null,
|
||||
})
|
||||
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
||||
|
||||
const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace])
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessDenied(result, 403)
|
||||
})
|
||||
|
||||
it('should grant access to owner for workflow without workspace', async () => {
|
||||
const workflowWithoutWorkspace = createWorkflowRecord({
|
||||
id: 'wf-2',
|
||||
userId: 'user-1',
|
||||
workspaceId: null,
|
||||
})
|
||||
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
||||
|
||||
const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace])
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default action', () => {
|
||||
it('should default to read action when not specified', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
||||
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'read' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await validateWorkflowPermissions('wf-1', 'req-1')
|
||||
|
||||
expectWorkflowAccessGranted(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkflowAccessContext', () => {
|
||||
const mockWorkflow = createWorkflowRecord({
|
||||
id: 'wf-1',
|
||||
userId: 'owner-1',
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return null for non-existent workflow', async () => {
|
||||
const mockLimit = vi.fn().mockResolvedValue([])
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await getWorkflowAccessContext('non-existent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return context with isOwner true for workflow owner', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'read' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await getWorkflowAccessContext('wf-1', 'owner-1')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.isOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('should return context with isOwner false for non-owner', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'read' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await getWorkflowAccessContext('wf-1', 'other-user')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.isOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('should return context with workspace permission for workspace member', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'write' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await getWorkflowAccessContext('wf-1', 'member-user')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.workspacePermission).toBe('write')
|
||||
})
|
||||
|
||||
it('should return context without permission for non-member', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await getWorkflowAccessContext('wf-1', 'stranger')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.workspacePermission).toBeNull()
|
||||
})
|
||||
|
||||
it('should identify workspace owner correctly', async () => {
|
||||
let callCount = 0
|
||||
const mockLimit = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||
if (callCount === 2) return Promise.resolve([{ ownerId: 'workspace-owner' }])
|
||||
return Promise.resolve([{ permissionType: 'admin' }])
|
||||
})
|
||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
||||
|
||||
const result = await getWorkflowAccessContext('wf-1', 'workspace-owner')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.isWorkspaceOwner).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
@@ -35,11 +36,7 @@ vi.mock('@sim/db/schema', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn().mockReturnValue('and-condition'),
|
||||
eq: vi.fn().mockReturnValue('eq-condition'),
|
||||
or: vi.fn().mockReturnValue('or-condition'),
|
||||
}))
|
||||
vi.mock('drizzle-orm', () => drizzleOrmMock)
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"dev:webpack": "next dev --webpack",
|
||||
"dev:sockets": "bun run socket-server/index.ts",
|
||||
"dev:sockets": "bun run socket/index.ts",
|
||||
"dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
@@ -140,6 +140,7 @@
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/testing": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@trigger.dev/build": "4.1.2",
|
||||
"@types/html-to-text": "9.0.4",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Serializer } from '@/serializer/index'
|
||||
import { validateRequiredParametersAfterMerge } from '@/tools/utils'
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: (type: string) => {
|
||||
@@ -55,10 +54,31 @@ vi.mock('@/blocks', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/utils', async () => {
|
||||
const actual = await vi.importActual('@/tools/utils')
|
||||
return {
|
||||
...actual,
|
||||
/**
|
||||
* Validates required parameters after user and LLM parameter merge.
|
||||
* This checks user-or-llm visibility fields that should have been provided by either source.
|
||||
*/
|
||||
function validateRequiredParametersAfterMerge(
|
||||
toolId: string,
|
||||
tool: any,
|
||||
params: Record<string, any>
|
||||
): void {
|
||||
if (!tool?.params) return
|
||||
|
||||
Object.entries(tool.params).forEach(([paramId, paramConfig]: [string, any]) => {
|
||||
// Only validate user-or-llm visibility fields (user-only are validated earlier)
|
||||
if (paramConfig.required && paramConfig.visibility === 'user-or-llm') {
|
||||
const value = params[paramId]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
// Capitalize first letter of paramId for display
|
||||
const displayName = paramId.charAt(0).toUpperCase() + paramId.slice(1)
|
||||
throw new Error(`${displayName} is required for ${tool.name}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@/tools/utils', () => ({
|
||||
getTool: (toolId: string) => {
|
||||
const mockTools: Record<string, any> = {
|
||||
jina_read_url: {
|
||||
@@ -98,8 +118,8 @@ vi.mock('@/tools/utils', async () => {
|
||||
}
|
||||
return mockTools[toolId] || null
|
||||
},
|
||||
}
|
||||
})
|
||||
validateRequiredParametersAfterMerge,
|
||||
}))
|
||||
|
||||
describe('Validation Integration Tests', () => {
|
||||
it.concurrent('early validation should catch missing user-only fields', () => {
|
||||
|
||||
1699
apps/sim/serializer/tests/serializer.extended.test.ts
Normal file
1699
apps/sim/serializer/tests/serializer.extended.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { HandlerDependencies } from '@/socket-server/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
|
||||
import type { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import type { HandlerDependencies } from '@/socket/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import type { RoomManager } from '@/socket/rooms/manager'
|
||||
|
||||
const logger = createLogger('ConnectionHandlers')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { setupConnectionHandlers } from '@/socket-server/handlers/connection'
|
||||
import { setupOperationsHandlers } from '@/socket-server/handlers/operations'
|
||||
import { setupPresenceHandlers } from '@/socket-server/handlers/presence'
|
||||
import { setupSubblocksHandlers } from '@/socket-server/handlers/subblocks'
|
||||
import { setupVariablesHandlers } from '@/socket-server/handlers/variables'
|
||||
import { setupWorkflowHandlers } from '@/socket-server/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
|
||||
import type { RoomManager, UserPresence, WorkflowRoom } from '@/socket-server/rooms/manager'
|
||||
import { setupConnectionHandlers } from '@/socket/handlers/connection'
|
||||
import { setupOperationsHandlers } from '@/socket/handlers/operations'
|
||||
import { setupPresenceHandlers } from '@/socket/handlers/presence'
|
||||
import { setupSubblocksHandlers } from '@/socket/handlers/subblocks'
|
||||
import { setupVariablesHandlers } from '@/socket/handlers/variables'
|
||||
import { setupWorkflowHandlers } from '@/socket/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import type { RoomManager, UserPresence, WorkflowRoom } from '@/socket/rooms/manager'
|
||||
|
||||
export type { UserPresence, WorkflowRoom }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ZodError } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { persistWorkflowOperation } from '@/socket-server/database/operations'
|
||||
import type { HandlerDependencies } from '@/socket-server/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
|
||||
import { checkRolePermission } from '@/socket-server/middleware/permissions'
|
||||
import type { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import { WorkflowOperationSchema } from '@/socket-server/validation/schemas'
|
||||
import { persistWorkflowOperation } from '@/socket/database/operations'
|
||||
import type { HandlerDependencies } from '@/socket/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import { checkRolePermission } from '@/socket/middleware/permissions'
|
||||
import type { RoomManager } from '@/socket/rooms/manager'
|
||||
import { WorkflowOperationSchema } from '@/socket/validation/schemas'
|
||||
|
||||
const logger = createLogger('OperationsHandlers')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { HandlerDependencies } from '@/socket-server/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
|
||||
import type { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import type { HandlerDependencies } from '@/socket/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import type { RoomManager } from '@/socket/rooms/manager'
|
||||
|
||||
const logger = createLogger('PresenceHandlers')
|
||||
|
||||
@@ -2,9 +2,9 @@ import { db } from '@sim/db'
|
||||
import { workflow, workflowBlocks } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { HandlerDependencies } from '@/socket-server/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
|
||||
import type { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import type { HandlerDependencies } from '@/socket/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import type { RoomManager } from '@/socket/rooms/manager'
|
||||
|
||||
const logger = createLogger('SubblocksHandlers')
|
||||
|
||||
@@ -2,9 +2,9 @@ import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { HandlerDependencies } from '@/socket-server/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
|
||||
import type { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import type { HandlerDependencies } from '@/socket/handlers/workflow'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import type { RoomManager } from '@/socket/rooms/manager'
|
||||
|
||||
const logger = createLogger('VariablesHandlers')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { db, user } from '@sim/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getWorkflowState } from '@/socket-server/database/operations'
|
||||
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
|
||||
import { verifyWorkflowAccess } from '@/socket-server/middleware/permissions'
|
||||
import type { RoomManager, UserPresence, WorkflowRoom } from '@/socket-server/rooms/manager'
|
||||
import { getWorkflowState } from '@/socket/database/operations'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import { verifyWorkflowAccess } from '@/socket/middleware/permissions'
|
||||
import type { RoomManager, UserPresence, WorkflowRoom } from '@/socket/rooms/manager'
|
||||
|
||||
const logger = createLogger('WorkflowHandlers')
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createServer, request as httpRequest } from 'http'
|
||||
import { createMockLogger, databaseMock } from '@sim/testing'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createSocketIOServer } from '@/socket-server/config/socket'
|
||||
import { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import { createHttpHandler } from '@/socket-server/routes/http'
|
||||
import { createSocketIOServer } from '@/socket/config/socket'
|
||||
import { RoomManager } from '@/socket/rooms/manager'
|
||||
import { createHttpHandler } from '@/socket/routes/http'
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: {
|
||||
@@ -18,17 +18,9 @@ vi.mock('@/lib/auth', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('@sim/db', () => databaseMock)
|
||||
|
||||
vi.mock('@/socket-server/middleware/auth', () => ({
|
||||
vi.mock('@/socket/middleware/auth', () => ({
|
||||
authenticateSocket: vi.fn((socket, next) => {
|
||||
socket.userId = 'test-user-id'
|
||||
socket.userName = 'Test User'
|
||||
@@ -37,7 +29,7 @@ vi.mock('@/socket-server/middleware/auth', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/socket-server/middleware/permissions', () => ({
|
||||
vi.mock('@/socket/middleware/permissions', () => ({
|
||||
verifyWorkflowAccess: vi.fn().mockResolvedValue({
|
||||
hasAccess: true,
|
||||
role: 'admin',
|
||||
@@ -47,7 +39,7 @@ vi.mock('@/socket-server/middleware/permissions', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/socket-server/database/operations', () => ({
|
||||
vi.mock('@/socket/database/operations', () => ({
|
||||
getWorkflowState: vi.fn().mockResolvedValue({
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
@@ -60,11 +52,11 @@ describe('Socket Server Index Integration', () => {
|
||||
let httpServer: any
|
||||
let io: any
|
||||
let roomManager: RoomManager
|
||||
let logger: any
|
||||
let logger: ReturnType<typeof createMockLogger>
|
||||
let PORT: number
|
||||
|
||||
beforeAll(() => {
|
||||
logger = createLogger('SocketServerTest')
|
||||
logger = createMockLogger()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -244,13 +236,13 @@ describe('Socket Server Index Integration', () => {
|
||||
|
||||
describe('Module Integration', () => {
|
||||
it.concurrent('should properly import all extracted modules', async () => {
|
||||
const { createSocketIOServer } = await import('@/socket-server/config/socket')
|
||||
const { createHttpHandler } = await import('@/socket-server/routes/http')
|
||||
const { RoomManager } = await import('@/socket-server/rooms/manager')
|
||||
const { authenticateSocket } = await import('@/socket-server/middleware/auth')
|
||||
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
|
||||
const { getWorkflowState } = await import('@/socket-server/database/operations')
|
||||
const { WorkflowOperationSchema } = await import('@/socket-server/validation/schemas')
|
||||
const { createSocketIOServer } = await import('@/socket/config/socket')
|
||||
const { createHttpHandler } = await import('@/socket/routes/http')
|
||||
const { RoomManager } = await import('@/socket/rooms/manager')
|
||||
const { authenticateSocket } = await import('@/socket/middleware/auth')
|
||||
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
|
||||
const { getWorkflowState } = await import('@/socket/database/operations')
|
||||
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
|
||||
|
||||
expect(createSocketIOServer).toBeTypeOf('function')
|
||||
expect(createHttpHandler).toBeTypeOf('function')
|
||||
@@ -299,7 +291,7 @@ describe('Socket Server Index Integration', () => {
|
||||
|
||||
describe('Validation and Utils', () => {
|
||||
it.concurrent('should validate workflow operations', async () => {
|
||||
const { WorkflowOperationSchema } = await import('@/socket-server/validation/schemas')
|
||||
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
|
||||
|
||||
const validOperation = {
|
||||
operation: 'add',
|
||||
@@ -317,7 +309,7 @@ describe('Socket Server Index Integration', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should validate block operations with autoConnectEdge', async () => {
|
||||
const { WorkflowOperationSchema } = await import('@/socket-server/validation/schemas')
|
||||
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
|
||||
|
||||
const validOperationWithAutoEdge = {
|
||||
operation: 'add',
|
||||
@@ -343,7 +335,7 @@ describe('Socket Server Index Integration', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should validate edge operations', async () => {
|
||||
const { WorkflowOperationSchema } = await import('@/socket-server/validation/schemas')
|
||||
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
|
||||
|
||||
const validEdgeOperation = {
|
||||
operation: 'add',
|
||||
@@ -360,7 +352,7 @@ describe('Socket Server Index Integration', () => {
|
||||
})
|
||||
|
||||
it('should validate subflow operations', async () => {
|
||||
const { WorkflowOperationSchema } = await import('@/socket-server/validation/schemas')
|
||||
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
|
||||
|
||||
const validSubflowOperation = {
|
||||
operation: 'update',
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createServer } from 'http'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createSocketIOServer } from '@/socket-server/config/socket'
|
||||
import { setupAllHandlers } from '@/socket-server/handlers'
|
||||
import { type AuthenticatedSocket, authenticateSocket } from '@/socket-server/middleware/auth'
|
||||
import { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import { createHttpHandler } from '@/socket-server/routes/http'
|
||||
import { createSocketIOServer } from '@/socket/config/socket'
|
||||
import { setupAllHandlers } from '@/socket/handlers'
|
||||
import { type AuthenticatedSocket, authenticateSocket } from '@/socket/middleware/auth'
|
||||
import { RoomManager } from '@/socket/rooms/manager'
|
||||
import { createHttpHandler } from '@/socket/routes/http'
|
||||
|
||||
const logger = createLogger('CollaborativeSocketServer')
|
||||
|
||||
255
apps/sim/socket/middleware/permissions.test.ts
Normal file
255
apps/sim/socket/middleware/permissions.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Tests for socket server permission middleware.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Role-based operation permissions (admin, write, read)
|
||||
* - All socket operations
|
||||
* - Edge cases and invalid inputs
|
||||
*/
|
||||
|
||||
import {
|
||||
expectPermissionAllowed,
|
||||
expectPermissionDenied,
|
||||
ROLE_ALLOWED_OPERATIONS,
|
||||
SOCKET_OPERATIONS,
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { checkRolePermission } from '@/socket/middleware/permissions'
|
||||
|
||||
describe('checkRolePermission', () => {
|
||||
describe('admin role', () => {
|
||||
it('should allow all operations for admin role', () => {
|
||||
const operations = SOCKET_OPERATIONS
|
||||
|
||||
for (const operation of operations) {
|
||||
const result = checkRolePermission('admin', operation)
|
||||
expectPermissionAllowed(result)
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow add operation', () => {
|
||||
const result = checkRolePermission('admin', 'add')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow remove operation', () => {
|
||||
const result = checkRolePermission('admin', 'remove')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow update operation', () => {
|
||||
const result = checkRolePermission('admin', 'update')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow duplicate operation', () => {
|
||||
const result = checkRolePermission('admin', 'duplicate')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow replace-state operation', () => {
|
||||
const result = checkRolePermission('admin', 'replace-state')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('write role', () => {
|
||||
it('should allow all operations for write role (same as admin)', () => {
|
||||
const operations = SOCKET_OPERATIONS
|
||||
|
||||
for (const operation of operations) {
|
||||
const result = checkRolePermission('write', operation)
|
||||
expectPermissionAllowed(result)
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow add operation', () => {
|
||||
const result = checkRolePermission('write', 'add')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow remove operation', () => {
|
||||
const result = checkRolePermission('write', 'remove')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should allow update-position operation', () => {
|
||||
const result = checkRolePermission('write', 'update-position')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read role', () => {
|
||||
it('should only allow update-position for read role', () => {
|
||||
const result = checkRolePermission('read', 'update-position')
|
||||
expectPermissionAllowed(result)
|
||||
})
|
||||
|
||||
it('should deny add operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'add')
|
||||
expectPermissionDenied(result, 'read')
|
||||
expectPermissionDenied(result, 'add')
|
||||
})
|
||||
|
||||
it('should deny remove operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'remove')
|
||||
expectPermissionDenied(result, 'read')
|
||||
})
|
||||
|
||||
it('should deny update operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'update')
|
||||
expectPermissionDenied(result, 'read')
|
||||
})
|
||||
|
||||
it('should deny duplicate operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'duplicate')
|
||||
expectPermissionDenied(result, 'read')
|
||||
})
|
||||
|
||||
it('should deny replace-state operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'replace-state')
|
||||
expectPermissionDenied(result, 'read')
|
||||
})
|
||||
|
||||
it('should deny toggle-enabled operation for read role', () => {
|
||||
const result = checkRolePermission('read', 'toggle-enabled')
|
||||
expectPermissionDenied(result, 'read')
|
||||
})
|
||||
|
||||
it('should deny all write operations for read role', () => {
|
||||
const writeOperations = SOCKET_OPERATIONS.filter((op) => op !== 'update-position')
|
||||
|
||||
for (const operation of writeOperations) {
|
||||
const result = checkRolePermission('read', operation)
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.reason).toContain('read')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown role', () => {
|
||||
it('should deny all operations for unknown role', () => {
|
||||
const operations = SOCKET_OPERATIONS
|
||||
|
||||
for (const operation of operations) {
|
||||
const result = checkRolePermission('unknown', operation)
|
||||
expectPermissionDenied(result)
|
||||
}
|
||||
})
|
||||
|
||||
it('should deny operations for empty role', () => {
|
||||
const result = checkRolePermission('', 'add')
|
||||
expectPermissionDenied(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown operations', () => {
|
||||
it('should deny unknown operations for admin', () => {
|
||||
const result = checkRolePermission('admin', 'unknown-operation')
|
||||
expectPermissionDenied(result, 'admin')
|
||||
expectPermissionDenied(result, 'unknown-operation')
|
||||
})
|
||||
|
||||
it('should deny unknown operations for write', () => {
|
||||
const result = checkRolePermission('write', 'unknown-operation')
|
||||
expectPermissionDenied(result)
|
||||
})
|
||||
|
||||
it('should deny unknown operations for read', () => {
|
||||
const result = checkRolePermission('read', 'unknown-operation')
|
||||
expectPermissionDenied(result)
|
||||
})
|
||||
|
||||
it('should deny empty operation', () => {
|
||||
const result = checkRolePermission('admin', '')
|
||||
expectPermissionDenied(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permission hierarchy verification', () => {
|
||||
it('should verify admin has same permissions as write', () => {
|
||||
const adminOps = ROLE_ALLOWED_OPERATIONS.admin
|
||||
const writeOps = ROLE_ALLOWED_OPERATIONS.write
|
||||
|
||||
// Admin and write should have same operations
|
||||
expect(adminOps).toEqual(writeOps)
|
||||
})
|
||||
|
||||
it('should verify read is a subset of write permissions', () => {
|
||||
const readOps = ROLE_ALLOWED_OPERATIONS.read
|
||||
const writeOps = ROLE_ALLOWED_OPERATIONS.write
|
||||
|
||||
for (const op of readOps) {
|
||||
expect(writeOps).toContain(op)
|
||||
}
|
||||
})
|
||||
|
||||
it('should verify read has minimal permissions', () => {
|
||||
const readOps = ROLE_ALLOWED_OPERATIONS.read
|
||||
expect(readOps).toHaveLength(1)
|
||||
expect(readOps).toContain('update-position')
|
||||
})
|
||||
})
|
||||
|
||||
describe('specific operations', () => {
|
||||
const testCases = [
|
||||
{ operation: 'add', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'remove', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'update', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: true },
|
||||
{ operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'toggle-enabled', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'update-parent', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'update-wide', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{
|
||||
operation: 'update-advanced-mode',
|
||||
adminAllowed: true,
|
||||
writeAllowed: true,
|
||||
readAllowed: false,
|
||||
},
|
||||
{
|
||||
operation: 'update-trigger-mode',
|
||||
adminAllowed: true,
|
||||
writeAllowed: true,
|
||||
readAllowed: false,
|
||||
},
|
||||
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'duplicate', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{ operation: 'replace-state', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
]
|
||||
|
||||
for (const { operation, adminAllowed, writeAllowed, readAllowed } of testCases) {
|
||||
it(`should ${adminAllowed ? 'allow' : 'deny'} "${operation}" for admin`, () => {
|
||||
const result = checkRolePermission('admin', operation)
|
||||
expect(result.allowed).toBe(adminAllowed)
|
||||
})
|
||||
|
||||
it(`should ${writeAllowed ? 'allow' : 'deny'} "${operation}" for write`, () => {
|
||||
const result = checkRolePermission('write', operation)
|
||||
expect(result.allowed).toBe(writeAllowed)
|
||||
})
|
||||
|
||||
it(`should ${readAllowed ? 'allow' : 'deny'} "${operation}" for read`, () => {
|
||||
const result = checkRolePermission('read', operation)
|
||||
expect(result.allowed).toBe(readAllowed)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('reason messages', () => {
|
||||
it('should include role in denial reason', () => {
|
||||
const result = checkRolePermission('read', 'add')
|
||||
expect(result.reason).toContain("'read'")
|
||||
})
|
||||
|
||||
it('should include operation in denial reason', () => {
|
||||
const result = checkRolePermission('read', 'add')
|
||||
expect(result.reason).toContain("'add'")
|
||||
})
|
||||
|
||||
it('should have descriptive denial message format', () => {
|
||||
const result = checkRolePermission('read', 'remove')
|
||||
expect(result.reason).toMatch(/Role '.*' not permitted to perform '.*'/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http'
|
||||
import type { RoomManager } from '@/socket-server/rooms/manager'
|
||||
import type { RoomManager } from '@/socket/rooms/manager'
|
||||
|
||||
interface Logger {
|
||||
info: (message: string, ...args: any[]) => void
|
||||
815
apps/sim/stores/undo-redo/store.test.ts
Normal file
815
apps/sim/stores/undo-redo/store.test.ts
Normal file
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* Tests for the undo/redo store.
|
||||
*
|
||||
* These tests cover:
|
||||
* - Basic push/undo/redo operations
|
||||
* - Stack capacity limits
|
||||
* - Move operation coalescing
|
||||
* - Recording suspension
|
||||
* - Stack pruning
|
||||
* - Multi-workflow/user isolation
|
||||
*/
|
||||
|
||||
import {
|
||||
createAddBlockEntry,
|
||||
createAddEdgeEntry,
|
||||
createBlock,
|
||||
createDuplicateBlockEntry,
|
||||
createMockStorage,
|
||||
createMoveBlockEntry,
|
||||
createRemoveBlockEntry,
|
||||
createRemoveEdgeEntry,
|
||||
createUpdateParentEntry,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
|
||||
|
||||
describe('useUndoRedoStore', () => {
|
||||
const workflowId = 'wf-test'
|
||||
const userId = 'user-test'
|
||||
|
||||
beforeEach(() => {
|
||||
global.localStorage = createMockStorage()
|
||||
|
||||
useUndoRedoStore.setState({
|
||||
stacks: {},
|
||||
capacity: 100,
|
||||
})
|
||||
})
|
||||
|
||||
describe('push', () => {
|
||||
it('should add an operation to the undo stack', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
const entry = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear redo stack when pushing new operation', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 2,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should respect capacity limit', () => {
|
||||
useUndoRedoStore.setState({ capacity: 3 })
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
|
||||
})
|
||||
|
||||
it('should limit number of stacks to 5', () => {
|
||||
const { push } = useUndoRedoStore.getState()
|
||||
|
||||
// Create 6 different workflow/user combinations
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const wfId = `wf-${i}`
|
||||
const uId = `user-${i}`
|
||||
push(wfId, uId, createAddBlockEntry(`block-${i}`, { workflowId: wfId, userId: uId }))
|
||||
}
|
||||
|
||||
const { stacks } = useUndoRedoStore.getState()
|
||||
expect(Object.keys(stacks).length).toBe(5)
|
||||
})
|
||||
|
||||
it('should remove oldest stack when limit exceeded', () => {
|
||||
const { push } = useUndoRedoStore.getState()
|
||||
|
||||
// Create stacks with varying timestamps
|
||||
for (let i = 0; i < 5; i++) {
|
||||
push(`wf-${i}`, `user-${i}`, createAddBlockEntry(`block-${i}`))
|
||||
}
|
||||
|
||||
// Add a 6th stack - should remove the oldest
|
||||
push('wf-new', 'user-new', createAddBlockEntry('block-new'))
|
||||
|
||||
const { stacks } = useUndoRedoStore.getState()
|
||||
expect(Object.keys(stacks).length).toBe(5)
|
||||
expect(stacks['wf-new:user-new']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('undo', () => {
|
||||
it('should return the last operation and move it to redo', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
const entry = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry)
|
||||
const result = undo(workflowId, userId)
|
||||
|
||||
expect(result).toEqual(entry)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 0,
|
||||
redoSize: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when undo stack is empty', () => {
|
||||
const { undo } = useUndoRedoStore.getState()
|
||||
|
||||
const result = undo(workflowId, userId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should undo operations in LIFO order', () => {
|
||||
const { push, undo } = useUndoRedoStore.getState()
|
||||
|
||||
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
|
||||
const entry3 = createAddBlockEntry('block-3', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry1)
|
||||
push(workflowId, userId, entry2)
|
||||
push(workflowId, userId, entry3)
|
||||
|
||||
expect(undo(workflowId, userId)).toEqual(entry3)
|
||||
expect(undo(workflowId, userId)).toEqual(entry2)
|
||||
expect(undo(workflowId, userId)).toEqual(entry1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redo', () => {
|
||||
it('should return the last undone operation and move it back to undo', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
const entry = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry)
|
||||
undo(workflowId, userId)
|
||||
const result = redo(workflowId, userId)
|
||||
|
||||
expect(result).toEqual(entry)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when redo stack is empty', () => {
|
||||
const { redo } = useUndoRedoStore.getState()
|
||||
|
||||
const result = redo(workflowId, userId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should redo operations in LIFO order', () => {
|
||||
const { push, undo, redo } = useUndoRedoStore.getState()
|
||||
|
||||
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry1)
|
||||
push(workflowId, userId, entry2)
|
||||
undo(workflowId, userId)
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(redo(workflowId, userId)).toEqual(entry1)
|
||||
expect(redo(workflowId, userId)).toEqual(entry2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear both undo and redo stacks', () => {
|
||||
const { push, undo, clear, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
clear(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 0,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should only clear stacks for specified workflow/user', () => {
|
||||
const { push, clear, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
'wf-1',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
|
||||
)
|
||||
push(
|
||||
'wf-2',
|
||||
'user-2',
|
||||
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-2' })
|
||||
)
|
||||
|
||||
clear('wf-1', 'user-1')
|
||||
|
||||
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
|
||||
expect(getStackSizes('wf-2', 'user-2').undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearRedo', () => {
|
||||
it('should only clear the redo stack', () => {
|
||||
const { push, undo, clearRedo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
clearRedo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStackSizes', () => {
|
||||
it('should return zero sizes for non-existent stack', () => {
|
||||
const { getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
expect(getStackSizes('non-existent', 'user')).toEqual({
|
||||
undoSize: 0,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct sizes', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 2,
|
||||
redoSize: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCapacity', () => {
|
||||
it('should update capacity', () => {
|
||||
const { setCapacity } = useUndoRedoStore.getState()
|
||||
|
||||
setCapacity(50)
|
||||
|
||||
expect(useUndoRedoStore.getState().capacity).toBe(50)
|
||||
})
|
||||
|
||||
it('should truncate existing stacks to new capacity', () => {
|
||||
const { push, setCapacity, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(10)
|
||||
|
||||
setCapacity(5)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('move-block coalescing', () => {
|
||||
it('should coalesce consecutive moves of the same block', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 10, y: 10 },
|
||||
})
|
||||
)
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 10, y: 10 },
|
||||
after: { x: 20, y: 20 },
|
||||
})
|
||||
)
|
||||
|
||||
// Should coalesce into a single operation
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should not coalesce moves of different blocks', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 10, y: 10 },
|
||||
})
|
||||
)
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-2', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 20, y: 20 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
})
|
||||
|
||||
it('should skip no-op moves', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 100, y: 100 },
|
||||
after: { x: 100, y: 100 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
})
|
||||
|
||||
it('should preserve original position when coalescing results in no-op', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
// Move block from (0,0) to (10,10)
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 10, y: 10 },
|
||||
})
|
||||
)
|
||||
|
||||
// Move block back to (0,0) - coalesces to a no-op
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 10, y: 10 },
|
||||
after: { x: 0, y: 0 },
|
||||
})
|
||||
)
|
||||
|
||||
// Should result in no operations since it's a round-trip
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recording suspension', () => {
|
||||
it('should skip operations when recording is suspended', async () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(() => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
})
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
})
|
||||
|
||||
it('should resume recording after suspension ends', async () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(() => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
})
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle nested suspension correctly', async () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(async () => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(() => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
})
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
|
||||
})
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-4', { workflowId, userId }))
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneInvalidEntries', () => {
|
||||
it('should remove entries for non-existent blocks', () => {
|
||||
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
// Add entries for blocks
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
|
||||
// Prune with only block-1 existing
|
||||
const graph = {
|
||||
blocksById: {
|
||||
'block-1': createBlock({ id: 'block-1' }),
|
||||
},
|
||||
edgesById: {},
|
||||
}
|
||||
|
||||
pruneInvalidEntries(workflowId, userId, graph)
|
||||
|
||||
// Only the entry for block-1 should remain (inverse is add-block which requires block NOT exist)
|
||||
// Actually, remove-block inverse is add-block, which is applicable when block doesn't exist
|
||||
// Let me reconsider: the pruneInvalidEntries checks if the INVERSE is applicable
|
||||
// For remove-block, inverse is add-block, which is applicable when block doesn't exist
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should remove redo entries with non-applicable operations', () => {
|
||||
const { push, undo, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
// Prune - block-1 doesn't exist, so remove-block is not applicable
|
||||
pruneInvalidEntries(workflowId, userId, { blocksById: {}, edgesById: {} })
|
||||
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflow/user isolation', () => {
|
||||
it('should keep stacks isolated by workflow and user', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
'wf-1',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
|
||||
)
|
||||
push(
|
||||
'wf-1',
|
||||
'user-2',
|
||||
createAddBlockEntry('block-2', { workflowId: 'wf-1', userId: 'user-2' })
|
||||
)
|
||||
push(
|
||||
'wf-2',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-3', { workflowId: 'wf-2', userId: 'user-1' })
|
||||
)
|
||||
|
||||
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(1)
|
||||
expect(getStackSizes('wf-1', 'user-2').undoSize).toBe(1)
|
||||
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should not affect other stacks when undoing', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
'wf-1',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
|
||||
)
|
||||
push(
|
||||
'wf-2',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-1' })
|
||||
)
|
||||
|
||||
undo('wf-1', 'user-1')
|
||||
|
||||
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
|
||||
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid consecutive operations', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle multiple undo/redo cycles', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
undo(workflowId, userId)
|
||||
redo(workflowId, userId)
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle mixed operation types', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 100, y: 100 },
|
||||
})
|
||||
)
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
|
||||
|
||||
undo(workflowId, userId)
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 2,
|
||||
redoSize: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge operations', () => {
|
||||
it('should handle add-edge operations', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-2', { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('add-edge')
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle remove-edge operations', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('remove-edge')
|
||||
expect(entry?.inverse.type).toBe('add-edge')
|
||||
})
|
||||
})
|
||||
|
||||
describe('duplicate-block operations', () => {
|
||||
it('should handle duplicate-block operations', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
const sourceBlock = createBlock({ id: 'source-block' })
|
||||
const duplicatedBlock = createBlock({ id: 'duplicated-block' })
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, {
|
||||
workflowId,
|
||||
userId,
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('duplicate-block')
|
||||
expect(entry?.inverse.type).toBe('remove-block')
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should store the duplicated block snapshot correctly', () => {
|
||||
const { push, undo } = useUndoRedoStore.getState()
|
||||
|
||||
const duplicatedBlock = createBlock({
|
||||
id: 'duplicated-block',
|
||||
name: 'Duplicated Agent',
|
||||
type: 'agent',
|
||||
position: { x: 200, y: 200 },
|
||||
})
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, {
|
||||
workflowId,
|
||||
userId,
|
||||
})
|
||||
)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.data.duplicatedBlockSnapshot).toMatchObject({
|
||||
id: 'duplicated-block',
|
||||
name: 'Duplicated Agent',
|
||||
type: 'agent',
|
||||
position: { x: 200, y: 200 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update-parent operations', () => {
|
||||
it('should handle update-parent operations', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createUpdateParentEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
oldParentId: undefined,
|
||||
newParentId: 'loop-1',
|
||||
oldPosition: { x: 100, y: 100 },
|
||||
newPosition: { x: 50, y: 50 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('update-parent')
|
||||
expect(entry?.inverse.type).toBe('update-parent')
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should correctly swap parent IDs in inverse operation', () => {
|
||||
const { push, undo } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createUpdateParentEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
oldParentId: 'loop-1',
|
||||
newParentId: 'loop-2',
|
||||
oldPosition: { x: 0, y: 0 },
|
||||
newPosition: { x: 100, y: 100 },
|
||||
})
|
||||
)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.inverse.data.oldParentId).toBe('loop-2')
|
||||
expect(entry?.inverse.data.newParentId).toBe('loop-1')
|
||||
expect(entry?.inverse.data.oldPosition).toEqual({ x: 100, y: 100 })
|
||||
expect(entry?.inverse.data.newPosition).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneInvalidEntries with edges', () => {
|
||||
it('should remove entries for non-existent edges', () => {
|
||||
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId }))
|
||||
push(workflowId, userId, createRemoveEdgeEntry('edge-2', null, { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
|
||||
const graph = {
|
||||
blocksById: {},
|
||||
edgesById: {
|
||||
'edge-1': { id: 'edge-1', source: 'a', target: 'b' },
|
||||
},
|
||||
}
|
||||
|
||||
pruneInvalidEntries(workflowId, userId, graph as any)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle a complete workflow creation scenario', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('starter', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('agent-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('agent-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 200, y: 100 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
|
||||
|
||||
undo(workflowId, userId)
|
||||
undo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 2, redoSize: 2 })
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 3, redoSize: 1 })
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('agent-2', { workflowId, userId }))
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 4, redoSize: 0 })
|
||||
})
|
||||
|
||||
it('should handle loop workflow with child blocks', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('loop-1', { workflowId, userId }))
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createUpdateParentEntry('child-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
oldParentId: undefined,
|
||||
newParentId: 'loop-1',
|
||||
})
|
||||
)
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('child-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 50, y: 50 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
|
||||
|
||||
const moveEntry = undo(workflowId, userId)
|
||||
expect(moveEntry?.operation.type).toBe('move-block')
|
||||
|
||||
const parentEntry = undo(workflowId, userId)
|
||||
expect(parentEntry?.operation.type).toBe('update-parent')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import {
|
||||
createAgentBlock,
|
||||
createBlock,
|
||||
createFunctionBlock,
|
||||
createLoopBlock,
|
||||
createStarterBlock,
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeName } from './utils'
|
||||
import { getUniqueBlockName, normalizeName } from './utils'
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it.concurrent('should convert to lowercase', () => {
|
||||
@@ -91,3 +98,127 @@ describe('normalizeName', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUniqueBlockName', () => {
|
||||
it('should return "Start" for starter blocks', () => {
|
||||
expect(getUniqueBlockName('Start', {})).toBe('Start')
|
||||
expect(getUniqueBlockName('Starter', {})).toBe('Start')
|
||||
expect(getUniqueBlockName('start', {})).toBe('Start')
|
||||
})
|
||||
|
||||
it('should return name with number 1 when no existing blocks', () => {
|
||||
expect(getUniqueBlockName('Agent', {})).toBe('Agent 1')
|
||||
expect(getUniqueBlockName('Function', {})).toBe('Function 1')
|
||||
expect(getUniqueBlockName('Loop', {})).toBe('Loop 1')
|
||||
})
|
||||
|
||||
it('should increment number when existing blocks have same base name', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
|
||||
})
|
||||
|
||||
it('should find highest number and increment', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
|
||||
'block-2': createAgentBlock({ id: 'block-2', name: 'Agent 3' }),
|
||||
'block-3': createAgentBlock({ id: 'block-3', name: 'Agent 2' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 4')
|
||||
})
|
||||
|
||||
it('should handle base name with existing number suffix', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createFunctionBlock({ id: 'block-1', name: 'Function 1' }),
|
||||
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 2' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('Function 1', existingBlocks)).toBe('Function 3')
|
||||
expect(getUniqueBlockName('Function 5', existingBlocks)).toBe('Function 3')
|
||||
})
|
||||
|
||||
it('should be case insensitive when matching base names', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createBlock({ id: 'block-1', name: 'API 1' }),
|
||||
'block-2': createBlock({ id: 'block-2', name: 'api 2' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('API', existingBlocks)).toBe('API 3')
|
||||
expect(getUniqueBlockName('api', existingBlocks)).toBe('api 3')
|
||||
})
|
||||
|
||||
it('should handle different block types independently', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
|
||||
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 1' }),
|
||||
'block-3': createLoopBlock({ id: 'block-3', name: 'Loop 1' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
|
||||
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
|
||||
expect(getUniqueBlockName('Loop', existingBlocks)).toBe('Loop 2')
|
||||
expect(getUniqueBlockName('Router', existingBlocks)).toBe('Router 1')
|
||||
})
|
||||
|
||||
it('should handle blocks without numbers as having number 0', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createBlock({ id: 'block-1', name: 'Custom' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('Custom', existingBlocks)).toBe('Custom 1')
|
||||
})
|
||||
|
||||
it('should handle multi-word base names', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createBlock({ id: 'block-1', name: 'API Block 1' }),
|
||||
'block-2': createBlock({ id: 'block-2', name: 'API Block 2' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('API Block', existingBlocks)).toBe('API Block 3')
|
||||
})
|
||||
|
||||
it('should handle starter blocks even with existing starters', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createStarterBlock({ id: 'block-1', name: 'Start' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
|
||||
expect(getUniqueBlockName('Starter', existingBlocks)).toBe('Start')
|
||||
})
|
||||
|
||||
it('should handle empty string base name', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createBlock({ id: 'block-1', name: ' 1' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('', existingBlocks)).toBe(' 1')
|
||||
})
|
||||
|
||||
it('should handle complex real-world scenarios', () => {
|
||||
const existingBlocks = {
|
||||
starter: createStarterBlock({ id: 'starter', name: 'Start' }),
|
||||
agent1: createAgentBlock({ id: 'agent1', name: 'Agent 1' }),
|
||||
agent2: createAgentBlock({ id: 'agent2', name: 'Agent 2' }),
|
||||
func1: createFunctionBlock({ id: 'func1', name: 'Function 1' }),
|
||||
loop1: createLoopBlock({ id: 'loop1', name: 'Loop 1' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 3')
|
||||
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
|
||||
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
|
||||
expect(getUniqueBlockName('Condition', existingBlocks)).toBe('Condition 1')
|
||||
})
|
||||
|
||||
it('should preserve original base name casing in result', () => {
|
||||
const existingBlocks = {
|
||||
'block-1': createBlock({ id: 'block-1', name: 'MyBlock 1' }),
|
||||
}
|
||||
|
||||
expect(getUniqueBlockName('MyBlock', existingBlocks)).toBe('MyBlock 2')
|
||||
expect(getUniqueBlockName('MYBLOCK', existingBlocks)).toBe('MYBLOCK 2')
|
||||
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import { createLoopBlock } from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { convertLoopBlockToLoop } from '@/stores/workflows/workflow/utils'
|
||||
@@ -5,20 +6,13 @@ import { convertLoopBlockToLoop } from '@/stores/workflows/workflow/utils'
|
||||
describe('convertLoopBlockToLoop', () => {
|
||||
it.concurrent('should keep JSON array string as-is for forEach loops', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
loop1: {
|
||||
loop1: createLoopBlock({
|
||||
id: 'loop1',
|
||||
type: 'loop',
|
||||
name: 'Test Loop',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
data: {
|
||||
loopType: 'forEach',
|
||||
count: 10,
|
||||
collection: '["item1", "item2", "item3"]',
|
||||
},
|
||||
},
|
||||
data: { collection: '["item1", "item2", "item3"]' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = convertLoopBlockToLoop('loop1', blocks)
|
||||
@@ -31,20 +25,13 @@ describe('convertLoopBlockToLoop', () => {
|
||||
|
||||
it.concurrent('should keep JSON object string as-is for forEach loops', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
loop1: {
|
||||
loop1: createLoopBlock({
|
||||
id: 'loop1',
|
||||
type: 'loop',
|
||||
name: 'Test Loop',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
data: {
|
||||
loopType: 'forEach',
|
||||
count: 5,
|
||||
collection: '{"key1": "value1", "key2": "value2"}',
|
||||
},
|
||||
},
|
||||
data: { collection: '{"key1": "value1", "key2": "value2"}' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = convertLoopBlockToLoop('loop1', blocks)
|
||||
@@ -56,20 +43,13 @@ describe('convertLoopBlockToLoop', () => {
|
||||
|
||||
it.concurrent('should keep string as-is if not valid JSON', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
loop1: {
|
||||
loop1: createLoopBlock({
|
||||
id: 'loop1',
|
||||
type: 'loop',
|
||||
name: 'Test Loop',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
data: {
|
||||
loopType: 'forEach',
|
||||
count: 5,
|
||||
collection: '<blockName.items>',
|
||||
},
|
||||
},
|
||||
data: { collection: '<blockName.items>' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = convertLoopBlockToLoop('loop1', blocks)
|
||||
@@ -80,20 +60,13 @@ describe('convertLoopBlockToLoop', () => {
|
||||
|
||||
it.concurrent('should handle empty collection', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
loop1: {
|
||||
loop1: createLoopBlock({
|
||||
id: 'loop1',
|
||||
type: 'loop',
|
||||
name: 'Test Loop',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
data: {
|
||||
loopType: 'forEach',
|
||||
count: 5,
|
||||
collection: '',
|
||||
},
|
||||
},
|
||||
data: { collection: '' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = convertLoopBlockToLoop('loop1', blocks)
|
||||
@@ -104,20 +77,13 @@ describe('convertLoopBlockToLoop', () => {
|
||||
|
||||
it.concurrent('should handle for loops without collection parsing', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
loop1: {
|
||||
loop1: createLoopBlock({
|
||||
id: 'loop1',
|
||||
type: 'loop',
|
||||
name: 'Test Loop',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
data: {
|
||||
loopType: 'for',
|
||||
count: 5,
|
||||
collection: '["should", "not", "matter"]',
|
||||
},
|
||||
},
|
||||
data: { collection: '["should", "not", "matter"]' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = convertLoopBlockToLoop('loop1', blocks)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* This file contains mock data samples to be used in tool unit tests.
|
||||
*/
|
||||
|
||||
// HTTP Request Mock Data
|
||||
/**
|
||||
* HTTP Request mock responses for different scenarios.
|
||||
*/
|
||||
export const mockHttpResponses = {
|
||||
simple: {
|
||||
data: { message: 'Success', status: 'ok' },
|
||||
@@ -24,318 +26,3 @@ export const mockHttpResponses = {
|
||||
status: 401,
|
||||
},
|
||||
}
|
||||
|
||||
// Gmail Mock Data
|
||||
export const mockGmailResponses = {
|
||||
// List messages response
|
||||
messageList: {
|
||||
messages: [
|
||||
{ id: 'msg1', threadId: 'thread1' },
|
||||
{ id: 'msg2', threadId: 'thread2' },
|
||||
{ id: 'msg3', threadId: 'thread3' },
|
||||
],
|
||||
nextPageToken: 'token123',
|
||||
},
|
||||
|
||||
// Empty list response
|
||||
emptyList: {
|
||||
messages: [],
|
||||
resultSizeEstimate: 0,
|
||||
},
|
||||
|
||||
// Single message response
|
||||
singleMessage: {
|
||||
id: 'msg1',
|
||||
threadId: 'thread1',
|
||||
labelIds: ['INBOX', 'UNREAD'],
|
||||
snippet: 'This is a snippet preview of the email...',
|
||||
payload: {
|
||||
headers: [
|
||||
{ name: 'From', value: 'sender@example.com' },
|
||||
{ name: 'To', value: 'recipient@example.com' },
|
||||
{ name: 'Subject', value: 'Test Email Subject' },
|
||||
{ name: 'Date', value: 'Mon, 15 Mar 2025 10:30:00 -0800' },
|
||||
],
|
||||
mimeType: 'multipart/alternative',
|
||||
parts: [
|
||||
{
|
||||
mimeType: 'text/plain',
|
||||
body: {
|
||||
data: Buffer.from('This is the plain text content of the email').toString('base64'),
|
||||
},
|
||||
},
|
||||
{
|
||||
mimeType: 'text/html',
|
||||
body: {
|
||||
data: Buffer.from('<div>This is the HTML content of the email</div>').toString(
|
||||
'base64'
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Google Drive Mock Data
|
||||
export const mockDriveResponses = {
|
||||
// List files response
|
||||
fileList: {
|
||||
files: [
|
||||
{ id: 'file1', name: 'Document1.docx', mimeType: 'application/vnd.google-apps.document' },
|
||||
{
|
||||
id: 'file2',
|
||||
name: 'Spreadsheet.xlsx',
|
||||
mimeType: 'application/vnd.google-apps.spreadsheet',
|
||||
},
|
||||
{
|
||||
id: 'file3',
|
||||
name: 'Presentation.pptx',
|
||||
mimeType: 'application/vnd.google-apps.presentation',
|
||||
},
|
||||
],
|
||||
nextPageToken: 'drive-page-token',
|
||||
},
|
||||
|
||||
// Empty file list
|
||||
emptyFileList: {
|
||||
files: [],
|
||||
},
|
||||
|
||||
// Single file metadata
|
||||
fileMetadata: {
|
||||
id: 'file1',
|
||||
name: 'Document1.docx',
|
||||
mimeType: 'application/vnd.google-apps.document',
|
||||
webViewLink: 'https://docs.google.com/document/d/123/edit',
|
||||
createdTime: '2025-03-15T12:00:00Z',
|
||||
modifiedTime: '2025-03-16T10:15:00Z',
|
||||
owners: [{ displayName: 'Test User', emailAddress: 'user@example.com' }],
|
||||
size: '12345',
|
||||
},
|
||||
}
|
||||
|
||||
// Google Sheets Mock Data
|
||||
export const mockSheetsResponses = {
|
||||
// Read range response
|
||||
rangeData: {
|
||||
range: 'Sheet1!A1:D5',
|
||||
majorDimension: 'ROWS',
|
||||
values: [
|
||||
['Header1', 'Header2', 'Header3', 'Header4'],
|
||||
['Row1Col1', 'Row1Col2', 'Row1Col3', 'Row1Col4'],
|
||||
['Row2Col1', 'Row2Col2', 'Row2Col3', 'Row2Col4'],
|
||||
['Row3Col1', 'Row3Col2', 'Row3Col3', 'Row3Col4'],
|
||||
['Row4Col1', 'Row4Col2', 'Row4Col3', 'Row4Col4'],
|
||||
],
|
||||
},
|
||||
|
||||
// Empty range
|
||||
emptyRange: {
|
||||
range: 'Sheet1!A1:D5',
|
||||
majorDimension: 'ROWS',
|
||||
values: [],
|
||||
},
|
||||
|
||||
// Update range response
|
||||
updateResponse: {
|
||||
spreadsheetId: 'spreadsheet123',
|
||||
updatedRange: 'Sheet1!A1:D5',
|
||||
updatedRows: 5,
|
||||
updatedColumns: 4,
|
||||
updatedCells: 20,
|
||||
},
|
||||
}
|
||||
|
||||
// Pinecone Mock Data
|
||||
export const mockPineconeResponses = {
|
||||
// Vector embedding
|
||||
embedding: {
|
||||
embedding: Array(1536)
|
||||
.fill(0)
|
||||
.map(() => Math.random() * 2 - 1),
|
||||
metadata: { text: 'Sample text for embedding', id: 'embed-123' },
|
||||
},
|
||||
|
||||
// Search results
|
||||
searchResults: {
|
||||
matches: [
|
||||
{ id: 'doc1', score: 0.92, metadata: { text: 'Matching text 1' } },
|
||||
{ id: 'doc2', score: 0.85, metadata: { text: 'Matching text 2' } },
|
||||
{ id: 'doc3', score: 0.78, metadata: { text: 'Matching text 3' } },
|
||||
],
|
||||
},
|
||||
|
||||
// Upsert response
|
||||
upsertResponse: {
|
||||
upsertedCount: 5,
|
||||
},
|
||||
}
|
||||
|
||||
// GitHub Mock Data
|
||||
export const mockGitHubResponses = {
|
||||
// Repository info
|
||||
repoInfo: {
|
||||
id: 12345,
|
||||
name: 'test-repo',
|
||||
full_name: 'user/test-repo',
|
||||
description: 'A test repository',
|
||||
html_url: 'https://github.com/user/test-repo',
|
||||
owner: {
|
||||
login: 'user',
|
||||
id: 54321,
|
||||
avatar_url: 'https://avatars.githubusercontent.com/u/54321',
|
||||
},
|
||||
private: false,
|
||||
fork: false,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-03-15T10:00:00Z',
|
||||
pushed_at: '2025-03-15T09:00:00Z',
|
||||
default_branch: 'main',
|
||||
open_issues_count: 5,
|
||||
watchers_count: 10,
|
||||
forks_count: 3,
|
||||
stargazers_count: 15,
|
||||
language: 'TypeScript',
|
||||
},
|
||||
|
||||
// PR creation response
|
||||
prResponse: {
|
||||
id: 12345,
|
||||
number: 42,
|
||||
title: 'Test PR Title',
|
||||
body: 'Test PR description',
|
||||
html_url: 'https://github.com/user/test-repo/pull/42',
|
||||
state: 'open',
|
||||
user: {
|
||||
login: 'user',
|
||||
id: 54321,
|
||||
},
|
||||
created_at: '2025-03-15T10:00:00Z',
|
||||
updated_at: '2025-03-15T10:05:00Z',
|
||||
},
|
||||
}
|
||||
|
||||
// Serper Search Mock Data
|
||||
export const mockSerperResponses = {
|
||||
// Search results
|
||||
searchResults: {
|
||||
searchParameters: {
|
||||
q: 'test query',
|
||||
gl: 'us',
|
||||
hl: 'en',
|
||||
},
|
||||
organic: [
|
||||
{
|
||||
title: 'Test Result 1',
|
||||
link: 'https://example.com/1',
|
||||
snippet: 'This is a snippet for the first test result.',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
title: 'Test Result 2',
|
||||
link: 'https://example.com/2',
|
||||
snippet: 'This is a snippet for the second test result.',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
title: 'Test Result 3',
|
||||
link: 'https://example.com/3',
|
||||
snippet: 'This is a snippet for the third test result.',
|
||||
position: 3,
|
||||
},
|
||||
],
|
||||
knowledgeGraph: {
|
||||
title: 'Test Knowledge Graph',
|
||||
type: 'Test Type',
|
||||
description: 'This is a test knowledge graph result',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Slack Mock Data
|
||||
export const mockSlackResponses = {
|
||||
// Message post response
|
||||
messageResponse: {
|
||||
ok: true,
|
||||
channel: 'C1234567890',
|
||||
ts: '1627385301.000700',
|
||||
message: {
|
||||
text: 'This is a test message',
|
||||
user: 'U1234567890',
|
||||
ts: '1627385301.000700',
|
||||
team: 'T1234567890',
|
||||
},
|
||||
},
|
||||
|
||||
// Error response
|
||||
errorResponse: {
|
||||
ok: false,
|
||||
error: 'channel_not_found',
|
||||
},
|
||||
}
|
||||
|
||||
// Tavily Mock Data
|
||||
export const mockTavilyResponses = {
|
||||
// Search results
|
||||
searchResults: {
|
||||
results: [
|
||||
{
|
||||
title: 'Test Article 1',
|
||||
url: 'https://example.com/article1',
|
||||
content: 'This is the content of test article 1.',
|
||||
score: 0.95,
|
||||
},
|
||||
{
|
||||
title: 'Test Article 2',
|
||||
url: 'https://example.com/article2',
|
||||
content: 'This is the content of test article 2.',
|
||||
score: 0.87,
|
||||
},
|
||||
{
|
||||
title: 'Test Article 3',
|
||||
url: 'https://example.com/article3',
|
||||
content: 'This is the content of test article 3.',
|
||||
score: 0.72,
|
||||
},
|
||||
],
|
||||
query: 'test query',
|
||||
search_id: 'search-123',
|
||||
},
|
||||
}
|
||||
|
||||
// Supabase Mock Data
|
||||
export const mockSupabaseResponses = {
|
||||
// Query response
|
||||
queryResponse: {
|
||||
data: [
|
||||
{ id: 1, name: 'Item 1', description: 'Description 1' },
|
||||
{ id: 2, name: 'Item 2', description: 'Description 2' },
|
||||
{ id: 3, name: 'Item 3', description: 'Description 3' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
|
||||
// Insert response
|
||||
insertResponse: {
|
||||
data: [{ id: 4, name: 'Item 4', description: 'Description 4' }],
|
||||
error: null,
|
||||
},
|
||||
|
||||
// Update response
|
||||
updateResponse: {
|
||||
data: [{ id: 1, name: 'Updated Item 1', description: 'Updated Description 1' }],
|
||||
error: null,
|
||||
},
|
||||
|
||||
// Error response
|
||||
errorResponse: {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Database error',
|
||||
details: 'Error details',
|
||||
hint: 'Error hint',
|
||||
code: 'DB_ERROR',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
* This file contains utility functions and classes for testing tools
|
||||
* in a controlled environment without external dependencies.
|
||||
*/
|
||||
import { createMockFetch as createBaseMockFetch, type MockFetchResponse } from '@sim/testing'
|
||||
import { type Mock, vi } from 'vitest'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
// Define a type that combines Mock with fetch properties
|
||||
/**
|
||||
* Type that combines Mock with fetch properties including Next.js preconnect.
|
||||
*/
|
||||
type MockFetch = Mock & {
|
||||
preconnect: Mock
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standard mock headers for HTTP testing
|
||||
* Create standard mock headers for HTTP testing.
|
||||
*/
|
||||
const createMockHeaders = (customHeaders: Record<string, string> = {}) => {
|
||||
return {
|
||||
@@ -32,7 +35,8 @@ const createMockHeaders = (customHeaders: Record<string, string> = {}) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock fetch function that returns a specified response
|
||||
* Creates a mock fetch function with Next.js preconnect support.
|
||||
* Wraps the @sim/testing createMockFetch with tool-specific additions.
|
||||
*/
|
||||
export function createMockFetch(
|
||||
responseData: any,
|
||||
@@ -40,68 +44,44 @@ export function createMockFetch(
|
||||
) {
|
||||
const { ok = true, status = 200, headers = { 'Content-Type': 'application/json' } } = options
|
||||
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
const mockFetchConfig: MockFetchResponse = {
|
||||
json: responseData,
|
||||
status,
|
||||
headers: {
|
||||
get: (key: string) => headers[key.toLowerCase()],
|
||||
forEach: (callback: (value: string, key: string) => void) => {
|
||||
Object.entries(headers).forEach(([key, value]) => callback(value, key))
|
||||
},
|
||||
},
|
||||
json: vi.fn().mockResolvedValue(responseData),
|
||||
text: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
typeof responseData === 'string' ? responseData : JSON.stringify(responseData)
|
||||
),
|
||||
})
|
||||
ok,
|
||||
headers,
|
||||
text: typeof responseData === 'string' ? responseData : JSON.stringify(responseData),
|
||||
}
|
||||
|
||||
// Add preconnect property to satisfy TypeScript
|
||||
const baseMockFetch = createBaseMockFetch(mockFetchConfig)
|
||||
;(baseMockFetch as any).preconnect = vi.fn()
|
||||
|
||||
;(mockFn as any).preconnect = vi.fn()
|
||||
|
||||
return mockFn as MockFetch
|
||||
return baseMockFetch as MockFetch
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock error fetch function
|
||||
* Creates a mock error fetch function.
|
||||
*/
|
||||
export function createErrorFetch(errorMessage: string, status = 400) {
|
||||
// Instead of rejecting, create a proper response with an error status
|
||||
const error = new Error(errorMessage)
|
||||
;(error as any).status = status
|
||||
|
||||
// Return both a network error version and a response error version
|
||||
// This better mimics different kinds of errors that can happen
|
||||
if (status < 0) {
|
||||
// Network error that causes the fetch to reject
|
||||
const mockFn = vi.fn().mockRejectedValue(error)
|
||||
;(mockFn as any).preconnect = vi.fn()
|
||||
return mockFn as MockFetch
|
||||
}
|
||||
// HTTP error with status code
|
||||
const mockFn = vi.fn().mockResolvedValue({
|
||||
|
||||
const mockFetchConfig: MockFetchResponse = {
|
||||
ok: false,
|
||||
status,
|
||||
statusText: errorMessage,
|
||||
headers: {
|
||||
get: () => 'application/json',
|
||||
forEach: () => {},
|
||||
},
|
||||
json: vi.fn().mockResolvedValue({
|
||||
error: errorMessage,
|
||||
message: errorMessage,
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
error: errorMessage,
|
||||
message: errorMessage,
|
||||
})
|
||||
),
|
||||
})
|
||||
;(mockFn as any).preconnect = vi.fn()
|
||||
return mockFn as MockFetch
|
||||
json: { error: errorMessage, message: errorMessage },
|
||||
}
|
||||
|
||||
const baseMockFetch = createBaseMockFetch(mockFetchConfig)
|
||||
;(baseMockFetch as any).preconnect = vi.fn()
|
||||
|
||||
return baseMockFetch as MockFetch
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,62 +430,3 @@ export class ToolTester<P = any, R = any> {
|
||||
return this.tool.request.body ? this.tool.request.body(params) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock environment variables for testing tools that use environment variables
|
||||
*/
|
||||
export function mockEnvironmentVariables(variables: Record<string, string>) {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
// Add the variables to process.env
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
process.env[key] = value
|
||||
})
|
||||
|
||||
// Return a cleanup function
|
||||
return () => {
|
||||
// Remove the added variables
|
||||
Object.keys(variables).forEach((key) => {
|
||||
delete process.env[key]
|
||||
})
|
||||
|
||||
// Restore original values
|
||||
Object.entries(originalEnv).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock OAuth store for testing tools that require OAuth
|
||||
*/
|
||||
export function mockOAuthTokenRequest(accessToken = 'mock-access-token') {
|
||||
// Mock the fetch call to /api/auth/oauth/token
|
||||
const originalFetch = global.fetch
|
||||
|
||||
const mockFn = vi.fn().mockImplementation((url, options) => {
|
||||
if (url.toString().includes('/api/auth/oauth/token')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ accessToken }),
|
||||
})
|
||||
}
|
||||
return originalFetch(url, options)
|
||||
})
|
||||
|
||||
// Add preconnect property
|
||||
|
||||
;(mockFn as any).preconnect = vi.fn()
|
||||
|
||||
const mockTokenFetch = mockFn as MockFetch
|
||||
|
||||
global.fetch = mockTokenFetch as unknown as typeof fetch
|
||||
|
||||
// Return a cleanup function
|
||||
return () => {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,27 +6,61 @@
|
||||
* This file contains unit tests for the tools registry and executeTool function,
|
||||
* which are the central pieces of infrastructure for executing tools.
|
||||
*/
|
||||
|
||||
import {
|
||||
createExecutionContext,
|
||||
createMockFetch,
|
||||
type ExecutionContext,
|
||||
type MockFetchResponse,
|
||||
} from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { mockEnvironmentVariables } from '@/tools/__test-utils__/test-tools'
|
||||
import { executeTool } from '@/tools/index'
|
||||
import { tools } from '@/tools/registry'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
const createMockExecutionContext = (overrides?: Partial<ExecutionContext>): ExecutionContext => ({
|
||||
workflowId: 'test-workflow',
|
||||
/**
|
||||
* Sets up global fetch mock with Next.js preconnect support.
|
||||
*/
|
||||
function setupFetchMock(config: MockFetchResponse = {}) {
|
||||
const mockFetch = createMockFetch(config)
|
||||
const fetchWithPreconnect = Object.assign(mockFetch, { preconnect: vi.fn() }) as typeof fetch
|
||||
global.fetch = fetchWithPreconnect
|
||||
return mockFetch
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock execution context with workspaceId for tool tests.
|
||||
*/
|
||||
function createToolExecutionContext(overrides?: Partial<ExecutionContext>): ExecutionContext {
|
||||
const ctx = createExecutionContext({
|
||||
workflowId: overrides?.workflowId ?? 'test-workflow',
|
||||
blockStates: overrides?.blockStates,
|
||||
executedBlocks: overrides?.executedBlocks,
|
||||
blockLogs: overrides?.blockLogs,
|
||||
metadata: overrides?.metadata,
|
||||
environmentVariables: overrides?.environmentVariables,
|
||||
})
|
||||
return {
|
||||
...ctx,
|
||||
workspaceId: 'workspace-456',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopExecutions: new Map(),
|
||||
completedLoops: new Set(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
...overrides,
|
||||
})
|
||||
} as ExecutionContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up environment variables and returns a cleanup function.
|
||||
*/
|
||||
function setupEnvVars(variables: Record<string, string>) {
|
||||
const originalEnv = { ...process.env }
|
||||
Object.assign(process.env, variables)
|
||||
|
||||
return () => {
|
||||
Object.keys(variables).forEach((key) => delete process.env[key])
|
||||
Object.entries(originalEnv).forEach(([key, value]) => {
|
||||
if (value !== undefined) process.env[key] = value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('Tools Registry', () => {
|
||||
it('should include all expected built-in tools', () => {
|
||||
@@ -146,38 +180,14 @@ describe('executeTool Function', () => {
|
||||
let cleanupEnvVars: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url, options) => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
setupFetchMock({
|
||||
json: { success: true, output: { result: 'Direct request successful' } },
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
output: { result: 'Direct request successful' },
|
||||
}),
|
||||
headers: {
|
||||
get: () => 'application/json',
|
||||
forEach: () => {},
|
||||
},
|
||||
clone: function () {
|
||||
return { ...this }
|
||||
},
|
||||
}
|
||||
|
||||
if (url.toString().includes('/api/proxy')) {
|
||||
return mockResponse
|
||||
}
|
||||
|
||||
return mockResponse
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
cleanupEnvVars = mockEnvironmentVariables({
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
})
|
||||
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -242,19 +252,7 @@ describe('executeTool Function', () => {
|
||||
})
|
||||
|
||||
it('should handle errors from tools', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
error: 'Bad request',
|
||||
}),
|
||||
}
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
setupFetchMock({ status: 400, ok: false, json: { error: 'Bad request' } })
|
||||
|
||||
const result = await executeTool(
|
||||
'http_request',
|
||||
@@ -291,9 +289,7 @@ describe('Automatic Internal Route Detection', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
cleanupEnvVars = mockEnvironmentVariables({
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
})
|
||||
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -541,9 +537,7 @@ describe('Centralized Error Handling', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
cleanupEnvVars = mockEnvironmentVariables({
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
})
|
||||
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -772,9 +766,7 @@ describe('MCP Tool Execution', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
cleanupEnvVars = mockEnvironmentVariables({
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
})
|
||||
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -811,7 +803,7 @@ describe('MCP Tool Execution', () => {
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const mockContext = createMockExecutionContext()
|
||||
const mockContext = createToolExecutionContext()
|
||||
|
||||
const result = await executeTool(
|
||||
'mcp-123-list_files',
|
||||
@@ -847,7 +839,7 @@ describe('MCP Tool Execution', () => {
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const mockContext2 = createMockExecutionContext()
|
||||
const mockContext2 = createToolExecutionContext()
|
||||
|
||||
await executeTool(
|
||||
'mcp-timestamp123-complex-tool-name',
|
||||
@@ -877,7 +869,7 @@ describe('MCP Tool Execution', () => {
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const mockContext3 = createMockExecutionContext()
|
||||
const mockContext3 = createToolExecutionContext()
|
||||
|
||||
await executeTool(
|
||||
'mcp-123-read_file',
|
||||
@@ -911,7 +903,7 @@ describe('MCP Tool Execution', () => {
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const mockContext4 = createMockExecutionContext()
|
||||
const mockContext4 = createToolExecutionContext()
|
||||
|
||||
await executeTool(
|
||||
'mcp-123-search',
|
||||
@@ -945,7 +937,7 @@ describe('MCP Tool Execution', () => {
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const mockContext5 = createMockExecutionContext()
|
||||
const mockContext5 = createToolExecutionContext()
|
||||
|
||||
const result = await executeTool(
|
||||
'mcp-123-nonexistent_tool',
|
||||
@@ -968,7 +960,7 @@ describe('MCP Tool Execution', () => {
|
||||
})
|
||||
|
||||
it('should handle invalid MCP tool ID format', async () => {
|
||||
const mockContext6 = createMockExecutionContext()
|
||||
const mockContext6 = createToolExecutionContext()
|
||||
|
||||
const result = await executeTool(
|
||||
'invalid-mcp-id',
|
||||
@@ -987,7 +979,7 @@ describe('MCP Tool Execution', () => {
|
||||
preconnect: vi.fn(),
|
||||
}) as typeof fetch
|
||||
|
||||
const mockContext7 = createMockExecutionContext()
|
||||
const mockContext7 = createToolExecutionContext()
|
||||
|
||||
const result = await executeTool(
|
||||
'mcp-123-test_tool',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createMockFetch, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import {
|
||||
@@ -10,14 +11,7 @@ import {
|
||||
validateRequiredParametersAfterMerge,
|
||||
} from '@/tools/utils'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/logs/console/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/stores/settings/environment/store', () => {
|
||||
const mockStore = {
|
||||
@@ -393,10 +387,10 @@ describe('validateRequiredParametersAfterMerge', () => {
|
||||
|
||||
describe('executeRequest', () => {
|
||||
let mockTool: ToolConfig
|
||||
let mockFetch: any
|
||||
let mockFetch: ReturnType<typeof createMockFetch>
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn()
|
||||
mockFetch = createMockFetch({ json: { result: 'success' }, status: 200 })
|
||||
global.fetch = mockFetch
|
||||
|
||||
mockTool = {
|
||||
@@ -422,12 +416,6 @@ describe('executeRequest', () => {
|
||||
})
|
||||
|
||||
it('should handle successful requests', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ result: 'success' }),
|
||||
})
|
||||
|
||||
const result = await executeRequest('test-tool', mockTool, {
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
@@ -448,12 +436,8 @@ describe('executeRequest', () => {
|
||||
|
||||
it.concurrent('should use default transform response if not provided', async () => {
|
||||
mockTool.transformResponse = undefined
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ result: 'success' }),
|
||||
})
|
||||
const localMockFetch = createMockFetch({ json: { result: 'success' }, status: 200 })
|
||||
global.fetch = localMockFetch
|
||||
|
||||
const result = await executeRequest('test-tool', mockTool, {
|
||||
url: 'https://api.example.com',
|
||||
@@ -468,12 +452,13 @@ describe('executeRequest', () => {
|
||||
})
|
||||
|
||||
it('should handle error responses', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
const errorFetch = createMockFetch({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({ message: 'Invalid input' }),
|
||||
json: { message: 'Invalid input' },
|
||||
})
|
||||
global.fetch = errorFetch
|
||||
|
||||
const result = await executeRequest('test-tool', mockTool, {
|
||||
url: 'https://api.example.com',
|
||||
@@ -489,8 +474,8 @@ describe('executeRequest', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should handle network errors', async () => {
|
||||
const networkError = new Error('Network error')
|
||||
mockFetch.mockRejectedValueOnce(networkError)
|
||||
const errorFetch = vi.fn().mockRejectedValueOnce(new Error('Network error'))
|
||||
global.fetch = errorFetch
|
||||
|
||||
const result = await executeRequest('test-tool', mockTool, {
|
||||
url: 'https://api.example.com',
|
||||
@@ -506,7 +491,7 @@ describe('executeRequest', () => {
|
||||
})
|
||||
|
||||
it('should handle JSON parse errors in error response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
const errorFetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
@@ -514,6 +499,7 @@ describe('executeRequest', () => {
|
||||
throw new Error('Invalid JSON')
|
||||
},
|
||||
})
|
||||
global.fetch = errorFetch
|
||||
|
||||
const result = await executeRequest('test-tool', mockTool, {
|
||||
url: 'https://api.example.com',
|
||||
@@ -524,7 +510,7 @@ describe('executeRequest', () => {
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
output: {},
|
||||
error: 'Server Error', // Should use statusText in the error message
|
||||
error: 'Server Error',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -543,12 +529,11 @@ describe('executeRequest', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
const xmlFetch = createMockFetch({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => '<xml><test>Mock XML response</test></xml>',
|
||||
text: '<xml><test>Mock XML response</test></xml>',
|
||||
})
|
||||
global.fetch = xmlFetch
|
||||
|
||||
const result = await executeRequest('test-tool', toolWithTransform, {
|
||||
url: 'https://api.example.com',
|
||||
|
||||
24
bun.lock
24
bun.lock
@@ -194,6 +194,7 @@
|
||||
"zustand": "^4.5.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/testing": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@trigger.dev/build": "4.1.2",
|
||||
"@types/html-to-text": "9.0.4",
|
||||
@@ -251,6 +252,17 @@
|
||||
"postgres": "^3.4.5",
|
||||
},
|
||||
},
|
||||
"packages/testing": {
|
||||
"name": "@sim/testing",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "^3.0.0",
|
||||
},
|
||||
},
|
||||
"packages/ts-sdk": {
|
||||
"name": "simstudio-ts-sdk",
|
||||
"version": "0.1.1",
|
||||
@@ -1155,6 +1167,8 @@
|
||||
|
||||
"@sim/db": ["@sim/db@workspace:packages/db"],
|
||||
|
||||
"@sim/testing": ["@sim/testing@workspace:packages/testing"],
|
||||
|
||||
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="],
|
||||
|
||||
"@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="],
|
||||
@@ -3263,7 +3277,7 @@
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
@@ -3745,8 +3759,6 @@
|
||||
|
||||
"@trigger.dev/core/socket.io-client": ["socket.io-client@4.7.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ=="],
|
||||
|
||||
"@trigger.dev/core/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"@trigger.dev/sdk/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="],
|
||||
|
||||
"@trigger.dev/sdk/cronstrue": ["cronstrue@2.61.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA=="],
|
||||
@@ -3853,6 +3865,8 @@
|
||||
|
||||
"fumadocs-mdx/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"fumadocs-mdx/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"fumadocs-mdx/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
|
||||
|
||||
"fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
@@ -3933,8 +3947,6 @@
|
||||
|
||||
"nypm/pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"oauth2-mock-server/express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"oauth2-mock-server/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
@@ -4069,8 +4081,6 @@
|
||||
|
||||
"vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
|
||||
|
||||
"vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
|
||||
|
||||
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
@@ -55,7 +55,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
# Copy node_modules from builder (cached if dependencies don't change)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Copy db package (needed by socket-server)
|
||||
# Copy db package (needed by socket)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/db ./packages/db
|
||||
|
||||
# Copy sim app (changes most frequently - placed last)
|
||||
@@ -71,4 +71,4 @@ ENV PORT=3002 \
|
||||
HOSTNAME="0.0.0.0"
|
||||
|
||||
# Run the socket server directly
|
||||
CMD ["bun", "apps/sim/socket-server/index.ts"]
|
||||
CMD ["bun", "apps/sim/socket/index.ts"]
|
||||
47
packages/testing/package.json
Normal file
47
packages/testing/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@sim/testing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.13",
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./factories": {
|
||||
"types": "./src/factories/index.ts",
|
||||
"default": "./src/factories/index.ts"
|
||||
},
|
||||
"./builders": {
|
||||
"types": "./src/builders/index.ts",
|
||||
"default": "./src/builders/index.ts"
|
||||
},
|
||||
"./mocks": {
|
||||
"types": "./src/mocks/index.ts",
|
||||
"default": "./src/mocks/index.ts"
|
||||
},
|
||||
"./assertions": {
|
||||
"types": "./src/assertions/index.ts",
|
||||
"default": "./src/assertions/index.ts"
|
||||
},
|
||||
"./setup": {
|
||||
"types": "./src/setup/vitest.setup.ts",
|
||||
"default": "./src/setup/vitest.setup.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
159
packages/testing/src/assertions/execution.assertions.ts
Normal file
159
packages/testing/src/assertions/execution.assertions.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { expect } from 'vitest'
|
||||
import type { ExecutionContext } from '../types'
|
||||
|
||||
/**
|
||||
* Asserts that a block was executed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockExecuted(ctx, 'block-1')
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockExecuted(ctx: ExecutionContext, blockId: string): void {
|
||||
expect(ctx.executedBlocks.has(blockId), `Block "${blockId}" should have been executed`).toBe(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block was NOT executed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockNotExecuted(ctx, 'skipped-block')
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockNotExecuted(ctx: ExecutionContext, blockId: string): void {
|
||||
expect(ctx.executedBlocks.has(blockId), `Block "${blockId}" should not have been executed`).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that blocks were executed in a specific order.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectExecutionOrder(executionLog, ['start', 'step1', 'step2', 'end'])
|
||||
* ```
|
||||
*/
|
||||
export function expectExecutionOrder(executedBlocks: string[], expectedOrder: string[]): void {
|
||||
const actualOrder = executedBlocks.filter((id) => expectedOrder.includes(id))
|
||||
expect(actualOrder, 'Blocks should be executed in expected order').toEqual(expectedOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block has a specific output state.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockOutput(ctx, 'agent-1', { response: 'Hello' })
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockOutput(
|
||||
ctx: ExecutionContext,
|
||||
blockId: string,
|
||||
expectedOutput: Record<string, any>
|
||||
): void {
|
||||
const state = ctx.blockStates.get(blockId)
|
||||
expect(state, `Block "${blockId}" should have state`).toBeDefined()
|
||||
expect(state).toMatchObject(expectedOutput)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that execution has a specific number of logs.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectLogCount(ctx, 5)
|
||||
* ```
|
||||
*/
|
||||
export function expectLogCount(ctx: ExecutionContext, expectedCount: number): void {
|
||||
expect(ctx.blockLogs.length, `Should have ${expectedCount} logs`).toBe(expectedCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a condition decision was made.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectConditionDecision(ctx, 'condition-1', true)
|
||||
* ```
|
||||
*/
|
||||
export function expectConditionDecision(
|
||||
ctx: ExecutionContext,
|
||||
blockId: string,
|
||||
expectedResult: boolean
|
||||
): void {
|
||||
const decision = ctx.decisions.condition.get(blockId)
|
||||
expect(decision, `Condition "${blockId}" should have a decision`).toBeDefined()
|
||||
expect(decision).toBe(expectedResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a loop was completed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectLoopCompleted(ctx, 'loop-1')
|
||||
* ```
|
||||
*/
|
||||
export function expectLoopCompleted(ctx: ExecutionContext, loopId: string): void {
|
||||
expect(ctx.completedLoops.has(loopId), `Loop "${loopId}" should be completed`).toBe(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block is in the active execution path.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectInActivePath(ctx, 'current-block')
|
||||
* ```
|
||||
*/
|
||||
export function expectInActivePath(ctx: ExecutionContext, blockId: string): void {
|
||||
expect(ctx.activeExecutionPath.has(blockId), `Block "${blockId}" should be in active path`).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that execution was cancelled.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectExecutionCancelled(ctx)
|
||||
* ```
|
||||
*/
|
||||
export function expectExecutionCancelled(ctx: ExecutionContext): void {
|
||||
expect(ctx.abortSignal?.aborted, 'Execution should be cancelled').toBe(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that execution was NOT cancelled.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectExecutionNotCancelled(ctx)
|
||||
* ```
|
||||
*/
|
||||
export function expectExecutionNotCancelled(ctx: ExecutionContext): void {
|
||||
expect(ctx.abortSignal?.aborted ?? false, 'Execution should not be cancelled').toBe(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that execution has specific environment variables.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectEnvironmentVariables(ctx, { API_KEY: 'test', MODE: 'production' })
|
||||
* ```
|
||||
*/
|
||||
export function expectEnvironmentVariables(
|
||||
ctx: ExecutionContext,
|
||||
expectedVars: Record<string, string>
|
||||
): void {
|
||||
Object.entries(expectedVars).forEach(([key, value]) => {
|
||||
expect(
|
||||
ctx.environmentVariables[key],
|
||||
`Environment variable "${key}" should be "${value}"`
|
||||
).toBe(value)
|
||||
})
|
||||
}
|
||||
69
packages/testing/src/assertions/index.ts
Normal file
69
packages/testing/src/assertions/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Custom assertions for testing workflows and execution.
|
||||
*
|
||||
* These provide semantic, readable assertions for common test scenarios.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import {
|
||||
* expectBlockExists,
|
||||
* expectEdgeConnects,
|
||||
* expectExecutionOrder,
|
||||
* } from '@sim/testing/assertions'
|
||||
*
|
||||
* // Workflow assertions
|
||||
* expectBlockExists(workflow.blocks, 'agent-1', 'agent')
|
||||
* expectEdgeConnects(workflow.edges, 'start', 'agent-1')
|
||||
*
|
||||
* // Execution assertions
|
||||
* expectBlockExecuted(ctx, 'agent-1')
|
||||
* expectExecutionOrder(log, ['start', 'agent-1', 'end'])
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Execution assertions
|
||||
export {
|
||||
expectBlockExecuted,
|
||||
expectBlockNotExecuted,
|
||||
expectBlockOutput,
|
||||
expectConditionDecision,
|
||||
expectEnvironmentVariables,
|
||||
expectExecutionCancelled,
|
||||
expectExecutionNotCancelled,
|
||||
expectExecutionOrder,
|
||||
expectInActivePath,
|
||||
expectLogCount,
|
||||
expectLoopCompleted,
|
||||
} from './execution.assertions'
|
||||
// Permission assertions
|
||||
export {
|
||||
expectApiKeyInvalid,
|
||||
expectApiKeyValid,
|
||||
expectPermissionAllowed,
|
||||
expectPermissionDenied,
|
||||
expectRoleCannotPerform,
|
||||
expectRoleCanPerform,
|
||||
expectSocketAccessDenied,
|
||||
expectSocketAccessGranted,
|
||||
expectUserHasNoPermission,
|
||||
expectUserHasPermission,
|
||||
expectWorkflowAccessDenied,
|
||||
expectWorkflowAccessGranted,
|
||||
} from './permission.assertions'
|
||||
// Workflow assertions
|
||||
export {
|
||||
expectBlockCount,
|
||||
expectBlockDisabled,
|
||||
expectBlockEnabled,
|
||||
expectBlockExists,
|
||||
expectBlockHasParent,
|
||||
expectBlockNotExists,
|
||||
expectBlockPosition,
|
||||
expectEdgeConnects,
|
||||
expectEdgeCount,
|
||||
expectEmptyWorkflow,
|
||||
expectLinearChain,
|
||||
expectLoopExists,
|
||||
expectNoEdgeBetween,
|
||||
expectParallelExists,
|
||||
} from './workflow.assertions'
|
||||
144
packages/testing/src/assertions/permission.assertions.ts
Normal file
144
packages/testing/src/assertions/permission.assertions.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { expect } from 'vitest'
|
||||
import type { PermissionType } from '../factories/permission.factory'
|
||||
|
||||
/**
|
||||
* Asserts that a permission check result is allowed.
|
||||
*/
|
||||
export function expectPermissionAllowed(result: { allowed: boolean; reason?: string }): void {
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.reason).toBeUndefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a permission check result is denied with a specific reason pattern.
|
||||
*/
|
||||
export function expectPermissionDenied(
|
||||
result: { allowed: boolean; reason?: string },
|
||||
reasonPattern?: string | RegExp
|
||||
): void {
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.reason).toBeDefined()
|
||||
if (reasonPattern) {
|
||||
if (typeof reasonPattern === 'string') {
|
||||
expect(result.reason).toContain(reasonPattern)
|
||||
} else {
|
||||
expect(result.reason).toMatch(reasonPattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a workflow validation result indicates success.
|
||||
*/
|
||||
export function expectWorkflowAccessGranted(result: {
|
||||
error: { message: string; status: number } | null
|
||||
session: unknown
|
||||
workflow: unknown
|
||||
}): void {
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.session).not.toBeNull()
|
||||
expect(result.workflow).not.toBeNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a workflow validation result indicates access denied.
|
||||
*/
|
||||
export function expectWorkflowAccessDenied(
|
||||
result: {
|
||||
error: { message: string; status: number } | null
|
||||
session: unknown
|
||||
workflow: unknown
|
||||
},
|
||||
expectedStatus: 401 | 403 | 404 = 403
|
||||
): void {
|
||||
expect(result.error).not.toBeNull()
|
||||
expect(result.error?.status).toBe(expectedStatus)
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.workflow).toBeNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a user has a specific permission level.
|
||||
*/
|
||||
export function expectUserHasPermission(
|
||||
permissions: Array<{ userId: string; permissionType: PermissionType }>,
|
||||
userId: string,
|
||||
expectedPermission: PermissionType
|
||||
): void {
|
||||
const userPermission = permissions.find((p) => p.userId === userId)
|
||||
expect(userPermission).toBeDefined()
|
||||
expect(userPermission?.permissionType).toBe(expectedPermission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a user has no permission.
|
||||
*/
|
||||
export function expectUserHasNoPermission(
|
||||
permissions: Array<{ userId: string; permissionType: PermissionType }>,
|
||||
userId: string
|
||||
): void {
|
||||
const userPermission = permissions.find((p) => p.userId === userId)
|
||||
expect(userPermission).toBeUndefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a role can perform an operation.
|
||||
*/
|
||||
export function expectRoleCanPerform(
|
||||
checkFn: (role: string, operation: string) => { allowed: boolean },
|
||||
role: string,
|
||||
operation: string
|
||||
): void {
|
||||
const result = checkFn(role, operation)
|
||||
expect(result.allowed).toBe(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a role cannot perform an operation.
|
||||
*/
|
||||
export function expectRoleCannotPerform(
|
||||
checkFn: (role: string, operation: string) => { allowed: boolean },
|
||||
role: string,
|
||||
operation: string
|
||||
): void {
|
||||
const result = checkFn(role, operation)
|
||||
expect(result.allowed).toBe(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts socket workflow access is granted.
|
||||
*/
|
||||
export function expectSocketAccessGranted(result: {
|
||||
hasAccess: boolean
|
||||
role?: string
|
||||
workspaceId?: string
|
||||
}): void {
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.role).toBeDefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts socket workflow access is denied.
|
||||
*/
|
||||
export function expectSocketAccessDenied(result: {
|
||||
hasAccess: boolean
|
||||
role?: string
|
||||
workspaceId?: string
|
||||
}): void {
|
||||
expect(result.hasAccess).toBe(false)
|
||||
expect(result.role).toBeUndefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts API key authentication succeeded.
|
||||
*/
|
||||
export function expectApiKeyValid(result: boolean): void {
|
||||
expect(result).toBe(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts API key authentication failed.
|
||||
*/
|
||||
export function expectApiKeyInvalid(result: boolean): void {
|
||||
expect(result).toBe(false)
|
||||
}
|
||||
244
packages/testing/src/assertions/workflow.assertions.ts
Normal file
244
packages/testing/src/assertions/workflow.assertions.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { expect } from 'vitest'
|
||||
import type { BlockState, Edge, WorkflowState } from '../types'
|
||||
|
||||
/**
|
||||
* Asserts that a block exists in the workflow.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const workflow = createLinearWorkflow(3)
|
||||
* expectBlockExists(workflow.blocks, 'block-0')
|
||||
* expectBlockExists(workflow.blocks, 'block-0', 'starter')
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockExists(
|
||||
blocks: Record<string, BlockState>,
|
||||
blockId: string,
|
||||
expectedType?: string
|
||||
): void {
|
||||
expect(blocks[blockId], `Block "${blockId}" should exist`).toBeDefined()
|
||||
expect(blocks[blockId].id).toBe(blockId)
|
||||
if (expectedType) {
|
||||
expect(blocks[blockId].type, `Block "${blockId}" should be type "${expectedType}"`).toBe(
|
||||
expectedType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block does NOT exist in the workflow.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockNotExists(workflow.blocks, 'deleted-block')
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockNotExists(blocks: Record<string, BlockState>, blockId: string): void {
|
||||
expect(blocks[blockId], `Block "${blockId}" should not exist`).toBeUndefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that an edge connects two blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectEdgeConnects(workflow.edges, 'block-0', 'block-1')
|
||||
* ```
|
||||
*/
|
||||
export function expectEdgeConnects(edges: Edge[], sourceId: string, targetId: string): void {
|
||||
const edge = edges.find((e) => e.source === sourceId && e.target === targetId)
|
||||
expect(edge, `Edge from "${sourceId}" to "${targetId}" should exist`).toBeDefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that no edge connects two blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectNoEdgeBetween(workflow.edges, 'block-1', 'block-0') // No reverse edge
|
||||
* ```
|
||||
*/
|
||||
export function expectNoEdgeBetween(edges: Edge[], sourceId: string, targetId: string): void {
|
||||
const edge = edges.find((e) => e.source === sourceId && e.target === targetId)
|
||||
expect(edge, `Edge from "${sourceId}" to "${targetId}" should not exist`).toBeUndefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block has a specific parent.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockHasParent(workflow.blocks, 'child-block', 'loop-1')
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockHasParent(
|
||||
blocks: Record<string, BlockState>,
|
||||
childId: string,
|
||||
expectedParentId: string
|
||||
): void {
|
||||
const block = blocks[childId]
|
||||
expect(block, `Child block "${childId}" should exist`).toBeDefined()
|
||||
expect(block.data?.parentId, `Block "${childId}" should have parent "${expectedParentId}"`).toBe(
|
||||
expectedParentId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a workflow has a specific number of blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockCount(workflow, 5)
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockCount(workflow: WorkflowState, expectedCount: number): void {
|
||||
const actualCount = Object.keys(workflow.blocks).length
|
||||
expect(actualCount, `Workflow should have ${expectedCount} blocks`).toBe(expectedCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a workflow has a specific number of edges.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectEdgeCount(workflow, 4)
|
||||
* ```
|
||||
*/
|
||||
export function expectEdgeCount(workflow: WorkflowState, expectedCount: number): void {
|
||||
expect(workflow.edges.length, `Workflow should have ${expectedCount} edges`).toBe(expectedCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block is at a specific position.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockPosition(workflow.blocks, 'block-1', { x: 200, y: 0 })
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockPosition(
|
||||
blocks: Record<string, BlockState>,
|
||||
blockId: string,
|
||||
expectedPosition: { x: number; y: number }
|
||||
): void {
|
||||
const block = blocks[blockId]
|
||||
expect(block, `Block "${blockId}" should exist`).toBeDefined()
|
||||
expect(block.position.x, `Block "${blockId}" x position`).toBeCloseTo(expectedPosition.x, 0)
|
||||
expect(block.position.y, `Block "${blockId}" y position`).toBeCloseTo(expectedPosition.y, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block is enabled.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockEnabled(workflow.blocks, 'block-1')
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockEnabled(blocks: Record<string, BlockState>, blockId: string): void {
|
||||
const block = blocks[blockId]
|
||||
expect(block, `Block "${blockId}" should exist`).toBeDefined()
|
||||
expect(block.enabled, `Block "${blockId}" should be enabled`).toBe(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a block is disabled.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectBlockDisabled(workflow.blocks, 'disabled-block')
|
||||
* ```
|
||||
*/
|
||||
export function expectBlockDisabled(blocks: Record<string, BlockState>, blockId: string): void {
|
||||
const block = blocks[blockId]
|
||||
expect(block, `Block "${blockId}" should exist`).toBeDefined()
|
||||
expect(block.enabled, `Block "${blockId}" should be disabled`).toBe(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a workflow has a loop with specific configuration.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectLoopExists(workflow, 'loop-1', { iterations: 5, loopType: 'for' })
|
||||
* ```
|
||||
*/
|
||||
export function expectLoopExists(
|
||||
workflow: WorkflowState,
|
||||
loopId: string,
|
||||
expectedConfig?: { iterations?: number; loopType?: string; nodes?: string[] }
|
||||
): void {
|
||||
const loop = workflow.loops[loopId]
|
||||
expect(loop, `Loop "${loopId}" should exist`).toBeDefined()
|
||||
|
||||
if (expectedConfig) {
|
||||
if (expectedConfig.iterations !== undefined) {
|
||||
expect(loop.iterations).toBe(expectedConfig.iterations)
|
||||
}
|
||||
if (expectedConfig.loopType !== undefined) {
|
||||
expect(loop.loopType).toBe(expectedConfig.loopType)
|
||||
}
|
||||
if (expectedConfig.nodes !== undefined) {
|
||||
expect(loop.nodes).toEqual(expectedConfig.nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a workflow has a parallel block with specific configuration.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectParallelExists(workflow, 'parallel-1', { count: 3 })
|
||||
* ```
|
||||
*/
|
||||
export function expectParallelExists(
|
||||
workflow: WorkflowState,
|
||||
parallelId: string,
|
||||
expectedConfig?: { count?: number; parallelType?: string; nodes?: string[] }
|
||||
): void {
|
||||
const parallel = workflow.parallels[parallelId]
|
||||
expect(parallel, `Parallel "${parallelId}" should exist`).toBeDefined()
|
||||
|
||||
if (expectedConfig) {
|
||||
if (expectedConfig.count !== undefined) {
|
||||
expect(parallel.count).toBe(expectedConfig.count)
|
||||
}
|
||||
if (expectedConfig.parallelType !== undefined) {
|
||||
expect(parallel.parallelType).toBe(expectedConfig.parallelType)
|
||||
}
|
||||
if (expectedConfig.nodes !== undefined) {
|
||||
expect(parallel.nodes).toEqual(expectedConfig.nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the workflow state is empty.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const workflow = createWorkflowState()
|
||||
* expectEmptyWorkflow(workflow)
|
||||
* ```
|
||||
*/
|
||||
export function expectEmptyWorkflow(workflow: WorkflowState): void {
|
||||
expect(Object.keys(workflow.blocks).length, 'Workflow should have no blocks').toBe(0)
|
||||
expect(workflow.edges.length, 'Workflow should have no edges').toBe(0)
|
||||
expect(Object.keys(workflow.loops).length, 'Workflow should have no loops').toBe(0)
|
||||
expect(Object.keys(workflow.parallels).length, 'Workflow should have no parallels').toBe(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that blocks are connected in a linear chain.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expectLinearChain(workflow.edges, ['start', 'step1', 'step2', 'end'])
|
||||
* ```
|
||||
*/
|
||||
export function expectLinearChain(edges: Edge[], blockIds: string[]): void {
|
||||
for (let i = 0; i < blockIds.length - 1; i++) {
|
||||
expectEdgeConnects(edges, blockIds[i], blockIds[i + 1])
|
||||
}
|
||||
}
|
||||
223
packages/testing/src/builders/execution.builder.ts
Normal file
223
packages/testing/src/builders/execution.builder.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { ExecutionContext } from '../types'
|
||||
|
||||
/**
|
||||
* Fluent builder for creating execution contexts.
|
||||
*
|
||||
* Use this for complex execution scenarios where you need
|
||||
* fine-grained control over the context state.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ctx = new ExecutionContextBuilder()
|
||||
* .forWorkflow('my-workflow')
|
||||
* .withBlockState('block-1', { output: 'hello' })
|
||||
* .markExecuted('block-1')
|
||||
* .withEnvironment({ API_KEY: 'test' })
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
export class ExecutionContextBuilder {
|
||||
private workflowId = 'test-workflow'
|
||||
private executionId = `exec-${Math.random().toString(36).substring(2, 10)}`
|
||||
private blockStates = new Map<string, any>()
|
||||
private executedBlocks = new Set<string>()
|
||||
private blockLogs: any[] = []
|
||||
private metadata: { duration: number; startTime?: string; endTime?: string } = { duration: 0 }
|
||||
private environmentVariables: Record<string, string> = {}
|
||||
private workflowVariables: Record<string, any> = {}
|
||||
private routerDecisions = new Map<string, any>()
|
||||
private conditionDecisions = new Map<string, any>()
|
||||
private loopExecutions = new Map<string, any>()
|
||||
private completedLoops = new Set<string>()
|
||||
private activeExecutionPath = new Set<string>()
|
||||
private abortSignal?: AbortSignal
|
||||
|
||||
/**
|
||||
* Sets the workflow ID.
|
||||
*/
|
||||
forWorkflow(workflowId: string): this {
|
||||
this.workflowId = workflowId
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom execution ID.
|
||||
*/
|
||||
withExecutionId(executionId: string): this {
|
||||
this.executionId = executionId
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a block state.
|
||||
*/
|
||||
withBlockState(blockId: string, state: any): this {
|
||||
this.blockStates.set(blockId, state)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds multiple block states at once.
|
||||
*/
|
||||
withBlockStates(states: Record<string, any>): this {
|
||||
Object.entries(states).forEach(([id, state]) => {
|
||||
this.blockStates.set(id, state)
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a block as executed.
|
||||
*/
|
||||
markExecuted(blockId: string): this {
|
||||
this.executedBlocks.add(blockId)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks multiple blocks as executed.
|
||||
*/
|
||||
markAllExecuted(...blockIds: string[]): this {
|
||||
blockIds.forEach((id) => this.executedBlocks.add(id))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a log entry.
|
||||
*/
|
||||
addLog(log: any): this {
|
||||
this.blockLogs.push(log)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets execution metadata.
|
||||
*/
|
||||
withMetadata(metadata: { duration?: number; startTime?: string; endTime?: string }): this {
|
||||
if (metadata.duration !== undefined) this.metadata.duration = metadata.duration
|
||||
if (metadata.startTime) this.metadata.startTime = metadata.startTime
|
||||
if (metadata.endTime) this.metadata.endTime = metadata.endTime
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds environment variables.
|
||||
*/
|
||||
withEnvironment(vars: Record<string, string>): this {
|
||||
this.environmentVariables = { ...this.environmentVariables, ...vars }
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds workflow variables.
|
||||
*/
|
||||
withVariables(vars: Record<string, any>): this {
|
||||
this.workflowVariables = { ...this.workflowVariables, ...vars }
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a router decision.
|
||||
*/
|
||||
withRouterDecision(blockId: string, decision: any): this {
|
||||
this.routerDecisions.set(blockId, decision)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a condition decision.
|
||||
*/
|
||||
withConditionDecision(blockId: string, decision: boolean): this {
|
||||
this.conditionDecisions.set(blockId, decision)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a loop as completed.
|
||||
*/
|
||||
completeLoop(loopId: string): this {
|
||||
this.completedLoops.add(loopId)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a block to the active execution path.
|
||||
*/
|
||||
activatePath(blockId: string): this {
|
||||
this.activeExecutionPath.add(blockId)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an abort signal (for cancellation testing).
|
||||
*/
|
||||
withAbortSignal(signal: AbortSignal): this {
|
||||
this.abortSignal = signal
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a context that is already cancelled.
|
||||
*/
|
||||
cancelled(): this {
|
||||
this.abortSignal = AbortSignal.abort()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a context with a timeout.
|
||||
*/
|
||||
withTimeout(ms: number): this {
|
||||
this.abortSignal = AbortSignal.timeout(ms)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns the execution context.
|
||||
*/
|
||||
build(): ExecutionContext {
|
||||
return {
|
||||
workflowId: this.workflowId,
|
||||
executionId: this.executionId,
|
||||
blockStates: this.blockStates,
|
||||
executedBlocks: this.executedBlocks,
|
||||
blockLogs: this.blockLogs,
|
||||
metadata: this.metadata,
|
||||
environmentVariables: this.environmentVariables,
|
||||
workflowVariables: this.workflowVariables,
|
||||
decisions: {
|
||||
router: this.routerDecisions,
|
||||
condition: this.conditionDecisions,
|
||||
},
|
||||
loopExecutions: this.loopExecutions,
|
||||
completedLoops: this.completedLoops,
|
||||
activeExecutionPath: this.activeExecutionPath,
|
||||
abortSignal: this.abortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh context builder for a workflow.
|
||||
*/
|
||||
static createForWorkflow(workflowId: string): ExecutionContextBuilder {
|
||||
return new ExecutionContextBuilder().forWorkflow(workflowId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cancelled context.
|
||||
*/
|
||||
static createCancelled(workflowId?: string): ExecutionContext {
|
||||
const builder = new ExecutionContextBuilder()
|
||||
if (workflowId) builder.forWorkflow(workflowId)
|
||||
return builder.cancelled().build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a context with a timeout.
|
||||
*/
|
||||
static createWithTimeout(ms: number, workflowId?: string): ExecutionContext {
|
||||
const builder = new ExecutionContextBuilder()
|
||||
if (workflowId) builder.forWorkflow(workflowId)
|
||||
return builder.withTimeout(ms).build()
|
||||
}
|
||||
}
|
||||
21
packages/testing/src/builders/index.ts
Normal file
21
packages/testing/src/builders/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Builder classes for fluent test data construction.
|
||||
*
|
||||
* Use builders when you need fine-grained control over complex objects.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { WorkflowBuilder, ExecutionContextBuilder } from '@sim/testing/builders'
|
||||
*
|
||||
* // Build a workflow
|
||||
* const workflow = WorkflowBuilder.linear(3).build()
|
||||
*
|
||||
* // Build an execution context
|
||||
* const ctx = ExecutionContextBuilder.forWorkflow('my-wf')
|
||||
* .withBlockState('block-1', { output: 'hello' })
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { ExecutionContextBuilder } from './execution.builder'
|
||||
export { WorkflowBuilder } from './workflow.builder'
|
||||
356
packages/testing/src/builders/workflow.builder.ts
Normal file
356
packages/testing/src/builders/workflow.builder.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
createAgentBlock,
|
||||
createBlock,
|
||||
createFunctionBlock,
|
||||
createStarterBlock,
|
||||
} from '../factories/block.factory'
|
||||
import type { BlockState, Edge, Loop, Parallel, Position, WorkflowState } from '../types'
|
||||
|
||||
/**
|
||||
* Fluent builder for creating complex workflow states.
|
||||
*
|
||||
* Use this when you need fine-grained control over workflow construction,
|
||||
* especially for testing edge cases or complex scenarios.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Simple linear workflow
|
||||
* const workflow = new WorkflowBuilder()
|
||||
* .addStarter('start')
|
||||
* .addAgent('agent', { x: 200, y: 0 })
|
||||
* .addFunction('end', { x: 400, y: 0 })
|
||||
* .connect('start', 'agent')
|
||||
* .connect('agent', 'end')
|
||||
* .build()
|
||||
*
|
||||
* // Using static presets
|
||||
* const workflow = WorkflowBuilder.linear(5).build()
|
||||
* const workflow = WorkflowBuilder.branching().build()
|
||||
* ```
|
||||
*/
|
||||
export class WorkflowBuilder {
|
||||
private blocks: Record<string, BlockState> = {}
|
||||
private edges: Edge[] = []
|
||||
private loops: Record<string, Loop> = {}
|
||||
private parallels: Record<string, Parallel> = {}
|
||||
private variables: WorkflowState['variables'] = []
|
||||
private isDeployed = false
|
||||
|
||||
/**
|
||||
* Adds a generic block to the workflow.
|
||||
*/
|
||||
addBlock(id: string, type: string, position?: Position, name?: string): this {
|
||||
this.blocks[id] = createBlock({
|
||||
id,
|
||||
type,
|
||||
name: name ?? id,
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a starter block (workflow entry point).
|
||||
*/
|
||||
addStarter(id = 'start', position?: Position): this {
|
||||
this.blocks[id] = createStarterBlock({
|
||||
id,
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a function block.
|
||||
*/
|
||||
addFunction(id: string, position?: Position, name?: string): this {
|
||||
this.blocks[id] = createFunctionBlock({
|
||||
id,
|
||||
name: name ?? id,
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an agent block.
|
||||
*/
|
||||
addAgent(id: string, position?: Position, name?: string): this {
|
||||
this.blocks[id] = createAgentBlock({
|
||||
id,
|
||||
name: name ?? id,
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a condition block.
|
||||
*/
|
||||
addCondition(id: string, position?: Position, name?: string): this {
|
||||
this.blocks[id] = createBlock({
|
||||
id,
|
||||
type: 'condition',
|
||||
name: name ?? id,
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a loop container block.
|
||||
*/
|
||||
addLoop(
|
||||
id: string,
|
||||
position?: Position,
|
||||
config?: {
|
||||
iterations?: number
|
||||
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
}
|
||||
): this {
|
||||
this.blocks[id] = createBlock({
|
||||
id,
|
||||
type: 'loop',
|
||||
name: 'Loop',
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
data: {
|
||||
loopType: config?.loopType ?? 'for',
|
||||
count: config?.iterations ?? 3,
|
||||
type: 'loop',
|
||||
},
|
||||
})
|
||||
this.loops[id] = {
|
||||
id,
|
||||
nodes: [],
|
||||
iterations: config?.iterations ?? 3,
|
||||
loopType: config?.loopType ?? 'for',
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a block as a child of a loop container.
|
||||
*/
|
||||
addLoopChild(loopId: string, childId: string, type = 'function', position?: Position): this {
|
||||
if (!this.loops[loopId]) {
|
||||
throw new Error(`Loop ${loopId} does not exist. Call addLoop first.`)
|
||||
}
|
||||
|
||||
this.blocks[childId] = createBlock({
|
||||
id: childId,
|
||||
type,
|
||||
name: childId,
|
||||
position: position ?? { x: 50, y: 50 },
|
||||
parentId: loopId,
|
||||
})
|
||||
|
||||
this.loops[loopId].nodes.push(childId)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a parallel container block.
|
||||
*/
|
||||
addParallel(
|
||||
id: string,
|
||||
position?: Position,
|
||||
config?: {
|
||||
count?: number
|
||||
parallelType?: 'count' | 'collection'
|
||||
}
|
||||
): this {
|
||||
this.blocks[id] = createBlock({
|
||||
id,
|
||||
type: 'parallel',
|
||||
name: 'Parallel',
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
data: {
|
||||
parallelType: config?.parallelType ?? 'count',
|
||||
count: config?.count ?? 2,
|
||||
type: 'parallel',
|
||||
},
|
||||
})
|
||||
this.parallels[id] = {
|
||||
id,
|
||||
nodes: [],
|
||||
count: config?.count ?? 2,
|
||||
parallelType: config?.parallelType ?? 'count',
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a block as a child of a parallel container.
|
||||
*/
|
||||
addParallelChild(
|
||||
parallelId: string,
|
||||
childId: string,
|
||||
type = 'function',
|
||||
position?: Position
|
||||
): this {
|
||||
if (!this.parallels[parallelId]) {
|
||||
throw new Error(`Parallel ${parallelId} does not exist. Call addParallel first.`)
|
||||
}
|
||||
|
||||
this.blocks[childId] = createBlock({
|
||||
id: childId,
|
||||
type,
|
||||
name: childId,
|
||||
position: position ?? { x: 50, y: 50 },
|
||||
parentId: parallelId,
|
||||
})
|
||||
|
||||
this.parallels[parallelId].nodes.push(childId)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an edge connecting two blocks.
|
||||
*/
|
||||
connect(sourceId: string, targetId: string, sourceHandle?: string, targetHandle?: string): this {
|
||||
this.edges.push({
|
||||
id: `${sourceId}-${targetId}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a workflow variable.
|
||||
*/
|
||||
addVariable(
|
||||
name: string,
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain',
|
||||
value: any
|
||||
): this {
|
||||
this.variables?.push({
|
||||
id: `var-${Math.random().toString(36).substring(2, 8)}`,
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the workflow as deployed.
|
||||
*/
|
||||
setDeployed(deployed = true): this {
|
||||
this.isDeployed = deployed
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns the workflow state.
|
||||
*/
|
||||
build(): WorkflowState {
|
||||
return {
|
||||
blocks: this.blocks,
|
||||
edges: this.edges,
|
||||
loops: this.loops,
|
||||
parallels: this.parallels,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: this.isDeployed,
|
||||
variables: this.variables?.length ? this.variables : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workflow with the specified blocks and connects them linearly.
|
||||
*/
|
||||
static chain(...blockConfigs: Array<{ id: string; type: string }>): WorkflowBuilder {
|
||||
const builder = new WorkflowBuilder()
|
||||
let x = 0
|
||||
const spacing = 200
|
||||
|
||||
blockConfigs.forEach((config, index) => {
|
||||
builder.addBlock(config.id, config.type, { x, y: 0 })
|
||||
x += spacing
|
||||
|
||||
if (index > 0) {
|
||||
builder.connect(blockConfigs[index - 1].id, config.id)
|
||||
}
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a linear workflow with N blocks.
|
||||
* First block is a starter, rest are function blocks.
|
||||
*/
|
||||
static linear(blockCount: number): WorkflowBuilder {
|
||||
const builder = new WorkflowBuilder()
|
||||
const spacing = 200
|
||||
|
||||
for (let i = 0; i < blockCount; i++) {
|
||||
const id = `block-${i}`
|
||||
const position = { x: i * spacing, y: 0 }
|
||||
|
||||
if (i === 0) {
|
||||
builder.addStarter(id, position)
|
||||
} else {
|
||||
builder.addFunction(id, position, `Step ${i}`)
|
||||
}
|
||||
|
||||
if (i > 0) {
|
||||
builder.connect(`block-${i - 1}`, id)
|
||||
}
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a branching workflow with a condition.
|
||||
*
|
||||
* Structure:
|
||||
* ```
|
||||
* ┌─→ true ─┐
|
||||
* start ─→ cond ├─→ end
|
||||
* └─→ false ┘
|
||||
* ```
|
||||
*/
|
||||
static branching(): WorkflowBuilder {
|
||||
return new WorkflowBuilder()
|
||||
.addStarter('start', { x: 0, y: 0 })
|
||||
.addCondition('condition', { x: 200, y: 0 })
|
||||
.addFunction('true-branch', { x: 400, y: -100 }, 'If True')
|
||||
.addFunction('false-branch', { x: 400, y: 100 }, 'If False')
|
||||
.addFunction('end', { x: 600, y: 0 }, 'End')
|
||||
.connect('start', 'condition')
|
||||
.connect('condition', 'true-branch', 'condition-if')
|
||||
.connect('condition', 'false-branch', 'condition-else')
|
||||
.connect('true-branch', 'end')
|
||||
.connect('false-branch', 'end')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workflow with a loop.
|
||||
*/
|
||||
static withLoop(iterations = 3): WorkflowBuilder {
|
||||
return new WorkflowBuilder()
|
||||
.addStarter('start', { x: 0, y: 0 })
|
||||
.addLoop('loop', { x: 200, y: 0 }, { iterations })
|
||||
.addLoopChild('loop', 'loop-body', 'function', { x: 50, y: 50 })
|
||||
.addFunction('end', { x: 500, y: 0 })
|
||||
.connect('start', 'loop')
|
||||
.connect('loop', 'end')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workflow with parallel execution.
|
||||
*/
|
||||
static withParallel(count = 2): WorkflowBuilder {
|
||||
return new WorkflowBuilder()
|
||||
.addStarter('start', { x: 0, y: 0 })
|
||||
.addParallel('parallel', { x: 200, y: 0 }, { count })
|
||||
.addParallelChild('parallel', 'parallel-task', 'function', { x: 50, y: 50 })
|
||||
.addFunction('end', { x: 500, y: 0 })
|
||||
.connect('start', 'parallel')
|
||||
.connect('parallel', 'end')
|
||||
}
|
||||
}
|
||||
217
packages/testing/src/factories/block.factory.ts
Normal file
217
packages/testing/src/factories/block.factory.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { BlockData, BlockOutput, BlockState, Position, SubBlockState } from '../types'
|
||||
|
||||
/**
|
||||
* Options for creating a mock block.
|
||||
* All fields are optional - sensible defaults are provided.
|
||||
*/
|
||||
export interface BlockFactoryOptions {
|
||||
id?: string
|
||||
type?: string
|
||||
name?: string
|
||||
position?: Position
|
||||
subBlocks?: Record<string, SubBlockState>
|
||||
outputs?: Record<string, BlockOutput>
|
||||
enabled?: boolean
|
||||
horizontalHandles?: boolean
|
||||
height?: number
|
||||
advancedMode?: boolean
|
||||
triggerMode?: boolean
|
||||
data?: BlockData
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique block ID.
|
||||
*/
|
||||
function generateBlockId(prefix = 'block'): string {
|
||||
return `${prefix}-${Math.random().toString(36).substring(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock block with sensible defaults.
|
||||
* Override any property as needed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Basic block
|
||||
* const block = createBlock({ type: 'agent' })
|
||||
*
|
||||
* // Block with specific position
|
||||
* const block = createBlock({ type: 'function', position: { x: 100, y: 200 } })
|
||||
*
|
||||
* // Block with parent (for loops/parallels)
|
||||
* const block = createBlock({ type: 'function', parentId: 'loop-1' })
|
||||
* ```
|
||||
*/
|
||||
export function createBlock(options: BlockFactoryOptions = {}): BlockState {
|
||||
const id = options.id ?? generateBlockId(options.type ?? 'block')
|
||||
|
||||
const data: BlockData = options.data ?? {}
|
||||
if (options.parentId) {
|
||||
data.parentId = options.parentId
|
||||
data.extent = 'parent'
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type: options.type ?? 'function',
|
||||
name: options.name ?? `Block ${id.substring(0, 8)}`,
|
||||
position: options.position ?? { x: 0, y: 0 },
|
||||
subBlocks: options.subBlocks ?? {},
|
||||
outputs: options.outputs ?? {},
|
||||
enabled: options.enabled ?? true,
|
||||
horizontalHandles: options.horizontalHandles ?? true,
|
||||
height: options.height ?? 0,
|
||||
advancedMode: options.advancedMode ?? false,
|
||||
triggerMode: options.triggerMode ?? false,
|
||||
data: Object.keys(data).length > 0 ? data : undefined,
|
||||
layout: {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a starter block (workflow entry point).
|
||||
*/
|
||||
export function createStarterBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'starter',
|
||||
name: options.name ?? 'Start',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an agent block (AI agent execution).
|
||||
*/
|
||||
export function createAgentBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'agent',
|
||||
name: options.name ?? 'Agent',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a function block (code execution).
|
||||
*/
|
||||
export function createFunctionBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'function',
|
||||
name: options.name ?? 'Function',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a condition block (branching logic).
|
||||
*/
|
||||
export function createConditionBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'condition',
|
||||
name: options.name ?? 'Condition',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a loop block (iteration container).
|
||||
*/
|
||||
export function createLoopBlock(
|
||||
options: Omit<BlockFactoryOptions, 'type'> & {
|
||||
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
count?: number
|
||||
} = {}
|
||||
): BlockState {
|
||||
const data: BlockData = {
|
||||
...options.data,
|
||||
loopType: options.loopType ?? 'for',
|
||||
count: options.count ?? 3,
|
||||
type: 'loop',
|
||||
}
|
||||
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'loop',
|
||||
name: options.name ?? 'Loop',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a parallel block (concurrent execution container).
|
||||
*/
|
||||
export function createParallelBlock(
|
||||
options: Omit<BlockFactoryOptions, 'type'> & {
|
||||
parallelType?: 'count' | 'collection'
|
||||
count?: number
|
||||
} = {}
|
||||
): BlockState {
|
||||
const data: BlockData = {
|
||||
...options.data,
|
||||
parallelType: options.parallelType ?? 'count',
|
||||
count: options.count ?? 2,
|
||||
type: 'parallel',
|
||||
}
|
||||
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'parallel',
|
||||
name: options.name ?? 'Parallel',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a router block (output routing).
|
||||
*/
|
||||
export function createRouterBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'router',
|
||||
name: options.name ?? 'Router',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an API block (HTTP requests).
|
||||
*/
|
||||
export function createApiBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'api',
|
||||
name: options.name ?? 'API',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a response block (workflow output).
|
||||
*/
|
||||
export function createResponseBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'response',
|
||||
name: options.name ?? 'Response',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a webhook trigger block.
|
||||
*/
|
||||
export function createWebhookBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'webhook',
|
||||
name: options.name ?? 'Webhook',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a knowledge block (vector search).
|
||||
*/
|
||||
export function createKnowledgeBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): BlockState {
|
||||
return createBlock({
|
||||
...options,
|
||||
type: 'knowledge',
|
||||
name: options.name ?? 'Knowledge',
|
||||
})
|
||||
}
|
||||
191
packages/testing/src/factories/dag.factory.ts
Normal file
191
packages/testing/src/factories/dag.factory.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Factory functions for creating DAG (Directed Acyclic Graph) test fixtures.
|
||||
* These are used in executor tests for DAG construction and edge management.
|
||||
*/
|
||||
|
||||
import { createSerializedBlock, type SerializedBlock } from './serialized-block.factory'
|
||||
|
||||
/**
|
||||
* DAG edge structure.
|
||||
*/
|
||||
export interface DAGEdge {
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* DAG node structure.
|
||||
*/
|
||||
export interface DAGNode {
|
||||
id: string
|
||||
block: SerializedBlock
|
||||
outgoingEdges: Map<string, DAGEdge>
|
||||
incomingEdges: Set<string>
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* DAG structure.
|
||||
*/
|
||||
export interface DAG {
|
||||
nodes: Map<string, DAGNode>
|
||||
loopConfigs: Map<string, any>
|
||||
parallelConfigs: Map<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a DAG node.
|
||||
*/
|
||||
export interface DAGNodeFactoryOptions {
|
||||
id?: string
|
||||
type?: string
|
||||
block?: SerializedBlock
|
||||
outgoingEdges?: DAGEdge[]
|
||||
incomingEdges?: string[]
|
||||
metadata?: Record<string, any>
|
||||
params?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DAG node with sensible defaults.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const node = createDAGNode({ id: 'block-1' })
|
||||
*
|
||||
* // With outgoing edges
|
||||
* const node = createDAGNode({
|
||||
* id: 'start',
|
||||
* outgoingEdges: [{ target: 'end' }]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createDAGNode(options: DAGNodeFactoryOptions = {}): DAGNode {
|
||||
const id = options.id ?? `node-${Math.random().toString(36).substring(2, 8)}`
|
||||
const block =
|
||||
options.block ??
|
||||
createSerializedBlock({
|
||||
id,
|
||||
type: options.type ?? 'function',
|
||||
params: options.params,
|
||||
})
|
||||
|
||||
const outgoingEdges = new Map<string, DAGEdge>()
|
||||
if (options.outgoingEdges) {
|
||||
options.outgoingEdges.forEach((edge, i) => {
|
||||
outgoingEdges.set(`edge-${i}`, edge)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
block,
|
||||
outgoingEdges,
|
||||
incomingEdges: new Set(options.incomingEdges ?? []),
|
||||
metadata: options.metadata ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DAG structure from a list of node IDs.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const dag = createDAG(['block-1', 'block-2', 'block-3'])
|
||||
* ```
|
||||
*/
|
||||
export function createDAG(nodeIds: string[]): DAG {
|
||||
const nodes = new Map<string, DAGNode>()
|
||||
for (const id of nodeIds) {
|
||||
nodes.set(id, createDAGNode({ id }))
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DAG from a node configuration array.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const dag = createDAGFromNodes([
|
||||
* { id: 'start', outgoingEdges: [{ target: 'middle' }] },
|
||||
* { id: 'middle', outgoingEdges: [{ target: 'end' }], incomingEdges: ['start'] },
|
||||
* { id: 'end', incomingEdges: ['middle'] }
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export function createDAGFromNodes(nodeConfigs: DAGNodeFactoryOptions[]): DAG {
|
||||
const nodes = new Map<string, DAGNode>()
|
||||
for (const config of nodeConfigs) {
|
||||
const node = createDAGNode(config)
|
||||
nodes.set(node.id, node)
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a linear DAG where each node connects to the next.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Creates A -> B -> C
|
||||
* const dag = createLinearDAG(['A', 'B', 'C'])
|
||||
* ```
|
||||
*/
|
||||
export function createLinearDAG(nodeIds: string[]): DAG {
|
||||
const nodes = new Map<string, DAGNode>()
|
||||
|
||||
for (let i = 0; i < nodeIds.length; i++) {
|
||||
const id = nodeIds[i]
|
||||
const outgoingEdges: DAGEdge[] = i < nodeIds.length - 1 ? [{ target: nodeIds[i + 1] }] : []
|
||||
const incomingEdges = i > 0 ? [nodeIds[i - 1]] : []
|
||||
|
||||
nodes.set(id, createDAGNode({ id, outgoingEdges, incomingEdges }))
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a node to an existing DAG.
|
||||
*/
|
||||
export function addNodeToDAG(dag: DAG, node: DAGNode): DAG {
|
||||
dag.nodes.set(node.id, node)
|
||||
return dag
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects two nodes in a DAG with an edge.
|
||||
*/
|
||||
export function connectDAGNodes(
|
||||
dag: DAG,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
sourceHandle?: string
|
||||
): DAG {
|
||||
const sourceNode = dag.nodes.get(sourceId)
|
||||
const targetNode = dag.nodes.get(targetId)
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const edgeId = sourceHandle
|
||||
? `${sourceId}→${targetId}-${sourceHandle}`
|
||||
: `${sourceId}→${targetId}`
|
||||
sourceNode.outgoingEdges.set(edgeId, { target: targetId, sourceHandle })
|
||||
targetNode.incomingEdges.add(sourceId)
|
||||
}
|
||||
|
||||
return dag
|
||||
}
|
||||
88
packages/testing/src/factories/edge.factory.ts
Normal file
88
packages/testing/src/factories/edge.factory.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Edge } from '../types'
|
||||
|
||||
/**
|
||||
* Options for creating a mock edge.
|
||||
*/
|
||||
export interface EdgeFactoryOptions {
|
||||
id?: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
type?: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an edge ID from source and target.
|
||||
*/
|
||||
function generateEdgeId(source: string, target: string): string {
|
||||
return `${source}-${target}-${Math.random().toString(36).substring(2, 6)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock edge connecting two blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Simple edge
|
||||
* const edge = createEdge({ source: 'block-1', target: 'block-2' })
|
||||
*
|
||||
* // Edge with specific handles
|
||||
* const edge = createEdge({
|
||||
* source: 'condition-1',
|
||||
* target: 'block-2',
|
||||
* sourceHandle: 'condition-if'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createEdge(options: EdgeFactoryOptions): Edge {
|
||||
return {
|
||||
id: options.id ?? generateEdgeId(options.source, options.target),
|
||||
source: options.source,
|
||||
target: options.target,
|
||||
sourceHandle: options.sourceHandle,
|
||||
targetHandle: options.targetHandle,
|
||||
type: options.type ?? 'default',
|
||||
data: options.data,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple edges from a connection specification.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const edges = createEdges([
|
||||
* { source: 'start', target: 'agent' },
|
||||
* { source: 'agent', target: 'end' },
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export function createEdges(
|
||||
connections: Array<{
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}>
|
||||
): Edge[] {
|
||||
return connections.map((conn) => createEdge(conn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a linear chain of edges connecting blocks in order.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Creates edges: a->b, b->c, c->d
|
||||
* const edges = createLinearEdges(['a', 'b', 'c', 'd'])
|
||||
* ```
|
||||
*/
|
||||
export function createLinearEdges(blockIds: string[]): Edge[] {
|
||||
const edges: Edge[] = []
|
||||
for (let i = 0; i < blockIds.length - 1; i++) {
|
||||
edges.push(createEdge({ source: blockIds[i], target: blockIds[i + 1] }))
|
||||
}
|
||||
return edges
|
||||
}
|
||||
113
packages/testing/src/factories/execution.factory.ts
Normal file
113
packages/testing/src/factories/execution.factory.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ExecutionContext } from '../types'
|
||||
|
||||
/**
|
||||
* Options for creating a mock execution context.
|
||||
*/
|
||||
export interface ExecutionContextFactoryOptions {
|
||||
workflowId?: string
|
||||
executionId?: string
|
||||
blockStates?: Map<string, any>
|
||||
executedBlocks?: Set<string>
|
||||
blockLogs?: any[]
|
||||
metadata?: {
|
||||
duration?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
environmentVariables?: Record<string, string>
|
||||
workflowVariables?: Record<string, any>
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock execution context for testing workflow execution.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ctx = createExecutionContext({ workflowId: 'test-wf' })
|
||||
*
|
||||
* // With abort signal
|
||||
* const ctx = createExecutionContext({
|
||||
* workflowId: 'test-wf',
|
||||
* abortSignal: AbortSignal.abort(),
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createExecutionContext(
|
||||
options: ExecutionContextFactoryOptions = {}
|
||||
): ExecutionContext {
|
||||
return {
|
||||
workflowId: options.workflowId ?? 'test-workflow',
|
||||
executionId: options.executionId ?? `exec-${Math.random().toString(36).substring(2, 10)}`,
|
||||
blockStates: options.blockStates ?? new Map(),
|
||||
executedBlocks: options.executedBlocks ?? new Set(),
|
||||
blockLogs: options.blockLogs ?? [],
|
||||
metadata: {
|
||||
duration: options.metadata?.duration ?? 0,
|
||||
startTime: options.metadata?.startTime ?? new Date().toISOString(),
|
||||
endTime: options.metadata?.endTime,
|
||||
},
|
||||
environmentVariables: options.environmentVariables ?? {},
|
||||
workflowVariables: options.workflowVariables ?? {},
|
||||
decisions: {
|
||||
router: new Map(),
|
||||
condition: new Map(),
|
||||
},
|
||||
loopExecutions: new Map(),
|
||||
completedLoops: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
abortSignal: options.abortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an execution context with pre-populated block states.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ctx = createExecutionContextWithStates({
|
||||
* 'block-1': { output: 'hello' },
|
||||
* 'block-2': { output: 'world' },
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createExecutionContextWithStates(
|
||||
blockStates: Record<string, any>,
|
||||
options: Omit<ExecutionContextFactoryOptions, 'blockStates'> = {}
|
||||
): ExecutionContext {
|
||||
const stateMap = new Map(Object.entries(blockStates))
|
||||
return createExecutionContext({
|
||||
...options,
|
||||
blockStates: stateMap,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an execution context that is already cancelled.
|
||||
*/
|
||||
export function createCancelledExecutionContext(
|
||||
options: Omit<ExecutionContextFactoryOptions, 'abortSignal'> = {}
|
||||
): ExecutionContext {
|
||||
return createExecutionContext({
|
||||
...options,
|
||||
abortSignal: AbortSignal.abort(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an execution context with a timeout.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ctx = createTimedExecutionContext(5000) // 5 second timeout
|
||||
* ```
|
||||
*/
|
||||
export function createTimedExecutionContext(
|
||||
timeoutMs: number,
|
||||
options: Omit<ExecutionContextFactoryOptions, 'abortSignal'> = {}
|
||||
): ExecutionContext {
|
||||
return createExecutionContext({
|
||||
...options,
|
||||
abortSignal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
}
|
||||
205
packages/testing/src/factories/executor-context.factory.ts
Normal file
205
packages/testing/src/factories/executor-context.factory.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Factory functions for creating ExecutionContext test fixtures for executor tests.
|
||||
* This is the executor-specific context, different from the generic testing context.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SerializedBlock,
|
||||
SerializedConnection,
|
||||
SerializedWorkflow,
|
||||
} from './serialized-block.factory'
|
||||
|
||||
/**
|
||||
* Block state in execution context.
|
||||
*/
|
||||
export interface ExecutorBlockState {
|
||||
output: Record<string, any>
|
||||
executed: boolean
|
||||
executionTime: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution context for executor tests.
|
||||
*/
|
||||
export interface ExecutorContext {
|
||||
workflowId: string
|
||||
workspaceId?: string
|
||||
executionId?: string
|
||||
userId?: string
|
||||
blockStates: Map<string, ExecutorBlockState>
|
||||
executedBlocks: Set<string>
|
||||
blockLogs: any[]
|
||||
metadata: {
|
||||
duration: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
environmentVariables: Record<string, string>
|
||||
workflowVariables?: Record<string, any>
|
||||
decisions: {
|
||||
router: Map<string, string>
|
||||
condition: Map<string, string>
|
||||
}
|
||||
loopExecutions: Map<string, any>
|
||||
completedLoops: Set<string>
|
||||
activeExecutionPath: Set<string>
|
||||
workflow?: SerializedWorkflow
|
||||
currentVirtualBlockId?: string
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an executor context.
|
||||
*/
|
||||
export interface ExecutorContextFactoryOptions {
|
||||
workflowId?: string
|
||||
workspaceId?: string
|
||||
executionId?: string
|
||||
userId?: string
|
||||
blockStates?: Map<string, ExecutorBlockState> | Record<string, ExecutorBlockState>
|
||||
executedBlocks?: Set<string> | string[]
|
||||
blockLogs?: any[]
|
||||
metadata?: {
|
||||
duration?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
environmentVariables?: Record<string, string>
|
||||
workflowVariables?: Record<string, any>
|
||||
workflow?: SerializedWorkflow
|
||||
currentVirtualBlockId?: string
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an executor context with sensible defaults.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ctx = createExecutorContext({ workflowId: 'test-wf' })
|
||||
*
|
||||
* // With pre-populated block states
|
||||
* const ctx = createExecutorContext({
|
||||
* blockStates: {
|
||||
* 'block-1': { output: { value: 10 }, executed: true, executionTime: 100 }
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createExecutorContext(
|
||||
options: ExecutorContextFactoryOptions = {}
|
||||
): ExecutorContext {
|
||||
let blockStates: Map<string, ExecutorBlockState>
|
||||
if (options.blockStates instanceof Map) {
|
||||
blockStates = options.blockStates
|
||||
} else if (options.blockStates) {
|
||||
blockStates = new Map(Object.entries(options.blockStates))
|
||||
} else {
|
||||
blockStates = new Map()
|
||||
}
|
||||
|
||||
let executedBlocks: Set<string>
|
||||
if (options.executedBlocks instanceof Set) {
|
||||
executedBlocks = options.executedBlocks
|
||||
} else if (Array.isArray(options.executedBlocks)) {
|
||||
executedBlocks = new Set(options.executedBlocks)
|
||||
} else {
|
||||
executedBlocks = new Set()
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId: options.workflowId ?? 'test-workflow-id',
|
||||
workspaceId: options.workspaceId ?? 'test-workspace-id',
|
||||
executionId: options.executionId,
|
||||
userId: options.userId,
|
||||
blockStates,
|
||||
executedBlocks,
|
||||
blockLogs: options.blockLogs ?? [],
|
||||
metadata: {
|
||||
duration: options.metadata?.duration ?? 0,
|
||||
startTime: options.metadata?.startTime,
|
||||
endTime: options.metadata?.endTime,
|
||||
},
|
||||
environmentVariables: options.environmentVariables ?? {},
|
||||
workflowVariables: options.workflowVariables,
|
||||
decisions: {
|
||||
router: new Map(),
|
||||
condition: new Map(),
|
||||
},
|
||||
loopExecutions: new Map(),
|
||||
completedLoops: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
workflow: options.workflow,
|
||||
currentVirtualBlockId: options.currentVirtualBlockId,
|
||||
abortSignal: options.abortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an executor context with pre-executed blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ctx = createExecutorContextWithBlocks({
|
||||
* 'source-block': { value: 10, text: 'hello' },
|
||||
* 'other-block': { result: true }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createExecutorContextWithBlocks(
|
||||
blockOutputs: Record<string, Record<string, any>>,
|
||||
options: Omit<ExecutorContextFactoryOptions, 'blockStates' | 'executedBlocks'> = {}
|
||||
): ExecutorContext {
|
||||
const blockStates = new Map<string, ExecutorBlockState>()
|
||||
const executedBlocks = new Set<string>()
|
||||
|
||||
for (const [blockId, output] of Object.entries(blockOutputs)) {
|
||||
blockStates.set(blockId, {
|
||||
output,
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
executedBlocks.add(blockId)
|
||||
}
|
||||
|
||||
return createExecutorContext({
|
||||
...options,
|
||||
blockStates,
|
||||
executedBlocks,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a block state to an existing context.
|
||||
* Returns the context for chaining.
|
||||
*/
|
||||
export function addBlockState(
|
||||
ctx: ExecutorContext,
|
||||
blockId: string,
|
||||
output: Record<string, any>,
|
||||
executionTime = 100
|
||||
): ExecutorContext {
|
||||
;(ctx.blockStates as Map<string, ExecutorBlockState>).set(blockId, {
|
||||
output,
|
||||
executed: true,
|
||||
executionTime,
|
||||
})
|
||||
;(ctx.executedBlocks as Set<string>).add(blockId)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal workflow for context.
|
||||
*/
|
||||
export function createMinimalWorkflow(
|
||||
blocks: SerializedBlock[],
|
||||
connections: SerializedConnection[] = []
|
||||
): SerializedWorkflow {
|
||||
return {
|
||||
version: '1.0',
|
||||
blocks,
|
||||
connections,
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
160
packages/testing/src/factories/index.ts
Normal file
160
packages/testing/src/factories/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Factory functions for creating test fixtures.
|
||||
*
|
||||
* Use these to create mock data with sensible defaults.
|
||||
* All functions allow overriding any field.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import {
|
||||
* createBlock,
|
||||
* createStarterBlock,
|
||||
* createAgentBlock,
|
||||
* createLinearWorkflow,
|
||||
* createExecutionContext,
|
||||
* } from '@sim/testing/factories'
|
||||
*
|
||||
* // Create a simple workflow
|
||||
* const workflow = createLinearWorkflow(3)
|
||||
*
|
||||
* // Create a specific block
|
||||
* const agent = createAgentBlock({ id: 'my-agent', position: { x: 100, y: 200 } })
|
||||
*
|
||||
* // Create execution context
|
||||
* const ctx = createExecutionContext({ workflowId: 'test' })
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Block factories
|
||||
export {
|
||||
type BlockFactoryOptions,
|
||||
createAgentBlock,
|
||||
createApiBlock,
|
||||
createBlock,
|
||||
createConditionBlock,
|
||||
createFunctionBlock,
|
||||
createKnowledgeBlock,
|
||||
createLoopBlock,
|
||||
createParallelBlock,
|
||||
createResponseBlock,
|
||||
createRouterBlock,
|
||||
createStarterBlock,
|
||||
createWebhookBlock,
|
||||
} from './block.factory'
|
||||
// DAG factories (for executor DAG tests)
|
||||
export {
|
||||
addNodeToDAG,
|
||||
connectDAGNodes,
|
||||
createDAG,
|
||||
createDAGFromNodes,
|
||||
createDAGNode,
|
||||
createLinearDAG,
|
||||
type DAG,
|
||||
type DAGEdge,
|
||||
type DAGNode,
|
||||
type DAGNodeFactoryOptions,
|
||||
} from './dag.factory'
|
||||
// Edge factories
|
||||
export { createEdge, createEdges, createLinearEdges, type EdgeFactoryOptions } from './edge.factory'
|
||||
// Execution factories
|
||||
export {
|
||||
createCancelledExecutionContext,
|
||||
createExecutionContext,
|
||||
createExecutionContextWithStates,
|
||||
createTimedExecutionContext,
|
||||
type ExecutionContextFactoryOptions,
|
||||
} from './execution.factory'
|
||||
// Executor context factories (for executor tests)
|
||||
export {
|
||||
addBlockState,
|
||||
createExecutorContext,
|
||||
createExecutorContextWithBlocks,
|
||||
createMinimalWorkflow,
|
||||
type ExecutorBlockState,
|
||||
type ExecutorContext,
|
||||
type ExecutorContextFactoryOptions,
|
||||
} from './executor-context.factory'
|
||||
// Permission factories
|
||||
export {
|
||||
createAdminPermission,
|
||||
createEncryptedApiKey,
|
||||
createLegacyApiKey,
|
||||
createPermission,
|
||||
createReadPermission,
|
||||
createSession,
|
||||
createWorkflowAccessContext,
|
||||
createWorkflowRecord,
|
||||
createWorkspaceRecord,
|
||||
createWritePermission,
|
||||
type EntityType,
|
||||
type MockSession,
|
||||
type Permission,
|
||||
type PermissionFactoryOptions,
|
||||
type PermissionType,
|
||||
ROLE_ALLOWED_OPERATIONS,
|
||||
type SessionFactoryOptions,
|
||||
SOCKET_OPERATIONS,
|
||||
type SocketOperation,
|
||||
type WorkflowAccessContext,
|
||||
type WorkflowRecord,
|
||||
type WorkflowRecordFactoryOptions,
|
||||
type WorkspaceRecord,
|
||||
type WorkspaceRecordFactoryOptions,
|
||||
} from './permission.factory'
|
||||
// Serialized block factories (for executor tests)
|
||||
export {
|
||||
createSerializedAgentBlock,
|
||||
createSerializedBlock,
|
||||
createSerializedConditionBlock,
|
||||
createSerializedConnection,
|
||||
createSerializedEvaluatorBlock,
|
||||
createSerializedFunctionBlock,
|
||||
createSerializedRouterBlock,
|
||||
createSerializedStarterBlock,
|
||||
createSerializedWorkflow,
|
||||
resetSerializedBlockCounter,
|
||||
type SerializedBlock,
|
||||
type SerializedBlockFactoryOptions,
|
||||
type SerializedConnection,
|
||||
type SerializedWorkflow,
|
||||
} from './serialized-block.factory'
|
||||
// Undo/redo operation factories
|
||||
export {
|
||||
type AddBlockOperation,
|
||||
type AddEdgeOperation,
|
||||
type BaseOperation,
|
||||
createAddBlockEntry,
|
||||
createAddEdgeEntry,
|
||||
createDuplicateBlockEntry,
|
||||
createMoveBlockEntry,
|
||||
createRemoveBlockEntry,
|
||||
createRemoveEdgeEntry,
|
||||
createUpdateParentEntry,
|
||||
type DuplicateBlockOperation,
|
||||
type MoveBlockOperation,
|
||||
type Operation,
|
||||
type OperationEntry,
|
||||
type OperationType,
|
||||
type RemoveBlockOperation,
|
||||
type RemoveEdgeOperation,
|
||||
type UpdateParentOperation,
|
||||
} from './undo-redo.factory'
|
||||
// User/workspace factories
|
||||
export {
|
||||
createUser,
|
||||
createUserWithWorkspace,
|
||||
createWorkflow,
|
||||
createWorkspace,
|
||||
type UserFactoryOptions,
|
||||
type WorkflowObjectFactoryOptions,
|
||||
type WorkspaceFactoryOptions,
|
||||
} from './user.factory'
|
||||
// Workflow factories
|
||||
export {
|
||||
createBranchingWorkflow,
|
||||
createLinearWorkflow,
|
||||
createLoopWorkflow,
|
||||
createParallelWorkflow,
|
||||
createWorkflowState,
|
||||
type WorkflowFactoryOptions,
|
||||
} from './workflow.factory'
|
||||
313
packages/testing/src/factories/permission.factory.ts
Normal file
313
packages/testing/src/factories/permission.factory.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
/**
|
||||
* Permission types in order of access level (highest to lowest).
|
||||
*/
|
||||
export type PermissionType = 'admin' | 'write' | 'read'
|
||||
|
||||
/**
|
||||
* Entity types that can have permissions.
|
||||
*/
|
||||
export type EntityType = 'workspace' | 'workflow' | 'organization'
|
||||
|
||||
/**
|
||||
* Permission record as stored in the database.
|
||||
*/
|
||||
export interface Permission {
|
||||
id: string
|
||||
userId: string
|
||||
entityType: EntityType
|
||||
entityId: string
|
||||
permissionType: PermissionType
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a permission.
|
||||
*/
|
||||
export interface PermissionFactoryOptions {
|
||||
id?: string
|
||||
userId?: string
|
||||
entityType?: EntityType
|
||||
entityId?: string
|
||||
permissionType?: PermissionType
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock permission record.
|
||||
*/
|
||||
export function createPermission(options: PermissionFactoryOptions = {}): Permission {
|
||||
return {
|
||||
id: options.id ?? nanoid(8),
|
||||
userId: options.userId ?? `user-${nanoid(6)}`,
|
||||
entityType: options.entityType ?? 'workspace',
|
||||
entityId: options.entityId ?? `ws-${nanoid(6)}`,
|
||||
permissionType: options.permissionType ?? 'read',
|
||||
createdAt: options.createdAt ?? new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workspace admin permission.
|
||||
*/
|
||||
export function createAdminPermission(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
options: Partial<PermissionFactoryOptions> = {}
|
||||
): Permission {
|
||||
return createPermission({
|
||||
userId,
|
||||
entityType: 'workspace',
|
||||
entityId: workspaceId,
|
||||
permissionType: 'admin',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workspace write permission.
|
||||
*/
|
||||
export function createWritePermission(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
options: Partial<PermissionFactoryOptions> = {}
|
||||
): Permission {
|
||||
return createPermission({
|
||||
userId,
|
||||
entityType: 'workspace',
|
||||
entityId: workspaceId,
|
||||
permissionType: 'write',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workspace read permission.
|
||||
*/
|
||||
export function createReadPermission(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
options: Partial<PermissionFactoryOptions> = {}
|
||||
): Permission {
|
||||
return createPermission({
|
||||
userId,
|
||||
entityType: 'workspace',
|
||||
entityId: workspaceId,
|
||||
permissionType: 'read',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace record for testing.
|
||||
*/
|
||||
export interface WorkspaceRecord {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
billedAccountUserId?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a workspace.
|
||||
*/
|
||||
export interface WorkspaceRecordFactoryOptions {
|
||||
id?: string
|
||||
name?: string
|
||||
ownerId?: string
|
||||
billedAccountUserId?: string
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock workspace record.
|
||||
*/
|
||||
export function createWorkspaceRecord(
|
||||
options: WorkspaceRecordFactoryOptions = {}
|
||||
): WorkspaceRecord {
|
||||
const id = options.id ?? `ws-${nanoid(6)}`
|
||||
const ownerId = options.ownerId ?? `user-${nanoid(6)}`
|
||||
return {
|
||||
id,
|
||||
name: options.name ?? `Workspace ${id}`,
|
||||
ownerId,
|
||||
billedAccountUserId: options.billedAccountUserId ?? ownerId,
|
||||
createdAt: options.createdAt ?? new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow record for testing.
|
||||
*/
|
||||
export interface WorkflowRecord {
|
||||
id: string
|
||||
name: string
|
||||
userId: string
|
||||
workspaceId: string | null
|
||||
state: string
|
||||
isDeployed: boolean
|
||||
runCount: number
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a workflow record.
|
||||
*/
|
||||
export interface WorkflowRecordFactoryOptions {
|
||||
id?: string
|
||||
name?: string
|
||||
userId?: string
|
||||
workspaceId?: string | null
|
||||
state?: string
|
||||
isDeployed?: boolean
|
||||
runCount?: number
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock workflow database record.
|
||||
*/
|
||||
export function createWorkflowRecord(options: WorkflowRecordFactoryOptions = {}): WorkflowRecord {
|
||||
const id = options.id ?? `wf-${nanoid(6)}`
|
||||
return {
|
||||
id,
|
||||
name: options.name ?? `Workflow ${id}`,
|
||||
userId: options.userId ?? `user-${nanoid(6)}`,
|
||||
workspaceId: options.workspaceId ?? null,
|
||||
state: options.state ?? '{}',
|
||||
isDeployed: options.isDeployed ?? false,
|
||||
runCount: options.runCount ?? 0,
|
||||
createdAt: options.createdAt ?? new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session object for testing.
|
||||
*/
|
||||
export interface MockSession {
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
}
|
||||
expiresAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a session.
|
||||
*/
|
||||
export interface SessionFactoryOptions {
|
||||
userId?: string
|
||||
email?: string
|
||||
name?: string
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock session object.
|
||||
*/
|
||||
export function createSession(options: SessionFactoryOptions = {}): MockSession {
|
||||
const userId = options.userId ?? `user-${nanoid(6)}`
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
email: options.email ?? `${userId}@test.com`,
|
||||
name: options.name,
|
||||
},
|
||||
expiresAt: options.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow access context for testing.
|
||||
*/
|
||||
export interface WorkflowAccessContext {
|
||||
workflow: WorkflowRecord
|
||||
workspaceOwnerId: string | null
|
||||
workspacePermission: PermissionType | null
|
||||
isOwner: boolean
|
||||
isWorkspaceOwner: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock workflow access context.
|
||||
*/
|
||||
export function createWorkflowAccessContext(options: {
|
||||
workflow: WorkflowRecord
|
||||
workspaceOwnerId?: string | null
|
||||
workspacePermission?: PermissionType | null
|
||||
userId?: string
|
||||
}): WorkflowAccessContext {
|
||||
const { workflow, workspaceOwnerId = null, workspacePermission = null, userId } = options
|
||||
|
||||
return {
|
||||
workflow,
|
||||
workspaceOwnerId,
|
||||
workspacePermission,
|
||||
isOwner: userId ? workflow.userId === userId : false,
|
||||
isWorkspaceOwner: userId && workspaceOwnerId ? workspaceOwnerId === userId : false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All socket operations that can be performed.
|
||||
*/
|
||||
export const SOCKET_OPERATIONS = [
|
||||
'add',
|
||||
'remove',
|
||||
'update',
|
||||
'update-position',
|
||||
'update-name',
|
||||
'toggle-enabled',
|
||||
'update-parent',
|
||||
'update-wide',
|
||||
'update-advanced-mode',
|
||||
'update-trigger-mode',
|
||||
'toggle-handles',
|
||||
'duplicate',
|
||||
'replace-state',
|
||||
] as const
|
||||
|
||||
export type SocketOperation = (typeof SOCKET_OPERATIONS)[number]
|
||||
|
||||
/**
|
||||
* Operations allowed for each role.
|
||||
*/
|
||||
export const ROLE_ALLOWED_OPERATIONS: Record<PermissionType, SocketOperation[]> = {
|
||||
admin: [...SOCKET_OPERATIONS],
|
||||
write: [...SOCKET_OPERATIONS],
|
||||
read: ['update-position'],
|
||||
}
|
||||
|
||||
/**
|
||||
* API key formats for testing.
|
||||
*/
|
||||
export interface ApiKeyTestData {
|
||||
plainKey: string
|
||||
encryptedStorage: string
|
||||
last4: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates test API key data.
|
||||
*/
|
||||
export function createLegacyApiKey(): { key: string; prefix: string } {
|
||||
const random = nanoid(24)
|
||||
return {
|
||||
key: `sim_${random}`,
|
||||
prefix: 'sim_',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates test encrypted format API key data.
|
||||
*/
|
||||
export function createEncryptedApiKey(): { key: string; prefix: string } {
|
||||
const random = nanoid(24)
|
||||
return {
|
||||
key: `sk-sim-${random}`,
|
||||
prefix: 'sk-sim-',
|
||||
}
|
||||
}
|
||||
229
packages/testing/src/factories/serialized-block.factory.ts
Normal file
229
packages/testing/src/factories/serialized-block.factory.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Factory functions for creating SerializedBlock test fixtures.
|
||||
* These are used in executor tests where blocks are in their serialized form.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serialized block structure used in executor tests.
|
||||
*/
|
||||
export interface SerializedBlock {
|
||||
id: string
|
||||
position: { x: number; y: number }
|
||||
config: {
|
||||
tool: string
|
||||
params: Record<string, any>
|
||||
}
|
||||
inputs: Record<string, any>
|
||||
outputs: Record<string, any>
|
||||
metadata?: {
|
||||
id: string
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
}
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized connection structure.
|
||||
*/
|
||||
export interface SerializedConnection {
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized workflow structure.
|
||||
*/
|
||||
export interface SerializedWorkflow {
|
||||
version: string
|
||||
blocks: SerializedBlock[]
|
||||
connections: SerializedConnection[]
|
||||
loops: Record<string, any>
|
||||
parallels?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a serialized block.
|
||||
*/
|
||||
export interface SerializedBlockFactoryOptions {
|
||||
id?: string
|
||||
type?: string
|
||||
name?: string
|
||||
description?: string
|
||||
position?: { x: number; y: number }
|
||||
tool?: string
|
||||
params?: Record<string, any>
|
||||
inputs?: Record<string, any>
|
||||
outputs?: Record<string, any>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
let blockCounter = 0
|
||||
|
||||
/**
|
||||
* Generates a unique block ID.
|
||||
*/
|
||||
function generateBlockId(prefix = 'block'): string {
|
||||
return `${prefix}-${++blockCounter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the block counter (useful for deterministic tests).
|
||||
*/
|
||||
export function resetSerializedBlockCounter(): void {
|
||||
blockCounter = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized block with sensible defaults.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const block = createSerializedBlock({ type: 'agent', name: 'My Agent' })
|
||||
* ```
|
||||
*/
|
||||
export function createSerializedBlock(
|
||||
options: SerializedBlockFactoryOptions = {}
|
||||
): SerializedBlock {
|
||||
const type = options.type ?? 'function'
|
||||
const id = options.id ?? generateBlockId(type)
|
||||
|
||||
return {
|
||||
id,
|
||||
position: options.position ?? { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: options.tool ?? type,
|
||||
params: options.params ?? {},
|
||||
},
|
||||
inputs: options.inputs ?? {},
|
||||
outputs: options.outputs ?? {},
|
||||
metadata: {
|
||||
id: type,
|
||||
name: options.name ?? `Block ${id}`,
|
||||
description: options.description,
|
||||
},
|
||||
enabled: options.enabled ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized condition block.
|
||||
*/
|
||||
export function createSerializedConditionBlock(
|
||||
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
|
||||
): SerializedBlock {
|
||||
return createSerializedBlock({
|
||||
...options,
|
||||
type: 'condition',
|
||||
name: options.name ?? 'Condition',
|
||||
inputs: options.inputs ?? { conditions: 'json' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized router block.
|
||||
*/
|
||||
export function createSerializedRouterBlock(
|
||||
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
|
||||
): SerializedBlock {
|
||||
return createSerializedBlock({
|
||||
...options,
|
||||
type: 'router',
|
||||
name: options.name ?? 'Router',
|
||||
inputs: options.inputs ?? { prompt: 'string', model: 'string' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized evaluator block.
|
||||
*/
|
||||
export function createSerializedEvaluatorBlock(
|
||||
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
|
||||
): SerializedBlock {
|
||||
return createSerializedBlock({
|
||||
...options,
|
||||
type: 'evaluator',
|
||||
name: options.name ?? 'Evaluator',
|
||||
inputs: options.inputs ?? {
|
||||
content: 'string',
|
||||
metrics: 'json',
|
||||
model: 'string',
|
||||
temperature: 'number',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized agent block.
|
||||
*/
|
||||
export function createSerializedAgentBlock(
|
||||
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
|
||||
): SerializedBlock {
|
||||
return createSerializedBlock({
|
||||
...options,
|
||||
type: 'agent',
|
||||
name: options.name ?? 'Agent',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized function block.
|
||||
*/
|
||||
export function createSerializedFunctionBlock(
|
||||
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
|
||||
): SerializedBlock {
|
||||
return createSerializedBlock({
|
||||
...options,
|
||||
type: 'function',
|
||||
name: options.name ?? 'Function',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized starter block.
|
||||
*/
|
||||
export function createSerializedStarterBlock(
|
||||
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
|
||||
): SerializedBlock {
|
||||
return createSerializedBlock({
|
||||
...options,
|
||||
type: 'starter',
|
||||
name: options.name ?? 'Start',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple serialized connection.
|
||||
*/
|
||||
export function createSerializedConnection(
|
||||
source: string,
|
||||
target: string,
|
||||
sourceHandle?: string
|
||||
): SerializedConnection {
|
||||
return {
|
||||
source,
|
||||
target,
|
||||
sourceHandle,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialized workflow with the given blocks and connections.
|
||||
*/
|
||||
export function createSerializedWorkflow(
|
||||
blocks: SerializedBlock[],
|
||||
connections: SerializedConnection[] = []
|
||||
): SerializedWorkflow {
|
||||
return {
|
||||
version: '1.0',
|
||||
blocks,
|
||||
connections,
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
385
packages/testing/src/factories/undo-redo.factory.ts
Normal file
385
packages/testing/src/factories/undo-redo.factory.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { BlockState, Edge } from '../types'
|
||||
|
||||
/**
|
||||
* Operation types supported by the undo/redo store.
|
||||
*/
|
||||
export type OperationType =
|
||||
| 'add-block'
|
||||
| 'remove-block'
|
||||
| 'add-edge'
|
||||
| 'remove-edge'
|
||||
| 'move-block'
|
||||
| 'duplicate-block'
|
||||
| 'update-parent'
|
||||
|
||||
/**
|
||||
* Base operation interface.
|
||||
*/
|
||||
export interface BaseOperation {
|
||||
id: string
|
||||
type: OperationType
|
||||
timestamp: number
|
||||
workflowId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Move block operation data.
|
||||
*/
|
||||
export interface MoveBlockOperation extends BaseOperation {
|
||||
type: 'move-block'
|
||||
data: {
|
||||
blockId: string
|
||||
before: { x: number; y: number; parentId?: string }
|
||||
after: { x: number; y: number; parentId?: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add block operation data.
|
||||
*/
|
||||
export interface AddBlockOperation extends BaseOperation {
|
||||
type: 'add-block'
|
||||
data: { blockId: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove block operation data.
|
||||
*/
|
||||
export interface RemoveBlockOperation extends BaseOperation {
|
||||
type: 'remove-block'
|
||||
data: {
|
||||
blockId: string
|
||||
blockSnapshot: BlockState | null
|
||||
edgeSnapshots?: Edge[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add edge operation data.
|
||||
*/
|
||||
export interface AddEdgeOperation extends BaseOperation {
|
||||
type: 'add-edge'
|
||||
data: { edgeId: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove edge operation data.
|
||||
*/
|
||||
export interface RemoveEdgeOperation extends BaseOperation {
|
||||
type: 'remove-edge'
|
||||
data: { edgeId: string; edgeSnapshot: Edge | null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate block operation data.
|
||||
*/
|
||||
export interface DuplicateBlockOperation extends BaseOperation {
|
||||
type: 'duplicate-block'
|
||||
data: {
|
||||
sourceBlockId: string
|
||||
duplicatedBlockId: string
|
||||
duplicatedBlockSnapshot: BlockState
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update parent operation data.
|
||||
*/
|
||||
export interface UpdateParentOperation extends BaseOperation {
|
||||
type: 'update-parent'
|
||||
data: {
|
||||
blockId: string
|
||||
oldParentId?: string
|
||||
newParentId?: string
|
||||
oldPosition: { x: number; y: number }
|
||||
newPosition: { x: number; y: number }
|
||||
}
|
||||
}
|
||||
|
||||
export type Operation =
|
||||
| AddBlockOperation
|
||||
| RemoveBlockOperation
|
||||
| AddEdgeOperation
|
||||
| RemoveEdgeOperation
|
||||
| MoveBlockOperation
|
||||
| DuplicateBlockOperation
|
||||
| UpdateParentOperation
|
||||
|
||||
/**
|
||||
* Operation entry with forward and inverse operations.
|
||||
*/
|
||||
export interface OperationEntry {
|
||||
id: string
|
||||
operation: Operation
|
||||
inverse: Operation
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface OperationEntryOptions {
|
||||
id?: string
|
||||
workflowId?: string
|
||||
userId?: string
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock add-block operation entry.
|
||||
*/
|
||||
export function createAddBlockEntry(
|
||||
blockId: string,
|
||||
options: OperationEntryOptions = {}
|
||||
): OperationEntry {
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'add-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, blockSnapshot: null },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock remove-block operation entry.
|
||||
*/
|
||||
export function createRemoveBlockEntry(
|
||||
blockId: string,
|
||||
blockSnapshot: BlockState | null = null,
|
||||
options: OperationEntryOptions = {}
|
||||
): OperationEntry {
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, blockSnapshot },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'add-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock add-edge operation entry.
|
||||
*/
|
||||
export function createAddEdgeEntry(
|
||||
edgeId: string,
|
||||
options: OperationEntryOptions = {}
|
||||
): OperationEntry {
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'add-edge',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { edgeId },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-edge',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { edgeId, edgeSnapshot: null },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock remove-edge operation entry.
|
||||
*/
|
||||
export function createRemoveEdgeEntry(
|
||||
edgeId: string,
|
||||
edgeSnapshot: Edge | null = null,
|
||||
options: OperationEntryOptions = {}
|
||||
): OperationEntry {
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-edge',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { edgeId, edgeSnapshot },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'add-edge',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { edgeId },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface MoveBlockOptions extends OperationEntryOptions {
|
||||
before?: { x: number; y: number; parentId?: string }
|
||||
after?: { x: number; y: number; parentId?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock move-block operation entry.
|
||||
*/
|
||||
export function createMoveBlockEntry(
|
||||
blockId: string,
|
||||
options: MoveBlockOptions = {}
|
||||
): OperationEntry {
|
||||
const {
|
||||
id = nanoid(8),
|
||||
workflowId = 'wf-1',
|
||||
userId = 'user-1',
|
||||
createdAt = Date.now(),
|
||||
before = { x: 0, y: 0 },
|
||||
after = { x: 100, y: 100 },
|
||||
} = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'move-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, before, after },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'move-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, before: after, after: before },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock duplicate-block operation entry.
|
||||
*/
|
||||
export function createDuplicateBlockEntry(
|
||||
sourceBlockId: string,
|
||||
duplicatedBlockId: string,
|
||||
duplicatedBlockSnapshot: BlockState,
|
||||
options: OperationEntryOptions = {}
|
||||
): OperationEntry {
|
||||
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'duplicate-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { sourceBlockId, duplicatedBlockId, duplicatedBlockSnapshot },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'remove-block',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId: duplicatedBlockId, blockSnapshot: duplicatedBlockSnapshot },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock update-parent operation entry.
|
||||
*/
|
||||
export function createUpdateParentEntry(
|
||||
blockId: string,
|
||||
options: OperationEntryOptions & {
|
||||
oldParentId?: string
|
||||
newParentId?: string
|
||||
oldPosition?: { x: number; y: number }
|
||||
newPosition?: { x: number; y: number }
|
||||
} = {}
|
||||
): OperationEntry {
|
||||
const {
|
||||
id = nanoid(8),
|
||||
workflowId = 'wf-1',
|
||||
userId = 'user-1',
|
||||
createdAt = Date.now(),
|
||||
oldParentId,
|
||||
newParentId,
|
||||
oldPosition = { x: 0, y: 0 },
|
||||
newPosition = { x: 50, y: 50 },
|
||||
} = options
|
||||
const timestamp = Date.now()
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'update-parent',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, oldParentId, newParentId, oldPosition, newPosition },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'update-parent',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId,
|
||||
oldParentId: newParentId,
|
||||
newParentId: oldParentId,
|
||||
oldPosition: newPosition,
|
||||
newPosition: oldPosition,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
114
packages/testing/src/factories/user.factory.ts
Normal file
114
packages/testing/src/factories/user.factory.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { User, Workflow, WorkflowState, Workspace } from '../types'
|
||||
import { createWorkflowState } from './workflow.factory'
|
||||
|
||||
/**
|
||||
* Options for creating a mock user.
|
||||
*/
|
||||
export interface UserFactoryOptions {
|
||||
id?: string
|
||||
email?: string
|
||||
name?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const user = createUser({ email: 'test@example.com' })
|
||||
* ```
|
||||
*/
|
||||
export function createUser(options: UserFactoryOptions = {}): User {
|
||||
const id = options.id ?? `user-${Math.random().toString(36).substring(2, 10)}`
|
||||
return {
|
||||
id,
|
||||
email: options.email ?? `${id}@test.example.com`,
|
||||
name: options.name ?? `Test User ${id.substring(0, 4)}`,
|
||||
image: options.image,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a mock workspace.
|
||||
*/
|
||||
export interface WorkspaceFactoryOptions {
|
||||
id?: string
|
||||
name?: string
|
||||
ownerId?: string
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock workspace.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const workspace = createWorkspace({ name: 'My Workspace' })
|
||||
* ```
|
||||
*/
|
||||
export function createWorkspace(options: WorkspaceFactoryOptions = {}): Workspace {
|
||||
const now = new Date()
|
||||
return {
|
||||
id: options.id ?? `ws-${Math.random().toString(36).substring(2, 10)}`,
|
||||
name: options.name ?? 'Test Workspace',
|
||||
ownerId: options.ownerId ?? `user-${Math.random().toString(36).substring(2, 10)}`,
|
||||
createdAt: options.createdAt ?? now,
|
||||
updatedAt: options.updatedAt ?? now,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a mock workflow.
|
||||
*/
|
||||
export interface WorkflowObjectFactoryOptions {
|
||||
id?: string
|
||||
name?: string
|
||||
workspaceId?: string
|
||||
state?: WorkflowState
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
isDeployed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock workflow object (not just state).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const workflow = createWorkflow({ name: 'My Workflow' })
|
||||
* ```
|
||||
*/
|
||||
export function createWorkflow(options: WorkflowObjectFactoryOptions = {}): Workflow {
|
||||
const now = new Date()
|
||||
return {
|
||||
id: options.id ?? `wf-${Math.random().toString(36).substring(2, 10)}`,
|
||||
name: options.name ?? 'Test Workflow',
|
||||
workspaceId: options.workspaceId ?? `ws-${Math.random().toString(36).substring(2, 10)}`,
|
||||
state: options.state ?? createWorkflowState(),
|
||||
createdAt: options.createdAt ?? now,
|
||||
updatedAt: options.updatedAt ?? now,
|
||||
isDeployed: options.isDeployed ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user with an associated workspace.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { user, workspace } = createUserWithWorkspace()
|
||||
* ```
|
||||
*/
|
||||
export function createUserWithWorkspace(
|
||||
userOptions: UserFactoryOptions = {},
|
||||
workspaceOptions: Omit<WorkspaceFactoryOptions, 'ownerId'> = {}
|
||||
): { user: User; workspace: Workspace } {
|
||||
const user = createUser(userOptions)
|
||||
const workspace = createWorkspace({
|
||||
...workspaceOptions,
|
||||
ownerId: user.id,
|
||||
})
|
||||
return { user, workspace }
|
||||
}
|
||||
209
packages/testing/src/factories/workflow.factory.ts
Normal file
209
packages/testing/src/factories/workflow.factory.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { BlockState, Edge, Loop, Parallel, WorkflowState } from '../types'
|
||||
import { createBlock, createFunctionBlock, createStarterBlock } from './block.factory'
|
||||
import { createLinearEdges } from './edge.factory'
|
||||
|
||||
/**
|
||||
* Options for creating a mock workflow state.
|
||||
*/
|
||||
export interface WorkflowFactoryOptions {
|
||||
blocks?: Record<string, BlockState>
|
||||
edges?: Edge[]
|
||||
loops?: Record<string, Loop>
|
||||
parallels?: Record<string, Parallel>
|
||||
lastSaved?: number
|
||||
isDeployed?: boolean
|
||||
variables?: WorkflowState['variables']
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty workflow state with defaults.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const workflow = createWorkflowState()
|
||||
* ```
|
||||
*/
|
||||
export function createWorkflowState(options: WorkflowFactoryOptions = {}): WorkflowState {
|
||||
return {
|
||||
blocks: options.blocks ?? {},
|
||||
edges: options.edges ?? [],
|
||||
loops: options.loops ?? {},
|
||||
parallels: options.parallels ?? {},
|
||||
lastSaved: options.lastSaved ?? Date.now(),
|
||||
isDeployed: options.isDeployed ?? false,
|
||||
variables: options.variables,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple linear workflow with the specified number of blocks.
|
||||
* First block is always a starter, rest are function blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Creates: starter -> function -> function
|
||||
* const workflow = createLinearWorkflow(3)
|
||||
* ```
|
||||
*/
|
||||
export function createLinearWorkflow(blockCount: number, spacing = 200): WorkflowState {
|
||||
if (blockCount < 1) {
|
||||
return createWorkflowState()
|
||||
}
|
||||
|
||||
const blocks: Record<string, BlockState> = {}
|
||||
const blockIds: string[] = []
|
||||
|
||||
for (let i = 0; i < blockCount; i++) {
|
||||
const id = `block-${i}`
|
||||
blockIds.push(id)
|
||||
|
||||
if (i === 0) {
|
||||
blocks[id] = createStarterBlock({
|
||||
id,
|
||||
position: { x: i * spacing, y: 0 },
|
||||
})
|
||||
} else {
|
||||
blocks[id] = createFunctionBlock({
|
||||
id,
|
||||
name: `Step ${i}`,
|
||||
position: { x: i * spacing, y: 0 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return createWorkflowState({
|
||||
blocks,
|
||||
edges: createLinearEdges(blockIds),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workflow with a branching condition.
|
||||
*
|
||||
* Structure:
|
||||
* ```
|
||||
* ┌─→ true-branch ─┐
|
||||
* start ─→ condition ├─→ end
|
||||
* └─→ false-branch ┘
|
||||
* ```
|
||||
*/
|
||||
export function createBranchingWorkflow(): WorkflowState {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
start: createStarterBlock({ id: 'start', position: { x: 0, y: 0 } }),
|
||||
condition: createBlock({
|
||||
id: 'condition',
|
||||
type: 'condition',
|
||||
name: 'Check',
|
||||
position: { x: 200, y: 0 },
|
||||
}),
|
||||
'true-branch': createFunctionBlock({
|
||||
id: 'true-branch',
|
||||
name: 'If True',
|
||||
position: { x: 400, y: -100 },
|
||||
}),
|
||||
'false-branch': createFunctionBlock({
|
||||
id: 'false-branch',
|
||||
name: 'If False',
|
||||
position: { x: 400, y: 100 },
|
||||
}),
|
||||
end: createFunctionBlock({ id: 'end', name: 'End', position: { x: 600, y: 0 } }),
|
||||
}
|
||||
|
||||
const edges: Edge[] = [
|
||||
{ id: 'e1', source: 'start', target: 'condition' },
|
||||
{ id: 'e2', source: 'condition', target: 'true-branch', sourceHandle: 'condition-if' },
|
||||
{ id: 'e3', source: 'condition', target: 'false-branch', sourceHandle: 'condition-else' },
|
||||
{ id: 'e4', source: 'true-branch', target: 'end' },
|
||||
{ id: 'e5', source: 'false-branch', target: 'end' },
|
||||
]
|
||||
|
||||
return createWorkflowState({ blocks, edges })
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workflow with a loop container.
|
||||
*
|
||||
* Structure:
|
||||
* ```
|
||||
* start ─→ loop[loop-body] ─→ end
|
||||
* ```
|
||||
*/
|
||||
export function createLoopWorkflow(iterations = 3): WorkflowState {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
start: createStarterBlock({ id: 'start', position: { x: 0, y: 0 } }),
|
||||
loop: createBlock({
|
||||
id: 'loop',
|
||||
type: 'loop',
|
||||
name: 'Loop',
|
||||
position: { x: 200, y: 0 },
|
||||
data: { loopType: 'for', count: iterations, type: 'loop' },
|
||||
}),
|
||||
'loop-body': createFunctionBlock({
|
||||
id: 'loop-body',
|
||||
name: 'Loop Body',
|
||||
position: { x: 50, y: 50 },
|
||||
parentId: 'loop',
|
||||
}),
|
||||
end: createFunctionBlock({ id: 'end', name: 'End', position: { x: 500, y: 0 } }),
|
||||
}
|
||||
|
||||
const edges: Edge[] = [
|
||||
{ id: 'e1', source: 'start', target: 'loop' },
|
||||
{ id: 'e2', source: 'loop', target: 'end' },
|
||||
]
|
||||
|
||||
const loops: Record<string, Loop> = {
|
||||
loop: {
|
||||
id: 'loop',
|
||||
nodes: ['loop-body'],
|
||||
iterations,
|
||||
loopType: 'for',
|
||||
},
|
||||
}
|
||||
|
||||
return createWorkflowState({ blocks, edges, loops })
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workflow with a parallel container.
|
||||
*
|
||||
* Structure:
|
||||
* ```
|
||||
* start ─→ parallel[parallel-task] ─→ end
|
||||
* ```
|
||||
*/
|
||||
export function createParallelWorkflow(count = 2): WorkflowState {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
start: createStarterBlock({ id: 'start', position: { x: 0, y: 0 } }),
|
||||
parallel: createBlock({
|
||||
id: 'parallel',
|
||||
type: 'parallel',
|
||||
name: 'Parallel',
|
||||
position: { x: 200, y: 0 },
|
||||
data: { parallelType: 'count', count, type: 'parallel' },
|
||||
}),
|
||||
'parallel-task': createFunctionBlock({
|
||||
id: 'parallel-task',
|
||||
name: 'Parallel Task',
|
||||
position: { x: 50, y: 50 },
|
||||
parentId: 'parallel',
|
||||
}),
|
||||
end: createFunctionBlock({ id: 'end', name: 'End', position: { x: 500, y: 0 } }),
|
||||
}
|
||||
|
||||
const edges: Edge[] = [
|
||||
{ id: 'e1', source: 'start', target: 'parallel' },
|
||||
{ id: 'e2', source: 'parallel', target: 'end' },
|
||||
]
|
||||
|
||||
const parallels: Record<string, Parallel> = {
|
||||
parallel: {
|
||||
id: 'parallel',
|
||||
nodes: ['parallel-task'],
|
||||
count,
|
||||
parallelType: 'count',
|
||||
},
|
||||
}
|
||||
|
||||
return createWorkflowState({ blocks, edges, parallels })
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user