/ __quarantined_tests__ / agents / slo-tracker.test.js
slo-tracker.test.js
  1  /**
  2   * SLO Tracker Tests
  3   *
  4   * Tests for Service-Level Objective tracking functionality
  5   */
  6  
  7  import { test } from 'node:test';
  8  import assert from 'node:assert';
  9  import Database from 'better-sqlite3';
 10  import fs from 'fs';
 11  import {
 12    SLO_DEFINITIONS,
 13    calculateStagePerformance,
 14    checkSLOCompliance,
 15    getSLOSummary,
 16    resetDb,
 17  } from '../../src/agents/utils/slo-tracker.js';
 18  
 19  // Test database path
 20  const TEST_DB_PATH = './db/test-slo-tracker.db';
 21  
 22  /**
 23   * Set up test database with schema
 24   */
 25  function setupTestDb() {
 26    // Remove existing test db
 27    if (fs.existsSync(TEST_DB_PATH)) {
 28      fs.unlinkSync(TEST_DB_PATH);
 29    }
 30  
 31    const db = new Database(TEST_DB_PATH);
 32  
 33    // Create minimal schema for testing
 34    db.exec(`
 35      CREATE TABLE sites (
 36        id INTEGER PRIMARY KEY AUTOINCREMENT,
 37        domain TEXT NOT NULL,
 38        status TEXT NOT NULL,
 39        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 40        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
 41      );
 42  
 43      CREATE TABLE site_status (
 44        id INTEGER PRIMARY KEY AUTOINCREMENT,
 45        site_id INTEGER NOT NULL REFERENCES sites(id),
 46        status TEXT NOT NULL,
 47        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 48      );
 49  
 50      CREATE INDEX idx_site_status_site_created ON site_status(site_id, created_at);
 51    `);
 52  
 53    return db;
 54  }
 55  
 56  /**
 57   * Insert test data with specific transition durations
 58   */
 59  // Format date for SQLite (space-separated, not T-format, to avoid lexicographic comparison bugs)
 60  function toSQLiteDate(date) {
 61    return date
 62      .toISOString()
 63      .replace('T', ' ')
 64      .replace(/\.\d{3}Z$/, '');
 65  }
 66  
 67  function insertTestData(db, fromStage, toStage, durationMinutes) {
 68    // Create site
 69    const siteResult = db
 70      .prepare('INSERT INTO sites (domain, status) VALUES (?, ?)')
 71      .run(`test-${Date.now()}.com`, toStage);
 72  
 73    const siteId = siteResult.lastInsertRowid;
 74  
 75    // Calculate timestamps
 76    const now = new Date();
 77    const startTime = new Date(now.getTime() - durationMinutes * 60 * 1000);
 78  
 79    // Insert status transitions (use SQLite format to avoid ISO 'T' comparison bugs)
 80    db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
 81      siteId,
 82      fromStage,
 83      toSQLiteDate(startTime)
 84    );
 85  
 86    db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
 87      siteId,
 88      toStage,
 89      toSQLiteDate(now)
 90    );
 91  
 92    return siteId;
 93  }
 94  
 95  test('SLO Tracker - SLO Definitions', async t => {
 96    await t.test('should have defined SLOs', () => {
 97      assert.ok(Array.isArray(SLO_DEFINITIONS), 'SLO_DEFINITIONS should be an array');
 98      assert.ok(SLO_DEFINITIONS.length > 0, 'Should have at least one SLO defined');
 99    });
100  
101    await t.test('should have required SLO fields', () => {
102      for (const slo of SLO_DEFINITIONS) {
103        assert.ok(slo.stage_name, 'SLO should have stage_name');
104        assert.ok(slo.description, 'SLO should have description');
105        assert.ok(typeof slo.target_percentile === 'number', 'target_percentile should be a number');
106        assert.ok(
107          typeof slo.target_duration_minutes === 'number',
108          'target_duration_minutes should be a number'
109        );
110        assert.ok(typeof slo.lookback_hours === 'number', 'lookback_hours should be a number');
111      }
112    });
113  
114    await t.test('should have expected SLOs', () => {
115      const stageNames = SLO_DEFINITIONS.map(slo => slo.stage_name);
116      assert.ok(stageNames.includes('serps_to_assets'), 'Should have serps_to_assets SLO');
117      assert.ok(stageNames.includes('assets_to_scored'), 'Should have assets_to_scored SLO');
118      assert.ok(stageNames.includes('scored_to_rescored'), 'Should have scored_to_rescored SLO');
119    });
120  });
121  
122  test('SLO Tracker - calculateStagePerformance', async t => {
123    let db;
124  
125    t.beforeEach(() => {
126      process.env.DATABASE_PATH = TEST_DB_PATH;
127      db = setupTestDb();
128      resetDb(); // Reset the module's db connection
129    });
130  
131    t.afterEach(() => {
132      if (db) {
133        db.close();
134      }
135      if (fs.existsSync(TEST_DB_PATH)) {
136        fs.unlinkSync(TEST_DB_PATH);
137      }
138      delete process.env.DATABASE_PATH;
139      resetDb();
140    });
141  
142    await t.test('should return empty result when no data', () => {
143      const result = calculateStagePerformance('found', 'assets_captured', 24);
144  
145      assert.strictEqual(result.totalSites, 0);
146      assert.deepStrictEqual(result.durations, []);
147      assert.strictEqual(result.p50, null);
148      assert.strictEqual(result.p95, null);
149      assert.strictEqual(result.p99, null);
150    });
151  
152    await t.test('should calculate performance with single site', () => {
153      // Insert site with 30 minute transition
154      insertTestData(db, 'found', 'assets_captured', 30);
155  
156      const result = calculateStagePerformance('found', 'assets_captured', 24);
157  
158      assert.strictEqual(result.totalSites, 1);
159      assert.strictEqual(result.durations.length, 1);
160      assert.ok(Math.abs(result.p50 - 30) < 1, 'P50 should be ~30 minutes');
161      assert.ok(Math.abs(result.p95 - 30) < 1, 'P95 should be ~30 minutes');
162    });
163  
164    await t.test('should calculate performance with multiple sites', () => {
165      // Insert sites with varying durations: 10, 20, 30, 40, 50 minutes
166      for (const duration of [10, 20, 30, 40, 50]) {
167        insertTestData(db, 'assets_captured', 'prog_scored', duration);
168      }
169  
170      const result = calculateStagePerformance('assets_captured', 'prog_scored', 24);
171  
172      assert.strictEqual(result.totalSites, 5);
173      assert.strictEqual(result.durations.length, 5);
174      assert.ok(Math.abs(result.p50 - 30) < 2, 'P50 should be ~30 minutes');
175      assert.ok(Math.abs(result.p95 - 50) <= 3, 'P95 should be ~50 minutes');
176    });
177  
178    await t.test('should respect lookback window', () => {
179      // Insert old data (25 hours ago) - should be excluded
180      const oldSite = db
181        .prepare('INSERT INTO sites (domain, status) VALUES (?, ?)')
182        .run('old.com', 'prog_scored');
183      const oldTime = new Date(Date.now() - 25 * 60 * 60 * 1000);
184  
185      db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
186        oldSite.lastInsertRowid,
187        'assets_captured',
188        toSQLiteDate(new Date(oldTime.getTime() - 30 * 60 * 1000))
189      );
190      db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
191        oldSite.lastInsertRowid,
192        'prog_scored',
193        toSQLiteDate(oldTime)
194      );
195  
196      // Insert recent data (1 hour ago)
197      insertTestData(db, 'assets_captured', 'prog_scored', 30);
198  
199      const result = calculateStagePerformance('assets_captured', 'prog_scored', 24);
200  
201      assert.strictEqual(result.totalSites, 1, 'Should only include recent data');
202    });
203  
204    await t.test('should handle sites with multiple transitions', () => {
205      // Site transitions: found -> assets_captured -> scored
206      const siteId = insertTestData(db, 'found', 'assets_captured', 60);
207  
208      // Add another transition for the same site
209      const now = new Date();
210      db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
211        siteId,
212        'prog_scored',
213        now.toISOString()
214      );
215  
216      // Query for assets_captured -> scored should work
217      const result = calculateStagePerformance('assets_captured', 'prog_scored', 24);
218      assert.strictEqual(result.totalSites, 1);
219    });
220  });
221  
222  test('SLO Tracker - checkSLOCompliance', async t => {
223    let db;
224  
225    t.beforeEach(() => {
226      process.env.DATABASE_PATH = TEST_DB_PATH;
227      db = setupTestDb();
228      resetDb();
229    });
230  
231    t.afterEach(() => {
232      if (db) {
233        db.close();
234      }
235      if (fs.existsSync(TEST_DB_PATH)) {
236        fs.unlinkSync(TEST_DB_PATH);
237      }
238      delete process.env.DATABASE_PATH;
239      resetDb();
240    });
241  
242    await t.test('should return no violations when no data', () => {
243      const violations = checkSLOCompliance();
244      assert.strictEqual(violations.length, 0);
245    });
246  
247    await t.test('should return no violations when within SLO', () => {
248      // serps_to_assets SLO: 95% within 60 minutes
249      // Insert 20 sites all within 50 minutes
250      for (let i = 0; i < 20; i++) {
251        insertTestData(db, 'found', 'assets_captured', 50);
252      }
253  
254      const violations = checkSLOCompliance();
255  
256      // Find serps_to_assets violations
257      const serpsViolations = violations.filter(v => v.slo.stage_name === 'serps_to_assets');
258      assert.strictEqual(serpsViolations.length, 0, 'Should have no violations');
259    });
260  
261    await t.test('should detect violations when exceeding SLO', () => {
262      // serps_to_assets SLO: 95% within 60 minutes
263      // Insert 20 sites all taking 120 minutes (2x target)
264      for (let i = 0; i < 20; i++) {
265        insertTestData(db, 'found', 'assets_captured', 120);
266      }
267  
268      const violations = checkSLOCompliance();
269  
270      // Find serps_to_assets violations
271      const serpsViolations = violations.filter(v => v.slo.stage_name === 'serps_to_assets');
272      assert.strictEqual(serpsViolations.length, 1, 'Should have one violation');
273  
274      const violation = serpsViolations[0];
275      assert.strictEqual(violation.slo.stage_name, 'serps_to_assets');
276      assert.ok(violation.actual.p95 > 60, 'P95 should exceed 60 minutes');
277      assert.ok(violation.violation_severity, 'Should have severity');
278    });
279  
280    await t.test('should calculate violation severity correctly', () => {
281      // Test critical severity (3x over target)
282      for (let i = 0; i < 20; i++) {
283        insertTestData(db, 'found', 'assets_captured', 180); // 3x the 60min target
284      }
285  
286      const violations = checkSLOCompliance();
287      const serpsViolations = violations.filter(v => v.slo.stage_name === 'serps_to_assets');
288  
289      if (serpsViolations.length > 0) {
290        const violation = serpsViolations[0];
291        assert.ok(
292          ['high', 'critical'].includes(violation.violation_severity),
293          'Should have high or critical severity for 3x over'
294        );
295      }
296    });
297  
298    await t.test('should include violation details', () => {
299      // Create violation
300      for (let i = 0; i < 20; i++) {
301        insertTestData(db, 'assets_captured', 'prog_scored', 90); // 3x the 30min target
302      }
303  
304      const violations = checkSLOCompliance();
305      const assetsViolations = violations.filter(v => v.slo.stage_name === 'assets_to_scored');
306  
307      if (assetsViolations.length > 0) {
308        const violation = assetsViolations[0];
309  
310        assert.ok(violation.slo, 'Should have slo object');
311        assert.ok(violation.actual, 'Should have actual object');
312        assert.ok(violation.violation_description, 'Should have description');
313        assert.ok(typeof violation.actual.totalSites === 'number', 'Should have totalSites');
314        assert.ok(typeof violation.actual.p50 === 'number', 'Should have p50');
315        assert.ok(typeof violation.actual.p95 === 'number', 'Should have p95');
316      }
317    });
318  
319    await t.test('should check multiple SLOs independently', () => {
320      // Create violation for serps_to_assets only
321      for (let i = 0; i < 20; i++) {
322        insertTestData(db, 'found', 'assets_captured', 150);
323      }
324  
325      // Create compliant data for assets_to_scored
326      for (let i = 0; i < 20; i++) {
327        insertTestData(db, 'assets_captured', 'prog_scored', 15);
328      }
329  
330      const violations = checkSLOCompliance();
331  
332      const serpsViolations = violations.filter(v => v.slo.stage_name === 'serps_to_assets');
333      const assetsViolations = violations.filter(v => v.slo.stage_name === 'assets_to_scored');
334  
335      assert.strictEqual(serpsViolations.length, 1, 'Should have serps violation');
336      assert.strictEqual(assetsViolations.length, 0, 'Should not have assets violation');
337    });
338  });
339  
340  test('SLO Tracker - getSLOSummary', async t => {
341    let db;
342  
343    t.beforeEach(() => {
344      process.env.DATABASE_PATH = TEST_DB_PATH;
345      db = setupTestDb();
346      resetDb();
347    });
348  
349    t.afterEach(() => {
350      if (db) {
351        db.close();
352      }
353      if (fs.existsSync(TEST_DB_PATH)) {
354        fs.unlinkSync(TEST_DB_PATH);
355      }
356      delete process.env.DATABASE_PATH;
357      resetDb();
358    });
359  
360    await t.test('should return summary with no violations', () => {
361      const summary = getSLOSummary();
362  
363      assert.ok(summary.total_slos > 0, 'Should have total SLOs');
364      assert.strictEqual(summary.violations, 0, 'Should have no violations');
365      assert.strictEqual(summary.compliance_rate, 100, 'Should be 100% compliant');
366      assert.ok(Array.isArray(summary.violations_detail), 'Should have violations_detail array');
367    });
368  
369    await t.test('should return summary with violations', () => {
370      // Create multiple violations
371      for (let i = 0; i < 20; i++) {
372        insertTestData(db, 'found', 'assets_captured', 150);
373        insertTestData(db, 'assets_captured', 'prog_scored', 90);
374      }
375  
376      const summary = getSLOSummary();
377  
378      assert.ok(summary.violations > 0, 'Should have violations');
379      assert.ok(summary.compliance_rate < 100, 'Should not be 100% compliant');
380      assert.strictEqual(
381        summary.violations,
382        summary.violations_detail.length,
383        'Violations count should match detail length'
384      );
385    });
386  
387    await t.test('should calculate compliance rate correctly', () => {
388      // Create exactly 1 violation out of 3 SLOs
389      for (let i = 0; i < 20; i++) {
390        insertTestData(db, 'found', 'assets_captured', 150); // Violation
391      }
392  
393      const summary = getSLOSummary();
394  
395      assert.strictEqual(summary.total_slos, 3, 'Should have 3 SLOs');
396      assert.strictEqual(summary.violations, 1, 'Should have 1 violation');
397  
398      const expectedRate = ((3 - 1) / 3) * 100;
399      assert.ok(
400        Math.abs(summary.compliance_rate - expectedRate) < 1,
401        `Compliance rate should be ~${expectedRate}%`
402      );
403    });
404  });
405  
406  test('SLO Tracker - Integration', async t => {
407    let db;
408  
409    t.beforeEach(() => {
410      process.env.DATABASE_PATH = TEST_DB_PATH;
411      db = setupTestDb();
412      resetDb();
413    });
414  
415    t.afterEach(() => {
416      if (db) {
417        db.close();
418      }
419      if (fs.existsSync(TEST_DB_PATH)) {
420        fs.unlinkSync(TEST_DB_PATH);
421      }
422      delete process.env.DATABASE_PATH;
423      resetDb();
424    });
425  
426    await t.test('should track realistic pipeline flow', () => {
427      // Simulate realistic pipeline: 100 sites flowing through
428      const now = Date.now();
429  
430      for (let i = 0; i < 100; i++) {
431        const siteResult = db
432          .prepare('INSERT INTO sites (domain, status) VALUES (?, ?)')
433          .run(`site-${i}.com`, 'prog_scored');
434        const siteId = siteResult.lastInsertRowid;
435  
436        // found -> assets_captured (45 minutes)
437        const foundTime = new Date(now - 120 * 60 * 1000);
438        const assetsTime = new Date(now - 75 * 60 * 1000);
439        const scoredTime = new Date(now - 60 * 60 * 1000);
440  
441        db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
442          siteId,
443          'found',
444          foundTime.toISOString()
445        );
446        db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
447          siteId,
448          'assets_captured',
449          assetsTime.toISOString()
450        );
451        db.prepare('INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, ?)').run(
452          siteId,
453          'prog_scored',
454          scoredTime.toISOString()
455        );
456      }
457  
458      const summary = getSLOSummary();
459  
460      assert.ok(summary.total_slos > 0, 'Should have SLOs');
461      assert.ok(summary.compliance_rate >= 0, 'Should have compliance rate');
462  
463      const violations = checkSLOCompliance();
464      assert.ok(Array.isArray(violations), 'Should return violations array');
465    });
466  
467    await t.test('should handle edge case: exactly at SLO threshold', () => {
468      // Insert sites at 59 minutes - safely within the 60-minute target for serps_to_assets
469      // (Using 59 instead of exactly 60 to avoid floating-point rounding issues)
470      for (let i = 0; i < 20; i++) {
471        insertTestData(db, 'found', 'assets_captured', 59);
472      }
473  
474      const violations = checkSLOCompliance();
475      const serpsViolations = violations.filter(v => v.slo.stage_name === 'serps_to_assets');
476  
477      // At or under threshold should not violate
478      assert.strictEqual(serpsViolations.length, 0, 'Should not violate at exact threshold');
479    });
480  
481    await t.test('should handle mixed performance data', () => {
482      // 90% fast (30 min), 10% slow (150 min)
483      for (let i = 0; i < 18; i++) {
484        insertTestData(db, 'found', 'assets_captured', 30);
485      }
486      for (let i = 0; i < 2; i++) {
487        insertTestData(db, 'found', 'assets_captured', 150);
488      }
489  
490      const result = calculateStagePerformance('found', 'assets_captured', 24);
491      assert.strictEqual(result.totalSites, 20);
492  
493      // P95 should be influenced by the slow ones
494      assert.ok(result.p95 > 30, 'P95 should be higher than fast sites');
495    });
496  });