update_test_case
Modify existing test cases in TestCollab by updating fields like title, priority, steps, tags, or custom fields to maintain accurate testing documentation.
Instructions
Update an existing test case in TestCollab. Only provided fields will be updated. Tip: Call get_project_context first to resolve suite/tag/custom field names to IDs. Tip: If you need existing steps (e.g., to fill missing expected results), call get_test_case first and then use steps_patch.
Required: id (test case ID)
Optional fields:
title: New title
suite: Move to different suite
description: New description (HTML)
priority: 0 (Low), 1 (Normal), 2 (High)
steps: Replaces all existing steps
steps_patch: Patch steps by step number (1-based) without replacing all steps
tags: Replaces all existing tags
requirements: Replaces all existing requirements
custom_fields: Update specific custom fields
Example: { "id": 1712, "title": "Updated login test", "priority": 2 }
Example - patch a single step: { "id": 1714, "steps_patch": [ { "step_number": 1, "expected_result": "Appropriate expected result" } ] }
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| id | Yes | Test case ID to update (required) | |
| project_id | No | Project ID (optional if default is set) | |
| title | No | New test case title | |
| suite | No | Move to a different suite by ID or title (null to remove) | |
| description | No | New description (HTML supported) | |
| priority | No | New priority: 0=Low, 1=Normal, 2=High | |
| steps | No | Replace all steps | |
| steps_patch | No | Patch steps by step number (1-based) without replacing all steps | |
| tags | No | Replace tags with these IDs or names | |
| requirements | No | Replace requirements with these IDs or names | |
| custom_fields | No | Update custom field values (id optional if name provided) | |
| attachments | No | Replace attachments with these file IDs |
Implementation Reference
- src/tools/test-cases/update.ts:563-1100 (handler)The handleUpdateTestCase function handles the execution logic for the update_test_case tool, including input validation, project/suite resolution, fetching current state to construct a complete update payload, and calling the API client.
export async function handleUpdateTestCase( args: unknown ): Promise<{ content: Array<{ type: "text"; text: string }> }> { // Validate input const parsed = updateTestCaseSchema.safeParse(args); if (!parsed.success) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "VALIDATION_ERROR", message: "Invalid input parameters", details: parsed.error.errors, }, }), }, ], }; } const { id, project_id, title, suite, description, priority, steps, steps_patch, tags, requirements, custom_fields, attachments, } = parsed.data; const rawArgs = (args && typeof args === "object") ? (args as Record<string, unknown>) : {}; const hasField = (key: string) => Object.prototype.hasOwnProperty.call(rawArgs, key); const hasSteps = hasField("steps"); const hasStepsPatch = hasField("steps_patch"); if (hasSteps && hasStepsPatch) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "INVALID_INPUT", message: "Provide either steps or steps_patch, not both.", }, }), }, ], }; } // Resolve project ID const requestContext = getRequestContext(); const envConfig = requestContext ? null : getConfig(); const resolvedProjectId = project_id ?? requestContext?.defaultProjectId ?? envConfig?.defaultProjectId; if (!resolvedProjectId) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "MISSING_PROJECT_ID", message: "project_id is required. Either provide it in the request or set TC_DEFAULT_PROJECT.", }, }), }, ], }; } try { const client = getApiClient(); const suiteInput = suite; const suiteNumericId = toNumberId(suiteInput); const suiteTitle = normalizeString(suiteInput); const suiteNeedsLookup = hasField("suite") && suiteInput !== null && suiteNumericId === undefined && suiteTitle !== undefined; const tagsNeedLookup = hasField("tags") && Array.isArray(tags) && tags.some(isNonNumericString); const requirementsNeedLookup = hasField("requirements") && Array.isArray(requirements) && requirements.some(isNonNumericString); const customFieldsNeedLookup = hasField("custom_fields") && custom_fields !== null && custom_fields?.some((cf) => cf.id === undefined || isNonNumericString(cf.id)); const needsCompanyId = tagsNeedLookup || requirementsNeedLookup || customFieldsNeedLookup; const [suitesList, projectForCompany] = await Promise.all([ suiteNeedsLookup ? client.listSuites(resolvedProjectId) : Promise.resolve(null), needsCompanyId ? client.getProject(resolvedProjectId) : Promise.resolve(null), ]); const companyId = projectForCompany ? getCompanyIdFromProject(projectForCompany) : undefined; const [tagsList, requirementsList, customFieldsList] = await Promise.all([ tagsNeedLookup ? client.listTags(resolvedProjectId) : Promise.resolve(null), requirementsNeedLookup ? client.listRequirements(resolvedProjectId) : Promise.resolve(null), customFieldsNeedLookup ? client.listProjectCustomFields(resolvedProjectId, companyId) : Promise.resolve(null), ]); // The PUT /testcases/{id} endpoint expects a full TestCasePayload. // Fetch current test case and merge with incoming changes to avoid partial payload errors. const existingRaw = await client.getTestCaseRaw(id, resolvedProjectId, { parseRs: hasSteps || hasStepsPatch, }); const existing = unwrapApiEntity(existingRaw); if (!existing) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "INVALID_TEST_CASE", message: `Unable to load test case ${id} for update.`, }, }), }, ], }; } const existingSuiteValue = getField<unknown>(existing, "suite"); const existingSuiteId = typeof existingSuiteValue === "number" ? existingSuiteValue : extractId(existingSuiteValue); type ExistingStep = { step: string; expectedResult?: string; reusableStepId?: number | null; }; const stepsSource = getExistingStepsSource(existing); const existingSteps: ExistingStep[] | undefined = stepsSource?.map((s) => ({ step: getStepText(s) ?? "", expectedResult: getStepExpectedResult(s), reusableStepId: getStepReusableId(s) ?? null, })); const existingTags = getArrayField(existing, "tags") ?.map((t) => extractId(t)) .filter((id): id is number => typeof id === "number"); const existingRequirements = getArrayField(existing, "requirements") ?.map((r) => extractId(r)) .filter((id): id is number => typeof id === "number"); const existingAttachments = getArrayField(existing, "attachments") ?.map((a) => { const attachmentId = extractId(a); return attachmentId !== undefined ? String(attachmentId) : undefined; }) .filter((id): id is string => typeof id === "string"); const existingCustomFields = getArrayField(existing, "customFields", [ "custom_fields", ]) ?.map((cf) => { const cfId = extractId(cf); const name = normalizeString(getField<string>(cf, "name")) ?? ""; if (cfId === undefined || name.length === 0) { return undefined; } return { id: cfId, name, label: getField<string>(cf, "label"), value: (getField<string | number | null>(cf, "value") ?? null), valueLabel: getField<string>(cf, "valueLabel") ?? getField<string>(cf, "value_label"), color: getField<string>(cf, "color"), }; }) .filter( ( cf ): cf is { id: number; name: string; label: string | undefined; value: string | number | null; valueLabel: string | undefined; color: string | undefined; } => cf !== undefined ); const existingTitle = getField<string>(existing, "title"); const existingDescription = getField<string | null>( existing, "description" ); const existingPriority = toNumberId(getField<unknown>(existing, "priority")); const resolvedTitle = hasField("title") && title !== undefined ? title : existingTitle; const resolvedDescription = hasField("description") ? description : existingDescription; const resolvedPriority = hasField("priority") && priority !== undefined ? priority : existingPriority; let resolvedSuiteId: number | null | undefined = hasField("suite") ? suiteInput === null ? null : suiteNumericId : existingSuiteId; if (suiteNeedsLookup && suitesList && suiteInput !== null) { const normalizedSuiteTitle = suiteTitle?.toLowerCase(); const match = suitesList.find((suiteItem) => { const title = normalizeString(getField<string>(suiteItem, "title")); return ( title !== undefined && normalizedSuiteTitle !== undefined && title.toLowerCase() === normalizedSuiteTitle ); }); resolvedSuiteId = toNumberId(match ? getField(match, "id") : undefined); if (resolvedSuiteId === undefined) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "SUITE_NOT_FOUND", message: `Suite not found with title "${suiteTitle}" in that project`, }, }), }, ], }; } } if ( hasField("suite") && suiteInput !== null && resolvedSuiteId === undefined ) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "INVALID_SUITE_ID", message: "suite must be a numeric ID or suite title", }, }), }, ], }; } let patchedStepsResult: | Array<{ step: string; expected_result?: string; reusable_step_id?: number | null; }> | null | undefined; if (hasStepsPatch) { if (!existingSteps || existingSteps.length === 0) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "MISSING_STEPS", message: "Cannot patch steps because the test case has no steps.", }, }), }, ], }; } const outOfRange = (steps_patch ?? []).find((patch) => { const index = patch.step_number - 1; return index < 0 || index >= existingSteps.length; }); if (outOfRange) { return { content: [ { type: "text", text: JSON.stringify({ error: { code: "INVALID_STEP_NUMBER", message: `Step number ${outOfRange.step_number} is out of range for test case ${id}.`, }, }), }, ], }; } const patchedSteps = existingSteps.map((s) => ({ step: s.step, expectedResult: s.expectedResult, reusableStepId: s.reusableStepId ?? null, })); (steps_patch ?? []).forEach((patch) => { const index = patch.step_number - 1; const target = patchedSteps[index]; if (!target) { return; } if (patch.step !== undefined) { target.step = patch.step; } if (patch.expected_result !== undefined) { target.expectedResult = patch.expected_result; } }); patchedStepsResult = patchedSteps.map((s) => ({ step: s.step, expected_result: s.expectedResult, reusable_step_id: s.reusableStepId ?? null, })); } const resolvedSteps = hasSteps ? steps === null ? null : steps?.map((s) => ({ step: s.step, expected_result: s.expected_result, })) : hasStepsPatch ? patchedStepsResult : existingSteps?.map((s) => ({ step: s.step, expected_result: s.expectedResult, reusable_step_id: s.reusableStepId ?? null, })); const resolvedTags = hasField("tags") ? tags === null ? null : tags ?.map((tag) => { const numericId = toNumberId(tag); if (numericId !== undefined) { return numericId; } if (!tagsList || typeof tag !== "string") { return undefined; } const match = tagsList.find( (t) => getField<string>(t, "name") === tag ); return toNumberId(match ? getField(match, "id") : undefined); }) .filter((id): id is number => typeof id === "number") : existingTags; const resolvedRequirements = hasField("requirements") ? requirements === null ? null : requirements ?.map((req) => { const numericId = toNumberId(req); if (numericId !== undefined) { return numericId; } if (!requirementsList || typeof req !== "string") { return undefined; } const match = requirementsList.find((r) => { const key = getField<string>(r, "requirement_key"); const reqId = getField<string>(r, "requirement_id"); const title = getField<string>(r, "title"); return key === req || reqId === req || title === req; }); return toNumberId(match ? getField(match, "id") : undefined); }) .filter((id): id is number => typeof id === "number") : existingRequirements; const customFieldMap = customFieldsList ? customFieldsList.reduce((map, cf) => { const name = getField<string>(cf, "name"); const id = toNumberId(getField(cf, "id")); if (!name || id === undefined) { return map; } const fieldType = getField<string>(cf, "field_type") ?? getField<string>(cf, "type"); const options = getCustomFieldOptions(cf); map.set(name, { id, name, label: getField<string>(cf, "label"), fieldType, options, }); return map; }, new Map<string, { id: number; name: string; label?: string; fieldType?: string; options?: unknown[] | null }>()) : null; const resolvedCustomFields = hasField("custom_fields") ? custom_fields === null ? null : custom_fields ?.map((cf) => { const numericId = toNumberId(cf.id); if (numericId !== undefined) { return { id: numericId, name: cf.name, value: cf.value, ...(cf.label !== undefined ? { label: cf.label } : {}), ...(cf.valueLabel !== undefined ? { valueLabel: cf.valueLabel } : {}), ...(cf.color !== undefined ? { color: cf.color } : {}), }; } if (!customFieldMap) { return undefined; } const match = customFieldMap.get(cf.name); if (!match) { return undefined; } const { value: resolvedValue, valueLabel: resolvedValueLabel } = resolveDropdownValue( match.fieldType, match.options, cf.value, cf.valueLabel ); return { id: match.id, name: match.name, value: resolvedValue, ...(cf.label !== undefined ? { label: cf.label } : {}), ...(resolvedValueLabel !== undefined ? { valueLabel: resolvedValueLabel } : {}), ...(cf.color !== undefined ? { color: cf.color } : {}), ...(cf.label === undefined && match.label !== undefined ? { label: match.label } : {}), }; }) .filter( ( cf ): cf is { id: number; name: string; label?: string; value: string | number | null; valueLabel?: string; color?: string; } => cf !== undefined ) : existingCustomFields; const resolvedAttachments = hasField("attachments") ? attachments : existingAttachments; const payload = { title: resolvedTitle, description: resolvedDescription ?? null, priority: resolvedPriority, suiteId: resolvedSuiteId ?? null, steps: resolvedSteps === undefined ? [] : resolvedSteps, tags: resolvedTags === undefined ? [] : resolvedTags, requirements: resolvedRequirements === undefined ? [] : resolvedRequirements, customFields: resolvedCustomFields === undefined ? [] : resolvedCustomFields, attachments: resolvedAttachments === undefined ? [] : resolvedAttachments, }; const result = await client.updateTestCase(id, resolvedProjectId, payload); // Priority labels const priorityLabels: Record<number, string> = { 0: "Low", 1: "Normal", 2: "High", }; return { content: [ { type: "text", text: JSON.stringify( { success: true, message: `Test case ${id} updated successfully`, testCase: { id: result.id, title: result.title, project: result.project, suite: result.suite, priority: result.priority, priorityLabel: priorityLabels[result.priority] ?? "Unknown", }, }, null, 2 ), }, - The updateTestCaseSchema defines the validation schema for the input arguments of the update_test_case tool using zod.
export const updateTestCaseSchema = z.object({ id: z.number().describe("Test case ID to update (required)"), project_id: z .number() .optional() .describe("Project ID (uses default if not specified)"), title: z.string().min(1).optional().describe("New test case title"), suite: z .union([z.number(), z.string()]) .nullable() .optional() .describe("Move to a different suite by ID or title (null to remove)"), description: z .string() .nullable() .optional() .describe("New description (HTML supported, null to clear)"), priority: z .number() .min(0) .max(2) .optional() .describe("New priority: 0=Low, 1=Normal, 2=High"), steps: z .array(stepSchema) .nullable() .optional() .describe("Replace all steps with this array (null to clear)"), steps_patch: z .array(stepPatchSchema) .optional() .describe( "Patch existing steps by step number (1-based) without replacing the entire steps array" ), tags: z .array(z.union([z.number(), z.string()])) .nullable() .optional() .describe("Replace tags with these IDs or names (null to clear)"), requirements: z .array(z.union([z.number(), z.string()])) .nullable() .optional() .describe("Replace requirements with these IDs or names (null to clear)"), custom_fields: z .array(customFieldSchema) .nullable() .optional() .describe("Update custom field values (null to clear)"), attachments: z .array(z.string()) .nullable() .optional() .describe("Replace attachments with these file IDs (null to clear)"), }); export type UpdateTestCaseInput = z.infer<typeof updateTestCaseSchema>; - src/tools/test-cases/update.ts:116-119 (registration)The updateTestCaseTool object contains the tool definition, including the name "update_test_case", description, and input schema.
export const updateTestCaseTool = { name: "update_test_case", description: `Update an existing test case in TestCollab.