@actagent/nostr 2026.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- package/actagent.plugin.json +17 -0
- package/api.ts +11 -0
- package/channel-plugin-api.ts +2 -0
- package/doctor-contract-api.test.ts +105 -0
- package/doctor-contract-api.ts +297 -0
- package/index.ts +96 -0
- package/npm-shrinkwrap.json +137 -0
- package/package.json +67 -0
- package/runtime-api.ts +6 -0
- package/setup-api.ts +2 -0
- package/setup-entry.ts +10 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +12 -0
- package/src/channel.inbound.test.ts +203 -0
- package/src/channel.lifecycle.test.ts +97 -0
- package/src/channel.outbound.test.ts +175 -0
- package/src/channel.setup.ts +161 -0
- package/src/channel.test.ts +527 -0
- package/src/channel.ts +215 -0
- package/src/config-schema.ts +99 -0
- package/src/default-relays.ts +2 -0
- package/src/gateway.ts +338 -0
- package/src/inbound-direct-dm-runtime.ts +2 -0
- package/src/metrics.ts +454 -0
- package/src/nostr-bus.fuzz.test.ts +383 -0
- package/src/nostr-bus.inbound.test.ts +598 -0
- package/src/nostr-bus.integration.test.ts +491 -0
- package/src/nostr-bus.test.ts +256 -0
- package/src/nostr-bus.ts +799 -0
- package/src/nostr-key-utils.ts +93 -0
- package/src/nostr-profile-core.ts +135 -0
- package/src/nostr-profile-http-runtime.ts +7 -0
- package/src/nostr-profile-http.test.ts +632 -0
- package/src/nostr-profile-http.ts +583 -0
- package/src/nostr-profile-import.test.ts +196 -0
- package/src/nostr-profile-import.ts +273 -0
- package/src/nostr-profile-url-safety.ts +22 -0
- package/src/nostr-profile.fuzz.test.ts +431 -0
- package/src/nostr-profile.test.ts +416 -0
- package/src/nostr-profile.ts +144 -0
- package/src/nostr-state-store.test.ts +172 -0
- package/src/nostr-state-store.ts +132 -0
- package/src/runtime.ts +10 -0
- package/src/seen-tracker.ts +291 -0
- package/src/session-route.ts +26 -0
- package/src/setup-adapter.ts +86 -0
- package/src/setup-surface.ts +204 -0
- package/src/test-fixtures.ts +46 -0
- package/src/types.ts +118 -0
- package/test/setup.ts +5 -0
- package/test-api.ts +2 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// Nostr tests cover nostr bus.integration plugin behavior.
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createMetrics, createNoopMetrics, type MetricEvent } from "./metrics.js";
|
|
4
|
+
import { createSeenTracker } from "./seen-tracker.js";
|
|
5
|
+
import { TEST_RELAY_URL } from "./test-fixtures.js";
|
|
6
|
+
|
|
7
|
+
const TEST_RELAY_URL_1 = "wss://relay1.com";
|
|
8
|
+
const TEST_RELAY_URL_2 = "wss://relay2.com";
|
|
9
|
+
const TEST_RELAY_URL_PRIMARY = "wss://relay.com";
|
|
10
|
+
const TEST_RELAY_URL_GOOD = "wss://good-relay.com";
|
|
11
|
+
const TEST_RELAY_URL_BAD = "wss://bad-relay.com";
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function createTracker(overrides?: Partial<Parameters<typeof createSeenTracker>[0]>) {
|
|
18
|
+
return createSeenTracker({
|
|
19
|
+
maxEntries: 100,
|
|
20
|
+
ttlMs: 60000,
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createCollectingMetrics() {
|
|
26
|
+
const events: MetricEvent[] = [];
|
|
27
|
+
return {
|
|
28
|
+
events,
|
|
29
|
+
metrics: createMetrics((event) => events.push(event)),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createPlainMetrics() {
|
|
34
|
+
return createMetrics();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Seen Tracker Integration Tests
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
describe("SeenTracker", () => {
|
|
42
|
+
describe("basic operations", () => {
|
|
43
|
+
it("tracks seen IDs", () => {
|
|
44
|
+
const tracker = createTracker();
|
|
45
|
+
|
|
46
|
+
// First check returns false and adds
|
|
47
|
+
expect(tracker.has("id1")).toBe(false);
|
|
48
|
+
// Second check returns true (already seen)
|
|
49
|
+
expect(tracker.has("id1")).toBe(true);
|
|
50
|
+
|
|
51
|
+
tracker.stop();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("peek does not add", () => {
|
|
55
|
+
const tracker = createTracker();
|
|
56
|
+
|
|
57
|
+
expect(tracker.peek("id1")).toBe(false);
|
|
58
|
+
expect(tracker.peek("id1")).toBe(false); // Still false
|
|
59
|
+
|
|
60
|
+
tracker.add("id1");
|
|
61
|
+
expect(tracker.peek("id1")).toBe(true);
|
|
62
|
+
|
|
63
|
+
tracker.stop();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("delete removes entries", () => {
|
|
67
|
+
const tracker = createTracker();
|
|
68
|
+
|
|
69
|
+
tracker.add("id1");
|
|
70
|
+
expect(tracker.peek("id1")).toBe(true);
|
|
71
|
+
|
|
72
|
+
tracker.delete("id1");
|
|
73
|
+
expect(tracker.peek("id1")).toBe(false);
|
|
74
|
+
|
|
75
|
+
tracker.stop();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clear removes all entries", () => {
|
|
79
|
+
const tracker = createTracker();
|
|
80
|
+
|
|
81
|
+
tracker.add("id1");
|
|
82
|
+
tracker.add("id2");
|
|
83
|
+
tracker.add("id3");
|
|
84
|
+
expect(tracker.size()).toBe(3);
|
|
85
|
+
|
|
86
|
+
tracker.clear();
|
|
87
|
+
expect(tracker.size()).toBe(0);
|
|
88
|
+
expect(tracker.peek("id1")).toBe(false);
|
|
89
|
+
|
|
90
|
+
tracker.stop();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("seed pre-populates entries", () => {
|
|
94
|
+
const tracker = createTracker();
|
|
95
|
+
|
|
96
|
+
tracker.seed(["id1", "id2", "id3"]);
|
|
97
|
+
expect(tracker.size()).toBe(3);
|
|
98
|
+
expect(tracker.peek("id1")).toBe(true);
|
|
99
|
+
expect(tracker.peek("id2")).toBe(true);
|
|
100
|
+
expect(tracker.peek("id3")).toBe(true);
|
|
101
|
+
|
|
102
|
+
tracker.stop();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("LRU eviction", () => {
|
|
107
|
+
it("evicts least recently used when at capacity", () => {
|
|
108
|
+
const tracker = createTracker({ maxEntries: 3 });
|
|
109
|
+
|
|
110
|
+
tracker.add("id1");
|
|
111
|
+
tracker.add("id2");
|
|
112
|
+
tracker.add("id3");
|
|
113
|
+
expect(tracker.size()).toBe(3);
|
|
114
|
+
|
|
115
|
+
// Adding fourth should evict oldest (id1)
|
|
116
|
+
tracker.add("id4");
|
|
117
|
+
expect(tracker.size()).toBe(3);
|
|
118
|
+
expect(tracker.peek("id1")).toBe(false); // Evicted
|
|
119
|
+
expect(tracker.peek("id2")).toBe(true);
|
|
120
|
+
expect(tracker.peek("id3")).toBe(true);
|
|
121
|
+
expect(tracker.peek("id4")).toBe(true);
|
|
122
|
+
|
|
123
|
+
tracker.stop();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("accessing an entry moves it to front (prevents eviction)", () => {
|
|
127
|
+
const tracker = createTracker({ maxEntries: 3 });
|
|
128
|
+
|
|
129
|
+
tracker.add("id1");
|
|
130
|
+
tracker.add("id2");
|
|
131
|
+
tracker.add("id3");
|
|
132
|
+
|
|
133
|
+
// Access id1, moving it to front
|
|
134
|
+
tracker.has("id1");
|
|
135
|
+
|
|
136
|
+
// Add id4 - should evict id2 (now oldest)
|
|
137
|
+
tracker.add("id4");
|
|
138
|
+
expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed
|
|
139
|
+
expect(tracker.peek("id2")).toBe(false); // Evicted
|
|
140
|
+
expect(tracker.peek("id3")).toBe(true);
|
|
141
|
+
expect(tracker.peek("id4")).toBe(true);
|
|
142
|
+
|
|
143
|
+
tracker.stop();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("handles capacity of 1", () => {
|
|
147
|
+
const tracker = createTracker({ maxEntries: 1 });
|
|
148
|
+
|
|
149
|
+
tracker.add("id1");
|
|
150
|
+
expect(tracker.peek("id1")).toBe(true);
|
|
151
|
+
|
|
152
|
+
tracker.add("id2");
|
|
153
|
+
expect(tracker.peek("id1")).toBe(false);
|
|
154
|
+
expect(tracker.peek("id2")).toBe(true);
|
|
155
|
+
|
|
156
|
+
tracker.stop();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("seed respects maxEntries", () => {
|
|
160
|
+
const tracker = createTracker({ maxEntries: 2 });
|
|
161
|
+
|
|
162
|
+
tracker.seed(["id1", "id2", "id3", "id4"]);
|
|
163
|
+
expect(tracker.size()).toBe(2);
|
|
164
|
+
// Seed stops when maxEntries reached, processing from end to start
|
|
165
|
+
// So id4 and id3 get added first, then we're at capacity
|
|
166
|
+
expect(tracker.peek("id3")).toBe(true);
|
|
167
|
+
expect(tracker.peek("id4")).toBe(true);
|
|
168
|
+
|
|
169
|
+
tracker.stop();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("keeps non-positive capacities usable", () => {
|
|
173
|
+
const tracker = createTracker({ maxEntries: 0 });
|
|
174
|
+
|
|
175
|
+
tracker.add("id1");
|
|
176
|
+
tracker.add("id2");
|
|
177
|
+
|
|
178
|
+
expect(tracker.size()).toBe(1);
|
|
179
|
+
expect(tracker.peek("id1")).toBe(false);
|
|
180
|
+
expect(tracker.peek("id2")).toBe(true);
|
|
181
|
+
|
|
182
|
+
tracker.stop();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("TTL expiration", () => {
|
|
187
|
+
it("expires entries after TTL", () => {
|
|
188
|
+
vi.useFakeTimers();
|
|
189
|
+
|
|
190
|
+
const tracker = createTracker({
|
|
191
|
+
maxEntries: 100,
|
|
192
|
+
ttlMs: 100,
|
|
193
|
+
pruneIntervalMs: 50,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
tracker.add("id1");
|
|
197
|
+
expect(tracker.peek("id1")).toBe(true);
|
|
198
|
+
|
|
199
|
+
// Advance past TTL
|
|
200
|
+
vi.advanceTimersByTime(150);
|
|
201
|
+
|
|
202
|
+
// Entry should be expired
|
|
203
|
+
expect(tracker.peek("id1")).toBe(false);
|
|
204
|
+
|
|
205
|
+
tracker.stop();
|
|
206
|
+
vi.useRealTimers();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("has() refreshes TTL", () => {
|
|
210
|
+
vi.useFakeTimers();
|
|
211
|
+
|
|
212
|
+
const tracker = createTracker({
|
|
213
|
+
maxEntries: 100,
|
|
214
|
+
ttlMs: 100,
|
|
215
|
+
pruneIntervalMs: 50,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
tracker.add("id1");
|
|
219
|
+
|
|
220
|
+
// Advance halfway
|
|
221
|
+
vi.advanceTimersByTime(50);
|
|
222
|
+
|
|
223
|
+
// Access to refresh
|
|
224
|
+
expect(tracker.has("id1")).toBe(true);
|
|
225
|
+
|
|
226
|
+
// Advance another 75ms (total 125ms from add, but only 75ms from last access)
|
|
227
|
+
vi.advanceTimersByTime(75);
|
|
228
|
+
|
|
229
|
+
// Should still be valid (refreshed at 50ms)
|
|
230
|
+
expect(tracker.peek("id1")).toBe(true);
|
|
231
|
+
|
|
232
|
+
tracker.stop();
|
|
233
|
+
vi.useRealTimers();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ============================================================================
|
|
239
|
+
// Metrics Integration Tests
|
|
240
|
+
// ============================================================================
|
|
241
|
+
|
|
242
|
+
describe("Metrics", () => {
|
|
243
|
+
describe("createMetrics", () => {
|
|
244
|
+
it("emits metric events to callback", () => {
|
|
245
|
+
const { events, metrics } = createCollectingMetrics();
|
|
246
|
+
|
|
247
|
+
metrics.emit("event.received");
|
|
248
|
+
metrics.emit("event.processed");
|
|
249
|
+
metrics.emit("event.duplicate");
|
|
250
|
+
|
|
251
|
+
expect(events).toHaveLength(3);
|
|
252
|
+
expect(events[0].name).toBe("event.received");
|
|
253
|
+
expect(events[1].name).toBe("event.processed");
|
|
254
|
+
expect(events[2].name).toBe("event.duplicate");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("includes labels in metric events", () => {
|
|
258
|
+
const { events, metrics } = createCollectingMetrics();
|
|
259
|
+
|
|
260
|
+
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL });
|
|
261
|
+
|
|
262
|
+
expect(events[0].labels).toEqual({ relay: TEST_RELAY_URL });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("accumulates counters in snapshot", () => {
|
|
266
|
+
const metrics = createPlainMetrics();
|
|
267
|
+
|
|
268
|
+
metrics.emit("event.received");
|
|
269
|
+
metrics.emit("event.received");
|
|
270
|
+
metrics.emit("event.processed");
|
|
271
|
+
metrics.emit("event.duplicate");
|
|
272
|
+
metrics.emit("event.duplicate");
|
|
273
|
+
metrics.emit("event.duplicate");
|
|
274
|
+
|
|
275
|
+
const snapshot = metrics.getSnapshot();
|
|
276
|
+
expect(snapshot.eventsReceived).toBe(2);
|
|
277
|
+
expect(snapshot.eventsProcessed).toBe(1);
|
|
278
|
+
expect(snapshot.eventsDuplicate).toBe(3);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("tracks per-relay stats", () => {
|
|
282
|
+
const metrics = createPlainMetrics();
|
|
283
|
+
|
|
284
|
+
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_1 });
|
|
285
|
+
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_2 });
|
|
286
|
+
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
|
|
287
|
+
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
|
|
288
|
+
|
|
289
|
+
const snapshot = metrics.getSnapshot();
|
|
290
|
+
const relayOne = snapshot.relays[TEST_RELAY_URL_1];
|
|
291
|
+
if (!relayOne) {
|
|
292
|
+
throw new Error("expected first relay metrics");
|
|
293
|
+
}
|
|
294
|
+
expect(relayOne.connects).toBe(1);
|
|
295
|
+
expect(relayOne.errors).toBe(2);
|
|
296
|
+
expect(snapshot.relays[TEST_RELAY_URL_2].connects).toBe(1);
|
|
297
|
+
expect(snapshot.relays[TEST_RELAY_URL_2].errors).toBe(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("tracks circuit breaker state changes", () => {
|
|
301
|
+
const metrics = createPlainMetrics();
|
|
302
|
+
|
|
303
|
+
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
304
|
+
|
|
305
|
+
let snapshot = metrics.getSnapshot();
|
|
306
|
+
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("open");
|
|
307
|
+
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerOpens).toBe(1);
|
|
308
|
+
|
|
309
|
+
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
310
|
+
|
|
311
|
+
snapshot = metrics.getSnapshot();
|
|
312
|
+
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("closed");
|
|
313
|
+
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerCloses).toBe(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("tracks all rejection reasons", () => {
|
|
317
|
+
const metrics = createPlainMetrics();
|
|
318
|
+
|
|
319
|
+
metrics.emit("event.rejected.invalid_shape");
|
|
320
|
+
metrics.emit("event.rejected.wrong_kind");
|
|
321
|
+
metrics.emit("event.rejected.stale");
|
|
322
|
+
metrics.emit("event.rejected.future");
|
|
323
|
+
metrics.emit("event.rejected.rate_limited");
|
|
324
|
+
metrics.emit("event.rejected.invalid_signature");
|
|
325
|
+
metrics.emit("event.rejected.oversized_ciphertext");
|
|
326
|
+
metrics.emit("event.rejected.oversized_plaintext");
|
|
327
|
+
metrics.emit("event.rejected.decrypt_failed");
|
|
328
|
+
metrics.emit("event.rejected.self_message");
|
|
329
|
+
|
|
330
|
+
const snapshot = metrics.getSnapshot();
|
|
331
|
+
expect(snapshot.eventsRejected.invalidShape).toBe(1);
|
|
332
|
+
expect(snapshot.eventsRejected.wrongKind).toBe(1);
|
|
333
|
+
expect(snapshot.eventsRejected.stale).toBe(1);
|
|
334
|
+
expect(snapshot.eventsRejected.future).toBe(1);
|
|
335
|
+
expect(snapshot.eventsRejected.rateLimited).toBe(1);
|
|
336
|
+
expect(snapshot.eventsRejected.invalidSignature).toBe(1);
|
|
337
|
+
expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
|
|
338
|
+
expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1);
|
|
339
|
+
expect(snapshot.eventsRejected.decryptFailed).toBe(1);
|
|
340
|
+
expect(snapshot.eventsRejected.selfMessage).toBe(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("tracks relay message types", () => {
|
|
344
|
+
const metrics = createPlainMetrics();
|
|
345
|
+
|
|
346
|
+
metrics.emit("relay.message.event", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
347
|
+
metrics.emit("relay.message.eose", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
348
|
+
metrics.emit("relay.message.closed", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
349
|
+
metrics.emit("relay.message.notice", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
350
|
+
metrics.emit("relay.message.ok", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
351
|
+
metrics.emit("relay.message.auth", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
352
|
+
|
|
353
|
+
const snapshot = metrics.getSnapshot();
|
|
354
|
+
const relay = snapshot.relays[TEST_RELAY_URL_PRIMARY];
|
|
355
|
+
expect(relay.messagesReceived.event).toBe(1);
|
|
356
|
+
expect(relay.messagesReceived.eose).toBe(1);
|
|
357
|
+
expect(relay.messagesReceived.closed).toBe(1);
|
|
358
|
+
expect(relay.messagesReceived.notice).toBe(1);
|
|
359
|
+
expect(relay.messagesReceived.ok).toBe(1);
|
|
360
|
+
expect(relay.messagesReceived.auth).toBe(1);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("tracks decrypt success/failure", () => {
|
|
364
|
+
const metrics = createPlainMetrics();
|
|
365
|
+
|
|
366
|
+
metrics.emit("decrypt.success");
|
|
367
|
+
metrics.emit("decrypt.success");
|
|
368
|
+
metrics.emit("decrypt.failure");
|
|
369
|
+
|
|
370
|
+
const snapshot = metrics.getSnapshot();
|
|
371
|
+
expect(snapshot.decrypt.success).toBe(2);
|
|
372
|
+
expect(snapshot.decrypt.failure).toBe(1);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("tracks memory gauges (replaces rather than accumulates)", () => {
|
|
376
|
+
const metrics = createPlainMetrics();
|
|
377
|
+
|
|
378
|
+
metrics.emit("memory.seen_tracker_size", 100);
|
|
379
|
+
metrics.emit("memory.seen_tracker_size", 150);
|
|
380
|
+
metrics.emit("memory.seen_tracker_size", 125);
|
|
381
|
+
|
|
382
|
+
const snapshot = metrics.getSnapshot();
|
|
383
|
+
expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("reset clears all counters", () => {
|
|
387
|
+
const metrics = createPlainMetrics();
|
|
388
|
+
|
|
389
|
+
metrics.emit("event.received");
|
|
390
|
+
metrics.emit("event.processed");
|
|
391
|
+
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
392
|
+
|
|
393
|
+
metrics.reset();
|
|
394
|
+
|
|
395
|
+
const snapshot = metrics.getSnapshot();
|
|
396
|
+
expect(snapshot.eventsReceived).toBe(0);
|
|
397
|
+
expect(snapshot.eventsProcessed).toBe(0);
|
|
398
|
+
expect(Object.keys(snapshot.relays)).toHaveLength(0);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("createNoopMetrics", () => {
|
|
403
|
+
it("ignores emitted metrics", () => {
|
|
404
|
+
const metrics = createNoopMetrics();
|
|
405
|
+
|
|
406
|
+
expect(metrics.emit("event.received")).toBeUndefined();
|
|
407
|
+
expect(metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY })).toBeUndefined();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("returns empty snapshot", () => {
|
|
411
|
+
const metrics = createNoopMetrics();
|
|
412
|
+
|
|
413
|
+
const snapshot = metrics.getSnapshot();
|
|
414
|
+
expect(snapshot.eventsReceived).toBe(0);
|
|
415
|
+
expect(snapshot.eventsProcessed).toBe(0);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ============================================================================
|
|
421
|
+
// Circuit Breaker Behavior Tests
|
|
422
|
+
// ============================================================================
|
|
423
|
+
|
|
424
|
+
describe("Circuit Breaker Behavior", () => {
|
|
425
|
+
// Test the circuit breaker logic through metrics emissions
|
|
426
|
+
it("emits circuit breaker metrics in correct sequence", () => {
|
|
427
|
+
const { events, metrics } = createCollectingMetrics();
|
|
428
|
+
|
|
429
|
+
// Simulate 5 failures -> open
|
|
430
|
+
for (let i = 0; i < 5; i++) {
|
|
431
|
+
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
432
|
+
}
|
|
433
|
+
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
434
|
+
|
|
435
|
+
// Simulate recovery
|
|
436
|
+
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
437
|
+
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
|
438
|
+
|
|
439
|
+
const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker"));
|
|
440
|
+
expect(cbEvents).toHaveLength(3);
|
|
441
|
+
expect(cbEvents[0].name).toBe("relay.circuit_breaker.open");
|
|
442
|
+
expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open");
|
|
443
|
+
expect(cbEvents[2].name).toBe("relay.circuit_breaker.close");
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ============================================================================
|
|
448
|
+
// Health Scoring Behavior Tests
|
|
449
|
+
// ============================================================================
|
|
450
|
+
|
|
451
|
+
describe("Health Scoring", () => {
|
|
452
|
+
it("metrics track relay errors for health scoring", () => {
|
|
453
|
+
const metrics = createPlainMetrics();
|
|
454
|
+
|
|
455
|
+
// Simulate mixed success/failure pattern
|
|
456
|
+
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_GOOD });
|
|
457
|
+
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_BAD });
|
|
458
|
+
|
|
459
|
+
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
|
460
|
+
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
|
461
|
+
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
|
462
|
+
|
|
463
|
+
const snapshot = metrics.getSnapshot();
|
|
464
|
+
expect(snapshot.relays[TEST_RELAY_URL_GOOD].errors).toBe(0);
|
|
465
|
+
expect(snapshot.relays[TEST_RELAY_URL_BAD].errors).toBe(3);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ============================================================================
|
|
470
|
+
// Reconnect Backoff Tests
|
|
471
|
+
// ============================================================================
|
|
472
|
+
|
|
473
|
+
describe("Reconnect Backoff", () => {
|
|
474
|
+
it("computes delays within expected bounds", () => {
|
|
475
|
+
// Compute expected delays (1s, 2s, 4s, 8s, 16s, 32s, 60s cap)
|
|
476
|
+
const BASE = 1000;
|
|
477
|
+
const MAX = 60000;
|
|
478
|
+
const JITTER = 0.3;
|
|
479
|
+
|
|
480
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
481
|
+
const exponential = BASE * 2 ** attempt;
|
|
482
|
+
const capped = Math.min(exponential, MAX);
|
|
483
|
+
const minDelay = capped * (1 - JITTER);
|
|
484
|
+
const maxDelay = capped * (1 + JITTER);
|
|
485
|
+
|
|
486
|
+
// These are the expected bounds
|
|
487
|
+
expect(minDelay).toBeGreaterThanOrEqual(BASE * 0.7);
|
|
488
|
+
expect(maxDelay).toBeLessThanOrEqual(MAX * 1.3);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|