Files
simstudioai--sim/apps/sim/lib/knowledge/connectors/sync-engine.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

291 lines
9.1 KiB
TypeScript

/**
* @vitest-environment node
*/
import { authOAuthUtilsMock, urlsMock } from '@sim/testing'
import { generateShortId } from '@sim/utils/id'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({ db: {} }))
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
eq: vi.fn(),
inArray: vi.fn(),
isNull: vi.fn(),
ne: vi.fn(),
}))
vi.mock('@/lib/core/utils/urls', () => urlsMock)
vi.mock('@/lib/knowledge/documents/service', () => ({
hardDeleteDocuments: vi.fn(),
isTriggerAvailable: vi.fn(),
processDocumentAsync: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({ StorageService: {} }))
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/background/knowledge-connector-sync', () => ({
knowledgeConnectorSync: { trigger: vi.fn() },
}))
const mockMapTags = vi.fn()
vi.mock('@/connectors/registry.server', () => ({
CONNECTOR_REGISTRY: {
jira: {
mapTags: mockMapTags,
},
'no-tags': {
name: 'No Tags',
},
},
}))
describe('shouldReconcileDeletions', () => {
it('runs on a clean full listing', async () => {
const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
expect(shouldReconcileDeletions(false, {}, undefined)).toBe(true)
expect(shouldReconcileDeletions(false, undefined, undefined)).toBe(true)
})
it('never runs on incremental syncs', async () => {
const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
expect(shouldReconcileDeletions(true, {}, undefined)).toBe(false)
expect(shouldReconcileDeletions(true, {}, true)).toBe(false)
expect(shouldReconcileDeletions(true, { listingCapped: true }, true)).toBe(false)
})
it('skips when a connector capped the listing', async () => {
const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
expect(shouldReconcileDeletions(false, { listingCapped: true }, undefined)).toBe(false)
expect(shouldReconcileDeletions(false, { listingCapped: true }, false)).toBe(false)
})
it('lets a forced fullSync override a connector cap', async () => {
const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
expect(shouldReconcileDeletions(false, { listingCapped: true }, true)).toBe(true)
})
it('never runs when the engine truncated pagination, even on a forced fullSync', async () => {
const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
expect(shouldReconcileDeletions(false, { listingTruncated: true }, undefined)).toBe(false)
expect(shouldReconcileDeletions(false, { listingTruncated: true }, true)).toBe(false)
expect(
shouldReconcileDeletions(false, { listingCapped: true, listingTruncated: true }, true)
).toBe(false)
})
})
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()
})
})
describe('classifyExternalDoc', () => {
const base = { content: 'hello', contentDeferred: false, contentHash: 'h1' }
it('records a new skipped file as a failed row', async () => {
const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine')
expect(
classifyExternalDoc({ ...base, content: '', skippedReason: 'too big' }, undefined)
).toEqual({ type: 'skip' })
})
it('keeps an already-indexed file as-is when it becomes skipped (last-known-good)', async () => {
const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine')
expect(
classifyExternalDoc(
{ ...base, content: '', skippedReason: 'too big' },
{
id: 'doc-1',
contentHash: 'old',
}
)
).toEqual({ type: 'unchanged' })
})
it('drops empty non-deferred content', async () => {
const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine')
expect(classifyExternalDoc({ ...base, content: ' ' }, undefined)).toEqual({ type: 'drop' })
})
it('adds new content and deferred stubs', async () => {
const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine')
expect(classifyExternalDoc(base, undefined)).toEqual({ type: 'add' })
expect(classifyExternalDoc({ ...base, content: '', contentDeferred: true }, undefined)).toEqual(
{ type: 'add' }
)
})
it('updates when the content hash changed and is unchanged otherwise', async () => {
const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine')
expect(classifyExternalDoc(base, { id: 'doc-1', contentHash: 'old' })).toEqual({
type: 'update',
existingId: 'doc-1',
})
expect(classifyExternalDoc(base, { id: 'doc-1', contentHash: 'h1' })).toEqual({
type: 'unchanged',
})
})
})
describe('chunkOpsByByteBudget', () => {
const MB = 1024 * 1024
const addOp = (sizeBytes?: number) => ({
type: 'add' as const,
extDoc: {
externalId: `e-${generateShortId()}`,
title: 'f',
content: 'x',
contentHash: 'h',
mimeType: 'text/plain',
...(sizeBytes != null ? { metadata: { fileSize: sizeBytes } } : {}),
},
})
const skipOp = (sizeBytes: number) => ({
type: 'skip' as const,
extDoc: {
externalId: `s-${generateShortId()}`,
title: 'f',
content: '',
contentHash: 'h',
mimeType: 'text/plain',
skippedReason: 'too big',
metadata: { fileSize: sizeBytes },
},
})
it('batches small ops up to the count cap', async () => {
const { chunkOpsByByteBudget } = await import('@/lib/knowledge/connectors/sync-engine')
const chunks = chunkOpsByByteBudget(
Array.from({ length: 7 }, () => addOp(1024)),
64 * MB,
5
)
expect(chunks.map((c) => c.length)).toEqual([5, 2])
})
it('isolates a file larger than the budget into its own chunk', async () => {
const { chunkOpsByByteBudget } = await import('@/lib/knowledge/connectors/sync-engine')
const chunks = chunkOpsByByteBudget([addOp(100 * MB), addOp(1024)], 64 * MB, 5)
expect(chunks.map((c) => c.length)).toEqual([1, 1])
})
it('caps summed bytes per chunk for medium files', async () => {
const { chunkOpsByByteBudget } = await import('@/lib/knowledge/connectors/sync-engine')
// 40 + 40 = 80 MB exceeds the 64 MB budget, so they split.
const chunks = chunkOpsByByteBudget([addOp(40 * MB), addOp(40 * MB)], 64 * MB, 5)
expect(chunks.map((c) => c.length)).toEqual([1, 1])
})
it('treats skip ops as zero bytes so they do not consume the budget', async () => {
const { chunkOpsByByteBudget } = await import('@/lib/knowledge/connectors/sync-engine')
const chunks = chunkOpsByByteBudget(
[skipOp(100 * MB), skipOp(100 * MB), addOp(1024)],
64 * MB,
5
)
expect(chunks).toHaveLength(1)
})
})