Skip to content

Commit

Permalink
Add test for shlink-api proxy route
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed May 18, 2024
1 parent c17afd1 commit 3b65c19
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 3 deletions.
6 changes: 3 additions & 3 deletions app/routes/server.$serverId.shlink-api.$method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ export async function action(
serversService: ServersService = serverContainer[ServersService.name],
createApiClient: ApiClientBuilder = serverContainer.apiClientBuilder,
authenticator: Authenticator<SessionData> = serverContainer[Authenticator.name],
console_ = console,
) {
const sessionData = await authenticator.isAuthenticated(request);
if (!sessionData) {
return problemDetails({
status: 403,
type: 'https://shlink.io/api/error/access-denied',
title: 'Access denied',
detail: 'You need to log-in in order to fetch data from Shlink server',
detail: 'You need to log-in to fetch data from Shlink',
});
}

Expand Down Expand Up @@ -89,10 +90,9 @@ export async function action(

try {
const response = await apiMethod.bind(client)(...args as Parameters<typeof apiMethod>);

return json(response);
} catch (e) {
console.error(e);
console_.error(e);
return problemDetails({
status: 500,
type: 'https://shlink.io/api/error/internal-server-error',
Expand Down
170 changes: 170 additions & 0 deletions test/routes/server.$serverId.shlink-api.$method.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk/api-contract';
import { ErrorType } from '@shlinkio/shlink-js-sdk/api-contract';
import { fromPartial } from '@total-typescript/shoehorn';
import type { Authenticator } from 'remix-auth';
import { expect } from 'vitest';
import type { SessionData } from '../../app/auth/session.server';
import { action } from '../../app/routes/server.$serverId.shlink-api.$method';
import type { ServersService } from '../../app/servers/ServersService.server';

describe('server.$serverId.shlink-api.$method', () => {
const getByPublicIdAndUser = vi.fn();
const serversService = fromPartial<ServersService>({ getByPublicIdAndUser });
const getShortUrl = async (shortCode: string) => {
if (shortCode === 'throw-error') {
throw new Error('Error getting short URL');
}
return { shortCode };
};
const editTag = (oldTag: string, newTag: string) => ({ oldTag, newTag });
const apiClient = fromPartial<ShlinkApiClient>({ getShortUrl, editTag });
const createApiClient = vi.fn().mockReturnValue(apiClient);
const isAuthenticated = vi.fn();
const authenticator = fromPartial<Authenticator<SessionData>>({ isAuthenticated });

const setUp = () => (args: ActionFunctionArgs) => action(
args,
serversService,
createApiClient,
authenticator,
fromPartial({ error: vi.fn() }),
);

it('returns error when user is not authenticated', async () => {
isAuthenticated.mockResolvedValue(null);
const action = setUp();

const resp = await action(fromPartial({ request: {} }));
const respPayload = await resp.json();

expect(resp.status).toEqual(403);
expect(respPayload).toEqual({
status: 403,
type: 'https://shlink.io/api/error/access-denied',
title: 'Access denied',
detail: 'You need to log-in to fetch data from Shlink',
});
expect(isAuthenticated).toHaveBeenCalled();
expect(getByPublicIdAndUser).not.toHaveBeenCalled();
expect(createApiClient).not.toHaveBeenCalled();
});

it('returns error if server is not found', async () => {
isAuthenticated.mockResolvedValue({ userId: 123 });
getByPublicIdAndUser.mockRejectedValue(new Error('Server not found'));
const action = setUp();

const serverId = 'abc123';
const resp = await action(fromPartial({ request: {}, params: { serverId } }));
const respPayload = await resp.json();

expect(resp.status).toEqual(404);
expect(respPayload).toEqual({
status: 404,
type: ErrorType.NOT_FOUND,
title: 'Server not found',
detail: `Server with ID ${serverId} not found`,
serverId,
});
expect(isAuthenticated).toHaveBeenCalled();
expect(getByPublicIdAndUser).toHaveBeenCalled();
expect(createApiClient).not.toHaveBeenCalled();
});

it('returns error if requested method is invalid', async () => {
isAuthenticated.mockResolvedValue({ userId: 123 });
getByPublicIdAndUser.mockResolvedValue({});
const action = setUp();

const method = 'invalid';
const resp = await action(fromPartial({ request: {}, params: { serverId: 'abc123', method } }));
const respPayload = await resp.json();

expect(resp.status).toEqual(404);
expect(respPayload).toEqual({
status: 404,
type: ErrorType.NOT_FOUND,
title: 'Action not found',
detail: `The ${method} action is not a valid Shlink SDK method`,
method,
});
expect(isAuthenticated).toHaveBeenCalled();
expect(getByPublicIdAndUser).toHaveBeenCalled();
expect(createApiClient).toHaveBeenCalled();
});

it.each([
['getShortUrl', undefined],
['editTag', undefined],
['getShortUrl', []],
['editTag', []],
['editTag', ['just_one_arg']],
])('returns error if provided args are invalid', async (method, args) => {
isAuthenticated.mockResolvedValue({ userId: 123 });
getByPublicIdAndUser.mockResolvedValue({});
const action = setUp();

const resp = await action(fromPartial({
request: fromPartial({ json: vi.fn().mockResolvedValue({ args }) }),
params: { serverId: 'abc123', method },
}));
const respPayload = await resp.json();

expect(resp.status).toEqual(400);
expect(respPayload).toEqual({
status: 400,
type: 'https://shlink.io/api/error/invalid-arguments',
title: 'Invalid arguments',
detail: `Provided arguments are not valid for ${method} action`,
args,
method,
});
expect(isAuthenticated).toHaveBeenCalled();
expect(getByPublicIdAndUser).toHaveBeenCalled();
expect(createApiClient).toHaveBeenCalled();
});

it('returns error if invoking SDK fails', async () => {
isAuthenticated.mockResolvedValue({ userId: 123 });
getByPublicIdAndUser.mockResolvedValue({});
const action = setUp();

const resp = await action(fromPartial({
request: fromPartial({ json: vi.fn().mockResolvedValue({ args: ['throw-error'] }) }),
params: { serverId: 'abc123', method: 'getShortUrl' },
}));
const respPayload = await resp.json();

expect(resp.status).toEqual(500);
expect(respPayload).toEqual({
status: 500,
type: 'https://shlink.io/api/error/internal-server-error',
title: 'Unexpected error',
detail: 'An unexpected error occurred while calling Shlink API',
});
expect(isAuthenticated).toHaveBeenCalled();
expect(getByPublicIdAndUser).toHaveBeenCalled();
expect(createApiClient).toHaveBeenCalled();
});

it.each([
['getShortUrl', ['foo'], { shortCode: 'foo' }],
['editTag', ['foo', 'bar'], { oldTag: 'foo', newTag: 'bar' }],
])('returns result from calling corresponding SDK method', async (method, args, expectedResponse) => {
isAuthenticated.mockResolvedValue({ userId: 123 });
getByPublicIdAndUser.mockResolvedValue({});
const action = setUp();

const resp = await action(fromPartial({
request: fromPartial({ json: vi.fn().mockResolvedValue({ args }) }),
params: { serverId: 'abc123', method },
}));
const respPayload = await resp.json();

expect(respPayload).toEqual(expectedResponse);
expect(isAuthenticated).toHaveBeenCalled();
expect(getByPublicIdAndUser).toHaveBeenCalled();
expect(createApiClient).toHaveBeenCalled();
});
});

0 comments on commit 3b65c19

Please sign in to comment.