@nobulex/sdk 0.1.0
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/dist/adapters/express.d.ts +322 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/express.js +356 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +15 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/langchain.d.ts +183 -0
- package/dist/adapters/langchain.d.ts.map +1 -0
- package/dist/adapters/langchain.js +203 -0
- package/dist/adapters/langchain.js.map +1 -0
- package/dist/adapters/vercel-ai.d.ts +122 -0
- package/dist/adapters/vercel-ai.d.ts.map +1 -0
- package/dist/adapters/vercel-ai.js +128 -0
- package/dist/adapters/vercel-ai.js.map +1 -0
- package/dist/benchmarks.d.ts +164 -0
- package/dist/benchmarks.d.ts.map +1 -0
- package/dist/benchmarks.js +327 -0
- package/dist/benchmarks.js.map +1 -0
- package/dist/benchmarks.test.d.ts +2 -0
- package/dist/benchmarks.test.d.ts.map +1 -0
- package/dist/benchmarks.test.js +71 -0
- package/dist/benchmarks.test.js.map +1 -0
- package/dist/conformance.d.ts +160 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +1242 -0
- package/dist/conformance.js.map +1 -0
- package/dist/events.d.ts +176 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +208 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +695 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +986 -0
- package/dist/index.test.js.map +1 -0
- package/dist/middleware.d.ts +104 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +222 -0
- package/dist/middleware.js.map +1 -0
- package/dist/middleware.test.d.ts +5 -0
- package/dist/middleware.test.d.ts.map +1 -0
- package/dist/middleware.test.js +735 -0
- package/dist/middleware.test.js.map +1 -0
- package/dist/plugins/auth.d.ts +49 -0
- package/dist/plugins/auth.d.ts.map +1 -0
- package/dist/plugins/auth.js +82 -0
- package/dist/plugins/auth.js.map +1 -0
- package/dist/plugins/cache.d.ts +40 -0
- package/dist/plugins/cache.d.ts.map +1 -0
- package/dist/plugins/cache.js +191 -0
- package/dist/plugins/cache.js.map +1 -0
- package/dist/plugins/index.d.ts +16 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +12 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/metrics-plugin.d.ts +32 -0
- package/dist/plugins/metrics-plugin.d.ts.map +1 -0
- package/dist/plugins/metrics-plugin.js +61 -0
- package/dist/plugins/metrics-plugin.js.map +1 -0
- package/dist/plugins/plugins.test.d.ts +8 -0
- package/dist/plugins/plugins.test.d.ts.map +1 -0
- package/dist/plugins/plugins.test.js +640 -0
- package/dist/plugins/plugins.test.js.map +1 -0
- package/dist/plugins/retry-plugin.d.ts +55 -0
- package/dist/plugins/retry-plugin.d.ts.map +1 -0
- package/dist/plugins/retry-plugin.js +133 -0
- package/dist/plugins/retry-plugin.js.map +1 -0
- package/dist/telemetry.d.ts +183 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +241 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/types.d.ts +200 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Stele SDK middleware system.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import { Logger, LogLevel } from '@nobulex/types';
|
|
6
|
+
import { MiddlewarePipeline, loggingMiddleware, validationMiddleware, timingMiddleware, rateLimitMiddleware, } from './middleware.js';
|
|
7
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
8
|
+
/** Create a simple async operation that returns its input. */
|
|
9
|
+
function echoOp(value) {
|
|
10
|
+
return async () => value;
|
|
11
|
+
}
|
|
12
|
+
/** Create an async operation that throws. */
|
|
13
|
+
function failingOp(message) {
|
|
14
|
+
return async () => {
|
|
15
|
+
throw new Error(message);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Create a simple pass-through middleware for testing. */
|
|
19
|
+
function passthroughMiddleware(name) {
|
|
20
|
+
return { name };
|
|
21
|
+
}
|
|
22
|
+
/** Create a middleware that records calls for order verification. */
|
|
23
|
+
function orderTrackingMiddleware(name, calls) {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
async before(ctx) {
|
|
27
|
+
calls.push(`${name}:before`);
|
|
28
|
+
return { proceed: true };
|
|
29
|
+
},
|
|
30
|
+
async after(ctx, result) {
|
|
31
|
+
calls.push(`${name}:after`);
|
|
32
|
+
return result;
|
|
33
|
+
},
|
|
34
|
+
async onError(ctx, error) {
|
|
35
|
+
calls.push(`${name}:onError`);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ─── MiddlewarePipeline — use / remove / list / clear ────────────────────────
|
|
40
|
+
describe('MiddlewarePipeline — use / remove / list / clear', () => {
|
|
41
|
+
let pipeline;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
pipeline = new MiddlewarePipeline();
|
|
44
|
+
});
|
|
45
|
+
it('starts with an empty list', () => {
|
|
46
|
+
expect(pipeline.list()).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('adds middleware via use()', () => {
|
|
49
|
+
pipeline.use(passthroughMiddleware('a'));
|
|
50
|
+
expect(pipeline.list()).toEqual(['a']);
|
|
51
|
+
});
|
|
52
|
+
it('returns this from use() for chaining', () => {
|
|
53
|
+
const result = pipeline.use(passthroughMiddleware('a'));
|
|
54
|
+
expect(result).toBe(pipeline);
|
|
55
|
+
});
|
|
56
|
+
it('adds multiple middleware in order', () => {
|
|
57
|
+
pipeline.use(passthroughMiddleware('a'));
|
|
58
|
+
pipeline.use(passthroughMiddleware('b'));
|
|
59
|
+
pipeline.use(passthroughMiddleware('c'));
|
|
60
|
+
expect(pipeline.list()).toEqual(['a', 'b', 'c']);
|
|
61
|
+
});
|
|
62
|
+
it('replaces middleware with the same name', () => {
|
|
63
|
+
const first = { name: 'dup', async before() { return { proceed: true, metadata: { v: 1 } }; } };
|
|
64
|
+
const second = { name: 'dup', async before() { return { proceed: true, metadata: { v: 2 } }; } };
|
|
65
|
+
pipeline.use(first);
|
|
66
|
+
pipeline.use(second);
|
|
67
|
+
expect(pipeline.list()).toEqual(['dup']);
|
|
68
|
+
});
|
|
69
|
+
it('removes middleware by name', () => {
|
|
70
|
+
pipeline.use(passthroughMiddleware('a'));
|
|
71
|
+
pipeline.use(passthroughMiddleware('b'));
|
|
72
|
+
pipeline.remove('a');
|
|
73
|
+
expect(pipeline.list()).toEqual(['b']);
|
|
74
|
+
});
|
|
75
|
+
it('returns this from remove() for chaining', () => {
|
|
76
|
+
pipeline.use(passthroughMiddleware('a'));
|
|
77
|
+
const result = pipeline.remove('a');
|
|
78
|
+
expect(result).toBe(pipeline);
|
|
79
|
+
});
|
|
80
|
+
it('does nothing when removing non-existent name', () => {
|
|
81
|
+
pipeline.use(passthroughMiddleware('a'));
|
|
82
|
+
pipeline.remove('nonexistent');
|
|
83
|
+
expect(pipeline.list()).toEqual(['a']);
|
|
84
|
+
});
|
|
85
|
+
it('clears all middleware', () => {
|
|
86
|
+
pipeline.use(passthroughMiddleware('a'));
|
|
87
|
+
pipeline.use(passthroughMiddleware('b'));
|
|
88
|
+
pipeline.clear();
|
|
89
|
+
expect(pipeline.list()).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
it('supports chained use().use().remove()', () => {
|
|
92
|
+
pipeline
|
|
93
|
+
.use(passthroughMiddleware('a'))
|
|
94
|
+
.use(passthroughMiddleware('b'))
|
|
95
|
+
.use(passthroughMiddleware('c'))
|
|
96
|
+
.remove('b');
|
|
97
|
+
expect(pipeline.list()).toEqual(['a', 'c']);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
// ─── MiddlewarePipeline — execute ────────────────────────────────────────────
|
|
101
|
+
describe('MiddlewarePipeline — execute passthrough', () => {
|
|
102
|
+
let pipeline;
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
pipeline = new MiddlewarePipeline();
|
|
105
|
+
});
|
|
106
|
+
it('passes through with no middleware', async () => {
|
|
107
|
+
const result = await pipeline.execute('test', {}, echoOp(42));
|
|
108
|
+
expect(result).toBe(42);
|
|
109
|
+
});
|
|
110
|
+
it('passes through with middleware that has no hooks', async () => {
|
|
111
|
+
pipeline.use(passthroughMiddleware('noop'));
|
|
112
|
+
const result = await pipeline.execute('test', {}, echoOp('hello'));
|
|
113
|
+
expect(result).toBe('hello');
|
|
114
|
+
});
|
|
115
|
+
it('returns complex objects unchanged', async () => {
|
|
116
|
+
const data = { key: 'value', nested: { arr: [1, 2, 3] } };
|
|
117
|
+
const result = await pipeline.execute('test', {}, echoOp(data));
|
|
118
|
+
expect(result).toEqual(data);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
// ─── Before middleware ───────────────────────────────────────────────────────
|
|
122
|
+
describe('MiddlewarePipeline — before hooks', () => {
|
|
123
|
+
let pipeline;
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
pipeline = new MiddlewarePipeline();
|
|
126
|
+
});
|
|
127
|
+
it('runs before hook and proceeds', async () => {
|
|
128
|
+
const beforeFn = vi.fn(async () => ({ proceed: true }));
|
|
129
|
+
pipeline.use({ name: 'test', before: beforeFn });
|
|
130
|
+
await pipeline.execute('op', { a: 1 }, echoOp('ok'));
|
|
131
|
+
expect(beforeFn).toHaveBeenCalledOnce();
|
|
132
|
+
});
|
|
133
|
+
it('receives correct context in before hook', async () => {
|
|
134
|
+
let capturedCtx;
|
|
135
|
+
pipeline.use({
|
|
136
|
+
name: 'capture',
|
|
137
|
+
async before(ctx) {
|
|
138
|
+
capturedCtx = ctx;
|
|
139
|
+
return { proceed: true };
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
await pipeline.execute('myOp', { x: 10 }, echoOp(null));
|
|
143
|
+
expect(capturedCtx).toBeDefined();
|
|
144
|
+
expect(capturedCtx.operation).toBe('myOp');
|
|
145
|
+
expect(capturedCtx.args).toEqual({ x: 10 });
|
|
146
|
+
expect(capturedCtx.timestamp).toBeTruthy();
|
|
147
|
+
expect(capturedCtx.metadata).toEqual({});
|
|
148
|
+
});
|
|
149
|
+
it('prevents execution when proceed is false', async () => {
|
|
150
|
+
const fn = vi.fn(async () => 'should not run');
|
|
151
|
+
pipeline.use({
|
|
152
|
+
name: 'blocker',
|
|
153
|
+
async before() {
|
|
154
|
+
return { proceed: false };
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const result = await pipeline.execute('op', {}, fn);
|
|
158
|
+
expect(fn).not.toHaveBeenCalled();
|
|
159
|
+
expect(result).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
it('modifies args via modifiedArgs', async () => {
|
|
162
|
+
let receivedArgs;
|
|
163
|
+
pipeline.use({
|
|
164
|
+
name: 'modifier',
|
|
165
|
+
async before(ctx) {
|
|
166
|
+
return { proceed: true, modifiedArgs: { extra: 'added' } };
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
pipeline.use({
|
|
170
|
+
name: 'reader',
|
|
171
|
+
async before(ctx) {
|
|
172
|
+
receivedArgs = { ...ctx.args };
|
|
173
|
+
return { proceed: true };
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
await pipeline.execute('op', { original: true }, echoOp(null));
|
|
177
|
+
expect(receivedArgs).toEqual({ original: true, extra: 'added' });
|
|
178
|
+
});
|
|
179
|
+
it('merges metadata from before hooks', async () => {
|
|
180
|
+
let capturedMetadata;
|
|
181
|
+
pipeline.use({
|
|
182
|
+
name: 'meta1',
|
|
183
|
+
async before() {
|
|
184
|
+
return { proceed: true, metadata: { source: 'meta1' } };
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
pipeline.use({
|
|
188
|
+
name: 'meta2',
|
|
189
|
+
async before(ctx) {
|
|
190
|
+
capturedMetadata = { ...ctx.metadata };
|
|
191
|
+
return { proceed: true, metadata: { source2: 'meta2' } };
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
await pipeline.execute('op', {}, echoOp(null));
|
|
195
|
+
expect(capturedMetadata).toEqual({ source: 'meta1' });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
// ─── After middleware ────────────────────────────────────────────────────────
|
|
199
|
+
describe('MiddlewarePipeline — after hooks', () => {
|
|
200
|
+
let pipeline;
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
pipeline = new MiddlewarePipeline();
|
|
203
|
+
});
|
|
204
|
+
it('runs after hook with result', async () => {
|
|
205
|
+
const afterFn = vi.fn(async (_ctx, result) => result);
|
|
206
|
+
pipeline.use({ name: 'test', after: afterFn });
|
|
207
|
+
const result = await pipeline.execute('op', {}, echoOp(42));
|
|
208
|
+
expect(afterFn).toHaveBeenCalledOnce();
|
|
209
|
+
expect(result).toBe(42);
|
|
210
|
+
});
|
|
211
|
+
it('transforms result via after hook', async () => {
|
|
212
|
+
pipeline.use({
|
|
213
|
+
name: 'transformer',
|
|
214
|
+
async after(_ctx, result) {
|
|
215
|
+
return result * 2;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
const result = await pipeline.execute('op', {}, echoOp(21));
|
|
219
|
+
expect(result).toBe(42);
|
|
220
|
+
});
|
|
221
|
+
it('chains after hook transformations in reverse order', async () => {
|
|
222
|
+
pipeline.use({
|
|
223
|
+
name: 'first',
|
|
224
|
+
async after(_ctx, result) {
|
|
225
|
+
return `${result}:first`;
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
pipeline.use({
|
|
229
|
+
name: 'second',
|
|
230
|
+
async after(_ctx, result) {
|
|
231
|
+
return `${result}:second`;
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
// After hooks run in reverse: second first, then first
|
|
235
|
+
const result = await pipeline.execute('op', {}, echoOp('start'));
|
|
236
|
+
expect(result).toBe('start:second:first');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
// ─── Error middleware ────────────────────────────────────────────────────────
|
|
240
|
+
describe('MiddlewarePipeline — error handling', () => {
|
|
241
|
+
let pipeline;
|
|
242
|
+
beforeEach(() => {
|
|
243
|
+
pipeline = new MiddlewarePipeline();
|
|
244
|
+
});
|
|
245
|
+
it('calls onError hook when operation throws', async () => {
|
|
246
|
+
const onErrorFn = vi.fn();
|
|
247
|
+
pipeline.use({ name: 'errHandler', onError: onErrorFn });
|
|
248
|
+
await expect(pipeline.execute('op', {}, failingOp('boom'))).rejects.toThrow('boom');
|
|
249
|
+
expect(onErrorFn).toHaveBeenCalledOnce();
|
|
250
|
+
expect(onErrorFn.mock.calls[0][1]).toBeInstanceOf(Error);
|
|
251
|
+
expect(onErrorFn.mock.calls[0][1].message).toBe('boom');
|
|
252
|
+
});
|
|
253
|
+
it('calls onError with correct context', async () => {
|
|
254
|
+
let capturedCtx;
|
|
255
|
+
pipeline.use({
|
|
256
|
+
name: 'errCapture',
|
|
257
|
+
async onError(ctx) {
|
|
258
|
+
capturedCtx = ctx;
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
await expect(pipeline.execute('failOp', { key: 'val' }, failingOp('oops'))).rejects.toThrow();
|
|
262
|
+
expect(capturedCtx).toBeDefined();
|
|
263
|
+
expect(capturedCtx.operation).toBe('failOp');
|
|
264
|
+
});
|
|
265
|
+
it('propagates the original error after onError hooks', async () => {
|
|
266
|
+
pipeline.use({
|
|
267
|
+
name: 'silent',
|
|
268
|
+
async onError() {
|
|
269
|
+
// does nothing, error still propagates
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
await expect(pipeline.execute('op', {}, failingOp('original error'))).rejects.toThrow('original error');
|
|
273
|
+
});
|
|
274
|
+
it('calls all onError hooks in order', async () => {
|
|
275
|
+
const calls = [];
|
|
276
|
+
pipeline.use({
|
|
277
|
+
name: 'err1',
|
|
278
|
+
async onError() { calls.push('err1'); },
|
|
279
|
+
});
|
|
280
|
+
pipeline.use({
|
|
281
|
+
name: 'err2',
|
|
282
|
+
async onError() { calls.push('err2'); },
|
|
283
|
+
});
|
|
284
|
+
await expect(pipeline.execute('op', {}, failingOp('fail'))).rejects.toThrow();
|
|
285
|
+
expect(calls).toEqual(['err1', 'err2']);
|
|
286
|
+
});
|
|
287
|
+
it('calls onError when before hook throws', async () => {
|
|
288
|
+
const onErrorFn = vi.fn();
|
|
289
|
+
pipeline.use({
|
|
290
|
+
name: 'thrower',
|
|
291
|
+
async before() {
|
|
292
|
+
throw new Error('before failed');
|
|
293
|
+
},
|
|
294
|
+
onError: onErrorFn,
|
|
295
|
+
});
|
|
296
|
+
await expect(pipeline.execute('op', {}, echoOp('ok'))).rejects.toThrow('before failed');
|
|
297
|
+
expect(onErrorFn).toHaveBeenCalledOnce();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
// ─── Execution order ─────────────────────────────────────────────────────────
|
|
301
|
+
describe('MiddlewarePipeline — execution order', () => {
|
|
302
|
+
let pipeline;
|
|
303
|
+
beforeEach(() => {
|
|
304
|
+
pipeline = new MiddlewarePipeline();
|
|
305
|
+
});
|
|
306
|
+
it('executes before hooks in registration order', async () => {
|
|
307
|
+
const calls = [];
|
|
308
|
+
pipeline.use(orderTrackingMiddleware('a', calls));
|
|
309
|
+
pipeline.use(orderTrackingMiddleware('b', calls));
|
|
310
|
+
pipeline.use(orderTrackingMiddleware('c', calls));
|
|
311
|
+
await pipeline.execute('op', {}, echoOp('ok'));
|
|
312
|
+
const beforeCalls = calls.filter((c) => c.endsWith(':before'));
|
|
313
|
+
expect(beforeCalls).toEqual(['a:before', 'b:before', 'c:before']);
|
|
314
|
+
});
|
|
315
|
+
it('executes after hooks in reverse registration order', async () => {
|
|
316
|
+
const calls = [];
|
|
317
|
+
pipeline.use(orderTrackingMiddleware('a', calls));
|
|
318
|
+
pipeline.use(orderTrackingMiddleware('b', calls));
|
|
319
|
+
pipeline.use(orderTrackingMiddleware('c', calls));
|
|
320
|
+
await pipeline.execute('op', {}, echoOp('ok'));
|
|
321
|
+
const afterCalls = calls.filter((c) => c.endsWith(':after'));
|
|
322
|
+
expect(afterCalls).toEqual(['c:after', 'b:after', 'a:after']);
|
|
323
|
+
});
|
|
324
|
+
it('executes full lifecycle: before -> operation -> after', async () => {
|
|
325
|
+
const calls = [];
|
|
326
|
+
pipeline.use({
|
|
327
|
+
name: 'tracker',
|
|
328
|
+
async before() {
|
|
329
|
+
calls.push('before');
|
|
330
|
+
return { proceed: true };
|
|
331
|
+
},
|
|
332
|
+
async after(_ctx, result) {
|
|
333
|
+
calls.push('after');
|
|
334
|
+
return result;
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const fn = async () => {
|
|
338
|
+
calls.push('operation');
|
|
339
|
+
return 'done';
|
|
340
|
+
};
|
|
341
|
+
await pipeline.execute('op', {}, fn);
|
|
342
|
+
expect(calls).toEqual(['before', 'operation', 'after']);
|
|
343
|
+
});
|
|
344
|
+
it('executes onError hooks in registration order', async () => {
|
|
345
|
+
const calls = [];
|
|
346
|
+
pipeline.use(orderTrackingMiddleware('a', calls));
|
|
347
|
+
pipeline.use(orderTrackingMiddleware('b', calls));
|
|
348
|
+
await expect(pipeline.execute('op', {}, failingOp('fail'))).rejects.toThrow();
|
|
349
|
+
const errorCalls = calls.filter((c) => c.endsWith(':onError'));
|
|
350
|
+
expect(errorCalls).toEqual(['a:onError', 'b:onError']);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
// ─── loggingMiddleware ───────────────────────────────────────────────────────
|
|
354
|
+
describe('loggingMiddleware', () => {
|
|
355
|
+
it('has the name "logging"', () => {
|
|
356
|
+
const mw = loggingMiddleware();
|
|
357
|
+
expect(mw.name).toBe('logging');
|
|
358
|
+
});
|
|
359
|
+
it('logs operation start on before', async () => {
|
|
360
|
+
const entries = [];
|
|
361
|
+
const logger = new Logger({
|
|
362
|
+
level: LogLevel.DEBUG,
|
|
363
|
+
output: (entry) => entries.push(entry),
|
|
364
|
+
});
|
|
365
|
+
const pipeline = new MiddlewarePipeline();
|
|
366
|
+
pipeline.use(loggingMiddleware(logger));
|
|
367
|
+
await pipeline.execute('createCovenant', { key: 'value' }, echoOp('ok'));
|
|
368
|
+
expect(entries.length).toBeGreaterThanOrEqual(2);
|
|
369
|
+
expect(entries[0].message).toContain('createCovenant');
|
|
370
|
+
expect(entries[0].message).toContain('started');
|
|
371
|
+
});
|
|
372
|
+
it('logs operation completion on after', async () => {
|
|
373
|
+
const entries = [];
|
|
374
|
+
const logger = new Logger({
|
|
375
|
+
level: LogLevel.DEBUG,
|
|
376
|
+
output: (entry) => entries.push(entry),
|
|
377
|
+
});
|
|
378
|
+
const pipeline = new MiddlewarePipeline();
|
|
379
|
+
pipeline.use(loggingMiddleware(logger));
|
|
380
|
+
await pipeline.execute('verifyCovenant', {}, echoOp(true));
|
|
381
|
+
const completionLogs = entries.filter((e) => e.message.includes('completed'));
|
|
382
|
+
expect(completionLogs.length).toBe(1);
|
|
383
|
+
expect(completionLogs[0].message).toContain('verifyCovenant');
|
|
384
|
+
});
|
|
385
|
+
it('logs errors via onError', async () => {
|
|
386
|
+
const entries = [];
|
|
387
|
+
const logger = new Logger({
|
|
388
|
+
level: LogLevel.DEBUG,
|
|
389
|
+
output: (entry) => entries.push(entry),
|
|
390
|
+
});
|
|
391
|
+
const pipeline = new MiddlewarePipeline();
|
|
392
|
+
pipeline.use(loggingMiddleware(logger));
|
|
393
|
+
await expect(pipeline.execute('op', {}, failingOp('something broke'))).rejects.toThrow();
|
|
394
|
+
const errorLogs = entries.filter((e) => e.level === 'ERROR');
|
|
395
|
+
expect(errorLogs.length).toBe(1);
|
|
396
|
+
expect(errorLogs[0].message).toContain('something broke');
|
|
397
|
+
});
|
|
398
|
+
it('uses default logger when none provided', () => {
|
|
399
|
+
const mw = loggingMiddleware();
|
|
400
|
+
expect(mw.before).toBeDefined();
|
|
401
|
+
expect(mw.after).toBeDefined();
|
|
402
|
+
expect(mw.onError).toBeDefined();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
// ─── validationMiddleware ────────────────────────────────────────────────────
|
|
406
|
+
describe('validationMiddleware', () => {
|
|
407
|
+
let pipeline;
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
pipeline = new MiddlewarePipeline();
|
|
410
|
+
pipeline.use(validationMiddleware());
|
|
411
|
+
});
|
|
412
|
+
it('has the name "validation"', () => {
|
|
413
|
+
const mw = validationMiddleware();
|
|
414
|
+
expect(mw.name).toBe('validation');
|
|
415
|
+
});
|
|
416
|
+
it('allows execution when no validatable args present', async () => {
|
|
417
|
+
const result = await pipeline.execute('op', { other: 'value' }, echoOp('ok'));
|
|
418
|
+
expect(result).toBe('ok');
|
|
419
|
+
});
|
|
420
|
+
it('allows valid constraints', async () => {
|
|
421
|
+
const result = await pipeline.execute('op', { constraints: "permit read on '/data'" }, echoOp('ok'));
|
|
422
|
+
expect(result).toBe('ok');
|
|
423
|
+
});
|
|
424
|
+
it('rejects empty string constraints', async () => {
|
|
425
|
+
await expect(pipeline.execute('op', { constraints: '' }, echoOp('ok'))).rejects.toThrow('constraints must be a non-empty string');
|
|
426
|
+
});
|
|
427
|
+
it('rejects whitespace-only constraints', async () => {
|
|
428
|
+
await expect(pipeline.execute('op', { constraints: ' ' }, echoOp('ok'))).rejects.toThrow('constraints must be a non-empty string');
|
|
429
|
+
});
|
|
430
|
+
it('rejects non-string constraints', async () => {
|
|
431
|
+
await expect(pipeline.execute('op', { constraints: 42 }, echoOp('ok'))).rejects.toThrow('constraints must be a non-empty string');
|
|
432
|
+
});
|
|
433
|
+
it('allows valid 32-byte privateKey', async () => {
|
|
434
|
+
const key = new Uint8Array(32).fill(1);
|
|
435
|
+
const result = await pipeline.execute('op', { privateKey: key }, echoOp('ok'));
|
|
436
|
+
expect(result).toBe('ok');
|
|
437
|
+
});
|
|
438
|
+
it('allows valid 64-byte privateKey', async () => {
|
|
439
|
+
const key = new Uint8Array(64).fill(1);
|
|
440
|
+
const result = await pipeline.execute('op', { privateKey: key }, echoOp('ok'));
|
|
441
|
+
expect(result).toBe('ok');
|
|
442
|
+
});
|
|
443
|
+
it('rejects invalid key size', async () => {
|
|
444
|
+
const key = new Uint8Array(16).fill(1);
|
|
445
|
+
await expect(pipeline.execute('op', { privateKey: key }, echoOp('ok'))).rejects.toThrow('privateKey must be 32 or 64 bytes');
|
|
446
|
+
});
|
|
447
|
+
it('ignores non-Uint8Array privateKey values', async () => {
|
|
448
|
+
// String keys are not validated by this middleware
|
|
449
|
+
const result = await pipeline.execute('op', { privateKey: 'stringkey' }, echoOp('ok'));
|
|
450
|
+
expect(result).toBe('ok');
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
// ─── timingMiddleware ────────────────────────────────────────────────────────
|
|
454
|
+
describe('timingMiddleware', () => {
|
|
455
|
+
it('has the name "timing"', () => {
|
|
456
|
+
const mw = timingMiddleware();
|
|
457
|
+
expect(mw.name).toBe('timing');
|
|
458
|
+
});
|
|
459
|
+
it('adds durationMs to metadata', async () => {
|
|
460
|
+
let capturedMetadata;
|
|
461
|
+
const pipeline = new MiddlewarePipeline();
|
|
462
|
+
// Register metaCapture BEFORE timing so its after hook runs AFTER timing's
|
|
463
|
+
// (after hooks execute in reverse registration order)
|
|
464
|
+
pipeline.use({
|
|
465
|
+
name: 'metaCapture',
|
|
466
|
+
async after(ctx, result) {
|
|
467
|
+
capturedMetadata = { ...ctx.metadata };
|
|
468
|
+
return result;
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
pipeline.use(timingMiddleware());
|
|
472
|
+
await pipeline.execute('op', {}, async () => {
|
|
473
|
+
// Small delay to ensure measurable duration
|
|
474
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
475
|
+
return 'done';
|
|
476
|
+
});
|
|
477
|
+
expect(capturedMetadata).toBeDefined();
|
|
478
|
+
expect(typeof capturedMetadata.durationMs).toBe('number');
|
|
479
|
+
expect(capturedMetadata.durationMs).toBeGreaterThanOrEqual(0);
|
|
480
|
+
});
|
|
481
|
+
it('removes internal _timingStart from metadata', async () => {
|
|
482
|
+
let capturedMetadata;
|
|
483
|
+
const pipeline = new MiddlewarePipeline();
|
|
484
|
+
pipeline.use({
|
|
485
|
+
name: 'metaCapture',
|
|
486
|
+
async after(ctx, result) {
|
|
487
|
+
capturedMetadata = { ...ctx.metadata };
|
|
488
|
+
return result;
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
pipeline.use(timingMiddleware());
|
|
492
|
+
await pipeline.execute('op', {}, echoOp('done'));
|
|
493
|
+
expect(capturedMetadata).toBeDefined();
|
|
494
|
+
expect(capturedMetadata._timingStart).toBeUndefined();
|
|
495
|
+
});
|
|
496
|
+
it('returns the original result unchanged', async () => {
|
|
497
|
+
const pipeline = new MiddlewarePipeline();
|
|
498
|
+
pipeline.use(timingMiddleware());
|
|
499
|
+
const result = await pipeline.execute('op', {}, echoOp({ x: 1 }));
|
|
500
|
+
expect(result).toEqual({ x: 1 });
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
// ─── rateLimitMiddleware ─────────────────────────────────────────────────────
|
|
504
|
+
describe('rateLimitMiddleware', () => {
|
|
505
|
+
it('has the name "rateLimit"', () => {
|
|
506
|
+
const mw = rateLimitMiddleware({ maxPerSecond: 10 });
|
|
507
|
+
expect(mw.name).toBe('rateLimit');
|
|
508
|
+
});
|
|
509
|
+
it('allows operations within the limit', async () => {
|
|
510
|
+
const pipeline = new MiddlewarePipeline();
|
|
511
|
+
pipeline.use(rateLimitMiddleware({ maxPerSecond: 100 }));
|
|
512
|
+
// Should all succeed within the bucket
|
|
513
|
+
for (let i = 0; i < 5; i++) {
|
|
514
|
+
const result = await pipeline.execute('op', {}, echoOp(i));
|
|
515
|
+
expect(result).toBe(i);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
it('rejects when rate limit is exceeded', async () => {
|
|
519
|
+
const pipeline = new MiddlewarePipeline();
|
|
520
|
+
pipeline.use(rateLimitMiddleware({ maxPerSecond: 2 }));
|
|
521
|
+
// Use up the tokens
|
|
522
|
+
await pipeline.execute('op', {}, echoOp(1));
|
|
523
|
+
await pipeline.execute('op', {}, echoOp(2));
|
|
524
|
+
// The third should fail
|
|
525
|
+
await expect(pipeline.execute('op', {}, echoOp(3))).rejects.toThrow('Rate limit exceeded');
|
|
526
|
+
});
|
|
527
|
+
it('refills tokens over time', async () => {
|
|
528
|
+
const pipeline = new MiddlewarePipeline();
|
|
529
|
+
pipeline.use(rateLimitMiddleware({ maxPerSecond: 5 }));
|
|
530
|
+
// Use all tokens
|
|
531
|
+
for (let i = 0; i < 5; i++) {
|
|
532
|
+
await pipeline.execute('op', {}, echoOp(i));
|
|
533
|
+
}
|
|
534
|
+
// Wait for refill
|
|
535
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
536
|
+
// Should have at least 1 token now
|
|
537
|
+
const result = await pipeline.execute('op', {}, echoOp('refilled'));
|
|
538
|
+
expect(result).toBe('refilled');
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
// ─── Pipeline composition ────────────────────────────────────────────────────
|
|
542
|
+
describe('MiddlewarePipeline — composition', () => {
|
|
543
|
+
it('composes logging + timing + validation together', async () => {
|
|
544
|
+
const entries = [];
|
|
545
|
+
const logger = new Logger({
|
|
546
|
+
level: LogLevel.DEBUG,
|
|
547
|
+
output: (entry) => entries.push(entry),
|
|
548
|
+
});
|
|
549
|
+
const pipeline = new MiddlewarePipeline();
|
|
550
|
+
pipeline.use(loggingMiddleware(logger));
|
|
551
|
+
pipeline.use(timingMiddleware());
|
|
552
|
+
pipeline.use(validationMiddleware());
|
|
553
|
+
const result = await pipeline.execute('createCovenant', { constraints: "permit read on '/data'" }, echoOp({ id: 'cov-123' }));
|
|
554
|
+
expect(result).toEqual({ id: 'cov-123' });
|
|
555
|
+
expect(entries.length).toBeGreaterThanOrEqual(2); // start + complete
|
|
556
|
+
});
|
|
557
|
+
it('validation rejects within composed pipeline', async () => {
|
|
558
|
+
const entries = [];
|
|
559
|
+
const logger = new Logger({
|
|
560
|
+
level: LogLevel.DEBUG,
|
|
561
|
+
output: (entry) => entries.push(entry),
|
|
562
|
+
});
|
|
563
|
+
const pipeline = new MiddlewarePipeline();
|
|
564
|
+
pipeline.use(loggingMiddleware(logger));
|
|
565
|
+
pipeline.use(validationMiddleware());
|
|
566
|
+
await expect(pipeline.execute('createCovenant', { constraints: '' }, echoOp('ok'))).rejects.toThrow('constraints must be a non-empty string');
|
|
567
|
+
// Should have error log
|
|
568
|
+
const errorLogs = entries.filter((e) => e.level === 'ERROR');
|
|
569
|
+
expect(errorLogs.length).toBe(1);
|
|
570
|
+
});
|
|
571
|
+
it('timing works with rate limiting', async () => {
|
|
572
|
+
let capturedMetadata;
|
|
573
|
+
const pipeline = new MiddlewarePipeline();
|
|
574
|
+
// Register metaCapture first so its after hook runs last (reverse order)
|
|
575
|
+
pipeline.use({
|
|
576
|
+
name: 'metaCapture',
|
|
577
|
+
async after(ctx, result) {
|
|
578
|
+
capturedMetadata = { ...ctx.metadata };
|
|
579
|
+
return result;
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
pipeline.use(timingMiddleware());
|
|
583
|
+
pipeline.use(rateLimitMiddleware({ maxPerSecond: 100 }));
|
|
584
|
+
await pipeline.execute('op', {}, echoOp('done'));
|
|
585
|
+
expect(capturedMetadata).toBeDefined();
|
|
586
|
+
expect(typeof capturedMetadata.durationMs).toBe('number');
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
// ─── Middleware error propagation ────────────────────────────────────────────
|
|
590
|
+
describe('MiddlewarePipeline — error propagation', () => {
|
|
591
|
+
it('propagates error from before hook', async () => {
|
|
592
|
+
const pipeline = new MiddlewarePipeline();
|
|
593
|
+
pipeline.use({
|
|
594
|
+
name: 'thrower',
|
|
595
|
+
async before() {
|
|
596
|
+
throw new Error('before hook error');
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
await expect(pipeline.execute('op', {}, echoOp('ok'))).rejects.toThrow('before hook error');
|
|
600
|
+
});
|
|
601
|
+
it('propagates error from after hook', async () => {
|
|
602
|
+
const pipeline = new MiddlewarePipeline();
|
|
603
|
+
pipeline.use({
|
|
604
|
+
name: 'afterThrower',
|
|
605
|
+
async after() {
|
|
606
|
+
throw new Error('after hook error');
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
await expect(pipeline.execute('op', {}, echoOp('ok'))).rejects.toThrow('after hook error');
|
|
610
|
+
});
|
|
611
|
+
it('wraps non-Error throws into Error instances', async () => {
|
|
612
|
+
const pipeline = new MiddlewarePipeline();
|
|
613
|
+
const onErrorFn = vi.fn();
|
|
614
|
+
pipeline.use({
|
|
615
|
+
name: 'stringThrower',
|
|
616
|
+
async before() {
|
|
617
|
+
throw 'string error';
|
|
618
|
+
},
|
|
619
|
+
onError: onErrorFn,
|
|
620
|
+
});
|
|
621
|
+
await expect(pipeline.execute('op', {}, echoOp('ok'))).rejects.toThrow('string error');
|
|
622
|
+
expect(onErrorFn).toHaveBeenCalledOnce();
|
|
623
|
+
expect(onErrorFn.mock.calls[0][1]).toBeInstanceOf(Error);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
// ─── Async middleware ────────────────────────────────────────────────────────
|
|
627
|
+
describe('MiddlewarePipeline — async middleware', () => {
|
|
628
|
+
it('handles async before hooks', async () => {
|
|
629
|
+
const pipeline = new MiddlewarePipeline();
|
|
630
|
+
pipeline.use({
|
|
631
|
+
name: 'asyncBefore',
|
|
632
|
+
async before(ctx) {
|
|
633
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
634
|
+
return { proceed: true, metadata: { async: true } };
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
let capturedMetadata;
|
|
638
|
+
pipeline.use({
|
|
639
|
+
name: 'reader',
|
|
640
|
+
async before(ctx) {
|
|
641
|
+
capturedMetadata = { ...ctx.metadata };
|
|
642
|
+
return { proceed: true };
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
await pipeline.execute('op', {}, echoOp('done'));
|
|
646
|
+
expect(capturedMetadata).toEqual({ async: true });
|
|
647
|
+
});
|
|
648
|
+
it('handles async after hooks', async () => {
|
|
649
|
+
const pipeline = new MiddlewarePipeline();
|
|
650
|
+
pipeline.use({
|
|
651
|
+
name: 'asyncAfter',
|
|
652
|
+
async after(_ctx, result) {
|
|
653
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
654
|
+
return `${result}:async`;
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
const result = await pipeline.execute('op', {}, echoOp('start'));
|
|
658
|
+
expect(result).toBe('start:async');
|
|
659
|
+
});
|
|
660
|
+
it('handles async error hooks', async () => {
|
|
661
|
+
const pipeline = new MiddlewarePipeline();
|
|
662
|
+
const errors = [];
|
|
663
|
+
pipeline.use({
|
|
664
|
+
name: 'asyncError',
|
|
665
|
+
async onError(_ctx, error) {
|
|
666
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
667
|
+
errors.push(error.message);
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
await expect(pipeline.execute('op', {}, failingOp('async fail'))).rejects.toThrow('async fail');
|
|
671
|
+
expect(errors).toEqual(['async fail']);
|
|
672
|
+
});
|
|
673
|
+
it('handles slow async operations with timing', async () => {
|
|
674
|
+
let capturedDuration;
|
|
675
|
+
const pipeline = new MiddlewarePipeline();
|
|
676
|
+
// Register durationCapture first so its after hook runs after timing's
|
|
677
|
+
pipeline.use({
|
|
678
|
+
name: 'durationCapture',
|
|
679
|
+
async after(ctx, result) {
|
|
680
|
+
capturedDuration = ctx.metadata.durationMs;
|
|
681
|
+
return result;
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
pipeline.use(timingMiddleware());
|
|
685
|
+
await pipeline.execute('op', {}, async () => {
|
|
686
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
687
|
+
return 'done';
|
|
688
|
+
});
|
|
689
|
+
expect(capturedDuration).toBeDefined();
|
|
690
|
+
expect(capturedDuration).toBeGreaterThanOrEqual(15);
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
|
694
|
+
describe('MiddlewarePipeline — edge cases', () => {
|
|
695
|
+
it('handles undefined return from after hook', async () => {
|
|
696
|
+
const pipeline = new MiddlewarePipeline();
|
|
697
|
+
pipeline.use({
|
|
698
|
+
name: 'nullifier',
|
|
699
|
+
async after() {
|
|
700
|
+
return undefined;
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
const result = await pipeline.execute('op', {}, echoOp(42));
|
|
704
|
+
expect(result).toBeUndefined();
|
|
705
|
+
});
|
|
706
|
+
it('handles empty args object', async () => {
|
|
707
|
+
const pipeline = new MiddlewarePipeline();
|
|
708
|
+
pipeline.use(validationMiddleware());
|
|
709
|
+
const result = await pipeline.execute('op', {}, echoOp('ok'));
|
|
710
|
+
expect(result).toBe('ok');
|
|
711
|
+
});
|
|
712
|
+
it('supports re-adding middleware after clear', async () => {
|
|
713
|
+
const pipeline = new MiddlewarePipeline();
|
|
714
|
+
pipeline.use(passthroughMiddleware('a'));
|
|
715
|
+
pipeline.clear();
|
|
716
|
+
pipeline.use(passthroughMiddleware('b'));
|
|
717
|
+
expect(pipeline.list()).toEqual(['b']);
|
|
718
|
+
const result = await pipeline.execute('op', {}, echoOp('ok'));
|
|
719
|
+
expect(result).toBe('ok');
|
|
720
|
+
});
|
|
721
|
+
it('does not mutate the original args object', async () => {
|
|
722
|
+
const pipeline = new MiddlewarePipeline();
|
|
723
|
+
pipeline.use({
|
|
724
|
+
name: 'modifier',
|
|
725
|
+
async before(ctx) {
|
|
726
|
+
return { proceed: true, modifiedArgs: { injected: true } };
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
const originalArgs = { key: 'value' };
|
|
730
|
+
await pipeline.execute('op', originalArgs, echoOp('ok'));
|
|
731
|
+
expect(originalArgs).toEqual({ key: 'value' });
|
|
732
|
+
expect(originalArgs.injected).toBeUndefined();
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
//# sourceMappingURL=middleware.test.js.map
|