chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
import { afterEach, beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock dependencies BEFORE importing the module under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const storageData = new Map<string, string>()
|
||||
const storageMock = {
|
||||
getItem: (key: string) => storageData.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storageData.set(key, value) },
|
||||
removeItem: (key: string) => { storageData.delete(key) },
|
||||
clear: () => { storageData.clear() },
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: storageMock,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: storageMock,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Mock zustand stores — both return a vanilla store-like object with getState()
|
||||
let storeApiKey: string | null = null
|
||||
let storeIsGuestMode = false
|
||||
const fakeSettingsStore = { getState: () => ({ apiKey: storeApiKey }) }
|
||||
const fakeAuthStore = {
|
||||
getState: () => ({
|
||||
isGuestMode: storeIsGuestMode,
|
||||
login: () => {},
|
||||
setTokenRenewal: () => {},
|
||||
}),
|
||||
}
|
||||
|
||||
mock.module('@/stores/settings', () => ({ useSettingsStore: fakeSettingsStore }))
|
||||
mock.module('@/stores/state', () => ({ useAuthStore: fakeAuthStore }))
|
||||
mock.module('@/services/navigation', () => ({
|
||||
navigationService: { navigateToLogin: () => {} },
|
||||
}))
|
||||
mock.module('@/lib/utils', () => ({
|
||||
errorMessage: (error: any) =>
|
||||
error instanceof Error ? error.message : `${error}`,
|
||||
}))
|
||||
mock.module('@/lib/constants', () => ({
|
||||
backendBaseUrl: 'http://localhost:9621',
|
||||
popularLabelsDefaultLimit: 300,
|
||||
searchLabelsDefaultLimit: 50,
|
||||
}))
|
||||
|
||||
// Mock axios — the module calls axios.create() at top level and
|
||||
// axios.get() in silentRefreshGuestToken
|
||||
mock.module('axios', () => {
|
||||
const instance = {
|
||||
get: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
access_token: 'mock-guest-token',
|
||||
auth_configured: false,
|
||||
core_version: '1.0',
|
||||
api_version: '1.0',
|
||||
},
|
||||
headers: {},
|
||||
}),
|
||||
post: () => Promise.resolve({ data: {}, headers: {} }),
|
||||
interceptors: {
|
||||
request: { use: () => {} },
|
||||
response: { use: () => {} },
|
||||
},
|
||||
}
|
||||
// The default export from axios is the main axios function, which also has
|
||||
// .create, .get, .post, etc. as static methods.
|
||||
const axiosFn: any = () => Promise.resolve({ data: {}, headers: {} })
|
||||
axiosFn.create = () => instance
|
||||
axiosFn.get = instance.get
|
||||
axiosFn.post = instance.post
|
||||
axiosFn.interceptors = instance.interceptors
|
||||
return { default: axiosFn, __esModule: true }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a minimal QueryRequest payload. */
|
||||
const makeQueryRequest = (overrides = {}) => ({
|
||||
query: 'test query',
|
||||
mode: 'mix' as const,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a ReadableStream from an array of Uint8Array chunks. Bun's Response
|
||||
* constructor accepts a ReadableStream directly.
|
||||
*/
|
||||
function makeNdjsonResponse(lines: string[], status = 200): Response {
|
||||
const encoder = new TextEncoder()
|
||||
const body = lines.map((l) => encoder.encode(l + '\n'))
|
||||
// Build a simple async iterable readable stream
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of body) {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { status })
|
||||
}
|
||||
|
||||
/** Build a non-streaming error response (text body). */
|
||||
function makeTextResponse(body: string, status: number): Response {
|
||||
return new Response(body, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a mocked implementation onto globalThis.fetch. Bun's `mock()` returns
|
||||
* a `Mock` that lacks the DOM `fetch.preconnect` static, so the assignment is
|
||||
* cast through `unknown` to satisfy the `typeof fetch` type.
|
||||
*/
|
||||
function installFetchMock(
|
||||
impl: (url: string, init?: RequestInit) => Response | Promise<Response>
|
||||
): void {
|
||||
globalThis.fetch = mock(impl) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let apiModule: typeof import('./lightrag')
|
||||
|
||||
beforeAll(async () => {
|
||||
apiModule = await import('./lightrag')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
storageData.clear()
|
||||
storeApiKey = null
|
||||
storeIsGuestMode = false
|
||||
})
|
||||
|
||||
describe('queryTextStream — normal path', () => {
|
||||
test('parses NDJSON response chunks and calls onChunk', async () => {
|
||||
const chunks: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
installFetchMock(() =>
|
||||
makeNdjsonResponse([
|
||||
'{"response": "Hello"}',
|
||||
'{"response": " World"}',
|
||||
'{"response": "!"}',
|
||||
])
|
||||
)
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
(e) => errors.push(e)
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['Hello', ' World', '!'])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('forwards error lines to onError', async () => {
|
||||
const chunks: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
installFetchMock(() =>
|
||||
makeNdjsonResponse([
|
||||
'{"response": "ok"}',
|
||||
'{"error": "Something went wrong"}',
|
||||
'{"response": "more"}',
|
||||
])
|
||||
)
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
(e) => errors.push(e)
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['ok', 'more'])
|
||||
expect(errors).toEqual(['Something went wrong'])
|
||||
})
|
||||
|
||||
test('skips malformed JSON lines', async () => {
|
||||
const chunks: string[] = []
|
||||
|
||||
installFetchMock(() =>
|
||||
makeNdjsonResponse([
|
||||
'not valid json',
|
||||
'{"response": "valid"}',
|
||||
'{broken',
|
||||
])
|
||||
)
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['valid'])
|
||||
})
|
||||
|
||||
test('handles multi-byte characters split across chunks', async () => {
|
||||
const chunks: string[] = []
|
||||
const encoder = new TextEncoder()
|
||||
// "Hello 😀🌍" — split the emoji bytes across chunks
|
||||
const fullJson = '{"response": "Hello 😀🌍"}\n'
|
||||
const fullBytes = encoder.encode(fullJson)
|
||||
// Split at an arbitrary point inside the emoji sequence
|
||||
const splitAt = 28
|
||||
const part1 = fullBytes.slice(0, splitAt)
|
||||
const part2 = fullBytes.slice(splitAt)
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(part1)
|
||||
controller.enqueue(part2)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
installFetchMock(() => new Response(stream, { status: 200 }))
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['Hello 😀🌍'])
|
||||
})
|
||||
|
||||
test('calls onError when final buffer is unparseable (truncated stream)', async () => {
|
||||
// Simulate a stream cut off mid-line with no trailing newline.
|
||||
// The residual buffer after the stream ends will be an incomplete JSON
|
||||
// object that can't be parsed.
|
||||
const encoder = new TextEncoder()
|
||||
const jsonLine = '{"response": "ok"}\n'
|
||||
const truncatedLine = '{"response": "incom'
|
||||
const body = new Uint8Array([
|
||||
...encoder.encode(jsonLine),
|
||||
...encoder.encode(truncatedLine),
|
||||
])
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(body)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
installFetchMock(() => new Response(stream, { status: 200 }))
|
||||
|
||||
const chunks: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
(e) => errors.push(e)
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['ok'])
|
||||
expect(errors).toEqual([
|
||||
'Response stream ended with incomplete data — the response may be truncated.',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — abort / stop button', () => {
|
||||
test('exits silently on user abort (signal.aborted)', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
installFetchMock(() => {
|
||||
// fetch should reject since the signal is already aborted
|
||||
return Promise.reject(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
|
||||
let errorCalled = false
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => { errorCalled = true },
|
||||
controller.signal
|
||||
)
|
||||
|
||||
expect(errorCalled).toBe(false)
|
||||
})
|
||||
|
||||
test('exits silently when AbortError is thrown without a signal', async () => {
|
||||
installFetchMock(() =>
|
||||
Promise.reject(new DOMException('Aborted', 'AbortError'))
|
||||
)
|
||||
|
||||
let errorCalled = false
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => { errorCalled = true }
|
||||
)
|
||||
|
||||
expect(errorCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — HTTP errors', () => {
|
||||
const errorCases = [
|
||||
{
|
||||
status: 403,
|
||||
expectedMessage:
|
||||
'You do not have permission to access this resource (403 Forbidden)',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
expectedMessage: 'The requested resource does not exist (404 Not Found)',
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
expectedMessage:
|
||||
'Too many requests, please try again later (429 Too Many Requests)',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
expectedMessage:
|
||||
'Server error, please try again later (500)',
|
||||
},
|
||||
{
|
||||
status: 502,
|
||||
expectedMessage:
|
||||
'Server error, please try again later (502)',
|
||||
},
|
||||
{
|
||||
status: 503,
|
||||
expectedMessage:
|
||||
'Server error, please try again later (503)',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { status, expectedMessage } of errorCases) {
|
||||
test(`shows friendly message for HTTP ${status}`, async () => {
|
||||
installFetchMock(() =>
|
||||
makeTextResponse('{"error":"details"}', status)
|
||||
)
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => { capturedError = e }
|
||||
)
|
||||
|
||||
expect(capturedError).toBe(expectedMessage)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('queryTextStream — network errors', () => {
|
||||
test('shows network error for Failed to fetch', async () => {
|
||||
installFetchMock(() =>
|
||||
Promise.reject(new TypeError('Failed to fetch'))
|
||||
)
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => { capturedError = e }
|
||||
)
|
||||
|
||||
expect(capturedError).toBe(
|
||||
'Network connection error, please check your internet connection'
|
||||
)
|
||||
})
|
||||
|
||||
test('shows network error for NetworkError', async () => {
|
||||
installFetchMock(() =>
|
||||
Promise.reject(new Error('NetworkError: connection refused'))
|
||||
)
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => { capturedError = e }
|
||||
)
|
||||
|
||||
expect(capturedError).toBe(
|
||||
'Network connection error, please check your internet connection'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — auth headers', () => {
|
||||
test('includes Bearer token when stored', async () => {
|
||||
storageData.set('LIGHTRAG-API-TOKEN', 'test-jwt-token')
|
||||
|
||||
let capturedHeaders: HeadersInit | undefined
|
||||
installFetchMock((_url: string, init?: RequestInit) => {
|
||||
capturedHeaders = init?.headers
|
||||
return makeNdjsonResponse(['{"response": "ok"}'])
|
||||
})
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
|
||||
// The module builds headers as a plain object literal, so the captured
|
||||
// RequestInit.headers is a Record we can assert against directly.
|
||||
const sentHeaders = capturedHeaders as Record<string, string>
|
||||
expect(sentHeaders).toBeDefined()
|
||||
expect(sentHeaders['Authorization']).toBe('Bearer test-jwt-token')
|
||||
expect(sentHeaders['Accept']).toBe('application/x-ndjson')
|
||||
expect(sentHeaders['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
test('omits Bearer token when none is stored', async () => {
|
||||
let capturedHeaders: HeadersInit | undefined
|
||||
installFetchMock((_url: string, init?: RequestInit) => {
|
||||
capturedHeaders = init?.headers
|
||||
return makeNdjsonResponse(['{"response": "ok"}'])
|
||||
})
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
|
||||
const sentHeaders = capturedHeaders as Record<string, string>
|
||||
expect(sentHeaders['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('calls /query/stream endpoint', async () => {
|
||||
let capturedUrl = ''
|
||||
installFetchMock((url: string) => {
|
||||
capturedUrl = url
|
||||
return makeNdjsonResponse(['{"response": "ok"}'])
|
||||
})
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(capturedUrl).toBe('http://localhost:9621/query/stream')
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — guest-token 401 retry', () => {
|
||||
test('retries with refreshed guest token on 401', async () => {
|
||||
storageData.set('LIGHTRAG-API-TOKEN', 'expired-guest-token')
|
||||
storeIsGuestMode = true
|
||||
|
||||
let callCount = 0
|
||||
|
||||
installFetchMock(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return makeTextResponse('{"error":"unauthorized"}', 401)
|
||||
}
|
||||
return makeNdjsonResponse(['{"response": "retry ok"}'])
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(callCount).toBe(2)
|
||||
expect(chunks).toEqual(['retry ok'])
|
||||
})
|
||||
|
||||
test('classifies a non-auth HTTP error on the retried stream (e.g. 429)', async () => {
|
||||
storageData.set('LIGHTRAG-API-TOKEN', 'expired-guest-token')
|
||||
storeIsGuestMode = true
|
||||
|
||||
let callCount = 0
|
||||
|
||||
installFetchMock(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return makeTextResponse('{"error":"unauthorized"}', 401)
|
||||
}
|
||||
// Refresh succeeded, but the retried request is rate-limited.
|
||||
return makeTextResponse('{"error":"rate limited"}', 429)
|
||||
})
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => {
|
||||
capturedError = e
|
||||
}
|
||||
)
|
||||
|
||||
// The retry's 429 must be classified like any other HTTP error, NOT
|
||||
// reported as an auth-refresh failure.
|
||||
expect(callCount).toBe(2)
|
||||
expect(capturedError).toBe(
|
||||
'Too many requests, please try again later (429 Too Many Requests)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
|
||||
|
||||
type DocumentsRequest = {
|
||||
status_filter?: 'pending' | 'processing' | 'preprocessed' | 'processed' | 'failed' | null
|
||||
page: number
|
||||
page_size: number
|
||||
sort_field: 'created_at' | 'updated_at' | 'id' | 'file_path'
|
||||
sort_direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
type LightragApiModule = typeof import('./lightrag')
|
||||
|
||||
const storageMock = () => {
|
||||
const data = new Map<string, string>()
|
||||
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
data.set(key, value)
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
data.delete(key)
|
||||
},
|
||||
clear: () => {
|
||||
data.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let apiModule: LightragApiModule
|
||||
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: storageMock(),
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: storageMock(),
|
||||
configurable: true
|
||||
})
|
||||
|
||||
apiModule = await import('./lightrag')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
apiModule.__resetPaginatedDocumentRequestsForTests()
|
||||
})
|
||||
|
||||
describe('getDocumentsPaginated', () => {
|
||||
test('issues a fresh request after aborting a timed-out in-flight request', async () => {
|
||||
const request: DocumentsRequest = {
|
||||
status_filter: null,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_field: 'updated_at',
|
||||
sort_direction: 'desc'
|
||||
}
|
||||
|
||||
let callCount = 0
|
||||
const resolvers: Array<(value: any) => void> = []
|
||||
|
||||
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
|
||||
callCount += 1
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
resolvers.push(resolve)
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(new DOMException('Aborted', 'AbortError')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const firstRequest = apiModule.getDocumentsPaginated(request)
|
||||
const secondRequest = apiModule.getDocumentsPaginated(request)
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
apiModule.abortDocumentsPaginated(request)
|
||||
const [firstResult, secondResult] = await Promise.allSettled([
|
||||
firstRequest,
|
||||
secondRequest
|
||||
])
|
||||
expect(firstResult.status).toBe('rejected')
|
||||
expect(secondResult.status).toBe('rejected')
|
||||
|
||||
const thirdRequest = apiModule.getDocumentsPaginated(request)
|
||||
expect(callCount).toBe(2)
|
||||
|
||||
resolvers[1]({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
|
||||
await expect(thirdRequest).resolves.toEqual({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
test('times out hanging requests and allows a fresh retry', async () => {
|
||||
const request: DocumentsRequest = {
|
||||
status_filter: null,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_field: 'updated_at',
|
||||
sort_direction: 'desc'
|
||||
}
|
||||
|
||||
let callCount = 0
|
||||
const resolvers: Array<(value: any) => void> = []
|
||||
|
||||
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
|
||||
callCount += 1
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
resolvers.push(resolve)
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(new DOMException('Aborted', 'AbortError')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
apiModule.getDocumentsPaginatedWithTimeout(request, 1)
|
||||
).rejects.toThrow('Document fetch timeout')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
const retryRequest = apiModule.getDocumentsPaginated(request)
|
||||
expect(callCount).toBe(2)
|
||||
|
||||
resolvers[1]({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
|
||||
await expect(retryRequest).resolves.toEqual({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
test('does not abort a shared request when only one timeout subscriber expires', async () => {
|
||||
const request: DocumentsRequest = {
|
||||
status_filter: null,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_field: 'updated_at',
|
||||
sort_direction: 'desc'
|
||||
}
|
||||
|
||||
let callCount = 0
|
||||
let resolveSharedRequest: ((value: any) => void) | undefined
|
||||
let abortCount = 0
|
||||
|
||||
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
|
||||
callCount += 1
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveSharedRequest = resolve
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
abortCount += 1
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const shortTimeoutRequest = apiModule.getDocumentsPaginatedWithTimeout(request, 1)
|
||||
const longTimeoutRequest = apiModule.getDocumentsPaginatedWithTimeout(request, 100)
|
||||
|
||||
await expect(shortTimeoutRequest).rejects.toThrow('Document fetch timeout')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
expect(abortCount).toBe(0)
|
||||
|
||||
resolveSharedRequest?.({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
|
||||
await expect(longTimeoutRequest).resolves.toEqual({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUserAbortError', () => {
|
||||
// Regression: the Stop button must suppress query cancellation everywhere it
|
||||
// surfaces — both the main stream catch and the guest-token retry catch (which
|
||||
// otherwise redirects an aborting guest to the login page). Both sites share
|
||||
// this predicate, so locking down its behavior guards both fixes.
|
||||
test('treats an aborted signal as a user abort regardless of the error', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
expect(apiModule.isUserAbortError(controller.signal, new Error('boom'))).toBe(true)
|
||||
})
|
||||
|
||||
test('treats an AbortError as a user abort even when the signal is absent', () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError')
|
||||
expect(apiModule.isUserAbortError(undefined, abortError)).toBe(true)
|
||||
})
|
||||
|
||||
test('does not treat a real failure on a live signal as a user abort', () => {
|
||||
const controller = new AbortController()
|
||||
expect(apiModule.isUserAbortError(controller.signal, new Error('network down'))).toBe(false)
|
||||
expect(apiModule.isUserAbortError(undefined, new Error('network down'))).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user