From 74db11c0fe315b20e4f3f7cf354d8147d25a705e Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 17 Feb 2026 10:51:06 -0800 Subject: [PATCH] added tests --- .../[connectorId]/documents/route.test.ts | 300 +++++++++++++ .../connectors/[connectorId]/route.test.ts | 231 ++++++++++ .../[connectorId]/sync/route.test.ts | 133 ++++++ .../api/knowledge/[id]/connectors/route.ts | 18 +- .../connectors/confluence/confluence.test.ts | 31 ++ apps/sim/connectors/confluence/confluence.ts | 2 +- apps/sim/connectors/mapTags.test.ts | 393 ++++++++++++++++++ .../knowledge/connectors/sync-engine.test.ts | 146 +++++++ .../lib/knowledge/connectors/sync-engine.ts | 2 +- apps/sim/lib/knowledge/constants.test.ts | 113 +++++ apps/sim/lib/knowledge/constants.ts | 27 ++ .../sim/lib/knowledge/documents/utils.test.ts | 157 +++++++ 12 files changed, 1538 insertions(+), 15 deletions(-) create mode 100644 apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts create mode 100644 apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts create mode 100644 apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts create mode 100644 apps/sim/connectors/confluence/confluence.test.ts create mode 100644 apps/sim/connectors/mapTags.test.ts create mode 100644 apps/sim/lib/knowledge/connectors/sync-engine.test.ts create mode 100644 apps/sim/lib/knowledge/constants.test.ts create mode 100644 apps/sim/lib/knowledge/documents/utils.test.ts diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts new file mode 100644 index 000000000..84add7b74 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts @@ -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']) + }) + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts new file mode 100644 index 000000000..34ac7694a --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -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) + }) + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts new file mode 100644 index 000000000..9dd8e0818 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -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' }) + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 57204f518..4a09f1a60 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -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(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) { diff --git a/apps/sim/connectors/confluence/confluence.test.ts b/apps/sim/connectors/confluence/confluence.test.ts new file mode 100644 index 000000000..629deae5f --- /dev/null +++ b/apps/sim/connectors/confluence/confluence.test.ts @@ -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 & ")).toBe("it's a test & ") + }) +}) diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index bf64d4366..79be0ce96 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -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, '\\"') } diff --git a/apps/sim/connectors/mapTags.test.ts b/apps/sim/connectors/mapTags.test.ts new file mode 100644 index 000000000..7fbeb531c --- /dev/null +++ b/apps/sim/connectors/mapTags.test.ts @@ -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) }) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts new file mode 100644 index 000000000..7fef3b998 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -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() + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index d5bd7e6aa..61c040184 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -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, sourceConfig?: Record diff --git a/apps/sim/lib/knowledge/constants.test.ts b/apps/sim/lib/knowledge/constants.test.ts new file mode 100644 index 000000000..05f6254d9 --- /dev/null +++ b/apps/sim/lib/knowledge/constants.test.ts @@ -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(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() + allocateTagSlots(defs, usedSlots) + + expect(usedSlots.size).toBe(0) + }) +}) diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 3ed4b5e4e..3362735ac 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -93,6 +93,33 @@ export const FIELD_TYPE_LABELS: Record = { 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 +): { mapping: Record; skipped: string[] } { + const mapping: Record = {} + 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 */ diff --git a/apps/sim/lib/knowledge/documents/utils.test.ts b/apps/sim/lib/knowledge/documents/utils.test.ts new file mode 100644 index 000000000..3156d21ff --- /dev/null +++ b/apps/sim/lib/knowledge/documents/utils.test.ts @@ -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) + }) + }) +})