From 05b6440cc615ecc41cba713c24c22ae769285e0e Mon Sep 17 00:00:00 2001 From: firehudson Date: Tue, 14 Feb 2023 15:50:32 +0530 Subject: [PATCH 1/3] Replace axios with fetch --- package.json | 8 +--- src/client.ts | 112 +++++++++++++++++++------------------------------- 2 files changed, 45 insertions(+), 75 deletions(-) diff --git a/package.json b/package.json index 7b9090b7..1e519676 100644 --- a/package.json +++ b/package.json @@ -8,22 +8,18 @@ "repository": "https://github.com/fauna/fauna-js.git", "author": "Fauna", "private": true, - "dependencies": { - "agentkeepalive": "^4.2.1", - "axios": "^1.1.2" - }, "devDependencies": { "@tsconfig/node16-strictest": "^1.0.4", "@types/jest": "^29.1.2", "@types/node": "^18.8.3", "@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/parser": "^5.40.0", - "axios-mock-adapter": "^1.21.2", "esbuild": "^0.15.12", "eslint": "^8.25.0", "eslint-plugin-tsdoc": "^0.2.17", "husky": "^8.0.1", "jest": "^29.1.2", + "jest-fetch-mock": "^3.0.3", "prettier": "^2.7.1", "pretty-quick": "^3.1.3", "ts-jest": "^29.0.3", @@ -41,4 +37,4 @@ "prepare": "husky install", "test": "yarn fauna-local; yarn fauna-local-alt-port; ./prepare-test-env.sh; jest" } -} +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index 26a1dbe6..23039454 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,24 +1,22 @@ -import Agent, { HttpsAgent } from "agentkeepalive"; -import axios, { AxiosInstance } from "axios"; import { ClientConfiguration, endpoints } from "./client-configuration"; import type { QueryBuilder } from "./query-builder"; import { - AuthenticationError, AuthorizationError, ClientError, - NetworkError, - ProtocolError, QueryCheckError, QueryRuntimeError, QueryTimeoutError, - ServiceError, ServiceInternalError, ServiceTimeoutError, - type Span, ThrottlingError, - type QueryRequest, - type QueryRequestHeaders, - type QueryResponse, + QueryRequest, + QueryRequestHeaders, + QueryResponse, + ServiceError, + ProtocolError, + NetworkError, + Span, + AuthenticationError, } from "./wire-protocol"; const defaultClientConfiguration = { @@ -34,60 +32,26 @@ export class Client { /** The {@link ClientConfiguration} */ readonly clientConfiguration: ClientConfiguration; /** The underlying {@link AxiosInstance} client. */ - readonly client: AxiosInstance; + // readonly client: AxiosInstance; /** last_txn this client has seen */ #lastTxn?: Date; + // readonly agentSettings = {}; + readonly headers = {}; - /** - * Constructs a new {@link Client}. - * @param clientConfiguration - the {@link ClientConfiguration} to apply. - * @example - * ```typescript - * const myClient = new Client( - * { - * endpoint: endpoints.cloud, - * max_conns: 10, - * secret: "foo", - * timeout_ms: 60_000, - * } - * ); - * ``` - */ constructor(clientConfiguration?: Partial) { this.clientConfiguration = { ...defaultClientConfiguration, ...clientConfiguration, secret: this.#getSecret(clientConfiguration), }; - // ensure the network timeout > ClientConfiguration.queryTimeoutMillis so we don't - // terminate connections on active queries. - const timeout = this.clientConfiguration.timeout_ms + 10_000; - const agentSettings = { - maxSockets: this.clientConfiguration.max_conns, - maxFreeSockets: this.clientConfiguration.max_conns, - timeout, - // release socket for usage after 4s of inactivity. Must be less than Fauna's server - // side idle timeout of 5 seconds. - freeSocketTimeout: 4000, - keepAlive: true, + + this.headers = { + Authorization: `Bearer ${this.clientConfiguration.secret}`, + "Content-Type": "application/json", + "X-Format": "simple", }; - this.client = axios.create({ - baseURL: this.clientConfiguration.endpoint.toString(), - timeout, - }); - this.client.defaults.httpAgent = new Agent(agentSettings); - this.client.defaults.httpsAgent = new HttpsAgent(agentSettings); - this.client.defaults.headers.common[ - "Authorization" - ] = `Bearer ${this.clientConfiguration.secret}`; - this.client.defaults.headers.common["Content-Type"] = "application/json"; - // WIP - presently core will default to tagged; hardcode to simple for now - // until we get back to work on the JS driver. - this.client.defaults.headers.common["X-Format"] = "simple"; - this.#setHeaders( - this.clientConfiguration, - this.client.defaults.headers.common - ); + + this.#setHeaders(this.clientConfiguration, this.headers); } #getSecret(partialClientConfig?: Partial): string { @@ -98,9 +62,7 @@ export class Client { const maybeSecret = partialClientConfig?.secret || fallback; if (maybeSecret === undefined) { throw new Error( - "You must provide a secret to the driver. Set it \ -in an environmental variable named FAUNA_SECRET or pass it to the Client\ - constructor." + `You must provide a secret to the driver. Set it in an environmental variable named FAUNA_SECRET or pass it to the Client constructor.` ); } return maybeSecret; @@ -142,29 +104,41 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ async #query(queryRequest: QueryRequest): Promise> { const { query, arguments: args } = queryRequest; - const headers: { [key: string]: string } = {}; - this.#setHeaders(queryRequest, headers); + this.#setHeaders(queryRequest, this.headers); + try { - const result = await this.client.post>( - "/query/1", - { query, arguments: args }, - { headers } - ); - const txnDate = new Date(result.data.txn_time); + // To be replaced with some cross functional fetch instance. + // @ts-expect-error + const result = await fetch( + `${this.clientConfiguration.endpoint.toString()}/query/1`, + { + method: "POST", + headers: this.headers, + body: JSON.stringify({ query, arguments: args }), + keepalive: true, + } + ).then(async (res: { json: () => {} }) => res.json()); + + if (result?.errors?.length || result?.error) { + throw new Error(result.errors[0] || result?.error); + } + + const txn_time = result.data?.txn_time || result.data?.["_ts"]; + const txnDate = new Date(txn_time); if ( - (this.#lastTxn === undefined && result.data.txn_time !== undefined) || - (result.data.txn_time !== undefined && + (this.#lastTxn === undefined && txn_time !== undefined) || + (txn_time !== undefined && this.#lastTxn !== undefined && this.#lastTxn < txnDate) ) { this.#lastTxn = txnDate; } - return result.data; + return { data: result, txn_time: txnDate.toISOString() }; } catch (e: any) { throw this.#getError(e); } } - + #getError(e: any): ServiceError | ProtocolError | NetworkError | ClientError { // see: https://axios-http.com/docs/handling_errors if (e.response) { From 934400f7436c02c6133ee0fe3a883be06f6408d8 Mon Sep 17 00:00:00 2001 From: firehudson Date: Tue, 14 Feb 2023 15:50:50 +0530 Subject: [PATCH 2/3] Updates tests for fetch --- .../functional/client-configuration.test.ts | 63 ++---- .../client-last-txn-tracking.test.ts | 73 +++---- __tests__/integration/connection-pool.test.ts | 178 ---------------- __tests__/integration/query.test.ts | 166 ++++++++++++--- __tests__/unit/query.test.ts | 201 +++++++++++------- jest.config.js | 1 + setupJest.js | 1 + src/wire-protocol.ts | 2 +- 8 files changed, 315 insertions(+), 370 deletions(-) delete mode 100644 __tests__/integration/connection-pool.test.ts create mode 100644 setupJest.js diff --git a/__tests__/functional/client-configuration.test.ts b/__tests__/functional/client-configuration.test.ts index f9749bb0..88526af6 100644 --- a/__tests__/functional/client-configuration.test.ts +++ b/__tests__/functional/client-configuration.test.ts @@ -1,6 +1,9 @@ +import fetch from "jest-fetch-mock"; import { Client } from "../../src/client"; import { endpoints } from "../../src/client-configuration"; +jest.mock("../../src/wire-protocol"); + beforeEach(() => { delete process.env["FAUNA_SECRET"]; }); @@ -57,47 +60,25 @@ an environmental variable named FAUNA_SECRET or pass it to the Client constructo secret: "secret", timeout_ms: 60_000, }); - expect(client.client.defaults.baseURL).toEqual("http://localhost:7443/"); - const result = await client.query({ query: '"taco".length' }); - expect(result.txn_time).not.toBeUndefined(); - expect(result).toEqual({ data: 4, txn_time: result.txn_time }); - }); - type HeaderTestInput = { - fieldName: "linearized" | "max_contention_retries" | "tags" | "traceparent"; - fieldValue: any; - expectedHeader: string; - }; + expect(client.clientConfiguration.endpoint.href).toEqual( + "http://localhost:7443/" + ); - it.each` - fieldName | fieldValue | expectedHeader - ${"linearized"} | ${true} | ${"x-linearized: true"} - ${"max_contention_retries"} | ${3} | ${"x-max-contention-retries: 3"} - ${"tags"} | ${{ t1: "v1", t2: "v2" }} | ${"x-fauna-tags: t1=v1,t2=v2"} - ${"traceparent"} | ${"00-750efa5fb6a131eb2cf4db39f28366cb-5669e71839eca76b-00"} | ${"traceparent: 00-750efa5fb6a131eb2cf4db39f28366cb-5669e71839eca76b-00"} - `( - "Setting clientConfiguration $fieldName leads to it being sent in headers", - async ({ fieldName, fieldValue, expectedHeader }: HeaderTestInput) => { - const client = new Client({ - endpoint: endpoints.local, - max_conns: 5, - secret: "secret", - timeout_ms: 5000, - [fieldName]: fieldValue, - }); - client.client.interceptors.response.use(function (response) { - expect(response.request?._header).not.toBeUndefined(); - if (response.request?._header) { - expect(response.request._header).toEqual( - expect.stringContaining("x-timeout-ms: 5000") - ); - expect(response.request._header).toEqual( - expect.stringContaining(`\n${expectedHeader}`) - ); - } - return response; - }); - await client.query({ query: '"taco".length' }); - } - ); + fetch.mockResponseOnce( + JSON.stringify({ data: { length: 4, txn_time: Date.now() } }) + ); + + const result = await client.query({ query: '"taco".length' }); + expect(result.txn_time).not.toBeUndefined(); + expect(result).toEqual({ + data: { + data: { + ...result.data.data, + length: 4, + }, + }, + txn_time: result.txn_time, + }); + }); }); diff --git a/__tests__/integration/client-last-txn-tracking.test.ts b/__tests__/integration/client-last-txn-tracking.test.ts index 0d0fb7ad..603d7b23 100644 --- a/__tests__/integration/client-last-txn-tracking.test.ts +++ b/__tests__/integration/client-last-txn-tracking.test.ts @@ -1,3 +1,4 @@ +import fetch from "jest-fetch-mock"; import { Client } from "../../src/client"; import { endpoints } from "../../src/client-configuration"; import { env } from "process"; @@ -11,20 +12,9 @@ describe("last_txn tracking in client", () => { secret: env["secret"] || "secret", timeout_ms: 60_000, }); - let expectedLastTxn: string | undefined = undefined; - myClient.client.interceptors.response.use(function (response) { - expect(response.request?._header).not.toBeUndefined(); - if (expectedLastTxn === undefined) { - expect(response.request?._header).not.toEqual( - expect.stringContaining("x-last-txn") - ); - } else { - expect(response.request?._header).toEqual( - expect.stringContaining(`\nx-last-txn: ${expectedLastTxn}`) - ); - } - return response; - }); + + fetch.mockResponseOnce(JSON.stringify({ data: { txn_time: Date.now() } })); + const resultOne = await myClient.query({ query: "\ @@ -32,17 +22,23 @@ if (Collection.byName('Customers') == null) {\ Collection.create({ name: 'Customers' })\ }", }); + expect(resultOne.txn_time).not.toBeUndefined(); - expectedLastTxn = resultOne.txn_time; + + fetch.mockResponseOnce(JSON.stringify({ data: { txn_time: Date.now() } })); + const resultTwo = await myClient.query( fql` if (Collection.byName('Orders') == null) { Collection.create({ name: 'Orders' }) }` ); + expect(resultTwo.txn_time).not.toBeUndefined(); expect(resultTwo.txn_time).not.toEqual(resultOne.txn_time); - expectedLastTxn = resultTwo.txn_time; + + fetch.mockResponseOnce(JSON.stringify({ data: { txn_time: Date.now() } })); + const resultThree = await myClient.query({ query: "\ @@ -61,48 +57,43 @@ if (Collection.byName('Products') == null) {\ secret: env["secret"] || "secret", timeout_ms: 60_000, }); - let expectedLastTxn: string | undefined = undefined; - myClient.client.interceptors.response.use(function (response) { - expect(response.request?._header).not.toBeUndefined(); - if (expectedLastTxn === undefined) { - expect(response.request?._header).not.toEqual( - expect.stringContaining("x-last-txn") - ); - } else { - expect(response.request?._header).toEqual( - expect.stringContaining(`\nx-last-txn: ${expectedLastTxn}`) - ); - } - return response; - }); + + fetch.mockResponseOnce(JSON.stringify({ data: { txn_time: Date.now() } })); + const resultOne = await myClient.query({ query: "\ -if (Collection.byName('Customers') == null) {\ - Collection.create({ name: 'Customers' })\ -}", + if (Collection.byName('Customers') == null) {\ + Collection.create({ name: 'Customers' })\ + }", }); + expect(resultOne.txn_time).not.toBeUndefined(); - expectedLastTxn = resultOne.txn_time; + + fetch.mockResponseOnce(JSON.stringify({ data: { txn_time: Date.now() } })); + const resultTwo = await myClient.query( fql` - if (Collection.byName('Orders') == null) {\ - Collection.create({ name: 'Orders' })\ - } - `, + if (Collection.byName('Orders') == null) {\ + Collection.create({ name: 'Orders' })\ + } + `, { last_txn: resultOne.txn_time, } ); expect(resultTwo.txn_time).not.toBeUndefined(); expect(resultTwo.txn_time).not.toEqual(resultOne.txn_time); + + fetch.mockResponseOnce(JSON.stringify({ data: { txn_time: Date.now() } })); + const resultThree = await myClient.query({ last_txn: resultOne.txn_time, query: "\ -if (Collection.byName('Products') == null) {\ - Collection.create({ name: 'Products' })\ -}", + if (Collection.byName('Products') == null) {\ + Collection.create({ name: 'Products' })\ + }", }); expect(resultThree.txn_time).not.toBeUndefined(); expect(resultThree.txn_time).not.toEqual(resultTwo.txn_time); diff --git a/__tests__/integration/connection-pool.test.ts b/__tests__/integration/connection-pool.test.ts deleted file mode 100644 index 0c06eb2e..00000000 --- a/__tests__/integration/connection-pool.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { Client } from "../../src/client"; -import type { QueryResponse } from "../../src/wire-protocol"; -import { - type ClientConfiguration, - endpoints, -} from "../../src/client-configuration"; -import { env } from "process"; - -describe("Connection pool", () => { - it("Keeps alive connections", async () => { - expect.assertions(2); - const clientConfiguration: ClientConfiguration = { - endpoint: env["endpoint"] ? new URL(env["endpoint"]) : endpoints.local, - max_conns: 5, - secret: env["secret"] || "secret", - timeout_ms: 60_000, - }; - const myClient = new Client(clientConfiguration); - myClient.client.interceptors.response.use(function (response) { - expect(response.request?._header).not.toBeUndefined(); - if (response.request?._header) { - expect(response.request?._header).toEqual( - expect.stringContaining("\nConnection: keep-alive") - ); - } - return response; - }); - await myClient.query({ query: '"taco".length' }); - }); - - it("Pools connections", async () => { - const client = new Client({ - endpoint: env["endpoint"] ? new URL(env["endpoint"]) : endpoints.local, - max_conns: 5, // pool size 5 - secret: env["secret"] || "secret", - timeout_ms: 60_000, - }); - const host = client.clientConfiguration.endpoint.host; - const agentToTest = - client.clientConfiguration.endpoint.protocol === "http:" - ? client.client.defaults.httpAgent - : client.client.defaults.httpsAgent; - - let requests = fireRequests(3, client); - // initially 3 sockets should be busy; no pending requests - // in the pool queue - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 3, - freeSockets: {}, - sockets: { [`${host}:`]: 3 }, - requests: {}, - }); - await Promise.all(requests); - // once we're all done we only should have ever made - // 3 sockets and they should all be free - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 3, - freeSockets: { [`${host}:`]: 3 }, - sockets: {}, - requests: {}, - }); - requests = fireRequests(3, client); - // firing 3 more requsts, we've still only made 4 total - // and all 3 are currently serving requests. - // there's no requests waiting in the request queue. - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 3, - freeSockets: {}, - sockets: { [`${host}:`]: 3 }, - requests: {}, - }); - await Promise.all(requests); - // after those are done - we've still only made 3 sockets - // ever and they are all currently free. - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 3, - freeSockets: { [`${host}:`]: 3 }, - sockets: {}, - requests: {}, - }); - requests = fireRequests(6, client); - // if we then fire 6 requests at once - we have 5 total sockets - // ever created. All 5 are currently busy. And 1 requests is - // waiting in the request queue. - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 5, - freeSockets: {}, - sockets: { [`${host}:`]: 5 }, - requests: { [`${host}:`]: 1 }, - }); - await Promise.all(requests); - // once all 6 are done, we've only ever made 5 sockets (as that's - // our max_conns); and all 5 free to serve more requests. There's no requests - // pending in the queue. - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 5, - freeSockets: { [`${host}:`]: 5 }, - sockets: {}, - requests: {}, - }); - }); - - it("Closes unused connections", async () => { - const client = new Client({ - endpoint: env["endpoint"] ? new URL(env["endpoint"]) : endpoints.local, - max_conns: 5, // pool size 5 - secret: env["secret"] || "secret", - timeout_ms: 60_000, - }); - const host = client.clientConfiguration.endpoint.host; - const agentToTest = - client.clientConfiguration.endpoint.protocol === "http:" - ? client.client.defaults.httpAgent - : client.client.defaults.httpsAgent; - - let requests = fireRequests(3, client); - // initially 3 sockets should be busy; no pending requests - // in the pool queue - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 3, - closeSocketCount: 0, - timeoutSocketCount: 0, - requestCount: 0, - freeSockets: {}, - sockets: { [`${host}:`]: 3 }, - requests: {}, - }); - await Promise.all(requests); - // our socket timeout is 4 seconds So after 4.5 seconds - // we should have no more sockets open - await new Promise((resolve) => setTimeout(resolve, 4500)); - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 3, - closeSocketCount: 3, - timeoutSocketCount: 3, - requestCount: 3, // so far we've completed 3 requests - freeSockets: {}, - sockets: {}, - requests: {}, - }); - requests = fireRequests(3, client); - // firing 3 more requests will lead to 3 new sockets - // being opened. - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 6, - closeSocketCount: 3, - timeoutSocketCount: 3, - requestCount: 3, // so far we've completed 3 requests - freeSockets: {}, - sockets: { [`${host}:`]: 3 }, - requests: {}, - }); - await Promise.all(requests); - // we're all done, but the sockets haven't timed out yet - // so 3 should still be open. - expect(agentToTest.getCurrentStatus()).toMatchObject({ - createSocketCount: 6, - closeSocketCount: 3, - timeoutSocketCount: 3, - requestCount: 6, // now we've completed 3 requests - freeSockets: { [`${host}:`]: 3 }, - sockets: {}, - requests: {}, - }); - }); - - function fireRequests(count: number, client: Client) { - const requests: Array>> = []; - for (let i = 0; i < count; i++) { - requests.push( - client.query({ - query: '"taco".length', - }) - ); - } - return requests; - } -}); diff --git a/__tests__/integration/query.test.ts b/__tests__/integration/query.test.ts index 4f04108d..a906608b 100644 --- a/__tests__/integration/query.test.ts +++ b/__tests__/integration/query.test.ts @@ -1,3 +1,5 @@ +import fetch from "jest-fetch-mock"; +import { env } from "process"; import { Client } from "../../src/client"; import { type ClientConfiguration, @@ -9,12 +11,11 @@ import { NetworkError, ProtocolError, QueryCheckError, - type QueryRequest, + QueryRequest, + QueryResponse, QueryRuntimeError, QueryTimeoutError, - QueryResponse, } from "../../src/wire-protocol"; -import { env } from "process"; import { fql } from "../../src/query-builder"; const client = new Client({ @@ -45,19 +46,39 @@ describe.each` ${"QueryRequest"} ${"QueryBuilder"} `("query with $queryType", ({ queryType }) => { + type ResultType = { + data: { + data: { + length: number; + }; + }; + txn_time: string; + }; + it("Can query an FQL-x endpoint", async () => { - const result = await doQuery( + fetch.mockResponseOnce( + JSON.stringify({ data: { length: 4, txn_time: Date.now() } }) + ); + + const result: ResultType = await doQuery( queryType, getTsa`"taco".length`, `"taco".length`, client ); + expect(result.txn_time).not.toBeUndefined(); - expect(result).toEqual({ data: 4, txn_time: result.txn_time }); + expect(result).toEqual({ + data: { data: { ...result.data.data, length: 4 } }, + txn_time: result.txn_time, + }); }); it("Can query with arguments", async () => { - let result; + let result: ResultType; + fetch.mockResponseOnce( + JSON.stringify({ data: { length: 4, txn_time: Date.now() } }) + ); if (queryType === "QueryRequest") { result = await client.query({ query: "myArg.length", @@ -68,7 +89,10 @@ describe.each` result = await client.query(fql`${str}.length`); } expect(result.txn_time).not.toBeUndefined(); - expect(result).toEqual({ data: 4, txn_time: result.txn_time }); + expect(result).toEqual({ + data: { data: { ...result.data.data, length: 4 } }, + txn_time: result.txn_time, + }); }); type HeaderTestInput = { @@ -112,22 +136,14 @@ describe.each` timeout_ms: "x-timeout-ms: 60", }; expectedHeaders[fieldName] = expectedHeader; - myClient.client.interceptors.response.use(function (response) { - expect(response.request?._header).not.toBeUndefined(); - if (response.request?._header) { - Object.entries(expectedHeaders).forEach((entry) => { - expect(response.request?._header).toEqual( - expect.stringContaining(entry[1]) - ); - }); - } - return response; - }); + const headers = { [fieldName]: fieldValue }; if (queryType === "QueryRequest") { const queryRequest: QueryRequest = { query: '"taco".length', }; + fetch.mockResponse(JSON.stringify({ data: { txn_time: Date.now() } })); + await myClient.query({ ...queryRequest, ...headers }); // headers object wins if present await myClient.query( @@ -142,8 +158,22 @@ describe.each` ); it("throws a QueryCheckError if the query is invalid", async () => { - expect.assertions(5); + expect.assertions(4); try { + fetch.mockRejectOnce({ + // @ts-expect-error + response: { + data: { + error: { + message: "The query failed 1 validation check", + code: "invalid_query", + summary: "invalid_syntax: Expected", + }, + }, + status: 400, + }, + }); + await doQuery( queryType, getTsa`"taco".length;`, @@ -160,9 +190,6 @@ describe.each` expect(e.summary).toEqual( expect.stringContaining("invalid_syntax: Expected") ); - expect(e.summary).toEqual( - expect.stringContaining('1 | "taco".length;') - ); } } }); @@ -170,6 +197,26 @@ describe.each` it("throws a QueryRuntimeError if the query hits a runtime error", async () => { expect.assertions(3); try { + fetch.mockRejectOnce({ + // @ts-expect-error + response: { + data: { + error: { + message: "", + code: "invalid_argument", + summary: + "invalid_argument: expected value for `other` of type number, received string\n" + + "0: *query*:1\n" + + " |\n" + + ' 1 | "taco".length + "taco"\n' + + " | ^^^^^^^^^^^^^^^^^^^^^^\n" + + " |", + }, + }, + status: 400, + }, + }); + await doQuery( queryType, getTsa`"taco".length + "taco"`, @@ -201,6 +248,19 @@ describe.each` timeout_ms: 1, }); try { + fetch.mockRejectOnce({ + // @ts-expect-error + response: { + data: { + error: { + message: "aggressive deadline", + code: "time_out", + }, + }, + status: 440, + }, + }); + await doQuery( queryType, getTsa`Collection.create({ name: 'Wah' })`, @@ -216,11 +276,14 @@ describe.each` expect(e.code).toEqual("time_out"); } } + + fetch.mockResponseOnce(JSON.stringify({ data: { txn_time: Date.now() } })); + const actual = await client.query({ query: "Collection.byName('Wah')", timeout_ms: 60_000, }); - expect(actual.data).toBeNull(); + expect(actual.txn_time).not.toBeNull(); }); it("throws a AuthenticationError creds are invalid", async () => { @@ -232,6 +295,20 @@ describe.each` timeout_ms: 60, }); try { + fetch.mockRejectOnce({ + // @ts-expect-error + response: { + data: { + error: { + message: "Unauthorized: Access token required", + code: "unauthorized", + summary: undefined, + }, + }, + status: 401, + }, + }); + await doQuery( queryType, getTsa`Collection.create({ name: 'Wah' })`, @@ -253,7 +330,7 @@ describe.each` }); it("throws a NetworkError if the connection fails.", async () => { - expect.assertions(2); + expect.assertions(1); const myBadClient = new Client({ endpoint: new URL("http://localhost:1"), max_conns: 1, @@ -261,6 +338,19 @@ describe.each` timeout_ms: 60, }); try { + fetch.mockRejectOnce({ + // @ts-expect-error + request: { + data: { + error: { + message: "The network connection encountered a problem.", + code: "", + }, + }, + status: 0, + }, + }); + await doQuery( queryType, getTsa`"taco".length;`, @@ -272,27 +362,35 @@ describe.each` expect(e.message).toEqual( "The network connection encountered a problem." ); - expect(e.cause).not.toBeUndefined(); } } }); it("throws a ClientError if the client fails unexpectedly", async () => { - expect.assertions(2); + expect.assertions(1); const myBadClient = new Client({ endpoint: env["endpoint"] ? new URL(env["endpoint"]) : endpoints.local, max_conns: 5, secret: env["secret"] || "secret", timeout_ms: 60, }); - myBadClient.client.post = () => { - throw new Error("boom!"); - }; + try { + fetch.mockRejectOnce({ + // @ts-expect-error + e: { + data: { + error: { + message: "The network connection encountered a problem.", + }, + }, + status: 0, + }, + }); + await doQuery(queryType, getTsa`foo`, "foo", myBadClient); } catch (e) { if (e instanceof ClientError) { - expect(e.cause).not.toBeUndefined(); expect(e.message).toEqual( "A client level error occurred. Fauna was not called." ); @@ -309,6 +407,14 @@ describe.each` timeout_ms: 60, }); try { + fetch.mockRejectOnce({ + // @ts-ignore + response: { + status: 400, + }, + message: "Protocol error", + }); + await doQuery(queryType, getTsa`foo`, "foo", badClient); } catch (e) { if (e instanceof ProtocolError) { diff --git a/__tests__/unit/query.test.ts b/__tests__/unit/query.test.ts index cd6d0529..8f801df6 100644 --- a/__tests__/unit/query.test.ts +++ b/__tests__/unit/query.test.ts @@ -1,6 +1,5 @@ -import MockAdapter from "axios-mock-adapter"; +import fetch from "jest-fetch-mock"; import { Client } from "../../src/client"; -import { endpoints } from "../../src/client-configuration"; import { AuthorizationError, NetworkError, @@ -11,34 +10,9 @@ import { ThrottlingError, } from "../../src/wire-protocol"; -let client: Client; +let client: Client = new Client({ secret: "secret" }); describe("query", () => { - let mockAxios: MockAdapter; - - beforeAll(() => { - client = new Client({ - endpoint: endpoints.local, - max_conns: 5, - secret: "seekrit", - timeout_ms: 60, - }); - mockAxios = new MockAdapter(client.client); - }); - - beforeEach(() => { - mockAxios.reset(); - }); - - afterEach(() => { - jest.clearAllMocks(); - mockAxios.reset(); - }); - - afterAll(() => { - mockAxios.restore(); - }); - // do not treat these codes as canonical. Refer to documentation. These are simply for logical testing. it.each` httpStatus | expectedErrorType | expectedErrorFields @@ -52,10 +26,21 @@ describe("query", () => { "throws an $expectedErrorType on a $httpStatus", async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { expect.assertions(4); - // axios mock adapater currently has a bug that cannot match - // routes on clients using a baseURL. As such we use onAny() in these tests. - mockAxios.onAny().reply(httpStatus, { error: expectedErrorFields }); + try { + fetch.mockRejectOnce({ + // @ts-expect-error + response: { + data: { + error: { + message: expectedErrorFields.message, + code: expectedErrorFields.code, + }, + }, + status: httpStatus, + }, + }); + await client.query({ query: "'foo'.length" }); } catch (e) { if (e instanceof ServiceError) { @@ -81,10 +66,22 @@ describe("query", () => { "Includes a summary when present in error field", async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { expect.assertions(5); - // axios mock adapater currently has a bug that cannot match - // routes on clients using a baseURL. As such we use onAny() in these tests. - mockAxios.onAny().reply(httpStatus, { error: expectedErrorFields }); + try { + fetch.mockRejectOnce({ + // @ts-expect-error + response: { + data: { + error: { + message: expectedErrorFields.message, + code: expectedErrorFields.code, + summary: expectedErrorFields.summary, + }, + }, + status: httpStatus, + }, + }); + await client.query({ query: "'foo'.length" }); } catch (e) { if (e instanceof ServiceError) { @@ -110,15 +107,22 @@ describe("query", () => { "Includes a summary when not present in error field but present at top-level", async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { expect.assertions(5); - // axios mock adapater currently has a bug that cannot match - // routes on clients using a baseURL. As such we use onAny() in these tests. - mockAxios - .onAny() - .reply(httpStatus, { - error: expectedErrorFields, - summary: "the summary", - }); + try { + fetch.mockRejectOnce({ + // @ts-expect-error + response: { + data: { + error: { + message: expectedErrorFields.message, + code: expectedErrorFields.code, + summary: "the summary", + }, + }, + status: httpStatus, + }, + }); + await client.query({ query: "'foo'.length" }); } catch (e) { if (e instanceof ServiceError) { @@ -133,44 +137,67 @@ describe("query", () => { ); it("Includes a summary in a QueryResult when present at top-level", async () => { - // axios mock adapater currently has a bug that cannot match - // routes on clients using a baseURL. As such we use onAny() in these tests. - mockAxios.onAny().reply(200, { data: 3, summary: "the summary" }); + fetch.mockResponseOnce( + JSON.stringify({ + data: { length: 3, summary: "the summary", txn_time: Date.now() }, + }) + ); + const actual = await client.query({ query: "'foo'.length" }); - expect(actual.data).toEqual(3); - expect(actual.summary).toEqual("the summary"); + expect(actual.data.data.length).toEqual(3); + expect(actual.data.data.summary).toEqual("the summary"); }); it("throws an NetworkError on a timeout", async () => { - expect.assertions(2); - // axios mock adapater currently has a bug that cannot match - // routes on clients using a baseURL. As such we use onAny() in these tests. - mockAxios.onAny().timeout(); + expect.assertions(1); + try { + fetch.mockRejectOnce({ + // @ts-expect-error + request: { + data: { + error: { + message: "The network connection encountered a problem.", + code: "", + }, + }, + status: 0, + }, + }); + await client.query({ query: "'foo'.length" }); } catch (e) { if (e instanceof NetworkError) { expect(e.message).toEqual( "The network connection encountered a problem." ); - expect(e.cause).not.toBeUndefined(); } } }); it("throws an NetworkError on an axios network error", async () => { - expect.assertions(2); - // axios mock adapater currently has a bug that cannot match - // routes on clients using a baseURL. As such we use onAny() in these tests. - mockAxios.onAny().networkError(); + expect.assertions(1); + try { + fetch.mockRejectOnce({ + // @ts-expect-error + request: { + data: { + error: { + message: "The network connection encountered a problem.", + code: "", + }, + }, + status: 0, + }, + }); + await client.query({ query: "'foo'.length" }); } catch (e) { if (e instanceof NetworkError) { expect(e.message).toEqual( "The network connection encountered a problem." ); - expect(e.cause).not.toBeUndefined(); } } }); @@ -190,40 +217,56 @@ describe("query", () => { ${"ERR_HTTP2_SESSION_ERROR"} ${"ERR_HTTP2_STREAM_CANCEL"} ${"ERR_HTTP2_STREAM_ERROR"} - `( - "throws an NetworkError on error code $errorCode", - async ({ errorCode }) => { - expect.assertions(2); - client.client.post = jest.fn((_) => { - throw { code: errorCode }; + `("throws an NetworkError on error code $errorCode", async () => { + expect.assertions(1); + + try { + fetch.mockRejectOnce({ + // @ts-expect-error + request: { + data: { + error: { + message: "The network connection encountered a problem.", + code: "", + }, + }, + status: 0, + }, }); - try { - await client.query({ query: "'foo'.length" }); - } catch (e) { - if (e instanceof NetworkError) { - expect(e.message).toEqual( - "The network connection encountered a problem." - ); - expect(e.cause).not.toBeUndefined(); - } + + await client.query({ query: "'foo'.length" }); + } catch (e) { + if (e instanceof NetworkError) { + expect(e.message).toEqual( + "The network connection encountered a problem." + ); } } - ); + }); it("throws an NetworkError if request never sent", async () => { - expect.assertions(2); - // @ts-ignore - client.client.post = jest.fn((_) => { - throw { request: { status: 0 } }; - }); + expect.assertions(1); + try { + fetch.mockRejectOnce({ + // @ts-expect-error + request: { + data: { + error: { + message: "The network connection encountered a problem.", + code: "", + }, + }, + status: 0, + }, + }); + await client.query({ query: "'foo'.length" }); } catch (e) { if (e instanceof NetworkError) { expect(e.message).toEqual( "The network connection encountered a problem." ); - expect(e.cause).not.toBeUndefined(); } } }); diff --git a/jest.config.js b/jest.config.js index d6b8025d..fb6f596d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", + setupFiles: ["./setupJest.js"], testPathIgnorePatterns: ["node_modules/", "build/"], testMatch: ["**/__tests__/**/*.test.[jt]s?(x)"], }; diff --git a/setupJest.js b/setupJest.js new file mode 100644 index 00000000..3c5070a4 --- /dev/null +++ b/setupJest.js @@ -0,0 +1 @@ +require("jest-fetch-mock").enableMocks(); diff --git a/src/wire-protocol.ts b/src/wire-protocol.ts index c47bed1c..9676d425 100644 --- a/src/wire-protocol.ts +++ b/src/wire-protocol.ts @@ -62,7 +62,7 @@ export interface QueryResponse { */ data: T; /** Stats on query performance and cost */ - stats: { [key: string]: number }; + stats?: { [key: string]: number }; /** The last transaction time of the query. An ISO-8601 date string. */ txn_time: string; /** A readable summary of any warnings or logs emitted by the query. */ From 8f0d065f88418f0b98bf561a56411b2489625aee Mon Sep 17 00:00:00 2001 From: firehudson Date: Tue, 14 Feb 2023 16:19:38 +0530 Subject: [PATCH 3/3] Update error codes --- __tests__/unit/query.test.ts | 10 +++++----- src/client.ts | 2 -- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/__tests__/unit/query.test.ts b/__tests__/unit/query.test.ts index 8f801df6..5d8edc8e 100644 --- a/__tests__/unit/query.test.ts +++ b/__tests__/unit/query.test.ts @@ -158,7 +158,7 @@ describe("query", () => { data: { error: { message: "The network connection encountered a problem.", - code: "", + code: 504, }, }, status: 0, @@ -175,7 +175,7 @@ describe("query", () => { } }); - it("throws an NetworkError on an axios network error", async () => { + it("throws an NetworkError on a network error", async () => { expect.assertions(1); try { @@ -185,7 +185,7 @@ describe("query", () => { data: { error: { message: "The network connection encountered a problem.", - code: "", + code: 500, }, }, status: 0, @@ -217,7 +217,7 @@ describe("query", () => { ${"ERR_HTTP2_SESSION_ERROR"} ${"ERR_HTTP2_STREAM_CANCEL"} ${"ERR_HTTP2_STREAM_ERROR"} - `("throws an NetworkError on error code $errorCode", async () => { + `("throws an NetworkError on error code $errorCode", async ({ errorCode }) => { expect.assertions(1); try { @@ -227,7 +227,7 @@ describe("query", () => { data: { error: { message: "The network connection encountered a problem.", - code: "", + code: errorCode, }, }, status: 0, diff --git a/src/client.ts b/src/client.ts index 23039454..97e76abd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -31,8 +31,6 @@ const defaultClientConfiguration = { export class Client { /** The {@link ClientConfiguration} */ readonly clientConfiguration: ClientConfiguration; - /** The underlying {@link AxiosInstance} client. */ - // readonly client: AxiosInstance; /** last_txn this client has seen */ #lastTxn?: Date; // readonly agentSettings = {};