Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: FW-57 retrieve existing playlists #25

Merged
merged 9 commits into from
Feb 18, 2025
85 changes: 85 additions & 0 deletions src/lib/clients/playlists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { Playlist } from '@/lib/playlists';
import { PlaylistsHTTPClient } from './playlists';
import { FakeHttpClient, HttpResponse, Method } from './http';
import { AuthHeaderBuilderStub, AuthHeaderBuilder } from './auth';

describe('PlaylistsHTTPClient', () => {
let url: string;
let token: string;
let name: string;
let limit: number;
let httpClient: FakeHttpClient;
let response: HttpResponse;
let authHeaderBuilder: AuthHeaderBuilder;

beforeEach(() => {
url = 'http://some_url';
token = 'my-token';
name = 'Chill';
limit = 5;
response = {
data: [
{
name: 'Chill Vibes',
description: 'Some description',
isPublic: true,
},
{ name: 'Chill Hits', description: 'Some description', isPublic: true },
],
status: 200,
};
httpClient = new FakeHttpClient(response);
authHeaderBuilder = new AuthHeaderBuilderStub();
});

it('should call the client with the correct parameters', async () => {
const headers = { something: 'value' };
const client = new PlaylistsHTTPClient(
url,
httpClient,
new AuthHeaderBuilderStub(headers)
);
vi.spyOn(httpClient, 'send');

await client.searchPlaylists(token, name, limit);

expect(httpClient.send).toHaveBeenCalledWith({
url: `${url}/playlists/search`,
method: Method.Get,
params: { name: name, limit: limit },
headers: headers,
});
});

it('should return the list of playlists returned by the HTTP client', async () => {
const client = new PlaylistsHTTPClient(url, httpClient, authHeaderBuilder);

const actual = await client.searchPlaylists(token, name, limit);

const expected = [
new Playlist('Chill Vibes', true, 'Some description'),
new Playlist('Chill Hits', true, 'Some description'),
];
expect(actual).toEqual(expected);
});

it('should throw an error if the HTTP client fails', async () => {
const errorMessage = 'Request failed';
httpClient.setSendErrorMessage(errorMessage);
const client = new PlaylistsHTTPClient(url, httpClient, authHeaderBuilder);

await expect(client.searchPlaylists(token, name, limit)).rejects.toThrow(
errorMessage
);
});

it('should throw an error if the header builder fails', async () => {
vi.spyOn(authHeaderBuilder, 'buildHeader').mockImplementation(() => {
throw new Error('test Error');
});
const client = new PlaylistsHTTPClient(url, httpClient, authHeaderBuilder);

await expect(client.searchPlaylists(token, name, limit)).rejects.toThrow();
});
});
60 changes: 60 additions & 0 deletions src/lib/clients/playlists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Playlist } from '../playlists';
import { AuthHeaderBuilder, BaseAuthHeaderBuilder } from './auth';
import { HttpClient, Method } from './http';

export interface PlaylistsClient {
searchPlaylists(
_token: string,
_name: string,
_limit: number
): Promise<Playlist[]>;
}

export class PlaylistsHTTPClient implements PlaylistsClient {
private url: string;
private httpClient: HttpClient;
private httpAuthHeaderBuilder: AuthHeaderBuilder;

constructor(
url: string,
httpClient: HttpClient,
httpAuthHeaderBuilder: BaseAuthHeaderBuilder
) {
this.url = url;
this.httpClient = httpClient;
this.httpAuthHeaderBuilder = httpAuthHeaderBuilder;
}

async searchPlaylists(
token: string,
name: string,
limit: number
): Promise<Playlist[]> {
const authHeader = await this.httpAuthHeaderBuilder.buildHeader(token);
return this.httpClient
.send({
url: `${this.url}/playlists/search`,
method: Method.Get,
params: { name, limit },
headers: authHeader,
})
.then((response) =>
response.data.map(
(playlist: any) =>
new Playlist(playlist.name, playlist.isPublic, playlist.description)
)
);
}
}

export class PlaylistsClientStub implements PlaylistsClient {
private searchPlaylistResult: Playlist[];

constructor(result: Playlist[] = []) {
this.searchPlaylistResult = result;
}

async searchPlaylists(..._: any[]): Promise<Playlist[]> {
return this.searchPlaylistResult;
}
}
31 changes: 31 additions & 0 deletions src/lib/playlists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export class Playlist {
private name: string;
private description: string | undefined = undefined;
private isPublic: boolean;

constructor(name: string, isPublic: boolean, description?: string) {
this.name = name;
this.isPublic = isPublic;
this.description = description;
}

getName(): string {
return this.name;
}

getDescription(): string | undefined {
return this.description;
}

getIsPublic(): boolean {
return this.isPublic;
}

toPrimitives() {
return {
name: this.name,
description: this.description,
isPublic: this.isPublic,
};
}
}
171 changes: 171 additions & 0 deletions src/pages/api/playlists/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, it, vi, expect, beforeEach } from 'vitest';
import { NextApiRequest, NextApiResponse } from 'next';
import {
createSearchPlaylistHandler,
SearchPlaylistHandlerParams,
} from './search';
import { PlaylistsClientStub } from '@/lib/clients/playlists';
import { Playlist } from '@/lib/playlists';
import { getToken } from 'next-auth/jwt';

