Skip to content

Commit 7e5a792

Browse files
committed
test(search): verify unchanged document indexing recovery
1 parent 149eb03 commit 7e5a792

1 file changed

Lines changed: 314 additions & 2 deletions

File tree

apps/sim/lib/knowledge/__integration__/providers-live.integration.ts

Lines changed: 314 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { readFile } from 'node:fs/promises'
33
import { parseEnv } from 'node:util'
44
import { db } from '@sim/db'
55
import {
6+
credential,
7+
credentialGroup,
68
document,
79
embedding,
810
knowledgeConnector,
11+
knowledgeConnectorMember,
12+
knowledgeConnectorMemberSyncLog,
13+
resourcePolicy,
914
user,
1015
workspace,
1116
workspaceFiles,
@@ -15,15 +20,24 @@ import { generateId } from '@sim/utils/id'
1520
import { eq, inArray } from 'drizzle-orm'
1621
import { PDFDocument } from 'pdf-lib'
1722
import sharp from 'sharp'
18-
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
23+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
1924
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
2025
import { env } from '@/lib/core/config/env'
21-
import { seedKnowledgeAclFixture } from '@/lib/knowledge/__integration__/seed-source-access-fixture'
26+
import { compileCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy'
27+
import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
28+
import { encryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
29+
import {
30+
seedKnowledgeAclFixture,
31+
seedKnowledgeMemberFixture,
32+
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
33+
import { syncKnowledgeConnector } from '@/lib/knowledge/application/connectors'
2234
import { searchKnowledge } from '@/lib/knowledge/application/search'
35+
import { grantKnowledgeConnectorCredentialAccess } from '@/lib/knowledge/connectors/member-access'
2336
import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock'
2437
import { addDocument, persistDocumentAcls } from '@/lib/knowledge/connectors/sync-persistence'
2538
import { processDocument } from '@/lib/knowledge/documents/document-processor'
2639
import { processDocumentAsync } from '@/lib/knowledge/documents/service'
40+
import { QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types'
2741
import { generateEmbeddings } from '@/lib/knowledge/embeddings'
2842
import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance'
2943
import { deleteFile } from '@/lib/uploads/core/storage-service'
@@ -145,6 +159,304 @@ describe.skipIf(!credentialsFile)('real embedding and scanned PDF providers', ()
145159
expect(result.results.map((row) => row.documentId)).toContain(created.documentId)
146160
}, 120000)
147161

162+
it('recovers unchanged Gmail documents after a real embedding rejection through manual sync', async () => {
163+
const previous = {
164+
OPENAI_API_KEY: env.OPENAI_API_KEY,
165+
GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID,
166+
GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET,
167+
}
168+
const fetchProvider = globalThis.fetch
169+
let rejectedEmbeddingRequests = 0
170+
let hydratedThreads = 0
171+
/** Only Gmail HTTP is a fixture. OpenAI receives real requests and returns real errors/vectors. */
172+
const provider = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
173+
const url = new URL(input instanceof Request ? input.url : input)
174+
if (url.origin === 'https://api.openai.com') {
175+
const response = await fetchProvider(input, init)
176+
if (response.status === 401) rejectedEmbeddingRequests++
177+
return response
178+
}
179+
if (url.origin !== 'https://gmail.googleapis.com') {
180+
throw new Error(`Unexpected recovery fixture request: ${url.origin}${url.pathname}`)
181+
}
182+
if (url.pathname.endsWith('/labels')) return Response.json({ labels: [] })
183+
if (url.pathname.endsWith('/threads'))
184+
return Response.json({ threads: [{ id: 'unchanged-recovery-thread' }] })
185+
expect(url.pathname.endsWith('/threads/unchanged-recovery-thread')).toBe(true)
186+
const metadata = { id: 'unchanged-recovery-thread', historyId: '100' }
187+
if (url.searchParams.get('format') === 'minimal') return Response.json(metadata)
188+
hydratedThreads++
189+
return Response.json({
190+
...metadata,
191+
messages: [
192+
{
193+
id: 'unchanged-recovery-message',
194+
threadId: metadata.id,
195+
internalDate: '1700000000000',
196+
payload: {
197+
mimeType: 'text/plain',
198+
headers: [{ name: 'Subject', value: 'Kestrel invoice exception approved' }],
199+
body: {
200+
data: Buffer.from(
201+
'Morgan Ellis approved net-45 payment terms for the Kestrel invoice. Reference KESTREL-RECOVERY-7477.'
202+
).toString('base64url'),
203+
},
204+
},
205+
},
206+
],
207+
})
208+
})
209+
try {
210+
Object.assign(env, {
211+
GOOGLE_CLIENT_ID: 'gmail-recovery-fixture-client',
212+
GOOGLE_CLIENT_SECRET: 'gmail-recovery-fixture-secret',
213+
})
214+
const fixture = await seedKnowledgeMemberFixture(ids)
215+
const policy = await getCredentialGroupProviderAdapter('gmail').getPolicy(undefined, {
216+
workspaceId: ids.workspaceId,
217+
credentialGroupId: fixture.groupId,
218+
credentialGroupOptionId: fixture.optionId,
219+
})
220+
await db
221+
.update(credentialGroup)
222+
.set({
223+
options: [
224+
{
225+
id: fixture.optionId,
226+
provider: 'gmail',
227+
label: 'Gmail recovery fixture',
228+
authorizationAppId: policy.authorizationAppId,
229+
requiredScopes: policy.requiredScopes,
230+
scopeVersion: policy.scopeVersion,
231+
required: false,
232+
status: 'active',
233+
},
234+
],
235+
})
236+
.where(eq(credentialGroup.id, fixture.groupId))
237+
await db
238+
.update(knowledgeConnector)
239+
.set({
240+
connectorType: 'gmail',
241+
sourceConfig: { maxThreads: 0 },
242+
status: 'active',
243+
memberSyncStatus: 'idle',
244+
memberSyncLockToken: null,
245+
})
246+
.where(eq(knowledgeConnector.id, fixture.connectorId))
247+
for (const member of fixture.members) {
248+
await db
249+
.update(credential)
250+
.set({
251+
providerId: 'google-email',
252+
authorizationAppId: policy.authorizationAppId,
253+
managedOauthScopeVersion: policy.scopeVersion,
254+
grantedScopes: policy.requiredScopes,
255+
encryptedOauthTokenSet: await encryptManagedOAuthTokenSet({
256+
accessToken: 'gmail-recovery-fixture',
257+
}),
258+
accessTokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
259+
})
260+
.where(eq(credential.id, member.credentialId))
261+
await db
262+
.update(knowledgeConnectorMember)
263+
.set({
264+
subjectToken: `s:google-email:fixture-domain:${member.userId}`,
265+
})
266+
.where(eq(knowledgeConnectorMember.id, member.id))
267+
}
268+
await db
269+
.insert(resourcePolicy)
270+
.values({
271+
id: generateId(),
272+
workspaceId: ids.workspaceId,
273+
resourceType: 'credential_group',
274+
resourceId: fixture.groupId,
275+
document: compileCredentialGroupWorkflowAccessPolicy({
276+
credentialGroupId: fixture.groupId,
277+
allowedWorkflowIds: [],
278+
}),
279+
createdBy: ids.aliceId,
280+
updatedBy: ids.aliceId,
281+
})
282+
.onConflictDoNothing()
283+
await grantKnowledgeConnectorCredentialAccess(
284+
{
285+
workspaceId: ids.workspaceId,
286+
credentialGroupId: fixture.groupId,
287+
credentialGroupOptionId: fixture.optionId,
288+
connectorId: fixture.connectorId,
289+
},
290+
ids.aliceId
291+
)
292+
293+
const documents = () =>
294+
db
295+
.select()
296+
.from(document)
297+
.where(eq(document.connectorId, fixture.connectorId))
298+
.orderBy(document.id)
299+
let runCount = 0
300+
async function manualSync() {
301+
await syncKnowledgeConnector.execute({
302+
principal: {
303+
kind: 'session',
304+
userId: ids.aliceId,
305+
sessionId: 'synthetic-indexing-recovery',
306+
},
307+
input: {
308+
assertedWorkspaceId: ids.workspaceId,
309+
connectorId: fixture.connectorId,
310+
source: 'ui',
311+
},
312+
})
313+
runCount++
314+
await expect
315+
.poll(
316+
async () => {
317+
const logs = await db
318+
.select({ status: knowledgeConnectorMemberSyncLog.status })
319+
.from(knowledgeConnectorMemberSyncLog)
320+
.where(eq(knowledgeConnectorMemberSyncLog.connectorId, fixture.connectorId))
321+
return logs.filter((log) => log.status === 'completed').length
322+
},
323+
{ timeout: 60000, interval: 100 }
324+
)
325+
.toBe(runCount)
326+
}
327+
const search = () =>
328+
searchKnowledge.execute({
329+
principal: {
330+
kind: 'session',
331+
userId: ids.aliceId,
332+
sessionId: 'synthetic-indexing-recovery',
333+
},
334+
input: {
335+
workspaceId: ids.workspaceId,
336+
knowledgeBaseIds: [ids.knowledgeBaseId],
337+
query: 'Who approved the Kestrel invoice payment terms?',
338+
topK: 3,
339+
},
340+
})
341+
Object.assign(env, { OPENAI_API_KEY: 'sk-synthetic-invalid-indexing-recovery' })
342+
await manualSync()
343+
await expect
344+
.poll(async () => (await documents()).map((row) => row.processingStatus), {
345+
timeout: 60000,
346+
interval: 100,
347+
})
348+
.toEqual(['failed', 'failed'])
349+
const failed = await documents()
350+
expect(rejectedEmbeddingRequests).toBe(2)
351+
expect(failed.every((row) => row.processingError && row.processingAttempts === 1)).toBe(true)
352+
expect(
353+
await db
354+
.select()
355+
.from(embedding)
356+
.where(
357+
inArray(
358+
embedding.documentId,
359+
failed.map((row) => row.id)
360+
)
361+
)
362+
).toEqual([])
363+
const hydratedBeforeRecovery = hydratedThreads
364+
365+
Object.assign(env, { OPENAI_API_KEY: previous.OPENAI_API_KEY })
366+
expect(
367+
(await search()).results.filter((row) => failed.some((item) => item.id === row.documentId))
368+
).toEqual([])
369+
await manualSync()
370+
expect(
371+
(await documents()).map((row) => ({
372+
id: row.id,
373+
hash: row.contentHash,
374+
status: row.processingStatus,
375+
attempts: row.processingAttempts,
376+
}))
377+
).toEqual(
378+
failed.map((row) => ({ id: row.id, hash: row.contentHash, status: 'failed', attempts: 1 }))
379+
)
380+
381+
/** Age only this fixture's document lifecycle timestamps, preserving their order and the unchanged content. */
382+
const elapsedGraceMs = QUEUED_DISPATCH_GRACE_MS + 1000
383+
for (const row of failed) {
384+
const age = (value: Date | null) => value && new Date(value.getTime() - elapsedGraceMs)
385+
await db
386+
.update(document)
387+
.set({
388+
uploadedAt: age(row.uploadedAt)!,
389+
processingQueuedAt: age(row.processingQueuedAt),
390+
processingStartedAt: age(row.processingStartedAt),
391+
processingCompletedAt: age(row.processingCompletedAt),
392+
})
393+
.where(eq(document.id, row.id))
394+
}
395+
await manualSync()
396+
await expect
397+
.poll(async () => (await documents()).map((row) => row.processingStatus), {
398+
timeout: 60000,
399+
interval: 100,
400+
})
401+
.toEqual(['completed', 'completed'])
402+
const recovered = await documents()
403+
expect(
404+
recovered.map((row) => ({
405+
id: row.id,
406+
externalId: row.externalId,
407+
hash: row.contentHash,
408+
storageKey: row.storageKey,
409+
}))
410+
).toEqual(
411+
failed.map((row) => ({
412+
id: row.id,
413+
externalId: row.externalId,
414+
hash: row.contentHash,
415+
storageKey: row.storageKey,
416+
}))
417+
)
418+
expect(
419+
recovered.every((row) => row.processingError === null && row.processingAttempts === 0)
420+
).toBe(true)
421+
expect(hydratedThreads).toBe(hydratedBeforeRecovery)
422+
const vectors = await db
423+
.select()
424+
.from(embedding)
425+
.where(
426+
inArray(
427+
embedding.documentId,
428+
recovered.map((row) => row.id)
429+
)
430+
)
431+
expect(vectors).toHaveLength(2)
432+
expect(
433+
vectors.every(
434+
(row) => row.embedding?.length === 1536 && row.embedding.every(Number.isFinite)
435+
)
436+
).toBe(true)
437+
const aliceDocument = recovered.find((row) =>
438+
row.externalId?.startsWith(`member:${fixture.members[0].id}:`)
439+
)!
440+
const bobDocument = recovered.find((row) => row.id !== aliceDocument.id)!
441+
const results = await search()
442+
expect(results.results[0]?.documentId).toBe(aliceDocument.id)
443+
expect(results.results.some((row) => row.documentId === bobDocument.id)).toBe(false)
444+
logger.info('Real OpenAI indexing recovery through unchanged-source manual sync', {
445+
rejectedEmbeddingRequests,
446+
retryGraceMs: QUEUED_DISPATCH_GRACE_MS,
447+
fixtureLifecycleTimestampsAged: true,
448+
completedDocuments: recovered.length,
449+
sourceContentUnchanged: true,
450+
documentIdsAndStorageUnchanged: true,
451+
authorizedDocumentRank: 1,
452+
peerDocumentExcluded: true,
453+
})
454+
} finally {
455+
Object.assign(env, previous)
456+
provider.mockRestore()
457+
}
458+
}, 180000)
459+
148460
it('ranks synthetic integration documents with real OpenAI embeddings and excludes private source content', async () => {
149461
const corpus = [
150462
{

0 commit comments

Comments
 (0)