feat(KB-629): complete Step 1 — add batch update API endpoint with tests
This commit is contained in:
@@ -867,6 +867,274 @@ describe("POST /tasks/archive-all-done", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/batch-update-models", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("updates multiple tasks with executor and validator models", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "KB-001" };
|
||||
const task2 = { ...FAKE_TASK_DETAIL, id: "KB-002" };
|
||||
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o", validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5" };
|
||||
const updated2 = { ...task2, modelProvider: "openai", modelId: "gpt-4o", validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5" };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(task1)
|
||||
.mockResolvedValueOnce(task2);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(updated1)
|
||||
.mockResolvedValueOnce(updated2);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001", "KB-002"],
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
expect(res.body.updated).toHaveLength(2);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-002", {
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates only executor model when only executor fields provided", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "KB-001" };
|
||||
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o" };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates only validator model when only validator fields provided", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "KB-001" };
|
||||
const updated1 = { ...task1, validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5" };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears models when null values provided", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "KB-001", modelProvider: "openai", modelId: "gpt-4o" };
|
||||
const updated1 = { ...task1, modelProvider: undefined, modelId: undefined };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when taskIds is not an array", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: "KB-001",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("taskIds must be an array");
|
||||
});
|
||||
|
||||
it("returns 400 when taskIds is empty", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: [],
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("at least one task ID");
|
||||
});
|
||||
|
||||
it("returns 400 when taskIds contains non-string values", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001", 123],
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("non-empty strings");
|
||||
});
|
||||
|
||||
it("returns 400 when no model fields provided", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("At least one model field");
|
||||
});
|
||||
|
||||
it("returns 400 when only executor provider provided (missing modelId)", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
modelProvider: "openai",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Executor model must include both provider and modelId");
|
||||
});
|
||||
|
||||
it("returns 400 when only executor modelId provided (missing provider)", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
modelId: "gpt-4o",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Executor model must include both provider and modelId");
|
||||
});
|
||||
|
||||
it("returns 400 when only validator provider provided (missing modelId)", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
validatorModelProvider: "anthropic",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Validator model must include both provider and modelId");
|
||||
});
|
||||
|
||||
it("returns 400 when only validator modelId provided (missing provider)", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001"],
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Validator model must include both provider and modelId");
|
||||
});
|
||||
|
||||
it("returns 404 when task does not exist", async () => {
|
||||
const err = new Error("Task KB-999 not found") as Error & { code: string };
|
||||
err.code = "ENOENT";
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValueOnce(err);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-999"],
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("KB-999 not found");
|
||||
});
|
||||
|
||||
it("continues with other tasks when individual update fails", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "KB-001" };
|
||||
const task2 = { ...FAKE_TASK_DETAIL, id: "KB-002" };
|
||||
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o" };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(task1)
|
||||
.mockResolvedValueOnce(task2);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(updated1)
|
||||
.mockRejectedValueOnce(new Error("Update failed"));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["KB-001", "KB-002"],
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.updated).toHaveLength(1);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /tasks/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -1567,6 +1567,132 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/batch-update-models
|
||||
* Batch update AI model configuration for multiple tasks.
|
||||
* Body: { taskIds: string[], modelProvider?: string | null, modelId?: string | null, validatorModelProvider?: string | null, validatorModelId?: string | null }
|
||||
* Returns: { updated: Task[], count: number }
|
||||
*/
|
||||
router.post("/tasks/batch-update-models", async (req, res) => {
|
||||
try {
|
||||
const { taskIds, modelProvider, modelId, validatorModelProvider, validatorModelId } = req.body;
|
||||
|
||||
// Validate taskIds
|
||||
if (!Array.isArray(taskIds)) {
|
||||
res.status(400).json({ error: "taskIds must be an array" });
|
||||
return;
|
||||
}
|
||||
if (taskIds.length === 0) {
|
||||
res.status(400).json({ error: "taskIds must contain at least one task ID" });
|
||||
return;
|
||||
}
|
||||
if (taskIds.some((id) => typeof id !== "string" || id.trim().length === 0)) {
|
||||
res.status(400).json({ error: "taskIds must contain non-empty strings" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that at least one model field is being updated
|
||||
const hasExecutorModel = modelProvider !== undefined || modelId !== undefined;
|
||||
const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined;
|
||||
if (!hasExecutorModel && !hasValidatorModel) {
|
||||
res.status(400).json({ error: "At least one model field must be provided" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate model field pairs (both provider and modelId must be provided together or neither)
|
||||
const validateModelPair = (provider: unknown, modelIdValue: unknown, name: string): { provider?: string | null; modelId?: string | null } => {
|
||||
if (provider === undefined && modelIdValue === undefined) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
if ((provider !== undefined && modelIdValue === undefined) || (provider === undefined && modelIdValue !== undefined)) {
|
||||
throw new Error(`${name} must include both provider and modelId or neither`);
|
||||
}
|
||||
if (provider !== null && typeof provider !== "string") {
|
||||
throw new Error(`${name} provider must be a string or null`);
|
||||
}
|
||||
if (modelIdValue !== null && typeof modelIdValue !== "string") {
|
||||
throw new Error(`${name} modelId must be a string or null`);
|
||||
}
|
||||
return { provider: provider as string | null, modelId: modelIdValue as string | null };
|
||||
};
|
||||
|
||||
let validatedExecutor: { provider?: string | null; modelId?: string | null };
|
||||
let validatedValidator: { provider?: string | null; modelId?: string | null };
|
||||
|
||||
try {
|
||||
validatedExecutor = validateModelPair(modelProvider, modelId, "Executor model");
|
||||
validatedValidator = validateModelPair(validatorModelProvider, validatorModelId, "Validator model");
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify all tasks exist
|
||||
const tasksById = new Map<string, Awaited<ReturnType<TaskStore["getTask"]>>>();
|
||||
for (const taskId of taskIds) {
|
||||
try {
|
||||
const task = await store.getTask(taskId);
|
||||
tasksById.set(taskId, task);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT" || err?.message?.includes("not found")) {
|
||||
res.status(404).json({ error: `Task ${taskId} not found` });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Build update payload (only include fields that were explicitly provided)
|
||||
const updates: { modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null } = {};
|
||||
if (validatedExecutor.provider !== undefined) {
|
||||
updates.modelProvider = validatedExecutor.provider;
|
||||
}
|
||||
if (validatedExecutor.modelId !== undefined) {
|
||||
updates.modelId = validatedExecutor.modelId;
|
||||
}
|
||||
if (validatedValidator.provider !== undefined) {
|
||||
updates.validatorModelProvider = validatedValidator.provider;
|
||||
}
|
||||
if (validatedValidator.modelId !== undefined) {
|
||||
updates.validatorModelId = validatedValidator.modelId;
|
||||
}
|
||||
|
||||
// Update all tasks in parallel
|
||||
const updatePromises = taskIds.map(async (taskId) => {
|
||||
try {
|
||||
const updated = await store.updateTask(taskId, updates);
|
||||
return { success: true, task: updated };
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to update task ${taskId}:`, err);
|
||||
return { success: false, taskId, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(updatePromises);
|
||||
|
||||
// Collect successful updates
|
||||
const updated: Task[] = [];
|
||||
const errors: Array<{ taskId: string; error: string }> = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.success && "task" in result) {
|
||||
updated.push(result.task);
|
||||
} else if (!result.success) {
|
||||
errors.push({ taskId: result.taskId, error: result.error });
|
||||
}
|
||||
}
|
||||
|
||||
// Log errors but don't fail the entire request
|
||||
if (errors.length > 0) {
|
||||
console.error(`[batch-update-models] ${errors.length} tasks failed to update:`, errors);
|
||||
}
|
||||
|
||||
res.json({ updated, count: updated.length });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to batch update models" });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload attachment
|
||||
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user