|
| 1 | +/** |
| 2 | + * Pins the Otter connector's listing-completeness and change-detection contracts. Otter has no |
| 3 | + * modification timestamp and reconciliation deletes whatever a complete listing omits, so a |
| 4 | + * malformed page must fail rather than shrink, and edits must still reach recent conversations. |
| 5 | + */ |
| 6 | +import { knowledgeDocumentsUtilsMock } from '@sim/testing/mocks/knowledge-documents-utils.mock' |
| 7 | +import { |
| 8 | + knowledgeSecureFetchMock, |
| 9 | + knowledgeSecureFetchMockFns, |
| 10 | +} from '@sim/testing/mocks/knowledge-secure-fetch.mock' |
| 11 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 12 | + |
| 13 | +vi.mock('@/lib/knowledge/documents/utils', () => knowledgeDocumentsUtilsMock) |
| 14 | +vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => knowledgeSecureFetchMock) |
| 15 | + |
| 16 | +import { otterConnector } from '@/connectors/otter/otter' |
| 17 | + |
| 18 | +const mockFetchWithRetry = knowledgeSecureFetchMockFns.mockFetchWithRetry |
| 19 | +const NOW = new Date('2026-09-28T12:00:00Z') |
| 20 | + |
| 21 | +/** A conversation shaped like the documented `GET /conversations` item. */ |
| 22 | +function conversation(id: string, createdAt = '2026-09-27T10:00:00Z') { |
| 23 | + return { |
| 24 | + id, |
| 25 | + title: `Meeting ${id}`, |
| 26 | + url: `https://otter.ai/u/${id}`, |
| 27 | + owner: { |
| 28 | + id: 'u1', |
| 29 | + name: 'Jane Doe', |
| 30 | + first_name: 'Jane', |
| 31 | + last_name: 'Doe', |
| 32 | + email: 'jane@example.com', |
| 33 | + }, |
| 34 | + created_at: createdAt, |
| 35 | + process_status: { abstract_summary: 'finished', action_item: 'finished', outline: 'finished' }, |
| 36 | + calendar_guests: [{ name: 'John Doe', email: 'john@example.com' }], |
| 37 | + shared_emails: [], |
| 38 | + shared_channels: [], |
| 39 | + abstract_summary: 'Discussed the launch.', |
| 40 | + conf_join_url: null, |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +function detail(id: string, createdAt: string, actionItem: string, transcript = 'Jane 00:00\nHi') { |
| 45 | + return { |
| 46 | + meta: { retrieved_at: '2026-09-28T12:00:00Z' }, |
| 47 | + data: { |
| 48 | + ...conversation(id, createdAt), |
| 49 | + relationships: { |
| 50 | + action_items: [{ id: 'a1', text: actionItem, assignee: null, status: null }], |
| 51 | + insights: [], |
| 52 | + outline: [], |
| 53 | + transcript: { content: transcript, format: 'txt' }, |
| 54 | + }, |
| 55 | + }, |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +const respond = (body: unknown, status = 200) => |
| 60 | + new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }) |
| 61 | + |
| 62 | +const list = (sourceConfig: Record<string, unknown> = {}, syncContext = {}) => |
| 63 | + otterConnector.listDocuments('key', sourceConfig, undefined, syncContext) |
| 64 | +const get = (id: string, sourceConfig: Record<string, unknown> = {}) => |
| 65 | + otterConnector.getDocument('key', sourceConfig, id) |
| 66 | +const match = (candidate: string, stored: string) => |
| 67 | + otterConnector.matchContentHash!(candidate, stored) |
| 68 | + |
| 69 | +describe('otter connector', () => { |
| 70 | + beforeEach(() => { |
| 71 | + vi.useFakeTimers({ toFake: ['Date'] }) |
| 72 | + vi.setSystemTime(NOW) |
| 73 | + mockFetchWithRetry.mockReset() |
| 74 | + }) |
| 75 | + afterEach(() => { |
| 76 | + vi.useRealTimers() |
| 77 | + }) |
| 78 | + |
| 79 | + describe('listing completeness', () => { |
| 80 | + it('rejects a page containing a conversation without an ID', async () => { |
| 81 | + mockFetchWithRetry.mockResolvedValueOnce( |
| 82 | + respond({ |
| 83 | + meta: { has_more: false }, |
| 84 | + data: [conversation('c1'), { ...conversation('c2'), id: null }], |
| 85 | + }) |
| 86 | + ) |
| 87 | + await expect(list()).rejects.toThrow('conversation without an ID') |
| 88 | + }) |
| 89 | + |
| 90 | + it('rejects a page whose has_more is not a boolean', async () => { |
| 91 | + mockFetchWithRetry.mockResolvedValueOnce(respond({ meta: {}, data: [conversation('c1')] })) |
| 92 | + await expect(list()).rejects.toThrow('missing meta.has_more') |
| 93 | + }) |
| 94 | + |
| 95 | + it('marks the listing capped only when the cap hid conversations', async () => { |
| 96 | + mockFetchWithRetry.mockResolvedValueOnce( |
| 97 | + respond({ |
| 98 | + meta: { has_more: true, next_cursor: 'n2' }, |
| 99 | + data: [conversation('c1'), conversation('c2')], |
| 100 | + }) |
| 101 | + ) |
| 102 | + const capped: Record<string, unknown> = {} |
| 103 | + const page = await list({ maxConversations: '2' }, capped) |
| 104 | + expect(page.hasMore).toBe(false) |
| 105 | + expect(capped.listingCapped).toBe(true) |
| 106 | + |
| 107 | + mockFetchWithRetry.mockResolvedValueOnce( |
| 108 | + respond({ meta: { has_more: false }, data: [conversation('c1'), conversation('c2')] }) |
| 109 | + ) |
| 110 | + const exact: Record<string, unknown> = {} |
| 111 | + await list({ maxConversations: '2' }, exact) |
| 112 | + expect(exact.listingCapped).toBeUndefined() |
| 113 | + }) |
| 114 | + }) |
| 115 | + |
| 116 | + describe('change detection', () => { |
| 117 | + it('keeps an older conversation current until its metadata changes', async () => { |
| 118 | + const created = '2026-08-01T10:00:00Z' |
| 119 | + mockFetchWithRetry.mockResolvedValueOnce( |
| 120 | + respond({ meta: { has_more: false }, data: [conversation('old', created)] }) |
| 121 | + ) |
| 122 | + const [stub] = (await list()).documents |
| 123 | + mockFetchWithRetry.mockResolvedValueOnce(respond(detail('old', created, 'Send pricing'))) |
| 124 | + const hydrated = await get('old') |
| 125 | + |
| 126 | + vi.setSystemTime(new Date('2026-09-29T12:00:00Z')) |
| 127 | + mockFetchWithRetry.mockResolvedValueOnce( |
| 128 | + respond({ meta: { has_more: false }, data: [conversation('old', created)] }) |
| 129 | + ) |
| 130 | + const [nextDay] = (await list()).documents |
| 131 | + |
| 132 | + expect(match(nextDay.contentHash, hydrated!.contentHash)).toBe('current') |
| 133 | + expect(nextDay.contentHash).toBe(stub.contentHash) |
| 134 | + }) |
| 135 | + |
| 136 | + it('re-reads a recent conversation daily and re-indexes only changed text', async () => { |
| 137 | + const created = '2026-09-27T10:00:00Z' |
| 138 | + mockFetchWithRetry.mockResolvedValueOnce(respond(detail('new', created, 'Send pricing'))) |
| 139 | + const stored = await get('new') |
| 140 | + |
| 141 | + vi.setSystemTime(new Date('2026-09-29T12:00:00Z')) |
| 142 | + mockFetchWithRetry.mockResolvedValueOnce( |
| 143 | + respond({ meta: { has_more: false }, data: [conversation('new', created)] }) |
| 144 | + ) |
| 145 | + const [nextDay] = (await list()).documents |
| 146 | + expect(match(nextDay.contentHash, stored!.contentHash)).toBe('stale') |
| 147 | + |
| 148 | + mockFetchWithRetry.mockResolvedValueOnce(respond(detail('new', created, 'Send pricing'))) |
| 149 | + const unchanged = await get('new') |
| 150 | + expect(match(unchanged!.contentHash, stored!.contentHash)).toBe('equivalent') |
| 151 | + |
| 152 | + mockFetchWithRetry.mockResolvedValueOnce(respond(detail('new', created, 'Send pricing v2'))) |
| 153 | + const edited = await get('new') |
| 154 | + expect(match(edited!.contentHash, stored!.contentHash)).toBe('stale') |
| 155 | + }) |
| 156 | + }) |
| 157 | + |
| 158 | + describe('hydration', () => { |
| 159 | + it('treats only a missing conversation as gone and rethrows transient failures', async () => { |
| 160 | + mockFetchWithRetry.mockResolvedValueOnce(respond({ error: 'not_found' }, 404)) |
| 161 | + await expect(get('gone')).resolves.toBeNull() |
| 162 | + |
| 163 | + mockFetchWithRetry.mockResolvedValueOnce(respond({ error: 'rate_limited' }, 429)) |
| 164 | + await expect(get('busy')).rejects.toThrow('429') |
| 165 | + }) |
| 166 | + |
| 167 | + it('indexes the notes when the full response exceeds the download limit', async () => { |
| 168 | + const created = '2026-09-27T10:00:00Z' |
| 169 | + const oversized = detail('big', created, 'Send pricing', 'x'.repeat(17 * 1024 * 1024)) |
| 170 | + mockFetchWithRetry |
| 171 | + .mockResolvedValueOnce(respond(oversized)) |
| 172 | + .mockResolvedValueOnce(respond(detail('big', created, 'Send pricing', ''))) |
| 173 | + |
| 174 | + const document = await get('big') |
| 175 | + |
| 176 | + expect(document?.skippedReason).toBeUndefined() |
| 177 | + expect(document?.content).toContain('Send pricing') |
| 178 | + expect(document?.content).toContain('Transcript omitted') |
| 179 | + expect(String(mockFetchWithRetry.mock.calls[1][0])).toContain( |
| 180 | + 'include=action_items%2Cinsights%2Coutline' |
| 181 | + ) |
| 182 | + }) |
| 183 | + }) |
| 184 | +}) |
0 commit comments