/** * Property-Based Test: Jira API Preservation — Unchanged Functions Baseline * * Feature: jira-api-compliance, Property 4: Preservation * * Verifies that all unchanged Jira API functions continue to produce the * correct HTTP method, URL path, and request body. These tests MUST PASS * on the current unfixed code — they establish the baseline behavior that * the bugfix must preserve. * * Functions under test: * 1. createIssue() — POST /rest/api/2/issue with { fields } * 2. updateIssue() — PUT /rest/api/2/issue/{key} with { fields } * 3. addComment() — POST /rest/api/2/issue/{key}/comment with { body } * 4. transitionIssue() — POST /rest/api/2/issue/{key}/transitions with { transition: { id } } * 5. getTransitions() — GET /rest/api/2/issue/{key}/transitions * 6. testConnection() — GET /rest/api/2/myself * * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6** */ const fc = require('fast-check'); // --------------------------------------------------------------------------- // Capture array for intercepted jiraRequest calls. // --------------------------------------------------------------------------- let mockCapturedCalls = []; // --------------------------------------------------------------------------- // Mock jiraRequest at the module level to capture HTTP method, path, and body. // Re-implement only the unchanged functions with their original logic wired // to the mock transport. // --------------------------------------------------------------------------- jest.mock('../helpers/jiraApi', () => { const originalModule = jest.requireActual('../helpers/jiraApi'); // Mock transport that records every call and returns appropriate responses const mockJiraRequest = jest.fn(async (method, urlPath, body, options) => { mockCapturedCalls.push({ method, urlPath, body }); // Return appropriate status codes based on method and path if (method === 'POST' && urlPath === '/rest/api/2/issue') { return { status: 201, body: JSON.stringify({ id: '10001', key: 'TEST-1', self: 'https://jira.example.com/rest/api/2/issue/10001' }) }; } if (method === 'PUT' && urlPath.startsWith('/rest/api/2/issue/')) { return { status: 204, body: '' }; } if (method === 'POST' && urlPath.endsWith('/comment')) { return { status: 201, body: JSON.stringify({ id: '20001', body: 'mock comment', author: { name: 'testuser' } }) }; } if (method === 'POST' && urlPath.endsWith('/transitions')) { return { status: 204, body: '' }; } if (method === 'GET' && urlPath.endsWith('/transitions')) { return { status: 200, body: JSON.stringify({ transitions: [ { id: '1', name: 'Open' }, { id: '2', name: 'In Progress' }, { id: '3', name: 'Done' } ] }) }; } if (method === 'GET' && urlPath === '/rest/api/2/myself') { return { status: 200, body: JSON.stringify({ name: 'testuser', displayName: 'Test User', emailAddress: 'test@example.com' }) }; } // Default 200 response return { status: 200, body: JSON.stringify({}) }; }); const mockJiraGet = (urlPath, options) => mockJiraRequest('GET', urlPath, null, options); const mockJiraPost = (urlPath, body, options) => mockJiraRequest('POST', urlPath, body, options); const mockJiraPut = (urlPath, body, options) => mockJiraRequest('PUT', urlPath, body, options); // Re-implement createIssue with the SAME logic as the original source async function createIssue(fields) { const res = await mockJiraPost('/rest/api/2/issue', { fields }); if (res.status === 201) { return { ok: true, data: JSON.parse(res.body) }; } return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited }; } // Re-implement updateIssue with the SAME logic as the original source async function updateIssue(issueKey, fields) { const res = await mockJiraPut( `/rest/api/2/issue/${encodeURIComponent(issueKey)}`, { fields } ); if (res.status === 204) { return { ok: true }; } return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited }; } // Re-implement addComment with the SAME logic as the original source async function addComment(issueKey, commentBody) { const res = await mockJiraPost( `/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment`, { body: commentBody } ); if (res.status === 201) { return { ok: true, data: JSON.parse(res.body) }; } return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited }; } // Re-implement transitionIssue with the SAME logic as the original source async function transitionIssue(issueKey, transitionId) { const res = await mockJiraPost( `/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`, { transition: { id: transitionId } } ); if (res.status === 204) { return { ok: true }; } return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited }; } // Re-implement getTransitions with the SAME logic as the original source async function getTransitions(issueKey) { const res = await mockJiraGet( `/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions` ); if (res.status === 200) { return { ok: true, data: JSON.parse(res.body) }; } return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited }; } // Re-implement testConnection with the SAME logic as the original source async function testConnection() { try { const res = await mockJiraGet('/rest/api/2/myself'); if (res.status === 200) { const user = JSON.parse(res.body); return { ok: true, user: { name: user.name, displayName: user.displayName, emailAddress: user.emailAddress } }; } return { ok: false, status: res.status, body: res.body }; } catch (err) { return { ok: false, error: err.message }; } } return { ...originalModule, jiraRequest: mockJiraRequest, jiraGet: mockJiraGet, jiraPost: mockJiraPost, jiraPut: mockJiraPut, createIssue, updateIssue, addComment, transitionIssue, getTransitions, testConnection }; }); const jiraApi = require('../helpers/jiraApi'); // --------------------------------------------------------------------------- // Arbitraries // --------------------------------------------------------------------------- // Issue key: e.g. "VULN-123", "AB-1", "ABCDEF-99999" const issueKeyArb = fc.tuple( fc.stringMatching(/^[A-Z]{2,6}$/), fc.integer({ min: 1, max: 99999 }) ).map(([prefix, num]) => `${prefix}-${num}`); // Field objects: at minimum a summary field const fieldObjectArb = fc.record({ summary: fc.string({ minLength: 1, maxLength: 100 }) }); // Comment strings: non-empty text const commentArb = fc.string({ minLength: 1, maxLength: 500 }); // Transition IDs: common Jira transition IDs as strings const transitionIdArb = fc.constantFrom('1', '2', '3', '4', '5', '11', '21', '31'); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('Feature: jira-api-compliance, Property 4: Preservation — Unchanged Jira API Functions', () => { beforeEach(() => { mockCapturedCalls = []; }); /** * Property 4.1: createIssue() always sends POST /rest/api/2/issue with { fields } body * * For any field object, createIssue() SHALL send a POST request to * /rest/api/2/issue with a JSON body containing { fields: }. * * **Validates: Requirements 3.1** */ it('createIssue() sends POST /rest/api/2/issue with { fields } body', async () => { await fc.assert( fc.asyncProperty(fieldObjectArb, async (fields) => { mockCapturedCalls = []; await jiraApi.createIssue(fields); expect(mockCapturedCalls.length).toBe(1); const call = mockCapturedCalls[0]; expect(call.method).toBe('POST'); expect(call.urlPath).toBe('/rest/api/2/issue'); expect(call.body).toEqual({ fields }); }), { numRuns: 50 } ); }, 30000); /** * Property 4.2: updateIssue() always sends PUT /rest/api/2/issue/{key} with { fields } body * * For any issue key and field object, updateIssue() SHALL send a PUT request * to /rest/api/2/issue/{key} with a JSON body containing { fields: }. * * **Validates: Requirements 3.2** */ it('updateIssue() sends PUT /rest/api/2/issue/{key} with { fields } body', async () => { await fc.assert( fc.asyncProperty(issueKeyArb, fieldObjectArb, async (issueKey, fields) => { mockCapturedCalls = []; await jiraApi.updateIssue(issueKey, fields); expect(mockCapturedCalls.length).toBe(1); const call = mockCapturedCalls[0]; expect(call.method).toBe('PUT'); expect(call.urlPath).toBe(`/rest/api/2/issue/${encodeURIComponent(issueKey)}`); expect(call.body).toEqual({ fields }); }), { numRuns: 50 } ); }, 30000); /** * Property 4.3: addComment() always sends POST /rest/api/2/issue/{key}/comment with { body } * * For any issue key and comment string, addComment() SHALL send a POST request * to /rest/api/2/issue/{key}/comment with a JSON body containing { body: }. * * **Validates: Requirements 3.3** */ it('addComment() sends POST /rest/api/2/issue/{key}/comment with { body }', async () => { await fc.assert( fc.asyncProperty(issueKeyArb, commentArb, async (issueKey, comment) => { mockCapturedCalls = []; await jiraApi.addComment(issueKey, comment); expect(mockCapturedCalls.length).toBe(1); const call = mockCapturedCalls[0]; expect(call.method).toBe('POST'); expect(call.urlPath).toBe(`/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment`); expect(call.body).toEqual({ body: comment }); }), { numRuns: 50 } ); }, 30000); /** * Property 4.4: transitionIssue() always sends POST /rest/api/2/issue/{key}/transitions * with { transition: { id } } * * For any issue key and transition ID, transitionIssue() SHALL send a POST request * to /rest/api/2/issue/{key}/transitions with a JSON body containing * { transition: { id: } }. * * **Validates: Requirements 3.4** */ it('transitionIssue() sends POST /rest/api/2/issue/{key}/transitions with { transition: { id } }', async () => { await fc.assert( fc.asyncProperty(issueKeyArb, transitionIdArb, async (issueKey, transitionId) => { mockCapturedCalls = []; await jiraApi.transitionIssue(issueKey, transitionId); expect(mockCapturedCalls.length).toBe(1); const call = mockCapturedCalls[0]; expect(call.method).toBe('POST'); expect(call.urlPath).toBe(`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`); expect(call.body).toEqual({ transition: { id: transitionId } }); }), { numRuns: 50 } ); }, 30000); /** * Property 4.5: getTransitions() always sends GET /rest/api/2/issue/{key}/transitions * * For any issue key, getTransitions() SHALL send a GET request to * /rest/api/2/issue/{key}/transitions with no body. * * **Validates: Requirements 3.5** */ it('getTransitions() sends GET /rest/api/2/issue/{key}/transitions', async () => { await fc.assert( fc.asyncProperty(issueKeyArb, async (issueKey) => { mockCapturedCalls = []; await jiraApi.getTransitions(issueKey); expect(mockCapturedCalls.length).toBe(1); const call = mockCapturedCalls[0]; expect(call.method).toBe('GET'); expect(call.urlPath).toBe(`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`); expect(call.body).toBeNull(); }), { numRuns: 50 } ); }, 30000); /** * Property 4.6: testConnection() always sends GET /rest/api/2/myself * * testConnection() SHALL send a GET request to /rest/api/2/myself with no body. * * **Validates: Requirements 3.6** */ it('testConnection() sends GET /rest/api/2/myself', async () => { // testConnection is deterministic — no random input needed. // Run it multiple times to confirm consistency. for (let i = 0; i < 10; i++) { mockCapturedCalls = []; const result = await jiraApi.testConnection(); expect(mockCapturedCalls.length).toBe(1); const call = mockCapturedCalls[0]; expect(call.method).toBe('GET'); expect(call.urlPath).toBe('/rest/api/2/myself'); expect(call.body).toBeNull(); // Verify response shape expect(result).toHaveProperty('ok', true); expect(result).toHaveProperty('user'); expect(result.user).toHaveProperty('name'); expect(result.user).toHaveProperty('displayName'); expect(result.user).toHaveProperty('emailAddress'); } }, 30000); });