Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions tests/core/ids/circuit-breaker.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/**
* Unit tests for circuit-breaker module
*
* Tests the CircuitBreaker pattern implementation with
* CLOSED, OPEN, and HALF_OPEN states.
*/

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All other IDS test files in this repo start with 'use strict'; (e.g. tests/core/ids/framework-governor.test.js, entity-registry-schema.test.js). To match the established test-file convention and avoid subtle strict-mode differences, add 'use strict'; as the first statement in this file.

Suggested change
'use strict';

Copilot uses AI. Check for mistakes.
const {
CircuitBreaker,
STATE_CLOSED,
STATE_OPEN,
STATE_HALF_OPEN,
DEFAULT_FAILURE_THRESHOLD,
DEFAULT_SUCCESS_THRESHOLD,
DEFAULT_RESET_TIMEOUT_MS,
} = require('../../../.aios-core/core/ids/circuit-breaker');

describe('CircuitBreaker', () => {
let breaker;

beforeEach(() => {
breaker = new CircuitBreaker();
});

// ============================================================
// Constants
// ============================================================
describe('constants', () => {
test('state constants are defined', () => {
expect(STATE_CLOSED).toBe('CLOSED');
expect(STATE_OPEN).toBe('OPEN');
expect(STATE_HALF_OPEN).toBe('HALF_OPEN');
});

test('default thresholds are defined', () => {
expect(DEFAULT_FAILURE_THRESHOLD).toBe(5);
expect(DEFAULT_SUCCESS_THRESHOLD).toBe(3);
expect(DEFAULT_RESET_TIMEOUT_MS).toBe(60000);
});
});

// ============================================================
// Constructor
// ============================================================
describe('constructor', () => {
test('starts in CLOSED state', () => {
expect(breaker.getState()).toBe(STATE_CLOSED);
});

test('uses default thresholds', () => {
const stats = breaker.getStats();
expect(stats.failureCount).toBe(0);
expect(stats.successCount).toBe(0);
expect(stats.totalTrips).toBe(0);
});

test('accepts custom options', () => {
const custom = new CircuitBreaker({
failureThreshold: 3,
successThreshold: 2,
resetTimeoutMs: 30000,
});
// Trip it to verify custom threshold
for (let i = 0; i < 3; i++) custom.recordFailure();
expect(custom.getState()).toBe(STATE_OPEN);
});
});

// ============================================================
// isAllowed
// ============================================================
describe('isAllowed', () => {
test('allows requests when CLOSED', () => {
expect(breaker.isAllowed()).toBe(true);
});

test('blocks requests when OPEN', () => {
// Trip the breaker
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_OPEN);
expect(breaker.isAllowed()).toBe(false);
});

test('transitions to HALF_OPEN after reset timeout', () => {
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_OPEN);

// Simulate timeout passing
breaker._lastFailureTime = Date.now() - 61000;

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

61000 is a magic number tied to the default reset timeout (60000ms). To keep the test resilient if defaults change, compute this using DEFAULT_RESET_TIMEOUT_MS (e.g. Date.now() - (DEFAULT_RESET_TIMEOUT_MS + 1000)) rather than hard-coding 61000.

Suggested change
breaker._lastFailureTime = Date.now() - 61000;
breaker._lastFailureTime = Date.now() - (DEFAULT_RESET_TIMEOUT_MS + 1000);

Copilot uses AI. Check for mistakes.

expect(breaker.isAllowed()).toBe(true);
expect(breaker.getState()).toBe(STATE_HALF_OPEN);
Comment on lines +85 to +92

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test forces the OPEN→HALF_OPEN transition by directly mutating the internal _lastFailureTime. That makes the test brittle to implementation refactors. Prefer advancing time via Jest fake timers (jest.useFakeTimers() + jest.setSystemTime() / jest.advanceTimersByTime()) or using a shorter resetTimeoutMs option and real timers, as done in other IDS tests.

Suggested change
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_OPEN);
// Simulate timeout passing
breaker._lastFailureTime = Date.now() - 61000;
expect(breaker.isAllowed()).toBe(true);
expect(breaker.getState()).toBe(STATE_HALF_OPEN);
// Use fake timers to simulate the reset timeout elapsing
jest.useFakeTimers().setSystemTime(new Date('2020-01-01T00:00:00Z'));
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_OPEN);
// Advance time past the default reset timeout (60s)
jest.setSystemTime(new Date('2020-01-01T00:01:01Z'));
expect(breaker.isAllowed()).toBe(true);
expect(breaker.getState()).toBe(STATE_HALF_OPEN);
jest.useRealTimers();

Copilot uses AI. Check for mistakes.
});

