diff --git a/backend/__tests__/vendor-issue-type-dropdown.property.test.js b/backend/__tests__/vendor-issue-type-dropdown.property.test.js new file mode 100644 index 0000000..b60e5a9 --- /dev/null +++ b/backend/__tests__/vendor-issue-type-dropdown.property.test.js @@ -0,0 +1,211 @@ +/** + * Property-Based Tests: Vendor Issue Type Dropdown + * + * Feature: vendor-issue-type-dropdown + * + * Tests the pure determination logic that decides which issue type list + * to display based on the project key input. + * + * Validates: Requirements 1.2, 1.3, 1.4, 1.5, 2.1, 2.3, 3.4, 3.5, 4.1, 4.2, 6.3 + */ + +const fc = require('fast-check'); + +// --------------------------------------------------------------------------- +// Replicate the pure functions from JiraPage.js for testing +// --------------------------------------------------------------------------- + +const VENDOR_PROJECT_KEYS = ['AA_VECIMA']; + +const VENDOR_ISSUE_TYPES = [ + 'Epic', + 'Story', + 'Task', + 'Defect', + 'Production Defect/Incident Fix', + 'New Feature', + 'Spike', + 'Release Candidate', + 'Documentation', +]; + +const STEAM_ISSUE_TYPES = [ + 'Story', + 'Epic', + 'Program', + 'Project', + 'Reservation', + 'Automation Maintenance', +]; + +function isVendorProject(projectKey, vendorKeys) { + if (!projectKey || typeof projectKey !== 'string') return false; + const normalized = projectKey.trim().toUpperCase(); + if (normalized.length === 0) return false; + return vendorKeys.includes(normalized); +} + +function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) { + return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes; +} + +/** + * Simulates the project_key onChange logic: resets issue_type only on context switch. + */ +function simulateProjectKeyChange(oldKey, newKey, currentIssueType, vendorKeys) { + const wasVendor = isVendorProject(oldKey, vendorKeys); + const isNowVendor = isVendorProject(newKey, vendorKeys); + return (wasVendor !== isNowVendor) ? '' : currentIssueType; +} + +// --------------------------------------------------------------------------- +// Property 1: Issue type list determination +// --------------------------------------------------------------------------- +describe('Feature: vendor-issue-type-dropdown, Property 1: Issue type list determination', () => { + it('returns VENDOR_ISSUE_TYPES when project key matches a vendor key (case-insensitive, trimmed)', () => { + // Generate variations of the vendor key with different casing and whitespace + const vendorKeyVariants = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + fc.constant('Aa_Vecima'), + fc.constant(' AA_VECIMA '), + fc.constant('aa_VECIMA'), + ); + + fc.assert( + fc.property(vendorKeyVariants, (key) => { + const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES); + expect(result).toBe(VENDOR_ISSUE_TYPES); + }), + { numRuns: 100 } + ); + }); + + it('returns STEAM_ISSUE_TYPES for any string that does not match a vendor key after normalization', () => { + // Generate arbitrary strings that are NOT 'AA_VECIMA' after trim+uppercase + const nonVendorKey = fc.string({ minLength: 0, maxLength: 50 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA'; + }); + + fc.assert( + fc.property(nonVendorKey, (key) => { + const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES); + expect(result).toBe(STEAM_ISSUE_TYPES); + }), + { numRuns: 100 } + ); + }); + + it('returns STEAM_ISSUE_TYPES for null, undefined, and empty string', () => { + const emptyInputs = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + ); + + fc.assert( + fc.property(emptyInputs, (key) => { + const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES); + expect(result).toBe(STEAM_ISSUE_TYPES); + }), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 2: Context switch resets issue type selection +// --------------------------------------------------------------------------- +describe('Feature: vendor-issue-type-dropdown, Property 2: Context switch resets issue type', () => { + it('resets issue_type to empty when switching from vendor to non-vendor context', () => { + // Generate a vendor key variant and a non-vendor key + const vendorKey = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + fc.constant(' AA_VECIMA '), + ); + const nonVendorKey = fc.string({ minLength: 1, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA' && normalized.length > 0; + }); + const anyIssueType = fc.string({ minLength: 1, maxLength: 50 }); + + fc.assert( + fc.property(vendorKey, nonVendorKey, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(''); + }), + { numRuns: 100 } + ); + }); + + it('resets issue_type to empty when switching from non-vendor to vendor context', () => { + const nonVendorKey = fc.string({ minLength: 1, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA' && normalized.length > 0; + }); + const vendorKey = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + ); + const anyIssueType = fc.string({ minLength: 1, maxLength: 50 }); + + fc.assert( + fc.property(nonVendorKey, vendorKey, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(''); + }), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 3: Same context preserves issue type selection +// --------------------------------------------------------------------------- +describe('Feature: vendor-issue-type-dropdown, Property 3: Same context preserves issue type', () => { + it('preserves issue_type when both old and new keys resolve to STEAM context', () => { + const nonVendorKey1 = fc.string({ minLength: 0, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA'; + }); + const nonVendorKey2 = fc.string({ minLength: 0, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA'; + }); + const anyIssueType = fc.string({ minLength: 0, maxLength: 50 }); + + fc.assert( + fc.property(nonVendorKey1, nonVendorKey2, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(issueType); + }), + { numRuns: 100 } + ); + }); + + it('preserves issue_type when both old and new keys resolve to vendor context', () => { + // With only one vendor key, both must be variants of AA_VECIMA + const vendorKey1 = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + fc.constant(' AA_VECIMA '), + ); + const vendorKey2 = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('Aa_Vecima'), + fc.constant('aa_VECIMA'), + ); + const anyIssueType = fc.string({ minLength: 0, maxLength: 50 }); + + fc.assert( + fc.property(vendorKey1, vendorKey2, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(issueType); + }), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/components/pages/IvantiTodoQueuePage.js b/frontend/src/components/pages/IvantiTodoQueuePage.js index 9ca58ff..fc20c03 100644 --- a/frontend/src/components/pages/IvantiTodoQueuePage.js +++ b/frontend/src/components/pages/IvantiTodoQueuePage.js @@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext'; import ConsolidationModal from '../ConsolidationModal'; import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation'; import { groupQueueItems } from '../../utils/queueGrouping'; +import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -300,7 +301,7 @@ export default function IvantiTodoQueuePage() { // Single-item Jira creation modal state (Requirement 2.4) const [showSingleJiraModal, setShowSingleJiraModal] = useState(false); const [singleJiraItem, setSingleJiraItem] = useState(null); - const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' }); + const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' }); const [singleJiraError, setSingleJiraError] = useState(null); const [singleJiraSaving, setSingleJiraSaving] = useState(false); const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null); @@ -480,6 +481,8 @@ export default function IvantiTodoQueuePage() { summary: generateConsolidatedSummary(items), description: generateConsolidatedDescription(items), source_context: 'ivanti_queue', + project_key: '', + issue_type: '', }); setSingleJiraError(null); setSingleJiraSummaryError(null); @@ -966,6 +969,30 @@ export default function IvantiTodoQueuePage() { onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))} /> +