Skip to content

Commit 3577e1e

Browse files
feat(testmanagement): apply custom templates via the v1 create endpoint
The public v2 create endpoint silently drops template_id, so a custom template could never be applied through it. The v1 create endpoint DOES honour template_id — verified end-to-end against prod. When template_id is set, createTestCase now routes to POST /api/v1/projects/{numericId}/test-cases (API-TOKEN auth, folder in the body) instead of v2. custom_fields are translated from the MCP's by-name shape to v1's by-id shape (field name -> id, option value -> option id) via form fields, so multi-select custom fields keep working alongside a template. When no template_id is given, the proven v2 path is unchanged (zero regression). Verified live (project PR-141254, throwaway folder, cleaned up): a create with template_id=656127 + custom_fields {aaas:[m40,m48]} + priority + tags + steps produced template_id=656127 and both custom-field values, isError:false. Tests: +2 (v1 routing shape/auth, custom_fields name->id translation). 198 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2bbab50 commit 3577e1e

2 files changed

Lines changed: 163 additions & 13 deletions

File tree

src/tools/testmanagement-utils/create-testcase.ts

Lines changed: 106 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export const CreateTestCaseSchema = z.object({
168168
.number()
169169
.optional()
170170
.describe(
171-
"Numeric ID of a custom template (find via listTestCaseTemplates). Takes precedence over 'template'.",
171+
"Numeric ID of a custom template (from listTestCaseTemplates); applies that template. Overrides 'template'.",
172172
),
173173
});
174174

@@ -258,6 +258,60 @@ async function fetchAppliedTemplateId(
258258
}
259259
}
260260

261+
/**
262+
* The v1 create endpoint (used when a template_id is requested) keys
263+
* custom_fields by numeric field id with option *ids* — unlike the v2 endpoint,
264+
* which keys by field name with option *values*. Translate the MCP's by-name
265+
* shape into v1's by-id shape using the project's form fields. Best-effort:
266+
* unknown fields/options pass through unchanged.
267+
*/
268+
async function toV1CustomFields(
269+
customFields: Record<string, CustomFieldValue>,
270+
numericProjectId: string,
271+
config: BrowserStackConfig,
272+
): Promise<Record<string, CustomFieldValue>> {
273+
let defs: any[] = [];
274+
try {
275+
const formFields = await fetchFormFields(numericProjectId, config);
276+
defs = Array.isArray(formFields?.custom_fields)
277+
? formFields.custom_fields
278+
: [];
279+
} catch {
280+
return customFields;
281+
}
282+
283+
const byName = new Map<string, any>(defs.map((f) => [f.field_name, f]));
284+
const out: Record<string, CustomFieldValue> = {};
285+
286+
for (const [name, value] of Object.entries(customFields)) {
287+
const def = byName.get(name);
288+
if (!def) {
289+
out[name] = value; // unknown field name — leave as-is
290+
continue;
291+
}
292+
const isOptionField =
293+
def.field_type === "field_dropdown" ||
294+
def.field_type === "field_multi_dropdown";
295+
if (isOptionField) {
296+
const optionIdByValue = new Map<string, string | number>();
297+
for (const o of (def.option_values ?? []) as Array<{
298+
option_value: string | number;
299+
id: string | number;
300+
}>) {
301+
optionIdByValue.set(String(o.option_value), o.id);
302+
}
303+
const toOptionId = (v: string | number): string | number =>
304+
optionIdByValue.get(String(v)) ?? v;
305+
out[String(def.id)] = Array.isArray(value)
306+
? value.map(toOptionId)
307+
: toOptionId(value as string | number);
308+
} else {
309+
out[String(def.id)] = value;
310+
}
311+
}
312+
return out;
313+
}
314+
261315
export async function createTestCase(
262316
params: TestCaseCreateRequest,
263317
config: BrowserStackConfig,
@@ -272,23 +326,62 @@ export async function createTestCase(
272326
);
273327
}
274328

275-
const body = { test_case: testCaseParams };
276329
const authString = getBrowserStackAuth(config);
277330
const [username, password] = authString.split(":");
278331

