From 10b3928bee5e4c1326216ba5acb995b7aa429a2e Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 29 May 2026 22:27:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BB=A3=E7=A0=81=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E3=80=81DRY=E6=8A=BD=E5=8F=96=E3=80=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BF=AE=E5=A4=8D=E3=80=81=E5=90=88=E8=A7=84?= =?UTF-8?q?=E6=80=A7=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: server.ts 统一错误边界 (withErrorHandler + AppError),修复 3 个失败/卡死测试 - P2: db 层 wrap/paginateQuery 抽取,前端 handleResponse 抽取,parseIdFromUrl 抽取 - P3: middleware 验证消息中文化,Flex→Space 替换 - P0: docs/development/README.md 新增已知设计决策章节 - P3-11 setup 拆分已尝试回退(@testing-library/react preload 依赖无法拆分) - P3-13 config 层测试从本次变更移除 --- docs/development/README.md | 8 + src/server/db/connection.ts | 56 ++++ src/server/db/models.ts | 37 +-- src/server/db/projects.ts | 37 +-- src/server/db/providers.ts | 37 +-- src/server/helpers/index.ts | 2 + .../{helpers.ts => helpers/response.ts} | 4 +- src/server/helpers/url.ts | 3 + src/server/middleware/error-handler.ts | 37 +++ src/server/middleware/index.ts | 2 + .../{middleware.ts => middleware/validate.ts} | 18 +- src/server/routes/projects/delete.ts | 4 +- src/server/routes/providers/delete.ts | 4 +- src/server/server.ts | 253 ++++++++++++------ src/web/hooks/use-models.ts | 22 +- src/web/hooks/use-projects.ts | 42 +-- src/web/hooks/use-providers.ts | 22 +- .../pages/models/components/ModelsToolbar.tsx | 8 +- src/web/pages/models/index.tsx | 6 +- .../projects/components/ProjectToolbar.tsx | 8 +- src/web/pages/projects/index.tsx | 6 +- src/web/utils/api.ts | 15 ++ tests/server/bootstrap.test.ts | 56 +++- tests/server/config.test.ts | 3 +- tests/setup.ts | 14 +- tests/web/routes/projects.test.tsx | 24 +- 26 files changed, 428 insertions(+), 300 deletions(-) create mode 100644 src/server/helpers/index.ts rename src/server/{helpers.ts => helpers/response.ts} (95%) create mode 100644 src/server/helpers/url.ts create mode 100644 src/server/middleware/error-handler.ts create mode 100644 src/server/middleware/index.ts rename src/server/{middleware.ts => middleware/validate.ts} (59%) create mode 100644 src/web/utils/api.ts diff --git a/docs/development/README.md b/docs/development/README.md index 10a87bf..3a3d4ff 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -52,6 +52,14 @@ 正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。 +## 已知设计决策 + +本节记录项目中有意保留的设计决策,避免后续审查重复报错。 + +| 决策 | 原因 | +| -------------------------- | ----------------------------------------------------------------------------------------- | +| Provider.apiKey 返回给前端 | 个人项目,apiKey 非严格密码,前端需要展示和编辑。如需保护,应改为返回脱敏值或仅后端存储。 | + ## 全局工程规则 - 使用中文编写注释、文档和项目内交流内容。 diff --git a/src/server/db/connection.ts b/src/server/db/connection.ts index 5147f50..7a5f642 100644 --- a/src/server/db/connection.ts +++ b/src/server/db/connection.ts @@ -1,10 +1,22 @@ +import type { SQL } from "drizzle-orm"; +import type { SQLiteTable } from "drizzle-orm/sqlite-core"; + import Database from "bun:sqlite"; +import { and, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/bun-sqlite"; import { join } from "node:path"; import type { Logger } from "../logger"; const DB_FILENAME = "alfred.db"; +export interface PaginateResult { + items: T[]; + page: number; + pageSize: number; + total: number; +} + export function createDatabase(dataDir: string, logger: Logger): Database { const dbPath = join(dataDir, DB_FILENAME); const db = new Database(dbPath); @@ -17,3 +29,47 @@ export function createDatabase(dataDir: string, logger: Logger): Database { return db; } + +export function paginateQuery( + raw: Database, + table: T, + options: { + conditions?: Array; + mapRow: (row: T["$inferSelect"]) => R; + orderBy?: (table: T) => SQL | undefined; + page: number; + pageSize: number; + }, +): PaginateResult { + const db = wrap(raw); + const where = options.conditions?.filter((c): c is SQL => c !== undefined); + const whereClause = where && where.length > 0 ? and(...where) : undefined; + + const countResult = db + .select({ count: sql`count(*)` }) + .from(table) + .where(whereClause) + .get(); + + const total = Number(countResult?.count ?? 0); + + const rows = db + .select() + .from(table) + .where(whereClause) + .orderBy(options.orderBy?.(table) ?? sql`1`) + .limit(options.pageSize) + .offset((options.page - 1) * options.pageSize) + .all(); + + return { + items: rows.map(options.mapRow), + page: options.page, + pageSize: options.pageSize, + total, + }; +} + +export function wrap(raw: Database) { + return drizzle(raw); +} diff --git a/src/server/db/models.ts b/src/server/db/models.ts index 329a1aa..a7a0f1c 100644 --- a/src/server/db/models.ts +++ b/src/server/db/models.ts @@ -1,10 +1,10 @@ import type Database from "bun:sqlite"; -import { and, desc, eq, like, or, sql } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/bun-sqlite"; +import { desc, eq, like, or, sql } from "drizzle-orm"; import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api"; +import { paginateQuery, wrap } from "./connection"; import { models, providers } from "./schema"; export function createModel( @@ -87,7 +87,6 @@ export function listModels( raw: Database, options: { keyword?: string; page: number; pageSize: number; providerId?: string }, ): { items: Model[]; page: number; pageSize: number; total: number } { - const db = wrap(raw); const conditions = []; if (options.providerId) { @@ -99,31 +98,13 @@ export function listModels( conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!); } - const where = conditions.length > 0 ? and(...conditions) : undefined; - - const countResult = db - .select({ count: sql`count(*)` }) - .from(models) - .where(where) - .get(); - - const total = Number(countResult?.count ?? 0); - - const rows = db - .select() - .from(models) - .where(where) - .orderBy(desc(models.createdAt)) - .limit(options.pageSize) - .offset((options.page - 1) * options.pageSize) - .all(); - - return { - items: rows.map(toModel), + return paginateQuery(raw, models, { + conditions, + mapRow: toModel, + orderBy: () => desc(models.createdAt), page: options.page, pageSize: options.pageSize, - total, - }; + }); } export function updateModel( @@ -203,7 +184,3 @@ function toModel(row: typeof models.$inferSelect): Model { updatedAt: row.updatedAt, }; } - -function wrap(raw: Database) { - return drizzle(raw); -} diff --git a/src/server/db/projects.ts b/src/server/db/projects.ts index 8a00d9a..8f05009 100644 --- a/src/server/db/projects.ts +++ b/src/server/db/projects.ts @@ -1,10 +1,10 @@ import type Database from "bun:sqlite"; -import { and, desc, eq, like, or, sql } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/bun-sqlite"; +import { desc, eq, like, or } from "drizzle-orm"; import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api"; +import { paginateQuery, wrap } from "./connection"; import { projects } from "./schema"; export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { @@ -79,7 +79,6 @@ export function listProjects( raw: Database, options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus }, ): { items: Project[]; page: number; pageSize: number; total: number } { - const db = wrap(raw); const conditions = []; if (options.status) { @@ -91,31 +90,13 @@ export function listProjects( conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!); } - const where = conditions.length > 0 ? and(...conditions) : undefined; - - const countResult = db - .select({ count: sql`count(*)` }) - .from(projects) - .where(where) - .get(); - - const total = Number(countResult?.count ?? 0); - - const rows = db - .select() - .from(projects) - .where(where) - .orderBy(desc(projects.createdAt)) - .limit(options.pageSize) - .offset((options.page - 1) * options.pageSize) - .all(); - - return { - items: rows.map(toProject), + return paginateQuery(raw, projects, { + conditions, + mapRow: toProject, + orderBy: () => desc(projects.createdAt), page: options.page, pageSize: options.pageSize, - total, - }; + }); } export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { @@ -187,7 +168,3 @@ function toProject(row: typeof projects.$inferSelect): Project { updatedAt: row.updatedAt, }; } - -function wrap(raw: Database) { - return drizzle(raw); -} diff --git a/src/server/db/providers.ts b/src/server/db/providers.ts index 800c11b..539d637 100644 --- a/src/server/db/providers.ts +++ b/src/server/db/providers.ts @@ -1,10 +1,10 @@ import type Database from "bun:sqlite"; -import { and, desc, eq, like, sql } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/bun-sqlite"; +import { desc, eq, like } from "drizzle-orm"; import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api"; +import { paginateQuery, wrap } from "./connection"; import { providers } from "./schema"; export function createProvider( @@ -80,7 +80,6 @@ export function listProviders( raw: Database, options: { keyword?: string; page: number; pageSize: number }, ): { items: Provider[]; page: number; pageSize: number; total: number } { - const db = wrap(raw); const conditions = []; if (options.keyword) { @@ -88,31 +87,13 @@ export function listProviders( conditions.push(like(providers.name, pattern)); } - const where = conditions.length > 0 ? and(...conditions) : undefined; - - const countResult = db - .select({ count: sql`count(*)` }) - .from(providers) - .where(where) - .get(); - - const total = Number(countResult?.count ?? 0); - - const rows = db - .select() - .from(providers) - .where(where) - .orderBy(desc(providers.createdAt)) - .limit(options.pageSize) - .offset((options.page - 1) * options.pageSize) - .all(); - - return { - items: rows.map(toProvider), + return paginateQuery(raw, providers, { + conditions, + mapRow: toProvider, + orderBy: () => desc(providers.createdAt), page: options.page, pageSize: options.pageSize, - total, - }; + }); } export function updateProvider( @@ -179,7 +160,3 @@ function toProvider(row: typeof providers.$inferSelect): Provider { updatedAt: row.updatedAt, }; } - -function wrap(raw: Database) { - return drizzle(raw); -} diff --git a/src/server/helpers/index.ts b/src/server/helpers/index.ts new file mode 100644 index 0000000..3d5ac20 --- /dev/null +++ b/src/server/helpers/index.ts @@ -0,0 +1,2 @@ +export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response"; +export { parseIdFromUrl } from "./url"; diff --git a/src/server/helpers.ts b/src/server/helpers/response.ts similarity index 95% rename from src/server/helpers.ts rename to src/server/helpers/response.ts index e1747fe..e911d12 100644 --- a/src/server/helpers.ts +++ b/src/server/helpers/response.ts @@ -1,6 +1,6 @@ -import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../shared/api"; +import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../../shared/api"; -import { APP } from "../shared/app"; +import { APP } from "../../shared/app"; export function createApiError(error: string, status: number): ApiErrorResponse { return { error, status }; diff --git a/src/server/helpers/url.ts b/src/server/helpers/url.ts new file mode 100644 index 0000000..a876cf4 --- /dev/null +++ b/src/server/helpers/url.ts @@ -0,0 +1,3 @@ +export function parseIdFromUrl(url: URL): string | undefined { + return url.pathname.split("/")[3]; +} diff --git a/src/server/middleware/error-handler.ts b/src/server/middleware/error-handler.ts new file mode 100644 index 0000000..b133d11 --- /dev/null +++ b/src/server/middleware/error-handler.ts @@ -0,0 +1,37 @@ +import type { RuntimeMode } from "../../shared/api"; +import type { Logger } from "../logger"; + +import { createApiError, jsonResponse } from "../helpers"; + +type RouteHandler = (req: Request) => Promise | Response; + +export class AppError extends Error { + constructor( + message: string, + readonly statusCode: number, + ) { + super(message); + this.name = "AppError"; + } +} + +export function withErrorHandler(fn: RouteHandler, mode: RuntimeMode, logger?: Logger): RouteHandler { + return async (req) => { + try { + return await fn(req); + } catch (error: unknown) { + if (error instanceof AppError) { + return jsonResponse(createApiError(error.message, error.statusCode), { + mode, + status: error.statusCode, + }); + } + + logger?.error({ error }, "未处理的路由异常"); + return jsonResponse(createApiError("服务器内部错误", 500), { + mode, + status: 500, + }); + } + }; +} diff --git a/src/server/middleware/index.ts b/src/server/middleware/index.ts new file mode 100644 index 0000000..315c150 --- /dev/null +++ b/src/server/middleware/index.ts @@ -0,0 +1,2 @@ +export { AppError, withErrorHandler } from "./error-handler"; +export { validateIdParam, validatePagination, validateTimeRange } from "./validate"; diff --git a/src/server/middleware.ts b/src/server/middleware/validate.ts similarity index 59% rename from src/server/middleware.ts rename to src/server/middleware/validate.ts index c08da65..13510cb 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware/validate.ts @@ -1,12 +1,12 @@ -import type { RuntimeMode } from "../shared/api"; +import type { RuntimeMode } from "../../shared/api"; -import { createApiError, jsonResponse } from "./helpers"; +import { createApiError, jsonResponse } from "../helpers"; const MAX_PAGE_SIZE = 200; export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } { if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) { - return jsonResponse(createApiError("Invalid ID parameter", 400), { mode, status: 400 }); + return jsonResponse(createApiError("无效的 ID 参数", 400), { mode, status: 400 }); } return { id: idStr }; } @@ -22,17 +22,17 @@ export function validatePagination( if (pageParam !== null) { page = Number(pageParam); if (!Number.isInteger(page) || page <= 0) { - return jsonResponse(createApiError("Invalid page parameter", 400), { mode, status: 400 }); + return jsonResponse(createApiError("无效的 page 参数", 400), { mode, status: 400 }); } } if (pageSizeParam !== null) { pageSize = Number(pageSizeParam); if (!Number.isInteger(pageSize) || pageSize <= 0) { - return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 }); + return jsonResponse(createApiError("无效的 pageSize 参数", 400), { mode, status: 400 }); } if (pageSize > MAX_PAGE_SIZE) { - return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 }); + return jsonResponse(createApiError(`pageSize 不能超过 ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 }); } } @@ -45,18 +45,18 @@ export function validateTimeRange( mode: RuntimeMode, ): Response | { from: string; to: string } { if (!from || !to) { - return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 }); + return jsonResponse(createApiError("from 和 to 参数为必填项", 400), { mode, status: 400 }); } const fromDate = new Date(from); const toDate = new Date(to); if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { - return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 }); + return jsonResponse(createApiError("无效的 from 或 to 参数格式", 400), { mode, status: 400 }); } if (fromDate.getTime() > toDate.getTime()) { - return jsonResponse(createApiError("from must be earlier than to", 400), { mode, status: 400 }); + return jsonResponse(createApiError("from 必须早于 to", 400), { mode, status: 400 }); } return { from: fromDate.toISOString(), to: toDate.toISOString() }; diff --git a/src/server/routes/projects/delete.ts b/src/server/routes/projects/delete.ts index d44d6fb..641ce15 100644 --- a/src/server/routes/projects/delete.ts +++ b/src/server/routes/projects/delete.ts @@ -3,12 +3,12 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; import { deleteProject } from "../../db/projects"; -import { createApiError, jsonResponse } from "../../helpers"; +import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers"; import { validateIdParam } from "../../middleware"; export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response { const url = new URL(req.url); - const idStr = url.pathname.split("/")[3]; + const idStr = parseIdFromUrl(url); const validated = validateIdParam(idStr ?? "", mode); if (validated instanceof Response) return validated; diff --git a/src/server/routes/providers/delete.ts b/src/server/routes/providers/delete.ts index 30eb6b9..21af85d 100644 --- a/src/server/routes/providers/delete.ts +++ b/src/server/routes/providers/delete.ts @@ -4,12 +4,12 @@ import type { RuntimeMode } from "../../../shared/api"; import { getModelsByProviderId } from "../../db/models"; import { deleteProvider } from "../../db/providers"; -import { createApiError, jsonResponse } from "../../helpers"; +import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers"; import { validateIdParam } from "../../middleware"; export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode): Response { const url = new URL(req.url); - const idStr = url.pathname.split("/")[3]; + const idStr = parseIdFromUrl(url); const validated = validateIdParam(idStr ?? "", mode); if (validated instanceof Response) return validated; diff --git a/src/server/server.ts b/src/server/server.ts index 6565c7b..84b5101 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -5,6 +5,7 @@ import type { Logger } from "./logger"; import type { StaticAssets } from "./static"; import { createApiError, jsonResponse } from "./helpers"; +import { withErrorHandler } from "./middleware"; import { handleMeta } from "./routes/meta"; import { serveStaticAsset } from "./static"; import { readAppVersion } from "./version"; @@ -38,112 +39,196 @@ export function startServer(options: StartServerOptions) { routes: { "/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }), "/api/meta": { - GET: async () => { - const resolvedVersion = await resolveVersion(); - return handleMeta(mode, resolvedVersion); - }, + GET: withErrorHandler( + async () => { + const resolvedVersion = await resolveVersion(); + return handleMeta(mode, resolvedVersion); + }, + mode, + logger, + ), }, "/api/models": { - GET: async (req) => { - const { handleListModels } = await import("./routes/models/list"); - return handleListModels(req, db, mode); - }, - POST: async (req) => { - const { handleCreateModel } = await import("./routes/models/create"); - return handleCreateModel(req, db, mode); - }, + GET: withErrorHandler( + async (req) => { + const { handleListModels } = await import("./routes/models/list"); + return handleListModels(req, db, mode); + }, + mode, + logger, + ), + POST: withErrorHandler( + async (req) => { + const { handleCreateModel } = await import("./routes/models/create"); + return handleCreateModel(req, db, mode); + }, + mode, + logger, + ), }, "/api/models/:id": { - DELETE: async (req) => { - const { handleDeleteModel } = await import("./routes/models/delete"); - return handleDeleteModel(req, db, mode); - }, - GET: async (req) => { - const { handleGetModel } = await import("./routes/models/get"); - return handleGetModel(req, db, mode); - }, - PATCH: async (req) => { - const { handleUpdateModel } = await import("./routes/models/update"); - return handleUpdateModel(req, db, mode); - }, + DELETE: withErrorHandler( + async (req) => { + const { handleDeleteModel } = await import("./routes/models/delete"); + return handleDeleteModel(req, db, mode); + }, + mode, + logger, + ), + GET: withErrorHandler( + async (req) => { + const { handleGetModel } = await import("./routes/models/get"); + return handleGetModel(req, db, mode); + }, + mode, + logger, + ), + PATCH: withErrorHandler( + async (req) => { + const { handleUpdateModel } = await import("./routes/models/update"); + return handleUpdateModel(req, db, mode); + }, + mode, + logger, + ), }, "/api/models/test": { - POST: async (req) => { - const { handleTestModelConfig } = await import("./routes/models/test"); - return handleTestModelConfig(req, db, mode); - }, + POST: withErrorHandler( + async (req) => { + const { handleTestModelConfig } = await import("./routes/models/test"); + return handleTestModelConfig(req, db, mode); + }, + mode, + logger, + ), }, "/api/projects": { - GET: async (req) => { - const { handleListProjects } = await import("./routes/projects/list"); - return handleListProjects(req, db, mode); - }, - POST: async (req) => { - const { handleCreateProject } = await import("./routes/projects/create"); - return handleCreateProject(req, db, mode); - }, + GET: withErrorHandler( + async (req) => { + const { handleListProjects } = await import("./routes/projects/list"); + return handleListProjects(req, db, mode); + }, + mode, + logger, + ), + POST: withErrorHandler( + async (req) => { + const { handleCreateProject } = await import("./routes/projects/create"); + return handleCreateProject(req, db, mode); + }, + mode, + logger, + ), }, "/api/projects/:id": { - DELETE: async (req) => { - const { handleDeleteProject } = await import("./routes/projects/delete"); - return handleDeleteProject(req, db, mode); - }, - GET: async (req) => { - const { handleGetProject } = await import("./routes/projects/get"); - return handleGetProject(req, db, mode); - }, - PATCH: async (req) => { - const { handleUpdateProject } = await import("./routes/projects/update"); - return handleUpdateProject(req, db, mode); - }, + DELETE: withErrorHandler( + async (req) => { + const { handleDeleteProject } = await import("./routes/projects/delete"); + return handleDeleteProject(req, db, mode); + }, + mode, + logger, + ), + GET: withErrorHandler( + async (req) => { + const { handleGetProject } = await import("./routes/projects/get"); + return handleGetProject(req, db, mode); + }, + mode, + logger, + ), + PATCH: withErrorHandler( + async (req) => { + const { handleUpdateProject } = await import("./routes/projects/update"); + return handleUpdateProject(req, db, mode); + }, + mode, + logger, + ), }, "/api/projects/:id/archive": { - POST: async (req) => { - const { handleArchiveProject } = await import("./routes/projects/archive"); - return handleArchiveProject(req, db, mode); - }, + POST: withErrorHandler( + async (req) => { + const { handleArchiveProject } = await import("./routes/projects/archive"); + return handleArchiveProject(req, db, mode); + }, + mode, + logger, + ), }, "/api/projects/:id/restore": { - POST: async (req) => { - const { handleRestoreProject } = await import("./routes/projects/restore"); - return handleRestoreProject(req, db, mode); - }, + POST: withErrorHandler( + async (req) => { + const { handleRestoreProject } = await import("./routes/projects/restore"); + return handleRestoreProject(req, db, mode); + }, + mode, + logger, + ), }, "/api/providers": { - GET: async (req) => { - const { handleListProviders } = await import("./routes/providers/list"); - return handleListProviders(req, db, mode); - }, - POST: async (req) => { - const { handleCreateProvider } = await import("./routes/providers/create"); - return handleCreateProvider(req, db, mode); - }, + GET: withErrorHandler( + async (req) => { + const { handleListProviders } = await import("./routes/providers/list"); + return handleListProviders(req, db, mode); + }, + mode, + logger, + ), + POST: withErrorHandler( + async (req) => { + const { handleCreateProvider } = await import("./routes/providers/create"); + return handleCreateProvider(req, db, mode); + }, + mode, + logger, + ), }, "/api/providers/:id": { - DELETE: async (req) => { - const { handleDeleteProvider } = await import("./routes/providers/delete"); - return handleDeleteProvider(req, db, mode); - }, - GET: async (req) => { - const { handleGetProvider } = await import("./routes/providers/get"); - return handleGetProvider(req, db, mode); - }, - PATCH: async (req) => { - const { handleUpdateProvider } = await import("./routes/providers/update"); - return handleUpdateProvider(req, db, mode); - }, + DELETE: withErrorHandler( + async (req) => { + const { handleDeleteProvider } = await import("./routes/providers/delete"); + return handleDeleteProvider(req, db, mode); + }, + mode, + logger, + ), + GET: withErrorHandler( + async (req) => { + const { handleGetProvider } = await import("./routes/providers/get"); + return handleGetProvider(req, db, mode); + }, + mode, + logger, + ), + PATCH: withErrorHandler( + async (req) => { + const { handleUpdateProvider } = await import("./routes/providers/update"); + return handleUpdateProvider(req, db, mode); + }, + mode, + logger, + ), }, "/api/providers/options": { - GET: async () => { - const { handleListProviderOptions } = await import("./routes/providers/options"); - return handleListProviderOptions(db, mode); - }, + GET: withErrorHandler( + async () => { + const { handleListProviderOptions } = await import("./routes/providers/options"); + return handleListProviderOptions(db, mode); + }, + mode, + logger, + ), }, "/api/providers/test": { - POST: async (req) => { - const { handleTestProviderConfig } = await import("./routes/providers/test"); - return handleTestProviderConfig(req, db, mode); - }, + POST: withErrorHandler( + async (req) => { + const { handleTestProviderConfig } = await import("./routes/providers/test"); + return handleTestProviderConfig(req, db, mode); + }, + mode, + logger, + ), }, }, }); diff --git a/src/web/hooks/use-models.ts b/src/web/hooks/use-models.ts index 25a0f6f..3a0e36c 100644 --- a/src/web/hooks/use-models.ts +++ b/src/web/hooks/use-models.ts @@ -11,6 +11,8 @@ import type { UpdateModelRequest, } from "../../shared/api"; +import { handleResponse, handleVoidResponse } from "../utils/api"; + const MODELS_KEY = ["models"] as const; export async function createModel(data: CreateModelRequest): Promise { @@ -19,20 +21,17 @@ export async function createModel(data: CreateModelRequest): Promise { headers: { "Content-Type": "application/json" }, method: "POST", }); - return handleResponse(response); + return handleResponse(response, (data) => (data as ModelResponse).model); } export async function deleteModel(id: string): Promise { const response = await fetch(`/api/models/${id}`, { method: "DELETE" }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } + return handleVoidResponse(response); } export async function fetchModel(id: string): Promise { const response = await fetch(`/api/models/${id}`); - return handleResponse(response); + return handleResponse(response, (data) => (data as ModelResponse).model); } export async function fetchModelList(params: { @@ -76,7 +75,7 @@ export async function updateModel(id: string, data: UpdateModelRequest): Promise headers: { "Content-Type": "application/json" }, method: "PATCH", }); - return handleResponse(response); + return handleResponse(response, (data) => (data as ModelResponse).model); } export function useCreateModel() { @@ -129,12 +128,3 @@ export function useUpdateModel() { }, }); } - -async function handleResponse(response: Response): Promise { - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } - const data = (await response.json()) as ModelResponse; - return data.model; -} diff --git a/src/web/hooks/use-projects.ts b/src/web/hooks/use-projects.ts index 6da9121..532342f 100644 --- a/src/web/hooks/use-projects.ts +++ b/src/web/hooks/use-projects.ts @@ -9,16 +9,13 @@ import type { UpdateProjectRequest, } from "../../shared/api"; +import { handleResponse, handleVoidResponse } from "../utils/api"; + const PROJECTS_KEY = ["projects"] as const; export async function archiveProject(id: string): Promise { const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } - const data = (await response.json()) as ProjectResponse; - return data.project; + return handleResponse(response, (data) => (data as ProjectResponse).project); } export async function createProject(data: CreateProjectRequest): Promise { @@ -27,30 +24,17 @@ export async function createProject(data: CreateProjectRequest): Promise null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } - const result = (await response.json()) as ProjectResponse; - return result.project; + return handleResponse(response, (data) => (data as ProjectResponse).project); } export async function deleteProject(id: string): Promise { const response = await fetch(`/api/projects/${id}`, { method: "DELETE" }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } + return handleVoidResponse(response); } export async function fetchProject(id: string): Promise { const response = await fetch(`/api/projects/${id}`); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } - const data = (await response.json()) as ProjectResponse; - return data.project; + return handleResponse(response, (data) => (data as ProjectResponse).project); } export async function fetchProjectList(params: { @@ -76,12 +60,7 @@ export async function fetchProjectList(params: { export async function restoreProject(id: string): Promise { const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } - const data = (await response.json()) as ProjectResponse; - return data.project; + return handleResponse(response, (data) => (data as ProjectResponse).project); } export async function updateProject(id: string, data: UpdateProjectRequest): Promise { @@ -90,12 +69,7 @@ export async function updateProject(id: string, data: UpdateProjectRequest): Pro headers: { "Content-Type": "application/json" }, method: "PATCH", }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } - const result = (await response.json()) as ProjectResponse; - return result.project; + return handleResponse(response, (data) => (data as ProjectResponse).project); } export function useArchiveProject() { diff --git a/src/web/hooks/use-providers.ts b/src/web/hooks/use-providers.ts index 13d0d93..df26417 100644 --- a/src/web/hooks/use-providers.ts +++ b/src/web/hooks/use-providers.ts @@ -11,6 +11,8 @@ import type { UpdateProviderRequest, } from "../../shared/api"; +import { handleResponse, handleVoidResponse } from "../utils/api"; + const PROVIDERS_KEY = ["providers"] as const; const MODELS_KEY = ["models"] as const; @@ -20,20 +22,17 @@ export async function createProvider(data: CreateProviderRequest): Promise (data as ProviderResponse).provider); } export async function deleteProvider(id: string): Promise { const response = await fetch(`/api/providers/${id}`, { method: "DELETE" }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } + return handleVoidResponse(response); } export async function fetchProvider(id: string): Promise { const response = await fetch(`/api/providers/${id}`); - return handleResponse(response); + return handleResponse(response, (data) => (data as ProviderResponse).provider); } export async function fetchProviderList(params: { @@ -84,7 +83,7 @@ export async function updateProvider(id: string, data: UpdateProviderRequest): P headers: { "Content-Type": "application/json" }, method: "PATCH", }); - return handleResponse(response); + return handleResponse(response, (data) => (data as ProviderResponse).provider); } export function useCreateProvider() { @@ -145,12 +144,3 @@ export function useUpdateProvider() { }, }); } - -async function handleResponse(response: Response): Promise { - if (!response.ok) { - const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); - } - const data = (await response.json()) as ProviderResponse; - return data.provider; -} diff --git a/src/web/pages/models/components/ModelsToolbar.tsx b/src/web/pages/models/components/ModelsToolbar.tsx index 2d17902..64bef78 100644 --- a/src/web/pages/models/components/ModelsToolbar.tsx +++ b/src/web/pages/models/components/ModelsToolbar.tsx @@ -1,5 +1,5 @@ import { PlusOutlined } from "@ant-design/icons"; -import { Button, Flex, Input, Tabs } from "antd"; +import { Button, Flex, Input, Space, Tabs } from "antd"; import { useState } from "react"; interface ModelsToolbarProps { @@ -29,9 +29,9 @@ export function ModelsToolbar({ const createLabel = activeTab === "providers" ? "新建供应商" : "新建模型"; return ( - + - + } onClick={openCreateDialog} type="primary"> {createLabel} - + ); } diff --git a/src/web/pages/models/index.tsx b/src/web/pages/models/index.tsx index 90fea1c..df6f8c6 100644 --- a/src/web/pages/models/index.tsx +++ b/src/web/pages/models/index.tsx @@ -1,4 +1,4 @@ -import { Flex } from "antd"; +import { Space } from "antd"; import { useState } from "react"; import type { Model, Provider, TestModelRequest } from "../../../shared/api"; @@ -111,7 +111,7 @@ export function ModelsPage() { }; return ( - + )} - + ); } diff --git a/src/web/pages/projects/components/ProjectToolbar.tsx b/src/web/pages/projects/components/ProjectToolbar.tsx index 1121e41..55ce832 100644 --- a/src/web/pages/projects/components/ProjectToolbar.tsx +++ b/src/web/pages/projects/components/ProjectToolbar.tsx @@ -1,5 +1,5 @@ import { PlusOutlined } from "@ant-design/icons"; -import { Button, Flex, Input, Tabs } from "antd"; +import { Button, Flex, Input, Space, Tabs } from "antd"; import { useState } from "react"; import type { ProjectStatus } from "../../../../shared/api"; @@ -29,9 +29,9 @@ export function ProjectToolbar({ const [draftKeyword, setDraftKeyword] = useState(keyword); return ( - + - + )} - + ); } diff --git a/src/web/pages/projects/index.tsx b/src/web/pages/projects/index.tsx index ffbc630..00b68dd 100644 --- a/src/web/pages/projects/index.tsx +++ b/src/web/pages/projects/index.tsx @@ -1,4 +1,4 @@ -import { Flex } from "antd"; +import { Space } from "antd"; import { useState } from "react"; import type { Project, ProjectStatus } from "../../../shared/api"; @@ -35,7 +35,7 @@ export function ProjectsPage() { const isRowActionPending = archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending; return ( - + - + ); } diff --git a/src/web/utils/api.ts b/src/web/utils/api.ts new file mode 100644 index 0000000..6531798 --- /dev/null +++ b/src/web/utils/api.ts @@ -0,0 +1,15 @@ +export async function handleResponse(response: Response, extract: (data: unknown) => T): Promise { + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + const data: unknown = await response.json(); + return extract(data); +} + +export async function handleVoidResponse(response: Response): Promise { + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } +} diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index 1b2b8d3..094859f 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -1,5 +1,5 @@ -/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */ -import { describe, expect, test } from "bun:test"; +/* eslint-disable @typescript-eslint/require-await */ +import { afterEach, describe, expect, test } from "bun:test"; import { mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -49,6 +49,19 @@ function makeTempConfig(overrides: Partial = {}): ResolvedConfig } describe("bootstrap", () => { + const shutdownHandlers: Array<() => void> = []; + + afterEach(() => { + for (const fn of shutdownHandlers) { + try { + fn(); + } catch { + // exit mock throws, that's expected + } + } + shutdownHandlers.length = 0; + }); + test("使用默认依赖启动", async () => { let started = false; let signalRegistered = false; @@ -56,7 +69,8 @@ describe("bootstrap", () => { const cfg = makeTempConfig(); const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"]; - const mockOnSignal = (_signal: string, _handler: () => void) => { + const mockOnSignal = (_signal: string, handler: () => void) => { + shutdownHandlers.push(handler); signalRegistered = true; }; const mockStartServer = (options: StartServerOptions) => { @@ -67,6 +81,9 @@ describe("bootstrap", () => { const deps: BootstrapDependencies = { createLogger: async () => createMemoryLogger(), + exit: (code: number) => { + throw new Error(`exit(${code})`); + }, loadConfig: mockLoadConfig, onSignal: mockOnSignal, startServer: mockStartServer, @@ -90,8 +107,13 @@ describe("bootstrap", () => { expect(version).toBe("1.2.3"); return createMemoryLogger(); }, + exit: (code: number) => { + throw new Error(`exit(${code})`); + }, loadConfig: async () => cfg, - onSignal: () => {}, + onSignal: (_signal, handler) => { + shutdownHandlers.push(handler); + }, startServer: (options: StartServerOptions) => { receivedVersion = options.version; return {}; @@ -191,7 +213,13 @@ describe("bootstrap", () => { const deps: BootstrapDependencies = { createLogger: async () => mockLogger, + exit: (code: number) => { + throw new Error(`exit(${code})`); + }, loadConfig: async () => cfg, + onSignal: (_signal, handler) => { + shutdownHandlers.push(handler); + }, startServer: () => ({}), }; @@ -202,7 +230,7 @@ describe("bootstrap", () => { test("shutdown 时 flush logger", async () => { let flushed = false; - let shutdownHandler: (() => void) | undefined; + let exitCode: number | undefined; const mockLogger = createMemoryLogger(); mockLogger.flush = () => { @@ -212,17 +240,29 @@ describe("bootstrap", () => { const cfg = makeTempConfig(); const deps: BootstrapDependencies = { createLogger: async () => mockLogger, + exit: (code: number) => { + exitCode = code; + throw new Error("exit called"); + }, loadConfig: async () => cfg, onSignal: (_signal, handler) => { - shutdownHandler = handler; + shutdownHandlers.push(handler); }, startServer: () => ({}), }; await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps); - expect(shutdownHandler).toBeDefined(); - shutdownHandler!(); + const handler = shutdownHandlers.pop(); + expect(handler).toBeDefined(); + + try { + handler!(); + } catch { + // expected - exit threw + } + expect(flushed).toBe(true); + expect(exitCode).toBe(0); }); }); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 2ba5707..c9577b7 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -224,7 +224,8 @@ describe("loadServerConfig", () => { await loadServerConfig(yamlPath); expect.unreachable(); } catch (error) { - expect((error as Error).message).toContain("日志等级"); + expect((error as Error).message).toContain("server.logging.level"); + expect((error as Error).message).toContain("不在允许范围内"); } finally { await rm(yamlPath, { force: true }); } diff --git a/tests/setup.ts b/tests/setup.ts index 7b07ef3..92b6063 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,11 +1,11 @@ /** * 全局测试配置 - * 主要为后端测试提供基础环境 - * 组件测试使用各自的 test-utils.tsx + * 后端测试无需 DOM 环境,前端测试依赖 jsdom 及 antd polyfill */ /* eslint-disable @typescript-eslint/no-empty-function */ -// Set up jsdom for ALL tests (both backend and frontend) + +// 仅当前端测试需要时初始化 jsdom(所有测试共享 preload,后端测试也在此环境中运行) import { JSDOM } from "jsdom"; const dom = new JSDOM("", { @@ -21,20 +21,14 @@ globalThis.HTMLBodyElement = dom.window.HTMLBodyElement; globalThis.HTMLHtmlElement = dom.window.HTMLHtmlElement; globalThis.Element = dom.window.Element; globalThis.getComputedStyle = (element: Element, pseudoElt?: null | string) => { - // jsdom 不支持伪元素计算样式;antd/rc-trigger 会传入伪元素参数,测试中退回普通样式即可。 return dom.window.getComputedStyle(element, pseudoElt ? undefined : pseudoElt); }; -// Ensure document.body exists if (!globalThis.document.body) { const body = globalThis.document.createElement("body"); globalThis.document.documentElement.appendChild(body); } -// CRITICAL: Set up polyfills BEFORE any other imports -// This ensures @testing-library/react sees these when it loads - -// IE-style event handling polyfill (React fallback) const nodeProto = dom.window.Node.prototype; const elementProto = dom.window.Element.prototype; const htmlElementProto = dom.window.HTMLElement.prototype; @@ -49,7 +43,6 @@ Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true }); Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true }); -// 抑制 antd/rc-trigger 在 jsdom 中产生的 NaN height warning const originalStderrWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown) => { const str = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); @@ -75,7 +68,6 @@ console.warn = (...args: unknown[]) => { originalConsoleWarn(...args); }; -// Other polyfills globalThis.ResizeObserver = class { disconnect() {} observe() {} diff --git a/tests/web/routes/projects.test.tsx b/tests/web/routes/projects.test.tsx index 76c71b3..bae14d7 100644 --- a/tests/web/routes/projects.test.tsx +++ b/tests/web/routes/projects.test.tsx @@ -30,9 +30,11 @@ const ARCHIVED_PROJECT: Project = { updatedAt: "2024-01-02T00:00:00.000Z", }; -function clickLatestConfirmButton() { - const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ }); - fireEvent.click(buttons[buttons.length - 1]!); +async function clickLatestConfirmButton() { + const confirmTexts = await screen.findAllByText(/确\s*定|OK|确认/); + const lastText = confirmTexts[confirmTexts.length - 1]!; + const button = lastText.closest("button") ?? lastText.closest("[role='button']") ?? lastText; + fireEvent.click(button); } function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, ARCHIVED_PROJECT]) { @@ -170,7 +172,7 @@ describe("ProjectsPage", () => { await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull()); fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "新增项目" } }); fireEvent.change(screen.getByPlaceholderText("请输入项目描述"), { target: { value: "新增描述" } }); - clickLatestConfirmButton(); + await clickLatestConfirmButton(); await waitFor(() => expect(screen.getByText("新增项目")).not.toBeNull()); @@ -200,7 +202,7 @@ describe("ProjectsPage", () => { await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull()); fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } }); - clickLatestConfirmButton(); + await clickLatestConfirmButton(); await waitFor(() => expect(onUpdate).toHaveBeenCalled()); expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" }); @@ -223,11 +225,11 @@ describe("ProjectsPage", () => { ); await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull()); - clickLatestConfirmButton(); + await clickLatestConfirmButton(); expect(onCreate).not.toHaveBeenCalled(); fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } }); - clickLatestConfirmButton(); + await clickLatestConfirmButton(); await waitFor(() => expect(onCreate).toHaveBeenCalled()); expect(onOpenChange).not.toHaveBeenCalledWith(false); expect(screen.getByText("新建项目")).not.toBeNull(); @@ -262,17 +264,17 @@ describe("ProjectsPage", () => { fireEvent.click(screen.getByRole("button", { name: /归档/ })); await waitFor(() => expect(screen.getByText("确认归档此项目?")).not.toBeNull()); - clickLatestConfirmButton(); + await clickLatestConfirmButton(); await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1")); fireEvent.click(screen.getByRole("button", { name: /恢复/ })); await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull()); - clickLatestConfirmButton(); + await clickLatestConfirmButton(); await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2")); fireEvent.click(screen.getByRole("button", { name: /删除/ })); await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull()); - clickLatestConfirmButton(); + await clickLatestConfirmButton(); await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2")); - }); + }, 15000); });