@guiie/buda-mcp 1.1.2 → 1.2.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/test/unit.ts ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Unit tests — no live API required.
3
+ * Run with: npm run test:unit
4
+ */
5
+
6
+ import { createHmac } from "crypto";
7
+ import { BudaClient, BudaApiError } from "../src/client.js";
8
+ import { MemoryCache } from "../src/cache.js";
9
+ import { validateMarketId } from "../src/validation.js";
10
+ import { handlePlaceOrder } from "../src/tools/place_order.js";
11
+ import { handleCancelOrder } from "../src/tools/cancel_order.js";
12
+
13
+ // ----------------------------------------------------------------
14
+ // Minimal test harness
15
+ // ----------------------------------------------------------------
16
+
17
+ let passed = 0;
18
+ let failed = 0;
19
+
20
+ function section(title: string): void {
21
+ console.log("\n" + "=".repeat(60));
22
+ console.log(` ${title}`);
23
+ console.log("=".repeat(60));
24
+ }
25
+
26
+ async function test(name: string, fn: () => Promise<void> | void): Promise<void> {
27
+ try {
28
+ await fn();
29
+ console.log(` PASS ${name}`);
30
+ passed++;
31
+ } catch (err) {
32
+ console.error(` FAIL ${name}: ${err instanceof Error ? err.message : String(err)}`);
33
+ failed++;
34
+ }
35
+ }
36
+
37
+ function assert(condition: boolean, message: string): void {
38
+ if (!condition) throw new Error(message);
39
+ }
40
+
41
+ function assertEqual<T>(actual: T, expected: T, label: string): void {
42
+ if (actual !== expected) {
43
+ throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
44
+ }
45
+ }
46
+
47
+ // ----------------------------------------------------------------
48
+ // a. HMAC signing — assert exact output for a known input/key/nonce
49
+ // ----------------------------------------------------------------
50
+
51
+ section("a. HMAC-SHA384 signing");
52
+
53
+ await test("GET with no body: sign string is 'METHOD PATH NONCE'", () => {
54
+ const secret = "test-api-secret";
55
+ const nonce = "1712000000000000";
56
+ const method = "GET";
57
+ const path = "/api/v2/markets.json";
58
+
59
+ // Expected: compute independently using raw crypto
60
+ const expected = createHmac("sha384", secret)
61
+ .update(`${method} ${path} ${nonce}`)
62
+ .digest("hex");
63
+
64
+ // Actual: via BudaClient (access private method through subclass)
65
+ class TestableClient extends BudaClient {
66
+ testSign(m: string, p: string, body: string, n: string): string {
67
+ return (this as unknown as { sign: (m: string, p: string, b: string, n: string) => string }).sign(m, p, body, n);
68
+ }
69
+ }
70
+ const client = new TestableClient(undefined, "test-api-key", secret);
71
+ const actual = client.testSign(method, path, "", nonce);
72
+
73
+ assertEqual(actual, expected, "HMAC signature");
74
+ });
75
+
76
+ await test("POST with body: sign string includes base64-encoded body", () => {
77
+ const secret = "another-secret";
78
+ const nonce = "9999999999999999";
79
+ const method = "POST";
80
+ const path = "/api/v2/markets/btc-clp/orders.json";
81
+ const body = JSON.stringify({ type: "Bid", amount: 0.001 });
82
+
83
+ const encodedBody = Buffer.from(body).toString("base64");
84
+ const expected = createHmac("sha384", secret)
85
+ .update(`${method} ${path} ${encodedBody} ${nonce}`)
86
+ .digest("hex");
87
+
88
+ class TestableClient extends BudaClient {
89
+ testSign(m: string, p: string, b: string, n: string): string {
90
+ return (this as unknown as { sign: (m: string, p: string, b: string, n: string) => string }).sign(m, p, b, n);
91
+ }
92
+ }
93
+ const client = new TestableClient(undefined, "test-api-key", secret);
94
+ const actual = client.testSign(method, path, body, nonce);
95
+
96
+ assertEqual(actual, expected, "HMAC signature with body");
97
+ });
98
+
99
+ await test("signing is deterministic for the same inputs", () => {
100
+ class TestableClient extends BudaClient {
101
+ testSign(m: string, p: string, b: string, n: string): string {
102
+ return (this as unknown as { sign: (m: string, p: string, b: string, n: string) => string }).sign(m, p, b, n);
103
+ }
104
+ }
105
+ const client = new TestableClient(undefined, "key", "secret");
106
+ const sig1 = client.testSign("GET", "/api/v2/tickers.json", "", "12345");
107
+ const sig2 = client.testSign("GET", "/api/v2/tickers.json", "", "12345");
108
+ assertEqual(sig1, sig2, "deterministic signature");
109
+ });
110
+
111
+ // ----------------------------------------------------------------
112
+ // b. Cache deduplication — fetcher called exactly once for concurrent requests
113
+ // ----------------------------------------------------------------
114
+
115
+ section("b. Cache in-flight deduplication");
116
+
117
+ await test("concurrent getOrFetch calls share the same in-flight promise", async () => {
118
+ const cache = new MemoryCache();
119
+ let fetchCount = 0;
120
+
121
+ const slowFetcher = async (): Promise<string> => {
122
+ fetchCount++;
123
+ await new Promise((r) => setTimeout(r, 20));
124
+ return "result";
125
+ };
126
+
127
+ // Fire three concurrent requests for the same key
128
+ const [r1, r2, r3] = await Promise.all([
129
+ cache.getOrFetch("key", 5000, slowFetcher),
130
+ cache.getOrFetch("key", 5000, slowFetcher),
131
+ cache.getOrFetch("key", 5000, slowFetcher),
132
+ ]);
133
+
134
+ assertEqual(fetchCount, 1, "fetcher call count");
135
+ assertEqual(r1, "result", "result 1");
136
+ assertEqual(r2, "result", "result 2");
137
+ assertEqual(r3, "result", "result 3");
138
+ });
139
+
140
+ await test("after expiry, fetcher is called again", async () => {
141
+ const cache = new MemoryCache();
142
+ let fetchCount = 0;
143
+
144
+ const fetcher = async (): Promise<number> => ++fetchCount;
145
+
146
+ await cache.getOrFetch("k", 1, fetcher); // ttl = 1ms, expires immediately
147
+ await new Promise((r) => setTimeout(r, 5));
148
+ await cache.getOrFetch("k", 1, fetcher);
149
+
150
+ assertEqual(fetchCount, 2, "fetcher called twice after expiry");
151
+ });
152
+
153
+ await test("rejected fetcher clears in-flight entry so next call retries", async () => {
154
+ const cache = new MemoryCache();
155
+ let fetchCount = 0;
156
+
157
+ const failingFetcher = async (): Promise<string> => {
158
+ fetchCount++;
159
+ throw new Error("transient error");
160
+ };
161
+
162
+ try {
163
+ await cache.getOrFetch("fail-key", 5000, failingFetcher);
164
+ } catch {
165
+ // expected
166
+ }
167
+
168
+ const okFetcher = async (): Promise<string> => {
169
+ fetchCount++;
170
+ return "recovered";
171
+ };
172
+
173
+ const result = await cache.getOrFetch("fail-key", 5000, okFetcher);
174
+ assertEqual(result, "recovered", "recovered after failure");
175
+ assertEqual(fetchCount, 2, "fetcher called twice (once failed, once succeeded)");
176
+ });
177
+
178
+ // ----------------------------------------------------------------
179
+ // c. confirmation_token guard — place_order and cancel_order
180
+ // ----------------------------------------------------------------
181
+
182
+ section("c. confirmation_token guard");
183
+
184
+ await test("place_order returns isError:true without 'CONFIRM'", async () => {
185
+ const fakeClient = {} as BudaClient;
186
+ const result = await handlePlaceOrder(
187
+ {
188
+ market_id: "BTC-CLP",
189
+ type: "Bid",
190
+ price_type: "limit",
191
+ amount: 0.001,
192
+ limit_price: 60_000_000,
193
+ confirmation_token: "yes",
194
+ },
195
+ fakeClient,
196
+ );
197
+ assert(result.isError === true, "isError should be true");
198
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
199
+ assertEqual(parsed.code, "CONFIRMATION_REQUIRED", "error code");
200
+ });
201
+
202
+ await test("place_order returns isError:true with empty confirmation_token", async () => {
203
+ const fakeClient = {} as BudaClient;
204
+ const result = await handlePlaceOrder(
205
+ {
206
+ market_id: "BTC-CLP",
207
+ type: "Ask",
208
+ price_type: "market",
209
+ amount: 0.005,
210
+ confirmation_token: "",
211
+ },
212
+ fakeClient,
213
+ );
214
+ assert(result.isError === true, "isError should be true for empty token");
215
+ });
216
+
217
+ await test("cancel_order returns isError:true without 'CONFIRM'", async () => {
218
+ const fakeClient = {} as BudaClient;
219
+ const result = await handleCancelOrder(
220
+ { order_id: 12345, confirmation_token: "cancel" },
221
+ fakeClient,
222
+ );
223
+ assert(result.isError === true, "isError should be true");
224
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
225
+ assertEqual(parsed.code, "CONFIRMATION_REQUIRED", "error code");
226
+ });
227
+
228
+ await test("cancel_order returns isError:true with no token", async () => {
229
+ const fakeClient = {} as BudaClient;
230
+ const result = await handleCancelOrder(
231
+ { order_id: 99, confirmation_token: "" },
232
+ fakeClient,
233
+ );
234
+ assert(result.isError === true, "isError should be true for empty token");
235
+ });
236
+
237
+ // ----------------------------------------------------------------
238
+ // d. Input sanitization — malformed market IDs return isError:true
239
+ // ----------------------------------------------------------------
240
+
241
+ section("d. Input sanitization — validateMarketId");
242
+
243
+ await test("rejects path traversal", () => {
244
+ assert(validateMarketId("../../etc/passwd") !== null, "should reject path traversal");
245
+ });
246
+
247
+ await test("rejects no-hyphen input", () => {
248
+ assert(validateMarketId("BTCCLP") !== null, "should reject missing hyphen");
249
+ });
250
+
251
+ await test("rejects empty string", () => {
252
+ assert(validateMarketId("") !== null, "should reject empty string");
253
+ });
254
+
255
+ await test("rejects segment too long (>10 chars)", () => {
256
+ assert(validateMarketId("ABCDEFGHIJK-CLP") !== null, "should reject 11-char base segment");
257
+ });
258
+
259
+ await test("rejects segment too short (<2 chars)", () => {
260
+ assert(validateMarketId("B-CLP") !== null, "should reject 1-char base segment");
261
+ });
262
+
263
+ await test("accepts standard market ID (uppercase)", () => {
264
+ assertEqual(validateMarketId("BTC-CLP"), null, "BTC-CLP should be valid");
265
+ });
266
+
267
+ await test("accepts lowercase market ID", () => {
268
+ assertEqual(validateMarketId("eth-btc"), null, "eth-btc should be valid (case-insensitive)");
269
+ });
270
+
271
+ await test("accepts USDC quote currency", () => {
272
+ assertEqual(validateMarketId("BTC-USDC"), null, "BTC-USDC should be valid");
273
+ });
274
+
275
+ await test("rejects special characters", () => {
276
+ assert(validateMarketId("BTC-CL$") !== null, "should reject $ in market ID");
277
+ });
278
+
279
+ // ----------------------------------------------------------------
280
+ // e. 429 retry — mock 429 then 200, assert fetch called twice
281
+ // ----------------------------------------------------------------
282
+
283
+ section("e. 429 Retry-After retry logic");
284
+
285
+ await test("retries once on 429 and returns 200 data", async () => {
286
+ const savedFetch = globalThis.fetch;
287
+ let callCount = 0;
288
+
289
+ const mockData = { ticker: { market_id: "BTC-CLP", last_price: ["65000000", "CLP"] } };
290
+
291
+ globalThis.fetch = async (): Promise<Response> => {
292
+ callCount++;
293
+ if (callCount === 1) {
294
+ return new Response(JSON.stringify({}), {
295
+ status: 429,
296
+ headers: { "Retry-After": "0" }, // 0 seconds = no actual wait in tests
297
+ });
298
+ }
299
+ return new Response(JSON.stringify(mockData), {
300
+ status: 200,
301
+ headers: { "Content-Type": "application/json" },
302
+ });
303
+ };
304
+
305
+ try {
306
+ const client = new BudaClient("https://www.buda.com/api/v2");
307
+ const result = await client.get<typeof mockData>("/markets/btc-clp/ticker");
308
+ assertEqual(callCount, 2, "fetch should be called exactly twice");
309
+ assertEqual(result.ticker.market_id, "BTC-CLP", "result should match mock data");
310
+ } finally {
311
+ globalThis.fetch = savedFetch;
312
+ }
313
+ });
314
+
315
+ await test("throws BudaApiError with retryAfterMs when second attempt also returns 429", async () => {
316
+ const savedFetch = globalThis.fetch;
317
+ let callCount = 0;
318
+
319
+ globalThis.fetch = async (): Promise<Response> => {
320
+ callCount++;
321
+ return new Response(JSON.stringify({}), {
322
+ status: 429,
323
+ headers: { "Retry-After": "0" },
324
+ });
325
+ };
326
+
327
+ try {
328
+ const client = new BudaClient("https://www.buda.com/api/v2");
329
+ try {
330
+ await client.get("/markets/btc-clp/ticker");
331
+ assert(false, "should have thrown BudaApiError");
332
+ } catch (err) {
333
+ assert(err instanceof BudaApiError, "should throw BudaApiError");
334
+ assertEqual((err as BudaApiError).status, 429, "status should be 429");
335
+ assert((err as BudaApiError).retryAfterMs !== undefined, "retryAfterMs should be set");
336
+ }
337
+ assertEqual(callCount, 2, "fetch should be called exactly twice");
338
+ } finally {
339
+ globalThis.fetch = savedFetch;
340
+ }
341
+ });
342
+
343
+ await test("Retry-After header parsed as seconds (RFC 7231): '2' header = 2000ms wait", async () => {
344
+ const savedFetch = globalThis.fetch;
345
+ const delays: number[] = [];
346
+ const savedSetTimeout = globalThis.setTimeout;
347
+
348
+ // Capture the delay value passed to setTimeout
349
+ globalThis.setTimeout = ((fn: () => void, ms: number) => {
350
+ delays.push(ms);
351
+ return savedSetTimeout(fn, 0); // execute immediately in test
352
+ }) as typeof setTimeout;
353
+
354
+ let callCount = 0;
355
+ globalThis.fetch = async (): Promise<Response> => {
356
+ callCount++;
357
+ if (callCount === 1) {
358
+ return new Response("{}", { status: 429, headers: { "Retry-After": "2" } });
359
+ }
360
+ return new Response(JSON.stringify({ markets: [] }), { status: 200 });
361
+ };
362
+
363
+ try {
364
+ const client = new BudaClient("https://www.buda.com/api/v2");
365
+ await client.get("/markets");
366
+ const retryDelay = delays[0];
367
+ assertEqual(retryDelay, 2000, "Retry-After: 2 should produce 2000ms delay");
368
+ } finally {
369
+ globalThis.fetch = savedFetch;
370
+ globalThis.setTimeout = savedSetTimeout;
371
+ }
372
+ });
373
+
374
+ await test("defaults to 1000ms when Retry-After header is absent", async () => {
375
+ const savedFetch = globalThis.fetch;
376
+ const delays: number[] = [];
377
+ const savedSetTimeout = globalThis.setTimeout;
378
+
379
+ globalThis.setTimeout = ((fn: () => void, ms: number) => {
380
+ delays.push(ms);
381
+ return savedSetTimeout(fn, 0);
382
+ }) as typeof setTimeout;
383
+
384
+ let callCount = 0;
385
+ globalThis.fetch = async (): Promise<Response> => {
386
+ callCount++;
387
+ if (callCount === 1) {
388
+ return new Response("{}", { status: 429 }); // no Retry-After header
389
+ }
390
+ return new Response(JSON.stringify({ markets: [] }), { status: 200 });
391
+ };
392
+
393
+ try {
394
+ const client = new BudaClient("https://www.buda.com/api/v2");
395
+ await client.get("/markets");
396
+ const retryDelay = delays[0];
397
+ assertEqual(retryDelay, 1000, "missing Retry-After should default to 1000ms");
398
+ } finally {
399
+ globalThis.fetch = savedFetch;
400
+ globalThis.setTimeout = savedSetTimeout;
401
+ }
402
+ });
403
+
404
+ // ----------------------------------------------------------------
405
+ // Summary
406
+ // ----------------------------------------------------------------
407
+
408
+ section("Summary");
409
+ if (failed === 0) {
410
+ console.log(` All ${passed} unit tests passed.`);
411
+ } else {
412
+ console.error(` ${failed} test(s) failed, ${passed} passed.`);
413
+ process.exit(1);
414
+ }