import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { createSeenTracker } from "./seen-tracker.js"; import { createMetrics, createNoopMetrics, type MetricEvent, } from "./metrics.js"; // ============================================================================ // Seen Tracker Integration Tests // ============================================================================ describe("SeenTracker", () => { describe("basic operations", () => { it("tracks seen IDs", () => { const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); // First check returns false and adds expect(tracker.has("id1")).toBe(false); // Second check returns true (already seen) expect(tracker.has("id1")).toBe(true); tracker.stop(); }); it("peek does not add", () => { const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); expect(tracker.peek("id1")).toBe(false); expect(tracker.peek("id1")).toBe(false); // Still false tracker.add("id1"); expect(tracker.peek("id1")).toBe(true); tracker.stop(); }); it("delete removes entries", () => { const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); tracker.add("id1"); expect(tracker.peek("id1")).toBe(true); tracker.delete("id1"); expect(tracker.peek("id1")).toBe(false); tracker.stop(); }); it("clear removes all entries", () => { const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); tracker.add("id1"); tracker.add("id2"); tracker.add("id3"); expect(tracker.size()).toBe(3); tracker.clear(); expect(tracker.size()).toBe(0); expect(tracker.peek("id1")).toBe(false); tracker.stop(); }); it("seed pre-populates entries", () => { const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); tracker.seed(["id1", "id2", "id3"]); expect(tracker.size()).toBe(3); expect(tracker.peek("id1")).toBe(true); expect(tracker.peek("id2")).toBe(true); expect(tracker.peek("id3")).toBe(true); tracker.stop(); }); }); describe("LRU eviction", () => { it("evicts least recently used when at capacity", () => { const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 }); tracker.add("id1"); tracker.add("id2"); tracker.add("id3"); expect(tracker.size()).toBe(3); // Adding fourth should evict oldest (id1) tracker.add("id4"); expect(tracker.size()).toBe(3); expect(tracker.peek("id1")).toBe(false); // Evicted expect(tracker.peek("id2")).toBe(true); expect(tracker.peek("id3")).toBe(true); expect(tracker.peek("id4")).toBe(true); tracker.stop(); }); it("accessing an entry moves it to front (prevents eviction)", () => { const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 }); tracker.add("id1"); tracker.add("id2"); tracker.add("id3"); // Access id1, moving it to front tracker.has("id1"); // Add id4 - should evict id2 (now oldest) tracker.add("id4"); expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed expect(tracker.peek("id2")).toBe(false); // Evicted expect(tracker.peek("id3")).toBe(true); expect(tracker.peek("id4")).toBe(true); tracker.stop(); }); it("handles capacity of 1", () => { const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 }); tracker.add("id1"); expect(tracker.peek("id1")).toBe(true); tracker.add("id2"); expect(tracker.peek("id1")).toBe(false); expect(tracker.peek("id2")).toBe(true); tracker.stop(); }); it("seed respects maxEntries", () => { const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 }); tracker.seed(["id1", "id2", "id3", "id4"]); expect(tracker.size()).toBe(2); // Seed stops when maxEntries reached, processing from end to start // So id4 and id3 get added first, then we're at capacity expect(tracker.peek("id3")).toBe(true); expect(tracker.peek("id4")).toBe(true); tracker.stop(); }); }); describe("TTL expiration", () => { it("expires entries after TTL", async () => { vi.useFakeTimers(); const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 100, pruneIntervalMs: 50, }); tracker.add("id1"); expect(tracker.peek("id1")).toBe(true); // Advance past TTL vi.advanceTimersByTime(150); // Entry should be expired expect(tracker.peek("id1")).toBe(false); tracker.stop(); vi.useRealTimers(); }); it("has() refreshes TTL", async () => { vi.useFakeTimers(); const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 100, pruneIntervalMs: 50, }); tracker.add("id1"); // Advance halfway vi.advanceTimersByTime(50); // Access to refresh expect(tracker.has("id1")).toBe(true); // Advance another 75ms (total 125ms from add, but only 75ms from last access) vi.advanceTimersByTime(75); // Should still be valid (refreshed at 50ms) expect(tracker.peek("id1")).toBe(true); tracker.stop(); vi.useRealTimers(); }); }); }); // ============================================================================ // Metrics Integration Tests // ============================================================================ describe("Metrics", () => { describe("createMetrics", () => { it("emits metric events to callback", () => { const events: MetricEvent[] = []; const metrics = createMetrics((event) => events.push(event)); metrics.emit("event.received"); metrics.emit("event.processed"); metrics.emit("event.duplicate"); expect(events).toHaveLength(3); expect(events[0].name).toBe("event.received"); expect(events[1].name).toBe("event.processed"); expect(events[2].name).toBe("event.duplicate"); }); it("includes labels in metric events", () => { const events: MetricEvent[] = []; const metrics = createMetrics((event) => events.push(event)); metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" }); expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" }); }); it("accumulates counters in snapshot", () => { const metrics = createMetrics(); metrics.emit("event.received"); metrics.emit("event.received"); metrics.emit("event.processed"); metrics.emit("event.duplicate"); metrics.emit("event.duplicate"); metrics.emit("event.duplicate"); const snapshot = metrics.getSnapshot(); expect(snapshot.eventsReceived).toBe(2); expect(snapshot.eventsProcessed).toBe(1); expect(snapshot.eventsDuplicate).toBe(3); }); it("tracks per-relay stats", () => { const metrics = createMetrics(); metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" }); metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" }); metrics.emit("relay.error", 1, { relay: "wss://relay1.com" }); metrics.emit("relay.error", 1, { relay: "wss://relay1.com" }); const snapshot = metrics.getSnapshot(); expect(snapshot.relays["wss://relay1.com"]).toBeDefined(); expect(snapshot.relays["wss://relay1.com"].connects).toBe(1); expect(snapshot.relays["wss://relay1.com"].errors).toBe(2); expect(snapshot.relays["wss://relay2.com"].connects).toBe(1); expect(snapshot.relays["wss://relay2.com"].errors).toBe(0); }); it("tracks circuit breaker state changes", () => { const metrics = createMetrics(); metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" }); let snapshot = metrics.getSnapshot(); expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open"); expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1); metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" }); snapshot = metrics.getSnapshot(); expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed"); expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1); }); it("tracks all rejection reasons", () => { const metrics = createMetrics(); metrics.emit("event.rejected.invalid_shape"); metrics.emit("event.rejected.wrong_kind"); metrics.emit("event.rejected.stale"); metrics.emit("event.rejected.future"); metrics.emit("event.rejected.rate_limited"); metrics.emit("event.rejected.invalid_signature"); metrics.emit("event.rejected.oversized_ciphertext"); metrics.emit("event.rejected.oversized_plaintext"); metrics.emit("event.rejected.decrypt_failed"); metrics.emit("event.rejected.self_message"); const snapshot = metrics.getSnapshot(); expect(snapshot.eventsRejected.invalidShape).toBe(1); expect(snapshot.eventsRejected.wrongKind).toBe(1); expect(snapshot.eventsRejected.stale).toBe(1); expect(snapshot.eventsRejected.future).toBe(1); expect(snapshot.eventsRejected.rateLimited).toBe(1); expect(snapshot.eventsRejected.invalidSignature).toBe(1); expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1); expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1); expect(snapshot.eventsRejected.decryptFailed).toBe(1); expect(snapshot.eventsRejected.selfMessage).toBe(1); }); it("tracks relay message types", () => { const metrics = createMetrics(); metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" }); metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" }); metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" }); metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" }); metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" }); metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" }); const snapshot = metrics.getSnapshot(); const relay = snapshot.relays["wss://relay.com"]; expect(relay.messagesReceived.event).toBe(1); expect(relay.messagesReceived.eose).toBe(1); expect(relay.messagesReceived.closed).toBe(1); expect(relay.messagesReceived.notice).toBe(1); expect(relay.messagesReceived.ok).toBe(1); expect(relay.messagesReceived.auth).toBe(1); }); it("tracks decrypt success/failure", () => { const metrics = createMetrics(); metrics.emit("decrypt.success"); metrics.emit("decrypt.success"); metrics.emit("decrypt.failure"); const snapshot = metrics.getSnapshot(); expect(snapshot.decrypt.success).toBe(2); expect(snapshot.decrypt.failure).toBe(1); }); it("tracks memory gauges (replaces rather than accumulates)", () => { const metrics = createMetrics(); metrics.emit("memory.seen_tracker_size", 100); metrics.emit("memory.seen_tracker_size", 150); metrics.emit("memory.seen_tracker_size", 125); const snapshot = metrics.getSnapshot(); expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum }); it("reset clears all counters", () => { const metrics = createMetrics(); metrics.emit("event.received"); metrics.emit("event.processed"); metrics.emit("relay.connect", 1, { relay: "wss://relay.com" }); metrics.reset(); const snapshot = metrics.getSnapshot(); expect(snapshot.eventsReceived).toBe(0); expect(snapshot.eventsProcessed).toBe(0); expect(Object.keys(snapshot.relays)).toHaveLength(0); }); }); describe("createNoopMetrics", () => { it("does not throw on emit", () => { const metrics = createNoopMetrics(); expect(() => { metrics.emit("event.received"); metrics.emit("relay.connect", 1, { relay: "wss://relay.com" }); }).not.toThrow(); }); it("returns empty snapshot", () => { const metrics = createNoopMetrics(); const snapshot = metrics.getSnapshot(); expect(snapshot.eventsReceived).toBe(0); expect(snapshot.eventsProcessed).toBe(0); }); }); }); // ============================================================================ // Circuit Breaker Behavior Tests // ============================================================================ describe("Circuit Breaker Behavior", () => { // Test the circuit breaker logic through metrics emissions it("emits circuit breaker metrics in correct sequence", () => { const events: MetricEvent[] = []; const metrics = createMetrics((event) => events.push(event)); // Simulate 5 failures -> open for (let i = 0; i < 5; i++) { metrics.emit("relay.error", 1, { relay: "wss://relay.com" }); } metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" }); // Simulate recovery metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" }); metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" }); const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker")); expect(cbEvents).toHaveLength(3); expect(cbEvents[0].name).toBe("relay.circuit_breaker.open"); expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open"); expect(cbEvents[2].name).toBe("relay.circuit_breaker.close"); }); }); // ============================================================================ // Health Scoring Behavior Tests // ============================================================================ describe("Health Scoring", () => { it("metrics track relay errors for health scoring", () => { const metrics = createMetrics(); // Simulate mixed success/failure pattern metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" }); metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" }); metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); const snapshot = metrics.getSnapshot(); expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0); expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3); }); }); // ============================================================================ // Reconnect Backoff Tests // ============================================================================ describe("Reconnect Backoff", () => { it("computes delays within expected bounds", () => { // Compute expected delays (1s, 2s, 4s, 8s, 16s, 32s, 60s cap) const BASE = 1000; const MAX = 60000; const JITTER = 0.3; for (let attempt = 0; attempt < 10; attempt++) { const exponential = BASE * Math.pow(2, attempt); const capped = Math.min(exponential, MAX); const minDelay = capped * (1 - JITTER); const maxDelay = capped * (1 + JITTER); // These are the expected bounds expect(minDelay).toBeGreaterThanOrEqual(BASE * 0.7); expect(maxDelay).toBeLessThanOrEqual(MAX * 1.3); } }); });