Skip to content

Commit

Permalink
refactor(rest): Split code for better testability and add tests
Browse files Browse the repository at this point in the history
Signed-off-by: Steve Dodier-Lazaro <[email protected]>
  • Loading branch information
Sidnioulz committed Sep 21, 2024
1 parent 61feb20 commit 4cb8152
Show file tree
Hide file tree
Showing 17 changed files with 640 additions and 66 deletions.
2 changes: 2 additions & 0 deletions packages/rest/__mocks__/fs.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const { fs } = require('memfs');
module.exports = fs;
2 changes: 2 additions & 0 deletions packages/rest/__mocks__/fs/promises.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const { fs } = require('memfs');
module.exports = fs.promises;
3 changes: 3 additions & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
"@types/node": "^22.5.5",
"@vitest/coverage-istanbul": "^2.1.1",
"@vitest/coverage-v8": "^2.1.1",
"cacheable": "^0.8.0",
"memfs": "^4.11.1",
"mocked-env": "^1.3.5",
"swagger-typescript-api": "^13.0.22",
"tsc-watch": "^6.2.0",
"tsup": "^8.2.4",
Expand Down
14 changes: 14 additions & 0 deletions packages/rest/src/__fixtures__/fileRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AxiosHeaders } from 'axios';

export const fileRequest = {
method: 'GET',
path: '/v1/files/eEzsMbC707n4RQ4QCsuUEm',
host: 'api.figma.com',
protocol: 'https:',
headers: new AxiosHeaders({
Accept: 'application/json, text/plain, */*',
'X-Figma-Token': 'CENSORED',
'User-Agent': 'axios/1.7.7',
'Accept-Encoding': 'gzip, compress, deflate, br',
}),
};
25 changes: 25 additions & 0 deletions packages/rest/src/__fixtures__/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { NotEmptyStorageValue } from 'axios-cache-interceptor';

export const Basic200 = {
data: {
data: 'Basic data',
headers: {},
status: 200,
statusText: 'OK',
},
createdAt: Date.now(),
ttl: 1000,
state: 'cached',
} satisfies NotEmptyStorageValue;

export const Alternative200 = {
data: {
data: 'Alternative data',
headers: {},
status: 200,
statusText: 'OK',
},
createdAt: Date.now(),
ttl: 1000,
state: 'cached',
} satisfies NotEmptyStorageValue;
281 changes: 281 additions & 0 deletions packages/rest/src/__tests__/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import * as cacheModule from '@figmarine/cache';
import * as loggerModule from '@figmarine/logger';
import { test as base } from 'vitest';
import upstreamMockedEnv from 'mocked-env';
import { vol } from 'memfs';

import * as interceptorsModule from '../interceptors';
import { Client } from '../client';

/* FS mocks. */
vi.mock('node:fs');
vi.mock('node:fs/promises');

/* Local context. */
interface ClientFixtures {
mockedEnv: (args?: typeof process.env) => void;
}
const it = base.extend<ClientFixtures>({
mockedEnv: async ({}, use) => {
// Setup.
let restore: ReturnType<typeof upstreamMockedEnv> | undefined;
const runMocker: ClientFixtures['mockedEnv'] = (args) => {
restore = upstreamMockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: 'foo',
NODE_ENV: 'production',
...(args ?? {}),
});
};

// Use.
await use(runMocker);

// Cleanup.
restore?.();
},
});

// TODO mock oas serv and write extra tests

