/** * 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'); expect(capturedJql).toContain('project ='); }), { numRuns: 100 } ); }, 60000); });