From e5afe4f767ff6c9d101522a117e9493aa9120e94 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Nov 2022 23:06:06 +0100 Subject: [PATCH] Migrated ShlinkApiClient from axios to fetch --- src/api/services/ShlinkApiClient.ts | 3 +- src/api/services/ShlinkApiClientBuilder.ts | 3 +- src/utils/types.ts | 2 + test/api/services/ShlinkApiClient.test.ts | 239 ++++++++---------- .../services/ShlinkApiClientBuilder.test.ts | 8 +- 5 files changed, 111 insertions(+), 144 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 6ea555ab..5ac02e2d 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -20,6 +20,7 @@ import { import { orderToString } from '../../utils/helpers/ordering'; import { isRegularNotFound, parseApiError } from '../utils'; import { stringifyQuery } from '../../utils/helpers/query'; +import { Fetch } from '../../utils/types'; const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`; const rejectNilProps = reject(isNil); @@ -33,7 +34,7 @@ export class ShlinkApiClient { private apiVersion: 2 | 3; public constructor( - private readonly fetch: typeof window.fetch, + private readonly fetch: Fetch, private readonly baseUrl: string, private readonly apiKey: string, ) { diff --git a/src/api/services/ShlinkApiClientBuilder.ts b/src/api/services/ShlinkApiClientBuilder.ts index 532bf04e..9d1e2910 100644 --- a/src/api/services/ShlinkApiClientBuilder.ts +++ b/src/api/services/ShlinkApiClientBuilder.ts @@ -1,6 +1,7 @@ import { hasServerData, ServerWithId } from '../../servers/data'; import { GetState } from '../../container/types'; import { ShlinkApiClient } from './ShlinkApiClient'; +import { Fetch } from '../../utils/types'; const apiClients: Record = {}; @@ -15,7 +16,7 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => { return selectedServer; }; -export const buildShlinkApiClient = (fetch: typeof window.fetch) => (getStateOrSelectedServer: GetState | ServerWithId) => { +export const buildShlinkApiClient = (fetch: Fetch) => (getStateOrSelectedServer: GetState | ServerWithId) => { const { url, apiKey } = isGetState(getStateOrSelectedServer) ? getSelectedServerFromState(getStateOrSelectedServer) : getStateOrSelectedServer; diff --git a/src/utils/types.ts b/src/utils/types.ts index 84bab12b..953c1981 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1 +1,3 @@ export type MediaMatcher = (query: string) => MediaQueryList; + +export type Fetch = typeof window.fetch; diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 9448303e..28dc7ee7 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -1,16 +1,17 @@ -import { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'; import { Mock } from 'ts-mockery'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { OptionalString } from '../../../src/utils/utils'; -import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types'; +import { ShlinkDomain, ShlinkVisits, ShlinkVisitsOverview } from '../../../src/api/types'; import { ShortUrl, ShortUrlsOrder } from '../../../src/short-urls/data'; -import { Visit } from '../../../src/visits/types'; +import { Fetch } from '../../../src/utils/types'; describe('ShlinkApiClient', () => { - const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; - const createAxiosMock = (data: AxiosRequestConfig = {}) => jest.fn(createAxios(data)) as unknown as AxiosInstance; - const createApiClient = (data: AxiosRequestConfig) => new ShlinkApiClient(createAxios(data), '', ''); - const shortCodesWithDomainCombinations: [ string, OptionalString ][] = [ + const buildFetch = (data: any) => jest.fn().mockResolvedValue({ json: () => Promise.resolve(data), ok: true }); + const buildRejectedFetch = (error: any) => jest.fn().mockResolvedValueOnce( + { json: () => Promise.resolve(error), ok: false }, + ); + const buildApiClient = (fetch: Fetch) => new ShlinkApiClient(fetch, '', ''); + const shortCodesWithDomainCombinations: [string, OptionalString][] = [ ['abc123', null], ['abc123', undefined], ['abc123', 'example.com'], @@ -20,11 +21,9 @@ describe('ShlinkApiClient', () => { const expectedList = ['foo', 'bar']; it('properly returns short URLs list', async () => { - const { listShortUrls } = createApiClient({ - data: { - shortUrls: expectedList, - }, - }); + const { listShortUrls } = buildApiClient(buildFetch({ + shortUrls: expectedList, + })); const actualList = await listShortUrls(); @@ -32,20 +31,16 @@ describe('ShlinkApiClient', () => { }); it.each([ - [{ field: 'visits', dir: 'DESC' } as ShortUrlsOrder, 'visits-DESC'], - [{ field: 'longUrl', dir: 'ASC' } as ShortUrlsOrder, 'longUrl-ASC'], - [{ field: 'longUrl', dir: undefined } as ShortUrlsOrder, undefined], + [{ field: 'visits', dir: 'DESC' } as ShortUrlsOrder, '?orderBy=visits-DESC'], + [{ field: 'longUrl', dir: 'ASC' } as ShortUrlsOrder, '?orderBy=longUrl-ASC'], + [{ field: 'longUrl', dir: undefined } as ShortUrlsOrder, ''], ])('parses orderBy in params', async (orderBy, expectedOrderBy) => { - const axiosSpy = createAxiosMock({ - data: expectedList, - }); - const { listShortUrls } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ data: expectedList }); + const { listShortUrls } = buildApiClient(fetch); await listShortUrls({ orderBy }); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - params: { orderBy: expectedOrderBy }, - })); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining(`/short-urls${expectedOrderBy}`), expect.anything()); }); }); @@ -55,39 +50,38 @@ describe('ShlinkApiClient', () => { }; it('returns create short URL', async () => { - const { createShortUrl } = createApiClient({ data: shortUrl }); + const { createShortUrl } = buildApiClient(buildFetch(shortUrl)); const result = await createShortUrl({ longUrl: '' }); expect(result).toEqual(shortUrl); }); it('removes all empty options', async () => { - const axiosSpy = createAxiosMock({ data: shortUrl }); - const { createShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ data: shortUrl }); + const { createShortUrl } = buildApiClient(fetch); await createShortUrl({ longUrl: 'bar', customSlug: undefined, maxVisits: null }); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ data: { longUrl: 'bar' } })); + expect(fetch).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + body: JSON.stringify({ longUrl: 'bar' }), + })); }); }); describe('getShortUrlVisits', () => { it('properly returns short URL visits', async () => { const expectedVisits = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - visits: { - data: expectedVisits, - }, + const fetch = buildFetch({ + visits: { + data: expectedVisits, }, }); - const { getShortUrlVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const { getShortUrlVisits } = buildApiClient(fetch); const actualVisits = await getShortUrlVisits('abc123', {}); expect({ data: expectedVisits }).toEqual(actualVisits); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/short-urls/abc123/visits'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/short-urls/abc123/visits'), expect.objectContaining({ method: 'GET', })); }); @@ -96,20 +90,17 @@ describe('ShlinkApiClient', () => { describe('getTagVisits', () => { it('properly returns tag visits', async () => { const expectedVisits = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - visits: { - data: expectedVisits, - }, + const fetch = buildFetch({ + visits: { + data: expectedVisits, }, }); - const { getTagVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const { getTagVisits } = buildApiClient(fetch); const actualVisits = await getTagVisits('foo', {}); expect({ data: expectedVisits }).toEqual(actualVisits); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags/foo/visits'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/tags/foo/visits'), expect.objectContaining({ method: 'GET', })); }); @@ -118,20 +109,17 @@ describe('ShlinkApiClient', () => { describe('getDomainVisits', () => { it('properly returns domain visits', async () => { const expectedVisits = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - visits: { - data: expectedVisits, - }, + const fetch = buildFetch({ + visits: { + data: expectedVisits, }, }); - const { getDomainVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const { getDomainVisits } = buildApiClient(fetch); const actualVisits = await getDomainVisits('foo.com', {}); expect({ data: expectedVisits }).toEqual(actualVisits); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/domains/foo.com/visits'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/domains/foo.com/visits'), expect.objectContaining({ method: 'GET', })); }); @@ -140,19 +128,17 @@ describe('ShlinkApiClient', () => { describe('getShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => { const expectedShortUrl = { foo: 'bar' }; - const axiosSpy = createAxiosMock({ - data: expectedShortUrl, - }); - const { getShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedShortUrl); + const { getShortUrl } = buildApiClient(fetch); + const expectedQuery = domain ? `?domain=${domain}` : ''; const result = await getShortUrl(shortCode, domain); expect(expectedShortUrl).toEqual(result); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`/short-urls/${shortCode}`), - method: 'GET', - params: domain ? { domain } : {}, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), + expect.objectContaining({ method: 'GET' }), + ); }); }); @@ -163,53 +149,49 @@ describe('ShlinkApiClient', () => { validSince: '2025-01-01T10:00:00+01:00', }; const expectedResp = Mock.of(); - const axiosSpy = createAxiosMock({ data: expectedResp }); - const { updateShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedResp); + const { updateShortUrl } = buildApiClient(fetch); + const expectedQuery = domain ? `?domain=${domain}` : ''; const result = await updateShortUrl(shortCode, domain, meta); expect(expectedResp).toEqual(result); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`/short-urls/${shortCode}`), - method: 'PATCH', - params: domain ? { domain } : {}, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), + expect.objectContaining({ method: 'PATCH' }), + ); }); }); describe('listTags', () => { it('properly returns list of tags', async () => { const expectedTags = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - tags: { data: expectedTags }, + const fetch = buildFetch({ + tags: { + data: expectedTags, }, }); - const { listTags } = new ShlinkApiClient(axiosSpy, '', ''); + const { listTags } = buildApiClient(fetch); const result = await listTags(); expect({ tags: expectedTags }).toEqual(result); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags'), - method: 'GET', - })); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/tags'), expect.objectContaining({ method: 'GET' })); }); }); describe('deleteTags', () => { it('properly deletes provided tags', async () => { const tags = ['foo', 'bar']; - const axiosSpy = createAxiosMock(); - const { deleteTags } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({}); + const { deleteTags } = buildApiClient(fetch); await deleteTags(tags); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags'), - method: 'DELETE', - params: { tags }, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/tags?${tags.map((tag) => `tags%5B%5D=${tag}`).join('&')}`), + expect.objectContaining({ method: 'DELETE' }), + ); }); }); @@ -217,31 +199,30 @@ describe('ShlinkApiClient', () => { it('properly edits provided tag', async () => { const oldName = 'foo'; const newName = 'bar'; - const axiosSpy = createAxiosMock(); - const { editTag } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({}); + const { editTag } = buildApiClient(fetch); await editTag(oldName, newName); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/tags'), expect.objectContaining({ method: 'PUT', - data: { oldName, newName }, + body: JSON.stringify({ oldName, newName }), })); }); }); describe('deleteShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly deletes provided short URL', async (shortCode, domain) => { - const axiosSpy = createAxiosMock({}); - const { deleteShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({}); + const { deleteShortUrl } = buildApiClient(fetch); + const expectedQuery = domain ? `?domain=${domain}` : ''; await deleteShortUrl(shortCode, domain); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`/short-urls/${shortCode}`), - method: 'DELETE', - params: domain ? { domain } : {}, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), + expect.objectContaining({ method: 'DELETE' }), + ); }); }); @@ -251,12 +232,12 @@ describe('ShlinkApiClient', () => { status: 'pass', version: '1.19.0', }; - const axiosSpy = createAxiosMock({ data: expectedData }); - const { health } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedData); + const { health } = buildApiClient(fetch); const result = await health(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); @@ -267,12 +248,12 @@ describe('ShlinkApiClient', () => { token: 'abc.123.def', mercureHubUrl: 'http://example.com/.well-known/mercure', }; - const axiosSpy = createAxiosMock({ data: expectedData }); - const { mercureInfo } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedData); + const { mercureInfo } = buildApiClient(fetch); const result = await mercureInfo(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); @@ -280,13 +261,12 @@ describe('ShlinkApiClient', () => { describe('listDomains', () => { it('returns domains', async () => { const expectedData = { data: [Mock.all(), Mock.all()] }; - const resp = { domains: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { listDomains } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ domains: expectedData }); + const { listDomains } = buildApiClient(fetch); const result = await listDomains(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); @@ -294,76 +274,61 @@ describe('ShlinkApiClient', () => { describe('getVisitsOverview', () => { it('returns visits overview', async () => { const expectedData = Mock.all(); - const resp = { visits: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { getVisitsOverview } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ visits: expectedData }); + const { getVisitsOverview } = buildApiClient(fetch); const result = await getVisitsOverview(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); describe('getOrphanVisits', () => { it('returns orphan visits', async () => { - const expectedData: Visit[] = []; - const resp = { visits: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { getOrphanVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ visits: Mock.of({ data: [] }) }); + const { getOrphanVisits } = buildApiClient(fetch); const result = await getOrphanVisits(); - expect(axiosSpy).toHaveBeenCalled(); - expect(result).toEqual(expectedData); + expect(fetch).toHaveBeenCalled(); + expect(result).toEqual({ data: [] }); }); }); describe('getNonOrphanVisits', () => { it('returns non-orphan visits', async () => { - const expectedData: Visit[] = []; - const resp = { visits: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { getNonOrphanVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ visits: Mock.of({ data: [] }) }); + const { getNonOrphanVisits } = buildApiClient(fetch); const result = await getNonOrphanVisits(); - expect(axiosSpy).toHaveBeenCalled(); - expect(result).toEqual(expectedData); + expect(fetch).toHaveBeenCalled(); + expect(result).toEqual({ data: [] }); }); }); describe('editDomainRedirects', () => { it('returns the redirects', async () => { const resp = { baseUrlRedirect: null, regular404Redirect: 'foo', invalidShortUrlRedirect: 'bar' }; - const axiosSpy = createAxiosMock({ data: resp }); - const { editDomainRedirects } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(resp); + const { editDomainRedirects } = buildApiClient(fetch); const result = await editDomainRedirects({ domain: 'foo' }); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(resp); }); it('retries request if API version is not supported', async () => { - const axiosSpy = jest.fn() - .mockImplementationOnce(() => Promise.reject(Mock.of({ - response: { - data: { type: 'NOT_FOUND', status: 404 }, - }, - }))) - .mockImplementation(createAxios({})) as unknown as AxiosInstance; - const { editDomainRedirects } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildRejectedFetch({ type: 'NOT_FOUND', status: 404 }).mockImplementation(buildFetch({})); + const { editDomainRedirects } = buildApiClient(fetch); await editDomainRedirects({ domain: 'foo' }); - expect(axiosSpy).toHaveBeenCalledTimes(2); - expect(axiosSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ - url: expect.stringContaining('/v3/'), - })); - expect(axiosSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ - url: expect.stringContaining('/v2/'), - })); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('/v3/'), expect.anything()); + expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('/v2/'), expect.anything()); }); }); }); diff --git a/test/api/services/ShlinkApiClientBuilder.test.ts b/test/api/services/ShlinkApiClientBuilder.test.ts index b67bd7e6..1b08d385 100644 --- a/test/api/services/ShlinkApiClientBuilder.test.ts +++ b/test/api/services/ShlinkApiClientBuilder.test.ts @@ -1,16 +1,14 @@ import { Mock } from 'ts-mockery'; -import { AxiosInstance } from 'axios'; import { buildShlinkApiClient } from '../../../src/api/services/ShlinkApiClientBuilder'; import { ReachableServer, SelectedServer } from '../../../src/servers/data'; import { ShlinkState } from '../../../src/container/types'; describe('ShlinkApiClientBuilder', () => { - const axiosMock = Mock.all(); + const fetch = jest.fn(); const server = (data: Partial) => Mock.of(data); const createBuilder = () => { - const builder = buildShlinkApiClient(axiosMock); - + const builder = buildShlinkApiClient(fetch); return (selectedServer: SelectedServer) => builder(() => Mock.of({ selectedServer })); }; @@ -44,7 +42,7 @@ describe('ShlinkApiClientBuilder', () => { it('does not fetch from state when provided param is already selected server', () => { const url = 'url'; const apiKey = 'apiKey'; - const apiClient = buildShlinkApiClient(axiosMock)(server({ url, apiKey })); + const apiClient = buildShlinkApiClient(fetch)(server({ url, apiKey })); expect(apiClient['baseUrl']).toEqual(url); // eslint-disable-line @typescript-eslint/dot-notation expect(apiClient['apiKey']).toEqual(apiKey); // eslint-disable-line @typescript-eslint/dot-notation