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 @@
-
-
-
-
-[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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-## 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"
}
}