diff --git a/src/integration/movies/internal/converters.spec.ts b/src/integration/movies/internal/converters.spec.ts new file mode 100644 index 0000000..674bb79 --- /dev/null +++ b/src/integration/movies/internal/converters.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from '@jest/globals'; +import movie3532674 from '../../../resources/movies/3532674.json'; +import movie5979300 from '../../../resources/movies/5979300.json'; +import movie11043689 from '../../../resources/movies/11043689.json'; +import movie11528860 from '../../../resources/movies/11528860.json'; +import { normalizeRawInternalData } from './converters'; + +describe('normalizeRawInternalData', () => { + it('normalizes data correctly for 3532674', () => { + const result = normalizeRawInternalData(movie3532674); + expect(result).toEqual({ + availableLanguages: ['de', 'en'], + duration: 119, + imdbId: 'tt0401792', + internalId: 3532674, + localDescription: + 'Im Sündenpfuhl Sin City werden drei Geschichten parallel erzählt: Der Polizist Hartigan jagt einen Pädophilen, der Outlaw Dwight muss im Rotlichtbezirk untertauchen und dem Schläger Marv wird ein Mord angehängt. ', + localTitle: 'Sin City', + originalLanguage: 'en', + productionYear: 2005, + ratings: [ + { + source: 'Internal', + value: '3.9/5', + }, + ], + studios: ['Studiocanal', 'Paramount'], + }); + }); + + it('normalizes data correctly for 5979300', () => { + const result = normalizeRawInternalData(movie5979300); + expect(result).toEqual({ + availableLanguages: ['de', 'en'], + duration: 127, + imdbId: 'tt0097576', + internalId: 5979300, + localDescription: + '1912: Der junge Indiana Jones will Grabräubern das Kreuz von Coronado abluchsen, um es in ein Museum zu bringen. Nach einem Zeitsprung ins Jahr 1938 kämpft Indy wieder um das Kreuz, diesmal erfolgreich. Anschließend soll er den Heiligen Gral suchen.', + localTitle: 'Indiana Jones und der letzte Kreuzzug', + originalLanguage: 'en', + productionYear: 1989, + ratings: [ + { + source: 'Internal', + value: '4.2/5', + }, + ], + studios: ['Paramount'], + }); + }); + + it('normalizes data correctly for 11043689', () => { + const result = normalizeRawInternalData(movie11043689); + expect(result).toEqual({ + availableLanguages: ['de', 'en'], + duration: 120, + imdbId: 'tt0076759', + internalId: 11043689, + localDescription: + 'Der im Exil lebende Jedi-Ritter Obi-Wan Kenobi nimmt sich einen Mann namens Luke Skywalker als Schüler. Zusammen helfen sie der Rebellion, die Pläne des bösen Imperiums und des Sith-Lords Darth Vader zu vereiteln. ', + localTitle: 'Star Wars: Eine neue Hoffnung', + originalLanguage: 'en', + productionYear: 1977, + ratings: [ + { + source: 'Internal', + value: '4.5/5', + }, + ], + studios: ['FOX', 'Paramount'], + }); + }); + + it('normalizes data correctly for 11528860', () => { + const result = normalizeRawInternalData(movie11528860); + expect(result).toEqual({ + availableLanguages: ['de', 'en'], + duration: 75, + imdbId: 'tt0061852', + internalId: 11528860, + localDescription: + 'Unter der Obhut des Panthers Baghira wächst das Findelkind Mogli bei einer Wolfsfamilie auf. Doch da erschüttert die Rückkehr des menschenfressenden Tigers Shir Khan den Dschungel. Die Sorge um Mogli zwingt Baghira zu der einzig möglichen Entscheidung.', + localTitle: 'Das Dschungelbuch', + originalLanguage: 'en', + productionYear: 1967, + ratings: [ + { + source: 'Internal', + value: '4.4/5', + }, + ], + studios: ['Disney'], + }); + }); +}); diff --git a/src/integration/movies/internal/converters.ts b/src/integration/movies/internal/converters.ts new file mode 100644 index 0000000..d8fb9b7 --- /dev/null +++ b/src/integration/movies/internal/converters.ts @@ -0,0 +1,54 @@ +import { createTargetHelpers, identity } from '../../../utils/objectHelpers'; +import { NormalizedInternalData } from '../types'; +import { StoredData } from './types'; + +const { createRequiredField } = createTargetHelpers([ + null, + '', +]); + +const internalRatingToRatings = (internalRating: StoredData['userrating']) => { + let totalStars = 0; + for (let i = 1; i <= 5; i++) { + totalStars += internalRating[`countStar${i as 1 | 2 | 3 | 4 | 5}`] * i; + } + + const rating = totalStars / internalRating.countTotal; + + return [ + { + source: 'Internal', + value: `${rating.toFixed(1)}/5`, + }, + ]; +}; + +export const normalizeRawInternalData = ( + rawInternalData: StoredData, +): NormalizedInternalData => { + const { + description, + duration, + id, + imdbId, + languages, + originalLanguage, + productionYear, + studios, + title, + userrating, + } = rawInternalData; + + return { + ...createRequiredField('localDescription', description, identity), + ...createRequiredField('duration', duration, identity), + ...createRequiredField('internalId', id, identity), + ...createRequiredField('imdbId', imdbId, identity), + ...createRequiredField('availableLanguages', languages, identity), + ...createRequiredField('originalLanguage', originalLanguage, identity), + ...createRequiredField('productionYear', productionYear, identity), + ...createRequiredField('studios', studios, identity), + ...createRequiredField('localTitle', title, identity), + ...createRequiredField('ratings', userrating, internalRatingToRatings), + }; +}; diff --git a/src/integration/movies/internal/index.spec.ts b/src/integration/movies/internal/index.spec.ts new file mode 100644 index 0000000..9260c2a --- /dev/null +++ b/src/integration/movies/internal/index.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from '@jest/globals'; + +import { createInternalProvider } from './index'; + +describe('createInternalProvider', () => { + const client = createInternalProvider(); + + it('returns correct data for 11528860', async () => { + const result = await client.getMetadataByInternalId(11528860); + expect(result).toMatchObject({ + availableLanguages: expect.any(Array), + duration: 75, + internalId: 11528860, + imdbId: 'tt0061852', + localDescription: expect.any(String), + localTitle: 'Das Dschungelbuch', + originalLanguage: 'en', + productionYear: 1967, + studios: ['Disney'], + ratings: expect.arrayContaining([ + { + source: expect.any(String), + value: expect.any(String), + }, + ]), + }); + }); + + it('returns undefined for non-existent internal id', async () => { + const result = await client.getMetadataByInternalId(999999); + expect(result).toBeUndefined(); + }); + + it('returns data for imdb id', async () => { + const result = await client.getMetadataByImdbId('tt0061852'); + expect(result).toMatchObject({ + availableLanguages: expect.any(Array), + duration: 75, + internalId: 11528860, + imdbId: 'tt0061852', + localDescription: expect.any(String), + localTitle: 'Das Dschungelbuch', + originalLanguage: 'en', + productionYear: 1967, + studios: ['Disney'], + ratings: expect.arrayContaining([ + { + source: expect.any(String), + value: expect.any(String), + }, + ]), + }); + }); + + it('returns undefined for non-existent imdb id', async () => { + const result = await client.getMetadataByImdbId('tt99999999999'); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/integration/movies/internal/index.ts b/src/integration/movies/internal/index.ts new file mode 100644 index 0000000..bb093c9 --- /dev/null +++ b/src/integration/movies/internal/index.ts @@ -0,0 +1,28 @@ +import { InternalProvider } from '../types'; +import { normalizeRawInternalData } from './converters'; +import { createStorageClient } from './storage'; + +export const createInternalProvider = (): InternalProvider => { + const storageClient = createStorageClient(); + + return { + getMetadataByInternalId: async (internalId: number) => { + const rawInternalData = + await storageClient.getMetadataByInternalId(internalId); + if (!rawInternalData) { + return undefined; + } + + return normalizeRawInternalData(rawInternalData); + }, + getMetadataByImdbId: async (imdbId: string) => { + const rawInternalData = + await storageClient.getMetadataByImdbId(imdbId); + if (!rawInternalData) { + return undefined; + } + + return normalizeRawInternalData(rawInternalData); + }, + }; +}; diff --git a/src/integration/movies/internal/storage.spec.ts b/src/integration/movies/internal/storage.spec.ts index e22dbe1..579c3fc 100644 --- a/src/integration/movies/internal/storage.spec.ts +++ b/src/integration/movies/internal/storage.spec.ts @@ -5,7 +5,7 @@ import { createStorageClient } from './storage'; describe('createStorageClient', () => { const client = createStorageClient(); - it('returns data for internal id', async () => { + it('returns data for 11528860', async () => { const result = await client.getMetadataByInternalId(11528860); expect(result).toMatchObject({ description: expect.any(String), @@ -33,7 +33,7 @@ describe('createStorageClient', () => { expect(result).toBeUndefined(); }); - it('returns data for imdb id', async () => { + it('returns data for tt0061852', async () => { const result = await client.getMetadataByImdbId('tt0061852'); expect(result).toMatchObject({ description: expect.any(String), diff --git a/src/integration/movies/internal/types.ts b/src/integration/movies/internal/types.ts index c49db64..b0766eb 100644 --- a/src/integration/movies/internal/types.ts +++ b/src/integration/movies/internal/types.ts @@ -1,4 +1,4 @@ -type StoredData = { +export type StoredData = { /** * Description in local language */ diff --git a/src/integration/movies/omdb/converters.ts b/src/integration/movies/omdb/converters.ts index 08b8034..f5c7e75 100644 --- a/src/integration/movies/omdb/converters.ts +++ b/src/integration/movies/omdb/converters.ts @@ -1,7 +1,10 @@ +import { createTargetHelpers, identity } from '../../../utils/objectHelpers'; import { NormalizedOmdbData } from '../types'; import { RawOmdbData } from './types'; -const identity = (value: T) => value; +const { createRequiredField, createOptionalField } = + createTargetHelpers([null, '', 'N/A']); + const stringToArray = (value: string) => value.split(', '); const stringToNumber = (value: string) => parseInt(value, 10); const stringToDuration = (value: string) => { @@ -17,42 +20,6 @@ const stringToDuration = (value: string) => { const omdbRatingsToNormalizedRatings = (ratings: RawOmdbData['Ratings']) => ratings.map(({ Source, Value }) => ({ source: Source, value: Value })); -const createRequiredField = ( - key: TKey, - value: TRawValue, - transformer: (value: TRawValue) => NormalizedOmdbData[TKey], -) => { - if (value === undefined) { - throw new Error(`Value for ${key} is not defined`); - } - - if (value === 'N/A') { - throw new Error(`Value for ${key} is "N/A"`); - } - - return { - [key]: transformer(value), - } as Record; -}; - -const createOptionalField = ( - key: TKey, - value: TRawValue, - transformer: ( - value: Exclude, - ) => NormalizedOmdbData[TKey], -) => { - if (value === undefined || value === 'N/A') { - return undefined; - } - - return createRequiredField( - key, - value as Exclude, - transformer, - ); -}; - export const normalizeRawOmdbData = ( rawOmdbData: RawOmdbData, ): NormalizedOmdbData => { diff --git a/src/integration/movies/omdb/index.spec.ts b/src/integration/movies/omdb/index.spec.ts index e481fde..d1772ed 100644 --- a/src/integration/movies/omdb/index.spec.ts +++ b/src/integration/movies/omdb/index.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from '@jest/globals'; -import { createOmdbProvider } from '.'; +import { createOmdbProvider } from './index'; describe('createOmdbProvider', () => { const client = createOmdbProvider('68fd98ab'); diff --git a/src/integration/movies/types.ts b/src/integration/movies/types.ts index 093b752..5e937a1 100644 --- a/src/integration/movies/types.ts +++ b/src/integration/movies/types.ts @@ -1,38 +1,3 @@ -type InternalMovieData = { - /** - * Description in local language - */ - description: string; - /** - * A whole number of minutes, presumably - */ - duration: number; - id: number; - imdbId: string; - /** - * Two-letter codes - */ - languages: string[]; - /** - * Two-letter code - */ - originalLanguage: string; - productionYear: number; - studios: string[]; - /** - * Title in local language - */ - title: string; - userrating: { - countStar1: number; - countStar2: number; - countStar3: number; - countStar4: number; - countStar5: number; - countTotal: number; - }; -}; - type MovieData = { internalId?: number; imdbId: string; @@ -74,7 +39,7 @@ type MovieData = { }[]; }; -export type StrictPartial< +type StrictPartial< T, TForbiddenKeys extends keyof T, TOptionalKeys extends Exclude, @@ -95,13 +60,36 @@ export type NormalizedOmdbData = StrictPartial< | 'website' >; -export type InternalMoviesProvider = { - getMovieMetadataByInternalId( +export type NormalizedInternalData = StrictPartial< + MovieData, + | 'actors' + | 'awards' + | 'boxOffice' + | 'contentRating' + | 'description' + | 'directors' + | 'dvdReleaseDate' + | 'genres' + | 'posterUrl' + | 'productionCountries' + | 'releaseDate' + | 'title' + | 'totalSeasons' + | 'type' + | 'website' + | 'writers', + never +> & { + internalId: number; +}; + +export type InternalProvider = { + getMetadataByInternalId( internalId: number, - ): Promise; - getMovieMetadataByImdbId( + ): Promise; + getMetadataByImdbId( imdbId: string, - ): Promise; + ): Promise; }; export type OmdbProvider = { diff --git a/src/utils/objectHelpers.ts b/src/utils/objectHelpers.ts new file mode 100644 index 0000000..5c62065 --- /dev/null +++ b/src/utils/objectHelpers.ts @@ -0,0 +1,50 @@ +export const identity = (value: T) => value; + +export const createTargetHelpers = ( + missingValueIndicators: unknown[], +) => { + const createRequiredField = < + TKey extends string & keyof TTarget, + TRawValue, + >( + key: TKey, + value: TRawValue, + transformer: (value: TRawValue) => TTarget[TKey], + ) => { + if (value === undefined) { + throw new Error(`Value for ${key} is not defined`); + } + + if (missingValueIndicators.includes(value)) { + throw new Error(`Missing value for ${key}`); + } + + return { + [key]: transformer(value), + } as Record; + }; + + const createOptionalField = < + TKey extends string & keyof TTarget, + TRawValue, + >( + key: TKey, + value: TRawValue, + transformer: (value: Exclude) => TTarget[TKey], + ) => { + if (value === undefined || missingValueIndicators.includes(value)) { + return undefined; + } + + return createRequiredField( + key, + value as Exclude, + transformer, + ); + }; + + return { + createRequiredField, + createOptionalField, + }; +};