diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index d8b3da09..5025578d 100644 --- a/src/tools/testmanagement-utils/create-testcase.ts +++ b/src/tools/testmanagement-utils/create-testcase.ts @@ -21,6 +21,14 @@ interface IssueTracker { host: string; } +// A custom field value may be a scalar or, for multi-select fields, an array +// of option values. The TM API accepts arrays only when keyed by field NAME. +export type CustomFieldValue = + | string + | number + | boolean + | Array; + export interface TestCaseCreateRequest { project_identifier: string; folder_id: string; @@ -32,9 +40,10 @@ export interface TestCaseCreateRequest { issues?: string[]; issue_tracker?: IssueTracker; tags?: string[]; - custom_fields?: Record; + custom_fields?: Record; automation_status?: string; priority?: string; + template?: string; } export interface TestCaseResponse { @@ -66,6 +75,14 @@ export interface TestCaseResponse { }; } +// Scalar value, or an array of values for multi-select custom fields. +export const customFieldValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number()])), +]); + export const CreateTestCaseSchema = z.object({ project_identifier: z .string() @@ -122,9 +139,11 @@ export const CreateTestCaseSchema = z.object({ "Tags to attach to the test case. This should be strictly in array format not the string of json", ), custom_fields: z - .record(z.string(), z.string()) + .record(z.string(), customFieldValueSchema) .optional() - .describe("Map of custom field names to values."), + .describe( + "Map of custom field NAME to value; use an array for multi-select fields.", + ), automation_status: z .string() .optional() @@ -137,6 +156,12 @@ export const CreateTestCaseSchema = z.object({ .describe( "Priority of the test case. Accepts either display name (e.g. 'Critical', 'High', 'Medium', 'Low') or internal name (e.g. 'medium'). If omitted, the project default (usually 'Medium') is applied. Valid values are per-project and discoverable via the form-fields endpoint.", ), + template: z + .string() + .optional() + .describe( + "Template internal slug, e.g. 'test_case_steps' or 'test_case_bdd'. Use the slug, not the display name.", + ), }); export function sanitizeArgs(args: any) { @@ -146,6 +171,7 @@ export function sanitizeArgs(args: any) { if (cleaned.owner === null) delete cleaned.owner; if (cleaned.preconditions === null) delete cleaned.preconditions; if (cleaned.automation_status === null) delete cleaned.automation_status; + if (cleaned.template === null) delete cleaned.template; if (cleaned.issue_tracker) { if ( @@ -245,22 +271,33 @@ export async function createTestCase( config, ); - return { - content: [ - { - type: "text", - text: `Test case successfully created: + const content: Array<{ type: "text"; text: string }> = []; + + // The TM API silently ignores an unrecognized template slug and falls back + // to the default. Surface that instead of letting it pass as success. + if ( + params.template && + tc.template && + String(tc.template).toLowerCase() !== + String(params.template).toLowerCase() + ) { + content.push({ + type: "text", + text: `Warning: requested template "${params.template}" was not applied — the test case was created with "${tc.template}". BrowserStack expects the template's internal slug (e.g. "test_case_steps", "test_case_bdd") and silently uses the default for unrecognized values.`, + }); + } + + content.push({ + type: "text", + text: `Test case successfully created: - Identifier: ${tc.identifier} - Title: ${tc.title} You can view it here: ${tmBaseUrl}/projects/${projectId}/folder/search?q=${tc.identifier}`, - }, - { - type: "text", - text: JSON.stringify(tc, null, 2), - }, - ], - }; + }); + content.push({ type: "text", text: JSON.stringify(tc, null, 2) }); + + return { content }; } catch (err) { // Delegate to our centralized Axios error formatter return formatAxiosError(err, "Failed to create test case"); diff --git a/src/tools/testmanagement-utils/update-testcase.ts b/src/tools/testmanagement-utils/update-testcase.ts index 13fbea70..98202b77 100644 --- a/src/tools/testmanagement-utils/update-testcase.ts +++ b/src/tools/testmanagement-utils/update-testcase.ts @@ -29,7 +29,10 @@ export interface TestCaseUpdateRequest { status?: string; tags?: string[]; issues?: string[]; - custom_fields?: Record; + custom_fields?: Record< + string, + string | number | boolean | Array + >; } export const UpdateTestCaseSchema = z.object({ @@ -101,10 +104,18 @@ export const UpdateTestCaseSchema = z.object({ "Replacement list of linked Jira/Asana/Azure issue IDs for the test case.", ), custom_fields: z - .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .record( + z.string(), + z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number()])), + ]), + ) .optional() .describe( - "Map of custom field name/id to value. Valid field names and value types are per-project; discover them via the project's form fields.", + "Map of custom field NAME to value; use an array for multi-select fields.", ), }); diff --git a/tests/tools/testmanagement.test.ts b/tests/tools/testmanagement.test.ts index e383de97..dcd6f059 100644 --- a/tests/tools/testmanagement.test.ts +++ b/tests/tools/testmanagement.test.ts @@ -190,7 +190,7 @@ vi.mock('../../src/tools/testmanagement-utils/get-sub-testplan', () => ({ // utils at the module level, so apiClient and tm-base-url never get reached // through real code paths today — adding these mocks is safe. vi.mock('../../src/lib/apiClient', () => ({ - apiClient: { get: vi.fn(), post: vi.fn() }, + apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }, })); vi.mock('../../src/lib/tm-base-url', () => ({ getTMBaseURL: vi.fn(async () => 'https://test-management.browserstack.com'), @@ -1163,3 +1163,128 @@ describe('createTestCase — priority normalization', () => { expect(body.test_case.priority).toBe('critical'); }); }); + +// PMAA-131: template slug pass-through + multi-select custom fields. +// Behaviour verified against the live TM v2 API: the create endpoint keys on +// the template's internal slug and silently falls back to the default for +// unrecognized values; multi-select custom fields accept arrays keyed by name. +describe('createTestCase — template & multi-select custom_fields', () => { + let createTestCaseReal: typeof import('../../src/tools/testmanagement-utils/create-testcase').createTestCase; + let apiClientMock: typeof import('../../src/lib/apiClient').apiClient; + + beforeAll(async () => { + const actual = await vi.importActual< + typeof import('../../src/tools/testmanagement-utils/create-testcase') + >('../../src/tools/testmanagement-utils/create-testcase'); + createTestCaseReal = actual.createTestCase; + apiClientMock = (await import('../../src/lib/apiClient')).apiClient; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const baseArgs = { + project_identifier: 'PR-1', + folder_id: 'F-1', + name: 'Sample', + test_case_steps: [{ step: 'a', result: 'b' }], + }; + + const resp = (template: string) => ({ + data: { + data: { + success: true, + test_case: { identifier: 'TC-1', title: 'Sample', folder_id: 1, template }, + }, + }, + }); + + it('passes the template slug through to the request body and does not warn when applied', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_bdd')); + + const result = await createTestCaseReal( + { ...baseArgs, template: 'test_case_bdd' }, + mockConfig as any, + ); + + const body = (apiClientMock.post as Mock).mock.calls[0][0].body; + expect(body.test_case.template).toBe('test_case_bdd'); + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).not.toContain('was not applied'); + }); + + it('warns when the API silently falls back to a different template', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_steps')); + + const result = await createTestCaseReal( + { ...baseArgs, template: 'test_case_sec' }, + mockConfig as any, + ); + + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).toContain('was not applied'); + expect(text).toContain('test_case_sec'); + }); + + it('passes array (multi-select) custom_fields through to the request body', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_steps')); + + await createTestCaseReal( + { ...baseArgs, custom_fields: { aaas: ['m40', 'm48'] } }, + mockConfig as any, + ); + + const body = (apiClientMock.post as Mock).mock.calls[0][0].body; + expect(body.test_case.custom_fields).toEqual({ aaas: ['m40', 'm48'] }); + }); +}); + +// PMAA-131: multi-select custom fields on the update (PATCH) path. +describe('updateTestCase — multi-select custom_fields', () => { + let updateTestCaseReal: typeof import('../../src/tools/testmanagement-utils/update-testcase').updateTestCase; + let apiClientMock: typeof import('../../src/lib/apiClient').apiClient; + + beforeAll(async () => { + const actual = await vi.importActual< + typeof import('../../src/tools/testmanagement-utils/update-testcase') + >('../../src/tools/testmanagement-utils/update-testcase'); + updateTestCaseReal = actual.updateTestCase; + apiClientMock = (await import('../../src/lib/apiClient')).apiClient; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes array (multi-select) custom_fields through to the PATCH body', async () => { + (apiClientMock.patch as Mock).mockResolvedValueOnce({ + data: { + data: { + success: true, + test_case: { + identifier: 'TC-1', + title: 'Sample', + case_type: 'functional', + priority: 'Medium', + status: 'active', + folder_id: 1, + }, + }, + }, + }); + + const result = await updateTestCaseReal( + { + project_identifier: 'PR-1', + test_case_identifier: 'TC-1', + custom_fields: { aaas: ['m40', 'm48'] }, + }, + mockConfig as any, + ); + + expect(result.isError).toBeFalsy(); + const body = (apiClientMock.patch as Mock).mock.calls[0][0].body; + expect(body.test_case.custom_fields).toEqual({ aaas: ['m40', 'm48'] }); + }); +});