describe('@figmarine/rest - client', () => {
beforeEach(() => {
vol.reset();
vol.fromJSON({ '/tmp': null });
});
afterEach(() => {
vi.restoreAllMocks();
});

describe('Options - cache', () => {
it('create cache in /tmp/@figmarine by default in development mode', async ({ mockedEnv }) => {
mockedEnv({
NODE_ENV: 'development',
});

await Client();

const folder = vol.lstatSync('/tmp/@figmarine/cache');

expect(folder.isDirectory).toBeTruthy();
});

it('has no cache by default in production mode', async ({ mockedEnv }) => {
mockedEnv();
await Client();

const hasFolder = vol.existsSync('/tmp/@figmarine/cache');

expect(hasFolder).toBeFalsy();
});

it('does not have cache when false is passed', async ({ mockedEnv }) => {
mockedEnv({
NODE_ENV: 'development',
});

await Client({ cache: false });

const hasFolder = vol.existsSync('/tmp/@figmarine/cache');

expect(hasFolder).toBeFalsy();
});

it('uses the passed absolute location if one is passed', async ({ mockedEnv }) => {
mockedEnv();
const location = '/some/arbitrary/location';
await Client({ cache: { location } });

const hasFolder = vol.existsSync(location);

expect(hasFolder).toBeTruthy();
});

it('writes a relative cache location to /tmp/@figmarine/cache', async ({ mockedEnv }) => {
mockedEnv();
const location = 'my-cool-web-app';
await Client({ cache: { location } });

const hasFolderRelativeToCwd = vol.existsSync(location);
const hasFolderRelativeToDefaultLocation = vol.existsSync(
`/tmp/@figmarine/cache/${location}`,
);

expect(hasFolderRelativeToCwd).toBeFalsy();
expect(hasFolderRelativeToDefaultLocation).toBeTruthy();
});

it('passes all `cache` options to the cache constructor', async ({ mockedEnv }) => {
const spy = vi.spyOn(cacheModule, 'makeCache');

mockedEnv();
const cacheOpts = {
location: 'my-cool-web-app',
ttl: 200,
};
await Client({ cache: cacheOpts });

expect(spy).toHaveBeenCalledWith(cacheOpts);
});
});

describe('Options - mode', () => {
it('defaults to process.env.NODE_ENV', async ({ mockedEnv }) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv();
await Client();
expect(spy).toHaveBeenCalledWith('Creating client in production mode.');
});

it('accepts development', async ({ mockedEnv }) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv();
await Client({ mode: 'development' });
expect(spy).toHaveBeenCalledWith('Creating client in development mode.');
});

it('accepts production', async ({ mockedEnv }) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv();
await Client({ mode: 'production' });
expect(spy).toHaveBeenCalledWith('Creating client in production mode.');
});
});

describe('Options - auth', () => {
it('successfully stores a personal access token for request auth', async ({ mockedEnv }) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: undefined,
FIGMA_OAUTH_TOKEN: undefined,
});
await Client({ personalAccessToken: 'foo' });
expect(spy).toHaveBeenCalledWith(
'Creating Figma REST client with personal access token (set programmatically)',
);
});

it('successfully stores an OAuth token for request auth', async ({ mockedEnv }) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: undefined,
FIGMA_OAUTH_TOKEN: undefined,
});
await Client({ oauthToken: 'foo' });
expect(spy).toHaveBeenCalledWith(
'Creating Figma REST client with OAuth token (set programmatically)',
);
});

it('prefers the OAuth token when both are passed', async ({ mockedEnv }) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: undefined,
FIGMA_OAUTH_TOKEN: undefined,
});
await Client({ oauthToken: 'foo', personalAccessToken: 'bar' });
expect(spy).toHaveBeenCalledWith(
'Creating Figma REST client with OAuth token (set programmatically)',
);
});

it('successfully stores a personal access token passed through process.env', async ({
mockedEnv,
}) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: 'foo',
FIGMA_OAUTH_TOKEN: undefined,
});
await Client();
expect(spy).toHaveBeenCalledWith(
'Creating Figma REST client with personal access token (from env)',
);
});

it('successfully stores an OAuth token passed through process.env', async ({ mockedEnv }) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: undefined,
FIGMA_OAUTH_TOKEN: 'foo',
});
await Client();
expect(spy).toHaveBeenCalledWith('Creating Figma REST client with OAuth token (from env)');
});

it('prefers a personal access token passed through options than through process.env', async ({
mockedEnv,
}) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: 'foo',
FIGMA_OAUTH_TOKEN: undefined,
});
await Client({ personalAccessToken: 'bar' });
expect(spy).toHaveBeenCalledWith(
'Creating Figma REST client with personal access token (set programmatically)',
);
});

it('prefers an OAuth token passed through options than through process.env', async ({
mockedEnv,
}) => {
const spy = vi.spyOn(loggerModule, 'log');
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: undefined,
FIGMA_OAUTH_TOKEN: 'foo',
});
await Client({ oauthToken: 'bar' });
expect(spy).toHaveBeenCalledWith(
'Creating Figma REST client with OAuth token (set programmatically)',
);
});

