379 lines
13 KiB
JavaScript
379 lines
13 KiB
JavaScript
|
|
/**
|
||
|
|
* 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: <fieldObject> }.
|
||
|
|
*
|
||
|
|
* **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: <fieldObject> }.
|
||
|
|
*
|
||
|
|
* **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: <comment> }.
|
||
|
|
*
|
||
|
|
* **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: <transitionId> } }.
|
||
|
|
*
|
||
|
|
* **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);
|
||
|
|
});
|