diff --git a/package-lock.json b/package-lock.json index 2641d70..ce3ca09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", "node-fetch": "^2.7.0", "p-queue": "^6.6.2", "reflect-metadata": "^0.1.13", @@ -2121,6 +2123,11 @@ "@types/superagent": "*" } }, + "node_modules/@types/validator": { + "version": "13.11.7", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.7.tgz", + "integrity": "sha512-q0JomTsJ2I5Mv7dhHhQLGjMvX0JJm5dyZ1DXQySIUzU1UlwzB8bt+R6+LODUbz0UDIOvEzGc28tk27gBJw2N8Q==" + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -3203,6 +3210,21 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.0.tgz", + "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", + "dependencies": { + "@types/validator": "^13.7.10", + "libphonenumber-js": "^1.10.14", + "validator": "^13.7.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -6160,6 +6182,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.10.51", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.51.tgz", + "integrity": "sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8557,6 +8584,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 7568ac5..6d2a2a7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", "node-fetch": "^2.7.0", "p-queue": "^6.6.2", "reflect-metadata": "^0.1.13", diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts deleted file mode 100644 index 0c6eeb2..0000000 --- a/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, type TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/src/app.controller.ts b/src/app.controller.ts deleted file mode 100644 index a325e8b..0000000 --- a/src/app.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/src/app.module.spec.ts b/src/app.module.spec.ts new file mode 100644 index 0000000..aeeeee7 --- /dev/null +++ b/src/app.module.spec.ts @@ -0,0 +1,93 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { ValidationPipe, type INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from './../src/app.module'; +import { createTestOmdbEnrichedDataService } from './utils/testHelpers'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const enrichedDataService = await createTestOmdbEnrichedDataService(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider('enrichedDataService') + .useValue(enrichedDataService) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.init(); + }); + + it('/api/movies/tt0061852 (GET)', () => { + return request(app.getHttpServer()) + .get('/api/movies/tt0061852') + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ + title: 'The Jungle Book', + }); + }); + }); + + it('/api/movies/11528860 (GET)', () => { + return request(app.getHttpServer()) + .get('/api/movies/11528860') + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ + title: 'The Jungle Book', + }); + }); + }); + + it('/api/movies (GET)', () => { + return request(app.getHttpServer()).get('/api/movies').expect(400); + }); + + it('/api/movies?writers=George Lucas (GET)', () => { + return request(app.getHttpServer()) + .get('/api/movies?writers=George Lucas') + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject([ + { title: 'Indiana Jones and the Last Crusade' }, + { title: 'Star Wars: Episode IV - A New Hope' }, + ]); + }); + }); + + it('/api/movies?writers=George Lucas&actors=Mark Hamill (GET)', () => { + return request(app.getHttpServer()) + .get('/api/movies?writers=George Lucas&actors=Mark Hamill') + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject([ + { title: 'Star Wars: Episode IV - A New Hope' }, + ]); + }); + }); + + it('/api/movies?productionYear=1967 (GET)', () => { + return request(app.getHttpServer()) + .get('/api/movies?productionYear=1967') + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject([ + { title: 'The Jungle Book' }, + ]); + }); + }); + + it('/api/movies?productionYear=1800 (GET)', () => { + return request(app.getHttpServer()) + .get('/api/movies?productionYear=1800') + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject([]); + }); + }); +}); diff --git a/src/app.module.ts b/src/app.module.ts index c6d9fb6..e2d55e8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { MoviesController } from './movies.controller'; +import { createTestOmdbEnrichedDataService } from './utils/testHelpers'; @Module({ imports: [], - controllers: [AppController], - providers: [AppService], + controllers: [MoviesController], + providers: [ + { + provide: 'enrichedDataService', + useFactory: createTestOmdbEnrichedDataService, + }, + ], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts deleted file mode 100644 index 61b7a5b..0000000 --- a/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/src/main.ts b/src/main.ts index 25f3010..911176d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,10 @@ +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalPipes(new ValidationPipe({ transform: true })); await app.listen(3000); } void bootstrap(); diff --git a/src/movies.controller.spec.ts b/src/movies.controller.spec.ts new file mode 100644 index 0000000..feab179 --- /dev/null +++ b/src/movies.controller.spec.ts @@ -0,0 +1,48 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { MoviesController } from './movies.controller'; +import { createTestOmdbEnrichedDataService } from './utils/testHelpers'; + +describe('AppController', () => { + let moviesController: MoviesController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [MoviesController], + providers: [ + { + provide: 'enrichedDataService', + useFactory: createTestOmdbEnrichedDataService, + }, + ], + }).compile(); + + moviesController = app.get(MoviesController); + }); + + describe('root', () => { + it('should return movie by internal id', () => { + expect(moviesController.getMovie('11528860')).toMatchObject({ + title: 'The Jungle Book', + }); + }); + + it('should return movie by imdb id', () => { + expect(moviesController.getMovie('tt0061852')).toMatchObject({ + title: 'The Jungle Book', + }); + }); + + it('should throw an error for empty search query', () => { + expect(() => moviesController.searchMovies({})).toThrow(); + }); + + it('should return movies matching writer', () => { + expect( + moviesController.searchMovies({ writers: 'George Lucas' }), + ).toMatchObject([ + { title: 'Indiana Jones and the Last Crusade' }, + { title: 'Star Wars: Episode IV - A New Hope' }, + ]); + }); + }); +}); diff --git a/src/movies.controller.ts b/src/movies.controller.ts new file mode 100644 index 0000000..df55548 --- /dev/null +++ b/src/movies.controller.ts @@ -0,0 +1,102 @@ +import { + BadRequestException, + Controller, + Get, + Inject, + NotFoundException, + Param, + Query, +} from '@nestjs/common'; +import { EnrichedDataService } from './types'; +import { IsIn, IsInt, IsOptional, IsString, Length } from 'class-validator'; +import { Type } from 'class-transformer'; + +const ENTRY_TYPES = ['movie', 'series', 'episode'] as const; + +class SearchDto { + @IsString() + @Length(1) + @IsOptional() + actors?: string; + + @IsString() + @Length(2, 2) + @IsOptional() + availableLanguages?: string; + + @IsString() + @Length(1) + @IsOptional() + directors?: string; + + @IsString() + @Length(1) + @IsOptional() + localTitle?: string; + + @IsString() + @Length(2, 2) + @IsOptional() + originalLanguage?: string; + + @IsString() + @Length(1) + @IsOptional() + productionCountries?: string; + + @IsInt() + @Type(() => Number) + @IsOptional() + productionYear?: number; + + @IsString() + @Length(1) + @IsOptional() + studios?: string; + + @IsString() + @Length(1) + @IsOptional() + title?: string; + + @IsString() + @IsIn(ENTRY_TYPES) + @IsOptional() + type?: (typeof ENTRY_TYPES)[number]; + + @IsString() + @Length(1) + @IsOptional() + writers?: string; +} + +@Controller('/api/movies') +export class MoviesController { + constructor( + @Inject('enrichedDataService') + private readonly enrichedDataService: EnrichedDataService, + ) {} + + @Get() + searchMovies(@Query() query: SearchDto) { + if (!Object.values(query).some((filterValue) => filterValue)) { + throw new BadRequestException('Query should not be empty'); + } + + const result = this.enrichedDataService.getSearchResults(query); + return result; + } + + @Get(':id') + getMovie(@Param('id') id: string) { + const result = /^\d+$/.test(id) + ? this.enrichedDataService.getMetadataByInternalId(parseInt(id, 10)) + : this.enrichedDataService.getMetadataByImdbId(id); + + if (!result) { + throw new NotFoundException(); + } + + return result; + } +}