2026-05-05 11:04:53 -06:00
|
|
|
|
/**
|
|
|
|
|
|
* Property-Based Test: JQL Window Invariant
|
|
|
|
|
|
*
|
|
|
|
|
|
* Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72 hours in bulk sync
|
|
|
|
|
|
*
|
|
|
|
|
|
* For any non-empty array of valid-looking issue keys passed to searchIssuesByKeys(),
|
|
|
|
|
|
* the generated JQL string SHALL contain the substring `updated >= -72h` and
|
|
|
|
|
|
* SHALL contain the substring `project =`.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Validates: Requirements 2.1, 2.3
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const fc = require('fast-check');
|
|
|
|
|
|
|
|
|
|
|
|
// Capture the JQL that flows through the HTTP layer.
|
|
|
|
|
|
let capturedJql = null;
|
|
|
|
|
|
|
|
|
|
|
|
// Mock https to intercept the request URL (which contains the JQL) and return
|
|
|
|
|
|
// a fake 200 response. This prevents real network calls while letting the
|
|
|
|
|
|
// real searchIssuesByKeys → searchIssues → jiraGet → jiraRequest chain execute.
|
|
|
|
|
|
jest.mock('https', () => ({
|
|
|
|
|
|
request: jest.fn((options, callback) => {
|
|
|
|
|
|
const fullPath = options.path || '';
|
|
|
|
|
|
const jqlMatch = fullPath.match(/[?&]jql=([^&]*)/);
|
|
|
|
|
|
if (jqlMatch) {
|
|
|
|
|
|
capturedJql = decodeURIComponent(jqlMatch[1]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
|
statusCode: 200,
|
|
|
|
|
|
on: jest.fn((event, handler) => {
|
|
|
|
|
|
if (event === 'data') {
|
|
|
|
|
|
handler(JSON.stringify({ total: 0, issues: [] }));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (event === 'end') {
|
|
|
|
|
|
handler();
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
// Use setImmediate so the callback fires on the same tick after promises
|
|
|
|
|
|
// resolve, but still asynchronously as Node's http expects.
|
|
|
|
|
|
setImmediate(() => callback(mockResponse));
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
on: jest.fn(),
|
|
|
|
|
|
write: jest.fn(),
|
|
|
|
|
|
end: jest.fn(),
|
|
|
|
|
|
destroy: jest.fn(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// Set required env vars before requiring the module so the module-level
|
|
|
|
|
|
// constants pick them up.
|
|
|
|
|
|
process.env.JIRA_PROJECT_KEY = 'TESTPROJ';
|
|
|
|
|
|
process.env.JIRA_BASE_URL = 'https://jira.example.com';
|
|
|
|
|
|
process.env.JIRA_API_USER = 'testuser';
|
|
|
|
|
|
process.env.JIRA_API_TOKEN = 'testtoken';
|
|
|
|
|
|
|
|
|
|
|
|
const jiraApi = require('../helpers/jiraApi');
|
|
|
|
|
|
|
|
|
|
|
|
describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72h in bulk sync', () => {
|
|
|
|
|
|
// Use fake timers so the rate-limiter's inter-request delays (1–2 seconds)
|
|
|
|
|
|
// resolve instantly. We preserve setImmediate so the https mock callback
|
|
|
|
|
|
// still fires asynchronously as expected.
|
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
|
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterAll(() => {
|
|
|
|
|
|
jest.useRealTimers();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
capturedJql = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Generator: produces a valid Jira issue key like "AB-1", "PROJ-42", etc.
|
|
|
|
|
|
const issueKeyArb = fc.tuple(
|
|
|
|
|
|
fc.stringMatching(/^[A-Z]{2,10}$/),
|
|
|
|
|
|
fc.integer({ min: 1, max: 99999 })
|
|
|
|
|
|
).map(([prefix, num]) => `${prefix}-${num}`);
|
|
|
|
|
|
|
|
|
|
|
|
// Generator: non-empty array of issue keys (1 to 50 keys)
|
|
|
|
|
|
const issueKeysArb = fc.array(issueKeyArb, { minLength: 1, maxLength: 50 });
|
|
|
|
|
|
|
|
|
|
|
|
it('searchIssuesByKeys() always generates JQL containing `updated >= -72h` and `project =`', async () => {
|
|
|
|
|
|
await fc.assert(
|
|
|
|
|
|
fc.asyncProperty(issueKeysArb, async (issueKeys) => {
|
|
|
|
|
|
capturedJql = null;
|
|
|
|
|
|
|
|
|
|
|
|
// Start the call — it will hit waitForDelay which uses setTimeout
|
|
|
|
|
|
const promise = jiraApi.searchIssuesByKeys(issueKeys);
|
|
|
|
|
|
|
|
|
|
|
|
// Advance fake timers to resolve any pending setTimeout from the
|
|
|
|
|
|
// rate limiter's waitForDelay function.
|
|
|
|
|
|
jest.advanceTimersByTime(5000);
|
|
|
|
|
|
|
|
|
|
|
|
await promise;
|
|
|
|
|
|
|
|
|
|
|
|
expect(capturedJql).not.toBeNull();
|
|
|
|
|
|
expect(capturedJql).toContain('updated >= -72h');
|
2026-05-20 14:01:28 -06:00
|
|
|
|
// project filter intentionally removed — issue keys are globally unique
|
|
|
|
|
|
// and the filter broke cross-project ticket sync
|
2026-05-05 11:04:53 -06:00
|
|
|
|
}),
|
|
|
|
|
|
{ numRuns: 100 }
|
|
|
|
|
|
);
|
|
|
|
|
|
}, 60000);
|
|
|
|
|
|
});
|