diff --git a/factory/createClient.test.ts b/factory/createClient.test.ts index 5f22c99..4f8198c 100644 --- a/factory/createClient.test.ts +++ b/factory/createClient.test.ts @@ -1,31 +1,38 @@ import { assert } from "https://deno.land/std@0.217.0/assert/assert.ts"; +import { assertEquals } from "https://deno.land/std@0.220.0/assert/assert_equals.ts"; import { assertThrows } from "https://deno.land/std@0.220.0/assert/assert_throws.ts"; import { mockFetch } from "../utils/mockFetch.ts"; import { createClient } from "./createClient.ts"; -Deno.test("should create a trade client with valid options", () => { - const client = createClient({ - baseURL: "https://paper-api.alpaca.markets", - keyId: "EXAMPLE_KEY_ID", - secretKey: "EXAMPLE_KEY_SECRET", - }); - - assert(client.account !== undefined); - assert(client.orders.create !== undefined); -}); - -Deno.test("should create a market data client with valid options", () => { - const client = createClient({ - baseURL: "https://data.alpaca.markets", - keyId: "EXAMPLE_KEY_ID", - secretKey: "EXAMPLE_KEY_SECRET", - }); - - assert(client.rest.v1beta1 !== undefined); - assert(client.rest.v1beta3 !== undefined); -}); - -Deno.test("should throw an error with an invalid base URL", () => { +Deno.test( + "createClient should create a trade client with valid options", + () => { + const client = createClient({ + baseURL: "https://paper-api.alpaca.markets", + keyId: "EXAMPLE_KEY_ID", + secretKey: "EXAMPLE_KEY_SECRET", + }); + + assert(client.account !== undefined); + assert(client.orders.create !== undefined); + } +); + +Deno.test( + "createClient should create a market data client with valid options", + () => { + const client = createClient({ + baseURL: "https://data.alpaca.markets", + keyId: "EXAMPLE_KEY_ID", + secretKey: "EXAMPLE_KEY_SECRET", + }); + + assert(client.rest.v1beta1 !== undefined); + assert(client.rest.v1beta3 !== undefined); + } +); + +Deno.test("createClient should throw an error with an invalid base URL", () => { assertThrows( () => { createClient({ @@ -41,7 +48,7 @@ Deno.test("should throw an error with an invalid base URL", () => { ); }); -Deno.test("should use the provided token bucket options", () => { +Deno.test("createClient should use the provided token bucket options", () => { const tokenBucketOptions = { capacity: 100, fillRate: 2, @@ -57,68 +64,78 @@ Deno.test("should use the provided token bucket options", () => { assert(client._context.options.tokenBucket === tokenBucketOptions); }); -Deno.test("should use default token bucket options if not provided", () => { - const client = createClient({ - baseURL: "https://paper-api.alpaca.markets", - keyId: "EXAMPLE_KEY_ID", - secretKey: "EXAMPLE_KEY_SECRET", - }); - - assert(client._context.options.tokenBucket === undefined); -}); - -Deno.test("should make a request with the correct options", async () => { - const mockResponse = { mock: "data" }; - const originalFetch = globalThis.fetch; - // deno-lint-ignore ban-ts-comment - // @ts-expect-error - globalThis.fetch = mockFetch(mockResponse); - - const client = createClient({ - baseURL: "https://paper-api.alpaca.markets", - keyId: "EXAMPLE_KEY_ID", - secretKey: "EXAMPLE_KEY_SECRET", - }); - - const response = await client._context.request({ - path: "/v2/account", - }); - - assert(response === mockResponse); - - globalThis.fetch = originalFetch; -}); - -Deno.test("should throttle requests based on token bucket", async () => { - const mockResponse = { mock: "data" }; - const originalFetch = globalThis.fetch; - - // deno-lint-ignore ban-ts-comment - // @ts-expect-error - globalThis.fetch = mockFetch(mockResponse); - - const client = createClient({ - baseURL: "https://paper-api.alpaca.markets", - keyId: "EXAMPLE_KEY_ID", - secretKey: "EXAMPLE_KEY_SECRET", - tokenBucket: { - capacity: 2, - fillRate: 1, - }, - }); - - const startTime = Date.now(); - - await Promise.all([ - client._context.request({ path: "/v2/account" }), - client._context.request({ path: "/v2/account" }), - client._context.request({ path: "/v2/account" }), - ]); - - const endTime = Date.now(); - const elapsedTime = endTime - startTime; - - assert(elapsedTime >= 2000, "Requests should be throttled"); - - globalThis.fetch = originalFetch; -}); +Deno.test( + "createClient should use default token bucket options if not provided", + () => { + const client = createClient({ + baseURL: "https://paper-api.alpaca.markets", + keyId: "EXAMPLE_KEY_ID", + secretKey: "EXAMPLE_KEY_SECRET", + }); + + assert(client._context.options.tokenBucket === undefined); + } +); + +Deno.test( + "createClient should make a request with the correct options", + async () => { + const mockResponse = { mock: "data" }; + const originalFetch = globalThis.fetch; + // deno-lint-ignore ban-ts-comment + // @ts-expect-error + globalThis.fetch = mockFetch(mockResponse); + + const client = createClient({ + baseURL: "https://paper-api.alpaca.markets", + keyId: "EXAMPLE_KEY_ID", + secretKey: "EXAMPLE_KEY_SECRET", + }); + + const response = await client._context.request({ + path: "/v2/account", + }); + + assertEquals(response.ok, true); + assertEquals(response.data, mockResponse); + + globalThis.fetch = originalFetch; + } +); + +Deno.test( + "createClient should throttle requests based on token bucket", + async () => { + const mockResponse = { mock: "data" }; + const originalFetch = globalThis.fetch; + + // deno-lint-ignore ban-ts-comment + // @ts-expect-error + globalThis.fetch = mockFetch(mockResponse); + + const client = createClient({ + baseURL: "https://paper-api.alpaca.markets", + keyId: "EXAMPLE_KEY_ID", + secretKey: "EXAMPLE_KEY_SECRET", + tokenBucket: { + capacity: 2, + fillRate: 1, + }, + }); + + const startTime = Date.now(); + + await Promise.all([ + client._context.request({ path: "/v2/account" }), + client._context.request({ path: "/v2/account" }), + client._context.request({ path: "/v2/account" }), + ]); + + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + assert(elapsedTime >= 2000, "Requests should be throttled"); + + globalThis.fetch = originalFetch; + } +); diff --git a/factory/createClient.ts b/factory/createClient.ts index 8f03d88..fb0c264 100644 --- a/factory/createClient.ts +++ b/factory/createClient.ts @@ -1,5 +1,6 @@ import marketData from "../api/marketData.ts"; import trade from "../api/trade.ts"; + import { TokenBucketOptions, createTokenBucket } from "./createTokenBucket.ts"; export type Trade = ReturnType; @@ -13,11 +14,13 @@ type ClientFactoryMap = { export type Client = Trade | MarketData; -type RequestOptions = { +type RequestOptions = { method?: string; path: string; + // deno-lint-ignore no-explicit-any params?: Record; data?: object; + responseType?: T; }; type CreateClientOptions = { @@ -29,7 +32,7 @@ type CreateClientOptions = { export type ClientContext = { options: CreateClientOptions; - request: (options: RequestOptions) => Promise; + request: (options: RequestOptions) => Promise; }; export type ClientWithContext = @@ -49,7 +52,8 @@ export function createClient( path, params, data, - }: RequestOptions): Promise => { + responseType, + }: RequestOptions): Promise => { await new Promise((resolve) => { // Poll the token bucket every second const timer = setInterval(() => { @@ -80,18 +84,19 @@ export function createClient( "Content-Type": "application/json", }); - // Make the request and parse the JSON response + // Make the request and return the Response object return fetch(url, { method, headers, body: data ? JSON.stringify(data) : null, - }).then((response) => { + }).then(async (response) => { if (!response.ok) { throw new Error( `Failed to ${method} ${url}: ${response.status} ${response.statusText}` ); } - return response.json(); + const responseData = await response.json(); + return Object.assign(response, { data: responseData as T }); }); }; diff --git a/factory/createTokenBucket.test.ts b/factory/createTokenBucket.test.ts index 9325d05..5c209d3 100644 --- a/factory/createTokenBucket.test.ts +++ b/factory/createTokenBucket.test.ts @@ -1,47 +1,59 @@ import { assert } from "https://deno.land/std@0.217.0/assert/assert.ts"; import { createTokenBucket } from "./createTokenBucket.ts"; -Deno.test("should allow taking tokens within capacity", () => { - const tokenBucket = createTokenBucket({ - capacity: 200, - fillRate: 3, - }); - - assert(tokenBucket.take(50) === true); -}); +Deno.test( + "createTokenBucket should allow taking tokens within capacity", + () => { + const tokenBucket = createTokenBucket({ + capacity: 200, + fillRate: 3, + }); + + assert(tokenBucket.take(50) === true); + } +); -Deno.test("should reject taking tokens beyond capacity", () => { - const tokenBucket = createTokenBucket({ - capacity: 200, - fillRate: 3, - }); +Deno.test( + "createTokenBucket should reject taking tokens beyond capacity", + () => { + const tokenBucket = createTokenBucket({ + capacity: 200, + fillRate: 3, + }); - assert(tokenBucket.take(300) === false); -}); + assert(tokenBucket.take(300) === false); + } +); -Deno.test("should refill tokens based on fill rate", async () => { - const tokenBucket = createTokenBucket({ - capacity: 200, - fillRate: 3, - }); +Deno.test( + "createTokenBucket should refill tokens based on fill rate", + async () => { + const tokenBucket = createTokenBucket({ + capacity: 200, + fillRate: 3, + }); - tokenBucket.take(50); + tokenBucket.take(50); - await new Promise((resolve) => setTimeout(resolve, 3000)); + await new Promise((resolve) => setTimeout(resolve, 3000)); - assert(tokenBucket.take(50) === true); -}); + assert(tokenBucket.take(50) === true); + } +); -Deno.test("should support 200 requests per 60 seconds", async () => { - const tokenBucket = createTokenBucket({ capacity: 200, fillRate: 3 }); +Deno.test( + "createTokenBucket should support 200 requests per 60 seconds", + () => { + const tokenBucket = createTokenBucket({ capacity: 200, fillRate: 3 }); - let successfulRequests = 0; + let successfulRequests = 0; - for (let i = 0; i < 200; i++) { - if (tokenBucket.take(1)) { - successfulRequests++; + for (let i = 0; i < 200; i++) { + if (tokenBucket.take(1)) { + successfulRequests++; + } } - } - assert(successfulRequests === 200); -}); + assert(successfulRequests === 200); + } +); diff --git a/utils/mockFetch.test.ts b/utils/mockFetch.test.ts new file mode 100644 index 0000000..7cf2b54 --- /dev/null +++ b/utils/mockFetch.test.ts @@ -0,0 +1,43 @@ +import { assert } from "https://deno.land/std@0.217.0/assert/assert.ts"; +import { mockFetch } from "./mockFetch.ts"; + +Deno.test("mockFetch should return a function", () => { + const response = { data: "mocked response" }; + const result = mockFetch(response); + assert(typeof result === "function"); +}); + +Deno.test( + "mockFetch should return a promise that resolves to a response object", + async () => { + const response = { data: "mocked response" }; + const fetch = mockFetch(response); + const result = await fetch("https://example.com"); + assert(result instanceof Response); + assert(result.ok === true); + assert(result.status === 200); + assert(result.headers.get("Content-Type") === "application/json"); + } +); + +Deno.test("mockFetch should return the mocked response data", async () => { + const response = { data: "mocked response" }; + const fetch = mockFetch(response); + const result = await fetch("https://example.com"); + const data = await result.json(); + assert(data.data === response.data); +}); + +Deno.test("mockFetch should ignore the url and init parameters", async () => { + const response = { data: "mocked response" }; + const fetch = mockFetch(response); + const result1 = await fetch("https://example.com"); + const result2 = await fetch("https://another-example.com", { + method: "POST", + body: JSON.stringify({ key: "value" }), + }); + const data1 = await result1.json(); + const data2 = await result2.json(); + assert(data1.data === response.data); + assert(data2.data === response.data); +}); diff --git a/utils/mockFetch.ts b/utils/mockFetch.ts index 07a059e..4611d3b 100644 --- a/utils/mockFetch.ts +++ b/utils/mockFetch.ts @@ -1,8 +1,11 @@ export const mockFetch = - // deno-lint-ignore no-explicit-any (response: any) => (_url: string, _init?: RequestInit) => { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(response), - } as Response); + return Promise.resolve( + new Response(JSON.stringify(response), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }) + ); };