453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|