test('allows one probe in HALF_OPEN', () => {
breaker._state = STATE_HALF_OPEN;
breaker._halfOpenProbeInFlight = false;

expect(breaker.isAllowed()).toBe(true);
Comment on lines +96 to +99

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions set _state / _halfOpenProbeInFlight directly to arrange HALF_OPEN behavior. To keep tests aligned with the public contract, prefer reaching HALF_OPEN via recordFailure() + elapsed timeout + isAllowed() (or fake timers) rather than writing internal fields.

Suggested change
breaker._state = STATE_HALF_OPEN;
breaker._halfOpenProbeInFlight = false;
expect(breaker.isAllowed()).toBe(true);
// Trip the breaker to OPEN
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_OPEN);
// Simulate timeout passing to allow transition to HALF_OPEN
breaker._lastFailureTime = Date.now() - 61000;
// First request after timeout should be allowed (probe) and move to HALF_OPEN
expect(breaker.isAllowed()).toBe(true);
expect(breaker.getState()).toBe(STATE_HALF_OPEN);
// Second request while probe is in flight should be blocked

Copilot uses AI. Check for mistakes.
expect(breaker.isAllowed()).toBe(false); // second request blocked
});
});

// ============================================================
// recordSuccess
// ============================================================
describe('recordSuccess', () => {
test('resets failure count in CLOSED state', () => {
breaker.recordFailure();
breaker.recordFailure();
expect(breaker.getStats().failureCount).toBe(2);

breaker.recordSuccess();
expect(breaker.getStats().failureCount).toBe(0);
});

test('counts successes in HALF_OPEN', () => {
breaker._state = STATE_HALF_OPEN;
breaker._halfOpenProbeInFlight = true;

breaker.recordSuccess();
expect(breaker.getStats().successCount).toBe(1);
});

test('closes circuit after success threshold in HALF_OPEN', () => {
breaker._state = STATE_HALF_OPEN;

for (let i = 0; i < 3; i++) {
breaker._halfOpenProbeInFlight = true;
breaker.recordSuccess();
}

expect(breaker.getState()).toBe(STATE_CLOSED);
expect(breaker.getStats().failureCount).toBe(0);
expect(breaker.getStats().successCount).toBe(0);
});
});

// ============================================================
// recordFailure
// ============================================================
describe('recordFailure', () => {
test('increments failure count', () => {
breaker.recordFailure();
expect(breaker.getStats().failureCount).toBe(1);
});

test('opens circuit at threshold', () => {
for (let i = 0; i < 4; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_CLOSED);

breaker.recordFailure(); // 5th failure
expect(breaker.getState()).toBe(STATE_OPEN);
});

test('increments totalTrips when opening', () => {
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getStats().totalTrips).toBe(1);
});

test('re-opens circuit from HALF_OPEN on failure', () => {
breaker._state = STATE_HALF_OPEN;
breaker._halfOpenProbeInFlight = true;

breaker.recordFailure();

expect(breaker.getState()).toBe(STATE_OPEN);
expect(breaker.getStats().totalTrips).toBe(1);
});

test('records lastFailureTime', () => {
const before = Date.now();
breaker.recordFailure();
const after = Date.now();

expect(breaker.getStats().lastFailureTime).toBeGreaterThanOrEqual(before);
expect(breaker.getStats().lastFailureTime).toBeLessThanOrEqual(after);
});
});

// ============================================================
// getStats
// ============================================================
describe('getStats', () => {
test('returns complete stats object', () => {
const stats = breaker.getStats();

expect(stats).toHaveProperty('state');
expect(stats).toHaveProperty('failureCount');
expect(stats).toHaveProperty('successCount');
expect(stats).toHaveProperty('totalTrips');
expect(stats).toHaveProperty('lastFailureTime');
});

test('lastFailureTime is null initially', () => {
expect(breaker.getStats().lastFailureTime).toBeNull();
});
});

