diff --git a/src/tools/testmanagement-utils/TCG-utils/api.ts b/src/tools/testmanagement-utils/TCG-utils/api.ts index 1c15d31d..c9fc19ee 100644 --- a/src/tools/testmanagement-utils/TCG-utils/api.ts +++ b/src/tools/testmanagement-utils/TCG-utils/api.ts @@ -33,6 +33,30 @@ export async function fetchFormFields( return res.data; } +/** + * Resolve a default-field input (priority/case_type) to the form's display or + * internal name, matching case-insensitively. Returns undefined if no match. + */ +export function normalizeDefaultFieldValue( + fieldValues: Array<{ + internal_name?: string | null; + name?: string; + value: any; + }>, + input: string, + emit: "name" | "internal_name", +): string | undefined { + const normalized = input.toLowerCase().trim(); + const match = fieldValues.find( + (v) => + (v.internal_name ?? "").toLowerCase() === normalized || + (v.name ?? "").toLowerCase() === normalized, + ); + if (!match) return undefined; + if (emit === "name") return match.name; + return match.internal_name ?? match.name; +} + /** * Trigger AI-based test case generation for a document. */ diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index 2cbeb3e5..d8b3da09 100644 --- a/src/tools/testmanagement-utils/create-testcase.ts +++ b/src/tools/testmanagement-utils/create-testcase.ts @@ -2,9 +2,14 @@ import { apiClient } from "../../lib/apiClient.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { formatAxiosError } from "../../lib/error.js"; -import { projectIdentifierToId } from "./TCG-utils/api.js"; +import { + fetchFormFields, + normalizeDefaultFieldValue, + projectIdentifierToId, +} from "./TCG-utils/api.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { getTMBaseURL } from "../../lib/tm-base-url.js"; +import logger from "../../logger.js"; interface TestCaseStep { step: string; @@ -29,6 +34,7 @@ export interface TestCaseCreateRequest { tags?: string[]; custom_fields?: Record; automation_status?: string; + priority?: string; } export interface TestCaseResponse { @@ -125,6 +131,12 @@ export const CreateTestCaseSchema = z.object({ .describe( "Automation status of the test case. Common values include 'not_automated', 'automated', 'automation_not_required'.", ), + priority: z + .string() + .optional() + .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.", + ), }); export function sanitizeArgs(args: any) { @@ -149,11 +161,52 @@ export function sanitizeArgs(args: any) { import { getBrowserStackAuth } from "../../lib/get-auth.js"; +/** + * Normalize priority to the display name the create endpoint accepts (it + * rejects lowercase). On lookup failure, pass the raw value through. + */ +async function normalizePriority( + projectIdentifier: string, + priority: string, + config: BrowserStackConfig, +): Promise { + try { + const numericProjectId = await projectIdentifierToId( + projectIdentifier, + config, + ); + const { default_fields } = await fetchFormFields(numericProjectId, config); + return ( + normalizeDefaultFieldValue( + default_fields?.priority?.values ?? [], + priority, + "name", + ) ?? priority + ); + } catch (err) { + logger.warn( + "Failed to normalize priority value; passing through as given: %s", + err instanceof Error ? err.message : String(err), + ); + return priority; + } +} + export async function createTestCase( params: TestCaseCreateRequest, config: BrowserStackConfig, ): Promise { - const body = { test_case: params }; + const testCaseParams: TestCaseCreateRequest = { ...params }; + + if (testCaseParams.priority !== undefined) { + testCaseParams.priority = await normalizePriority( + params.project_identifier, + testCaseParams.priority, + config, + ); + } + + const body = { test_case: testCaseParams }; const authString = getBrowserStackAuth(config); const [username, password] = authString.split(":"); diff --git a/src/tools/testmanagement-utils/update-testcase.ts b/src/tools/testmanagement-utils/update-testcase.ts index 39c86c2a..13fbea70 100644 --- a/src/tools/testmanagement-utils/update-testcase.ts +++ b/src/tools/testmanagement-utils/update-testcase.ts @@ -2,7 +2,11 @@ import { apiClient } from "../../lib/apiClient.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { formatAxiosError } from "../../lib/error.js"; -import { fetchFormFields, projectIdentifierToId } from "./TCG-utils/api.js"; +import { + fetchFormFields, + normalizeDefaultFieldValue, + projectIdentifierToId, +} from "./TCG-utils/api.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { getTMBaseURL } from "../../lib/tm-base-url.js"; import { getBrowserStackAuth } from "../../lib/get-auth.js"; @@ -104,36 +108,6 @@ export const UpdateTestCaseSchema = z.object({ ), }); -/** - * Build a normalizer for a default field's accepted value. - * The TM PATCH endpoint accepts different casings for different default - * fields (Title-Case display name for priority/case_type, snake_case - * internal_name for automation_status). We accept either from the caller - * and emit the form the API actually wants. - * - * Returns undefined when no matching option is found — callers should - * pass the raw value through so the backend can surface its own error. - */ -function normalizeDefaultFieldValue( - fieldValues: Array<{ - internal_name?: string | null; - name?: string; - value: any; - }>, - input: string, - emit: "name" | "internal_name", -): string | undefined { - const normalized = input.toLowerCase().trim(); - const match = fieldValues.find( - (v) => - (v.internal_name ?? "").toLowerCase() === normalized || - (v.name ?? "").toLowerCase() === normalized, - ); - if (!match) return undefined; - if (emit === "name") return match.name; - return match.internal_name ?? match.name; -} - /** * Normalise default-field inputs (priority/case_type/automation_status) to * what the TM PATCH endpoint accepts. Fetches the project's form-fields diff --git a/tests/tools/testmanagement.test.ts b/tests/tools/testmanagement.test.ts index 1f8726e7..e383de97 100644 --- a/tests/tools/testmanagement.test.ts +++ b/tests/tools/testmanagement.test.ts @@ -98,7 +98,9 @@ vi.mock('../../src/lib/get-auth', () => ({ getBrowserStackAuth: vi.fn(() => 'fake-user:fake-key') })); vi.mock('../../src/tools/testmanagement-utils/TCG-utils/api', () => ({ - projectIdentifierToId: vi.fn(() => Promise.resolve('999')) + projectIdentifierToId: vi.fn(() => Promise.resolve('999')), + fetchFormFields: vi.fn(), + normalizeDefaultFieldValue: vi.fn(), })); vi.mock('form-data', () => { return { @@ -193,6 +195,9 @@ vi.mock('../../src/lib/apiClient', () => ({ vi.mock('../../src/lib/tm-base-url', () => ({ getTMBaseURL: vi.fn(async () => 'https://test-management.browserstack.com'), })); +vi.mock('../../src/logger', () => ({ + default: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); const mockedAxios = axios as Mocked; @@ -1031,3 +1036,130 @@ describe('getSubTestPlan util — fail-soft enrichment', () => { expect(result.content?.[0]?.text).toContain('Failed to fetch sub-test-plan'); }); }); + +// Real function via importActual (the module is mocked at the top). +describe('normalizeDefaultFieldValue', () => { + let normalize: typeof import('../../src/tools/testmanagement-utils/TCG-utils/api').normalizeDefaultFieldValue; + + beforeAll(async () => { + const actual = await vi.importActual< + typeof import('../../src/tools/testmanagement-utils/TCG-utils/api') + >('../../src/tools/testmanagement-utils/TCG-utils/api'); + normalize = actual.normalizeDefaultFieldValue; + }); + + const values = [ + { name: 'Critical', internal_name: 'critical', value: 1 }, + { name: 'Medium', internal_name: 'medium', value: 2 }, + ]; + + it('resolves a lowercase internal name to the Title-Case display name', () => { + expect(normalize(values, 'critical', 'name')).toBe('Critical'); + }); + + it('matches case-insensitively regardless of input casing', () => { + expect(normalize(values, 'CRITICAL', 'name')).toBe('Critical'); + expect(normalize(values, ' Critical ', 'name')).toBe('Critical'); + }); + + it('resolves a display name to the internal name when emit is internal_name', () => { + expect(normalize(values, 'Critical', 'internal_name')).toBe('critical'); + }); + + it('returns undefined when no option matches (caller passes raw value through)', () => { + expect(normalize(values, 'urgent', 'name')).toBeUndefined(); + expect(normalize([], 'critical', 'name')).toBeUndefined(); + }); +}); + +// Real createTestCase via importActual; collaborators are mocked at module scope. +describe('createTestCase — priority normalization', () => { + let createTestCaseReal: typeof import('../../src/tools/testmanagement-utils/create-testcase').createTestCase; + let apiClientMock: typeof import('../../src/lib/apiClient').apiClient; + let fetchFormFieldsMock: Mock; + let normalizeMock: Mock; + + 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; + const api = await import('../../src/tools/testmanagement-utils/TCG-utils/api'); + fetchFormFieldsMock = api.fetchFormFields as unknown as Mock; + normalizeMock = api.normalizeDefaultFieldValue as unknown as Mock; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Priority options as returned by fetchFormFields. + const priorityValues = [ + { name: 'Critical', internal_name: 'critical', value: 1 }, + { name: 'Medium', internal_name: 'medium', value: 2 }, + ]; + const formFields = { + default_fields: { priority: { values: priorityValues } }, + custom_fields: {}, + }; + const createSuccess = { + data: { + data: { + success: true, + test_case: { + identifier: 'TC-1', + title: 'Sample', + priority: 'Critical', + folder_id: 1, + }, + }, + }, + }; + + const baseArgs = { + project_identifier: 'PR-1', + folder_id: 'F-1', + name: 'Sample', + test_case_steps: [{ step: 'a', result: 'b' }], + }; + + it('looks up the project form fields and sends the normalized priority in the request body', async () => { + fetchFormFieldsMock.mockResolvedValue(formFields); + normalizeMock.mockReturnValue('Critical'); + (apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess); + + const result = await createTestCaseReal( + { ...baseArgs, priority: 'critical' }, + mockConfig as any, + ); + + expect(result.isError).toBeFalsy(); + expect(normalizeMock).toHaveBeenCalledWith(priorityValues, 'critical', 'name'); + const body = (apiClientMock.post as Mock).mock.calls[0][0].body; + expect(body.test_case.priority).toBe('Critical'); + }); + + it('omits priority from the request body when not provided (preserves project default)', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess); + + await createTestCaseReal({ ...baseArgs }, mockConfig as any); + + expect(fetchFormFieldsMock).not.toHaveBeenCalled(); + const body = (apiClientMock.post as Mock).mock.calls[0][0].body; + expect(body.test_case).not.toHaveProperty('priority'); + }); + + it('passes the raw priority through when the form-fields lookup fails (graceful fallback)', async () => { + fetchFormFieldsMock.mockRejectedValue(new Error('Network Error')); + (apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess); + + await createTestCaseReal( + { ...baseArgs, priority: 'critical' }, + mockConfig as any, + ); + + const body = (apiClientMock.post as Mock).mock.calls[0][0].body; + expect(body.test_case.priority).toBe('critical'); + }); +});