vi.mock('next-auth/jwt', () => ({
getToken: vi.fn(),
}));

describe('createSearchPlaylistHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getToken).mockResolvedValue({ accessToken: 'mocked-token' });
});

function createMockRequest(
query: any = { name: 'Chill Vibes' }
): NextApiRequest {
return { query } as unknown as NextApiRequest;
}

function createMockResponse(): NextApiResponse {
const response = {} as NextApiResponse;
response.status = vi.fn().mockReturnValue(response);
response.json = vi.fn().mockReturnValue(response);
return response;
}

function createHandler(
{ client, defaultLimit, maxLimit }: SearchPlaylistHandlerParams = {
client: new PlaylistsClientStub(),
defaultLimit: 5,
maxLimit: 10,
}
): (_request: NextApiRequest, _response: NextApiResponse) => Promise<void> {
return createSearchPlaylistHandler({ client, defaultLimit, maxLimit });
}

it.each([
{ _title: 'not a number', limit: 'invalid' },
{ _title: 'bigger than maximum', limit: '20' },
])('should return 400 if limit is $_title', async ({ limit }) => {
const request = createMockRequest({ name: 'test', limit });
const response = createMockResponse();

createHandler()(request, response);

expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith({
message: `"limit" should be a number in interval [1, 10]`,
});
});

it.each([
{
_title: 'name is missing',
variable: 'name',
name: null,
},
{
_title: 'name is not a string',
variable: 'name',
name: 42,
},
])('should return 400 if $_title', async ({ variable, name }) => {
const request = createMockRequest({ name: name });
const response = createMockResponse();

createHandler()(request, response);

expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith({
message: `"${variable}" should be provided as a string`,
});
});

it.each([{ limit: undefined }, { limit: '4' }])(
'should search with the provided arguments (limit: $limit)',
async ({ limit }) => {
const args: { name: string } = {
name: 'name',
};
const request = createMockRequest({ ...args, limit: limit });
const client = new PlaylistsClientStub();
vi.spyOn(client, 'searchPlaylists');

const defaultLimit = 5;

await createHandler({
client: client,
defaultLimit: defaultLimit,
maxLimit: 10,
})(request, createMockResponse());

expect(client.searchPlaylists).toBeCalledWith(
'mocked-token',
args.name,
limit ? parseInt(limit, 10) : defaultLimit
);
}
);

it('should return backend client results', async () => {
const retrievedPlaylists = [
new Playlist('Chill Vibes', true, 'Relaxing music'),
new Playlist('Workout Hits', false, 'High energy music'),
];
const client = new PlaylistsClientStub(retrievedPlaylists);
const response = createMockResponse();

const handler = createHandler({
client: client,
defaultLimit: 5,
maxLimit: 10,
});
await handler(createMockRequest(), response);

const expected = {
playlists: [
{ name: 'Chill Vibes', description: 'Relaxing music', isPublic: true },
{
name: 'Workout Hits',
description: 'High energy music',
isPublic: false,
},
],
message: 'Playlists successfully retrieved',
};

expect(response.status).toBeCalledWith(200);
expect(response.json).toBeCalledWith(expected);
});

it('should return an error if search fails', async () => {
const client = new PlaylistsClientStub();
vi.spyOn(client, 'searchPlaylists').mockImplementation(() => {
throw new Error('test error');
});
const response = createMockResponse();

const handler = createHandler({
client: client,
defaultLimit: 5,
maxLimit: 10,
});
await handler(createMockRequest(), response);

expect(response.status).toBeCalledWith(500);
expect(response.json).toBeCalledWith({
message: 'Unexpected error, cannot retrieve playlists',
});
});

it('should return 401 if token is not provided', async () => {
vi.mocked(getToken).mockResolvedValue(null);
const response = createMockResponse();

const handler = createHandler();
await handler(createMockRequest(), response);

expect(response.status).toBeCalledWith(401);
expect(response.json).toBeCalledWith({
message: 'Unauthorized',
});
});
});
Loading