279332
try {
280333
const tmBaseUrl = await getTMBaseURL(config);
281-
const response = await apiClient.post({
282-
url: `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(
334+
335+
// The public v2 create endpoint silently drops template_id, so a specific
336+
// (custom) template cannot be applied through it. The v1 create endpoint
337+
// DOES honour template_id — but it needs the numeric project id, the folder
338+
// in the body, API-TOKEN auth, and custom_fields keyed by id. Use v1 only
339+
// when a template_id is requested; otherwise keep the proven v2 path so
340+
// existing behaviour (incl. custom_fields by name) is unchanged.
341+
let request: { url: string; headers: Record<string, string>; body: any };
342+
if (testCaseParams.template_id !== undefined) {
343+
const numericProjectId = await projectIdentifierToId(
283344
params.project_identifier,
284-
)}/folders/${encodeURIComponent(params.folder_id)}/test-cases`,
285-
headers: {
286-
"Content-Type": "application/json",
287-
Authorization:
288-
"Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
289-
},
290-
body,
291-
});
345+
config,
346+
);
347+
const v1TestCase: Record<string, any> = { ...testCaseParams };
348+
delete v1TestCase.project_identifier;
349+
delete v1TestCase.folder_id;
350+
delete v1TestCase.custom_fields;
351+
v1TestCase.test_case_folder_id = Number(params.folder_id);
352+
if (testCaseParams.custom_fields) {
353+
v1TestCase.custom_fields = await toV1CustomFields(
354+
testCaseParams.custom_fields,
355+
numericProjectId,
356+
config,
357+
);
358+
}
359+
request = {
360+
url: `${tmBaseUrl}/api/v1/projects/${encodeURIComponent(
361+
numericProjectId,
362+
)}/test-cases`,
363+
headers: {
364+
"Content-Type": "application/json",
365+
"API-TOKEN": authString,
366+
},
367+
body: { folder_id: Number(params.folder_id), test_case: v1TestCase },
368+
};
369+
} else {
370+
request = {
371+
url: `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(
372+
params.project_identifier,
373+
)}/folders/${encodeURIComponent(params.folder_id)}/test-cases`,
374+
headers: {
375+
"Content-Type": "application/json",
376+
Authorization:
377+
"Basic " +
378+
Buffer.from(`${username}:${password}`).toString("base64"),
379+
},
380+
body: { test_case: testCaseParams },
381+
};
382+
}
383+
384+
const response = await apiClient.post(request);
292385

293386
const { data } = response.data;
294387
if (!data.success) {
@@ -325,7 +418,7 @@ export async function createTestCase(
325418
if (appliedId !== undefined && appliedId !== Number(params.template_id)) {
326419
content.push({
327420
type: "text",
328-
text: `Warning: requested template_id ${params.template_id} was not applied — the test case uses template_id ${appliedId}. The public Test Management create API does not currently apply a chosen template; verify the id via listTestCaseTemplates and that it is linked to this project.`,
421+
text: `Warning: requested template_id ${params.template_id} was not applied — the test case uses template_id ${appliedId}. Confirm the id via listTestCaseTemplates and that the template is linked to this project.`,
329422
});
330423
}
331424
}

tests/tools/testmanagement.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,63 @@ describe('createTestCase — template & multi-select custom_fields', () => {
13141314
expect(text).toContain('template_id 656127 was not applied');
13151315
expect(text).toContain('template_id 2');
13161316
});
1317+
1318+
// template_id is honoured only by the v1 create endpoint (v2 strips it), so a
1319+
// template_id request must route to /api/v1/.../test-cases with API-TOKEN auth
1320+
// and the folder carried in the body.
1321+
it('routes to the v1 create endpoint with API-TOKEN auth when template_id is set', async () => {
1322+
(apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(656127));
1323+
1324+
await createTestCaseReal(
1325+
{ ...baseArgs, folder_id: '50', template_id: 656127 },
1326+
mockConfig as any,
1327+
);
1328+
1329+
const call = (apiClientMock.post as Mock).mock.calls[0][0];
1330+
expect(call.url).toContain('/api/v1/projects/');
1331+
expect(call.url).toContain('/test-cases');
1332+
expect(call.url).not.toContain('/folders/'); // folder goes in the body for v1
1333+
expect(call.headers['API-TOKEN']).toBe('fake-user:fake-key');
1334+
expect(call.headers.Authorization).toBeUndefined();
1335+
expect(call.body.folder_id).toBe(50);
1336+
expect(call.body.test_case.test_case_folder_id).toBe(50);
1337+
expect(call.body.test_case.template_id).toBe(656127);
1338+
// MCP-only params must not leak into the v1 test_case payload.
1339+
expect(call.body.test_case.project_identifier).toBeUndefined();
1340+
expect(call.body.test_case.folder_id).toBeUndefined();
1341+
});
1342+
1343+
it('translates custom_fields name→id and option value→id on the v1 path', async () => {
1344+
const api = await import('../../src/tools/testmanagement-utils/TCG-utils/api');
1345+
(api.fetchFormFields as Mock).mockResolvedValueOnce({
1346+
default_fields: {},
1347+
custom_fields: [
1348+
{
1349+
field_name: 'aaas',
1350+
id: 194184,
1351+
field_type: 'field_multi_dropdown',
1352+
option_values: [
1353+
{ option_value: 'm40', id: 111 },
1354+
{ option_value: 'm48', id: 222 },
1355+
],
1356+
},
1357+
],
1358+
});
1359+
(apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(656127));
1360+
1361+
await createTestCaseReal(
1362+
{
1363+
...baseArgs,
1364+
folder_id: '50',
1365+
template_id: 656127,
1366+
custom_fields: { aaas: ['m40', 'm48'] },
1367+
},
1368+
mockConfig as any,
1369+
);
1370+
1371+
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
1372+
expect(body.test_case.custom_fields).toEqual({ 194184: [111, 222] });
1373+
});
13171374
});
13181375

13191376
// PMAA-131: multi-select custom fields on the update (PATCH) path.

0 commit comments

Comments
 (0)