refactor: 代码审查修复 — 错误边界、DRY抽取、测试修复、合规性改进
- 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 层测试从本次变更移除
This commit is contained in:
@@ -52,6 +52,14 @@
|
|||||||
|
|
||||||
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
||||||
|
|
||||||
|
## 已知设计决策
|
||||||
|
|
||||||
|
本节记录项目中有意保留的设计决策,避免后续审查重复报错。
|
||||||
|
|
||||||
|
| 决策 | 原因 |
|
||||||
|
| -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| Provider.apiKey 返回给前端 | 个人项目,apiKey 非严格密码,前端需要展示和编辑。如需保护,应改为返回脱敏值或仅后端存储。 |
|
||||||
|
|
||||||
## 全局工程规则
|
## 全局工程规则
|
||||||
|
|
||||||
- 使用中文编写注释、文档和项目内交流内容。
|
- 使用中文编写注释、文档和项目内交流内容。
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
import Database from "bun:sqlite";
|
import Database from "bun:sqlite";
|
||||||
|
import { and, sql } from "drizzle-orm";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
const DB_FILENAME = "alfred.db";
|
const DB_FILENAME = "alfred.db";
|
||||||
|
|
||||||
|
export interface PaginateResult<T> {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDatabase(dataDir: string, logger: Logger): Database {
|
export function createDatabase(dataDir: string, logger: Logger): Database {
|
||||||
const dbPath = join(dataDir, DB_FILENAME);
|
const dbPath = join(dataDir, DB_FILENAME);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -17,3 +29,47 @@ export function createDatabase(dataDir: string, logger: Logger): Database {
|
|||||||
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function paginateQuery<T extends SQLiteTable, R>(
|
||||||
|
raw: Database,
|
||||||
|
table: T,
|
||||||
|
options: {
|
||||||
|
conditions?: Array<SQL | undefined>;
|
||||||
|
mapRow: (row: T["$inferSelect"]) => R;
|
||||||
|
orderBy?: (table: T) => SQL | undefined;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
},
|
||||||
|
): PaginateResult<R> {
|
||||||
|
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<number>`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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type Database from "bun:sqlite";
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
import { and, desc, eq, like, or, sql } from "drizzle-orm";
|
import { desc, eq, like, or, sql } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
||||||
|
|
||||||
import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api";
|
import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api";
|
||||||
|
|
||||||
|
import { paginateQuery, wrap } from "./connection";
|
||||||
import { models, providers } from "./schema";
|
import { models, providers } from "./schema";
|
||||||
|
|
||||||
export function createModel(
|
export function createModel(
|
||||||
@@ -87,7 +87,6 @@ export function listModels(
|
|||||||
raw: Database,
|
raw: Database,
|
||||||
options: { keyword?: string; page: number; pageSize: number; providerId?: string },
|
options: { keyword?: string; page: number; pageSize: number; providerId?: string },
|
||||||
): { items: Model[]; page: number; pageSize: number; total: number } {
|
): { items: Model[]; page: number; pageSize: number; total: number } {
|
||||||
const db = wrap(raw);
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
if (options.providerId) {
|
if (options.providerId) {
|
||||||
@@ -99,31 +98,13 @@ export function listModels(
|
|||||||
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
|
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
return paginateQuery(raw, models, {
|
||||||
|
conditions,
|
||||||
const countResult = db
|
mapRow: toModel,
|
||||||
.select({ count: sql<number>`count(*)` })
|
orderBy: () => desc(models.createdAt),
|
||||||
.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),
|
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
total,
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateModel(
|
export function updateModel(
|
||||||
@@ -203,7 +184,3 @@ function toModel(row: typeof models.$inferSelect): Model {
|
|||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap(raw: Database) {
|
|
||||||
return drizzle(raw);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type Database from "bun:sqlite";
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
import { and, desc, eq, like, or, sql } from "drizzle-orm";
|
import { desc, eq, like, or } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
||||||
|
|
||||||
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api";
|
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api";
|
||||||
|
|
||||||
|
import { paginateQuery, wrap } from "./connection";
|
||||||
import { projects } from "./schema";
|
import { projects } from "./schema";
|
||||||
|
|
||||||
export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||||
@@ -79,7 +79,6 @@ export function listProjects(
|
|||||||
raw: Database,
|
raw: Database,
|
||||||
options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus },
|
options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus },
|
||||||
): { items: Project[]; page: number; pageSize: number; total: number } {
|
): { items: Project[]; page: number; pageSize: number; total: number } {
|
||||||
const db = wrap(raw);
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
if (options.status) {
|
if (options.status) {
|
||||||
@@ -91,31 +90,13 @@ export function listProjects(
|
|||||||
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
|
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
return paginateQuery(raw, projects, {
|
||||||
|
conditions,
|
||||||
const countResult = db
|
mapRow: toProject,
|
||||||
.select({ count: sql<number>`count(*)` })
|
orderBy: () => desc(projects.createdAt),
|
||||||
.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),
|
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
total,
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
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,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap(raw: Database) {
|
|
||||||
return drizzle(raw);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type Database from "bun:sqlite";
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
import { and, desc, eq, like, sql } from "drizzle-orm";
|
import { desc, eq, like } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
||||||
|
|
||||||
import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api";
|
import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api";
|
||||||
|
|
||||||
|
import { paginateQuery, wrap } from "./connection";
|
||||||
import { providers } from "./schema";
|
import { providers } from "./schema";
|
||||||
|
|
||||||
export function createProvider(
|
export function createProvider(
|
||||||
@@ -80,7 +80,6 @@ export function listProviders(
|
|||||||
raw: Database,
|
raw: Database,
|
||||||
options: { keyword?: string; page: number; pageSize: number },
|
options: { keyword?: string; page: number; pageSize: number },
|
||||||
): { items: Provider[]; page: number; pageSize: number; total: number } {
|
): { items: Provider[]; page: number; pageSize: number; total: number } {
|
||||||
const db = wrap(raw);
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
if (options.keyword) {
|
if (options.keyword) {
|
||||||
@@ -88,31 +87,13 @@ export function listProviders(
|
|||||||
conditions.push(like(providers.name, pattern));
|
conditions.push(like(providers.name, pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
return paginateQuery(raw, providers, {
|
||||||
|
conditions,
|
||||||
const countResult = db
|
mapRow: toProvider,
|
||||||
.select({ count: sql<number>`count(*)` })
|
orderBy: () => desc(providers.createdAt),
|
||||||
.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),
|
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
total,
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProvider(
|
export function updateProvider(
|
||||||
@@ -179,7 +160,3 @@ function toProvider(row: typeof providers.$inferSelect): Provider {
|
|||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap(raw: Database) {
|
|
||||||
return drizzle(raw);
|
|
||||||
}
|
|
||||||
|
|||||||
2
src/server/helpers/index.ts
Normal file
2
src/server/helpers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response";
|
||||||
|
export { parseIdFromUrl } from "./url";
|
||||||
@@ -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 {
|
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||||
return { error, status };
|
return { error, status };
|
||||||
3
src/server/helpers/url.ts
Normal file
3
src/server/helpers/url.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function parseIdFromUrl(url: URL): string | undefined {
|
||||||
|
return url.pathname.split("/")[3];
|
||||||
|
}
|
||||||
37
src/server/middleware/error-handler.ts
Normal file
37
src/server/middleware/error-handler.ts
Normal file
@@ -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> | 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
2
src/server/middleware/index.ts
Normal file
2
src/server/middleware/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AppError, withErrorHandler } from "./error-handler";
|
||||||
|
export { validateIdParam, validatePagination, validateTimeRange } from "./validate";
|
||||||
@@ -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;
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
|
||||||
export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } {
|
export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } {
|
||||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
|
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 };
|
return { id: idStr };
|
||||||
}
|
}
|
||||||
@@ -22,17 +22,17 @@ export function validatePagination(
|
|||||||
if (pageParam !== null) {
|
if (pageParam !== null) {
|
||||||
page = Number(pageParam);
|
page = Number(pageParam);
|
||||||
if (!Number.isInteger(page) || page <= 0) {
|
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) {
|
if (pageSizeParam !== null) {
|
||||||
pageSize = Number(pageSizeParam);
|
pageSize = Number(pageSizeParam);
|
||||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
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) {
|
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,
|
mode: RuntimeMode,
|
||||||
): Response | { from: string; to: string } {
|
): Response | { from: string; to: string } {
|
||||||
if (!from || !to) {
|
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 fromDate = new Date(from);
|
||||||
const toDate = new Date(to);
|
const toDate = new Date(to);
|
||||||
|
|
||||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
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()) {
|
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() };
|
return { from: fromDate.toISOString(), to: toDate.toISOString() };
|
||||||
@@ -3,12 +3,12 @@ import type Database from "bun:sqlite";
|
|||||||
import type { RuntimeMode } from "../../../shared/api";
|
import type { RuntimeMode } from "../../../shared/api";
|
||||||
|
|
||||||
import { deleteProject } from "../../db/projects";
|
import { deleteProject } from "../../db/projects";
|
||||||
import { createApiError, jsonResponse } from "../../helpers";
|
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||||
import { validateIdParam } from "../../middleware";
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response {
|
export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const idStr = url.pathname.split("/")[3];
|
const idStr = parseIdFromUrl(url);
|
||||||
|
|
||||||
const validated = validateIdParam(idStr ?? "", mode);
|
const validated = validateIdParam(idStr ?? "", mode);
|
||||||
if (validated instanceof Response) return validated;
|
if (validated instanceof Response) return validated;
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import type { RuntimeMode } from "../../../shared/api";
|
|||||||
|
|
||||||
import { getModelsByProviderId } from "../../db/models";
|
import { getModelsByProviderId } from "../../db/models";
|
||||||
import { deleteProvider } from "../../db/providers";
|
import { deleteProvider } from "../../db/providers";
|
||||||
import { createApiError, jsonResponse } from "../../helpers";
|
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||||
import { validateIdParam } from "../../middleware";
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode): Response {
|
export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const idStr = url.pathname.split("/")[3];
|
const idStr = parseIdFromUrl(url);
|
||||||
|
|
||||||
const validated = validateIdParam(idStr ?? "", mode);
|
const validated = validateIdParam(idStr ?? "", mode);
|
||||||
if (validated instanceof Response) return validated;
|
if (validated instanceof Response) return validated;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Logger } from "./logger";
|
|||||||
import type { StaticAssets } from "./static";
|
import type { StaticAssets } from "./static";
|
||||||
|
|
||||||
import { createApiError, jsonResponse } from "./helpers";
|
import { createApiError, jsonResponse } from "./helpers";
|
||||||
|
import { withErrorHandler } from "./middleware";
|
||||||
import { handleMeta } from "./routes/meta";
|
import { handleMeta } from "./routes/meta";
|
||||||
import { serveStaticAsset } from "./static";
|
import { serveStaticAsset } from "./static";
|
||||||
import { readAppVersion } from "./version";
|
import { readAppVersion } from "./version";
|
||||||
@@ -38,112 +39,196 @@ export function startServer(options: StartServerOptions) {
|
|||||||
routes: {
|
routes: {
|
||||||
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
||||||
"/api/meta": {
|
"/api/meta": {
|
||||||
GET: async () => {
|
GET: withErrorHandler(
|
||||||
const resolvedVersion = await resolveVersion();
|
async () => {
|
||||||
return handleMeta(mode, resolvedVersion);
|
const resolvedVersion = await resolveVersion();
|
||||||
},
|
return handleMeta(mode, resolvedVersion);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/models": {
|
"/api/models": {
|
||||||
GET: async (req) => {
|
GET: withErrorHandler(
|
||||||
const { handleListModels } = await import("./routes/models/list");
|
async (req) => {
|
||||||
return handleListModels(req, db, mode);
|
const { handleListModels } = await import("./routes/models/list");
|
||||||
},
|
return handleListModels(req, db, mode);
|
||||||
POST: async (req) => {
|
},
|
||||||
const { handleCreateModel } = await import("./routes/models/create");
|
mode,
|
||||||
return handleCreateModel(req, db, mode);
|
logger,
|
||||||
},
|
),
|
||||||
|
POST: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleCreateModel } = await import("./routes/models/create");
|
||||||
|
return handleCreateModel(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/models/:id": {
|
"/api/models/:id": {
|
||||||
DELETE: async (req) => {
|
DELETE: withErrorHandler(
|
||||||
const { handleDeleteModel } = await import("./routes/models/delete");
|
async (req) => {
|
||||||
return handleDeleteModel(req, db, mode);
|
const { handleDeleteModel } = await import("./routes/models/delete");
|
||||||
},
|
return handleDeleteModel(req, db, mode);
|
||||||
GET: async (req) => {
|
},
|
||||||
const { handleGetModel } = await import("./routes/models/get");
|
mode,
|
||||||
return handleGetModel(req, db, mode);
|
logger,
|
||||||
},
|
),
|
||||||
PATCH: async (req) => {
|
GET: withErrorHandler(
|
||||||
const { handleUpdateModel } = await import("./routes/models/update");
|
async (req) => {
|
||||||
return handleUpdateModel(req, db, mode);
|
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": {
|
"/api/models/test": {
|
||||||
POST: async (req) => {
|
POST: withErrorHandler(
|
||||||
const { handleTestModelConfig } = await import("./routes/models/test");
|
async (req) => {
|
||||||
return handleTestModelConfig(req, db, mode);
|
const { handleTestModelConfig } = await import("./routes/models/test");
|
||||||
},
|
return handleTestModelConfig(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/projects": {
|
"/api/projects": {
|
||||||
GET: async (req) => {
|
GET: withErrorHandler(
|
||||||
const { handleListProjects } = await import("./routes/projects/list");
|
async (req) => {
|
||||||
return handleListProjects(req, db, mode);
|
const { handleListProjects } = await import("./routes/projects/list");
|
||||||
},
|
return handleListProjects(req, db, mode);
|
||||||
POST: async (req) => {
|
},
|
||||||
const { handleCreateProject } = await import("./routes/projects/create");
|
mode,
|
||||||
return handleCreateProject(req, db, mode);
|
logger,
|
||||||
},
|
),
|
||||||
|
POST: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleCreateProject } = await import("./routes/projects/create");
|
||||||
|
return handleCreateProject(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/projects/:id": {
|
"/api/projects/:id": {
|
||||||
DELETE: async (req) => {
|
DELETE: withErrorHandler(
|
||||||
const { handleDeleteProject } = await import("./routes/projects/delete");
|
async (req) => {
|
||||||
return handleDeleteProject(req, db, mode);
|
const { handleDeleteProject } = await import("./routes/projects/delete");
|
||||||
},
|
return handleDeleteProject(req, db, mode);
|
||||||
GET: async (req) => {
|
},
|
||||||
const { handleGetProject } = await import("./routes/projects/get");
|
mode,
|
||||||
return handleGetProject(req, db, mode);
|
logger,
|
||||||
},
|
),
|
||||||
PATCH: async (req) => {
|
GET: withErrorHandler(
|
||||||
const { handleUpdateProject } = await import("./routes/projects/update");
|
async (req) => {
|
||||||
return handleUpdateProject(req, db, mode);
|
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": {
|
"/api/projects/:id/archive": {
|
||||||
POST: async (req) => {
|
POST: withErrorHandler(
|
||||||
const { handleArchiveProject } = await import("./routes/projects/archive");
|
async (req) => {
|
||||||
return handleArchiveProject(req, db, mode);
|
const { handleArchiveProject } = await import("./routes/projects/archive");
|
||||||
},
|
return handleArchiveProject(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/projects/:id/restore": {
|
"/api/projects/:id/restore": {
|
||||||
POST: async (req) => {
|
POST: withErrorHandler(
|
||||||
const { handleRestoreProject } = await import("./routes/projects/restore");
|
async (req) => {
|
||||||
return handleRestoreProject(req, db, mode);
|
const { handleRestoreProject } = await import("./routes/projects/restore");
|
||||||
},
|
return handleRestoreProject(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/providers": {
|
"/api/providers": {
|
||||||
GET: async (req) => {
|
GET: withErrorHandler(
|
||||||
const { handleListProviders } = await import("./routes/providers/list");
|
async (req) => {
|
||||||
return handleListProviders(req, db, mode);
|
const { handleListProviders } = await import("./routes/providers/list");
|
||||||
},
|
return handleListProviders(req, db, mode);
|
||||||
POST: async (req) => {
|
},
|
||||||
const { handleCreateProvider } = await import("./routes/providers/create");
|
mode,
|
||||||
return handleCreateProvider(req, db, mode);
|
logger,
|
||||||
},
|
),
|
||||||
|
POST: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleCreateProvider } = await import("./routes/providers/create");
|
||||||
|
return handleCreateProvider(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/providers/:id": {
|
"/api/providers/:id": {
|
||||||
DELETE: async (req) => {
|
DELETE: withErrorHandler(
|
||||||
const { handleDeleteProvider } = await import("./routes/providers/delete");
|
async (req) => {
|
||||||
return handleDeleteProvider(req, db, mode);
|
const { handleDeleteProvider } = await import("./routes/providers/delete");
|
||||||
},
|
return handleDeleteProvider(req, db, mode);
|
||||||
GET: async (req) => {
|
},
|
||||||
const { handleGetProvider } = await import("./routes/providers/get");
|
mode,
|
||||||
return handleGetProvider(req, db, mode);
|
logger,
|
||||||
},
|
),
|
||||||
PATCH: async (req) => {
|
GET: withErrorHandler(
|
||||||
const { handleUpdateProvider } = await import("./routes/providers/update");
|
async (req) => {
|
||||||
return handleUpdateProvider(req, db, mode);
|
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": {
|
"/api/providers/options": {
|
||||||
GET: async () => {
|
GET: withErrorHandler(
|
||||||
const { handleListProviderOptions } = await import("./routes/providers/options");
|
async () => {
|
||||||
return handleListProviderOptions(db, mode);
|
const { handleListProviderOptions } = await import("./routes/providers/options");
|
||||||
},
|
return handleListProviderOptions(db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"/api/providers/test": {
|
"/api/providers/test": {
|
||||||
POST: async (req) => {
|
POST: withErrorHandler(
|
||||||
const { handleTestProviderConfig } = await import("./routes/providers/test");
|
async (req) => {
|
||||||
return handleTestProviderConfig(req, db, mode);
|
const { handleTestProviderConfig } = await import("./routes/providers/test");
|
||||||
},
|
return handleTestProviderConfig(req, db, mode);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
UpdateModelRequest,
|
UpdateModelRequest,
|
||||||
} from "../../shared/api";
|
} from "../../shared/api";
|
||||||
|
|
||||||
|
import { handleResponse, handleVoidResponse } from "../utils/api";
|
||||||
|
|
||||||
const MODELS_KEY = ["models"] as const;
|
const MODELS_KEY = ["models"] as const;
|
||||||
|
|
||||||
export async function createModel(data: CreateModelRequest): Promise<Model> {
|
export async function createModel(data: CreateModelRequest): Promise<Model> {
|
||||||
@@ -19,20 +21,17 @@ export async function createModel(data: CreateModelRequest): Promise<Model> {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response, (data) => (data as ModelResponse).model);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModel(id: string): Promise<void> {
|
export async function deleteModel(id: string): Promise<void> {
|
||||||
const response = await fetch(`/api/models/${id}`, { method: "DELETE" });
|
const response = await fetch(`/api/models/${id}`, { method: "DELETE" });
|
||||||
if (!response.ok) {
|
return handleVoidResponse(response);
|
||||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
|
||||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchModel(id: string): Promise<Model> {
|
export async function fetchModel(id: string): Promise<Model> {
|
||||||
const response = await fetch(`/api/models/${id}`);
|
const response = await fetch(`/api/models/${id}`);
|
||||||
return handleResponse(response);
|
return handleResponse(response, (data) => (data as ModelResponse).model);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchModelList(params: {
|
export async function fetchModelList(params: {
|
||||||
@@ -76,7 +75,7 @@ export async function updateModel(id: string, data: UpdateModelRequest): Promise
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
});
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response, (data) => (data as ModelResponse).model);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateModel() {
|
export function useCreateModel() {
|
||||||
@@ -129,12 +128,3 @@ export function useUpdateModel() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResponse(response: Response): Promise<Model> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,16 +9,13 @@ import type {
|
|||||||
UpdateProjectRequest,
|
UpdateProjectRequest,
|
||||||
} from "../../shared/api";
|
} from "../../shared/api";
|
||||||
|
|
||||||
|
import { handleResponse, handleVoidResponse } from "../utils/api";
|
||||||
|
|
||||||
const PROJECTS_KEY = ["projects"] as const;
|
const PROJECTS_KEY = ["projects"] as const;
|
||||||
|
|
||||||
export async function archiveProject(id: string): Promise<Project> {
|
export async function archiveProject(id: string): Promise<Project> {
|
||||||
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
|
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
|
||||||
if (!response.ok) {
|
return handleResponse(response, (data) => (data as ProjectResponse).project);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProject(data: CreateProjectRequest): Promise<Project> {
|
export async function createProject(data: CreateProjectRequest): Promise<Project> {
|
||||||
@@ -27,30 +24,17 @@ export async function createProject(data: CreateProjectRequest): Promise<Project
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
return handleResponse(response, (data) => (data as ProjectResponse).project);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProject(id: string): Promise<void> {
|
export async function deleteProject(id: string): Promise<void> {
|
||||||
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||||
if (!response.ok) {
|
return handleVoidResponse(response);
|
||||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
|
||||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProject(id: string): Promise<Project> {
|
export async function fetchProject(id: string): Promise<Project> {
|
||||||
const response = await fetch(`/api/projects/${id}`);
|
const response = await fetch(`/api/projects/${id}`);
|
||||||
if (!response.ok) {
|
return handleResponse(response, (data) => (data as ProjectResponse).project);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProjectList(params: {
|
export async function fetchProjectList(params: {
|
||||||
@@ -76,12 +60,7 @@ export async function fetchProjectList(params: {
|
|||||||
|
|
||||||
export async function restoreProject(id: string): Promise<Project> {
|
export async function restoreProject(id: string): Promise<Project> {
|
||||||
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
|
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
|
||||||
if (!response.ok) {
|
return handleResponse(response, (data) => (data as ProjectResponse).project);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
export async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||||||
@@ -90,12 +69,7 @@ export async function updateProject(id: string, data: UpdateProjectRequest): Pro
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
return handleResponse(response, (data) => (data as ProjectResponse).project);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useArchiveProject() {
|
export function useArchiveProject() {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
UpdateProviderRequest,
|
UpdateProviderRequest,
|
||||||
} from "../../shared/api";
|
} from "../../shared/api";
|
||||||
|
|
||||||
|
import { handleResponse, handleVoidResponse } from "../utils/api";
|
||||||
|
|
||||||
const PROVIDERS_KEY = ["providers"] as const;
|
const PROVIDERS_KEY = ["providers"] as const;
|
||||||
const MODELS_KEY = ["models"] as const;
|
const MODELS_KEY = ["models"] as const;
|
||||||
|
|
||||||
@@ -20,20 +22,17 @@ export async function createProvider(data: CreateProviderRequest): Promise<Provi
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response, (data) => (data as ProviderResponse).provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProvider(id: string): Promise<void> {
|
export async function deleteProvider(id: string): Promise<void> {
|
||||||
const response = await fetch(`/api/providers/${id}`, { method: "DELETE" });
|
const response = await fetch(`/api/providers/${id}`, { method: "DELETE" });
|
||||||
if (!response.ok) {
|
return handleVoidResponse(response);
|
||||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
|
||||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProvider(id: string): Promise<Provider> {
|
export async function fetchProvider(id: string): Promise<Provider> {
|
||||||
const response = await fetch(`/api/providers/${id}`);
|
const response = await fetch(`/api/providers/${id}`);
|
||||||
return handleResponse(response);
|
return handleResponse(response, (data) => (data as ProviderResponse).provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProviderList(params: {
|
export async function fetchProviderList(params: {
|
||||||
@@ -84,7 +83,7 @@ export async function updateProvider(id: string, data: UpdateProviderRequest): P
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
});
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response, (data) => (data as ProviderResponse).provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateProvider() {
|
export function useCreateProvider() {
|
||||||
@@ -145,12 +144,3 @@ export function useUpdateProvider() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResponse(response: Response): Promise<Provider> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PlusOutlined } from "@ant-design/icons";
|
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 { useState } from "react";
|
||||||
|
|
||||||
interface ModelsToolbarProps {
|
interface ModelsToolbarProps {
|
||||||
@@ -29,9 +29,9 @@ export function ModelsToolbar({
|
|||||||
const createLabel = activeTab === "providers" ? "新建供应商" : "新建模型";
|
const createLabel = activeTab === "providers" ? "新建供应商" : "新建模型";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" gap="var(--ant-margin-lg)" justify="space-between" wrap="wrap">
|
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
|
||||||
<Tabs activeKey={activeTab} items={TAB_ITEMS} onChange={onTabChange} />
|
<Tabs activeKey={activeTab} items={TAB_ITEMS} onChange={onTabChange} />
|
||||||
<Flex align="center" gap="small">
|
<Space size="small">
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
enterButton="搜索"
|
enterButton="搜索"
|
||||||
@@ -47,7 +47,7 @@ export function ModelsToolbar({
|
|||||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
||||||
{createLabel}
|
{createLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Flex } from "antd";
|
import { Space } from "antd";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { Model, Provider, TestModelRequest } from "../../../shared/api";
|
import type { Model, Provider, TestModelRequest } from "../../../shared/api";
|
||||||
@@ -111,7 +111,7 @@ export function ModelsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex flex={1} gap="var(--ant-margin-lg)" vertical>
|
<Space orientation="vertical" size="large" style={{ flex: 1 }}>
|
||||||
<ModelsToolbar
|
<ModelsToolbar
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
@@ -185,6 +185,6 @@ export function ModelsPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PlusOutlined } from "@ant-design/icons";
|
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 { useState } from "react";
|
||||||
|
|
||||||
import type { ProjectStatus } from "../../../../shared/api";
|
import type { ProjectStatus } from "../../../../shared/api";
|
||||||
@@ -29,9 +29,9 @@ export function ProjectToolbar({
|
|||||||
const [draftKeyword, setDraftKeyword] = useState(keyword);
|
const [draftKeyword, setDraftKeyword] = useState(keyword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" gap="var(--ant-margin-lg)" justify="space-between" wrap="wrap">
|
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
|
||||||
<Tabs activeKey={activeTab} items={STATUS_TAB_ITEMS} onChange={onTabChange} />
|
<Tabs activeKey={activeTab} items={STATUS_TAB_ITEMS} onChange={onTabChange} />
|
||||||
<Flex align="center" gap="small">
|
<Space size="small">
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
enterButton="搜索"
|
enterButton="搜索"
|
||||||
@@ -49,7 +49,7 @@ export function ProjectToolbar({
|
|||||||
新建项目
|
新建项目
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Flex } from "antd";
|
import { Space } from "antd";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { Project, ProjectStatus } from "../../../shared/api";
|
import type { Project, ProjectStatus } from "../../../shared/api";
|
||||||
@@ -35,7 +35,7 @@ export function ProjectsPage() {
|
|||||||
const isRowActionPending = archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending;
|
const isRowActionPending = archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex flex={1} gap="var(--ant-margin-lg)" vertical>
|
<Space orientation="vertical" size="large" style={{ flex: 1 }}>
|
||||||
<ProjectToolbar
|
<ProjectToolbar
|
||||||
activeTab={tabValue}
|
activeTab={tabValue}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
@@ -84,6 +84,6 @@ export function ProjectsPage() {
|
|||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
submitting={isSubmitting}
|
submitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/web/utils/api.ts
Normal file
15
src/web/utils/api.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export async function handleResponse<T>(response: Response, extract: (data: unknown) => T): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||||
|
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/require-await */
|
||||||
import { describe, expect, test } from "bun:test";
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -49,6 +49,19 @@ function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("bootstrap", () => {
|
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 () => {
|
test("使用默认依赖启动", async () => {
|
||||||
let started = false;
|
let started = false;
|
||||||
let signalRegistered = false;
|
let signalRegistered = false;
|
||||||
@@ -56,7 +69,8 @@ describe("bootstrap", () => {
|
|||||||
|
|
||||||
const cfg = makeTempConfig();
|
const cfg = makeTempConfig();
|
||||||
const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"];
|
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;
|
signalRegistered = true;
|
||||||
};
|
};
|
||||||
const mockStartServer = (options: StartServerOptions) => {
|
const mockStartServer = (options: StartServerOptions) => {
|
||||||
@@ -67,6 +81,9 @@ describe("bootstrap", () => {
|
|||||||
|
|
||||||
const deps: BootstrapDependencies = {
|
const deps: BootstrapDependencies = {
|
||||||
createLogger: async () => createMemoryLogger(),
|
createLogger: async () => createMemoryLogger(),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit(${code})`);
|
||||||
|
},
|
||||||
loadConfig: mockLoadConfig,
|
loadConfig: mockLoadConfig,
|
||||||
onSignal: mockOnSignal,
|
onSignal: mockOnSignal,
|
||||||
startServer: mockStartServer,
|
startServer: mockStartServer,
|
||||||
@@ -90,8 +107,13 @@ describe("bootstrap", () => {
|
|||||||
expect(version).toBe("1.2.3");
|
expect(version).toBe("1.2.3");
|
||||||
return createMemoryLogger();
|
return createMemoryLogger();
|
||||||
},
|
},
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit(${code})`);
|
||||||
|
},
|
||||||
loadConfig: async () => cfg,
|
loadConfig: async () => cfg,
|
||||||
onSignal: () => {},
|
onSignal: (_signal, handler) => {
|
||||||
|
shutdownHandlers.push(handler);
|
||||||
|
},
|
||||||
startServer: (options: StartServerOptions) => {
|
startServer: (options: StartServerOptions) => {
|
||||||
receivedVersion = options.version;
|
receivedVersion = options.version;
|
||||||
return {};
|
return {};
|
||||||
@@ -191,7 +213,13 @@ describe("bootstrap", () => {
|
|||||||
|
|
||||||
const deps: BootstrapDependencies = {
|
const deps: BootstrapDependencies = {
|
||||||
createLogger: async () => mockLogger,
|
createLogger: async () => mockLogger,
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit(${code})`);
|
||||||
|
},
|
||||||
loadConfig: async () => cfg,
|
loadConfig: async () => cfg,
|
||||||
|
onSignal: (_signal, handler) => {
|
||||||
|
shutdownHandlers.push(handler);
|
||||||
|
},
|
||||||
startServer: () => ({}),
|
startServer: () => ({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,7 +230,7 @@ describe("bootstrap", () => {
|
|||||||
|
|
||||||
test("shutdown 时 flush logger", async () => {
|
test("shutdown 时 flush logger", async () => {
|
||||||
let flushed = false;
|
let flushed = false;
|
||||||
let shutdownHandler: (() => void) | undefined;
|
let exitCode: number | undefined;
|
||||||
|
|
||||||
const mockLogger = createMemoryLogger();
|
const mockLogger = createMemoryLogger();
|
||||||
mockLogger.flush = () => {
|
mockLogger.flush = () => {
|
||||||
@@ -212,17 +240,29 @@ describe("bootstrap", () => {
|
|||||||
const cfg = makeTempConfig();
|
const cfg = makeTempConfig();
|
||||||
const deps: BootstrapDependencies = {
|
const deps: BootstrapDependencies = {
|
||||||
createLogger: async () => mockLogger,
|
createLogger: async () => mockLogger,
|
||||||
|
exit: (code: number) => {
|
||||||
|
exitCode = code;
|
||||||
|
throw new Error("exit called");
|
||||||
|
},
|
||||||
loadConfig: async () => cfg,
|
loadConfig: async () => cfg,
|
||||||
onSignal: (_signal, handler) => {
|
onSignal: (_signal, handler) => {
|
||||||
shutdownHandler = handler;
|
shutdownHandlers.push(handler);
|
||||||
},
|
},
|
||||||
startServer: () => ({}),
|
startServer: () => ({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||||
|
|
||||||
expect(shutdownHandler).toBeDefined();
|
const handler = shutdownHandlers.pop();
|
||||||
shutdownHandler!();
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
handler!();
|
||||||
|
} catch {
|
||||||
|
// expected - exit threw
|
||||||
|
}
|
||||||
|
|
||||||
expect(flushed).toBe(true);
|
expect(flushed).toBe(true);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ describe("loadServerConfig", () => {
|
|||||||
await loadServerConfig(yamlPath);
|
await loadServerConfig(yamlPath);
|
||||||
expect.unreachable();
|
expect.unreachable();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect((error as Error).message).toContain("日志等级");
|
expect((error as Error).message).toContain("server.logging.level");
|
||||||
|
expect((error as Error).message).toContain("不在允许范围内");
|
||||||
} finally {
|
} finally {
|
||||||
await rm(yamlPath, { force: true });
|
await rm(yamlPath, { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 全局测试配置
|
* 全局测试配置
|
||||||
* 主要为后端测试提供基础环境
|
* 后端测试无需 DOM 环境,前端测试依赖 jsdom 及 antd polyfill
|
||||||
* 组件测试使用各自的 test-utils.tsx
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||||
// Set up jsdom for ALL tests (both backend and frontend)
|
|
||||||
|
// 仅当前端测试需要时初始化 jsdom(所有测试共享 preload,后端测试也在此环境中运行)
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||||
@@ -21,20 +21,14 @@ globalThis.HTMLBodyElement = dom.window.HTMLBodyElement;
|
|||||||
globalThis.HTMLHtmlElement = dom.window.HTMLHtmlElement;
|
globalThis.HTMLHtmlElement = dom.window.HTMLHtmlElement;
|
||||||
globalThis.Element = dom.window.Element;
|
globalThis.Element = dom.window.Element;
|
||||||
globalThis.getComputedStyle = (element: Element, pseudoElt?: null | string) => {
|
globalThis.getComputedStyle = (element: Element, pseudoElt?: null | string) => {
|
||||||
// jsdom 不支持伪元素计算样式;antd/rc-trigger 会传入伪元素参数,测试中退回普通样式即可。
|
|
||||||
return dom.window.getComputedStyle(element, pseudoElt ? undefined : pseudoElt);
|
return dom.window.getComputedStyle(element, pseudoElt ? undefined : pseudoElt);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure document.body exists
|
|
||||||
if (!globalThis.document.body) {
|
if (!globalThis.document.body) {
|
||||||
const body = globalThis.document.createElement("body");
|
const body = globalThis.document.createElement("body");
|
||||||
globalThis.document.documentElement.appendChild(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 nodeProto = dom.window.Node.prototype;
|
||||||
const elementProto = dom.window.Element.prototype;
|
const elementProto = dom.window.Element.prototype;
|
||||||
const htmlElementProto = dom.window.HTMLElement.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, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||||
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, 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);
|
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||||
process.stderr.write = (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown) => {
|
process.stderr.write = (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown) => {
|
||||||
const str = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
|
const str = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
|
||||||
@@ -75,7 +68,6 @@ console.warn = (...args: unknown[]) => {
|
|||||||
originalConsoleWarn(...args);
|
originalConsoleWarn(...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Other polyfills
|
|
||||||
globalThis.ResizeObserver = class {
|
globalThis.ResizeObserver = class {
|
||||||
disconnect() {}
|
disconnect() {}
|
||||||
observe() {}
|
observe() {}
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ const ARCHIVED_PROJECT: Project = {
|
|||||||
updatedAt: "2024-01-02T00:00:00.000Z",
|
updatedAt: "2024-01-02T00:00:00.000Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
function clickLatestConfirmButton() {
|
async function clickLatestConfirmButton() {
|
||||||
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
|
const confirmTexts = await screen.findAllByText(/确\s*定|OK|确认/);
|
||||||
fireEvent.click(buttons[buttons.length - 1]!);
|
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]) {
|
function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, ARCHIVED_PROJECT]) {
|
||||||
@@ -170,7 +172,7 @@ describe("ProjectsPage", () => {
|
|||||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
||||||
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "新增项目" } });
|
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "新增项目" } });
|
||||||
fireEvent.change(screen.getByPlaceholderText("请输入项目描述"), { target: { value: "新增描述" } });
|
fireEvent.change(screen.getByPlaceholderText("请输入项目描述"), { target: { value: "新增描述" } });
|
||||||
clickLatestConfirmButton();
|
await clickLatestConfirmButton();
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("新增项目")).not.toBeNull());
|
await waitFor(() => expect(screen.getByText("新增项目")).not.toBeNull());
|
||||||
|
|
||||||
@@ -200,7 +202,7 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
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(onUpdate).toHaveBeenCalled());
|
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
||||||
expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" });
|
expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" });
|
||||||
@@ -223,11 +225,11 @@ describe("ProjectsPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
||||||
clickLatestConfirmButton();
|
await clickLatestConfirmButton();
|
||||||
expect(onCreate).not.toHaveBeenCalled();
|
expect(onCreate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } });
|
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } });
|
||||||
clickLatestConfirmButton();
|
await clickLatestConfirmButton();
|
||||||
await waitFor(() => expect(onCreate).toHaveBeenCalled());
|
await waitFor(() => expect(onCreate).toHaveBeenCalled());
|
||||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||||
expect(screen.getByText("新建项目")).not.toBeNull();
|
expect(screen.getByText("新建项目")).not.toBeNull();
|
||||||
@@ -262,17 +264,17 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /归档/ }));
|
fireEvent.click(screen.getByRole("button", { name: /归档/ }));
|
||||||
await waitFor(() => expect(screen.getByText("确认归档此项目?")).not.toBeNull());
|
await waitFor(() => expect(screen.getByText("确认归档此项目?")).not.toBeNull());
|
||||||
clickLatestConfirmButton();
|
await clickLatestConfirmButton();
|
||||||
await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1"));
|
await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1"));
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /恢复/ }));
|
fireEvent.click(screen.getByRole("button", { name: /恢复/ }));
|
||||||
await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull());
|
await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull());
|
||||||
clickLatestConfirmButton();
|
await clickLatestConfirmButton();
|
||||||
await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2"));
|
await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2"));
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /删除/ }));
|
fireEvent.click(screen.getByRole("button", { name: /删除/ }));
|
||||||
await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull());
|
await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull());
|
||||||
clickLatestConfirmButton();
|
await clickLatestConfirmButton();
|
||||||
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2"));
|
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2"));
|
||||||
});
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user