├── db-data └── .gitkeep ├── src ├── exceptions │ ├── BadRequestEntity.ts │ └── EntityNotFoundError.ts ├── index.ts ├── repositories │ ├── IRepository.ts │ ├── DirectorRepository.ts │ └── MovieRepository.ts ├── models │ ├── Route.ts │ ├── Director.ts │ └── Movie.ts ├── routes │ ├── IRoutes.ts │ ├── MovieRoutes.ts │ └── DirectorRoutes.ts ├── services │ ├── DirectorService.ts │ └── MovieService.ts ├── controllers │ ├── MovieController.ts │ └── DirectorController.ts └── MovieListr.ts ├── .gitignore ├── scripts ├── run-e2e.ts ├── stop-test-db.ts └── start-test-db.ts ├── docker-compose.yml ├── docker-compose.e2e.yml ├── db-scripts ├── create.sql └── seed.sql ├── tslint.json ├── .vscode └── launch.json ├── tsconfig.json ├── test ├── testutils │ ├── DirectorTestBuilder.ts │ └── MovieTestBuilder.ts ├── services │ ├── DirectorService.spec.ts │ └── MovieService.spec.ts └── controllers │ └── MovieController.spec.ts ├── README.md ├── package.json └── e2e └── movies.spec.ts /db-data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/exceptions/BadRequestEntity.ts: -------------------------------------------------------------------------------- 1 | export default class BadRequestEntity extends Error { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/exceptions/EntityNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export default class EntityNotFoundError extends Error { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .ts-node/ 4 | db-data/* 5 | !db-data/.gitkeep 6 | yarn.lock 7 | yarn-error.lock 8 | yarn-error.log 9 | .vscode/settings.json -------------------------------------------------------------------------------- /scripts/run-e2e.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "shelljs"; 2 | 3 | const executeE2ETests = () => { 4 | console.log("Executing e2e tests..."); 5 | exec("mocha -r ts-node/register e2e/**/*.spec.ts"); 6 | }; 7 | 8 | executeE2ETests(); 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import "reflect-metadata"; 3 | 4 | import { Container } from "typescript-ioc"; 5 | 6 | import MovieListr from "./MovieListr"; 7 | 8 | const app: MovieListr = Container.get(MovieListr); 9 | app.start(); 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | db: 4 | image: mysql:latest 5 | ports: 6 | - 3306:3306 7 | volumes: 8 | - ./db-data:/var/lib/mysql 9 | environment: 10 | - MYSQL_ROOT_PASSWORD=root 11 | - MYSQL_DATABASE=movielistr 12 | -------------------------------------------------------------------------------- /scripts/stop-test-db.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "shelljs"; 2 | 3 | const stopAndRemoveDockerContainer = () => { 4 | console.log("Stopping and removing the docker container..."); 5 | exec("docker stop dbtest && docker rm dbtest", { silent: true }); 6 | }; 7 | 8 | stopAndRemoveDockerContainer(); 9 | -------------------------------------------------------------------------------- /docker-compose.e2e.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | dbtest: 4 | image: healthcheck/mysql:latest 5 | container_name: dbtest 6 | ports: 7 | - 3306:3306 8 | volumes: 9 | - ./db-scripts:/docker-entrypoint-initdb.d 10 | environment: 11 | - MYSQL_ROOT_PASSWORD=root 12 | - MYSQL_DATABASE=movielistr 13 | -------------------------------------------------------------------------------- /src/repositories/IRepository.ts: -------------------------------------------------------------------------------- 1 | import { getEntityManager } from "typeorm"; 2 | import Director from "../models/Director"; 3 | import Movie from "../models/Movie"; 4 | 5 | export default abstract class IRepository { 6 | 7 | protected getDirectorRepository() { 8 | return getEntityManager().getRepository(Director); 9 | } 10 | 11 | protected getMovieRepository() { 12 | return getEntityManager().getRepository(Movie); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /db-scripts/create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE director ( 2 | id int auto_increment not null primary key, 3 | firstName varchar(255) not null, 4 | lastName varchar(255) not null, 5 | birthYear int not null 6 | ); 7 | 8 | CREATE TABLE movie ( 9 | id int auto_increment not null primary key, 10 | title varchar(255) not null, 11 | releaseYear int not null, 12 | duration int not null, 13 | rating int not null, 14 | seen boolean not null, 15 | director int not null, 16 | FOREIGN KEY (director) REFERENCES director(id) 17 | ); -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "max-line-length": [ 9 | 150 10 | ], 11 | "member-ordering": [ 12 | false 13 | ], 14 | "object-literal-sort-keys": false, 15 | "no-console": [ 16 | false 17 | ], 18 | "no-unused-variable": [ 19 | true 20 | ], 21 | "curly": false 22 | }, 23 | "rulesDirectory": [] 24 | } -------------------------------------------------------------------------------- /src/models/Route.ts: -------------------------------------------------------------------------------- 1 | import { IRouterContext } from "koa-router"; 2 | 3 | export default class Route { 4 | 5 | private path: string; 6 | private method: string; 7 | private action: (ctx: IRouterContext) => void; 8 | 9 | public static newRoute(path: string, method: string, action: (ctx: IRouterContext) => void) { 10 | const route = new Route(); 11 | route.path = path; 12 | route.method = method; 13 | route.action = action; 14 | return route; 15 | } 16 | 17 | public get $path(): string { 18 | return this.path; 19 | } 20 | 21 | public get $method(): string { 22 | return this.method; 23 | } 24 | 25 | public get $action(): (ctx: IRouterContext) => void { 26 | return this.action; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /db-scripts/seed.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO director (firstName, lastName, birthYear) values ('Robert', 'Rodriguez', 1968); 2 | INSERT INTO director (firstName, lastName, birthYear) values ('Quentin', 'Tarantino', 1963); 3 | INSERT INTO director (firstName, lastName, birthYear) values ('John', 'Lasseter', 1957); 4 | INSERT INTO director (firstName, lastName, birthYear) values ('Toby', 'Hooper', 1943); 5 | 6 | INSERT INTO movie(title, releaseYear, duration, rating, seen, director) values ("Finding Nemo", 2008, 100, 7, true, 3); 7 | INSERT INTO movie(title, releaseYear, duration, rating, seen, director) values ("From Dusk Till Dawn", 1996, 95, 9, true, 1); 8 | INSERT INTO movie(title, releaseYear, duration, rating, seen, director) values ("The Hateful Eight", 2015, 180, 8, true, 2); 9 | INSERT INTO movie(title, releaseYear, duration, rating, seen, director) values ("The Texas Chainsaw Massacre", 1976, 90, 8, true, 4); -------------------------------------------------------------------------------- /src/routes/IRoutes.ts: -------------------------------------------------------------------------------- 1 | import * as Router from "koa-router"; 2 | import Route from "../models/Route"; 3 | 4 | export abstract class IRoutes { 5 | 6 | protected abstract getRoutes(): Route[]; 7 | 8 | public register(router: Router) { 9 | this.getRoutes().forEach((route) => { 10 | this.registerRoute(route, router); 11 | }); 12 | } 13 | 14 | private registerRoute = (route: Route, router: Router) => { 15 | switch (route.$method) { 16 | case ("get"): 17 | router.get(route.$path, route.$action); 18 | break; 19 | case ("post"): 20 | router.post(route.$path, route.$action); 21 | break; 22 | case ("put"): 23 | router.put(route.$path, route.$action); 24 | break; 25 | case ("delete"): 26 | router.delete(route.$path, route.$action); 27 | break; 28 | } 29 | } 30 | 31 | } 32 | 33 | export default IRoutes; 34 | -------------------------------------------------------------------------------- /src/routes/MovieRoutes.ts: -------------------------------------------------------------------------------- 1 | import { IRouterContext } from "koa-router"; 2 | import { Container, Inject } from "typescript-ioc"; 3 | import MovieController from "../controllers/MovieController"; 4 | import Route from "../models/Route"; 5 | import IRoutes from "./IRoutes"; 6 | 7 | export default class MovieRoutes extends IRoutes { 8 | 9 | constructor( @Inject private movieController: MovieController) { 10 | super(); 11 | } 12 | 13 | protected getRoutes(): Route[] { 14 | return [ 15 | Route.newRoute("/movies", "get", (ctx: IRouterContext) => this.movieController.getAllMovies(ctx)), 16 | Route.newRoute("/movies/:id", "get", (ctx: IRouterContext) => this.movieController.findMovieById(ctx)), 17 | Route.newRoute("/movies", "post", (ctx: IRouterContext) => this.movieController.saveMovie(ctx)), 18 | Route.newRoute("/movies/:id", "put", (ctx: IRouterContext) => this.movieController.updateMovie(ctx)), 19 | Route.newRoute("/movies/:id", "delete", (ctx: IRouterContext) => this.movieController.deleteMovie(ctx)), 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scripts/start-test-db.ts: -------------------------------------------------------------------------------- 1 | import { exec, ExecOutputReturnValue } from "shelljs"; 2 | 3 | const checkDocker = (): boolean => { 4 | const result: ExecOutputReturnValue = exec("docker inspect --format '{{.State.Health.Status}}' dbtest", { silent: true }) as ExecOutputReturnValue; 5 | return result.stdout.indexOf("healthy") > -1; 6 | }; 7 | 8 | const sleep = (ms: number) => { 9 | return new Promise((resolve) => setTimeout(resolve, ms)); 10 | }; 11 | 12 | const startTestDBDocker = (): void => { 13 | console.log("Starting testdb docker..."); 14 | exec("docker-compose -f docker-compose.e2e.yml up -d", { silent: true }); 15 | }; 16 | 17 | const waitForDockerToBeUpAndRunning = async (): Promise => { 18 | console.log("Doing health checks until the docker container is done..."); 19 | let dockerDone = checkDocker(); 20 | while (!dockerDone) { 21 | await sleep(3000); 22 | dockerDone = checkDocker(); 23 | console.log("Docker started: " + dockerDone); 24 | } 25 | return Promise.resolve(); 26 | }; 27 | 28 | const main = async () => { 29 | startTestDBDocker(); 30 | await waitForDockerToBeUpAndRunning(); 31 | }; 32 | 33 | main(); 34 | -------------------------------------------------------------------------------- /src/routes/DirectorRoutes.ts: -------------------------------------------------------------------------------- 1 | import { IMiddleware, IRouterContext } from "koa-router"; 2 | import { Container, Inject } from "typescript-ioc"; 3 | import DirectorController from "../controllers/DirectorController"; 4 | import Route from "../models/Route"; 5 | import IRoutes from "./IRoutes"; 6 | 7 | export default class DirectorRoutes extends IRoutes { 8 | 9 | constructor( @Inject private directorController: DirectorController) { 10 | super(); 11 | } 12 | 13 | protected getRoutes(): Route[] { 14 | return [ 15 | Route.newRoute("/directors", "get", (ctx: IRouterContext) => this.directorController.getAllDirectors(ctx)), 16 | Route.newRoute("/directors/:id", "get", (ctx: IRouterContext) => this.directorController.findDirectorById(ctx)), 17 | Route.newRoute("/directors/", "post", (ctx: IRouterContext) => this.directorController.saveDirector(ctx)), 18 | Route.newRoute("/directors/:id", "put", (ctx: IRouterContext) => this.directorController.saveDirector(ctx)), 19 | Route.newRoute("/directors/:id", "delete", (ctx: IRouterContext) => this.directorController.deleteDirector(ctx)), 20 | ]; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug Application", 11 | "runtimeExecutable": "npm", 12 | "windows": { 13 | "runtimeExecutable": "npm.cmd" 14 | }, 15 | "runtimeArgs": [ 16 | "run-script", 17 | "start:debug" 18 | ], 19 | "outFiles": [], 20 | "protocol": "inspector", 21 | "sourceMaps": true, 22 | "port": 5858 23 | }, 24 | { 25 | "type": "node", 26 | "request": "launch", 27 | "name": "Debug Tests", 28 | "runtimeExecutable": "npm", 29 | "windows": { 30 | "runtimeExecutable": "npm.cmd" 31 | }, 32 | "runtimeArgs": [ 33 | "run-script", 34 | "test:debug" 35 | ], 36 | "outFiles": [], 37 | "protocol": "inspector", 38 | "sourceMaps": true, 39 | "port": 9229 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /src/services/DirectorService.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Singleton } from "typescript-ioc"; 2 | 3 | import BadRequestEntity from "../exceptions/BadRequestEntity"; 4 | import EntityNotFoundError from "../exceptions/EntityNotFoundError"; 5 | import Director from "../models/Director"; 6 | import DirectorRepository from "../repositories/DirectorRepository"; 7 | 8 | @Singleton 9 | export default class DirectorService { 10 | 11 | constructor( @Inject private directorRepository: DirectorRepository) { } 12 | 13 | public async findById(id: number): Promise { 14 | return this.directorRepository.findDirectorById(id); 15 | } 16 | 17 | public async findAll(): Promise { 18 | return this.directorRepository.getAllDirectors(); 19 | } 20 | 21 | public async save(director: Director): Promise { 22 | return this.directorRepository.saveDirector(director); 23 | } 24 | 25 | public async update(director: Director) { 26 | try { 27 | await this.directorRepository.findDirectorById(director.$id); 28 | return this.directorRepository.saveDirector(director); 29 | } catch (e) { 30 | if (e instanceof EntityNotFoundError) { 31 | throw new BadRequestEntity("The given director does not exist yet."); 32 | } 33 | } 34 | } 35 | 36 | public async delete(directorId: number) { 37 | return this.directorRepository.deleteDirectorWithId(directorId); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/services/MovieService.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Singleton } from "typescript-ioc"; 2 | import BadRequestEntity from "../exceptions/BadRequestEntity"; 3 | import EntityNotFoundError from "../exceptions/EntityNotFoundError"; 4 | import Movie from "../models/Movie"; 5 | import MovieRepository from "../repositories/MovieRepository"; 6 | import DirectorService from "./DirectorService"; 7 | 8 | @Singleton 9 | export default class MovieService { 10 | 11 | constructor( @Inject private movieRepository: MovieRepository) { } 12 | 13 | public async findById(id: number): Promise { 14 | return this.movieRepository.findMovieById(id); 15 | } 16 | 17 | public async findAll(): Promise { 18 | return this.movieRepository.getAllMovies(); 19 | } 20 | 21 | public async update(movie: Movie): Promise { 22 | try { 23 | await this.movieRepository.findMovieById(movie.$id); 24 | return this.movieRepository.saveMovie(movie); 25 | } catch (e) { 26 | if (e instanceof EntityNotFoundError) { 27 | throw new BadRequestEntity("The given movie does not exist yet."); 28 | } 29 | throw e; 30 | } 31 | } 32 | 33 | public async save(movie: Movie): Promise { 34 | return this.movieRepository.saveMovie(movie); 35 | } 36 | 37 | public async delete(movieId: number): Promise { 38 | return this.movieRepository.deleteMovie(movieId); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/models/Director.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity() 4 | export default class Director { 5 | 6 | @PrimaryGeneratedColumn() 7 | private id: number; 8 | @Column() 9 | private firstName: string; 10 | @Column() 11 | private lastName: string; 12 | @Column() 13 | private birthYear: number; 14 | 15 | public get $id(): number { 16 | return this.id; 17 | } 18 | 19 | public set $id(value: number) { 20 | this.id = value; 21 | } 22 | 23 | public get $firstName(): string { 24 | return this.firstName; 25 | } 26 | 27 | public set $firstName(value: string) { 28 | this.firstName = value; 29 | } 30 | 31 | public get $lastName(): string { 32 | return this.lastName; 33 | } 34 | 35 | public set $lastName(value: string) { 36 | this.lastName = value; 37 | } 38 | 39 | public get $birthYear(): number { 40 | return this.birthYear; 41 | } 42 | 43 | public set $birthYear(value: number) { 44 | this.birthYear = value; 45 | } 46 | 47 | public static newDirector(obj: { id?: number, firstName?: string, lastName?: string, birthYear?: number }) { 48 | const newDirector = new Director(); 49 | if (obj.id) newDirector.id = obj.id; 50 | if (obj.firstName) newDirector.firstName = obj.firstName; 51 | if (obj.lastName) newDirector.lastName = obj.lastName; 52 | if (obj.birthYear) newDirector.birthYear = obj.birthYear; 53 | return newDirector; 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/repositories/DirectorRepository.ts: -------------------------------------------------------------------------------- 1 | import { FindOptions, getEntityManager, Repository } from "typeorm"; 2 | import { Inject, Singleton } from "typescript-ioc"; 3 | import EntityNotFoundError from "../exceptions/EntityNotFoundError"; 4 | import Director from "../models/Director"; 5 | import Movie from "../models/Movie"; 6 | import IRepository from "../repositories/IRepository"; 7 | import MovieRepository from "./MovieRepository"; 8 | 9 | @Singleton 10 | export default class DirectorRepository extends IRepository { 11 | 12 | constructor( @Inject private movieRepository: MovieRepository) { 13 | super(); 14 | } 15 | 16 | public async getAllDirectors(): Promise { 17 | return this.getDirectorRepository().find(); 18 | } 19 | 20 | public async findDirectorById(id: number): Promise { 21 | const result = await this.getDirectorRepository().findOneById(id); 22 | if (!result) { 23 | throw new EntityNotFoundError("No director was found for ID: " + id); 24 | } 25 | return result; 26 | } 27 | 28 | public async saveDirector(director: Director): Promise { 29 | return this.getDirectorRepository().persist(director); 30 | } 31 | 32 | public async deleteDirectorWithId(id: number) { 33 | await this.movieRepository.deleteMoviesFromDirector(id); 34 | await this.getDirectorRepository() 35 | .createQueryBuilder("director") 36 | .delete() 37 | .where("director.id = :id", { id }) 38 | .execute(); 39 | return Promise.resolve(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ 6 | "lib": [ 7 | "dom", 8 | "es5", 9 | "es6", 10 | "esnext.asynciterable" 11 | ], /* Specify library files to be included in the compilation: */ 12 | "sourceRoot": "./src/", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 13 | "outDir": "./dist/", /* Redirect output structure to the directory. */ 14 | "removeComments": true, /* Do not emit comments to output. */ 15 | "strict": true, /* Enable all strict type-checking options. */ 16 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 17 | "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 18 | "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 19 | "typeRoots": [ 20 | "./node_modules/@types" 21 | ], /* List of folders to include type definitions from. */ 22 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 23 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 24 | }, 25 | "exclude": [ 26 | "node_modules", 27 | "scripts", 28 | "test", 29 | "e2e" 30 | ] 31 | } -------------------------------------------------------------------------------- /test/testutils/DirectorTestBuilder.ts: -------------------------------------------------------------------------------- 1 | import Director from "../../src/models/Director"; 2 | 3 | export default class DirectorTestBuilder { 4 | 5 | private director: Director; 6 | 7 | constructor() { 8 | this.director = new Director(); 9 | } 10 | 11 | public static newDirector(): DirectorTestBuilder { 12 | return new DirectorTestBuilder(); 13 | } 14 | 15 | public withFirstName(firstName: string): DirectorTestBuilder { 16 | this.director.$firstName = firstName; 17 | return this; 18 | } 19 | public withLastName(lastName: string): DirectorTestBuilder { 20 | this.director.$lastName = lastName; 21 | return this; 22 | } 23 | public withBirthYear(birthYear: number): DirectorTestBuilder { 24 | this.director.$birthYear = birthYear; 25 | return this; 26 | } 27 | 28 | public withId(id: number): DirectorTestBuilder { 29 | this.director.$id = id; 30 | return this; 31 | } 32 | 33 | public withRandomId(): DirectorTestBuilder { 34 | this.director.$id = Math.random() * 10; 35 | return this; 36 | } 37 | 38 | public withDefaultValues(): DirectorTestBuilder { 39 | return this 40 | .withFirstName("John") 41 | .withLastName("Lasseter") 42 | .withBirthYear(1966); 43 | } 44 | 45 | public build(): Director { 46 | return this.director; 47 | } 48 | 49 | public static getListOfDefaultDirectors(length: number): Director[] { 50 | const result = []; 51 | for (let i = 0; i < length; i++) { 52 | result.push(DirectorTestBuilder.newDirector().withDefaultValues().build()); 53 | } 54 | return result; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MovieListr Backend with NodeJS 2 | 3 | ## Why 4 | This project was a test for me to see how far JS has come along to be actually used for a backend. A few years ago I also created a backend for a small application in NodeJS with regular old javascript (I did not have a lot of experience with javascript then), but I really hated it. Now with typescript, I wanted to give it another chance. 5 | 6 | ## Technologies used: 7 | 8 | ### For the application 9 | 10 | * [Typescript](https://www.typescriptlang.org/) 11 | * [typescript-ioc](https://www.npmjs.com/package/typescript-ioc) 12 | * [typeorm](https://www.npmjs.com/package/typeorm) 13 | * [docker](https://www.docker.com/) 14 | * [koa](https://www.npmjs.com/package/koa) 15 | 16 | ### For testing 17 | 18 | * [ts-mockito](https://www.npmjs.com/package/ts-mockito) 19 | * [mocha](https://www.npmjs.com/package/mocha) 20 | * [sinon](https://www.npmjs.com/package/sinon) 21 | 22 | I also make a lot of use of the new async/await syntax. 23 | 24 | ## To get it up and running: 25 | 26 | 1. Run `yarn install` 27 | 2. Run `docker-compose up`, this will run the container for the mysql db. 28 | 3. Run `docker ps` to find the name of the docker container. 29 | 4. Run `docker exec -i -t /bin/bash` to open a bash in the container. 30 | 5. Run `mysql -u root -p` and fill in the password 'root'. 31 | 6. Run the sql scripts in the db-scripts folder to create the correct tables and fill them with dummy data. 32 | 7. Run `npm run build` to compile the typescript into the dist folder. 33 | 8. Run `node dist/index.js` to run the application. 34 | 35 | You can now use postman to do requests to [http://localhost:3000](http://localhost:3000). For full URL's, check the classes in the src/routes folder. 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/controllers/MovieController.ts: -------------------------------------------------------------------------------- 1 | import { IRouterContext } from "koa-router"; 2 | import { Container, Inject, Singleton } from "typescript-ioc"; 3 | import Movie from "../models/Movie"; 4 | import MovieService from "../services/MovieService"; 5 | 6 | @Singleton 7 | export default class MovieController { 8 | 9 | constructor( @Inject private movieService: MovieService) { } 10 | 11 | public async getAllMovies(ctx: IRouterContext) { 12 | ctx.body = await this.movieService.findAll(); 13 | } 14 | 15 | public async findMovieById(ctx: IRouterContext) { 16 | try { 17 | ctx.body = await this.movieService.findById(ctx.params.id); 18 | } catch (e) { 19 | ctx.throw(404); 20 | } 21 | } 22 | 23 | public async updateMovie(ctx: IRouterContext) { 24 | try { 25 | const movie: Movie = Movie.newMovie(ctx.request.body); 26 | if (String(ctx.params.id) !== String(movie.$id)) { 27 | ctx.throw(400); 28 | } 29 | ctx.body = await this.movieService.update(movie); 30 | } catch (e) { 31 | ctx.throw(400, e.message); 32 | } 33 | } 34 | 35 | public async saveMovie(ctx: IRouterContext) { 36 | try { 37 | const movie: Movie = Movie.newMovie(ctx.request.body); 38 | const result = await this.movieService.save(movie); 39 | ctx.body = result; 40 | } catch (e) { 41 | ctx.throw(400, e.message); 42 | } 43 | } 44 | 45 | public async deleteMovie(ctx: IRouterContext) { 46 | try { 47 | await this.movieService.delete(ctx.params.id); 48 | ctx.status = 200; 49 | } catch (e) { 50 | ctx.throw(404, e.message); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movielistr", 3 | "version": "1.0.0", 4 | "description": "A program to keep a diary of movies you watched.", 5 | "scripts": { 6 | "build": "tsc --rootDir ./src/", 7 | "start": "ts-node src/index.ts", 8 | "start:debug": "ts-node --inspect=5858 --debug-brk --ignore false src/index.ts", 9 | "test": "mocha -r ts-node/register test/**/*.spec.ts", 10 | "test:debug": "mocha --inspect --debug-brk --not-timeouts --compilers ts:ts-node/register test/**/*.spec.ts", 11 | "pree2e": "ts-node scripts/start-test-db.ts", 12 | "e2e": "ts-node scripts/run-e2e.ts", 13 | "poste2e": "ts-node scripts/stop-test-db.ts" 14 | }, 15 | "keywords": [ 16 | "movielistr", 17 | "movies", 18 | "listr", 19 | "crud" 20 | ], 21 | "dependencies": { 22 | "koa": "^2.2.0", 23 | "koa-bodyparser": "^4.2.0", 24 | "koa-logger": "^3.0.0", 25 | "koa-router": "^7.1.1", 26 | "mysql": "^2.13.0", 27 | "reflect-metadata": "^0.1.10", 28 | "typeorm": "^0.0.11", 29 | "typescript-ioc": "^0.4.1" 30 | }, 31 | "author": "Ivar van Hartingsveldt", 32 | "license": "ISC", 33 | "devDependencies": { 34 | "@types/chai": "^3.5.2", 35 | "@types/koa": "^2.0.39", 36 | "@types/koa-bodyparser": "^3.0.23", 37 | "@types/koa-logger": "^2.0.2", 38 | "@types/koa-router": "^7.0.22", 39 | "@types/mocha": "^2.2.41", 40 | "@types/node": "^7.0.18", 41 | "@types/shelljs": "^0.7.1", 42 | "@types/sinon": "^2.2.2", 43 | "@types/sinon-chai": "^2.7.27", 44 | "@types/supertest": "^2.0.0", 45 | "chai": "^3.5.0", 46 | "mocha": "^3.4.1", 47 | "shelljs": "^0.7.7", 48 | "sinon": "^2.2.0", 49 | "sinon-chai": "^2.10.0", 50 | "supertest": "^3.0.0", 51 | "ts-mockito": "^2.0.0", 52 | "ts-node": "^3.0.4", 53 | "typescript": "^2.3.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/MovieListr.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from "koa"; 2 | import * as bodyParser from "koa-bodyparser"; 3 | import * as logger from "koa-logger"; 4 | import * as Router from "koa-router"; 5 | 6 | import { createConnection } from "typeorm"; 7 | import { Inject } from "typescript-ioc"; 8 | 9 | import Director from "./models/Director"; 10 | import Movie from "./models/Movie"; 11 | import Route from "./models/Route"; 12 | import DirectorRoutes from "./routes/DirectorRoutes"; 13 | import MovieRoutes from "./routes/MovieRoutes"; 14 | 15 | export default class MovieListr { 16 | 17 | constructor( 18 | @Inject private movieRoutes: MovieRoutes, 19 | @Inject private directorRoutes: DirectorRoutes) { } 20 | 21 | private async createApp() { 22 | await createConnection({ 23 | name: "default", 24 | driver: { 25 | type: "mysql", 26 | host: "localhost", 27 | port: 3306, 28 | username: "root", 29 | password: "root", 30 | database: "movielistr", 31 | }, 32 | entities: [ 33 | Movie, Director, 34 | ], 35 | }); 36 | 37 | const app: Koa = new Koa(); 38 | const router: Router = new Router(); 39 | 40 | this.movieRoutes.register(router); 41 | this.directorRoutes.register(router); 42 | 43 | app.use(logger()); 44 | app.use(bodyParser()); 45 | app.use(router.routes()); 46 | app.use(router.allowedMethods()); 47 | 48 | return Promise.resolve(app); 49 | } 50 | 51 | public async start() { 52 | const app = await this.createApp(); 53 | console.log("Started listening on port 3000..."); 54 | const server = app.listen(3000); 55 | return Promise.resolve(server); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/controllers/DirectorController.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "koa"; 2 | import { IMiddleware, IRouterContext } from "koa-router"; 3 | import { Inject, Singleton } from "typescript-ioc"; 4 | import Director from "../models/Director"; 5 | import DirectorService from "../services/DirectorService"; 6 | 7 | @Singleton 8 | export default class DirectorController { 9 | 10 | constructor( @Inject private directorService: DirectorService) { } 11 | 12 | public async getAllDirectors(ctx: IRouterContext) { 13 | ctx.body = await this.directorService.findAll(); 14 | } 15 | 16 | public async findDirectorById(ctx: IRouterContext) { 17 | try { 18 | ctx.body = await this.directorService.findById(ctx.params.id); 19 | } catch (e) { 20 | ctx.throw(404); 21 | } 22 | } 23 | 24 | public async saveDirector(ctx: IRouterContext) { 25 | try { 26 | const director: Director = Director.newDirector(ctx.request.body); 27 | const result = await this.directorService.save(director); 28 | ctx.body = result; 29 | } catch (e) { 30 | ctx.throw(400, e.message); 31 | } 32 | } 33 | 34 | public async updateDirector(ctx: IRouterContext) { 35 | try { 36 | const director: Director = Director.newDirector(ctx.request.body); 37 | if (String(ctx.params.id) !== String(director.$id)) { 38 | ctx.throw(400); 39 | } 40 | const result = await this.directorService.update(director); 41 | } catch (e) { 42 | ctx.throw(400, e.message); 43 | } 44 | } 45 | 46 | public async deleteDirector(ctx: IRouterContext) { 47 | const directorId = ctx.params.id; 48 | await this.directorService.delete(directorId); 49 | ctx.status = 200; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/testutils/MovieTestBuilder.ts: -------------------------------------------------------------------------------- 1 | import Director from "../../src/models/Director"; 2 | import Movie from "../../src/models/Movie"; 3 | import DirectorTestBuilder from "./DirectorTestBuilder"; 4 | 5 | export default class MovieTestBuilder { 6 | 7 | private movie: Movie = new Movie(); 8 | 9 | public static newMovie() { 10 | return new MovieTestBuilder(); 11 | } 12 | 13 | public withId(id: number): MovieTestBuilder { 14 | this.movie.$id = id; 15 | return this; 16 | } 17 | public withTitle(title: string): MovieTestBuilder { 18 | this.movie.$title = title; 19 | return this; 20 | } 21 | public withReleaseYear(releaseYear: number): MovieTestBuilder { 22 | this.movie.$releaseYear = releaseYear; 23 | return this; 24 | } 25 | public withDuration(duration: number): MovieTestBuilder { 26 | this.movie.$duration = duration; 27 | return this; 28 | } 29 | public withRating(rating: number): MovieTestBuilder { 30 | this.movie.$rating = rating; 31 | return this; 32 | } 33 | public withSeen(seen: boolean): MovieTestBuilder { 34 | this.movie.$seen = seen; 35 | return this; 36 | } 37 | public withDirector(director: Director): MovieTestBuilder { 38 | this.movie.$director = director; 39 | return this; 40 | } 41 | 42 | public withDefaultValues(): MovieTestBuilder { 43 | return this 44 | .withTitle("title") 45 | .withDuration(100) 46 | .withRating(5) 47 | .withReleaseYear(2016) 48 | .withSeen(true) 49 | .withDirector( 50 | DirectorTestBuilder 51 | .newDirector() 52 | .withDefaultValues() 53 | .build()); 54 | 55 | } 56 | 57 | public build(): Movie { 58 | return this.movie; 59 | } 60 | 61 | public static createListOfDefaultMovies(size: number) { 62 | const result = []; 63 | for (let i = 0; i < size; i++) { 64 | result.push(MovieTestBuilder.newMovie().withDefaultValues().withId(Math.random() * 10).build()); 65 | } 66 | return result; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/repositories/MovieRepository.ts: -------------------------------------------------------------------------------- 1 | import { Singleton } from "typescript-ioc"; 2 | import BadRequestEntity from "../exceptions/BadRequestEntity"; 3 | import EntityNotFoundError from "../exceptions/EntityNotFoundError"; 4 | import Director from "../models/Director"; 5 | import Movie from "../models/Movie"; 6 | import IRepository from "./IRepository"; 7 | 8 | @Singleton 9 | export default class MovieRepository extends IRepository { 10 | 11 | public async getAllMovies(): Promise { 12 | return this.getMovieRepository() 13 | .find({ 14 | alias: "movie", 15 | leftJoinAndSelect: { 16 | director: "movie.director", 17 | }, 18 | }); 19 | } 20 | 21 | public async findMovieById(id: number): Promise { 22 | const result = await this.getMovieRepository() 23 | .findOneById(id, { 24 | alias: "movie", 25 | leftJoinAndSelect: { 26 | director: "movie.director", 27 | }, 28 | }); 29 | if (!result) { 30 | throw new EntityNotFoundError(); 31 | } 32 | return result; 33 | } 34 | 35 | public async saveMovie(movie: Movie): Promise { 36 | const director = await this.getDirectorRepository().findOneById(movie.$director.$id); 37 | if (!director) { 38 | throw new BadRequestEntity("No director found for this ID: " + movie.$director.$id); 39 | } 40 | return this.getMovieRepository().persist(movie); 41 | } 42 | 43 | public async deleteMovie(id: number): Promise { 44 | const movie = await this.getMovieRepository().findOneById(id); 45 | if (!movie) { 46 | throw new EntityNotFoundError("No movie found with this ID"); 47 | } 48 | await this.getMovieRepository() 49 | .createQueryBuilder("movie") 50 | .delete() 51 | .where("movie.id = :id", { id }) 52 | .execute(); 53 | return Promise.resolve(); 54 | } 55 | 56 | public async deleteMoviesFromDirector(directorId: number): Promise { 57 | await this.getMovieRepository() 58 | .createQueryBuilder("movie") 59 | .delete() 60 | .where("movie.director.id = :directorId", { directorId }) 61 | .execute(); 62 | return Promise.resolve(); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/models/Movie.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from "typeorm"; 2 | import Director from "./Director"; 3 | 4 | @Entity() 5 | export default class Movie { 6 | 7 | @PrimaryGeneratedColumn() 8 | private id: number; 9 | 10 | @Column() 11 | private title: string; 12 | 13 | @Column() 14 | private releaseYear: number; 15 | 16 | @Column() 17 | private duration: number; 18 | 19 | @Column() 20 | private rating: number = 0; 21 | 22 | @Column() 23 | private seen: boolean = false; 24 | 25 | @OneToOne((type) => Director, { cascadeInsert: false, cascadeRemove: true, nullable: false }) 26 | @JoinColumn() 27 | private director: Director; 28 | 29 | public static newMovie(obj: { id?: number, title?: string, releaseYear?: number, duration?: number, director?: object, rating?: number, seen?: boolean }): Movie { 30 | const movie = new Movie(); 31 | if (obj.id) movie.id = obj.id; 32 | if (obj.title) movie.title = obj.title; 33 | if (obj.releaseYear) movie.releaseYear = obj.releaseYear; 34 | if (obj.duration) movie.duration = obj.duration; 35 | if (obj.director) movie.director = Director.newDirector(obj.director); 36 | if (obj.rating) movie.rating = obj.rating; 37 | if (obj.seen) movie.seen = obj.seen; 38 | return movie; 39 | } 40 | 41 | public get $id(): number { 42 | return this.id; 43 | } 44 | 45 | public set $id(id: number) { 46 | this.id = id; 47 | } 48 | 49 | public get $title(): string { 50 | return this.title; 51 | } 52 | 53 | public set $title(value: string) { 54 | this.title = value; 55 | } 56 | 57 | public get $releaseYear(): number { 58 | return this.releaseYear; 59 | } 60 | 61 | public set $releaseYear(value: number) { 62 | this.releaseYear = value; 63 | } 64 | 65 | public get $duration(): number { 66 | return this.duration; 67 | } 68 | 69 | public set $duration(value: number) { 70 | this.duration = value; 71 | } 72 | 73 | public get $director(): Director { 74 | return this.director; 75 | } 76 | 77 | public set $director(value: Director) { 78 | this.director = value; 79 | } 80 | 81 | public get $rating(): number { 82 | return this.rating; 83 | } 84 | 85 | public set $rating(value: number) { 86 | this.rating = value; 87 | } 88 | 89 | public get $seen(): boolean { 90 | return this.seen; 91 | } 92 | 93 | public set $seen(value: boolean) { 94 | this.seen = value; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/services/DirectorService.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | 4 | import { instance, mock, verify, when } from "ts-mockito/lib/ts-mockito"; 5 | import Director from "../../src/models/Director"; 6 | import DirectorRepository from "../../src/repositories/DirectorRepository"; 7 | import DirectorService from "../../src/services/DirectorService"; 8 | import DirectorTestBuilder from "../testutils/DirectorTestBuilder"; 9 | 10 | describe("DirectorService", () => { 11 | 12 | let serviceUnderTest: DirectorService; 13 | let directorRepository: DirectorRepository; 14 | 15 | const testId = 80085; 16 | const testDirectorList = DirectorTestBuilder.getListOfDefaultDirectors(5); 17 | const testDirectorWithId = DirectorTestBuilder.newDirector().withDefaultValues().withId(testId).build(); 18 | const testDirectorWithoutId = DirectorTestBuilder.newDirector().withDefaultValues().build(); 19 | 20 | beforeEach(() => { 21 | directorRepository = mock(DirectorRepository); 22 | serviceUnderTest = new DirectorService( 23 | instance(directorRepository), 24 | ); 25 | }); 26 | 27 | describe("findAll", () => { 28 | 29 | it("should return the 4 dummy directors", async () => { 30 | when(directorRepository.getAllDirectors()).thenReturn(Promise.resolve(testDirectorList)); 31 | const actual = await serviceUnderTest.findAll(); 32 | expect(actual).to.have.length(5); 33 | }); 34 | }); 35 | 36 | describe("findById", () => { 37 | 38 | it("should return the director with given Id if the director exists", async () => { 39 | when(directorRepository.findDirectorById(testId)).thenReturn(Promise.resolve(testDirectorWithId)); 40 | const actual = await serviceUnderTest.findById(testId); 41 | expect(actual).to.equal(testDirectorWithId); 42 | }); 43 | 44 | }); 45 | 46 | describe("saveDirector", () => { 47 | 48 | it("should add a director with the given information", async () => { 49 | when(directorRepository.saveDirector(testDirectorWithoutId)).thenReturn(Promise.resolve(testDirectorWithId)); 50 | const actual = await serviceUnderTest.save(testDirectorWithoutId); 51 | expect(actual).to.equal(testDirectorWithId); 52 | }); 53 | 54 | }); 55 | 56 | describe("deleteDirector", () => { 57 | 58 | it("should call the delete on the repository", async () => { 59 | when(directorRepository.deleteDirectorWithId(testId)).thenReturn(Promise.resolve()); 60 | await serviceUnderTest.delete(testId); 61 | verify(directorRepository.deleteDirectorWithId(testId)).called(); 62 | }); 63 | 64 | }); 65 | 66 | }); 67 | -------------------------------------------------------------------------------- /test/services/MovieService.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import * as sinon from "sinon"; 4 | import "sinon-chai"; 5 | import { instance, mock, verify, when } from "ts-mockito"; 6 | 7 | import Director from "../../src/models/Director"; 8 | import Movie from "../../src/models/Movie"; 9 | import MovieRepository from "../../src/repositories/MovieRepository"; 10 | import DirectorService from "../../src/services/DirectorService"; 11 | import MovieService from "../../src/services/MovieService"; 12 | import DirectorTestBuilder from "../testutils/DirectorTestBuilder"; 13 | import MovieTestBuilder from "../testutils/MovieTestBuilder"; 14 | 15 | describe("MovieService", () => { 16 | 17 | let serviceUnderTest: MovieService; 18 | let movieRepository: MovieRepository; 19 | 20 | const directorId = 80085; 21 | const movieId = 4141; 22 | const director: Director = DirectorTestBuilder.newDirector().withDefaultValues().build(); 23 | const movieList = MovieTestBuilder.createListOfDefaultMovies(5); 24 | const moviewithId = MovieTestBuilder.newMovie().withDefaultValues().withId(movieId).build(); 25 | const moviewithoutId = MovieTestBuilder.newMovie().withDefaultValues().build(); 26 | 27 | beforeEach(() => { 28 | movieRepository = mock(MovieRepository); 29 | 30 | serviceUnderTest = new MovieService( 31 | instance(movieRepository), 32 | ); 33 | }); 34 | 35 | describe("findAll", () => { 36 | 37 | it("should return all the movies", async () => { 38 | when(movieRepository.getAllMovies()).thenReturn(Promise.resolve(movieList)); 39 | const result = await serviceUnderTest.findAll(); 40 | expect(result).to.equal(movieList); 41 | }); 42 | 43 | }); 44 | 45 | describe("findById", () => { 46 | 47 | it("should return the movie with given ID", async () => { 48 | when(movieRepository.findMovieById(movieId)).thenReturn(Promise.resolve(moviewithId)); 49 | const actual = await serviceUnderTest.findById(moviewithId.$id); 50 | expect(actual).to.equal(moviewithId); 51 | }); 52 | 53 | }); 54 | 55 | describe("saveMovie", () => { 56 | 57 | it("should save the given movie", async () => { 58 | when(movieRepository.saveMovie(moviewithoutId)).thenReturn(Promise.resolve(moviewithId)); 59 | const actual = await serviceUnderTest.save(moviewithoutId); 60 | expect(actual).to.equal(moviewithId); 61 | }); 62 | 63 | }); 64 | 65 | describe("deleteMovie", () => { 66 | 67 | it("should delete the given movie", async () => { 68 | when(movieRepository.deleteMovie(movieId)).thenReturn(Promise.resolve()); 69 | const actual = await serviceUnderTest.delete(movieId); 70 | verify(movieRepository.deleteMovie(movieId)).called(); 71 | }); 72 | 73 | }); 74 | 75 | }); 76 | -------------------------------------------------------------------------------- /test/controllers/MovieController.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Context } from "koa"; 3 | import { IRouterContext } from "koa-router"; 4 | import "mocha"; 5 | import * as sinon from "sinon"; 6 | import { anything, capture, instance, mock, verify, when } from "ts-mockito"; 7 | import MovieController from "../../src/controllers/MovieController"; 8 | import Director from "../../src/models/Director"; 9 | import Movie from "../../src/models/Movie"; 10 | import DirectorService from "../../src/services/DirectorService"; 11 | import MovieService from "../../src/services/MovieService"; 12 | import MovieTestBuilder from "../testutils/MovieTestBuilder"; 13 | 14 | describe("MovieController", () => { 15 | 16 | let controllerUnderTest: MovieController; 17 | let movieService: MovieService; 18 | 19 | const testId = 80085; 20 | const movieWithId: Movie = MovieTestBuilder.newMovie().withDefaultValues().withId(testId).build(); 21 | const movieWithoutId: Movie = MovieTestBuilder.newMovie().withDefaultValues().build(); 22 | 23 | beforeEach(() => { 24 | movieService = mock(MovieService); 25 | controllerUnderTest = new MovieController(instance(movieService)); 26 | }); 27 | 28 | describe("getAllMovies", () => { 29 | 30 | it("puts the movies on the body", async () => { 31 | const movies = MovieTestBuilder.createListOfDefaultMovies(5); 32 | when(movieService.findAll()).thenReturn(Promise.resolve(movies)); 33 | const ctx: Context = {} as Context; 34 | 35 | await controllerUnderTest.getAllMovies(ctx); 36 | 37 | expect(ctx.body).to.equal(movies); 38 | }); 39 | }); 40 | 41 | describe("findMovieById", () => { 42 | 43 | it("puts the found the found movie on the body", async () => { 44 | const ctx: Context = {} as Context; 45 | ctx.params = { id: testId }; 46 | when(movieService.findById(testId)).thenReturn(Promise.resolve(movieWithId)); 47 | 48 | await controllerUnderTest.findMovieById(ctx); 49 | 50 | verify(movieService.findById(testId)).called(); 51 | expect(ctx.body).to.equal(movieWithId); 52 | }); 53 | 54 | it("return with a 404 if no movie is found", async () => { 55 | const errorMessage = "No movie found with ID."; 56 | const ctx: Context = { 57 | params: { id: testId }, 58 | throw: () => null, 59 | } as Context; 60 | when(movieService.findById(testId)).thenThrow(new Error(errorMessage)); 61 | const ctxMock = sinon.mock(ctx); 62 | ctxMock.expects("throw").calledWithExactly(404, errorMessage); 63 | 64 | await controllerUnderTest.findMovieById(ctx); 65 | 66 | ctxMock.verify(); 67 | }); 68 | }); 69 | 70 | describe("saveMovie", () => { 71 | 72 | it("delegates to movieService and responds with 200", async () => { 73 | const ctx: Context = { request: {} } as Context; 74 | const requestBody = { 75 | title: movieWithoutId.$title, 76 | releaseYear: movieWithoutId.$releaseYear, 77 | duration: movieWithoutId.$duration, 78 | rating: movieWithoutId.$rating, 79 | seen: movieWithoutId.$seen, 80 | director: { 81 | id: movieWithoutId.$director.$id, 82 | firstName: movieWithoutId.$director.$firstName, 83 | lastName: movieWithoutId.$director.$lastName, 84 | birthYear: movieWithoutId.$director.$birthYear, 85 | }, 86 | }; 87 | ctx.request.body = requestBody; 88 | 89 | when(movieService.save(anything())) 90 | .thenReturn(Promise.resolve(movieWithId)); 91 | 92 | await controllerUnderTest.saveMovie(ctx); 93 | 94 | const [firstArg] = capture(movieService.save).last(); 95 | console.log(JSON.stringify(firstArg)); 96 | expect(firstArg.$id).equals(undefined); 97 | expect(firstArg.$title).equals(requestBody.title); 98 | expect(firstArg.$releaseYear).equals(requestBody.releaseYear); 99 | expect(firstArg.$duration).equals(requestBody.duration); 100 | expect(firstArg.$rating).equals(requestBody.rating); 101 | expect(firstArg.$seen).equals(requestBody.seen); 102 | expect(firstArg.$director.$id).equals(requestBody.director.id); 103 | expect(firstArg.$director.$firstName).equals(requestBody.director.firstName); 104 | expect(firstArg.$director.$lastName).equals(requestBody.director.lastName); 105 | expect(firstArg.$director.$birthYear).equals(requestBody.director.birthYear); 106 | 107 | expect(ctx.body).to.equal(movieWithId); 108 | }); 109 | 110 | }); 111 | 112 | }); 113 | -------------------------------------------------------------------------------- /e2e/movies.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Server } from "http"; 3 | import * as Koa from "koa"; 4 | import "mocha"; 5 | import * as sinon from "sinon"; 6 | import { agent } from "supertest"; 7 | import { instance, mock, verify, when } from "ts-mockito"; 8 | import { Container } from "typescript-ioc"; 9 | import Movie from "../src/models/Movie"; 10 | import MovieListr from "../src/MovieListr"; 11 | import DirectorTestBuilder from "../test/testutils/DirectorTestBuilder"; 12 | import MovieTestBuilder from "../test/testutils/MovieTestBuilder"; 13 | 14 | describe("E2E: Movie Actions", () => { 15 | 16 | let app: Server; 17 | 18 | const title: string = "Spongebob Squarepants Movie"; 19 | const rating = 7; 20 | const duration = 84; 21 | const releaseYear = 2008; 22 | const seen = true; 23 | const directorId = 3; 24 | const directorFirstName = "John"; 25 | const directorLastName = "Lasseter"; 26 | const directorBirthYear = 1957; 27 | const director = () => DirectorTestBuilder.newDirector() 28 | .withId(3) 29 | .withFirstName(directorFirstName) 30 | .withLastName(directorLastName) 31 | .withBirthYear(directorBirthYear); 32 | 33 | before(async () => { 34 | const movielistr: MovieListr = Container.get(MovieListr); 35 | app = await movielistr.start(); 36 | }); 37 | 38 | describe("GET /movies", () => { 39 | 40 | it("returns a list of all movies", async () => { 41 | const response = await agent(app) 42 | .get("/movies") 43 | .accept("json") 44 | .expect(200); 45 | 46 | const result: Movie[] = response.body; 47 | expect(result).to.have.length(4); 48 | }); 49 | }); 50 | 51 | describe("GET /movies/:id", () => { 52 | 53 | it("should return the specific movie", async () => { 54 | const response = await agent(app) 55 | .get("/movies/1") 56 | .accept("json") 57 | .expect(200); 58 | 59 | const result = response.body; 60 | expect(result.id).to.equal(1); 61 | expect(result.title).to.equal("Finding Nemo"); 62 | expect(result.rating).to.equal(7); 63 | expect(result.releaseYear).to.equal(2008); 64 | expect(result.duration).to.equal(100); 65 | expect(result.director.id).to.equal(3); 66 | expect(result.director.firstName).to.equal("John"); 67 | expect(result.director.lastName).to.equal("Lasseter"); 68 | expect(result.director.birthYear).to.equal(1957); 69 | }); 70 | 71 | it("should return a 404 when the movie with id doesn't exist", async () => { 72 | const response = await agent(app) 73 | .get("/movies/999") 74 | .accept("json") 75 | .expect(404); 76 | }); 77 | 78 | }); 79 | 80 | describe("POST /movies", () => { 81 | 82 | it("should add the movie", async () => { 83 | const response = await agent(app) 84 | .post("/movies") 85 | .accept("json") 86 | .send(MovieTestBuilder 87 | .newMovie() 88 | .withTitle(title) 89 | .withRating(rating) 90 | .withDuration(duration) 91 | .withReleaseYear(releaseYear) 92 | .withSeen(seen) 93 | .withDirector(director().build()) 94 | .build(), 95 | ).expect(200); 96 | 97 | const result = response.body; 98 | expect(result.id).is.greaterThan(0); 99 | }); 100 | 101 | it("should return 400 if the director does not exist yet", async () => { 102 | await agent(app) 103 | .post("/movies") 104 | .accept("json") 105 | .send(MovieTestBuilder 106 | .newMovie() 107 | .withTitle(title) 108 | .withRating(rating) 109 | .withDuration(duration) 110 | .withReleaseYear(releaseYear) 111 | .withSeen(seen) 112 | .withDirector(director().withId(999).build()) 113 | .build(), 114 | ).expect(400); 115 | }); 116 | }); 117 | 118 | describe("PUT /movies/:id", () => { 119 | 120 | it("should update the movie with given ID", async () => { 121 | const movieResponse = await agent(app) 122 | .get("/movies") 123 | .accept("json") 124 | .expect(200); 125 | const allMovies = movieResponse.body; 126 | const movieId = allMovies[0].id; 127 | 128 | const response = await agent(app) 129 | .put(`/movies/${movieId}`) 130 | .accept("json") 131 | .send(MovieTestBuilder 132 | .newMovie() 133 | .withId(movieId) 134 | .withTitle(title) 135 | .withRating(rating) 136 | .withDuration(duration) 137 | .withReleaseYear(releaseYear) 138 | .withSeen(seen) 139 | .withDirector(director().build()) 140 | .build(), 141 | ).expect(200); 142 | 143 | const result = response.body; 144 | expect(result.id).to.equal(1); 145 | expect(result.title).to.equal(title); 146 | expect(result.rating).to.equal(rating); 147 | expect(result.duration).to.equal(duration); 148 | expect(result.releaseYear).to.equal(releaseYear); 149 | expect(result.seen).to.equal(seen); 150 | expect(result.director.id).to.equal(directorId); 151 | expect(result.director.firstName).to.equal(directorFirstName); 152 | expect(result.director.lastName).to.equal(directorLastName); 153 | expect(result.director.birthYear).to.equal(directorBirthYear); 154 | }); 155 | 156 | it("should return 400 if the movie does not exist yet", async () => { 157 | await agent(app) 158 | .put("/movies/99") 159 | .accept("json") 160 | .send(MovieTestBuilder 161 | .newMovie() 162 | .withId(99) 163 | .withTitle(title) 164 | .withRating(rating) 165 | .withDuration(duration) 166 | .withReleaseYear(releaseYear) 167 | .withSeen(seen) 168 | .withDirector(director().build()) 169 | .build(), 170 | ).expect(400); 171 | }); 172 | 173 | it("should return 400 if the id of the request object does not match the url id", async () => { 174 | await agent(app) 175 | .put("/movies/10") 176 | .accept("json") 177 | .send(MovieTestBuilder 178 | .newMovie() 179 | .withId(5) 180 | .withTitle(title) 181 | .withRating(rating) 182 | .withDuration(duration) 183 | .withReleaseYear(releaseYear) 184 | .withSeen(seen) 185 | .withDirector(director().build()) 186 | .build(), 187 | ).expect(400); 188 | }); 189 | }); 190 | 191 | describe("DELETE /movies/:id", () => { 192 | 193 | it("should delete the movie with the given type", async () => { 194 | const movieResponse = await agent(app) 195 | .get("/movies") 196 | .accept("json") 197 | .expect(200); 198 | const currentMovies = movieResponse.body; 199 | const movieId = currentMovies[0].id; 200 | 201 | await agent(app) 202 | .delete(`/movies/${movieId}`) 203 | .accept("json") 204 | .expect(200); 205 | 206 | const response = await agent(app) 207 | .get("/movies") 208 | .accept("json") 209 | .expect(200); 210 | 211 | const allMovies: Movie[] = response.body; 212 | 213 | allMovies.forEach((element) => { 214 | expect(element.$id).not.to.equal(movieId); 215 | }); 216 | }); 217 | 218 | it("should return a 404 if the movie does not exist (anymore)", async () => { 219 | await agent(app) 220 | .delete("/movies/999") 221 | .accept("json") 222 | .expect(404); 223 | }); 224 | 225 | }); 226 | 227 | }); 228 | --------------------------------------------------------------------------------