|
| 1 | +/** |
| 2 | + * Pins the listing-completeness signals the sync engine relies on to decide whether it may |
| 3 | + * hard-delete stored documents, plus the Link-header cursor parsing Circleback pagination |
| 4 | + * depends on. `listingCapped` and a truthful `hasMore` are the only things standing between |
| 5 | + * a partial listing and reconciliation purging the rest of the knowledge base. |
| 6 | + * |
| 7 | + * @vitest-environment node |
| 8 | + */ |
| 9 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 10 | + |
| 11 | +const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() })) |
| 12 | + |
| 13 | +vi.mock('@/lib/knowledge/documents/utils', () => ({ |
| 14 | + fetchWithRetry: mockFetchWithRetry, |
| 15 | + VALIDATE_RETRY_OPTIONS: {}, |
| 16 | +})) |
| 17 | +vi.mock('@/components/icons', () => ({ CirclebackIcon: () => null })) |
| 18 | + |
| 19 | +import { circlebackConnector } from '@/connectors/circleback/circleback' |
| 20 | + |
| 21 | +function meeting(id: string) { |
| 22 | + return { |
| 23 | + id, |
| 24 | + name: `Meeting ${id}`, |
| 25 | + createdAt: '2026-01-27T15:30:00Z', |
| 26 | + updatedAt: '2026-01-27T16:45:00Z', |
| 27 | + duration: 1800, |
| 28 | + tags: [{ id: 1, name: 'Customer' }], |
| 29 | + attendees: [{ profileId: 1, name: 'Oat Benson', email: 'oat@example.com' }], |
| 30 | + notes: '## Recap\nWe discussed the rollout.', |
| 31 | + actionItems: [ |
| 32 | + { |
| 33 | + id: 10, |
| 34 | + title: 'Send follow-up', |
| 35 | + description: '', |
| 36 | + assignee: { name: 'Oat Benson', email: 'oat@example.com' }, |
| 37 | + status: 'PENDING', |
| 38 | + }, |
| 39 | + ], |
| 40 | + insights: {}, |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +/** Queue a single Circleback list response with an optional RFC 8288 next link. */ |
| 45 | +function mockListResponse(body: unknown, nextCursor?: string, status = 200) { |
| 46 | + mockFetchWithRetry.mockResolvedValue({ |
| 47 | + ok: status >= 200 && status < 300, |
| 48 | + status, |
| 49 | + headers: { |
| 50 | + get: (name: string) => |
| 51 | + name.toLowerCase() === 'link' && nextCursor |
| 52 | + ? `<https://circleback.ai/api/meetings?cursor=${nextCursor}>; rel="next"` |
| 53 | + : null, |
| 54 | + }, |
| 55 | + json: async () => body, |
| 56 | + text: async () => JSON.stringify(body), |
| 57 | + } as unknown as Response) |
| 58 | +} |
| 59 | + |
| 60 | +const list = (sourceConfig: Record<string, unknown>, syncContext: Record<string, unknown>) => |
| 61 | + circlebackConnector.listDocuments('tok', sourceConfig, undefined, syncContext, undefined) |
| 62 | + |
| 63 | +describe('circleback connector listing completeness', () => { |
| 64 | + beforeEach(() => { |
| 65 | + mockFetchWithRetry.mockReset() |
| 66 | + }) |
| 67 | + |
| 68 | + it('parses the next cursor from the Link header on a normal page', async () => { |
| 69 | + mockListResponse([meeting('m1')], 'cur_2') |
| 70 | + |
| 71 | + const syncContext: Record<string, unknown> = {} |
| 72 | + const page = await list({}, syncContext) |
| 73 | + |
| 74 | + expect(page.hasMore).toBe(true) |
| 75 | + expect(page.nextCursor).toBe('cur_2') |
| 76 | + expect(syncContext.listingCapped).toBeUndefined() |
| 77 | + }) |
| 78 | + |
| 79 | + it('reports a complete listing when there is no next link', async () => { |
| 80 | + mockListResponse([meeting('m1'), meeting('m2')]) |
| 81 | + |
| 82 | + const syncContext: Record<string, unknown> = {} |
| 83 | + const page = await list({}, syncContext) |
| 84 | + |
| 85 | + expect(page.hasMore).toBe(false) |
| 86 | + expect(page.nextCursor).toBeUndefined() |
| 87 | + expect(syncContext.listingCapped).toBeUndefined() |
| 88 | + expect(page.documents).toHaveLength(2) |
| 89 | + }) |
| 90 | + |
| 91 | + it('never caps when no maxMeetings is configured', async () => { |
| 92 | + mockListResponse([meeting('m1'), meeting('m2')], 'cur_2') |
| 93 | + |
| 94 | + const syncContext: Record<string, unknown> = {} |
| 95 | + await list({ maxMeetings: '' }, syncContext) |
| 96 | + |
| 97 | + expect(syncContext.listingCapped).toBeUndefined() |
| 98 | + }) |
| 99 | + |
| 100 | + it('caps and flags when maxMeetings slices a page, hiding meetings that still exist', async () => { |
| 101 | + mockListResponse([meeting('m1'), meeting('m2'), meeting('m3')]) |
| 102 | + |
| 103 | + const syncContext: Record<string, unknown> = {} |
| 104 | + const page = await list({ maxMeetings: '2' }, syncContext) |
| 105 | + |
| 106 | + expect(page.documents).toHaveLength(2) |
| 107 | + expect(syncContext.listingCapped).toBe(true) |
| 108 | + expect(page.hasMore).toBe(false) |
| 109 | + }) |
| 110 | + |
| 111 | + it('caps and flags when maxMeetings lands on a page boundary but more pages exist', async () => { |
| 112 | + mockListResponse([meeting('m1'), meeting('m2')], 'cur_2') |
| 113 | + |
| 114 | + const syncContext: Record<string, unknown> = {} |
| 115 | + const page = await list({ maxMeetings: '2' }, syncContext) |
| 116 | + |
| 117 | + expect(page.documents).toHaveLength(2) |
| 118 | + expect(syncContext.listingCapped).toBe(true) |
| 119 | + expect(page.hasMore).toBe(false) |
| 120 | + }) |
| 121 | + |
| 122 | + it('does NOT flag when the cap lands exactly on the last meeting and the source is exhausted', async () => { |
| 123 | + mockListResponse([meeting('m1'), meeting('m2')]) |
| 124 | + |
| 125 | + const syncContext: Record<string, unknown> = {} |
| 126 | + const page = await list({ maxMeetings: '2' }, syncContext) |
| 127 | + |
| 128 | + expect(page.documents).toHaveLength(2) |
| 129 | + expect(syncContext.listingCapped).toBeUndefined() |
| 130 | + }) |
| 131 | + |
| 132 | + it('carries the cap across pages via totalDocsFetched', async () => { |
| 133 | + mockListResponse([meeting('m3'), meeting('m4')], 'cur_3') |
| 134 | + |
| 135 | + const syncContext: Record<string, unknown> = { totalDocsFetched: 1 } |
| 136 | + const page = await list({ maxMeetings: '2' }, syncContext) |
| 137 | + |
| 138 | + expect(page.documents).toHaveLength(1) |
| 139 | + expect(syncContext.totalDocsFetched).toBe(2) |
| 140 | + expect(syncContext.listingCapped).toBe(true) |
| 141 | + }) |
| 142 | +}) |
| 143 | + |
| 144 | +describe('circleback connector request shaping and documents', () => { |
| 145 | + beforeEach(() => { |
| 146 | + mockFetchWithRetry.mockReset() |
| 147 | + }) |
| 148 | + |
| 149 | + it('applies only valid scope filters and defaults ownership to Mine', async () => { |
| 150 | + mockListResponse([]) |
| 151 | + |
| 152 | + await list({ ownership: 'Everything', tagIds: '3, oops, 7' }, {}) |
| 153 | + |
| 154 | + const url = new URL(mockFetchWithRetry.mock.calls[0][0] as string) |
| 155 | + expect(url.searchParams.get('ownership')).toBe('Mine') |
| 156 | + expect(url.searchParams.getAll('tagIds')).toEqual(['3', '7']) |
| 157 | + }) |
| 158 | + |
| 159 | + it('returns deferred plain-text stubs with a metadata-based hash and source URL', async () => { |
| 160 | + mockListResponse([meeting('m1')]) |
| 161 | + |
| 162 | + const page = await list({}, {}) |
| 163 | + const stub = page.documents[0] |
| 164 | + |
| 165 | + expect(stub.mimeType).toBe('text/plain') |
| 166 | + expect(stub.contentDeferred).toBe(true) |
| 167 | + expect(stub.contentHash).toBe('circleback:m1:2026-01-27T16:45:00Z:notes') |
| 168 | + expect(stub.sourceUrl).toBe('https://circleback.ai/meetings/m1') |
| 169 | + }) |
| 170 | + |
| 171 | + it('varies the content hash with the transcript mode so toggling it rehydrates', async () => { |
| 172 | + mockListResponse([meeting('m1')]) |
| 173 | + const withTranscript = await list({ includeTranscript: 'true' }, {}) |
| 174 | + expect(withTranscript.documents[0].contentHash).toBe( |
| 175 | + 'circleback:m1:2026-01-27T16:45:00Z:transcript' |
| 176 | + ) |
| 177 | + }) |
| 178 | + |
| 179 | + it('assembles notes and action items into content with an identical hash on getDocument', async () => { |
| 180 | + mockFetchWithRetry.mockResolvedValue({ |
| 181 | + ok: true, |
| 182 | + status: 200, |
| 183 | + headers: { get: () => null }, |
| 184 | + json: async () => meeting('m1'), |
| 185 | + text: async () => '', |
| 186 | + } as unknown as Response) |
| 187 | + |
| 188 | + const doc = await circlebackConnector.getDocument('tok', {}, 'm1') |
| 189 | + |
| 190 | + expect(doc).not.toBeNull() |
| 191 | + expect(doc?.contentDeferred).toBe(false) |
| 192 | + expect(doc?.contentHash).toBe('circleback:m1:2026-01-27T16:45:00Z:notes') |
| 193 | + expect(doc?.content).toContain('# Meeting m1') |
| 194 | + expect(doc?.content).toContain('We discussed the rollout.') |
| 195 | + expect(doc?.content).toContain('- [ ] Send follow-up (Oat Benson)') |
| 196 | + /* Transcript is opt-in, so only the meeting endpoint is called by default. */ |
| 197 | + expect(mockFetchWithRetry).toHaveBeenCalledTimes(1) |
| 198 | + }) |
| 199 | + |
| 200 | + it('returns null for a 404 but rethrows other failures so indexed documents survive', async () => { |
| 201 | + mockFetchWithRetry.mockResolvedValueOnce({ |
| 202 | + ok: false, |
| 203 | + status: 404, |
| 204 | + headers: { get: () => null }, |
| 205 | + json: async () => ({}), |
| 206 | + text: async () => '', |
| 207 | + } as unknown as Response) |
| 208 | + expect(await circlebackConnector.getDocument('tok', {}, 'gone')).toBeNull() |
| 209 | + |
| 210 | + mockFetchWithRetry.mockResolvedValueOnce({ |
| 211 | + ok: false, |
| 212 | + status: 500, |
| 213 | + headers: { get: () => null }, |
| 214 | + json: async () => ({}), |
| 215 | + text: async () => '', |
| 216 | + } as unknown as Response) |
| 217 | + await expect(circlebackConnector.getDocument('tok', {}, 'm1')).rejects.toThrow('500') |
| 218 | + }) |
| 219 | + |
| 220 | + it('rejects caps that the parser would silently treat as unlimited', async () => { |
| 221 | + for (const bad of ['0', '0.5', 'Infinity', '-1', 'abc']) { |
| 222 | + const result = await circlebackConnector.validateConfig('tok', { maxMeetings: bad }) |
| 223 | + expect(result.valid).toBe(false) |
| 224 | + } |
| 225 | + |
| 226 | + mockFetchWithRetry.mockResolvedValue({ |
| 227 | + ok: true, |
| 228 | + status: 200, |
| 229 | + headers: { get: () => null }, |
| 230 | + json: async () => [], |
| 231 | + text: async () => '', |
| 232 | + } as unknown as Response) |
| 233 | + const ok = await circlebackConnector.validateConfig('tok', { maxMeetings: '25' }) |
| 234 | + expect(ok.valid).toBe(true) |
| 235 | + }) |
| 236 | + |
| 237 | + it('maps metadata to declared tag keys', () => { |
| 238 | + const tags = circlebackConnector.mapTags?.({ |
| 239 | + title: 'Weekly Sync', |
| 240 | + attendees: ['Oat Benson', 'Sam Lee'], |
| 241 | + tags: ['Customer'], |
| 242 | + meetingDate: '2026-01-27T15:30:00Z', |
| 243 | + duration: 1800, |
| 244 | + }) |
| 245 | + |
| 246 | + expect(tags?.title).toBe('Weekly Sync') |
| 247 | + expect(tags?.attendees).toBe('Oat Benson, Sam Lee') |
| 248 | + expect(tags?.tags).toBe('Customer') |
| 249 | + expect(tags?.meetingDate).toBeInstanceOf(Date) |
| 250 | + expect(tags?.duration).toBe(1800) |
| 251 | + }) |
| 252 | +}) |
0 commit comments