// ============================================================
// reset
// ============================================================
describe('reset', () => {
test('resets to CLOSED state', () => {
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_OPEN);

breaker.reset();

expect(breaker.getState()).toBe(STATE_CLOSED);
expect(breaker.getStats().failureCount).toBe(0);
expect(breaker.getStats().successCount).toBe(0);
});
Comment on lines +204 to +213

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

reset test is missing assertions for lastFailureTime and totalTrips.

After calling reset(), neither lastFailureTime (expected null) nor totalTrips (whatever the contract is — preserved or zeroed) is verified. Both are part of the getStats() surface and their post-reset values are an important behavioral contract.

🛡️ Proposed additional assertions
   breaker.reset();

   expect(breaker.getState()).toBe(STATE_CLOSED);
   expect(breaker.getStats().failureCount).toBe(0);
   expect(breaker.getStats().successCount).toBe(0);
+  expect(breaker.getStats().lastFailureTime).toBeNull();
+  // totalTrips: assert the expected value (0 if reset() clears it, 1 if it's preserved)
+  expect(breaker.getStats().totalTrips).toBe(/* 0 or 1 per implementation contract */);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/ids/circuit-breaker.test.js` around lines 204 - 213, The test
"resets to CLOSED state" omits assertions for stats fields lastFailureTime and
totalTrips after calling breaker.reset(); update the test to call
breaker.getStats() and assert that stats.lastFailureTime is null and
stats.totalTrips matches the intended contract (either 0 if reset should clear
trips or preserved value if spec dictates) so the reset() behavior for
lastFailureTime and totalTrips is explicitly verified alongside getState() and
counts; reference the breaker.reset(), breaker.getStats(),
stats.lastFailureTime, and stats.totalTrips symbols when making the assertions.

});

// ============================================================
// Full lifecycle
// ============================================================
describe('full lifecycle', () => {
test('CLOSED -> OPEN -> HALF_OPEN -> CLOSED', () => {
// 1. Start CLOSED
expect(breaker.getState()).toBe(STATE_CLOSED);

// 2. Trip to OPEN
for (let i = 0; i < 5; i++) breaker.recordFailure();
expect(breaker.getState()).toBe(STATE_OPEN);

// 3. Wait and transition to HALF_OPEN
breaker._lastFailureTime = Date.now() - 61000;
breaker.isAllowed();
expect(breaker.getState()).toBe(STATE_HALF_OPEN);

// 4. Success probe closes circuit
breaker.recordSuccess();
breaker._halfOpenProbeInFlight = true;
breaker.recordSuccess();
breaker._halfOpenProbeInFlight = true;
Comment on lines +233 to +237

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this lifecycle test, manually toggling _halfOpenProbeInFlight between recordSuccess() calls bypasses the actual probe-gating logic in isAllowed() and doesn’t exercise the intended HALF_OPEN flow. Consider calling isAllowed() before each probe attempt (or using fake timers) so each success corresponds to an allowed probe and the test validates the real state machine behavior end-to-end.

Suggested change
// 4. Success probe closes circuit
breaker.recordSuccess();
breaker._halfOpenProbeInFlight = true;
breaker.recordSuccess();
breaker._halfOpenProbeInFlight = true;
// 4. Success probes close circuit via allowed HALF_OPEN probes
expect(breaker.isAllowed()).toBe(true);
breaker.recordSuccess();
expect(breaker.isAllowed()).toBe(true);
breaker.recordSuccess();
expect(breaker.isAllowed()).toBe(true);

Copilot uses AI. Check for mistakes.
breaker.recordSuccess();
Comment on lines +234 to +238

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Lifecycle test bypasses isAllowed() for the 2nd and 3rd probe cycles.

After the first recordSuccess() at line 234 (which follows the legitimate isAllowed() at line 230), the test manually resets _halfOpenProbeInFlight = true at lines 235 and 237 instead of calling isAllowed() again. This skips exercising whether isAllowed() correctly re-gates probes after a success in HALF_OPEN. A bug where isAllowed() in HALF_OPEN mis-gates subsequent probes would not be caught.

The probe cycle should follow the real usage pattern: isAllowed() → service call → recordSuccess().

🛡️ Corrected probe sequence for the lifecycle test
   // 4. Success probe closes circuit
-  breaker.recordSuccess();
-  breaker._halfOpenProbeInFlight = true;
-  breaker.recordSuccess();
-  breaker._halfOpenProbeInFlight = true;
-  breaker.recordSuccess();
+  // Probe 1: isAllowed() was already called above (line 230), probe is in flight
+  breaker.recordSuccess(); // success 1
+  // Probe 2
+  expect(breaker.isAllowed()).toBe(true); // should allow next probe
+  breaker.recordSuccess(); // success 2
+  // Probe 3
+  expect(breaker.isAllowed()).toBe(true); // should allow next probe
+  breaker.recordSuccess(); // success 3 → closes circuit
   expect(breaker.getState()).toBe(STATE_CLOSED);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
breaker.recordSuccess();
breaker._halfOpenProbeInFlight = true;
breaker.recordSuccess();
breaker._halfOpenProbeInFlight = true;
breaker.recordSuccess();
// Probe 1: isAllowed() was already called above (line 230), probe is in flight
breaker.recordSuccess(); // success 1
// Probe 2
expect(breaker.isAllowed()).toBe(true); // should allow next probe
breaker.recordSuccess(); // success 2
// Probe 3
expect(breaker.isAllowed()).toBe(true); // should allow next probe
breaker.recordSuccess(); // success 3 → closes circuit
expect(breaker.getState()).toBe(STATE_CLOSED);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/ids/circuit-breaker.test.js` around lines 234 - 238, The test
currently bypasses the public gating logic by setting
breaker._halfOpenProbeInFlight = true directly between breaker.recordSuccess()
calls; change the test to drive probe cycles via the public isAllowed() method
instead (i.e., call breaker.isAllowed() before each simulated service call and
only then call breaker.recordSuccess()), so the HALF_OPEN gating logic is
exercised rather than mutating the internal _halfOpenProbeInFlight flag
directly.

expect(breaker.getState()).toBe(STATE_CLOSED);
});

test('CLOSED -> OPEN -> HALF_OPEN -> OPEN (failure in half-open)', () => {
for (let i = 0; i < 5; i++) breaker.recordFailure();
breaker._lastFailureTime = Date.now() - 61000;
breaker.isAllowed(); // HALF_OPEN

breaker.recordFailure(); // re-opens
expect(breaker.getState()).toBe(STATE_OPEN);
expect(breaker.getStats().totalTrips).toBe(2);
});
});
});
Loading