fix(api): harden response contracts
This commit is contained in:
138
apps/web_v1/scripts/api-client.test.mjs
Normal file
138
apps/web_v1/scripts/api-client.test.mjs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import test from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const clientPath = new URL('../src/api/client.ts', import.meta.url)
|
||||||
|
const source = (await readFile(clientPath, 'utf8')).replaceAll('import.meta.env', '{}')
|
||||||
|
const output = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
target: ts.ScriptTarget.ES2023,
|
||||||
|
},
|
||||||
|
fileName: 'client.ts',
|
||||||
|
}).outputText
|
||||||
|
const client = await import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`)
|
||||||
|
const { ApiError, apiRequest, normalizeBaseUrl, setApiBaseUrl } = client
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
setApiBaseUrl('http://api.test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty 200 response rejects with a stable invalid_response ApiError', async () => {
|
||||||
|
globalThis.fetch = async () => new Response('', { status: 200 })
|
||||||
|
|
||||||
|
await assert.rejects(apiRequest('/empty'), invalidResponseError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-JSON 200 response rejects with a stable invalid_response ApiError', async () => {
|
||||||
|
globalThis.fetch = async () => new Response('<html>bad gateway</html>', { status: 200 })
|
||||||
|
|
||||||
|
await assert.rejects(apiRequest('/html'), invalidResponseError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('interrupted successful response body rejects with a stable invalid_response ApiError', async () => {
|
||||||
|
globalThis.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => {
|
||||||
|
throw new Error('read interrupted')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await assert.rejects(apiRequest('/interrupted'), invalidResponseError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('204 response returns undefined without reading a body', async () => {
|
||||||
|
globalThis.fetch = async () => new Response(null, { status: 204 })
|
||||||
|
|
||||||
|
assert.equal(await apiRequest('/no-content'), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit void response returns undefined for a non-204 response', async () => {
|
||||||
|
globalThis.fetch = async () => new Response('ignored', { status: 200 })
|
||||||
|
|
||||||
|
assert.equal(await apiRequest('/accepted', { responseType: 'void' }), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('network failure rejects with network_error ApiError', async () => {
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
throw new Error('socket closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(apiRequest('/network'), (error) => {
|
||||||
|
assert.equal(error instanceof ApiError, true)
|
||||||
|
assert.equal(error.status, 0)
|
||||||
|
assert.equal(error.code, 'network_error')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('standard error envelope preserves status, code, and Chinese message', async () => {
|
||||||
|
globalThis.fetch = async () => Response.json(
|
||||||
|
{ error: { code: 'invalid_request', message: '请求参数无效' } },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
|
||||||
|
await assert.rejects(apiRequest('/standard-error'), (error) => {
|
||||||
|
assert.equal(error instanceof ApiError, true)
|
||||||
|
assert.equal(error.status, 400)
|
||||||
|
assert.equal(error.code, 'invalid_request')
|
||||||
|
assert.equal(error.message, '请求参数无效')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET without a body does not send Content-Type', async () => {
|
||||||
|
let requestInit
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
requestInit = init
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest('/read')
|
||||||
|
|
||||||
|
assert.equal(requestInit.headers['Content-Type'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('JSON body sends application/json Content-Type', async () => {
|
||||||
|
let requestInit
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
requestInit = init
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest('/write', { method: 'POST', body: { title: '测试' } })
|
||||||
|
|
||||||
|
assert.equal(requestInit.headers['Content-Type'], 'application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FormData body leaves Content-Type unset so fetch provides the boundary', async () => {
|
||||||
|
let requestInit
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
requestInit = init
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest('/upload', { method: 'POST', body: new FormData() })
|
||||||
|
|
||||||
|
assert.equal(requestInit.headers['Content-Type'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('base URL normalizer trims whitespace and all trailing slashes', () => {
|
||||||
|
assert.equal(normalizeBaseUrl(' https://host.example/// '), 'https://host.example')
|
||||||
|
})
|
||||||
|
|
||||||
|
function invalidResponseError(error) {
|
||||||
|
assert.equal(error instanceof ApiError, true)
|
||||||
|
assert.equal(error.status, 200)
|
||||||
|
assert.equal(error.code, 'invalid_response')
|
||||||
|
assert.equal(error.message, '服务器返回了无法识别的数据')
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ const requiredFiles = [
|
|||||||
'src/api/mappers.tsx',
|
'src/api/mappers.tsx',
|
||||||
'src/api/search.ts',
|
'src/api/search.ts',
|
||||||
'src/api/inbox.ts',
|
'src/api/inbox.ts',
|
||||||
|
'scripts/api-client.test.mjs',
|
||||||
]
|
]
|
||||||
|
|
||||||
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
||||||
@@ -77,9 +78,17 @@ for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\
|
|||||||
if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`)
|
if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`)
|
||||||
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
|
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
|
||||||
}
|
}
|
||||||
|
if (/\|\s*undefined/.test(projectsSource)) failures.push('project DTOs must not use undefined fields')
|
||||||
|
|
||||||
const searchSource = readFileSync('src/api/search.ts', 'utf8')
|
const searchSource = readFileSync('src/api/search.ts', 'utf8')
|
||||||
if (!searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
|
if (!searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
|
||||||
|
for (const unsupportedType of ["'source'", "'inbox'", "'ai_session'"]) {
|
||||||
|
if (searchSource.includes(unsupportedType)) failures.push(`search DTO contains unsupported result type ${unsupportedType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSource = readFileSync('src/pages/login.tsx', 'utf8')
|
||||||
|
if (!loginSource.includes("import { normalizeBaseUrl } from '../api/client'")) failures.push('login must share the API base URL normalizer')
|
||||||
|
if (loginSource.includes('function normalizeBaseUrl')) failures.push('login must not define a second base URL normalizer')
|
||||||
|
|
||||||
const inboxSource = readFileSync('src/api/inbox.ts', 'utf8')
|
const inboxSource = readFileSync('src/api/inbox.ts', 'utf8')
|
||||||
for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) {
|
for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type RequestOptions = {
|
|||||||
method?: string
|
method?: string
|
||||||
body?: unknown
|
body?: unknown
|
||||||
token?: string
|
token?: string
|
||||||
|
responseType?: 'json' | 'void'
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorEnvelope = {
|
type ErrorEnvelope = {
|
||||||
@@ -54,7 +55,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
response = await fetch(`${configuredBaseUrl}${path}`, {
|
response = await fetch(`${configuredBaseUrl}${path}`, {
|
||||||
method: options.method ?? 'GET',
|
method: options.method ?? 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||||
},
|
},
|
||||||
body: requestBody as BodyInit | undefined,
|
body: requestBody as BodyInit | undefined,
|
||||||
@@ -75,15 +76,28 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status))
|
throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204 || options.responseType === 'void') {
|
||||||
return undefined as T
|
return undefined as T
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text()
|
let text: string
|
||||||
if (text.trim() === '') {
|
try {
|
||||||
return undefined as T
|
text = await response.text()
|
||||||
|
} catch {
|
||||||
|
throw invalidResponse(response.status)
|
||||||
}
|
}
|
||||||
return JSON.parse(text) as T
|
if (text.trim() === '') {
|
||||||
|
throw invalidResponse(response.status)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
} catch {
|
||||||
|
throw invalidResponse(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidResponse(status: number) {
|
||||||
|
return new ApiError(status, 'invalid_response', '服务器返回了无法识别的数据')
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrorEnvelope(value: unknown): value is ErrorEnvelope {
|
function isErrorEnvelope(value: unknown): value is ErrorEnvelope {
|
||||||
@@ -119,7 +133,7 @@ function fallbackErrorMessage(status: number) {
|
|||||||
return '请求失败,请稍后重试'
|
return '请求失败,请稍后重试'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
export function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
return trimmed.replace(/\/+$/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,6 @@ export async function confirmInboxItem(session: ApiSession, inboxId: string, sug
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: { suggestions },
|
body: { suggestions },
|
||||||
|
responseType: 'void',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export type WorkspaceChannelDTO = {
|
|||||||
type: string
|
type: string
|
||||||
title: string
|
title: string
|
||||||
icon: string
|
icon: string
|
||||||
count: number | undefined
|
count: number
|
||||||
url: string | undefined
|
url: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiRequest, type ApiSession } from './client'
|
import { apiRequest, type ApiSession } from './client'
|
||||||
|
|
||||||
export type SearchResultDTO = {
|
export type SearchResultDTO = {
|
||||||
type: 'project' | 'task' | 'note' | 'source' | 'inbox' | 'ai_session'
|
type: 'project' | 'task' | 'note'
|
||||||
id: string
|
id: string
|
||||||
projectId: string
|
projectId: string
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
IconSafe,
|
IconSafe,
|
||||||
IconUserGroup,
|
IconUserGroup,
|
||||||
} from '@arco-design/web-react/icon'
|
} from '@arco-design/web-react/icon'
|
||||||
|
import { normalizeBaseUrl } from '../api/client'
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
@@ -129,11 +130,6 @@ async function checkServerStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
|
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="value-point">
|
<div className="value-point">
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ type WorkspaceChannelDTO struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Count *int64 `json:"count,omitempty"`
|
Count int64 `json:"count"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
|
|||||||
require.Len(t, workspace.Channels, 7)
|
require.Len(t, workspace.Channels, 7)
|
||||||
require.Equal(t, "overview", workspace.Channels[0].Type)
|
require.Equal(t, "overview", workspace.Channels[0].Type)
|
||||||
require.Equal(t, "inbox", workspace.Channels[1].Type)
|
require.Equal(t, "inbox", workspace.Channels[1].Type)
|
||||||
require.Equal(t, int64(1), *workspace.Channels[1].Count)
|
require.Equal(t, int64(1), workspace.Channels[1].Count)
|
||||||
require.Equal(t, "custom_link", workspace.Channels[6].Type)
|
require.Equal(t, "custom_link", workspace.Channels[6].Type)
|
||||||
require.Equal(t, "Roadmap Board", workspace.Channels[6].Title)
|
require.Equal(t, "Roadmap Board", workspace.Channels[6].Title)
|
||||||
require.Equal(t, "https://example.com/roadmap-a1", workspace.Channels[6].URL)
|
require.Equal(t, "https://example.com/roadmap-a1", workspace.Channels[6].URL)
|
||||||
|
|||||||
@@ -120,12 +120,12 @@ func (s *Service) workspaceTags(projectID uint) ([]WorkspaceTagDTO, error) {
|
|||||||
func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) {
|
func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) {
|
||||||
total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans
|
total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans
|
||||||
channels := []WorkspaceChannelDTO{
|
channels := []WorkspaceChannelDTO{
|
||||||
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: &total, SortOrder: 1},
|
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: total, URL: "", SortOrder: 1},
|
||||||
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: &counts.inbox, SortOrder: 2},
|
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: counts.inbox, URL: "", SortOrder: 2},
|
||||||
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: &counts.tasks, SortOrder: 3},
|
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: counts.tasks, URL: "", SortOrder: 3},
|
||||||
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: &counts.aiSessions, SortOrder: 4},
|
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: counts.aiSessions, URL: "", SortOrder: 4},
|
||||||
{ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: &counts.notesSources, SortOrder: 5},
|
{ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: counts.notesSources, URL: "", SortOrder: 5},
|
||||||
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: &counts.cronPlans, SortOrder: 6},
|
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: counts.cronPlans, URL: "", SortOrder: 6},
|
||||||
}
|
}
|
||||||
var customChannels []models.SenlinAgentProjectChannel
|
var customChannels []models.SenlinAgentProjectChannel
|
||||||
if err := models.DBService.Where("project_id = ?", projectID).Order("sort_order asc, id asc").Find(&customChannels).Error; err != nil {
|
if err := models.DBService.Where("project_id = ?", projectID).Order("sort_order asc, id asc").Find(&customChannels).Error; err != nil {
|
||||||
@@ -134,7 +134,7 @@ func (s *Service) workspaceChannels(projectID uint, projectIdentity string, coun
|
|||||||
for _, channel := range customChannels {
|
for _, channel := range customChannels {
|
||||||
channels = append(channels, WorkspaceChannelDTO{
|
channels = append(channels, WorkspaceChannelDTO{
|
||||||
ID: channel.Identity, ProjectID: projectIdentity, Type: "custom_link", Title: channel.Title,
|
ID: channel.Identity, ProjectID: projectIdentity, Type: "custom_link", Title: channel.Title,
|
||||||
Icon: channel.Icon, URL: channel.URL, SortOrder: channel.SortOrder,
|
Icon: channel.Icon, Count: 0, URL: channel.URL, SortOrder: channel.SortOrder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return channels, nil
|
return channels, nil
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
|
|||||||
require.NotContains(t, string(payload), "duration")
|
require.NotContains(t, string(payload), "duration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceChannelDTOSerializesRequiredCountAndURL(t *testing.T) {
|
||||||
|
payload, err := json.Marshal(WorkspaceChannelDTO{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(payload), `"count":0`)
|
||||||
|
require.Contains(t, string(payload), `"url":""`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorkspaceDoneTaskDoesNotInventCompletedAt(t *testing.T) {
|
func TestWorkspaceDoneTaskDoesNotInventCompletedAt(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
service := NewService()
|
service := NewService()
|
||||||
|
|||||||
Reference in New Issue
Block a user