added tests

This commit is contained in:
waleed
2026-02-17 10:51:06 -08:00
parent e207ad2502
commit 74db11c0fe
12 changed files with 1538 additions and 15 deletions

View File

@@ -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'])
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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' })
})
})

View File

@@ -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) {

View 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>")
})
})

View File

@@ -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, '\\"')
}

View 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) })
})
})

View 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()
})
})

View File

@@ -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>

View 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)
})
})

View File

@@ -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
*/

View 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)
})
})
})