From 9f7ac09fb081390ed6caf71ad379972c939f0834 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 29 Aug 2023 23:12:25 +0200 Subject: [PATCH 1/2] Replace local ShlinkApiClient with the one from shlink-js-sdk --- CHANGELOG.md | 1 + package-lock.json | 31 +- package.json | 3 +- src/api/services/ShlinkApiClient.ts | 186 --------- src/api/services/ShlinkApiClientBuilder.ts | 15 +- src/common/services/HttpClient.ts | 46 -- src/common/services/provideServices.ts | 4 +- src/servers/reducers/remoteServers.ts | 4 +- src/servers/reducers/selectedServer.ts | 4 +- test/api/services/ShlinkApiClient.test.ts | 394 ------------------ .../services/ShlinkApiClientBuilder.test.ts | 35 +- test/common/services/HttpClient.test.ts | 72 ---- test/servers/reducers/remoteServers.test.ts | 10 +- test/servers/reducers/selectedServer.test.ts | 2 +- 14 files changed, 62 insertions(+), 745 deletions(-) delete mode 100644 src/api/services/ShlinkApiClient.ts delete mode 100644 src/common/services/HttpClient.ts delete mode 100644 test/api/services/ShlinkApiClient.test.ts delete mode 100644 test/common/services/HttpClient.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba8b4a2..a14a019c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed * [#338](https://github.com/shlinkio/shlink-web-client/issues/338) Extract `@shlinkio/shlink-web-component` and `@shlinkio/shlink-frontend-kit` as external libs. +* Use API client from `@shlinkio/shlink-js-sdk` to consume Shlink servers. ### Deprecated * *Nothing* diff --git a/package-lock.json b/package-lock.json index 19d1025d..dc76f642 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "@json2csv/plainjs": "^7.0.3", "@reduxjs/toolkit": "^1.9.5", "@shlinkio/shlink-frontend-kit": "^0.2.0", - "@shlinkio/shlink-web-component": "^0.3.1", + "@shlinkio/shlink-js-sdk": "^0.1.0", + "@shlinkio/shlink-web-component": "^0.3.3", "bootstrap": "5.2.3", "bottlejs": "^2.0.1", "classnames": "^2.3.2", @@ -3031,10 +3032,15 @@ "reactstrap": "^9.2.0" } }, + "node_modules/@shlinkio/shlink-js-sdk": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-js-sdk/-/shlink-js-sdk-0.1.0.tgz", + "integrity": "sha512-03xlTFZH/XbjTDxrMJPD+fhJp+Vysb/lS4MKyOodcxdFieIeiBogCJrRuXiiAwNaXzJoxaon7T3s6yz5DVsbTQ==" + }, "node_modules/@shlinkio/shlink-web-component": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.3.1.tgz", - "integrity": "sha512-9oVbAC/4kNFMWTeDOI5QSVs0SY7lF6o0me8SNcju+cBLIxPrXlsJcYOGUa0OZGcsM+aFbnRKvlCJJAFT462ymw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.3.3.tgz", + "integrity": "sha512-xoRfeAmZ1VE5UyP3z4KtuemMQJYEYcjgvmBM79+en1XHrHyf/NVBZbcIE0U77IPb9hTcCU0fWmElQFboBSXSmw==", "dependencies": { "@json2csv/plainjs": "^7.0.3", "bottlejs": "^2.0.1", @@ -3062,11 +3068,17 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "^1.9.5", "@shlinkio/shlink-frontend-kit": "^0.2.0", + "@shlinkio/shlink-js-sdk": "^0.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.1.2", "react-router-dom": "^6.14.2", "reactstrap": "^9.2.0" + }, + "peerDependenciesMeta": { + "@shlinkio/shlink-js-sdk": { + "optional": true + } } }, "node_modules/@shlinkio/stylelint-config-css-coding-standard": { @@ -12471,10 +12483,15 @@ "uuid": "^9.0.0" } }, + "@shlinkio/shlink-js-sdk": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-js-sdk/-/shlink-js-sdk-0.1.0.tgz", + "integrity": "sha512-03xlTFZH/XbjTDxrMJPD+fhJp+Vysb/lS4MKyOodcxdFieIeiBogCJrRuXiiAwNaXzJoxaon7T3s6yz5DVsbTQ==" + }, "@shlinkio/shlink-web-component": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.3.1.tgz", - "integrity": "sha512-9oVbAC/4kNFMWTeDOI5QSVs0SY7lF6o0me8SNcju+cBLIxPrXlsJcYOGUa0OZGcsM+aFbnRKvlCJJAFT462ymw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.3.3.tgz", + "integrity": "sha512-xoRfeAmZ1VE5UyP3z4KtuemMQJYEYcjgvmBM79+en1XHrHyf/NVBZbcIE0U77IPb9hTcCU0fWmElQFboBSXSmw==", "requires": { "@json2csv/plainjs": "^7.0.3", "bottlejs": "^2.0.1", diff --git a/package.json b/package.json index 98784e98..1772fe7c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "@json2csv/plainjs": "^7.0.3", "@reduxjs/toolkit": "^1.9.5", "@shlinkio/shlink-frontend-kit": "^0.2.0", - "@shlinkio/shlink-web-component": "^0.3.1", + "@shlinkio/shlink-js-sdk": "^0.1.0", + "@shlinkio/shlink-web-component": "^0.3.3", "bootstrap": "5.2.3", "bottlejs": "^2.0.1", "classnames": "^2.3.2", diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts deleted file mode 100644 index 602a5e76..00000000 --- a/src/api/services/ShlinkApiClient.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { orderToString, stringifyQuery } from '@shlinkio/shlink-frontend-kit'; -import type { - RegularNotFound, - ShlinkApiClient as BaseShlinkApiClient, - ShlinkCreateShortUrlData, - ShlinkDomainRedirects, - ShlinkDomainsResponse, - ShlinkEditDomainRedirects, - ShlinkEditShortUrlData, - ShlinkHealth, - ShlinkMercureInfo, - ShlinkShortUrl, - ShlinkShortUrlsListNormalizedParams, - ShlinkShortUrlsListParams, - ShlinkShortUrlsResponse, - ShlinkTags, - ShlinkTagsResponse, - ShlinkTagsStatsResponse, - ShlinkVisits, - ShlinkVisitsOverview, - ShlinkVisitsParams, -} from '@shlinkio/shlink-web-component/api-contract'; -import { - ErrorTypeV2, - ErrorTypeV3, -} from '@shlinkio/shlink-web-component/api-contract'; -import { isEmpty, isNil, reject } from 'ramda'; -import type { HttpClient, RequestOptions } from '../../common/services/HttpClient'; -import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; -import type { OptionalString } from '../../utils/utils'; - -type ApiVersion = 2 | 3; - -type ShlinkRequestOptions = { - url: string; - method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - query?: object; - body?: object; - domain?: string; -}; - -const buildShlinkBaseUrl = (url: string, version: ApiVersion) => `${url}/rest/v${version}`; -const rejectNilProps = reject(isNil); -const normalizeListParams = ( - { orderBy = {}, excludeMaxVisitsReached, excludePastValidUntil, ...rest }: ShlinkShortUrlsListParams, -): ShlinkShortUrlsListNormalizedParams => ({ - ...rest, - excludeMaxVisitsReached: excludeMaxVisitsReached === true ? 'true' : undefined, - excludePastValidUntil: excludePastValidUntil === true ? 'true' : undefined, - orderBy: orderToString(orderBy), -}); -const isRegularNotFound = (error: unknown): error is RegularNotFound => { - if (error === null || !(typeof error === 'object' && 'type' in error && 'status' in error)) { - return false; - } - - return (error.type === ErrorTypeV2.NOT_FOUND || error.type === ErrorTypeV3.NOT_FOUND) && error.status === 404; -}; - -export class ShlinkApiClient implements BaseShlinkApiClient { - private apiVersion: ApiVersion; - - public constructor( - private readonly httpClient: HttpClient, - public readonly baseUrl: string, - public readonly apiKey: string, - ) { - this.apiVersion = 3; - } - - public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => - this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>( - { url: '/short-urls', query: normalizeListParams(params) }, - ).then(({ shortUrls }) => shortUrls); - - public readonly createShortUrl = async (options: ShlinkCreateShortUrlData): Promise => { - const body = reject((value) => isEmpty(value) || isNil(value), options as any); - return this.performRequest({ url: '/short-urls', method: 'POST', body }); - }; - - public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise => - this.performRequest<{ visits: ShlinkVisits }>({ url: `/short-urls/${shortCode}/visits`, query }) - .then(({ visits }) => visits); - - public readonly getTagVisits = async (tag: string, query?: Omit): Promise => - this.performRequest<{ visits: ShlinkVisits }>({ url: `/tags/${tag}/visits`, query }).then(({ visits }) => visits); - - public readonly getDomainVisits = async (domain: string, query?: Omit): Promise => - this.performRequest<{ visits: ShlinkVisits }>({ url: `/domains/${domain}/visits`, query }).then(({ visits }) => visits); - - public readonly getOrphanVisits = async (query?: Omit): Promise => - this.performRequest<{ visits: ShlinkVisits }>({ url: '/visits/orphan', query }).then(({ visits }) => visits); - - public readonly getNonOrphanVisits = async (query?: Omit): Promise => - this.performRequest<{ visits: ShlinkVisits }>({ url: '/visits/non-orphan', query }).then(({ visits }) => visits); - - public readonly getVisitsOverview = async (): Promise => - this.performRequest<{ visits: ShlinkVisitsOverview }>({ url: '/visits' }).then(({ visits }) => visits); - - public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise => - this.performRequest({ url: `/short-urls/${shortCode}`, query: { domain } }); - - public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise => - this.performEmptyRequest({ url: `/short-urls/${shortCode}`, method: 'DELETE', query: { domain } }); - - public readonly updateShortUrl = async ( - shortCode: string, - domain: OptionalString, - body: ShlinkEditShortUrlData, - ): Promise => - this.performRequest({ url: `/short-urls/${shortCode}`, method: 'PATCH', query: { domain }, body }); - - public readonly listTags = async (): Promise => - this.performRequest<{ tags: ShlinkTagsResponse }>({ url: '/tags', query: { withStats: 'true' } }) - .then(({ tags }) => tags) - .then(({ data, stats }) => ({ tags: data, stats })); - - public readonly tagsStats = async (): Promise => - this.performRequest<{ tags: ShlinkTagsStatsResponse }>({ url: '/tags/stats' }) - .then(({ tags }) => tags) - .then(({ data }) => ({ tags: data.map(({ tag }) => tag), stats: data })); - - public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> => - this.performEmptyRequest({ url: '/tags', method: 'DELETE', query: { tags } }).then(() => ({ tags })); - - public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> => - this.performEmptyRequest({ - url: '/tags', - method: 'PUT', - body: { oldName, newName }, - }).then(() => ({ oldName, newName })); - - public readonly health = async (domain?: string): Promise => this.performRequest( - { url: '/health', domain }, - ); - - public readonly mercureInfo = async (): Promise => - this.performRequest({ url: '/mercure-info' }); - - public readonly listDomains = async (): Promise => - this.performRequest<{ domains: ShlinkDomainsResponse }>({ url: '/domains' }).then(({ domains }) => domains); - - public readonly editDomainRedirects = async ( - domainRedirects: ShlinkEditDomainRedirects, - ): Promise => - this.performRequest({ url: '/domains/redirects', method: 'PATCH', body: domainRedirects }); - - private readonly performRequest = async (requestOptions: ShlinkRequestOptions): Promise => - this.httpClient.fetchJson(...this.toFetchParams(requestOptions)).catch( - this.handleFetchError(() => this.httpClient.fetchJson(...this.toFetchParams(requestOptions))), - ); - - private readonly performEmptyRequest = async (requestOptions: ShlinkRequestOptions): Promise => - this.httpClient.fetchEmpty(...this.toFetchParams(requestOptions)).catch( - this.handleFetchError(() => this.httpClient.fetchEmpty(...this.toFetchParams(requestOptions))), - ); - - private readonly toFetchParams = ({ - url, - method = 'GET', - query = {}, - body, - domain, - }: ShlinkRequestOptions): [string, RequestOptions] => { - const normalizedQuery = stringifyQuery(rejectNilProps(query)); - const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`; - const baseUrl = domain ? replaceAuthorityFromUri(this.baseUrl, domain) : this.baseUrl; - - return [`${buildShlinkBaseUrl(baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, { - method, - body: body && JSON.stringify(body), - headers: { 'X-Api-Key': this.apiKey }, - }]; - }; - - private readonly handleFetchError = (retryFetch: Function) => (e: unknown) => { - if (!isRegularNotFound(e)) { - throw e; - } - - // If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to - // v2 and retry - this.apiVersion = 2; - return retryFetch(); - }; -} diff --git a/src/api/services/ShlinkApiClientBuilder.ts b/src/api/services/ShlinkApiClientBuilder.ts index d5cae053..7b77c46f 100644 --- a/src/api/services/ShlinkApiClientBuilder.ts +++ b/src/api/services/ShlinkApiClientBuilder.ts @@ -1,8 +1,8 @@ -import type { HttpClient } from '../../common/services/HttpClient'; +import type { HttpClient } from '@shlinkio/shlink-js-sdk'; +import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; import type { GetState } from '../../container/types'; import type { ServerWithId } from '../../servers/data'; import { hasServerData } from '../../servers/data'; -import { ShlinkApiClient } from './ShlinkApiClient'; const apiClients: Record = {}; @@ -18,16 +18,15 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => { }; export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => { - const { url, apiKey } = isGetState(getStateOrSelectedServer) + const { url: baseUrl, apiKey } = isGetState(getStateOrSelectedServer) ? getSelectedServerFromState(getStateOrSelectedServer) : getStateOrSelectedServer; - const clientKey = `${url}_${apiKey}`; + const serverKey = `${apiKey}_${baseUrl}`; - if (!apiClients[clientKey]) { - apiClients[clientKey] = new ShlinkApiClient(httpClient, url, apiKey); - } + const apiClient = apiClients[serverKey] ?? new ShlinkApiClient(httpClient, { apiKey, baseUrl }); + apiClients[serverKey] = apiClient; - return apiClients[clientKey]; + return apiClient; }; export type ShlinkApiClientBuilder = ReturnType; diff --git a/src/common/services/HttpClient.ts b/src/common/services/HttpClient.ts deleted file mode 100644 index 3065c3ca..00000000 --- a/src/common/services/HttpClient.ts +++ /dev/null @@ -1,46 +0,0 @@ -type Fetch = typeof window.fetch; - -export type RequestOptions = { - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - body?: string; - headers?: Record; -}; - -const applicationJsonHeader = { 'Content-Type': 'application/json' }; -const withJsonContentType = (options?: RequestOptions): RequestInit | undefined => { - if (!options?.body) { - return options; - } - - return options ? { - ...options, - headers: { - ...(options.headers ?? {}), - ...applicationJsonHeader, - }, - } : { - headers: applicationJsonHeader, - }; -}; - -export class HttpClient { - constructor(private readonly fetch: Fetch) {} - - public readonly fetchJson = (url: string, options?: RequestOptions): Promise => - this.fetch(url, withJsonContentType(options)).then(async (resp) => { - const json = await resp.json(); - - if (!resp.ok) { - throw json; - } - - return json as T; - }); - - public readonly fetchEmpty = (url: string, options?: RequestOptions): Promise => - this.fetch(url, withJsonContentType(options)).then(async (resp) => { - if (!resp.ok) { - throw await resp.json(); - } - }); -} diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index 7bcb9f38..309c7751 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -1,3 +1,4 @@ +import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/browser'; import { ShlinkWebComponent } from '@shlinkio/shlink-web-component'; import type Bottle from 'bottlejs'; import type { ConnectDecorator } from '../../container/types'; @@ -8,14 +9,13 @@ import { MainHeader } from '../MainHeader'; import { ScrollToTop } from '../ScrollToTop'; import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer'; import { ShlinkWebComponentContainer } from '../ShlinkWebComponentContainer'; -import { HttpClient } from './HttpClient'; export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Services bottle.constant('window', window); bottle.constant('console', console); bottle.constant('fetch', window.fetch.bind(window)); - bottle.service('HttpClient', HttpClient, 'fetch'); + bottle.service('HttpClient', FetchHttpClient, 'fetch'); // Components bottle.serviceFactory('ScrollToTop', () => ScrollToTop); diff --git a/src/servers/reducers/remoteServers.ts b/src/servers/reducers/remoteServers.ts index a1995d71..1ae5496c 100644 --- a/src/servers/reducers/remoteServers.ts +++ b/src/servers/reducers/remoteServers.ts @@ -1,5 +1,5 @@ +import type { HttpClient } from '@shlinkio/shlink-js-sdk'; import pack from '../../../package.json'; -import type { HttpClient } from '../../common/services/HttpClient'; import { createAsyncThunk } from '../../utils/helpers/redux'; import type { ServerData } from '../data'; import { hasServerData } from '../data'; @@ -10,7 +10,7 @@ const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) export const fetchServers = (httpClient: HttpClient) => createAsyncThunk( 'shlink/remoteServers/fetchServers', async (_: void, { dispatch }): Promise => { - const resp = await httpClient.fetchJson(`${pack.homepage}/servers.json`); + const resp = await httpClient.jsonRequest(`${pack.homepage}/servers.json`); const result = responseToServersList(resp); dispatch(createServers(result)); diff --git a/src/servers/reducers/selectedServer.ts b/src/servers/reducers/selectedServer.ts index 4d9ce70a..e67a8c0d 100644 --- a/src/servers/reducers/selectedServer.ts +++ b/src/servers/reducers/selectedServer.ts @@ -42,8 +42,8 @@ export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr } try { - const { health } = buildShlinkApiClient(selectedServer); - const { version, printableVersion } = await getServerVersion(selectedServer, health); + const apiClient = buildShlinkApiClient(selectedServer); + const { version, printableVersion } = await getServerVersion(selectedServer, () => apiClient.health()); return { ...selectedServer, diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts deleted file mode 100644 index 90094250..00000000 --- a/test/api/services/ShlinkApiClient.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -import type { - ShlinkDomain, - ShlinkShortUrl, - ShlinkShortUrlsOrder, - ShlinkVisits, - ShlinkVisitsOverview, -} from '@shlinkio/shlink-web-component/api-contract'; -import { ErrorTypeV2, ErrorTypeV3 } from '@shlinkio/shlink-web-component/api-contract'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { HttpClient } from '../../../src/common/services/HttpClient'; -import type { OptionalString } from '../../../src/utils/utils'; - -describe('ShlinkApiClient', () => { - const fetchJson = vi.fn().mockResolvedValue({}); - const fetchEmpty = vi.fn().mockResolvedValue(undefined); - const httpClient = fromPartial({ fetchJson, fetchEmpty }); - const buildApiClient = () => new ShlinkApiClient(httpClient, '', ''); - const shortCodesWithDomainCombinations: [string, OptionalString][] = [ - ['abc123', null], - ['abc123', undefined], - ['abc123', 'example.com'], - ]; - - describe('listShortUrls', () => { - const expectedList = ['foo', 'bar']; - - it('properly returns short URLs list', async () => { - fetchJson.mockResolvedValue({ shortUrls: expectedList }); - const { listShortUrls } = buildApiClient(); - - const actualList = await listShortUrls(); - - expect(expectedList).toEqual(actualList); - }); - - it.each([ - [{ field: 'visits', dir: 'DESC' } as ShlinkShortUrlsOrder, '?orderBy=visits-DESC'], - [{ field: 'longUrl', dir: 'ASC' } as ShlinkShortUrlsOrder, '?orderBy=longUrl-ASC'], - [{ field: 'longUrl', dir: undefined } as ShlinkShortUrlsOrder, ''], - ])('parses orderBy in params', async (orderBy, expectedOrderBy) => { - fetchJson.mockResolvedValue({ data: expectedList }); - const { listShortUrls } = buildApiClient(); - - await listShortUrls({ orderBy }); - - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining(`/short-urls${expectedOrderBy}`), - expect.anything(), - ); - }); - - it.each([ - [{}, ''], - [{ excludeMaxVisitsReached: false }, ''], - [{ excludeMaxVisitsReached: true }, '?excludeMaxVisitsReached=true'], - [{ excludePastValidUntil: false }, ''], - [{ excludePastValidUntil: true }, '?excludePastValidUntil=true'], - [ - { excludePastValidUntil: true, excludeMaxVisitsReached: true }, - '?excludeMaxVisitsReached=true&excludePastValidUntil=true', - ], - ])('parses disabled URLs params', async (params, expectedQuery) => { - fetchJson.mockResolvedValue({ data: expectedList }); - const { listShortUrls } = buildApiClient(); - - await listShortUrls(params); - - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining(`/short-urls${expectedQuery}`), - expect.anything(), - ); - }); - }); - - describe('createShortUrl', () => { - const shortUrl = { - bar: 'foo', - }; - - it('returns create short URL', async () => { - fetchJson.mockResolvedValue(shortUrl); - const { createShortUrl } = buildApiClient(); - const result = await createShortUrl({ longUrl: '' }); - - expect(result).toEqual(shortUrl); - }); - - it('removes all empty options', async () => { - fetchJson.mockResolvedValue({ data: shortUrl }); - const { createShortUrl } = buildApiClient(); - - await createShortUrl({ longUrl: 'bar', customSlug: undefined, maxVisits: null }); - - expect(fetchJson).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ - body: JSON.stringify({ longUrl: 'bar' }), - })); - }); - }); - - describe('getShortUrlVisits', () => { - it('properly returns short URL visits', async () => { - const expectedVisits = ['foo', 'bar']; - fetchJson.mockResolvedValue({ - visits: { - data: expectedVisits, - }, - }); - const { getShortUrlVisits } = buildApiClient(); - - const actualVisits = await getShortUrlVisits('abc123', {}); - - expect({ data: expectedVisits }).toEqual(actualVisits); - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining('/short-urls/abc123/visits'), - expect.objectContaining({ method: 'GET' }), - ); - }); - }); - - describe('getTagVisits', () => { - it('properly returns tag visits', async () => { - const expectedVisits = ['foo', 'bar']; - fetchJson.mockResolvedValue({ - visits: { - data: expectedVisits, - }, - }); - const { getTagVisits } = buildApiClient(); - - const actualVisits = await getTagVisits('foo', {}); - - expect({ data: expectedVisits }).toEqual(actualVisits); - expect(fetchJson).toHaveBeenCalledWith(expect.stringContaining('/tags/foo/visits'), expect.objectContaining({ - method: 'GET', - })); - }); - }); - - describe('getDomainVisits', () => { - it('properly returns domain visits', async () => { - const expectedVisits = ['foo', 'bar']; - fetchJson.mockResolvedValue({ - visits: { - data: expectedVisits, - }, - }); - const { getDomainVisits } = buildApiClient(); - - const actualVisits = await getDomainVisits('foo.com', {}); - - expect({ data: expectedVisits }).toEqual(actualVisits); - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining('/domains/foo.com/visits'), - expect.objectContaining({ method: 'GET' }), - ); - }); - }); - - describe('getShortUrl', () => { - it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => { - const expectedShortUrl = { foo: 'bar' }; - fetchJson.mockResolvedValue(expectedShortUrl); - const { getShortUrl } = buildApiClient(); - const expectedQuery = domain ? `?domain=${domain}` : ''; - - const result = await getShortUrl(shortCode, domain); - - expect(expectedShortUrl).toEqual(result); - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), - expect.objectContaining({ method: 'GET' }), - ); - }); - }); - - describe('updateShortUrl', () => { - it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => { - const meta = { - maxVisits: 50, - validSince: '2025-01-01T10:00:00+01:00', - }; - const expectedResp = fromPartial({}); - fetchJson.mockResolvedValue(expectedResp); - const { updateShortUrl } = buildApiClient(); - const expectedQuery = domain ? `?domain=${domain}` : ''; - - const result = await updateShortUrl(shortCode, domain, meta); - - expect(expectedResp).toEqual(result); - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), - expect.objectContaining({ method: 'PATCH' }), - ); - }); - }); - - describe('listTags', () => { - it('properly returns list of tags', async () => { - const expectedTags = ['foo', 'bar']; - fetchJson.mockResolvedValue({ - tags: { - data: expectedTags, - }, - }); - const { listTags } = buildApiClient(); - - const result = await listTags(); - - expect({ tags: expectedTags }).toEqual(result); - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining('/tags'), - expect.objectContaining({ method: 'GET' }), - ); - }); - }); - - describe('tagsStats', () => { - it('can use /tags/stats endpoint', async () => { - const expectedTags = ['foo', 'bar']; - const expectedStats = expectedTags.map((tag) => ({ tag, shortUrlsCount: 10, visitsCount: 10 })); - - fetchJson.mockResolvedValue({ - tags: { - data: expectedStats, - }, - }); - const { tagsStats } = buildApiClient(); - - const result = await tagsStats(); - - expect({ tags: expectedTags, stats: expectedStats }).toEqual(result); - expect(fetchJson).toHaveBeenCalledWith( - expect.stringContaining('/tags/stats'), - expect.objectContaining({ method: 'GET' }), - ); - }); - }); - - describe('deleteTags', () => { - it('properly deletes provided tags', async () => { - const tags = ['foo', 'bar']; - const { deleteTags } = buildApiClient(); - - await deleteTags(tags); - - expect(fetchEmpty).toHaveBeenCalledWith( - expect.stringContaining(`/tags?${tags.map((tag) => `tags%5B%5D=${tag}`).join('&')}`), - expect.objectContaining({ method: 'DELETE' }), - ); - }); - }); - - describe('editTag', () => { - it('properly edits provided tag', async () => { - const oldName = 'foo'; - const newName = 'bar'; - const { editTag } = buildApiClient(); - - await editTag(oldName, newName); - - expect(fetchEmpty).toHaveBeenCalledWith(expect.stringContaining('/tags'), expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ oldName, newName }), - })); - }); - }); - - describe('deleteShortUrl', () => { - it.each(shortCodesWithDomainCombinations)('properly deletes provided short URL', async (shortCode, domain) => { - const { deleteShortUrl } = buildApiClient(); - const expectedQuery = domain ? `?domain=${domain}` : ''; - - await deleteShortUrl(shortCode, domain); - - expect(fetchEmpty).toHaveBeenCalledWith( - expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), - expect.objectContaining({ method: 'DELETE' }), - ); - }); - }); - - describe('health', () => { - it('returns health data', async () => { - const expectedData = { - status: 'pass', - version: '1.19.0', - }; - fetchJson.mockResolvedValue(expectedData); - const { health } = buildApiClient(); - - const result = await health(); - - expect(fetchJson).toHaveBeenCalled(); - expect(result).toEqual(expectedData); - }); - }); - - describe('mercureInfo', () => { - it('returns mercure info', async () => { - const expectedData = { - token: 'abc.123.def', - mercureHubUrl: 'http://example.com/.well-known/mercure', - }; - fetchJson.mockResolvedValue(expectedData); - const { mercureInfo } = buildApiClient(); - - const result = await mercureInfo(); - - expect(fetchJson).toHaveBeenCalled(); - expect(result).toEqual(expectedData); - }); - }); - - describe('listDomains', () => { - it('returns domains', async () => { - const expectedData = { data: [fromPartial({}), fromPartial({})] }; - fetchJson.mockResolvedValue({ domains: expectedData }); - const { listDomains } = buildApiClient(); - - const result = await listDomains(); - - expect(fetchJson).toHaveBeenCalled(); - expect(result).toEqual(expectedData); - }); - }); - - describe('getVisitsOverview', () => { - it('returns visits overview', async () => { - const expectedData = fromPartial({}); - fetchJson.mockResolvedValue({ visits: expectedData }); - const { getVisitsOverview } = buildApiClient(); - - const result = await getVisitsOverview(); - - expect(fetchJson).toHaveBeenCalled(); - expect(result).toEqual(expectedData); - }); - }); - - describe('getOrphanVisits', () => { - it('returns orphan visits', async () => { - fetchJson.mockResolvedValue({ visits: fromPartial({ data: [] }) }); - const { getOrphanVisits } = buildApiClient(); - - const result = await getOrphanVisits(); - - expect(fetchJson).toHaveBeenCalled(); - expect(result).toEqual({ data: [] }); - }); - }); - - describe('getNonOrphanVisits', () => { - it('returns non-orphan visits', async () => { - fetchJson.mockResolvedValue({ visits: fromPartial({ data: [] }) }); - const { getNonOrphanVisits } = buildApiClient(); - - const result = await getNonOrphanVisits(); - - expect(fetchJson).toHaveBeenCalled(); - expect(result).toEqual({ data: [] }); - }); - }); - - describe('editDomainRedirects', () => { - it('returns the redirects', async () => { - const resp = { baseUrlRedirect: null, regular404Redirect: 'foo', invalidShortUrlRedirect: 'bar' }; - fetchJson.mockResolvedValue(resp); - const { editDomainRedirects } = buildApiClient(); - - const result = await editDomainRedirects({ domain: 'foo' }); - - expect(fetchJson).toHaveBeenCalled(); - expect(result).toEqual(resp); - }); - - it.each([ - ['NOT_FOUND'], - [ErrorTypeV2.NOT_FOUND], - [ErrorTypeV3.NOT_FOUND], - ])('retries request if API version is not supported', async (type) => { - fetchJson - .mockRejectedValueOnce({ type, detail: 'detail', title: 'title', status: 404 }) - .mockResolvedValue({}); - const { editDomainRedirects } = buildApiClient(); - - await editDomainRedirects({ domain: 'foo' }); - - expect(fetchJson).toHaveBeenCalledTimes(2); - expect(fetchJson).toHaveBeenNthCalledWith(1, expect.stringContaining('/v3/'), expect.anything()); - expect(fetchJson).toHaveBeenNthCalledWith(2, expect.stringContaining('/v2/'), expect.anything()); - }); - }); -}); diff --git a/test/api/services/ShlinkApiClientBuilder.test.ts b/test/api/services/ShlinkApiClientBuilder.test.ts index af5d3faa..af89f43a 100644 --- a/test/api/services/ShlinkApiClientBuilder.test.ts +++ b/test/api/services/ShlinkApiClientBuilder.test.ts @@ -12,29 +12,26 @@ describe('ShlinkApiClientBuilder', () => { it('creates new instances when provided params are different', async () => { const builder = createBuilder(); - const [firstApiClient, secondApiClient, thirdApiClient] = await Promise.all([ - builder(server({ url: 'foo', apiKey: 'bar' })), - builder(server({ url: 'bar', apiKey: 'bar' })), - builder(server({ url: 'bar', apiKey: 'foo' })), - ]); + const firstApiClient = builder(server({ url: 'foo', apiKey: 'bar' })); + const secondApiClient = builder(server({ url: 'bar', apiKey: 'bar' })); + const thirdApiClient = builder(server({ url: 'bar', apiKey: 'foo' })); - expect(firstApiClient).not.toBe(secondApiClient); - expect(firstApiClient).not.toBe(thirdApiClient); - expect(secondApiClient).not.toBe(thirdApiClient); + expect(firstApiClient === secondApiClient).toEqual(false); + expect(firstApiClient === thirdApiClient).toEqual(false); + expect(secondApiClient === thirdApiClient).toEqual(false); }); - it('returns existing instances when provided params are the same', async () => { + it('returns existing instances when provided params are the same', () => { const builder = createBuilder(); const selectedServer = server({ url: 'foo', apiKey: 'bar' }); - const [firstApiClient, secondApiClient, thirdApiClient] = await Promise.all([ - builder(selectedServer), - builder(selectedServer), - builder(selectedServer), - ]); - expect(firstApiClient).toBe(secondApiClient); - expect(firstApiClient).toBe(thirdApiClient); - expect(secondApiClient).toBe(thirdApiClient); + const firstApiClient = builder(selectedServer); + const secondApiClient = builder(selectedServer); + const thirdApiClient = builder(selectedServer); + + expect(firstApiClient === secondApiClient).toEqual(true); + expect(firstApiClient === thirdApiClient).toEqual(true); + expect(secondApiClient === thirdApiClient).toEqual(true); }); it('does not fetch from state when provided param is already selected server', () => { @@ -42,7 +39,7 @@ describe('ShlinkApiClientBuilder', () => { const apiKey = 'apiKey'; const apiClient = buildShlinkApiClient(fromPartial({}))(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 + expect(apiClient['serverInfo'].baseUrl).toEqual(url); // eslint-disable-line @typescript-eslint/dot-notation + expect(apiClient['serverInfo'].apiKey).toEqual(apiKey); // eslint-disable-line @typescript-eslint/dot-notation }); }); diff --git a/test/common/services/HttpClient.test.ts b/test/common/services/HttpClient.test.ts deleted file mode 100644 index 9df724b8..00000000 --- a/test/common/services/HttpClient.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { RequestOptions } from '../../../src/common/services/HttpClient'; -import { HttpClient } from '../../../src/common/services/HttpClient'; - -describe('HttpClient', () => { - const fetch = vi.fn(); - const httpClient = new HttpClient(fetch); - const requestOptions = (options: Omit): RequestOptions => ({ - method: 'GET', - ...options, - }); - - describe('fetchJson', () => { - it('throws json on success', async () => { - const theError = { error: true, foo: 'bar' }; - fetch.mockResolvedValue({ json: () => theError, ok: false }); - - await expect(httpClient.fetchJson('')).rejects.toEqual(theError); - }); - - it.each([ - [undefined], - [requestOptions({})], - [requestOptions({ body: undefined })], - [requestOptions({ body: '' })], - ])('return json on failure', async (options) => { - const theJson = { foo: 'bar' }; - fetch.mockResolvedValue({ json: () => theJson, ok: true }); - - const result = await httpClient.fetchJson('the_url', options); - - expect(result).toEqual(theJson); - expect(fetch).toHaveBeenCalledWith('the_url', options); - }); - - it.each([ - [requestOptions({ body: 'the_body' })], - [requestOptions({ - body: 'the_body', - headers: { - 'Content-Type': 'text/plain', - }, - })], - ])('forwards JSON content-type when appropriate', async (options) => { - const theJson = { foo: 'bar' }; - fetch.mockResolvedValue({ json: () => theJson, ok: true }); - - const result = await httpClient.fetchJson('the_url', options); - - expect(result).toEqual(theJson); - expect(fetch).toHaveBeenCalledWith('the_url', expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - })); - }); - }); - - describe('fetchEmpty', () => { - it('returns empty on success', async () => { - fetch.mockResolvedValue({ ok: true }); - - const result = await httpClient.fetchEmpty(''); - - expect(result).not.toBeDefined(); - }); - - it('throws error on failure', async () => { - const theError = { error: true, foo: 'bar' }; - fetch.mockResolvedValue({ json: () => theError, ok: false }); - - await expect(httpClient.fetchEmpty('')).rejects.toEqual(theError); - }); - }); -}); diff --git a/test/servers/reducers/remoteServers.test.ts b/test/servers/reducers/remoteServers.test.ts index 40bcd6f7..1026cbcd 100644 --- a/test/servers/reducers/remoteServers.test.ts +++ b/test/servers/reducers/remoteServers.test.ts @@ -1,12 +1,12 @@ +import type { HttpClient } from '@shlinkio/shlink-js-sdk'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { HttpClient } from '../../../src/common/services/HttpClient'; import { fetchServers } from '../../../src/servers/reducers/remoteServers'; describe('remoteServersReducer', () => { describe('fetchServers', () => { const dispatch = vi.fn(); - const fetchJson = vi.fn(); - const httpClient = fromPartial({ fetchJson }); + const jsonRequest = vi.fn(); + const httpClient = fromPartial({ jsonRequest }); it.each([ [ @@ -76,7 +76,7 @@ describe('remoteServersReducer', () => { ['', {}], [{}, {}], ])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => { - fetchJson.mockResolvedValue(mockedValue); + jsonRequest.mockResolvedValue(mockedValue); const doFetchServers = fetchServers(httpClient); await doFetchServers()(dispatch, vi.fn(), {}); @@ -84,7 +84,7 @@ describe('remoteServersReducer', () => { expect(dispatch).toHaveBeenCalledTimes(3); expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ payload: expectedNewServers })); expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({ payload: undefined })); - expect(fetchJson).toHaveBeenCalledTimes(1); + expect(jsonRequest).toHaveBeenCalledTimes(1); }); }); }); diff --git a/test/servers/reducers/selectedServer.test.ts b/test/servers/reducers/selectedServer.test.ts index b89af129..738c1249 100644 --- a/test/servers/reducers/selectedServer.test.ts +++ b/test/servers/reducers/selectedServer.test.ts @@ -1,6 +1,6 @@ +import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; import { fromPartial } from '@total-typescript/shoehorn'; import { v4 as uuid } from 'uuid'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import type { ShlinkState } from '../../../src/container/types'; import type { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data'; import { From 30307de37ad8b21e540c26b7be1bcb5db75425b1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 29 Aug 2023 23:15:20 +0200 Subject: [PATCH 2/2] Adjust required code coverage --- vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index fb29b2ba..6badc9ce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -45,7 +45,7 @@ export default defineConfig({ // Required code coverage. Lower than this will make the check fail statements: 95, branches: 95, - functions: 95, + functions: 90, lines: 95, }, },