fix(client): work through the findings from the coverage pass

214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
This commit is contained in:
Maurice
2026-07-29 11:57:24 +02:00
committed by Maurice
parent a4bf815d24
commit 72a82b3c44
425 changed files with 3155 additions and 1968 deletions
+31 -3
View File
@@ -1,4 +1,4 @@
// FE-APIWIRE-001 to FE-APIWIRE-035
// FE-APIWIRE-001 to FE-APIWIRE-036
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -184,6 +184,20 @@ describe('client > rate-limit translation', () => {
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-035: an array 429 body is replaced, not grafted onto', async () => {
server.use(http.get('/api/limited', () => HttpResponse.json([{ field: 'email' }], { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-036: Catalan, Greek and Vietnamese have their own 429 message', async () => {
for (const lang of ['ca', 'gr', 'vi']) {
localStorage.setItem('app_language', lang)
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).not.toBe('Too many attempts. Please try again later.')
}
})
})
describe('client > proxy auth challenges', () => {
@@ -398,6 +412,7 @@ describe('client > adminApi.llmLocalPull', () => {
read: async () => (i < chunks.length
? { done: false, value: encoder.encode(chunks[i++]) }
: { done: true, value: undefined }),
cancel: async () => {},
}),
},
} as unknown as Response
@@ -450,13 +465,26 @@ describe('client > adminApi.llmLocalPull', () => {
.rejects.toThrow('Pull failed (500)')
})
it('FE-APIWIRE-033: a 200 without a readable body is treated as a failure', async () => {
it('FE-APIWIRE-036: a throw from onProgress aborts the pull', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((p: { error?: string }) => {
if (p.error) throw new Error(p.error)
})
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', onProgress))
.rejects.toThrow('manifest not found')
expect(onProgress).toHaveBeenCalledTimes(2)
})
it('FE-APIWIRE-033: a 200 without a readable body reports the missing stream', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true, status: 200, body: null,
json: async () => ({}),
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull failed (200)')
.rejects.toThrow('Pull returned no progress stream')
})
})
+29 -13
View File
@@ -105,6 +105,9 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
ca: 'Massa intents. Torneu-ho a provar més tard.',
gr: 'Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά αργότερα.',
vi: 'Quá nhiều lần thử. Vui lòng thử lại sau.',
}
function translateRateLimit(): string {
@@ -228,9 +231,11 @@ apiClient.interceptors.response.use(
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
const data = error.response.data
// Only a plain object body carries an `error` field worth overwriting;
// an array (a validation-error list) or a string is replaced outright.
if (data && typeof data === 'object' && !Array.isArray(data)) {
(data as { error?: string }).error = translated
} else {
error.response.data = { error: translated }
}
@@ -551,24 +556,35 @@ export const adminApi = {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok || !res.body) {
if (!res.ok) {
let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg)
}
// An accepted request without a stream can't be followed to completion.
if (!res.body) throw new Error('Pull returned no progress stream')
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
// split() always yields at least one element; the last one is the
// trailing (possibly partial) line carried into the next chunk.
const lines = buf.split('\n')
buf = lines.pop()!
for (const line of lines) {
if (!line.trim()) continue
// Only the parse is swallowed — a throw from onProgress aborts the pull.
let frame: { status?: string; total?: number; completed?: number; error?: string }
try { frame = JSON.parse(line) } catch { continue }
onProgress(frame)
}
}
} finally {
reader.cancel().catch(() => {})
}
},
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),