From e46c16e678b17217bda5a633bc6c92d979049740 Mon Sep 17 00:00:00 2001 From: Olcar Date: Mon, 6 Jul 2026 16:48:17 -0400 Subject: [PATCH] fix: resolve production bugs, improve code quality, and add test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix PATCH data corruption (originPlanet/character undefined queries) - Fix DELETE soft-delete (was a no-op for characters) - Fix auth typo (acces_token → access_token) - Strip password hash from register response - Remove 10-day Cache-Control on API responses - Remove X-Powered-By header - Fix ESLint config and remove 12 unused imports - Add synchronization warnings for production - Add .env.example for easier onboarding - 28 unit tests across all 4 services - 4 e2e tests replacing broken placeholder - Rewrite README with project-specific documentation --- .env.example | 17 ++ .eslintrc.js | 13 +- README.md | 112 +++++------ package-lock.json | 30 +-- package.json | 5 +- src/app.module.ts | 3 +- src/auth/auth.service.spec.ts | 108 +++++++++++ src/auth/auth.service.ts | 7 +- src/characters/characters.controller.ts | 1 - src/characters/characters.module.ts | 1 - src/characters/characters.service.spec.ts | 177 ++++++++++++++++++ src/characters/characters.service.ts | 30 ++- src/characters/dto/character.dto.ts | 4 +- src/characters/dto/pagination.dto.ts | 10 - src/characters/entities/character.entity.ts | 1 - src/config/data.source.ts | 2 + src/main.ts | 10 +- src/planets/dto/planet.dto.ts | 4 +- src/planets/planets.controller.ts | 1 - src/planets/planets.service.spec.ts | 119 ++++++++++++ src/planets/planets.service.ts | 4 - src/transformation/dto/transformation.dto.ts | 2 +- src/transformation/transformation.module.ts | 1 - .../transformation.service.spec.ts | 162 ++++++++++++++++ src/transformation/transformation.service.ts | 20 +- test/app.e2e-spec.ts | 55 +++++- test/jest-e2e.json | 3 + 27 files changed, 747 insertions(+), 155 deletions(-) create mode 100644 .env.example create mode 100644 src/auth/auth.service.spec.ts create mode 100644 src/characters/characters.service.spec.ts delete mode 100644 src/characters/dto/pagination.dto.ts create mode 100644 src/planets/planets.service.spec.ts create mode 100644 src/transformation/transformation.service.spec.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..96feebb --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +PORT=3000 +API_URL=http://localhost:3000 + +# MySQL Database +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD= +MYSQL_DATABASE=dragonball_api + +# JWT Secret (generate a strong random key for production) +JWT_SECRET=change-me-to-a-random-secret + +# Cloudinary (image uploads — required for write endpoints) +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= diff --git a/.eslintrc.js b/.eslintrc.js index 7b76314..356232f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,12 +21,11 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], }, - - 'prettier/prettier': [ - 'error', - { - endOfLine: 'auto', - }, - ], }; diff --git a/README.md b/README.md index 00a13b1..bc6b20b 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,73 @@ -

- Nest Logo -

- -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest - -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Coverage -Discord -Backers on Open Collective -Sponsors on Open Collective - - Support us - -

