mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-17 18:02:09 -05:00
added tests
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
document: {
|
||||
id: 'id',
|
||||
connectorId: 'connectorId',
|
||||
deletedAt: 'deletedAt',
|
||||
filename: 'filename',
|
||||
externalId: 'externalId',
|
||||
sourceUrl: 'sourceUrl',
|
||||
enabled: 'enabled',
|
||||
userExcluded: 'userExcluded',
|
||||
uploadedAt: 'uploadedAt',
|
||||
processingStatus: 'processingStatus',
|
||||
},
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: vi.fn(),
|
||||
checkKnowledgeBaseWriteAccess: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
describe('Connector Documents API Route', () => {
|
||||
/**
|
||||
* The route chains db calls in sequence. We track call order
|
||||
* to return different values for connector lookup vs document queries.
|
||||
*/
|
||||
let limitCallCount: number
|
||||
let orderByCallCount: number
|
||||
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn(() => {
|
||||
orderByCallCount++
|
||||
return Promise.resolve([])
|
||||
}),
|
||||
limit: vi.fn(() => {
|
||||
limitCallCount++
|
||||
return Promise.resolve([])
|
||||
}),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
limitCallCount = 0
|
||||
orderByCallCount = 0
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockImplementation(() => {
|
||||
orderByCallCount++
|
||||
return Promise.resolve([])
|
||||
})
|
||||
mockDbChain.limit.mockImplementation(() => {
|
||||
limitCallCount++
|
||||
return Promise.resolve([])
|
||||
})
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
mockDbChain.returning.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: false,
|
||||
userId: null,
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns documents list on success', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
|
||||
const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false }
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.orderBy.mockResolvedValueOnce([doc])
|
||||
|
||||
const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents'
|
||||
const req = createMockRequest('GET', undefined, undefined, url)
|
||||
Object.assign(req, { nextUrl: new URL(url) })
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.documents).toHaveLength(1)
|
||||
expect(data.data.counts.active).toBe(1)
|
||||
expect(data.data.counts.excluded).toBe(0)
|
||||
})
|
||||
|
||||
it('includes excluded documents when includeExcluded=true', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.orderBy
|
||||
.mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }])
|
||||
.mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }])
|
||||
|
||||
const url =
|
||||
'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true'
|
||||
const req = createMockRequest('GET', undefined, undefined, url)
|
||||
Object.assign(req, { nextUrl: new URL(url) })
|
||||
const { GET } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await GET(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.documents).toHaveLength(2)
|
||||
expect(data.data.counts.active).toBe(1)
|
||||
expect(data.data.counts.excluded).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: false,
|
||||
userId: null,
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid body', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
|
||||
const req = createMockRequest('PATCH', { documentIds: [] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns success for restore operation', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
|
||||
|
||||
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.restoredCount).toBe(1)
|
||||
})
|
||||
|
||||
it('returns success for exclude operation', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
|
||||
|
||||
const req = createMockRequest('PATCH', {
|
||||
operation: 'exclude',
|
||||
documentIds: ['doc-2', 'doc-3'],
|
||||
})
|
||||
const { PATCH } = await import(
|
||||
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
)
|
||||
const response = await PATCH(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.excludedCount).toBe(2)
|
||||
expect(data.data.documentIds).toEqual(['doc-2', 'doc-3'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: vi.fn(),
|
||||
checkKnowledgeBaseWriteAccess: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
refreshAccessTokenIfNeeded: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/connectors/registry', () => ({
|
||||
CONNECTOR_REGISTRY: {
|
||||
jira: { validateConfig: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
knowledgeBase: { id: 'id', userId: 'userId' },
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
connectorType: 'connectorType',
|
||||
credentialId: 'credentialId',
|
||||
},
|
||||
knowledgeConnectorSyncLog: { connectorId: 'connectorId', startedAt: 'startedAt' },
|
||||
}))
|
||||
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
describe('Knowledge Connector By ID API Route', () => {
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockReturnThis()
|
||||
mockDbChain.limit.mockResolvedValue([])
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
|
||||
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when KB not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false, notFound: true })
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns connector with sync logs on success', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' }
|
||||
const mockLogs = [{ id: 'log-1', status: 'completed' }]
|
||||
|
||||
mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs)
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await GET(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.id).toBe('conn-456')
|
||||
expect(data.data.syncLogs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('PATCH', { status: 'paused' })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid body', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('Invalid request')
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found during sourceConfig validation', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 200 and updates status', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
|
||||
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
|
||||
|
||||
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
|
||||
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await PATCH(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.status).toBe('paused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await DELETE(req, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 200 on successful soft-delete', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
|
||||
const response = await DELETE(req, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
knowledgeConnector: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
deletedAt: 'deletedAt',
|
||||
status: 'status',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseWriteAccess: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
|
||||
dispatchSync: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
describe('Connector Manual Sync API Route', () => {
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockResolvedValue([]),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbChain.select.mockReturnThis()
|
||||
mockDbChain.from.mockReturnThis()
|
||||
mockDbChain.where.mockReturnThis()
|
||||
mockDbChain.orderBy.mockResolvedValue([])
|
||||
mockDbChain.limit.mockResolvedValue([])
|
||||
mockDbChain.update.mockReturnThis()
|
||||
mockDbChain.set.mockReturnThis()
|
||||
|
||||
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: false,
|
||||
userId: null,
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when connector not found', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when connector is syncing', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
})
|
||||
|
||||
it('dispatches sync on valid request', async () => {
|
||||
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
|
||||
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
|
||||
const { dispatchSync } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
} as never)
|
||||
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
|
||||
const response = await POST(req as never, { params: mockParams })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(vi.mocked(dispatchSync)).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
import { getSlotsForFieldType } from '@/lib/knowledge/constants'
|
||||
import { allocateTagSlots } from '@/lib/knowledge/constants'
|
||||
import { createTagDefinition } from '@/lib/knowledge/tags/service'
|
||||
import { getCredential } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
@@ -130,19 +130,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
|
||||
Object.assign(tagSlotMapping, mapping)
|
||||
|
||||
const skippedTags: string[] = []
|
||||
for (const td of enabledDefs) {
|
||||
const slots = getSlotsForFieldType(td.fieldType)
|
||||
const available = slots.find((s) => !usedSlots.has(s))
|
||||
|
||||
if (!available) {
|
||||
skippedTags.push(td.displayName)
|
||||
logger.warn(`[${requestId}] No available ${td.fieldType} slots for "${td.displayName}"`)
|
||||
continue
|
||||
}
|
||||
usedSlots.add(available)
|
||||
tagSlotMapping[td.id] = available
|
||||
for (const name of skippedTags) {
|
||||
logger.warn(`[${requestId}] No available slots for "${name}"`)
|
||||
}
|
||||
|
||||
if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
|
||||
|
||||
31
apps/sim/connectors/confluence/confluence.test.ts
Normal file
31
apps/sim/connectors/confluence/confluence.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { escapeCql } from '@/connectors/confluence/confluence'
|
||||
|
||||
describe('escapeCql', () => {
|
||||
it.concurrent('returns plain strings unchanged', () => {
|
||||
expect(escapeCql('Engineering')).toBe('Engineering')
|
||||
})
|
||||
|
||||
it.concurrent('escapes double quotes', () => {
|
||||
expect(escapeCql('say "hello"')).toBe('say \\"hello\\"')
|
||||
})
|
||||
|
||||
it.concurrent('escapes backslashes', () => {
|
||||
expect(escapeCql('path\\to\\file')).toBe('path\\\\to\\\\file')
|
||||
})
|
||||
|
||||
it.concurrent('escapes backslashes before quotes', () => {
|
||||
expect(escapeCql('a\\"b')).toBe('a\\\\\\"b')
|
||||
})
|
||||
|
||||
it.concurrent('handles empty string', () => {
|
||||
expect(escapeCql('')).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('leaves other special chars unchanged', () => {
|
||||
expect(escapeCql("it's a test & <tag>")).toBe("it's a test & <tag>")
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ const logger = createLogger('ConfluenceConnector')
|
||||
/**
|
||||
* Escapes a value for use inside CQL double-quoted strings.
|
||||
*/
|
||||
function escapeCql(value: string): string {
|
||||
export function escapeCql(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
|
||||
393
apps/sim/connectors/mapTags.test.ts
Normal file
393
apps/sim/connectors/mapTags.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/components/icons', () => ({
|
||||
JiraIcon: () => null,
|
||||
ConfluenceIcon: () => null,
|
||||
GithubIcon: () => null,
|
||||
LinearIcon: () => null,
|
||||
NotionIcon: () => null,
|
||||
GoogleDriveIcon: () => null,
|
||||
AirtableIcon: () => null,
|
||||
}))
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||
fetchWithRetry: vi.fn(),
|
||||
VALIDATE_RETRY_OPTIONS: {},
|
||||
}))
|
||||
vi.mock('@/tools/jira/utils', () => ({ extractAdfText: vi.fn(), getJiraCloudId: vi.fn() }))
|
||||
vi.mock('@/tools/confluence/utils', () => ({ getConfluenceCloudId: vi.fn() }))
|
||||
|
||||
import { airtableConnector } from '@/connectors/airtable/airtable'
|
||||
import { confluenceConnector } from '@/connectors/confluence/confluence'
|
||||
import { githubConnector } from '@/connectors/github/github'
|
||||
import { googleDriveConnector } from '@/connectors/google-drive/google-drive'
|
||||
import { jiraConnector } from '@/connectors/jira/jira'
|
||||
import { linearConnector } from '@/connectors/linear/linear'
|
||||
import { notionConnector } from '@/connectors/notion/notion'
|
||||
|
||||
const ISO_DATE = '2025-06-15T10:30:00.000Z'
|
||||
|
||||
describe('Jira mapTags', () => {
|
||||
const mapTags = jiraConnector.mapTags!
|
||||
|
||||
it.concurrent('maps all fields when present', () => {
|
||||
const result = mapTags({
|
||||
issueType: 'Bug',
|
||||
status: 'In Progress',
|
||||
priority: 'High',
|
||||
labels: ['frontend', 'urgent'],
|
||||
assignee: 'Alice',
|
||||
updated: ISO_DATE,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
issueType: 'Bug',
|
||||
status: 'In Progress',
|
||||
priority: 'High',
|
||||
labels: 'frontend, urgent',
|
||||
assignee: 'Alice',
|
||||
updated: new Date(ISO_DATE),
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('returns empty object for empty metadata', () => {
|
||||
expect(mapTags({})).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips fields with wrong types', () => {
|
||||
const result = mapTags({
|
||||
issueType: 123,
|
||||
status: null,
|
||||
priority: undefined,
|
||||
assignee: true,
|
||||
})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips updated when date string is invalid', () => {
|
||||
const result = mapTags({ updated: 'not-a-date' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips labels when not an array', () => {
|
||||
const result = mapTags({ labels: 'not-an-array' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips labels when array is empty', () => {
|
||||
const result = mapTags({ labels: [] })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips updated when value is not a string', () => {
|
||||
const result = mapTags({ updated: 12345 })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Confluence mapTags', () => {
|
||||
const mapTags = confluenceConnector.mapTags!
|
||||
|
||||
it.concurrent('maps all fields when present', () => {
|
||||
const result = mapTags({
|
||||
labels: ['docs', 'published'],
|
||||
version: 5,
|
||||
lastModified: ISO_DATE,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
labels: 'docs, published',
|
||||
version: 5,
|
||||
lastModified: new Date(ISO_DATE),
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('returns empty object for empty metadata', () => {
|
||||
expect(mapTags({})).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips labels when not an array', () => {
|
||||
const result = mapTags({ labels: 'single-label' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips version when NaN', () => {
|
||||
const result = mapTags({ version: 'abc' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('converts string version to number', () => {
|
||||
const result = mapTags({ version: '3' })
|
||||
expect(result).toEqual({ version: 3 })
|
||||
})
|
||||
|
||||
it.concurrent('skips lastModified when date is invalid', () => {
|
||||
const result = mapTags({ lastModified: 'garbage' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips lastModified when not a string', () => {
|
||||
const result = mapTags({ lastModified: 12345 })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GitHub mapTags', () => {
|
||||
const mapTags = githubConnector.mapTags!
|
||||
|
||||
it.concurrent('maps all fields when present', () => {
|
||||
const result = mapTags({
|
||||
path: 'src/index.ts',
|
||||
repository: 'owner/repo',
|
||||
branch: 'main',
|
||||
size: 1024,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
path: 'src/index.ts',
|
||||
repository: 'owner/repo',
|
||||
branch: 'main',
|
||||
size: 1024,
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('returns empty object for empty metadata', () => {
|
||||
expect(mapTags({})).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips string fields with wrong types', () => {
|
||||
const result = mapTags({
|
||||
path: 42,
|
||||
repository: null,
|
||||
branch: true,
|
||||
})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips size when NaN', () => {
|
||||
const result = mapTags({ size: 'not-a-number' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('converts string size to number', () => {
|
||||
const result = mapTags({ size: '512' })
|
||||
expect(result).toEqual({ size: 512 })
|
||||
})
|
||||
|
||||
it.concurrent('maps size of zero', () => {
|
||||
const result = mapTags({ size: 0 })
|
||||
expect(result).toEqual({ size: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Linear mapTags', () => {
|
||||
const mapTags = linearConnector.mapTags!
|
||||
|
||||
it.concurrent('maps all fields when present', () => {
|
||||
const result = mapTags({
|
||||
labels: ['bug', 'p0'],
|
||||
state: 'In Progress',
|
||||
priority: 'Urgent',
|
||||
assignee: 'Bob',
|
||||
lastModified: ISO_DATE,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
labels: 'bug, p0',
|
||||
state: 'In Progress',
|
||||
priority: 'Urgent',
|
||||
assignee: 'Bob',
|
||||
lastModified: new Date(ISO_DATE),
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('returns empty object for empty metadata', () => {
|
||||
expect(mapTags({})).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips string fields with wrong types', () => {
|
||||
const result = mapTags({
|
||||
state: 123,
|
||||
priority: false,
|
||||
assignee: [],
|
||||
})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips labels when not an array', () => {
|
||||
const result = mapTags({ labels: 'not-array' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips lastModified when date is invalid', () => {
|
||||
const result = mapTags({ lastModified: 'invalid-date' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips lastModified when not a string', () => {
|
||||
const result = mapTags({ lastModified: 99999 })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Notion mapTags', () => {
|
||||
const mapTags = notionConnector.mapTags!
|
||||
|
||||
it.concurrent('maps all fields when present', () => {
|
||||
const result = mapTags({
|
||||
tags: ['engineering', 'docs'],
|
||||
lastModified: ISO_DATE,
|
||||
createdTime: '2025-01-01T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
tags: 'engineering, docs',
|
||||
lastModified: new Date(ISO_DATE),
|
||||
created: new Date('2025-01-01T00:00:00.000Z'),
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('returns empty object for empty metadata', () => {
|
||||
expect(mapTags({})).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips tags when not an array', () => {
|
||||
const result = mapTags({ tags: 'single' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips lastModified when date is invalid', () => {
|
||||
const result = mapTags({ lastModified: 'bad-date' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips createdTime when date is invalid', () => {
|
||||
const result = mapTags({ createdTime: 'bad-date' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips date fields when not strings', () => {
|
||||
const result = mapTags({ lastModified: 12345, createdTime: true })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('maps createdTime to created key', () => {
|
||||
const result = mapTags({ createdTime: ISO_DATE })
|
||||
expect(result).toEqual({ created: new Date(ISO_DATE) })
|
||||
expect(result).not.toHaveProperty('createdTime')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Google Drive mapTags', () => {
|
||||
const mapTags = googleDriveConnector.mapTags!
|
||||
|
||||
it.concurrent('maps all fields when present', () => {
|
||||
const result = mapTags({
|
||||
owners: ['Alice', 'Bob'],
|
||||
originalMimeType: 'application/vnd.google-apps.document',
|
||||
modifiedTime: ISO_DATE,
|
||||
starred: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
owners: 'Alice, Bob',
|
||||
fileType: 'Google Doc',
|
||||
lastModified: new Date(ISO_DATE),
|
||||
starred: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('returns empty object for empty metadata', () => {
|
||||
expect(mapTags({})).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('maps spreadsheet mime type', () => {
|
||||
const result = mapTags({ originalMimeType: 'application/vnd.google-apps.spreadsheet' })
|
||||
expect(result).toEqual({ fileType: 'Google Sheet' })
|
||||
})
|
||||
|
||||
it.concurrent('maps presentation mime type', () => {
|
||||
const result = mapTags({ originalMimeType: 'application/vnd.google-apps.presentation' })
|
||||
expect(result).toEqual({ fileType: 'Google Slides' })
|
||||
})
|
||||
|
||||
it.concurrent('maps text/ mime types to Text File', () => {
|
||||
const result = mapTags({ originalMimeType: 'text/plain' })
|
||||
expect(result).toEqual({ fileType: 'Text File' })
|
||||
})
|
||||
|
||||
it.concurrent('falls back to raw mime type for unknown types', () => {
|
||||
const result = mapTags({ originalMimeType: 'application/pdf' })
|
||||
expect(result).toEqual({ fileType: 'application/pdf' })
|
||||
})
|
||||
|
||||
it.concurrent('skips owners when not an array', () => {
|
||||
const result = mapTags({ owners: 'not-an-array' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips modifiedTime when date is invalid', () => {
|
||||
const result = mapTags({ modifiedTime: 'garbage' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips modifiedTime when not a string', () => {
|
||||
const result = mapTags({ modifiedTime: 99999 })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('maps starred false', () => {
|
||||
const result = mapTags({ starred: false })
|
||||
expect(result).toEqual({ starred: false })
|
||||
})
|
||||
|
||||
it.concurrent('skips starred when not a boolean', () => {
|
||||
const result = mapTags({ starred: 'yes' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('maps modifiedTime to lastModified key', () => {
|
||||
const result = mapTags({ modifiedTime: ISO_DATE })
|
||||
expect(result).toEqual({ lastModified: new Date(ISO_DATE) })
|
||||
expect(result).not.toHaveProperty('modifiedTime')
|
||||
})
|
||||
|
||||
it.concurrent('maps originalMimeType to fileType key', () => {
|
||||
const result = mapTags({ originalMimeType: 'application/vnd.google-apps.document' })
|
||||
expect(result).toEqual({ fileType: 'Google Doc' })
|
||||
expect(result).not.toHaveProperty('originalMimeType')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Airtable mapTags', () => {
|
||||
const mapTags = airtableConnector.mapTags!
|
||||
|
||||
it.concurrent('maps createdTime when present', () => {
|
||||
const result = mapTags({ createdTime: ISO_DATE })
|
||||
expect(result).toEqual({ createdTime: new Date(ISO_DATE) })
|
||||
})
|
||||
|
||||
it.concurrent('returns empty object for empty metadata', () => {
|
||||
expect(mapTags({})).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips createdTime when date is invalid', () => {
|
||||
const result = mapTags({ createdTime: 'not-a-date' })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('skips createdTime when not a string', () => {
|
||||
const result = mapTags({ createdTime: 12345 })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('ignores unrelated metadata fields', () => {
|
||||
const result = mapTags({ foo: 'bar', count: 42, createdTime: ISO_DATE })
|
||||
expect(result).toEqual({ createdTime: new Date(ISO_DATE) })
|
||||
})
|
||||
})
|
||||
146
apps/sim/lib/knowledge/connectors/sync-engine.test.ts
Normal file
146
apps/sim/lib/knowledge/connectors/sync-engine.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: {} }))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
document: {},
|
||||
knowledgeBase: {},
|
||||
knowledgeConnector: {},
|
||||
knowledgeConnectorSyncLog: {},
|
||||
}))
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
ne: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: vi.fn() }))
|
||||
vi.mock('@/lib/knowledge/documents/service', () => ({
|
||||
isTriggerAvailable: vi.fn(),
|
||||
processDocumentAsync: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/uploads', () => ({ StorageService: {} }))
|
||||
vi.mock('@/app/api/auth/oauth/utils', () => ({ refreshAccessTokenIfNeeded: vi.fn() }))
|
||||
vi.mock('@/background/knowledge-connector-sync', () => ({
|
||||
knowledgeConnectorSync: { trigger: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockMapTags = vi.fn()
|
||||
|
||||
vi.mock('@/connectors/registry', () => ({
|
||||
CONNECTOR_REGISTRY: {
|
||||
jira: {
|
||||
mapTags: mockMapTags,
|
||||
},
|
||||
'no-tags': {
|
||||
name: 'No Tags',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('resolveTagMapping', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('maps semantic keys to DB slots', async () => {
|
||||
mockMapTags.mockReturnValue({
|
||||
issueType: 'Bug',
|
||||
status: 'Open',
|
||||
priority: 'High',
|
||||
})
|
||||
|
||||
const { resolveTagMapping } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
const result = resolveTagMapping(
|
||||
'jira',
|
||||
{ issueType: 'Bug', status: 'Open', priority: 'High' },
|
||||
{
|
||||
tagSlotMapping: {
|
||||
issueType: 'tag1',
|
||||
status: 'tag2',
|
||||
priority: 'tag3',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
tag1: 'Bug',
|
||||
tag2: 'Open',
|
||||
tag3: 'High',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when connector has no mapTags', async () => {
|
||||
const { resolveTagMapping } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
const result = resolveTagMapping(
|
||||
'no-tags',
|
||||
{ key: 'value' },
|
||||
{
|
||||
tagSlotMapping: { key: 'tag1' },
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when connector type is unknown', async () => {
|
||||
const { resolveTagMapping } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
const result = resolveTagMapping('unknown', { key: 'value' }, {})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when no tagSlotMapping in sourceConfig', async () => {
|
||||
mockMapTags.mockReturnValue({ issueType: 'Bug' })
|
||||
|
||||
const { resolveTagMapping } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
const result = resolveTagMapping('jira', { issueType: 'Bug' }, {})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets null for missing metadata keys', async () => {
|
||||
mockMapTags.mockReturnValue({
|
||||
issueType: 'Bug',
|
||||
status: undefined,
|
||||
})
|
||||
|
||||
const { resolveTagMapping } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
const result = resolveTagMapping(
|
||||
'jira',
|
||||
{ issueType: 'Bug' },
|
||||
{
|
||||
tagSlotMapping: {
|
||||
issueType: 'tag1',
|
||||
status: 'tag2',
|
||||
missing: 'tag3',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
tag1: 'Bug',
|
||||
tag2: null,
|
||||
tag3: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when sourceConfig is undefined', async () => {
|
||||
mockMapTags.mockReturnValue({ issueType: 'Bug' })
|
||||
|
||||
const { resolveTagMapping } = await import('@/lib/knowledge/connectors/sync-engine')
|
||||
|
||||
const result = resolveTagMapping('jira', { issueType: 'Bug' }, undefined)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@ const logger = createLogger('ConnectorSyncEngine')
|
||||
* Translates semantic keys returned by mapTags to actual DB slots using the
|
||||
* tagSlotMapping stored in sourceConfig during connector creation.
|
||||
*/
|
||||
function resolveTagMapping(
|
||||
export function resolveTagMapping(
|
||||
connectorType: string,
|
||||
metadata: Record<string, unknown>,
|
||||
sourceConfig?: Record<string, unknown>
|
||||
|
||||
113
apps/sim/lib/knowledge/constants.test.ts
Normal file
113
apps/sim/lib/knowledge/constants.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { allocateTagSlots, getSlotsForFieldType } from '@/lib/knowledge/constants'
|
||||
|
||||
describe('allocateTagSlots', () => {
|
||||
it.concurrent('assigns unique slots for multiple text tags', () => {
|
||||
const defs = [
|
||||
{ id: 'issueType', displayName: 'Issue Type', fieldType: 'text' },
|
||||
{ id: 'status', displayName: 'Status', fieldType: 'text' },
|
||||
{ id: 'priority', displayName: 'Priority', fieldType: 'text' },
|
||||
]
|
||||
|
||||
const { mapping, skipped } = allocateTagSlots(defs, new Set())
|
||||
|
||||
expect(mapping).toEqual({
|
||||
issueType: 'tag1',
|
||||
status: 'tag2',
|
||||
priority: 'tag3',
|
||||
})
|
||||
expect(skipped).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('assigns slots across different field types', () => {
|
||||
const defs = [
|
||||
{ id: 'label', displayName: 'Label', fieldType: 'text' },
|
||||
{ id: 'count', displayName: 'Count', fieldType: 'number' },
|
||||
{ id: 'updated', displayName: 'Updated', fieldType: 'date' },
|
||||
{ id: 'active', displayName: 'Active', fieldType: 'boolean' },
|
||||
]
|
||||
|
||||
const { mapping, skipped } = allocateTagSlots(defs, new Set())
|
||||
|
||||
expect(mapping).toEqual({
|
||||
label: 'tag1',
|
||||
count: 'number1',
|
||||
updated: 'date1',
|
||||
active: 'boolean1',
|
||||
})
|
||||
expect(skipped).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('skips already-used slots', () => {
|
||||
const defs = [
|
||||
{ id: 'a', displayName: 'A', fieldType: 'text' },
|
||||
{ id: 'b', displayName: 'B', fieldType: 'text' },
|
||||
]
|
||||
|
||||
const usedSlots = new Set(['tag1', 'tag3'])
|
||||
const { mapping, skipped } = allocateTagSlots(defs, usedSlots)
|
||||
|
||||
expect(mapping).toEqual({
|
||||
a: 'tag2',
|
||||
b: 'tag4',
|
||||
})
|
||||
expect(skipped).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('skips tags when all slots of that type are used', () => {
|
||||
const defs = [
|
||||
{ id: 'a', displayName: 'Date A', fieldType: 'date' },
|
||||
{ id: 'b', displayName: 'Date B', fieldType: 'date' },
|
||||
{ id: 'c', displayName: 'Date C', fieldType: 'date' },
|
||||
]
|
||||
|
||||
const { mapping, skipped } = allocateTagSlots(defs, new Set())
|
||||
|
||||
expect(mapping).toEqual({
|
||||
a: 'date1',
|
||||
b: 'date2',
|
||||
})
|
||||
expect(skipped).toEqual(['Date C'])
|
||||
})
|
||||
|
||||
it.concurrent('returns empty mapping when all slots are used', () => {
|
||||
const allTextSlots = getSlotsForFieldType('text')
|
||||
const usedSlots = new Set<string>(allTextSlots)
|
||||
|
||||
const defs = [{ id: 'label', displayName: 'Label', fieldType: 'text' }]
|
||||
const { mapping, skipped } = allocateTagSlots(defs, usedSlots)
|
||||
|
||||
expect(mapping).toEqual({})
|
||||
expect(skipped).toEqual(['Label'])
|
||||
})
|
||||
|
||||
it.concurrent('handles empty definitions list', () => {
|
||||
const { mapping, skipped } = allocateTagSlots([], new Set())
|
||||
|
||||
expect(mapping).toEqual({})
|
||||
expect(skipped).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('handles unknown field type gracefully', () => {
|
||||
const defs = [{ id: 'x', displayName: 'Unknown', fieldType: 'unknown' }]
|
||||
const { mapping, skipped } = allocateTagSlots(defs, new Set())
|
||||
|
||||
expect(mapping).toEqual({})
|
||||
expect(skipped).toEqual(['Unknown'])
|
||||
})
|
||||
|
||||
it.concurrent('does not mutate the input usedSlots set', () => {
|
||||
const defs = [
|
||||
{ id: 'a', displayName: 'A', fieldType: 'text' },
|
||||
{ id: 'b', displayName: 'B', fieldType: 'text' },
|
||||
]
|
||||
|
||||
const usedSlots = new Set<string>()
|
||||
allocateTagSlots(defs, usedSlots)
|
||||
|
||||
expect(usedSlots.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -93,6 +93,33 @@ export const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate tag slots for a set of tag definitions, avoiding already-used slots.
|
||||
* Returns a mapping of semantic IDs to slot names and a list of skipped tag names.
|
||||
*/
|
||||
export function allocateTagSlots(
|
||||
tagDefinitions: Array<{ id: string; displayName: string; fieldType: string }>,
|
||||
usedSlots: Set<string>
|
||||
): { mapping: Record<string, string>; skipped: string[] } {
|
||||
const mapping: Record<string, string> = {}
|
||||
const skipped: string[] = []
|
||||
const claimed = new Set(usedSlots)
|
||||
|
||||
for (const td of tagDefinitions) {
|
||||
const slots = getSlotsForFieldType(td.fieldType)
|
||||
const available = slots.find((s) => !claimed.has(s))
|
||||
|
||||
if (!available) {
|
||||
skipped.push(td.displayName)
|
||||
continue
|
||||
}
|
||||
claimed.add(available)
|
||||
mapping[td.id] = available
|
||||
}
|
||||
|
||||
return { mapping, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get placeholder text for value input based on field type
|
||||
*/
|
||||
|
||||
157
apps/sim/lib/knowledge/documents/utils.test.ts
Normal file
157
apps/sim/lib/knowledge/documents/utils.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
import { isRetryableError } from './utils'
|
||||
|
||||
describe('isRetryableError', () => {
|
||||
describe('retryable status codes', () => {
|
||||
it.concurrent('returns true for 429 on Error with status', () => {
|
||||
const error = Object.assign(new Error('Too Many Requests'), { status: 429 })
|
||||
expect(isRetryableError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for 502 on Error with status', () => {
|
||||
const error = Object.assign(new Error('Bad Gateway'), { status: 502 })
|
||||
expect(isRetryableError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for 503 on Error with status', () => {
|
||||
const error = Object.assign(new Error('Service Unavailable'), { status: 503 })
|
||||
expect(isRetryableError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for 504 on Error with status', () => {
|
||||
const error = Object.assign(new Error('Gateway Timeout'), { status: 504 })
|
||||
expect(isRetryableError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for plain object with status 429', () => {
|
||||
expect(isRetryableError({ status: 429 })).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for plain object with status 502', () => {
|
||||
expect(isRetryableError({ status: 502 })).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for plain object with status 503', () => {
|
||||
expect(isRetryableError({ status: 503 })).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for plain object with status 504', () => {
|
||||
expect(isRetryableError({ status: 504 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-retryable status codes', () => {
|
||||
it.concurrent('returns false for 400', () => {
|
||||
const error = Object.assign(new Error('Bad Request'), { status: 400 })
|
||||
expect(isRetryableError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for 401', () => {
|
||||
const error = Object.assign(new Error('Unauthorized'), { status: 401 })
|
||||
expect(isRetryableError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for 403', () => {
|
||||
const error = Object.assign(new Error('Forbidden'), { status: 403 })
|
||||
expect(isRetryableError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for 404', () => {
|
||||
const error = Object.assign(new Error('Not Found'), { status: 404 })
|
||||
expect(isRetryableError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for 500', () => {
|
||||
const error = Object.assign(new Error('Internal Server Error'), { status: 500 })
|
||||
expect(isRetryableError(error)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryable error messages', () => {
|
||||
it.concurrent('returns true for "rate limit" in message', () => {
|
||||
expect(isRetryableError(new Error('You have hit the rate limit'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for "rate_limit" in message', () => {
|
||||
expect(isRetryableError(new Error('rate_limit_exceeded'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for "too many requests" in message', () => {
|
||||
expect(isRetryableError(new Error('too many requests, slow down'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for "quota exceeded" in message', () => {
|
||||
expect(isRetryableError(new Error('API quota exceeded'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for "throttled" in message', () => {
|
||||
expect(isRetryableError(new Error('Request was throttled'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for "retry after" in message', () => {
|
||||
expect(isRetryableError(new Error('Please retry after 60 seconds'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for "temporarily unavailable" in message', () => {
|
||||
expect(isRetryableError(new Error('Service is temporarily unavailable'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('returns true for "service unavailable" in message', () => {
|
||||
expect(isRetryableError(new Error('The service unavailable right now'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('case insensitivity', () => {
|
||||
it.concurrent('matches "Rate Limit" with mixed case', () => {
|
||||
expect(isRetryableError(new Error('Rate Limit Exceeded'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('matches "THROTTLED" in uppercase', () => {
|
||||
expect(isRetryableError(new Error('REQUEST THROTTLED'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('matches "Too Many Requests" in title case', () => {
|
||||
expect(isRetryableError(new Error('Too Many Requests'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('null, undefined, and non-error inputs', () => {
|
||||
it.concurrent('returns false for null', () => {
|
||||
expect(isRetryableError(null)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for undefined', () => {
|
||||
expect(isRetryableError(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for empty string', () => {
|
||||
expect(isRetryableError('')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for a number', () => {
|
||||
expect(isRetryableError(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-retryable errors', () => {
|
||||
it.concurrent('returns false for Error with no status and unrelated message', () => {
|
||||
expect(isRetryableError(new Error('Something went wrong'))).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for plain object with only non-retryable status', () => {
|
||||
expect(isRetryableError({ status: 404 })).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('returns false for plain object with non-retryable status and no message', () => {
|
||||
expect(isRetryableError({ status: 500 })).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user