it.todo(
'gives a clear error message when an invalid personalAccessToken is passed',
// async ({ mockedEnv }) => {},
);
it.todo(
'gives a clear error message when an invalid oauthToken is passed',
// async ({ mockedEnv }) => {},
);

it('fails to run when neither token is passed', async ({ mockedEnv }) => {
mockedEnv({
FIGMA_PERSONAL_ACCESS_TOKEN: undefined,
FIGMA_OAUTH_TOKEN: undefined,
});
expect(Client).rejects.toThrow(
'You must set the environment variable FIGMA_PERSONAL_ACCESS_TOKEN or FIGMA_OAUTH_TOKEN',
);
});
});

describe('Options - rateLimit', () => {
it('rate limits by default', async ({ mockedEnv }) => {
const logSpy = vi.spyOn(loggerModule, 'log');
const rlSpy = vi.spyOn(interceptorsModule, 'rateLimitRequestInterceptor');
mockedEnv({});
await Client();
expect(logSpy).toHaveBeenCalledWith('Applying rate limit proxy to API client.');
expect(rlSpy).toHaveBeenCalled();
});

it('does not rate limit when false', async ({ mockedEnv }) => {
const logSpy = vi.spyOn(loggerModule, 'log');
const rlSpy = vi.spyOn(interceptorsModule, 'rateLimitRequestInterceptor');
mockedEnv({});
await Client({ rateLimit: false });
expect(logSpy).not.toHaveBeenCalledWith('Applying rate limit proxy to API client.');
expect(rlSpy).not.toHaveBeenCalled();
});

it('does rate limit when true', async ({ mockedEnv }) => {
const logSpy = vi.spyOn(loggerModule, 'log');
const rlSpy = vi.spyOn(interceptorsModule, 'rateLimitRequestInterceptor');
mockedEnv({});
await Client({ rateLimit: true });
expect(logSpy).toHaveBeenCalledWith('Applying rate limit proxy to API client.');
expect(rlSpy).toHaveBeenCalled();
});
});
});
9 changes: 7 additions & 2 deletions packages/rest/src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
describe('@figmarine/rest', () => {
it.skip('TODO', () => {});
import * as index from '../index';

describe('@figmarine/rest - index', () => {
it('exports something', () => {
expect(index).toBeDefined();
expect(index.Client).toBeDefined();
});
});
43 changes: 43 additions & 0 deletions packages/rest/src/__tests__/interceptors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test as base } from 'vitest';
import { Cacheable } from 'cacheable';
import { defaultKeyGenerator } from 'axios-cache-interceptor';

import * as rateLimitModule from '../rateLimit';
import { Basic200 } from '../__fixtures__/storage';
import { fileRequest } from '../__fixtures__/fileRequest';
import { rateLimitRequestInterceptor } from '../interceptors';

/* Mock diskCache. */
interface StorageFixtures {
diskCache: Cacheable;
}
const it = base.extend<StorageFixtures>({
diskCache: async ({}, use) => {
const c = new Cacheable({});
c.set('existing', JSON.stringify(Basic200));
await use(c);
},
});

describe('@figmarine/rest - interceptors', () => {
describe('rateLimitRequestInterceptor', () => {
it('returns a function when called', ({ diskCache }) => {
const interceptor = rateLimitRequestInterceptor(defaultKeyGenerator, diskCache);
expect(typeof interceptor).toBe('function');
});

it('rate limits requests that do not hit cache', async ({ diskCache }) => {
const rlSpy = vi.spyOn(rateLimitModule, 'interceptRequest');
const interceptor = rateLimitRequestInterceptor(defaultKeyGenerator, diskCache);
await interceptor(fileRequest);
expect(rlSpy).toHaveBeenCalled();
});
it('skip rate limiting for requests that do hit cache', async ({ diskCache }) => {
const rlSpy = vi.spyOn(rateLimitModule, 'interceptRequest');
await diskCache.set(defaultKeyGenerator(fileRequest), 'someResponse');
const interceptor = rateLimitRequestInterceptor(defaultKeyGenerator, diskCache);
await interceptor(fileRequest);
expect(rlSpy).not.toHaveBeenCalled();
});
});
});
Loading

0 comments on commit 4cb8152

Please sign in to comment.