- - -## Description - -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. - -## Installation +# Dragon Ball API -```bash -$ npm install -``` +REST API for Dragon Ball characters, planets, and transformations built with NestJS. + +## Tech Stack -## Running the app +- **Runtime:** Node.js 20 +- **Framework:** NestJS 10 + Express +- **Database:** MySQL 8.0 via TypeORM +- **Auth:** JWT (bcryptjs) +- **Image Upload:** Cloudinary +- **Docs:** Swagger (`/api-docs`) +- **Testing:** Jest (28 unit tests, 4 e2e) + +## Setup ```bash -# development -$ npm run start +# 1. Copy env vars and fill them in +cp .env.example .env + +# 2. Install +npm install + +# 3. Start MySQL and create the database +mysql -u root -e "CREATE DATABASE dragonball_api;" -# watch mode -$ npm run start:dev +# 4. (optional) Import seed data +mysql -u root dragonball_api < "Dump 04112023last.sql" -# production mode -$ npm run start:prod +# 5. Run +npm run start:dev ``` -## Test +## API -```bash -# unit tests -$ npm run test +Global prefix: `/api` — Docs at `/api-docs` -# e2e tests -$ npm run test:e2e +### Public Endpoints -# test coverage -$ npm run test:cov -``` +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/characters` | List characters (paginated, filterable) | +| GET | `/api/characters/:id` | Get character with planet + transformations | +| GET | `/api/planets` | List planets (paginated, filterable) | +| GET | `/api/planets/:id` | Get planet with characters | +| GET | `/api/transformations` | List transformations | +| GET | `/api/transformations/:id` | Get transformation with character | + +### Auth Endpoints -## Support +| Method | Path | Body | Returns | +|--------|------|------|---------| +| POST | `/api/auth/register` | `{name, email, password}` | User (no password hash) | +| POST | `/api/auth/login` | `{email, password}` | `{access_token, user}` | -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). +### Protected Endpoints (require `Authorization: Bearer `) -## Stay in touch +POST/PATCH/DELETE on all `/api/characters`, `/api/planets`, `/api/transformations` -- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +## Scripts + +```bash +npm run start:dev # watch mode +npm run test # 28 unit tests +npm run test:e2e # 4 e2e tests (requires MySQL) +npm run lint # eslint +npm run format # prettier +npm run build # compile +``` -## License +## Node.js Compatibility -Nest is [MIT licensed](LICENSE). +Node.js 20 is recommended. Node 26+ requires a patch to `buffer-equal-constant-time` (see `node_modules/`). diff --git a/package-lock.json b/package-lock.json index c8e1884..7dee0f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -285,7 +285,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -1612,7 +1611,6 @@ "version": "10.2.7", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.2.7.tgz", "integrity": "sha512-cUtCRXiUstDmh4bSBhVbq4cI439Gngp4LgLGLBmd5dqFQodfXKnSD441ldYfFiLz4rbUsnoMJz/8ZjuIEI+B7A==", - "peer": true, "dependencies": { "iterare": "1.2.1", "tslib": "2.6.2", @@ -1657,7 +1655,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.2.7.tgz", "integrity": "sha512-5GSu53QUUcwX17sNmlJPa1I0wIeAZOKbedyVuQx0ZAwWVa9g0wJBbsNP+R4EJ+j5Dkdzt/8xkiZvnKt8RFRR8g==", "hasInstallScript": true, - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -1725,7 +1722,6 @@ "version": "10.2.7", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.2.7.tgz", "integrity": "sha512-p+kp6aJtkgAdVpUrCVmM6MKtOvjsbt7QofBiZMidjYesZkMeG5gZ1D2SK8XzvQ8VXHJfFgEdY2xcKGB+wJLOYQ==", - "peer": true, "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -2139,7 +2135,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.6.tgz", "integrity": "sha512-P6bY56TVmX8y9J87jHNgQh43h6VVU+6H7oN7hgvivV81K2XY8qJZ5vqPy/HdUoVIelii2kChYVzQanlswPWVFw==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2262,7 +2257,6 @@ "version": "20.8.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2393,7 +2387,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.9.0.tgz", "integrity": "sha512-GZmjMh4AJ/5gaH4XF2eXA8tMnHWP+Pm1mjQR2QN4Iz+j/zO04b9TOvJYOX2sCNIQHtRStKTxRY1FX7LhpJT4Gw==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.9.0", "@typescript-eslint/types": "6.9.0", @@ -2724,7 +2717,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "devOptional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3217,7 +3209,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -3420,7 +3411,6 @@ "url": "https://paulmillr.com/funding/" } ], - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3470,14 +3460,12 @@ "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==", - "peer": true + "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==", - "peer": true, "dependencies": { "@types/validator": "^13.7.10", "libphonenumber-js": "^1.10.14", @@ -4369,7 +4357,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.52.0.tgz", "integrity": "sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4670,7 +4657,6 @@ "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5856,7 +5842,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6661,8 +6646,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "peer": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -7534,7 +7518,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7774,8 +7757,7 @@ "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", - "peer": true + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" }, "node_modules/regenerator-runtime": { "version": "0.14.0", @@ -8013,7 +7995,6 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -8065,7 +8046,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8920,7 +8900,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "devOptional": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9055,7 +9034,6 @@ "version": "0.3.17", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.17.tgz", "integrity": "sha512-UDjUEwIQalO9tWw9O2A4GU+sT3oyoUXheHJy4ft+RFdnRdQctdQ34L9SqE2p7LdwzafHx1maxT+bqXON+Qnmig==", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "app-root-path": "^3.1.0", @@ -9236,7 +9214,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "devOptional": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9424,7 +9401,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", "dev": true, - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", diff --git a/package.json b/package.json index 70d58ce..29b6738 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,9 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + } } } diff --git a/src/app.module.ts b/src/app.module.ts index 63ce3e1..a4df9d7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,4 @@ import { AuthModule } from './auth/auth.module'; -import { AuthController } from './auth/auth.controller'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; @@ -23,6 +22,8 @@ import { UsersModule } from './users/users.module'; password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, entities: [__dirname + '/**/*.entity{.ts,.js}'], + // TODO: synchronize drops and recreates tables on every restart. + // Replace with TypeORM migrations before deploying to production. synchronize: true, }), ServeStaticModule.forRoot({ diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..bf1ab57 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,108 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { UsersService } from 'src/users/users.service'; +import { JwtService } from '@nestjs/jwt'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; + +const mockUser = { + id: 1, + name: 'Test User', + email: 'test@test.com', + password: '$2a$10$hashedpassword', + deleteAt: null, +}; + +describe('AuthService', () => { + let service: AuthService; + + const mockUsersService = { + findOneByEmail: jest.fn(), + create: jest.fn(), + }; + + const mockJwtService = { + signAsync: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: UsersService, useValue: mockUsersService }, + { provide: JwtService, useValue: mockJwtService }, + ], + }).compile(); + + service = module.get(AuthService); + jest.clearAllMocks(); + }); + + describe('register', () => { + it('should create a user and strip password from response', async () => { + const dto = { + name: 'Test User', + email: 'test@test.com', + password: 'password123', + }; + mockUsersService.findOneByEmail.mockResolvedValue(null); + mockUsersService.create.mockResolvedValue({ ...mockUser }); + + const result = await service.register(dto); + + expect(mockUsersService.findOneByEmail).toHaveBeenCalledWith( + 'test@test.com', + ); + expect(mockUsersService.create).toHaveBeenCalled(); + expect(result).not.toHaveProperty('password'); + expect(result).toHaveProperty('email', 'test@test.com'); + expect(result).toHaveProperty('name', 'Test User'); + }); + + it('should throw when email already exists', async () => { + const dto = { + name: 'Test User', + email: 'existing@test.com', + password: 'password123', + }; + mockUsersService.findOneByEmail.mockResolvedValue(mockUser); + + await expect(service.register(dto)).rejects.toThrow(BadRequestException); + expect(mockUsersService.findOneByEmail).toHaveBeenCalledWith( + 'existing@test.com', + ); + expect(mockUsersService.create).not.toHaveBeenCalled(); + }); + }); + + describe('login', () => { + it('should return access_token and user name on success', async () => { + const dto = { email: 'test@test.com', password: 'password123' }; + mockUsersService.findOneByEmail.mockResolvedValue(mockUser); + jest.spyOn(bcrypt, 'compareSync').mockReturnValue(true); + mockJwtService.signAsync.mockResolvedValue('jwt-token'); + + const result = await service.login(dto); + + expect(result).toEqual({ access_token: 'jwt-token', user: 'Test User' }); + expect(mockUsersService.findOneByEmail).toHaveBeenCalledWith( + 'test@test.com', + ); + }); + + it('should throw when email is wrong', async () => { + const dto = { email: 'wrong@test.com', password: 'password123' }; + mockUsersService.findOneByEmail.mockResolvedValue(null); + + await expect(service.login(dto)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw when password is wrong', async () => { + const dto = { email: 'test@test.com', password: 'wrongpassword' }; + mockUsersService.findOneByEmail.mockResolvedValue(mockUser); + jest.spyOn(bcrypt, 'compareSync').mockReturnValue(false); + + await expect(service.login(dto)).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 0ceffc7..b260a8c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -23,11 +23,14 @@ export class AuthService { if (user) { throw new BadRequestException('Email already in use'); } - return this.userRepository.create({ + const newUser = await this.userRepository.create({ name, email, password: bcrypt.hashSync(password, 10), }); + return Object.fromEntries( + Object.entries(newUser).filter(([key]) => key !== 'password'), + ); } async login({ email, password }: LoginDto) { @@ -44,6 +47,6 @@ export class AuthService { sub: user.id, name: user.name, }; - return { acces_token: await this.jwt.signAsync(payload), user: user.name }; + return { access_token: await this.jwt.signAsync(payload), user: user.name }; } } diff --git a/src/characters/characters.controller.ts b/src/characters/characters.controller.ts index 9fb76ea..d045be6 100644 --- a/src/characters/characters.controller.ts +++ b/src/characters/characters.controller.ts @@ -1,4 +1,3 @@ -import { Transformation } from './../transformation/entities/transformation.entity'; import { Controller, Get, diff --git a/src/characters/characters.module.ts b/src/characters/characters.module.ts index af1d336..de8c25d 100644 --- a/src/characters/characters.module.ts +++ b/src/characters/characters.module.ts @@ -4,7 +4,6 @@ import { CharactersController } from './characters.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Character } from './entities/character.entity'; import { PlanetsModule } from 'src/planets/planets.module'; -import { CloudinaryService } from 'src/cloudinary/cloudinary.service'; import { CloudinaryModule } from 'src/cloudinary/cloudinary.module'; @Module({ diff --git a/src/characters/characters.service.spec.ts b/src/characters/characters.service.spec.ts new file mode 100644 index 0000000..eabae24 --- /dev/null +++ b/src/characters/characters.service.spec.ts @@ -0,0 +1,177 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CharactersService } from './characters.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Character } from './entities/character.entity'; +import { Planet } from 'src/planets/entities/planet.entity'; +import { CloudinaryService } from 'src/cloudinary/cloudinary.service'; +import { BadRequestException } from '@nestjs/common'; + +const mockCharacter = { + id: 1, + name: 'Goku', + ki: '60.000.000', + maxKi: '90 Septillion', + race: 'Saiyan', + gender: 'Male', + description: 'Test', + image: 'https://example.com/goku.webp', + affiliation: 'Z Fighter', + originPlanet: { id: 2, name: 'Tierra' }, + transformations: [], + deletedAt: null, +}; + +const mockPlanet = { + id: 2, + name: 'Tierra', + isDestroyed: false, + description: 'Earth', + image: '', + deletedAt: null, +}; + +describe('CharactersService', () => { + let service: CharactersService; + + const mockCharacterRepository = { + findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + }; + + const mockPlanetRepository = { + findOneBy: jest.fn(), + }; + + const mockCloudinaryService = { + uploadImage: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CharactersService, + { + provide: getRepositoryToken(Character), + useValue: mockCharacterRepository, + }, + { provide: getRepositoryToken(Planet), useValue: mockPlanetRepository }, + { provide: CloudinaryService, useValue: mockCloudinaryService }, + ], + }).compile(); + + service = module.get(CharactersService); + jest.clearAllMocks(); + }); + + describe('findOne', () => { + it('should return a character with relations', async () => { + mockCharacterRepository.findOne.mockResolvedValue(mockCharacter); + + const result = await service.findOne(1); + + expect(result).toEqual(mockCharacter); + expect(mockCharacterRepository.findOne).toHaveBeenCalledWith({ + where: { id: 1 }, + relations: ['originPlanet', 'transformations'], + }); + }); + + it('should throw when character is not found', async () => { + mockCharacterRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow(BadRequestException); + }); + }); + + describe('filter', () => { + it('should filter by name', async () => { + mockCharacterRepository.find.mockResolvedValue([mockCharacter]); + + const result = await service.filter( + 'Goku', + undefined, + undefined, + undefined, + ); + + expect(result).toEqual([mockCharacter]); + expect(mockCharacterRepository.find).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update without changing planet when originPlanet is not provided', async () => { + const updateDto = { name: 'Goku Updated' }; + const image = undefined; + mockCharacterRepository.findOneBy.mockResolvedValue(mockCharacter); + + await service.update(1, updateDto as any, image as any); + + expect(mockCharacterRepository.update).toHaveBeenCalledWith(1, { + name: 'Goku Updated', + image: mockCharacter.image, + }); + expect(mockPlanetRepository.findOneBy).not.toHaveBeenCalled(); + }); + + it('should update with new planet when originPlanet is provided', async () => { + const updateDto = { name: 'Goku Updated', originPlanet: 'Namek' }; + const image = undefined; + mockCharacterRepository.findOneBy.mockResolvedValue(mockCharacter); + mockPlanetRepository.findOneBy.mockResolvedValue(mockPlanet); + + await service.update(1, updateDto as any, image as any); + + expect(mockCharacterRepository.update).toHaveBeenCalledWith(1, { + name: 'Goku Updated', + image: mockCharacter.image, + originPlanet: mockPlanet, + }); + expect(mockPlanetRepository.findOneBy).toHaveBeenCalledWith({ + name: 'Namek', + }); + }); + + it('should throw when originPlanet is not found', async () => { + const updateDto = { name: 'Goku Updated', originPlanet: 'InvalidPlanet' }; + const image = undefined; + mockCharacterRepository.findOneBy.mockResolvedValue(mockCharacter); + mockPlanetRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.update(1, updateDto as any, image as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw when character is not found', async () => { + mockCharacterRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.update(999, {} as any, undefined as any), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('remove', () => { + it('should soft-delete a character', async () => { + mockCharacterRepository.findOneBy.mockResolvedValue(mockCharacter); + mockCharacterRepository.softDelete.mockResolvedValue({ affected: 1 }); + + const result = await service.remove(1); + + expect(result).toEqual({ affected: 1 }); + expect(mockCharacterRepository.softDelete).toHaveBeenCalledWith(1); + }); + + it('should throw when character is not found', async () => { + mockCharacterRepository.findOneBy.mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/characters/characters.service.ts b/src/characters/characters.service.ts index d2a94a0..7b57725 100644 --- a/src/characters/characters.service.ts +++ b/src/characters/characters.service.ts @@ -63,12 +63,6 @@ export class CharactersService { return await this.characterRepository.save(character); } - findAll() { - return this.characterRepository.find({ - relations: ['originPlanet', 'transformations'], - }); - } - async findOne(id: number) { const character = await this.characterRepository.findOne({ where: { id }, @@ -101,18 +95,22 @@ export class CharactersService { } } - const planet = await this.planetRepository.findOneBy({ - name: updateCharacterDto.originPlanet, - }); - if (!planet) { - throw new BadRequestException('OriginPlanet not found'); - } - const updatedCharacter = { - ...updateCharacterDto, + const { originPlanet: planetName, ...rest } = updateCharacterDto; + const updatedCharacter: any = { + ...rest, image: image ? result.secure_url : character.image, - originPlanet: planet, }; + if (planetName) { + const planet = await this.planetRepository.findOneBy({ + name: planetName, + }); + if (!planet) { + throw new BadRequestException('OriginPlanet not found'); + } + updatedCharacter.originPlanet = planet; + } + return await this.characterRepository.update(id, updatedCharacter); } @@ -121,6 +119,6 @@ export class CharactersService { if (!character) { throw new BadRequestException('Character ID not found'); } - return character; + return await this.characterRepository.softDelete(id); } } diff --git a/src/characters/dto/character.dto.ts b/src/characters/dto/character.dto.ts index 9da94ef..a64150c 100644 --- a/src/characters/dto/character.dto.ts +++ b/src/characters/dto/character.dto.ts @@ -1,9 +1,9 @@ import { PartialType } from '@nestjs/mapped-types'; -import { IsOptional, IsString, IsInt, IsEnum } from 'class-validator'; +import { IsString, IsEnum } from 'class-validator'; import { Affiliation } from 'src/constants/affiliation'; import { Gender } from 'src/constants/gender'; import { Race } from 'src/constants/race'; -import { ApiProperty, ApiTags } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; export class CreateCharacterDTO { @ApiProperty() diff --git a/src/characters/dto/pagination.dto.ts b/src/characters/dto/pagination.dto.ts deleted file mode 100644 index c438dd4..0000000 --- a/src/characters/dto/pagination.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNumber } from 'class-validator'; - -export class PaginationDTO { - @ApiProperty() - @IsNumber() - limit: number; - @ApiProperty() - offset: number; -} diff --git a/src/characters/entities/character.entity.ts b/src/characters/entities/character.entity.ts index 5fa9b03..dfbec17 100644 --- a/src/characters/entities/character.entity.ts +++ b/src/characters/entities/character.entity.ts @@ -2,7 +2,6 @@ import { Entity, PrimaryGeneratedColumn, Column, - OneToOne, ManyToOne, OneToMany, DeleteDateColumn, diff --git a/src/config/data.source.ts b/src/config/data.source.ts index d6b728b..6de24a6 100644 --- a/src/config/data.source.ts +++ b/src/config/data.source.ts @@ -16,6 +16,8 @@ export const DataSourceConfig: DataSourceOptions = { password: configService.get('MYSQL_PASSWORD'), database: configService.get('MYSQL_DATABASE'), entities: [__dirname + '/**/*.entity{.ts,.js}'], + // TODO: synchronize drops and recreates tables on every restart. + // Replace with TypeORM migrations before deploying to production. synchronize: true, }; diff --git a/src/main.ts b/src/main.ts index b3914f9..9e054bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { NestFactory } from '@nestjs/core'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; @@ -13,6 +13,7 @@ async function bootstrap() { app.enableCors(CORS); app.use(morgan('dev')); + app.disable('x-powered-by'); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api-docs', app, document); @@ -23,12 +24,7 @@ async function bootstrap() { forbidNonWhitelisted: true, transform: true, }), - ), - // Middleware personalizado para establecer Cache-Control 10 dias - app.use((req, res, next) => { - res.setHeader('Cache-Control', 'max-age=864000'); - next(); - }); + ); await app.listen(process.env.PORT || 3000); console.log(`Application is running on: ${await app.getUrl()}`); diff --git a/src/planets/dto/planet.dto.ts b/src/planets/dto/planet.dto.ts index 062fdd4..b8cb721 100644 --- a/src/planets/dto/planet.dto.ts +++ b/src/planets/dto/planet.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsString } from 'class-validator'; import { PartialType } from '@nestjs/mapped-types'; import { ApiProperty } from '@nestjs/swagger'; export class CreatePlanetDTO { @@ -6,7 +6,7 @@ export class CreatePlanetDTO { @IsString() name: string; @ApiProperty() - @IsString() + @IsBoolean() isDestroyed: boolean; @ApiProperty() @IsString() diff --git a/src/planets/planets.controller.ts b/src/planets/planets.controller.ts index 78b7226..30659dd 100644 --- a/src/planets/planets.controller.ts +++ b/src/planets/planets.controller.ts @@ -1,4 +1,3 @@ -import { IsBoolean } from 'class-validator'; import { Controller, Get, diff --git a/src/planets/planets.service.spec.ts b/src/planets/planets.service.spec.ts new file mode 100644 index 0000000..3a7e5f9 --- /dev/null +++ b/src/planets/planets.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PlanetsService } from './planets.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Planet } from './entities/planet.entity'; +import { CloudinaryService } from 'src/cloudinary/cloudinary.service'; +import { BadRequestException } from '@nestjs/common'; + +const mockPlanet = { + id: 1, + name: 'Namek', + isDestroyed: true, + description: 'Home of Namekians', + image: 'https://example.com/namek.webp', + characters: [], + deletedAt: null, +}; + +describe('PlanetsService', () => { + let service: PlanetsService; + + const mockPlanetRepository = { + findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + }; + + const mockCloudinaryService = { + uploadImage: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PlanetsService, + { provide: getRepositoryToken(Planet), useValue: mockPlanetRepository }, + { provide: CloudinaryService, useValue: mockCloudinaryService }, + ], + }).compile(); + + service = module.get(PlanetsService); + jest.clearAllMocks(); + }); + + describe('findOne', () => { + it('should return a planet with relations', async () => { + mockPlanetRepository.findOne.mockResolvedValue(mockPlanet); + + const result = await service.findOne(1); + + expect(result).toEqual(mockPlanet); + expect(mockPlanetRepository.findOne).toHaveBeenCalledWith({ + where: { id: 1 }, + relations: ['characters'], + }); + }); + + it('should throw when planet is not found', async () => { + mockPlanetRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow(BadRequestException); + }); + }); + + describe('filter', () => { + it('should filter by name', async () => { + mockPlanetRepository.find.mockResolvedValue([mockPlanet]); + + const result = await service.filter('Namek', undefined); + + expect(result).toEqual([mockPlanet]); + expect(mockPlanetRepository.find).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update planet', async () => { + const updateDto = { name: 'New Namek' }; + const image = undefined; + mockPlanetRepository.findOneBy.mockResolvedValue(mockPlanet); + + await service.update(1, updateDto as any, image as any); + + expect(mockPlanetRepository.update).toHaveBeenCalledWith(1, { + name: 'New Namek', + image: mockPlanet.image, + }); + }); + + it('should throw when planet is not found', async () => { + mockPlanetRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.update(999, {} as any, undefined as any), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('remove', () => { + it('should soft-delete a planet', async () => { + mockPlanetRepository.findOneBy.mockResolvedValue(mockPlanet); + mockPlanetRepository.softDelete.mockResolvedValue({ affected: 1 }); + + const result = await service.remove(1); + + expect(result).toEqual({ affected: 1 }); + expect(mockPlanetRepository.softDelete).toHaveBeenCalledWith(1); + }); + + it('should throw when planet is not found', async () => { + mockPlanetRepository.findOneBy.mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/planets/planets.service.ts b/src/planets/planets.service.ts index e96b760..9b7fe22 100644 --- a/src/planets/planets.service.ts +++ b/src/planets/planets.service.ts @@ -44,10 +44,6 @@ export class PlanetsService { return await this.planetRepository.save(planet); } - async findAll() { - return await this.planetRepository.find(); - } - async findOne(id: number) { const planet = await this.planetRepository.findOne({ where: { id }, diff --git a/src/transformation/dto/transformation.dto.ts b/src/transformation/dto/transformation.dto.ts index 60bb067..074be58 100644 --- a/src/transformation/dto/transformation.dto.ts +++ b/src/transformation/dto/transformation.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsOptional, IsString } from 'class-validator'; +import { IsString } from 'class-validator'; import { PartialType } from '@nestjs/mapped-types'; import { ApiProperty } from '@nestjs/swagger'; export class TransformationDTO { diff --git a/src/transformation/transformation.module.ts b/src/transformation/transformation.module.ts index d847130..822515e 100644 --- a/src/transformation/transformation.module.ts +++ b/src/transformation/transformation.module.ts @@ -5,7 +5,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Transformation } from './entities/transformation.entity'; import { CloudinaryModule } from 'src/cloudinary/cloudinary.module'; import { CharactersModule } from 'src/characters/characters.module'; -import { PlanetsModule } from 'src/planets/planets.module'; import { Character } from 'src/characters/entities/character.entity'; @Module({ diff --git a/src/transformation/transformation.service.spec.ts b/src/transformation/transformation.service.spec.ts new file mode 100644 index 0000000..3a4eb4d --- /dev/null +++ b/src/transformation/transformation.service.spec.ts @@ -0,0 +1,162 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TransformationService } from './transformation.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Transformation } from './entities/transformation.entity'; +import { Character } from 'src/characters/entities/character.entity'; +import { CloudinaryService } from 'src/cloudinary/cloudinary.service'; +import { BadRequestException } from '@nestjs/common'; + +const mockTransformation = { + id: 1, + name: 'Super Saiyan', + ki: '100.000.000', + image: 'https://example.com/ssj.webp', + character: { id: 1, name: 'Goku' }, + deletedAt: null, +}; + +const mockCharacter = { id: 1, name: 'Goku' }; + +describe('TransformationService', () => { + let service: TransformationService; + + const mockTransformationRepository = { + findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + }; + + const mockCharacterRepository = { + findOneBy: jest.fn(), + }; + + const mockCloudinaryService = { + uploadImage: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransformationService, + { + provide: getRepositoryToken(Transformation), + useValue: mockTransformationRepository, + }, + { + provide: getRepositoryToken(Character), + useValue: mockCharacterRepository, + }, + { provide: CloudinaryService, useValue: mockCloudinaryService }, + ], + }).compile(); + + service = module.get(TransformationService); + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all transformations', async () => { + mockTransformationRepository.find.mockResolvedValue([mockTransformation]); + + const result = await service.findAll(); + + expect(result).toEqual([mockTransformation]); + expect(mockTransformationRepository.find).toHaveBeenCalled(); + }); + }); + + describe('findOne', () => { + it('should return a transformation with character relation', async () => { + mockTransformationRepository.findOne.mockResolvedValue( + mockTransformation, + ); + + const result = await service.findOne(1); + + expect(result).toEqual(mockTransformation); + expect(mockTransformationRepository.findOne).toHaveBeenCalledWith({ + where: { id: 1 }, + relations: ['character'], + }); + }); + }); + + describe('update', () => { + it('should update without changing character when character is not provided', async () => { + const updateDto = { name: 'Super Saiyan 2' }; + const image = undefined; + mockTransformationRepository.findOneBy.mockResolvedValue( + mockTransformation, + ); + mockTransformationRepository.create.mockReturnValue({ + name: 'Super Saiyan 2', + image: mockTransformation.image, + }); + + await service.update(1, updateDto as any, image as any); + + expect(mockTransformationRepository.update).toHaveBeenCalledWith(1, { + name: 'Super Saiyan 2', + image: mockTransformation.image, + }); + expect(mockCharacterRepository.findOneBy).not.toHaveBeenCalled(); + }); + + it('should update with new character when character is provided', async () => { + const updateDto = { name: 'Super Saiyan 2', character: 'Vegeta' }; + const image = undefined; + mockTransformationRepository.findOneBy.mockResolvedValue( + mockTransformation, + ); + mockCharacterRepository.findOneBy.mockResolvedValue(mockCharacter); + mockTransformationRepository.create.mockReturnValue({ + name: 'Super Saiyan 2', + image: mockTransformation.image, + }); + + await service.update(1, updateDto as any, image as any); + + expect(mockCharacterRepository.findOneBy).toHaveBeenCalledWith({ + name: 'Vegeta', + }); + }); + + it('should throw when character is not found', async () => { + const updateDto = { character: 'InvalidChar' }; + const image = undefined; + mockTransformationRepository.findOneBy.mockResolvedValue( + mockTransformation, + ); + mockCharacterRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.update(1, updateDto as any, image as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw when transformation is not found', async () => { + mockTransformationRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.update(999, {} as any, undefined as any), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('remove', () => { + it('should soft-delete a transformation', async () => { + mockTransformationRepository.softDelete.mockResolvedValue({ + affected: 1, + }); + + const result = await service.remove(1); + + expect(result).toEqual({ affected: 1 }); + expect(mockTransformationRepository.softDelete).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/src/transformation/transformation.service.ts b/src/transformation/transformation.service.ts index ce795bf..ebb15dc 100644 --- a/src/transformation/transformation.service.ts +++ b/src/transformation/transformation.service.ts @@ -77,19 +77,23 @@ export class TransformationService { throw new BadRequestException('Image not uploaded'); } } - const character = await this.characterRepository.findOneBy({ - name: updateTransformationDto.character, - }); - if (!character) { - throw new BadRequestException('Character not found'); - } + const { character: characterName, ...rest } = updateTransformationDto; const updatedTransformation = this.transformationRepository.create({ - ...updateTransformationDto, + ...rest, image: result ? result.secure_url : transformation.image, - character, }); + if (characterName) { + const character = await this.characterRepository.findOneBy({ + name: characterName, + }); + if (!character) { + throw new BadRequestException('Character not found'); + } + updatedTransformation.character = character; + } + return await this.transformationRepository.update( id, updatedTransformation, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50cda62..87ea904 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,24 +1,67 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from './../src/app.module'; -describe('AppController (e2e)', () => { +describe('App (e2e)', () => { let app: INestApplication; - beforeEach(async () => { + beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); await app.init(); }); - it('/ (GET)', () => { + it('GET /api/characters returns paginated characters', () => { return request(app.getHttpServer()) - .get('/') + .get('/api/characters?limit=1') .expect(200) - .expect('Hello World!'); + .expect((res) => { + expect(res.body).toHaveProperty('items'); + expect(res.body).toHaveProperty('meta'); + expect(res.body.meta).toHaveProperty('totalItems'); + expect(res.body.items.length).toBeLessThanOrEqual(1); + }); + }); + + it('GET /api/planets returns paginated planets', () => { + return request(app.getHttpServer()) + .get('/api/planets?limit=1') + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty('items'); + expect(res.body).toHaveProperty('meta'); + }); + }); + + it('GET /api/characters/:id returns a single character', () => { + return request(app.getHttpServer()) + .get('/api/characters/1') + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty('id', 1); + expect(res.body).toHaveProperty('name'); + expect(res.body).toHaveProperty('originPlanet'); + expect(res.body).toHaveProperty('transformations'); + }); + }); + + it('GET /api/characters/999 returns 400 for non-existent character', () => { + return request(app.getHttpServer()).get('/api/characters/999').expect(400); + }); + + afterAll(async () => { + await app.close(); }); }); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..54be8a3 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -5,5 +5,8 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^src/(.*)$": "/../src/$1" } }