|
3 | 3 | */ |
4 | 4 | import { beforeEach, describe, expect, it, vi } from 'vitest' |
5 | 5 |
|
6 | | -const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() })) |
| 6 | +const { mockFetchWithRetry, mockReadBoundedHttpErrorPayload } = vi.hoisted(() => ({ |
| 7 | + mockFetchWithRetry: vi.fn(), |
| 8 | + mockReadBoundedHttpErrorPayload: vi.fn(), |
| 9 | +})) |
7 | 10 |
|
8 | 11 | vi.mock('@/lib/knowledge/documents/utils', () => ({ |
9 | 12 | fetchWithRetry: mockFetchWithRetry, |
10 | | - readBoundedHttpErrorBody: async (response: Response) => (await response.text()).slice(0, 2000), |
11 | | - readBoundedHttpErrorPayload: async (response: Response) => response.text(), |
| 13 | + readBoundedHttpErrorPayload: mockReadBoundedHttpErrorPayload, |
12 | 14 | VALIDATE_RETRY_OPTIONS: {}, |
13 | 15 | })) |
14 | 16 | vi.mock('@/components/icons', () => ({ NotionIcon: () => null })) |
@@ -52,6 +54,14 @@ function dataSourceCursor(value: Record<string, unknown>): string { |
52 | 54 | return `notion-data-sources:v1:${encodeURIComponent(JSON.stringify(value))}` |
53 | 55 | } |
54 | 56 |
|
| 57 | +beforeEach(() => { |
| 58 | + mockReadBoundedHttpErrorPayload.mockReset() |
| 59 | + mockReadBoundedHttpErrorPayload.mockImplementation(async (response: Response) => ({ |
| 60 | + ok: true, |
| 61 | + body: await response.text(), |
| 62 | + })) |
| 63 | +}) |
| 64 | + |
55 | 65 | describe('notion markdown hydration', () => { |
56 | 66 | beforeEach(() => { |
57 | 67 | mockFetchWithRetry.mockReset() |
@@ -226,6 +236,35 @@ describe('notion markdown hydration', () => { |
226 | 236 | ) |
227 | 237 | }) |
228 | 238 |
|
| 239 | + it('recovers the expression from an unsupported equation block', async () => { |
| 240 | + mockFetchWithRetry |
| 241 | + .mockResolvedValueOnce(notionResponse(page())) |
| 242 | + .mockResolvedValueOnce( |
| 243 | + notionResponse({ |
| 244 | + markdown: 'Before unsupported equation: <unknown>', |
| 245 | + truncated: false, |
| 246 | + unknown_block_ids: ['equation-1'], |
| 247 | + }) |
| 248 | + ) |
| 249 | + .mockResolvedValueOnce( |
| 250 | + notionResponse({ |
| 251 | + object: 'block', |
| 252 | + id: 'equation-1', |
| 253 | + type: 'equation', |
| 254 | + equation: { expression: 'e=mc^2' }, |
| 255 | + }) |
| 256 | + ) |
| 257 | + |
| 258 | + const document = await notionConnector.getDocument('token', {}, 'page-1') |
| 259 | + |
| 260 | + expect(document).toMatchObject({ contentDeferred: false }) |
| 261 | + expect(document?.content).toContain('e=mc^2') |
| 262 | + expect(document?.skippedReason).toBeUndefined() |
| 263 | + expect(String(mockFetchWithRetry.mock.calls[2][0])).toBe( |
| 264 | + 'https://api.notion.com/v1/blocks/equation-1' |
| 265 | + ) |
| 266 | + }) |
| 267 | + |
229 | 268 | it('marks an unrecoverable truncated response as skipped rather than storing partial content', async () => { |
230 | 269 | mockFetchWithRetry |
231 | 270 | .mockResolvedValueOnce(notionResponse(page())) |
@@ -340,6 +379,29 @@ describe('notion markdown hydration', () => { |
340 | 379 | ) |
341 | 380 | }) |
342 | 381 |
|
| 382 | + it('degrades to status-only diagnostics when the bounded error payload is unavailable', async () => { |
| 383 | + mockReadBoundedHttpErrorPayload.mockResolvedValueOnce({ ok: false, reason: 'too_large' }) |
| 384 | + mockFetchWithRetry.mockResolvedValueOnce(notionResponse(page())).mockResolvedValueOnce( |
| 385 | + notionResponse( |
| 386 | + { |
| 387 | + object: 'error', |
| 388 | + code: 'validation_error', |
| 389 | + message: 'This diagnostic must not be consumed', |
| 390 | + request_id: 'request-omitted', |
| 391 | + }, |
| 392 | + 400 |
| 393 | + ) |
| 394 | + ) |
| 395 | + |
| 396 | + await expect(notionConnector.getDocument('token', {}, 'page-1')).rejects.toMatchObject({ |
| 397 | + message: 'Failed to fetch markdown for page-1: 400', |
| 398 | + status: 400, |
| 399 | + code: undefined, |
| 400 | + requestId: undefined, |
| 401 | + }) |
| 402 | + expect(mockReadBoundedHttpErrorPayload).toHaveBeenCalledTimes(1) |
| 403 | + }) |
| 404 | + |
343 | 405 | it('records an oversized markdown response as an intrinsic skipped document', async () => { |
344 | 406 | mockFetchWithRetry.mockResolvedValueOnce(notionResponse(page())).mockResolvedValueOnce( |
345 | 407 | new Response('{}', { |
@@ -473,6 +535,49 @@ describe('notion listing completeness', () => { |
473 | 535 | expect(mockFetchWithRetry).toHaveBeenCalledTimes(3) |
474 | 536 | }) |
475 | 537 |
|
| 538 | + it.each([undefined, null, '', ' '])( |
| 539 | + 'stops safely when a data source has more rows without a usable cursor: %j', |
| 540 | + async (nextCursor) => { |
| 541 | + mockFetchWithRetry |
| 542 | + .mockResolvedValueOnce( |
| 543 | + notionResponse({ |
| 544 | + object: 'database', |
| 545 | + id: 'database-1', |
| 546 | + data_sources: [ |
| 547 | + { id: 'source-1', name: 'Primary' }, |
| 548 | + { id: 'source-2', name: 'Archive' }, |
| 549 | + ], |
| 550 | + }) |
| 551 | + ) |
| 552 | + .mockResolvedValueOnce( |
| 553 | + notionResponse({ |
| 554 | + results: [page('page-1')], |
| 555 | + has_more: true, |
| 556 | + next_cursor: nextCursor, |
| 557 | + }) |
| 558 | + ) |
| 559 | + |
| 560 | + const syncContext: Record<string, unknown> = {} |
| 561 | + const result = await notionConnector.listDocuments( |
| 562 | + 'token', |
| 563 | + { scope: 'database', databaseId: 'database-1' }, |
| 564 | + undefined, |
| 565 | + syncContext |
| 566 | + ) |
| 567 | + |
| 568 | + expect(result.documents.map((document) => document.externalId)).toEqual(['page-1']) |
| 569 | + expect(result.hasMore).toBe(false) |
| 570 | + expect(result.nextCursor).toBeUndefined() |
| 571 | + expect(result.reconciliationSafe).toBe(false) |
| 572 | + expect(syncContext.listingCapped).toBe(true) |
| 573 | + expect(syncContext.reconciliationUnsafe).toBe(true) |
| 574 | + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) |
| 575 | + expect( |
| 576 | + mockFetchWithRetry.mock.calls.some(([url]) => String(url).includes('/source-2/query')) |
| 577 | + ).toBe(false) |
| 578 | + } |
| 579 | + ) |
| 580 | + |
476 | 581 | it('makes a data-source listing non-authoritative when Notion reports its 10,000-row ceiling', async () => { |
477 | 582 | mockFetchWithRetry |
478 | 583 | .mockResolvedValueOnce( |
|
0 commit comments