diff --git a/src/app/clients/tmdb/TmdbClient.ts b/src/app/clients/tmdb/TmdbClient.ts new file mode 100644 index 0000000..8ad295b --- /dev/null +++ b/src/app/clients/tmdb/TmdbClient.ts @@ -0,0 +1,32 @@ +import { ApiClient } from '../../../core/http/ApiClient'; +import type { TMDbMovie } from '../../models'; +import { SearchMovieEndpoint } from './endpoints/SearchMovieEndpoint'; +import { RecommendedMoviesEndpoint, SimilarMoviesEndpoint } from './endpoints/MovieListEndpoint'; + +const TMDB_BASE_URL = 'https://api.themoviedb.org'; + +export class TmdbClient extends ApiClient { + private readonly searchEndpoint = new SearchMovieEndpoint(); + private readonly similarEndpoint = new SimilarMoviesEndpoint(); + private readonly recommendedEndpoint = new RecommendedMoviesEndpoint(); + + constructor( + private readonly apiKey: string, + baseUrl: string = TMDB_BASE_URL + ) { + super(baseUrl); + } + + async searchMovie(title: string, year?: number): Promise { + const results = await this.request(this.searchEndpoint, { apiKey: this.apiKey, title, year }); + return results[0]; + } + + getSimilar(tmdbId: number): Promise { + return this.request(this.similarEndpoint, { apiKey: this.apiKey, tmdbId }); + } + + getRecommendations(tmdbId: number): Promise { + return this.request(this.recommendedEndpoint, { apiKey: this.apiKey, tmdbId }); + } +} diff --git a/src/app/clients/tmdb/endpoints/MovieListEndpoint.ts b/src/app/clients/tmdb/endpoints/MovieListEndpoint.ts new file mode 100644 index 0000000..0c926d7 --- /dev/null +++ b/src/app/clients/tmdb/endpoints/MovieListEndpoint.ts @@ -0,0 +1,44 @@ +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { TMDbMovie } from '../../../models'; +import { toTMDbMovie } from './SearchMovieEndpoint'; + +interface TMDbResult { + id: number; + title: string; + release_date: string; +} + +interface TMDbListResponse { + results: TMDbResult[]; +} + +export interface MovieListParams { + apiKey: string; + tmdbId: number; +} + +export class SimilarMoviesEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(params: MovieListParams): string { + return `/3/movie/${params.tmdbId}/similar`; + } + query(params: MovieListParams): Record { + return { api_key: params.apiKey }; + } + parseResponse(json: unknown): TMDbMovie[] { + return (json as TMDbListResponse).results.map(toTMDbMovie); + } +} + +export class RecommendedMoviesEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(params: MovieListParams): string { + return `/3/movie/${params.tmdbId}/recommendations`; + } + query(params: MovieListParams): Record { + return { api_key: params.apiKey }; + } + parseResponse(json: unknown): TMDbMovie[] { + return (json as TMDbListResponse).results.map(toTMDbMovie); + } +} diff --git a/src/app/clients/tmdb/endpoints/SearchMovieEndpoint.ts b/src/app/clients/tmdb/endpoints/SearchMovieEndpoint.ts new file mode 100644 index 0000000..a4089bd --- /dev/null +++ b/src/app/clients/tmdb/endpoints/SearchMovieEndpoint.ts @@ -0,0 +1,41 @@ +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { TMDbMovie } from '../../../models'; + +interface TMDbResult { + id: number; + title: string; + release_date: string; +} + +interface TMDbSearchResponse { + results: TMDbResult[]; +} + +export interface SearchMovieParams { + apiKey: string; + title: string; + year?: number; +} + +export function toTMDbMovie(result: TMDbResult): TMDbMovie { + const year = result.release_date && result.release_date.length >= 4 ? Number(result.release_date.slice(0, 4)) : 0; + return { id: result.id, title: result.title, year }; +} + +export class SearchMovieEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + + path(): string { + return '/3/search/movie'; + } + + query(params: SearchMovieParams): Record { + const query: Record = { api_key: params.apiKey, query: params.title }; + if (params.year) query.year = String(params.year); + return query; + } + + parseResponse(json: unknown): TMDbMovie[] { + return (json as TMDbSearchResponse).results.map(toTMDbMovie); + } +} diff --git a/test/app/clients/tmdb/TmdbClient.test.ts b/test/app/clients/tmdb/TmdbClient.test.ts new file mode 100644 index 0000000..dd85985 --- /dev/null +++ b/test/app/clients/tmdb/TmdbClient.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TmdbClient } from '../../../../src/app/clients/tmdb/TmdbClient'; + +describe('TmdbClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('searchMovie returns the first result mapped to a TMDbMovie', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ results: [{ id: 438631, title: 'Dune', release_date: '2021-09-15' }] }), + }); + + const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org'); + const result = await client.searchMovie('Dune', 2021); + + expect(result).toEqual({ id: 438631, title: 'Dune', year: 2021 }); + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('/3/search/movie'); + expect(url).toContain('query=Dune'); + expect(url).toContain('year=2021'); + }); + + it('searchMovie returns undefined when there are no results', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) }); + + const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org'); + const result = await client.searchMovie('Nonexistent'); + expect(result).toBeUndefined(); + }); + + it('getSimilar and getRecommendations hit the right paths', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) }); + + const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org'); + await client.getSimilar(438631); + await client.getRecommendations(438631); + + expect(fetchMock.mock.calls[0][0]).toContain('/3/movie/438631/similar'); + expect(fetchMock.mock.calls[1][0]).toContain('/3/movie/438631/recommendations'); + }); +});