From 2d175a5d7f4243188d481afde1091bfd9988e8a6 Mon Sep 17 00:00:00 2001 From: Eric Carraway Date: Wed, 8 May 2024 00:02:15 -0400 Subject: [PATCH 1/6] initial `vitest` tests.spec.ts --- .eslintrc.cjs | 8 ++++++ package-lock.json | 69 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 5 ++-- tests.spec.ts | 51 +++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 tests.spec.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b9bfaf4..f0766fd 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,6 +5,14 @@ module.exports = { `plugin:typescript-sort-keys/recommended`, `@percuss.io/eslint-config-ericcarraway`, ], + overrides: [ + { + files: [`tests.spec.ts`], + rules: { + 'import/no-extraneous-dependencies': `off`, + }, + }, + ], parser: `@typescript-eslint/parser`, parserOptions: { ecmaVersion: 2018, diff --git a/package-lock.json b/package-lock.json index c7f0871..80a4971 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "prettier": "^3.2.5", "rimraf": "^5.0.5", "typescript": "~5.3", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vitest-fetch-mock": "^0.2.2" }, "engines": { "node": ">=18" @@ -2529,6 +2530,15 @@ } } }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -6363,6 +6373,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/normalize-package-data": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.1.tgz", @@ -8184,6 +8214,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -8641,6 +8677,21 @@ } } }, + "node_modules/vitest-fetch-mock": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.2.2.tgz", + "integrity": "sha512-XmH6QgTSjCWrqXoPREIdbj40T7i1xnGmAsTAgfckoO75W1IEHKR8hcPCQ7SO16RsdW1t85oUm6pcQRLeBgjVYQ==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.6" + }, + "engines": { + "node": ">=14.14.0" + }, + "peerDependencies": { + "vitest": ">=0.16.0" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -8650,6 +8701,22 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 6fb9b16..8ea716c 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "prepublishOnly": "npm run test", "pretest": "npm run build", "release": "np", - "test": "npm run lint && npm run typecheck", + "test": "npm run lint && npm run typecheck && vitest", "typecheck": "tsc --noEmit" }, "devDependencies": { @@ -55,6 +55,7 @@ "prettier": "^3.2.5", "rimraf": "^5.0.5", "typescript": "~5.3", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vitest-fetch-mock": "^0.2.2" } } diff --git a/tests.spec.ts b/tests.spec.ts new file mode 100644 index 0000000..0f2f5b1 --- /dev/null +++ b/tests.spec.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import createFetchMock from 'vitest-fetch-mock'; + +import { AirtableClient } from './dist'; + +const fetchMocker = createFetchMock(vi); + +// Sets `globalThis.fetch` & `globalThis.fetchMock` to our mocked version. +fetchMocker.enableMocks(); + +const getClient = () => + new AirtableClient({ + apiKey: `pat_mock_123456789`, + baseId: `app_mock_123456789`, + }); + +describe(`AirtableClient`, () => { + beforeEach(() => { + fetchMocker.resetMocks(); + }); + + it(`createRecord`, async () => { + const client = getClient(); + + fetchMocker.mockResponseOnce(JSON.stringify({ response: true })); + + const res = await client.createRecord({ + fields: { foo: `bar` }, + tableIdOrName: `table_mock`, + }); + + expect(res.data).toEqual({ response: true }); + + expect(fetchMocker.requests().length).toEqual(1); + + const request = fetchMocker.requests()[0]; + + expect(request.method).toEqual(`POST`); + + expect(request.url).toEqual( + `https://api.airtable.com/v0/app_mock_123456789/table_mock`, + ); + + const requestBody = request.body + ? // @ts-expect-error + JSON.parse(Buffer.from(request.body).toString()) + : null; + + expect(requestBody).toEqual({ fields: { foo: `bar` } }); + }); +}); From 45c41eb5503b5edaf9015e6629e230feb0a4c45c Mon Sep 17 00:00:00 2001 From: Eric Carraway Date: Wed, 8 May 2024 08:34:47 -0400 Subject: [PATCH 2/6] `fetch` is testable without dependency injection --- src/index.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index a104666..db8101b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,6 @@ /* eslint no-underscore-dangle: ["error", { "allow": ["_fetch"] }] */ export type AirtableClientOpts = { - _fetch?: typeof fetch; - /** A string of at least 10 characters. */ apiKey: string; @@ -57,15 +55,13 @@ export type UpdateRecordOpts = { }; export class AirtableClient { - private _fetch: typeof fetch = fetch; - private baseId: string | null = null; private baseUrl: string = `https://api.airtable.com/v0`; private headers: Record = {}; - constructor({ _fetch, apiKey, baseId, baseUrl }: AirtableClientOpts) { + constructor({ apiKey, baseId, baseUrl }: AirtableClientOpts) { if (typeof apiKey !== `string` || apiKey.length < 10) { throw new TypeError( `AirtableClient expected 'apiKey' to be string of at least 10 characters`, @@ -92,10 +88,6 @@ export class AirtableClient { if (baseUrl && typeof baseUrl === `string`) { this.baseUrl = baseUrl; } - - if (typeof _fetch === `function`) { - this._fetch = _fetch; - } } /** @@ -121,7 +113,7 @@ export class AirtableClient { const createRecordUrl = `${this.baseUrl}/${this.baseId}/${tableIdOrName}`; const body = JSON.stringify({ fields }); - const res: Response = await this._fetch(createRecordUrl, { + const res: Response = await fetch(createRecordUrl, { body, headers: this.headers, method: `POST`, @@ -159,7 +151,7 @@ export class AirtableClient { const getRecordUrl = `${this.baseUrl}/${this.baseId}/${tableIdOrName}/${recordId}`; - const res = await this._fetch(getRecordUrl, { + const res = await fetch(getRecordUrl, { headers: this.headers, method: `GET`, }); @@ -213,7 +205,7 @@ export class AirtableClient { const updateRecordUrl = `${this.baseUrl}/${this.baseId}/${tableIdOrName}/${recordId}`; const body = JSON.stringify({ fields }); - const res = await this._fetch(updateRecordUrl, { + const res = await fetch(updateRecordUrl, { body, headers: this.headers, method: method.toUpperCase(), From 3f5b7c9943b17852f688b464f5c9c47a4d8fc231 Mon Sep 17 00:00:00 2001 From: Eric Carraway Date: Wed, 8 May 2024 08:48:15 -0400 Subject: [PATCH 3/6] rework initial `createRecord` test --- package-lock.json | 16 ++++++++++++++++ package.json | 1 + tests.spec.ts | 36 +++++++++++++++--------------------- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 80a4971..0fc3e8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@percuss.io/eslint-config-ericcarraway": "^3.0.0", + "@types/node": "^20.12.11", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.57.0", @@ -1153,6 +1154,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/node": { + "version": "20.12.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", + "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -8420,6 +8430,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", diff --git a/package.json b/package.json index 8ea716c..583dd0b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "@percuss.io/eslint-config-ericcarraway": "^3.0.0", + "@types/node": "^20.12.11", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.57.0", diff --git a/tests.spec.ts b/tests.spec.ts index 0f2f5b1..0b0f78d 100644 --- a/tests.spec.ts +++ b/tests.spec.ts @@ -3,10 +3,10 @@ import createFetchMock from 'vitest-fetch-mock'; import { AirtableClient } from './dist'; -const fetchMocker = createFetchMock(vi); +const fetchMock = createFetchMock(vi); -// Sets `globalThis.fetch` & `globalThis.fetchMock` to our mocked version. -fetchMocker.enableMocks(); +// Set `globalThis.fetch` to our mocked version. +fetchMock.enableMocks(); const getClient = () => new AirtableClient({ @@ -14,38 +14,32 @@ const getClient = () => baseId: `app_mock_123456789`, }); +const parseBody = (body) => + body ? JSON.parse(Buffer.from(body).toString()) : null; + describe(`AirtableClient`, () => { beforeEach(() => { - fetchMocker.resetMocks(); + fetchMock.resetMocks(); }); it(`createRecord`, async () => { - const client = getClient(); - - fetchMocker.mockResponseOnce(JSON.stringify({ response: true })); + fetchMock.mockResponseOnce(JSON.stringify({ createRecordResponse: true })); + const client = getClient(); const res = await client.createRecord({ fields: { foo: `bar` }, tableIdOrName: `table_mock`, }); - expect(res.data).toEqual({ response: true }); + expect(fetchMock.requests().length).toEqual(1); - expect(fetchMocker.requests().length).toEqual(1); + const { body, method, url } = fetchMock.requests()[0]; - const request = fetchMocker.requests()[0]; - - expect(request.method).toEqual(`POST`); - - expect(request.url).toEqual( + expect(method).toEqual(`POST`); + expect(url).toEqual( `https://api.airtable.com/v0/app_mock_123456789/table_mock`, ); - - const requestBody = request.body - ? // @ts-expect-error - JSON.parse(Buffer.from(request.body).toString()) - : null; - - expect(requestBody).toEqual({ fields: { foo: `bar` } }); + expect(parseBody(body)).toEqual({ fields: { foo: `bar` } }); + expect(res.data).toEqual({ createRecordResponse: true }); }); }); From 65b42b6821b98cf9479befe91a550c1b5abc524d Mon Sep 17 00:00:00 2001 From: Eric Carraway Date: Wed, 8 May 2024 08:59:15 -0400 Subject: [PATCH 4/6] initial `getRecord` test --- src/index.ts | 2 -- tests.spec.ts | 35 ++++++++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index db8101b..9f4818d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -/* eslint no-underscore-dangle: ["error", { "allow": ["_fetch"] }] */ - export type AirtableClientOpts = { /** A string of at least 10 characters. */ apiKey: string; diff --git a/tests.spec.ts b/tests.spec.ts index 0b0f78d..f136744 100644 --- a/tests.spec.ts +++ b/tests.spec.ts @@ -8,11 +8,11 @@ const fetchMock = createFetchMock(vi); // Set `globalThis.fetch` to our mocked version. fetchMock.enableMocks(); -const getClient = () => - new AirtableClient({ - apiKey: `pat_mock_123456789`, - baseId: `app_mock_123456789`, - }); +const apiKey = `pat_mock_123456789`; +const baseId = `app_mock_123456789`; +const tableIdOrName = `table_mock`; + +const getClient = () => new AirtableClient({ apiKey, baseId }); const parseBody = (body) => body ? JSON.parse(Buffer.from(body).toString()) : null; @@ -25,10 +25,10 @@ describe(`AirtableClient`, () => { it(`createRecord`, async () => { fetchMock.mockResponseOnce(JSON.stringify({ createRecordResponse: true })); - const client = getClient(); + const client: AirtableClient = getClient(); const res = await client.createRecord({ fields: { foo: `bar` }, - tableIdOrName: `table_mock`, + tableIdOrName, }); expect(fetchMock.requests().length).toEqual(1); @@ -42,4 +42,25 @@ describe(`AirtableClient`, () => { expect(parseBody(body)).toEqual({ fields: { foo: `bar` } }); expect(res.data).toEqual({ createRecordResponse: true }); }); + + it(`getRecord`, async () => { + fetchMock.mockResponseOnce(JSON.stringify({ getRecordResponse: true })); + + const client: AirtableClient = getClient(); + const res = await client.getRecord({ + recordId: `rec_mock_123456789`, + tableIdOrName, + }); + + expect(fetchMock.requests().length).toEqual(1); + + const { body, method, url } = fetchMock.requests()[0]; + + expect(method).toEqual(`GET`); + expect(url).toEqual( + `https://api.airtable.com/v0/app_mock_123456789/table_mock/rec_mock_123456789`, + ); + expect(parseBody(body)).toEqual(null); + expect(res.data).toEqual({ getRecordResponse: true }); + }); }); From 0170c3d409acf846e62ae635f8cdf8a3350d2a34 Mon Sep 17 00:00:00 2001 From: Eric Carraway Date: Wed, 8 May 2024 09:04:05 -0400 Subject: [PATCH 5/6] initial `updateRecord` test --- tests.spec.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tests.spec.ts b/tests.spec.ts index f136744..f62aea9 100644 --- a/tests.spec.ts +++ b/tests.spec.ts @@ -10,6 +10,7 @@ fetchMock.enableMocks(); const apiKey = `pat_mock_123456789`; const baseId = `app_mock_123456789`; +const recordId = `rec_mock_123456789`; const tableIdOrName = `table_mock`; const getClient = () => new AirtableClient({ apiKey, baseId }); @@ -47,10 +48,7 @@ describe(`AirtableClient`, () => { fetchMock.mockResponseOnce(JSON.stringify({ getRecordResponse: true })); const client: AirtableClient = getClient(); - const res = await client.getRecord({ - recordId: `rec_mock_123456789`, - tableIdOrName, - }); + const res = await client.getRecord({ recordId, tableIdOrName }); expect(fetchMock.requests().length).toEqual(1); @@ -63,4 +61,26 @@ describe(`AirtableClient`, () => { expect(parseBody(body)).toEqual(null); expect(res.data).toEqual({ getRecordResponse: true }); }); + + it(`updateRecord`, async () => { + fetchMock.mockResponseOnce(JSON.stringify({ updateRecordResponse: true })); + + const client: AirtableClient = getClient(); + const res = await client.updateRecord({ + fields: { foo: `bar` }, + recordId, + tableIdOrName, + }); + + expect(fetchMock.requests().length).toEqual(1); + + const { body, method, url } = fetchMock.requests()[0]; + + expect(method).toEqual(`PATCH`); + expect(url).toEqual( + `https://api.airtable.com/v0/app_mock_123456789/table_mock/rec_mock_123456789`, + ); + expect(parseBody(body)).toEqual({ fields: { foo: `bar` } }); + expect(res.data).toEqual({ updateRecordResponse: true }); + }); }); From 44ec8c7e2708d1f72a98b9ddd0fe832c5080ce75 Mon Sep 17 00:00:00 2001 From: Eric Carraway Date: Wed, 8 May 2024 09:12:25 -0400 Subject: [PATCH 6/6] lightweight Airtable API client powered by fetch --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fa95e50..cb7a8b0 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# airtable +# @ensembleblock/airtable + +Lightweight Airtable API client powered by fetch.