diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..01e304b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: Java CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'adopt' + cache: maven + - name: Build with Maven + run: mvn --batch-mode --update-snapshots verify diff --git a/.gitignore b/.gitignore index 549e00a..13ba091 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ +.env +*.env +.env* ### STS ### .apt_generated .classpath diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7955f97 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM openjdk:17-slim AS builder +WORKDIR application +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} application.jar +RUN java -Djarmode=layertools -jar application.jar extract + +FROM openjdk:17-slim +WORKDIR application +COPY --from=builder application/dependencies/ ./ +COPY --from=builder application/spring-boot-loader/ ./ +COPY --from=builder application/snapshot-dependencies/ ./ +COPY --from=builder application/application/ ./ +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] +EXPOSE 8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5aa8a3e --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# ๐Ÿš— Car-Sharing API + +This API is designed to manage a car-sharing service, offering features such as car inventory management, rental processing, user registration, JWT authentication, payment integration with Stripe, and Telegram notifications. It provides a complete solution for building a modern car-sharing platform with role-based access control for users and administrators. + +## ๐Ÿ› ๏ธ Technologies & Tools +- **Core Language**: Java 17 +- **Framework**: Spring Boot 3.4.4 (with Spring Web, Spring Data JPA, Spring Security) +- **Database**: MySQL 8.0 (with Liquibase for schema migrations) +- **Payment Processing**: Stripe API +- **Notifications**: Telegram Bot API +- **Testing**: JUnit 5, MockMvc, Testcontainers +- **API Documentation**: Swagger/OpenAPI +- **Dependency Management**: Maven +- **Containerization**: Docker, Docker Compose +- **Object Mapping**: MapStruct 1.6.3 +- **Validation**: Jakarta Validation 3.4.5 + +## โšก Functionality +The project provides a comprehensive set of features for managing cars, users, rentals, and payments: + +**๐Ÿ‘ค User Management** (`AuthController`, `UsersController`) +- User registration and authentication with JWT +- Role-based access control (MANAGER/CUSTOMER) +- Profile management + +**๐Ÿš— Car Management** (`CarsController`) +- CRUD operations for car inventory +- Car type classification (SEDAN, SUV, HATCHBACK, UNIVERSAL) +- Inventory tracking +- Daily fee management + +**๐Ÿ“… Rental Management** (`RentalsController`) +- Create new rentals with inventory checks +- Return management with inventory updates +- Rental status tracking (active/returned) +- Filtering by user and status + +**๐Ÿ’ณ Payment Processing** (`PaymentsController`) +- Integration with Stripe payment system +- Payment session management +- Success/cancel payment handlers +- Fine calculation for overdue rentals + +**๐Ÿ”” Telegram notifications** +- New rentals +- Overdue rentals +- Successful payments + +## ๐Ÿ“Š Database Schema + +![image](https://github.com/user-attachments/assets/7ca5d4c0-4331-419a-bd03-fd238edc5cff) + + +## ๐Ÿš€ Getting Started + +1๏ธโƒฃ **Setup** + +Clone the repository: + +```bash +git clone https://github.com/trokhim03/CarSharing-API.git +``` +2๏ธโƒฃ **ะกreate an environment of variables** + +Create file .env by copying the content from file .env.sample and fill in the fields. + +3๏ธโƒฃ **Build the project:** +```bash +docker build -t name_image_your_app +``` +4๏ธโƒฃ **Start the application using Docker Compose:** +```bash +docker-compose up +``` + +## Connecting to a Custom Database ๐Ÿ—„ +Configure your database connection and application settings by editing the src/main/resources/application.properties file. + +```bash +# Application +spring.application.name=car-sharing +server.servlet.context-path=/api + +# Database +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# JPA +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect + +# JWT +jwt.secret=${JWT_SECRET} +jwt.expiration=${JWT_EXPIRATION} + +# Stripe +stripe.secret.key=${STRIPE_SECRET_KEY} +stripe.public.key=${STRIPE_PUBLIC_KEY} +stripe.success.url=${STRIPE_SUCCESS_URL} +stripe.cancel.url=${STRIPE_CANCEL_URL} + +# Telegram +telegram.bot.name=${TELEGRAM_BOT_NAME} +telegram.bot.token=${TELEGRAM_BOT_TOKEN} +telegram.bot.chat.id=${TELEGRAM_CHAT_ID} +``` +The application will be available at http://localhost:8080 (default port) + +## ๐Ÿ“– API Documentation + +Explore the API endpoints with Swagger UI: + +**๐Ÿ”— [Swagger UI](http://localhost:8080/api/swagger-ui/index.html)** + +ะ—ะฝั–ะผะพะบ ะตะบั€ะฐะฝะฐ 2025-05-08 ะพ 14 10 15 + +## ๐Ÿ”’ Security +- JWT authentication for all endpoints + +- Role-based authorization + +- Password encryption + +- Secure payment processing with Stripe + +- All sensitive data stored in environment variables + +## ๐Ÿ“Œ Example API Requests +**Register a new user:** + +```bash +curl -X POST "http://localhost:8080/api/auth/registration" \ +-H "Content-Type: application/json" \ +-d '{ + "email": "user@example.com", + "password": "securePassword123", + "repeatPassword": "securePassword123", + "firstName": "John", + "lastName": "Doe" +}' +``` + +**Get available cars:** + +```bash +curl -X GET "http://localhost:8080/api/cars" \ +-H "Authorization: Bearer your.jwt.token" +``` diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..4548f16 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..58fdcec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: "3.8" + +services: + mysqldb: + image: mysql:8 + platform: linux/amd64 + restart: unless-stopped + env_file: ./.env + environment: + MYSQL_ROOT_PASSWORD: "${MYSQLDB_ROOT_PASSWORD}" + MYSQL_DATABASE: "${MYSQLDB_DATABASE}" + MYSQL_USER: "${MYSQLDB_USER}" + MYSQL_PASSWORD: "${MYSQLDB_PASSWORD}" + ports: + - "${MYSQLDB_LOCAL_PORT}:3306" + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + interval: 30s + timeout: 30s + retries: 3 + + carsharing-service: + build: + context: . + dockerfile: Dockerfile + env_file: ./.env + restart: on-failure + ports: + - "${SPRING_LOCAL_PORT}:${SPRING_DOCKER_PORT}" + - "${DEBUG_PORT}:${DEBUG_PORT}" + environment: + SPRING_DATASOURCE_URL: "jdbc:mysql://mysqldb:3306/${MYSQLDB_DATABASE}" + SPRING_DATASOURCE_USERNAME: "${MYSQLDB_USER}" + SPRING_DATASOURCE_PASSWORD: "${MYSQLDB_PASSWORD}" + SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: "org.hibernate.dialect.MySQL8Dialect" + JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:${DEBUG_PORT}" + depends_on: + mysqldb: + condition: service_healthy \ No newline at end of file diff --git a/pom.xml b/pom.xml index 40a5a5a..390596f 100644 --- a/pom.xml +++ b/pom.xml @@ -1,34 +1,42 @@ - + + 4.0.0 + org.springframework.boot spring-boot-starter-parent 3.4.4 - + + mate.academy car-sharing 0.0.1-SNAPSHOT car-sharing car-sharing - - - - - - - - - - - - - + 17 + 0.12.6 + 2.8.6 + 6.9.7.1 + 1.6.3 + 1.18.30 + 0.2.0 + 3.12.1 + 3.3.0 + checkstyle.xml + 28.0.0 + 1.20.6 + 3.4.5 + 6.2.5 + 6.4.4 + org.springframework.boot @@ -42,7 +50,20 @@ org.springframework.boot spring-boot-starter-web - + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.liquibase + liquibase-core + + + org.telegram + telegrambots-spring-boot-starter + ${telegrambots.version} + com.h2database h2 @@ -68,6 +89,60 @@ spring-security-test test + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + com.stripe + stripe-java + ${stripe.version} + + + org.testcontainers + mysql + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.springframework.boot + spring-boot-starter-validation + ${validation.version} + @@ -75,15 +150,30 @@ org.apache.maven.plugins maven-compiler-plugin + ${maven.compiler.plugin.version} + ${java.version} + ${java.version} org.projectlombok lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok-mapstruct-binding + ${lombok.mapstruct.binding.version} + org.springframework.boot spring-boot-maven-plugin @@ -96,7 +186,27 @@ + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven.checkstyle.plugin.version} + + + compile + + check + + + + + ${checkstyle.config.location} + true + true + false + src + + - diff --git a/src/main/java/mate/academy/carsharing/config/MapperConfig.java b/src/main/java/mate/academy/carsharing/config/MapperConfig.java new file mode 100644 index 0000000..da4bd16 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/config/MapperConfig.java @@ -0,0 +1,13 @@ +package mate.academy.carsharing.config; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.NullValueCheckStrategy; + +@org.mapstruct.MapperConfig( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS, + implementationPackage = ".impl" +) +public class MapperConfig { +} diff --git a/src/main/java/mate/academy/carsharing/config/SecurityConfig.java b/src/main/java/mate/academy/carsharing/config/SecurityConfig.java new file mode 100644 index 0000000..6ed2650 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package mate.academy.carsharing.config; + +import static org.springframework.security.config.Customizer.withDefaults; + +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final UserDetailsService service; + + @Bean + public PasswordEncoder getPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) + throws Exception { + return http + .cors(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + auth -> auth + .requestMatchers("/auth/**", "/error", + "/swagger-ui/**", + "/v3/api-docs/**") + .permitAll() + .anyRequest() + .authenticated() + ) + .httpBasic(withDefaults()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class) + .userDetailsService(service) + .build(); + } + + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authenticationConfiguration + ) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } +} diff --git a/src/main/java/mate/academy/carsharing/controller/AuthController.java b/src/main/java/mate/academy/carsharing/controller/AuthController.java new file mode 100644 index 0000000..6ab552f --- /dev/null +++ b/src/main/java/mate/academy/carsharing/controller/AuthController.java @@ -0,0 +1,43 @@ +package mate.academy.carsharing.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.user.UserLoginRequestDto; +import mate.academy.carsharing.dto.user.UserLoginResponseDto; +import mate.academy.carsharing.dto.user.UserRegistrationRequestDto; +import mate.academy.carsharing.dto.user.UserResponseDto; +import mate.academy.carsharing.exception.RegistrationException; +import mate.academy.carsharing.security.AuthenticationService; +import mate.academy.carsharing.service.user.UserService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Authentication management", + description = "Endpoints for user registration and login") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + private final UserService userService; + private final AuthenticationService authenticationService; + + @Operation(summary = "Register a new user", + description = "Register a new user with email, password and other details") + @PostMapping("/registration") + public UserResponseDto registration( + @RequestBody @Valid UserRegistrationRequestDto userRegistrationRequestDto) + throws RegistrationException { + return userService.register(userRegistrationRequestDto); + } + + @Operation(summary = "Login user", + description = "Authenticate user and return JWT token") + @PostMapping("/login") + public UserLoginResponseDto login(@RequestBody @Valid UserLoginRequestDto userLoginRequestDto) { + return authenticationService.authenticate(userLoginRequestDto); + } +} diff --git a/src/main/java/mate/academy/carsharing/controller/CarController.java b/src/main/java/mate/academy/carsharing/controller/CarController.java new file mode 100644 index 0000000..4f6a629 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/controller/CarController.java @@ -0,0 +1,74 @@ +package mate.academy.carsharing.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.car.CarRequestDto; +import mate.academy.carsharing.dto.car.CarResponseDto; +import mate.academy.carsharing.service.car.CarService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Car manager", + description = "Endpoints for managing cars") +@RestController +@RequiredArgsConstructor +@RequestMapping("/cars") +public class CarController { + private final CarService carService; + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Get all cars", + description = "Get a paginated list of all available cars") + @GetMapping + public Page getCars(Pageable pageable) { + return carService.getCars(pageable); + } + + @PreAuthorize("hasRole('MANAGER')") + @Operation(summary = "Create a new car", + description = "Create a new car with the provided details") + @PostMapping + public CarResponseDto createCar(@RequestBody @Valid CarRequestDto carRequestDto) { + return carService.createCar(carRequestDto); + } + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Get car by ID", + description = "Get a single car by its unique identifier") + @GetMapping("/{carId}") + public CarResponseDto findById(@PathVariable Long carId) { + return carService.findById(carId); + } + + @PreAuthorize("hasRole('MANAGER')") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Update car by ID", + description = "Update an existing car with new data") + @PutMapping("/{carId}") + public CarResponseDto updateById(@PathVariable Long carId, + @RequestBody @Valid CarRequestDto carRequestDto) { + return carService.updateById(carId, carRequestDto); + } + + @PreAuthorize("hasRole('MANAGER')") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete car by ID", + description = "Delete a car by its unique identifier") + @DeleteMapping("/{carId}") + public void deleteById(@PathVariable Long carId) { + carService.deleteById(carId); + } +} diff --git a/src/main/java/mate/academy/carsharing/controller/PaymentController.java b/src/main/java/mate/academy/carsharing/controller/PaymentController.java new file mode 100644 index 0000000..9019655 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/controller/PaymentController.java @@ -0,0 +1,64 @@ +package mate.academy.carsharing.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.payment.PaymentRequestDto; +import mate.academy.carsharing.dto.payment.PaymentResponseDto; +import mate.academy.carsharing.service.payment.PaymentService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Payment Management", + description = "Endpoints for managing payments and payment processing") +@RestController +@RequiredArgsConstructor +@RequestMapping("/payments") +public class PaymentController { + private final PaymentService paymentService; + + @PreAuthorize("hasRole('MANAGER')") + @Operation(summary = "Get user payments", + description = "Retrieve paginated list of payments for specific user") + @GetMapping + public Page getWithUserId(Pageable pageable, + @RequestParam(name = "user_id") Long userId) { + return paymentService.getWithUserId(pageable, userId); + } + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Create payment", + description = "Create a new payment session for rental") + @PostMapping + public PaymentResponseDto createPayment(@RequestBody + @Valid PaymentRequestDto paymentRequestDto) { + return paymentService.createPayment(paymentRequestDto); + } + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Payment success callback", + description = "Endpoint for payment provider to redirect after successful payment") + @GetMapping("/success/{paymentId}") + public String paymentSuccessRedirect(@PathVariable(required = false) Long paymentId) { + paymentService.checkSuccessfulPayment(paymentId); + return "Success"; + } + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Payment cancellation callback", + description = "Endpoint for payment provider to redirect after cancelled payment") + @GetMapping("/cancel/{paymentId}") + public String paymentCancelRedirect(@PathVariable(required = false) Long paymentId) { + return "Payment with id " + paymentId + + " was canceled."; + } +} diff --git a/src/main/java/mate/academy/carsharing/controller/RentalController.java b/src/main/java/mate/academy/carsharing/controller/RentalController.java new file mode 100644 index 0000000..10043ea --- /dev/null +++ b/src/main/java/mate/academy/carsharing/controller/RentalController.java @@ -0,0 +1,78 @@ +package mate.academy.carsharing.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.rental.RentalRequestDto; +import mate.academy.carsharing.dto.rental.RentalResponseDto; +import mate.academy.carsharing.dto.rental.RentalReturnRequestDto; +import mate.academy.carsharing.model.User; +import mate.academy.carsharing.service.rental.RentalService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Rental Management", + description = "Endpoints for managing car rentals") +@RestController +@RequiredArgsConstructor +@RequestMapping("/rentals") +public class RentalController { + private final RentalService rentalService; + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Create new rental", + description = "Create a new car rental for authenticated user") + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public RentalResponseDto createRental(Authentication authentication, + @RequestBody @Valid RentalRequestDto rentalRequestDto) { + Long userId = getAuthenticationUserId(authentication); + return rentalService.createRental(userId, rentalRequestDto); + } + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Get rental by ID", + description = "Get specific rental details by ID for authenticated user") + @GetMapping("/{rentalId}") + public RentalResponseDto getById(Authentication authentication, @PathVariable Long rentalId) { + Long userId = getAuthenticationUserId(authentication); + return rentalService.getById(userId, rentalId); + } + + @PreAuthorize("hasRole('MANAGER')") + @Operation(summary = "Get user rentals", + description = "Get paginated list of rentals for" + + " specific user with active status filter") + @GetMapping + public Page getAllByUserId( + Pageable pageable, + @RequestParam(name = "user_id") Long userId, + @RequestParam(name = "is_active") boolean isActive) { + return rentalService.getAllByUserId(pageable, userId, isActive); + } + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Return rental car", + description = "Set actual return date and process rental completion") + @PostMapping("/return") + public RentalResponseDto setDataReturn( + @RequestBody @Valid RentalReturnRequestDto rentalReturnRequestDto) { + return rentalService.setDataReturn(rentalReturnRequestDto); + } + + private Long getAuthenticationUserId(Authentication authentication) { + return ((User) authentication.getPrincipal()).getId(); + } +} diff --git a/src/main/java/mate/academy/carsharing/controller/UserController.java b/src/main/java/mate/academy/carsharing/controller/UserController.java new file mode 100644 index 0000000..0436db8 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/controller/UserController.java @@ -0,0 +1,61 @@ +package mate.academy.carsharing.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.role.RoleNameRequestDto; +import mate.academy.carsharing.dto.user.UserRegistrationRequestDto; +import mate.academy.carsharing.dto.user.UserResponseDto; +import mate.academy.carsharing.model.User; +import mate.academy.carsharing.service.user.UserService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "User Management", + description = "Endpoints for managing user accounts and roles") +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + private final UserService userService; + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Get current user info", + description = "Retrieve authenticated user's information") + @GetMapping("/me") + public UserResponseDto getUser(Authentication authentication) { + Long authenticationUserId = getAuthenticationUserId(authentication); + return userService.getUser(authenticationUserId); + } + + @PreAuthorize("hasRole('MANAGER')") + @Operation(summary = "Update user role", + description = "Update user's role (Admin only)") + @PutMapping("/update/{userId}/role") + public UserResponseDto updateRole(@PathVariable Long userId, + @RequestBody @Valid RoleNameRequestDto roleNameRequestDto) { + return userService.updateUserRole(userId, roleNameRequestDto); + } + + @PreAuthorize("hasRole('CUSTOMER')") + @Operation(summary = "Update current user info", + description = "Update authenticated user's personal information") + @PutMapping("/me") + public UserResponseDto updateUserInfo( + Authentication authentication, + @RequestBody @Valid UserRegistrationRequestDto userRegistrationRequestDto) { + Long authenticationUserId = getAuthenticationUserId(authentication); + return userService.updateMe(authenticationUserId, userRegistrationRequestDto); + } + + private Long getAuthenticationUserId(Authentication authentication) { + return ((User) authentication.getPrincipal()).getId(); + } +} diff --git a/src/main/java/mate/academy/carsharing/dto/car/CarRequestDto.java b/src/main/java/mate/academy/carsharing/dto/car/CarRequestDto.java new file mode 100644 index 0000000..08b0c5b --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/car/CarRequestDto.java @@ -0,0 +1,30 @@ +package mate.academy.carsharing.dto.car; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; +import lombok.Data; +import lombok.experimental.Accessors; +import mate.academy.carsharing.model.Car; + +@Data +@Accessors(chain = true) +public class CarRequestDto { + @NotBlank + private String model; + + @NotBlank + private String brand; + + @NotNull + private Car.Type type; + + @Positive + private int inventory; + + @NotNull + @DecimalMin(value = "0.0", inclusive = false) + private BigDecimal dailyFee; +} diff --git a/src/main/java/mate/academy/carsharing/dto/car/CarResponseDto.java b/src/main/java/mate/academy/carsharing/dto/car/CarResponseDto.java new file mode 100644 index 0000000..e5b5333 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/car/CarResponseDto.java @@ -0,0 +1,22 @@ +package mate.academy.carsharing.dto.car; + +import java.math.BigDecimal; +import lombok.Data; +import lombok.experimental.Accessors; +import mate.academy.carsharing.model.Car; + +@Data +@Accessors(chain = true) +public class CarResponseDto { + private Long id; + + private String model; + + private String brand; + + private Car.Type type; + + private int inventory; + + private BigDecimal dailyFee; +} diff --git a/src/main/java/mate/academy/carsharing/dto/payment/PaymentRequestDto.java b/src/main/java/mate/academy/carsharing/dto/payment/PaymentRequestDto.java new file mode 100644 index 0000000..37f1bfe --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/payment/PaymentRequestDto.java @@ -0,0 +1,12 @@ +package mate.academy.carsharing.dto.payment; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +@Data +public class PaymentRequestDto { + @NotNull + @Positive + private Long rentalId; +} diff --git a/src/main/java/mate/academy/carsharing/dto/payment/PaymentResponseDto.java b/src/main/java/mate/academy/carsharing/dto/payment/PaymentResponseDto.java new file mode 100644 index 0000000..eab3f46 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/payment/PaymentResponseDto.java @@ -0,0 +1,22 @@ +package mate.academy.carsharing.dto.payment; + +import java.math.BigDecimal; +import lombok.Data; +import mate.academy.carsharing.model.Payment; + +@Data +public class PaymentResponseDto { + private Long id; + + private Payment.Status status; + + private Payment.Type type; + + private Long rentalId; + + private String sessionUrl; + + private String sessionId; + + private BigDecimal amountToPay; +} diff --git a/src/main/java/mate/academy/carsharing/dto/rental/RentalRequestDto.java b/src/main/java/mate/academy/carsharing/dto/rental/RentalRequestDto.java new file mode 100644 index 0000000..6b5d1bd --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/rental/RentalRequestDto.java @@ -0,0 +1,28 @@ +package mate.academy.carsharing.dto.rental; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDate; +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class RentalRequestDto { + @NotNull + @JsonFormat(pattern = "yyyy-MM-dd") + @FutureOrPresent + private LocalDate rentalDate; + + @NotNull + @JsonFormat(pattern = "yyyy-MM-dd") + @Future + private LocalDate returnDate; + + @NotNull + @Positive + private Long carId; +} diff --git a/src/main/java/mate/academy/carsharing/dto/rental/RentalResponseDto.java b/src/main/java/mate/academy/carsharing/dto/rental/RentalResponseDto.java new file mode 100644 index 0000000..5ba8e27 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/rental/RentalResponseDto.java @@ -0,0 +1,19 @@ +package mate.academy.carsharing.dto.rental; + +import java.time.LocalDate; +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class RentalResponseDto { + private LocalDate rentalDate; + + private LocalDate returnDate; + + private LocalDate actualReturnDate; + + private Long carId; + + private Long userId; +} diff --git a/src/main/java/mate/academy/carsharing/dto/rental/RentalReturnRequestDto.java b/src/main/java/mate/academy/carsharing/dto/rental/RentalReturnRequestDto.java new file mode 100644 index 0000000..db8ec72 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/rental/RentalReturnRequestDto.java @@ -0,0 +1,22 @@ +package mate.academy.carsharing.dto.rental; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDate; +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class RentalReturnRequestDto { + @NotNull + @Positive + private Long rentalId; + + @NotNull + @FutureOrPresent + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate actualReturnDate; +} diff --git a/src/main/java/mate/academy/carsharing/dto/role/RoleNameRequestDto.java b/src/main/java/mate/academy/carsharing/dto/role/RoleNameRequestDto.java new file mode 100644 index 0000000..3a62374 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/role/RoleNameRequestDto.java @@ -0,0 +1,13 @@ +package mate.academy.carsharing.dto.role; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.experimental.Accessors; +import mate.academy.carsharing.model.Role; + +@Data +@Accessors(chain = true) +public class RoleNameRequestDto { + @NotNull + private Role.RoleName roleName; +} diff --git a/src/main/java/mate/academy/carsharing/dto/user/UserLoginRequestDto.java b/src/main/java/mate/academy/carsharing/dto/user/UserLoginRequestDto.java new file mode 100644 index 0000000..a803119 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/user/UserLoginRequestDto.java @@ -0,0 +1,18 @@ +package mate.academy.carsharing.dto.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class UserLoginRequestDto { + @NotBlank + @Email + @Size(min = 8, max = 30) + private String email; + + @NotBlank + @Size(min = 8, max = 20) + private String password; +} diff --git a/src/main/java/mate/academy/carsharing/dto/user/UserLoginResponseDto.java b/src/main/java/mate/academy/carsharing/dto/user/UserLoginResponseDto.java new file mode 100644 index 0000000..8578504 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/user/UserLoginResponseDto.java @@ -0,0 +1,4 @@ +package mate.academy.carsharing.dto.user; + +public record UserLoginResponseDto(String token) { +} diff --git a/src/main/java/mate/academy/carsharing/dto/user/UserRegistrationRequestDto.java b/src/main/java/mate/academy/carsharing/dto/user/UserRegistrationRequestDto.java new file mode 100644 index 0000000..8fa421a --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/user/UserRegistrationRequestDto.java @@ -0,0 +1,29 @@ +package mate.academy.carsharing.dto.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import mate.academy.carsharing.validation.FieldMatch; + +@Data +@FieldMatch(first = "password", second = "repeatPassword") +public class UserRegistrationRequestDto { + @Email + @NotBlank + private String email; + + @NotBlank + @Size(min = 8, max = 20) + private String password; + + @NotBlank + @Size(min = 8, max = 20) + private String repeatPassword; + + @NotBlank + private String firstName; + + @NotBlank + private String lastName; +} diff --git a/src/main/java/mate/academy/carsharing/dto/user/UserResponseDto.java b/src/main/java/mate/academy/carsharing/dto/user/UserResponseDto.java new file mode 100644 index 0000000..a3c5b7d --- /dev/null +++ b/src/main/java/mate/academy/carsharing/dto/user/UserResponseDto.java @@ -0,0 +1,18 @@ +package mate.academy.carsharing.dto.user; + +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class UserResponseDto { + private Long id; + + private String email; + + private String firstName; + + private String lastName; + + private String password; +} diff --git a/src/main/java/mate/academy/carsharing/exception/CarNotAvailableException.java b/src/main/java/mate/academy/carsharing/exception/CarNotAvailableException.java new file mode 100644 index 0000000..e46633c --- /dev/null +++ b/src/main/java/mate/academy/carsharing/exception/CarNotAvailableException.java @@ -0,0 +1,7 @@ +package mate.academy.carsharing.exception; + +public class CarNotAvailableException extends RuntimeException { + public CarNotAvailableException(String message) { + super(message); + } +} diff --git a/src/main/java/mate/academy/carsharing/exception/CustomGlobalExceptionHandler.java b/src/main/java/mate/academy/carsharing/exception/CustomGlobalExceptionHandler.java new file mode 100644 index 0000000..20ca37a --- /dev/null +++ b/src/main/java/mate/academy/carsharing/exception/CustomGlobalExceptionHandler.java @@ -0,0 +1,91 @@ +package mate.academy.carsharing.exception; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@ControllerAdvice +public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final String TIME_LABEL = "time"; + private static final String STATUS_LABEL = "status"; + private static final String ERRORS_LABEL = "errors"; + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + Map body = new LinkedHashMap<>(); + body.put(TIME_LABEL, LocalDateTime.now()); + body.put(STATUS_LABEL, HttpStatus.BAD_REQUEST); + List errors = ex.getBindingResult() + .getAllErrors() + .stream() + .map(this::getErrorMessage) + .toList(); + body.put(ERRORS_LABEL, errors); + + return new ResponseEntity<>(body, headers, status); + } + + @ExceptionHandler(CarNotAvailableException.class) + public ResponseEntity handleCarNotAvailableException(CarNotAvailableException ex) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { + return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(PaymentException.class) + public ResponseEntity handlePaymentException(PaymentException ex) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(RegistrationException.class) + public ResponseEntity handleRegistrationException(RegistrationException ex) { + return buildResponse(HttpStatus.CONFLICT, ex.getMessage()); + } + + @ExceptionHandler(StripePaymentException.class) + public ResponseEntity handleStripePaymentException(StripePaymentException ex) { + return buildResponse(HttpStatus.SERVICE_UNAVAILABLE, ex.getMessage()); + } + + @ExceptionHandler(TelegramNotificationException.class) + public ResponseEntity handleTelegramNotificationException( + TelegramNotificationException ex + ) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + + private ResponseEntity buildResponse(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put(TIME_LABEL, LocalDateTime.now()); + body.put(STATUS_LABEL, status); + body.put(ERRORS_LABEL, List.of(message)); + return new ResponseEntity<>(body, status); + } + + private Object getErrorMessage(ObjectError e) { + if (e instanceof FieldError fieldError) { + return fieldError.getField() + " " + fieldError.getDefaultMessage(); + } + return e.getDefaultMessage(); + } +} diff --git a/src/main/java/mate/academy/carsharing/exception/EntityNotFoundException.java b/src/main/java/mate/academy/carsharing/exception/EntityNotFoundException.java new file mode 100644 index 0000000..e52d30e --- /dev/null +++ b/src/main/java/mate/academy/carsharing/exception/EntityNotFoundException.java @@ -0,0 +1,7 @@ +package mate.academy.carsharing.exception; + +public class EntityNotFoundException extends RuntimeException { + public EntityNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/mate/academy/carsharing/exception/PaymentException.java b/src/main/java/mate/academy/carsharing/exception/PaymentException.java new file mode 100644 index 0000000..498f4be --- /dev/null +++ b/src/main/java/mate/academy/carsharing/exception/PaymentException.java @@ -0,0 +1,7 @@ +package mate.academy.carsharing.exception; + +public class PaymentException extends RuntimeException { + public PaymentException(String message) { + super(message); + } +} diff --git a/src/main/java/mate/academy/carsharing/exception/RegistrationException.java b/src/main/java/mate/academy/carsharing/exception/RegistrationException.java new file mode 100644 index 0000000..60aed32 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/exception/RegistrationException.java @@ -0,0 +1,7 @@ +package mate.academy.carsharing.exception; + +public class RegistrationException extends Exception { + public RegistrationException(String message) { + super(message); + } +} diff --git a/src/main/java/mate/academy/carsharing/exception/StripePaymentException.java b/src/main/java/mate/academy/carsharing/exception/StripePaymentException.java new file mode 100644 index 0000000..24f5c98 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/exception/StripePaymentException.java @@ -0,0 +1,7 @@ +package mate.academy.carsharing.exception; + +public class StripePaymentException extends RuntimeException { + public StripePaymentException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/mate/academy/carsharing/exception/TelegramNotificationException.java b/src/main/java/mate/academy/carsharing/exception/TelegramNotificationException.java new file mode 100644 index 0000000..18bec5d --- /dev/null +++ b/src/main/java/mate/academy/carsharing/exception/TelegramNotificationException.java @@ -0,0 +1,7 @@ +package mate.academy.carsharing.exception; + +public class TelegramNotificationException extends RuntimeException { + public TelegramNotificationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/mate/academy/carsharing/mapper/CarMapper.java b/src/main/java/mate/academy/carsharing/mapper/CarMapper.java new file mode 100644 index 0000000..fb3f647 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/mapper/CarMapper.java @@ -0,0 +1,20 @@ +package mate.academy.carsharing.mapper; + +import mate.academy.carsharing.config.MapperConfig; +import mate.academy.carsharing.dto.car.CarRequestDto; +import mate.academy.carsharing.dto.car.CarResponseDto; +import mate.academy.carsharing.model.Car; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; + +@Mapper(config = MapperConfig.class) +public interface CarMapper { + CarResponseDto toDto(Car car); + + Car toModel(CarRequestDto carRequestDto); + + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateModelFromDto(CarRequestDto requestDto, @MappingTarget Car car); +} diff --git a/src/main/java/mate/academy/carsharing/mapper/PaymentMapper.java b/src/main/java/mate/academy/carsharing/mapper/PaymentMapper.java new file mode 100644 index 0000000..37b2614 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/mapper/PaymentMapper.java @@ -0,0 +1,13 @@ +package mate.academy.carsharing.mapper; + +import mate.academy.carsharing.config.MapperConfig; +import mate.academy.carsharing.dto.payment.PaymentResponseDto; +import mate.academy.carsharing.model.Payment; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(config = MapperConfig.class) +public interface PaymentMapper { + @Mapping(target = "rentalId", source = "rental.id") + PaymentResponseDto toDto(Payment payment); +} diff --git a/src/main/java/mate/academy/carsharing/mapper/RentalMapper.java b/src/main/java/mate/academy/carsharing/mapper/RentalMapper.java new file mode 100644 index 0000000..7335d61 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/mapper/RentalMapper.java @@ -0,0 +1,19 @@ +package mate.academy.carsharing.mapper; + +import mate.academy.carsharing.config.MapperConfig; +import mate.academy.carsharing.dto.rental.RentalRequestDto; +import mate.academy.carsharing.dto.rental.RentalResponseDto; +import mate.academy.carsharing.model.Rental; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(config = MapperConfig.class) +public interface RentalMapper { + @Mapping(target = "user.id", source = "userId") + @Mapping(target = "car.id", source = "carId") + Rental toModelWithCarIdAndUserId(RentalRequestDto rentalRequestDto, Long carId, Long userId); + + @Mapping(target = "carId", source = "car.id") + @Mapping(target = "userId", source = "user.id") + RentalResponseDto toDto(Rental rental); +} diff --git a/src/main/java/mate/academy/carsharing/mapper/UserMapper.java b/src/main/java/mate/academy/carsharing/mapper/UserMapper.java new file mode 100644 index 0000000..88be139 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/mapper/UserMapper.java @@ -0,0 +1,20 @@ +package mate.academy.carsharing.mapper; + +import mate.academy.carsharing.config.MapperConfig; +import mate.academy.carsharing.dto.user.UserRegistrationRequestDto; +import mate.academy.carsharing.dto.user.UserResponseDto; +import mate.academy.carsharing.model.User; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; + +@Mapper(config = MapperConfig.class) +public interface UserMapper { + UserResponseDto toUserResponse(User user); + + User toModel(UserRegistrationRequestDto userRegistrationDto); + + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateModelFromDto(UserRegistrationRequestDto requestDto, @MappingTarget User user); +} diff --git a/src/main/java/mate/academy/carsharing/model/Car.java b/src/main/java/mate/academy/carsharing/model/Car.java new file mode 100644 index 0000000..511087b --- /dev/null +++ b/src/main/java/mate/academy/carsharing/model/Car.java @@ -0,0 +1,54 @@ +package mate.academy.carsharing.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Setter +@SQLDelete(sql = "UPDATE cars SET is_deleted = true WHERE id=?") +@SQLRestriction(value = "is_deleted = false") +@Accessors(chain = true) +@Table(name = "cars") +public class Car { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String model; + + @Column(nullable = false) + private String brand; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Type type; + + private int inventory; + + @Column(nullable = false) + private BigDecimal dailyFee; + + @Column(nullable = false) + private boolean isDeleted = false; + + public enum Type { + SEDAN, + SUV, + HATCHBACK, + UNIVERSAL + } +} diff --git a/src/main/java/mate/academy/carsharing/model/Payment.java b/src/main/java/mate/academy/carsharing/model/Payment.java new file mode 100644 index 0000000..f1ed407 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/model/Payment.java @@ -0,0 +1,67 @@ +package mate.academy.carsharing.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Setter +@Getter +@SQLDelete(sql = "UPDATE payments SET is_deleted = true WHERE id =?") +@SQLRestriction(value = "is_deleted = false") +@Table(name = "payments") +public class Payment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Type type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "rental_id", nullable = false) + private Rental rental; + + @Lob + @Column(nullable = false, columnDefinition = "TEXT") + private String sessionUrl; + + @Lob + @Column(nullable = false, unique = true, columnDefinition = "TEXT") + private String sessionId; + + @Column(nullable = false) + private BigDecimal amountToPay; + + @Column(nullable = false) + private boolean isDeleted = false; + + public enum Status { + PENDING, + PAID + } + + public enum Type { + PAYMENT, + FINE + } +} diff --git a/src/main/java/mate/academy/carsharing/model/Rental.java b/src/main/java/mate/academy/carsharing/model/Rental.java new file mode 100644 index 0000000..0a94a19 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/model/Rental.java @@ -0,0 +1,47 @@ +package mate.academy.carsharing.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Setter +@Getter +@SQLDelete(sql = "UPDATE rentals SET is_deleted = true WHERE id=?") +@SQLRestriction(value = "is_deleted = false") +@Table(name = "rentals") +public class Rental { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate rentalDate; + + @Column(nullable = false) + private LocalDate returnDate; + + private LocalDate actualReturnDate; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "car_id", nullable = false) + private Car car; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private boolean isDeleted = false; +} diff --git a/src/main/java/mate/academy/carsharing/model/Role.java b/src/main/java/mate/academy/carsharing/model/Role.java new file mode 100644 index 0000000..bbb2f3b --- /dev/null +++ b/src/main/java/mate/academy/carsharing/model/Role.java @@ -0,0 +1,37 @@ +package mate.academy.carsharing.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; + +@Entity +@Setter +@Getter +@Table(name = "roles") +public class Role implements GrantedAuthority { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, unique = true) + private RoleName name; + + @Override + public String getAuthority() { + return "ROLE_" + name.name(); + } + + public enum RoleName { + MANAGER, + CUSTOMER + } +} diff --git a/src/main/java/mate/academy/carsharing/model/User.java b/src/main/java/mate/academy/carsharing/model/User.java new file mode 100644 index 0000000..42a1752 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/model/User.java @@ -0,0 +1,89 @@ +package mate.academy.carsharing.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Entity +@Setter +@Getter +@SQLDelete(sql = "UPDATE users SET is_deleted = true Where id=?") +@SQLRestriction(value = "is_deleted = false") +@Table(name = "users") +public class User implements UserDetails { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String firstName; + + @Column(nullable = false) + private String lastName; + + @Column(nullable = false) + private String password; + + @ManyToMany + @JoinTable(name = "users_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles = new HashSet<>(); + + @Column(nullable = false) + private boolean isDeleted = false; + + @Override + public Collection getAuthorities() { + return roles; + } + + @Override + public String getUsername() { + return email; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return !isDeleted; + } +} diff --git a/src/main/java/mate/academy/carsharing/repository/CarRepository.java b/src/main/java/mate/academy/carsharing/repository/CarRepository.java new file mode 100644 index 0000000..f15c2ee --- /dev/null +++ b/src/main/java/mate/academy/carsharing/repository/CarRepository.java @@ -0,0 +1,7 @@ +package mate.academy.carsharing.repository; + +import mate.academy.carsharing.model.Car; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CarRepository extends JpaRepository { +} diff --git a/src/main/java/mate/academy/carsharing/repository/PaymentRepository.java b/src/main/java/mate/academy/carsharing/repository/PaymentRepository.java new file mode 100644 index 0000000..33e9a1e --- /dev/null +++ b/src/main/java/mate/academy/carsharing/repository/PaymentRepository.java @@ -0,0 +1,12 @@ +package mate.academy.carsharing.repository; + +import mate.academy.carsharing.model.Payment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PaymentRepository extends JpaRepository { + Page findAllByRental_UserId(Pageable pageable, Long userId); + + boolean existsByRentalIdAndStatus(Long id, Payment.Status status); +} diff --git a/src/main/java/mate/academy/carsharing/repository/RentalRepository.java b/src/main/java/mate/academy/carsharing/repository/RentalRepository.java new file mode 100644 index 0000000..b9b6f19 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/repository/RentalRepository.java @@ -0,0 +1,15 @@ +package mate.academy.carsharing.repository; + +import java.util.Optional; +import mate.academy.carsharing.model.Rental; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RentalRepository extends JpaRepository { + Page findAllByUserIdAndActualReturnDateIsNotNull(Pageable pageable, Long userId); + + Page findAllByUserIdAndActualReturnDateIsNull(Pageable pageable, Long userId); + + Optional findByIdAndUserId(Long rentalId, Long userId); +} diff --git a/src/main/java/mate/academy/carsharing/repository/RoleRepository.java b/src/main/java/mate/academy/carsharing/repository/RoleRepository.java new file mode 100644 index 0000000..afd8ffd --- /dev/null +++ b/src/main/java/mate/academy/carsharing/repository/RoleRepository.java @@ -0,0 +1,9 @@ +package mate.academy.carsharing.repository; + +import java.util.Optional; +import mate.academy.carsharing.model.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RoleRepository extends JpaRepository { + Optional findByName(Role.RoleName roleName); +} diff --git a/src/main/java/mate/academy/carsharing/repository/UserRepository.java b/src/main/java/mate/academy/carsharing/repository/UserRepository.java new file mode 100644 index 0000000..28b78b5 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/repository/UserRepository.java @@ -0,0 +1,16 @@ +package mate.academy.carsharing.repository; + +import java.util.Optional; +import mate.academy.carsharing.model.User; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + + @EntityGraph(attributePaths = "roles") + Optional findByEmail(String email); + + @EntityGraph(attributePaths = "roles") + Optional findById(Long id); +} diff --git a/src/main/java/mate/academy/carsharing/security/AuthenticationService.java b/src/main/java/mate/academy/carsharing/security/AuthenticationService.java new file mode 100644 index 0000000..a062d55 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/security/AuthenticationService.java @@ -0,0 +1,26 @@ +package mate.academy.carsharing.security; + +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.user.UserLoginRequestDto; +import mate.academy.carsharing.dto.user.UserLoginResponseDto; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + private final JwtUtil jwtUtil; + private final AuthenticationManager authenticationManager; + + public UserLoginResponseDto authenticate(UserLoginRequestDto request) { + final Authentication authentication = authenticationManager + .authenticate(new UsernamePasswordAuthenticationToken( + request.getEmail(), + request.getPassword() + )); + String token = jwtUtil.generateToken(authentication.getName()); + return new UserLoginResponseDto(token); + } +} diff --git a/src/main/java/mate/academy/carsharing/security/CustomUserDetailsService.java b/src/main/java/mate/academy/carsharing/security/CustomUserDetailsService.java new file mode 100644 index 0000000..5f5755d --- /dev/null +++ b/src/main/java/mate/academy/carsharing/security/CustomUserDetailsService.java @@ -0,0 +1,22 @@ +package mate.academy.carsharing.security; + +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.repository.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return userRepository.findByEmail(email) + .orElseThrow(() -> new EntityNotFoundException("Can't find user " + + "by email:" + email)); + } +} diff --git a/src/main/java/mate/academy/carsharing/security/JwtAuthenticationFilter.java b/src/main/java/mate/academy/carsharing/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..7f596f8 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/security/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package mate.academy.carsharing.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String BEARER_PREFIX = "Bearer "; + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String token = getToken(request); + + if (token != null && jwtUtil.isValidToken(token)) { + String username = jwtUtil.getUsername(token); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } + + private String getToken(HttpServletRequest request) { + String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/src/main/java/mate/academy/carsharing/security/JwtUtil.java b/src/main/java/mate/academy/carsharing/security/JwtUtil.java new file mode 100644 index 0000000..fc9494d --- /dev/null +++ b/src/main/java/mate/academy/carsharing/security/JwtUtil.java @@ -0,0 +1,60 @@ +package mate.academy.carsharing.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.function.Function; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + private final SecretKey secret; + + @Value("${jwt.expiration}") + private long expiration; + + public JwtUtil(@Value("${jwt.secret}") String secretString) { + secret = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(String username) { + return Jwts.builder() + .subject(username) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(secret) + .compact(); + } + + public boolean isValidToken(String token) { + try { + Jws claimsJws = Jwts.parser() + .verifyWith(secret) + .build() + .parseSignedClaims(token); + return claimsJws.getPayload().getExpiration().after(new Date()); + } catch (JwtException | IllegalArgumentException e) { + throw new JwtException("Expired or invalid JWT token"); + } + } + + public String getUsername(String token) { + return getClaimFromToken(token, Claims::getSubject); + } + + private T getClaimFromToken(String token, Function claimsResolver) { + Claims claims = Jwts + .parser() + .verifyWith(secret) + .build() + .parseSignedClaims(token) + .getPayload(); + return claimsResolver.apply(claims); + } +} diff --git a/src/main/java/mate/academy/carsharing/service/car/CarService.java b/src/main/java/mate/academy/carsharing/service/car/CarService.java new file mode 100644 index 0000000..a03dedd --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/car/CarService.java @@ -0,0 +1,18 @@ +package mate.academy.carsharing.service.car; + +import mate.academy.carsharing.dto.car.CarRequestDto; +import mate.academy.carsharing.dto.car.CarResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CarService { + CarResponseDto createCar(CarRequestDto carRequestDto); + + Page getCars(Pageable pageable); + + CarResponseDto findById(Long carId); + + CarResponseDto updateById(Long carId, CarRequestDto carRequestDto); + + void deleteById(Long carId); +} diff --git a/src/main/java/mate/academy/carsharing/service/car/CarServiceImpl.java b/src/main/java/mate/academy/carsharing/service/car/CarServiceImpl.java new file mode 100644 index 0000000..4dbe0fd --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/car/CarServiceImpl.java @@ -0,0 +1,55 @@ +package mate.academy.carsharing.service.car; + +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.car.CarRequestDto; +import mate.academy.carsharing.dto.car.CarResponseDto; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.mapper.CarMapper; +import mate.academy.carsharing.model.Car; +import mate.academy.carsharing.repository.CarRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CarServiceImpl implements CarService { + private final CarMapper carMapper; + private final CarRepository carRepository; + + @Override + public CarResponseDto createCar(CarRequestDto carRequestDto) { + Car car = carMapper.toModel(carRequestDto); + carRepository.save(car); + return carMapper.toDto(car); + } + + @Override + public Page getCars(Pageable pageable) { + return carRepository.findAll(pageable) + .map(carMapper::toDto); + } + + @Override + public CarResponseDto findById(Long carId) { + Car car = carRepository.findById(carId).orElseThrow( + () -> new EntityNotFoundException("Can't find car with id: " + carId) + ); + return carMapper.toDto(car); + } + + @Override + public CarResponseDto updateById(Long carId, CarRequestDto carRequestDto) { + Car car = carRepository.findById(carId).orElseThrow( + () -> new EntityNotFoundException("Can't find car with id: " + carId) + ); + carMapper.updateModelFromDto(carRequestDto, car); + carRepository.save(car); + return carMapper.toDto(car); + } + + @Override + public void deleteById(Long carId) { + carRepository.deleteById(carId); + } +} diff --git a/src/main/java/mate/academy/carsharing/service/payment/PaymentService.java b/src/main/java/mate/academy/carsharing/service/payment/PaymentService.java new file mode 100644 index 0000000..07604b7 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/payment/PaymentService.java @@ -0,0 +1,14 @@ +package mate.academy.carsharing.service.payment; + +import mate.academy.carsharing.dto.payment.PaymentRequestDto; +import mate.academy.carsharing.dto.payment.PaymentResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PaymentService { + Page getWithUserId(Pageable pageable, Long userId); + + PaymentResponseDto createPayment(PaymentRequestDto paymentRequestDto); + + void checkSuccessfulPayment(Long paymentId); +} diff --git a/src/main/java/mate/academy/carsharing/service/payment/PaymentServiceImpl.java b/src/main/java/mate/academy/carsharing/service/payment/PaymentServiceImpl.java new file mode 100644 index 0000000..febf004 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/payment/PaymentServiceImpl.java @@ -0,0 +1,152 @@ +package mate.academy.carsharing.service.payment; + +import com.stripe.model.checkout.Session; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.time.temporal.ChronoUnit; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.payment.PaymentRequestDto; +import mate.academy.carsharing.dto.payment.PaymentResponseDto; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.exception.PaymentException; +import mate.academy.carsharing.mapper.PaymentMapper; +import mate.academy.carsharing.model.Payment; +import mate.academy.carsharing.model.Rental; +import mate.academy.carsharing.repository.PaymentRepository; +import mate.academy.carsharing.repository.RentalRepository; +import mate.academy.carsharing.service.payment.stripe.StripePaymentService; +import mate.academy.carsharing.service.telegram.NotificationMessageService; +import mate.academy.carsharing.service.telegram.NotificationService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class PaymentServiceImpl implements PaymentService { + private static final BigDecimal DAILY_FINE_MULTIPLIER = BigDecimal.valueOf(1.4); + + @Value("${payment.session-id}") + private String defaultSessionId; + + @Value("${payment.session-url}") + private String defaultSessionUrl; + + private final PaymentRepository paymentRepository; + private final StripePaymentService stripePaymentService; + private final PaymentMapper paymentMapper; + private final RentalRepository rentalRepository; + private final NotificationService notificationService; + private final NotificationMessageService notificationMessageService; + + @Override + public Page getWithUserId(Pageable pageable, Long userId) { + return paymentRepository.findAllByRental_UserId(pageable, userId) + .map(paymentMapper::toDto); + } + + @Override + public PaymentResponseDto createPayment(PaymentRequestDto paymentRequestDto) { + Rental rental = getRentalById(paymentRequestDto.getRentalId()); + Payment.Type type = determinePaymentType(rental); + checkActivePaymentExists(rental.getId()); + + BigDecimal amountToPay = calculateAmountToPay(rental, type); + Payment payment = createPendingPayment(rental, type, amountToPay); + + Session paymentSession = stripePaymentService + .createPaymentSession(payment, rental, amountToPay); + stripePaymentService.setPaymentSessionUrl(payment, paymentSession); + + payment.setSessionId(paymentSession.getId()); + notifyAboutPaymentCreation(payment, rental); + + return paymentMapper.toDto(paymentRepository.save(payment)); + } + + @Override + public void checkSuccessfulPayment(Long paymentId) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new EntityNotFoundException( + "Can't find payment by id: " + paymentId)); + + Payment.Status oldStatus = payment.getStatus(); + Payment.Status newStatus = stripePaymentService.checkPaymentStatus(payment.getSessionId()); + + if (oldStatus != newStatus) { + payment.setStatus(newStatus); + paymentRepository.save(payment); + } + if (newStatus == Payment.Status.PAID) { + notifyAboutSuccessfulPayment(payment); + } + } + + private Rental getRentalById(Long rentalId) { + return rentalRepository.findById(rentalId) + .orElseThrow(() -> new EntityNotFoundException("Can't find rental" + + " by id:" + rentalId)); + } + + private void checkActivePaymentExists(Long rentalId) { + if (paymentRepository.existsByRentalIdAndStatus(rentalId, Payment.Status.PENDING)) { + throw new PaymentException("Active payment already exists for this rental"); + } + } + + private Payment createPendingPayment(Rental rental, Payment.Type type, BigDecimal amountToPay) { + Payment payment = new Payment(); + payment.setRental(rental); + payment.setType(type); + payment.setStatus(Payment.Status.PENDING); + payment.setAmountToPay(amountToPay); + payment.setSessionId(defaultSessionId); + payment.setSessionUrl(defaultSessionUrl); + return paymentRepository.save(payment); + } + + private void notifyAboutPaymentCreation(Payment payment, Rental rental) { + String message = notificationMessageService + .createPaymentNotification(payment, rental, rental.getCar()); + notificationService.sendNotification(message); + } + + private void notifyAboutSuccessfulPayment(Payment payment) { + String message = notificationMessageService + .createSuccessfulPaymentNotification(payment); + notificationService.sendNotification(message); + } + + private Payment.Type determinePaymentType(Rental rental) { + if (rental.getActualReturnDate() == null) { + throw new PaymentException("Cannot create payment - car is not returned yet"); + } + + if (rental.getActualReturnDate().isAfter(rental.getReturnDate())) { + return Payment.Type.FINE; + } + + return Payment.Type.PAYMENT; + } + + private BigDecimal calculateAmountToPay(Rental rental, Payment.Type type) { + long basicRentalDays = Math.max(1, rental.getRentalDate() + .until(rental.getReturnDate(), ChronoUnit.DAYS)); + BigDecimal basicCost = rental.getCar().getDailyFee() + .multiply(BigDecimal.valueOf(basicRentalDays)); + + if (type == Payment.Type.PAYMENT) { + return basicCost; + } else { + long overdueDays = Math.max(1, rental.getReturnDate() + .until(rental.getActualReturnDate(), ChronoUnit.DAYS)); + BigDecimal fine = rental.getCar().getDailyFee() + .multiply(BigDecimal.valueOf(overdueDays)) + .multiply(DAILY_FINE_MULTIPLIER); + + return basicCost.add(fine); + } + } +} diff --git a/src/main/java/mate/academy/carsharing/service/payment/stripe/StripePaymentService.java b/src/main/java/mate/academy/carsharing/service/payment/stripe/StripePaymentService.java new file mode 100644 index 0000000..11a5091 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/payment/stripe/StripePaymentService.java @@ -0,0 +1,14 @@ +package mate.academy.carsharing.service.payment.stripe; + +import com.stripe.model.checkout.Session; +import java.math.BigDecimal; +import mate.academy.carsharing.model.Payment; +import mate.academy.carsharing.model.Rental; + +public interface StripePaymentService { + Session createPaymentSession(Payment payment, Rental rental, BigDecimal amount); + + void setPaymentSessionUrl(Payment payment, Session session); + + Payment.Status checkPaymentStatus(String sessionId); +} diff --git a/src/main/java/mate/academy/carsharing/service/payment/stripe/StripePaymentServiceImpl.java b/src/main/java/mate/academy/carsharing/service/payment/stripe/StripePaymentServiceImpl.java new file mode 100644 index 0000000..78c987e --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/payment/stripe/StripePaymentServiceImpl.java @@ -0,0 +1,101 @@ +package mate.academy.carsharing.service.payment.stripe; + +import com.stripe.Stripe; +import com.stripe.exception.StripeException; +import com.stripe.model.checkout.Session; +import com.stripe.param.checkout.SessionCreateParams; +import jakarta.annotation.PostConstruct; +import java.math.BigDecimal; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.exception.StripePaymentException; +import mate.academy.carsharing.model.Payment; +import mate.academy.carsharing.model.Rental; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StripePaymentServiceImpl implements StripePaymentService { + private static final String CURRENCY = "usd"; + private static final long SESSION_EXPIRATION_HOURS = 24; + + @Value("${stripe.secret.key}") + private String stripeSecretKey; + + @Value("${stripe.success.url}") + private String successUrl; + + @Value("${stripe.cancel.url}") + private String cancelUrl; + + @PostConstruct + public void init() { + Stripe.apiKey = stripeSecretKey; + } + + @Override + public Session createPaymentSession(Payment payment, Rental rental, BigDecimal amount) { + try { + SessionCreateParams params = SessionCreateParams.builder() + .setMode(SessionCreateParams.Mode.PAYMENT) + .setSuccessUrl(successUrl + payment.getId()) + .setCancelUrl(cancelUrl + payment.getId()) + .addLineItem(createLineItem(rental, amount)) + .setExpiresAt(calculateExpirationTime()) + .build(); + + return Session.create(params); + } catch (StripeException e) { + throw new StripePaymentException("Failed to create Stripe session", e); + } + } + + @Override + public void setPaymentSessionUrl(Payment payment, Session session) { + payment.setSessionUrl(session.getUrl()); + } + + @Override + public Payment.Status checkPaymentStatus(String sessionId) { + try { + Session session = Session.retrieve(sessionId); + return mapStripeStatusToPaymentStatus(session.getStatus()); + } catch (StripeException e) { + throw new StripePaymentException("Failed to check payment status", e); + } + } + + private Payment.Status mapStripeStatusToPaymentStatus(String status) { + return switch (status) { + case "complete", "succeeded", "paid" -> Payment.Status.PAID; + case "pending", "canceled", "failed" -> Payment.Status.PENDING; + default -> throw new IllegalStateException("Unexpected value: " + status); + }; + } + + private Long calculateExpirationTime() { + return Instant.now() + .plusSeconds(SESSION_EXPIRATION_HOURS * 60 * 60) + .getEpochSecond(); + } + + private SessionCreateParams.LineItem createLineItem(Rental rental, BigDecimal amount) { + return SessionCreateParams.LineItem.builder() + .setQuantity(1L) + .setPriceData( + SessionCreateParams.LineItem.PriceData.builder() + .setCurrency(CURRENCY) + .setUnitAmount(convertToCents(amount)) + .setProductData( + SessionCreateParams.LineItem.PriceData.ProductData.builder() + .setName("Rental #" + rental.getId()) + .build()) + .build()) + .build(); + } + + private Long convertToCents(BigDecimal amount) { + return amount.multiply(BigDecimal.valueOf(100)).longValue(); + } +} diff --git a/src/main/java/mate/academy/carsharing/service/rental/RentalService.java b/src/main/java/mate/academy/carsharing/service/rental/RentalService.java new file mode 100644 index 0000000..89dad1b --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/rental/RentalService.java @@ -0,0 +1,17 @@ +package mate.academy.carsharing.service.rental; + +import mate.academy.carsharing.dto.rental.RentalRequestDto; +import mate.academy.carsharing.dto.rental.RentalResponseDto; +import mate.academy.carsharing.dto.rental.RentalReturnRequestDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface RentalService { + RentalResponseDto createRental(Long userId, RentalRequestDto rentalRequestDto); + + Page getAllByUserId(Pageable pageable, Long userId, boolean isActive); + + RentalResponseDto getById(Long userId, Long rentalId); + + RentalResponseDto setDataReturn(RentalReturnRequestDto rentalReturnRequestDto); +} diff --git a/src/main/java/mate/academy/carsharing/service/rental/RentalServiceImpl.java b/src/main/java/mate/academy/carsharing/service/rental/RentalServiceImpl.java new file mode 100644 index 0000000..b2a139f --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/rental/RentalServiceImpl.java @@ -0,0 +1,94 @@ +package mate.academy.carsharing.service.rental; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.rental.RentalRequestDto; +import mate.academy.carsharing.dto.rental.RentalResponseDto; +import mate.academy.carsharing.dto.rental.RentalReturnRequestDto; +import mate.academy.carsharing.exception.CarNotAvailableException; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.mapper.RentalMapper; +import mate.academy.carsharing.model.Car; +import mate.academy.carsharing.model.Rental; +import mate.academy.carsharing.repository.CarRepository; +import mate.academy.carsharing.repository.RentalRepository; +import mate.academy.carsharing.service.telegram.NotificationMessageService; +import mate.academy.carsharing.service.telegram.NotificationService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class RentalServiceImpl implements RentalService { + private final RentalRepository rentalRepository; + private final RentalMapper rentalMapper; + private final CarRepository carRepository; + private final NotificationService notificationService; + private final NotificationMessageService notificationMessageService; + + @Override + public RentalResponseDto createRental(Long userId, RentalRequestDto rentalRequestDto) { + Car car = carRepository + .findById(rentalRequestDto.getCarId()).orElseThrow(() -> + new EntityNotFoundException("Can't find car by id: " + + rentalRequestDto.getCarId())); + if (car.getInventory() < 1) { + throw new CarNotAvailableException("Car with id " + + rentalRequestDto.getCarId() + " is not available"); + } + + car.setInventory(car.getInventory() - 1); + + Rental rental = rentalMapper.toModelWithCarIdAndUserId(rentalRequestDto, + car.getId(), userId); + carRepository.save(car); + rentalRepository.save(rental); + String message = notificationMessageService.createRentalNotification(rental, car); + notificationService.sendNotification(message); + return rentalMapper.toDto(rental); + } + + @Override + public Page getAllByUserId(Pageable pageable, + Long userId, boolean isActive) { + Page rentalsPage; + if (isActive) { + rentalsPage = rentalRepository + .findAllByUserIdAndActualReturnDateIsNull(pageable, userId); + + } else { + rentalsPage = rentalRepository + .findAllByUserIdAndActualReturnDateIsNotNull(pageable, userId); + } + return rentalsPage.map(rentalMapper::toDto); + } + + @Override + public RentalResponseDto getById(Long userId, Long rentalId) { + Rental rental = rentalRepository.findByIdAndUserId(rentalId, userId) + .orElseThrow(() -> new EntityNotFoundException("Can't find " + + "rental by id:" + rentalId)); + return rentalMapper.toDto(rental); + } + + @Override + public RentalResponseDto setDataReturn(RentalReturnRequestDto rentalReturnRequestDto) { + Rental rental = rentalRepository.findById(rentalReturnRequestDto.getRentalId()) + .orElseThrow(() -> new EntityNotFoundException("Can't find rental by id:" + + rentalReturnRequestDto.getRentalId())); + + Car car = carRepository.findById(rental.getCar().getId()) + .orElseThrow(() -> new EntityNotFoundException("Can't find car by id:" + + rental.getCar().getId())); + + rental.setActualReturnDate(rentalReturnRequestDto.getActualReturnDate()); + car.setInventory(car.getInventory() + 1); + rentalRepository.save(rental); + carRepository.save(car); + String message = notificationMessageService.createReturnNotification(rental, car); + notificationService.sendNotification(message); + return rentalMapper.toDto(rental); + } +} diff --git a/src/main/java/mate/academy/carsharing/service/telegram/CarSharingBot.java b/src/main/java/mate/academy/carsharing/service/telegram/CarSharingBot.java new file mode 100644 index 0000000..1071080 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/telegram/CarSharingBot.java @@ -0,0 +1,60 @@ +package mate.academy.carsharing.service.telegram; + +import mate.academy.carsharing.exception.TelegramNotificationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.telegram.telegrambots.bots.TelegramLongPollingBot; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +@Component +public class CarSharingBot extends TelegramLongPollingBot { + + @Value("${telegram.bot.name}") + private String botName; + + @Value("${telegram.bot.token}") + private String botToken; + + @Override + public String getBotUsername() { + return botName; + } + + @Override + public String getBotToken() { + return botToken; + } + + @Override + public void onUpdateReceived(Update update) { + if (update.hasMessage() && update.getMessage().hasText()) { + String text = update.getMessage().getText(); + Long chatId = update.getMessage().getChatId(); + + SendMessage message = new SendMessage(); + message.setChatId(chatId); + message.setText(text); + + try { + execute(message); + } catch (TelegramApiException e) { + throw new TelegramNotificationException("Failed to send" + + " Telegram message to chat:", e); + } + } + } + + public void sendMessage(String text, Long chatId) { + SendMessage message = new SendMessage(); + message.setChatId(chatId); + message.setText(text); + + try { + execute(message); + } catch (TelegramApiException e) { + throw new TelegramNotificationException("Failed to send Telegram message to chat:", e); + } + } +} diff --git a/src/main/java/mate/academy/carsharing/service/telegram/NotificationMessageService.java b/src/main/java/mate/academy/carsharing/service/telegram/NotificationMessageService.java new file mode 100644 index 0000000..f0a0062 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/telegram/NotificationMessageService.java @@ -0,0 +1,110 @@ +package mate.academy.carsharing.service.telegram; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import mate.academy.carsharing.model.Car; +import mate.academy.carsharing.model.Payment; +import mate.academy.carsharing.model.Rental; +import org.springframework.stereotype.Service; + +@Service +public class NotificationMessageService { + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("dd.MM.yyyy"); + + public String createRentalNotification(Rental rental, Car car) { + return String.format( + """ + ๐Ÿš— New rental created! + ---------------------------- + Car: %s %s + Type: %s + Available cars: %d + Rental dates: %s - %s + Rental ID: %d + ---------------------------- + Thank you for choosing our service! + """, + car.getBrand(), + car.getModel(), + car.getType().name(), + car.getInventory(), + rental.getRentalDate().format(DATE_FORMATTER), + rental.getReturnDate().format(DATE_FORMATTER), + rental.getId() + ); + } + + public String createReturnNotification(Rental rental, Car car) { + return String.format( + """ + ๐Ÿ”™ Car returned! + ---------------------------- + Car: %s %s + Rental ID: %d + Planned return date: %s + Actual return date: %s + Delay: %s + Available cars of this model: %d + ---------------------------- + Thank you for using our service! + """, + car.getBrand(), + car.getModel(), + rental.getId(), + rental.getReturnDate().format(DATE_FORMATTER), + rental.getActualReturnDate().format(DATE_FORMATTER), + calculateDelay(rental.getReturnDate(), rental.getActualReturnDate()), + car.getInventory() + ); + } + + public String createPaymentNotification(Payment payment, Rental rental, Car car) { + return String.format( + """ + ๐Ÿ’ณ New payment created! + ---------------------------- + Car: %s %s + Rental ID: %d + Payment type: %s + Amount to pay: %.2f $ + Status: %s + ---------------------------- + """, + car.getBrand(), + car.getModel(), + rental.getId(), + payment.getType().name(), + payment.getAmountToPay(), + payment.getStatus().name() + ); + } + + public String createSuccessfulPaymentNotification(Payment payment) { + return String.format( + """ + โœ… Payment successful! + ---------------------------- + Rental ID: %d + Payment type: %s + Amount paid: %.2f $ + Payment date: %s + ---------------------------- + Thank you for your payment! Receipt has been sent to your email. + """, + payment.getRental().getId(), + payment.getType().name(), + payment.getAmountToPay(), + LocalDate.now().format(DATE_FORMATTER) + ); + } + + private String calculateDelay(LocalDate returnDate, LocalDate actualReturnDate) { + if (actualReturnDate.isAfter(returnDate)) { + long days = ChronoUnit.DAYS.between(returnDate, actualReturnDate); + return days + " day(s) delay"; + } + return "no delay"; + } +} diff --git a/src/main/java/mate/academy/carsharing/service/telegram/NotificationService.java b/src/main/java/mate/academy/carsharing/service/telegram/NotificationService.java new file mode 100644 index 0000000..1a7db27 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/telegram/NotificationService.java @@ -0,0 +1,24 @@ +package mate.academy.carsharing.service.telegram; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +public class NotificationService { + private final CarSharingBot carSharingBot; + + @Value("${telegram.bot.chat.id}") + private String chatId; + + @Autowired + public NotificationService(CarSharingBot carSharingBot) { + this.carSharingBot = carSharingBot; + } + + @Async + public void sendNotification(String message) { + carSharingBot.sendMessage(message, Long.valueOf(chatId)); + } +} diff --git a/src/main/java/mate/academy/carsharing/service/user/UserService.java b/src/main/java/mate/academy/carsharing/service/user/UserService.java new file mode 100644 index 0000000..6d0b986 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/user/UserService.java @@ -0,0 +1,17 @@ +package mate.academy.carsharing.service.user; + +import mate.academy.carsharing.dto.role.RoleNameRequestDto; +import mate.academy.carsharing.dto.user.UserRegistrationRequestDto; +import mate.academy.carsharing.dto.user.UserResponseDto; +import mate.academy.carsharing.exception.RegistrationException; + +public interface UserService { + UserResponseDto register(UserRegistrationRequestDto userRegistrationRequestDto) + throws RegistrationException; + + UserResponseDto updateUserRole(Long userId, RoleNameRequestDto roleNameRequestDto); + + UserResponseDto getUser(Long userId); + + UserResponseDto updateMe(Long id, UserRegistrationRequestDto userRegistrationRequestDto); +} diff --git a/src/main/java/mate/academy/carsharing/service/user/UserServiceImpl.java b/src/main/java/mate/academy/carsharing/service/user/UserServiceImpl.java new file mode 100644 index 0000000..7fb9e00 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/service/user/UserServiceImpl.java @@ -0,0 +1,85 @@ +package mate.academy.carsharing.service.user; + +import jakarta.transaction.Transactional; +import java.util.HashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import mate.academy.carsharing.dto.role.RoleNameRequestDto; +import mate.academy.carsharing.dto.user.UserRegistrationRequestDto; +import mate.academy.carsharing.dto.user.UserResponseDto; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.exception.RegistrationException; +import mate.academy.carsharing.mapper.UserMapper; +import mate.academy.carsharing.model.Role; +import mate.academy.carsharing.model.User; +import mate.academy.carsharing.repository.RoleRepository; +import mate.academy.carsharing.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + private final UserRepository userRepository; + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final RoleRepository roleRepository; + + @Override + public UserResponseDto register(UserRegistrationRequestDto userRegistrationRequestDto) + throws RegistrationException { + if (userRepository.existsByEmail(userRegistrationRequestDto.getEmail())) { + throw new RegistrationException("Can't register user " + + "with existing email: " + userRegistrationRequestDto.getEmail()); + + } + User user = userMapper.toModel(userRegistrationRequestDto); + user.setPassword(passwordEncoder.encode(userRegistrationRequestDto.getPassword())); + + Role defaultRole = roleRepository.findByName(Role.RoleName.CUSTOMER) + .orElseThrow(() -> new EntityNotFoundException("Role " + + Role.RoleName.CUSTOMER.name() + " not found")); + user.setRoles(Set.of(defaultRole)); + userRepository.save(user); + return userMapper.toUserResponse(user); + } + + @Override + public UserResponseDto updateUserRole(Long userId, RoleNameRequestDto roleNameRequestDto) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("Can't find user by id: " + userId)); + Role role = roleRepository + .findByName(roleNameRequestDto.getRoleName()) + .orElseThrow(() -> new EntityNotFoundException("Can't find role by role name: " + + roleNameRequestDto.getRoleName())); + Set roles = new HashSet<>(); + roles.add(role); + user.setRoles(roles); + return userMapper.toUserResponse(userRepository.save(user)); + } + + @Override + public UserResponseDto getUser(Long userId) { + User user = userRepository + .findById(userId).orElseThrow( + () -> new EntityNotFoundException("Can't find user by id: " + userId)); + return userMapper.toUserResponse(user); + } + + @Override + public UserResponseDto updateMe(Long id, + UserRegistrationRequestDto userRegistrationRequestDto) { + User user = userRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Can't find user email: " + + userRegistrationRequestDto + .getEmail())); + userMapper.updateModelFromDto(userRegistrationRequestDto, user); + + if (!userRegistrationRequestDto.getPassword().isEmpty()) { + user.setPassword(passwordEncoder.encode(userRegistrationRequestDto.getPassword())); + } + userRepository.save(user); + return userMapper.toUserResponse(user); + } +} diff --git a/src/main/java/mate/academy/carsharing/validation/FieldMatch.java b/src/main/java/mate/academy/carsharing/validation/FieldMatch.java new file mode 100644 index 0000000..4ae5f0b --- /dev/null +++ b/src/main/java/mate/academy/carsharing/validation/FieldMatch.java @@ -0,0 +1,23 @@ +package mate.academy.carsharing.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = FieldMatchValidator.class) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface FieldMatch { + String first(); + + String second(); + + String message() default "Fields must match"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/mate/academy/carsharing/validation/FieldMatchValidator.java b/src/main/java/mate/academy/carsharing/validation/FieldMatchValidator.java new file mode 100644 index 0000000..08c4d61 --- /dev/null +++ b/src/main/java/mate/academy/carsharing/validation/FieldMatchValidator.java @@ -0,0 +1,28 @@ +package mate.academy.carsharing.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Objects; +import org.springframework.beans.BeanWrapperImpl; + +public class FieldMatchValidator implements ConstraintValidator { + private String firstField; + private String secondField; + + @Override + public void initialize(FieldMatch constraintAnnotation) { + this.firstField = constraintAnnotation.first(); + this.secondField = constraintAnnotation.second(); + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) { + if (value == null) { + return true; + } + Object firstValue = new BeanWrapperImpl(value).getPropertyValue(firstField); + Object secondValue = new BeanWrapperImpl(value).getPropertyValue(secondField); + return Objects.equals(firstValue, secondValue); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 50ca5ac..37880e8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,33 @@ +# Application spring.application.name=car-sharing +server.servlet.context-path=/api + +# Database +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# JPA +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect + +# JWT +jwt.secret=${JWT_SECRET} +jwt.expiration=${JWT_EXPIRATION} + +# Stripe +stripe.secret.key=${STRIPE_SECRET_KEY} +stripe.public.key=${STRIPE_PUBLIC_KEY} +stripe.success.url=${STRIPE_SUCCESS_URL} +stripe.cancel.url=${STRIPE_CANCEL_URL} + +# Telegram +telegram.bot.name=${TELEGRAM_BOT_NAME} +telegram.bot.token=${TELEGRAM_BOT_TOKEN} +telegram.bot.chat.id=${TELEGRAM_CHAT_ID} + +# Payment +payment.session-id=none +payment.session-url=http://none.none diff --git a/src/main/resources/db/changelog/changes/01-create-users-table.yaml b/src/main/resources/db/changelog/changes/01-create-users-table.yaml new file mode 100644 index 0000000..f8e0b62 --- /dev/null +++ b/src/main/resources/db/changelog/changes/01-create-users-table.yaml @@ -0,0 +1,42 @@ +databaseChangeLog: + - changeSet: + id: create-users-table + author: trokhim + changes: + - createTable: + tableName: users + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: email + type: varchar(320) + constraints: + nullable: false + unique: true + - column: + name: first_name + type: varchar(50) + constraints: + nullable: false + - column: + name: last_name + type: varchar(50) + constraints: + nullable: false + - column: + name: password + type: varchar(255) + constraints: + nullable: false + - column: + name: is_deleted + type: boolean + defaultValueBoolean: false + constraints: + nullable: false diff --git a/src/main/resources/db/changelog/changes/02-create-roles-table.yaml b/src/main/resources/db/changelog/changes/02-create-roles-table.yaml new file mode 100644 index 0000000..b982bc8 --- /dev/null +++ b/src/main/resources/db/changelog/changes/02-create-roles-table.yaml @@ -0,0 +1,37 @@ +databaseChangeLog: + - changeSet: + id: create-roles-table + author: trokhim + changes: + - createTable: + tableName: roles + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: name + type: varchar(255) + constraints: + nullable: false + unique: true + - changeSet: + id: add-role-customer + author: trokhim + changes: + - insert: + tableName: roles + columns: + - column: + name: name + value: 'CUSTOMER' + - insert: + tableName: roles + columns: + - column: + name: name + value: 'MANAGER' diff --git a/src/main/resources/db/changelog/changes/03-create-users-roles-table.yaml b/src/main/resources/db/changelog/changes/03-create-users-roles-table.yaml new file mode 100644 index 0000000..a0dea1d --- /dev/null +++ b/src/main/resources/db/changelog/changes/03-create-users-roles-table.yaml @@ -0,0 +1,37 @@ +databaseChangeLog: + - changeSet: + id: create-users-roles-table + author: trokhim + preConditions: + - onFail: MARK_RAN + - not: + tableExists: + tableName: users_roles + changes: + - createTable: + tableName: users_roles + columns: + - column: + name: user_id + type: bigint + constraints: + nullable: false + - column: + name: role_id + type: bigint + constraints: + nullable: false + - addForeignKeyConstraint: + baseTableName: users_roles + baseColumnNames: user_id + constraintName: fk_user_roles_user_id + referencedTableName: users + referencedColumnNames: id + onDelete: CASCADE + - addForeignKeyConstraint: + baseTableName: users_roles + baseColumnNames: role_id + constraintName: fk_user_roles_role_id + referencedTableName: roles + referencedColumnNames: id + onDelete: CASCADE diff --git a/src/main/resources/db/changelog/changes/04-create-cars-table.yaml b/src/main/resources/db/changelog/changes/04-create-cars-table.yaml new file mode 100644 index 0000000..87a6d42 --- /dev/null +++ b/src/main/resources/db/changelog/changes/04-create-cars-table.yaml @@ -0,0 +1,46 @@ +databaseChangeLog: + - changeSet: + id: create-cars-table + author: trokhim + changes: + - createTable: + tableName: cars + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: model + type: varchar(100) + constraints: + nullable: false + - column: + name: brand + type: varchar(50) + constraints: + nullable: false + - column: + name: type + type: varchar(20) + constraints: + nullable: false + - column: + name: inventory + type: int + constraints: + nullable: false + - column: + name: daily_fee + type: decimal(19,2) + constraints: + nullable: false + - column: + name: is_deleted + type: boolean + defaultValueBoolean: false + constraints: + nullable: false diff --git a/src/main/resources/db/changelog/changes/05-insert-users.yaml b/src/main/resources/db/changelog/changes/05-insert-users.yaml new file mode 100644 index 0000000..7d0ff73 --- /dev/null +++ b/src/main/resources/db/changelog/changes/05-insert-users.yaml @@ -0,0 +1,41 @@ +databaseChangeLog: + - changeSet: + id: insert-users + author: trokhim + changes: + - insert: + tableName: users + columns: + - column: + name: email + value: "customer@example.com" + - column: + name: first_name + value: "John" + - column: + name: last_name + value: "Doe" + - column: + name: password + value: "$2a$10$P/QlrPsxZ9AKvvl24zQ5W.WCPL.LmI//508AJnbn4nMZmbr9IWIJG" # password = "12345678" + - column: + name: is_deleted + valueBoolean: false + - insert: + tableName: users + columns: + - column: + name: email + value: "manager@example.com" + - column: + name: first_name + value: "Jane" + - column: + name: last_name + value: "Smith" + - column: + name: password + value: "$2a$10$P/QlrPsxZ9AKvvl24zQ5W.WCPL.LmI//508AJnbn4nMZmbr9IWIJG" # password = "12345678" + - column: + name: is_deleted + valueBoolean: false \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/06-insert-users-roles.yaml b/src/main/resources/db/changelog/changes/06-insert-users-roles.yaml new file mode 100644 index 0000000..10b5715 --- /dev/null +++ b/src/main/resources/db/changelog/changes/06-insert-users-roles.yaml @@ -0,0 +1,23 @@ +databaseChangeLog: + - changeSet: + id: insert-users-roles + author: trokhim + changes: + - insert: + tableName: users_roles + columns: + - column: + name: user_id + valueComputed: (SELECT id FROM users WHERE email = 'manager@example.com') + - column: + name: role_id + value: 2 + - insert: + tableName: users_roles + columns: + - column: + name: user_id + valueComputed: (SELECT id FROM users WHERE email = 'customer@example.com') + - column: + name: role_id + value: 1 \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/07-create-rentals-table.yaml b/src/main/resources/db/changelog/changes/07-create-rentals-table.yaml new file mode 100644 index 0000000..82d43f4 --- /dev/null +++ b/src/main/resources/db/changelog/changes/07-create-rentals-table.yaml @@ -0,0 +1,66 @@ +databaseChangeLog: + - changeSet: + id: create-rental-table + author: trokhim + changes: + - createTable: + tableName: rentals + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: rental_date + type: date + constraints: + nullable: false + - column: + name: return_date + type: date + constraints: + nullable: false + - column: + name: actual_return_date + type: date + constraints: + nullable: true + - column: + name: car_id + type: bigint + constraints: + nullable: false + - column: + name: user_id + type: bigint + constraints: + nullable: false + - column: + name: is_deleted + type: boolean + defaultValueBoolean: false + constraints: + nullable: false + + - changeSet: + id: add-foreign-keys-rentals + author: trokhim + changes: + - addForeignKeyConstraint: + constraintName: fk_rentals_car + baseColumnNames: car_id + baseTableName: rentals + referencedTableName: cars + referencedColumnNames: id + onDelete: CASCADE + + - addForeignKeyConstraint: + constraintName: fk_rentals_user + baseColumnNames: user_id + baseTableName: rentals + referencedTableName: users + referencedColumnNames: id + onDelete: CASCADE \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/08-create-payments-table.yaml b/src/main/resources/db/changelog/changes/08-create-payments-table.yaml new file mode 100644 index 0000000..a5755ac --- /dev/null +++ b/src/main/resources/db/changelog/changes/08-create-payments-table.yaml @@ -0,0 +1,70 @@ + +databaseChangeLog: + - changeSet: + id: create-payments-table + author: trokhim + runOnChange: true + changes: + - createTable: + tableName: payments + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: status + type: varchar(20) + constraints: + nullable: false + - column: + name: type + type: varchar(20) + constraints: + nullable: false + - column: + name: rental_id + type: bigint + constraints: + nullable: false + unique: true + - column: + name: session_url + type: TEXT + constraints: + nullable: false + - column: + name: session_id + type: TEXT + constraints: + nullable: false + - column: + name: amount_to_pay + type: decimal(19,2) + constraints: + nullable: false + - column: + name: is_deleted + type: boolean + constraints: + nullable: false + defaultValueBoolean: false + + - changeSet: + id: add-foreign-keys-payments + author: trokhim + changes: + - addForeignKeyConstraint: + constraintName: fk_payments_rental + baseColumnNames: rental_id + baseTableName: payments + referencedTableName: rentals + referencedColumnNames: id + onDelete: cascade + - addUniqueConstraint: + tableName: payments + columnNames: rental_id + constraintName: uc_payments_rental_id \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..1e72e6d --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,17 @@ +databaseChangeLog: + - include: + file: db/changelog/changes/01-create-users-table.yaml + - include: + file: db/changelog/changes/02-create-roles-table.yaml + - include: + file: db/changelog/changes/03-create-users-roles-table.yaml + - include: + file: db/changelog/changes/04-create-cars-table.yaml + - include: + file: db/changelog/changes/05-insert-users.yaml + - include: + file: db/changelog/changes/06-insert-users-roles.yaml + - include: + file: db/changelog/changes/07-create-rentals-table.yaml + - include: + file: db/changelog/changes/08-create-payments-table.yaml \ No newline at end of file diff --git a/src/test/java/mate/academy/carsharing/config/CustomMySqlContainer.java b/src/test/java/mate/academy/carsharing/config/CustomMySqlContainer.java new file mode 100644 index 0000000..5d32364 --- /dev/null +++ b/src/test/java/mate/academy/carsharing/config/CustomMySqlContainer.java @@ -0,0 +1,32 @@ +package mate.academy.carsharing.config; + +import org.testcontainers.containers.MySQLContainer; + +public class CustomMySqlContainer extends MySQLContainer { + private static final String IMAGE_VERSION = "mysql:8.0"; + private static CustomMySqlContainer container; + + private CustomMySqlContainer() { + super(IMAGE_VERSION); + } + + public static CustomMySqlContainer getInstance() { + if (container == null) { + container = new CustomMySqlContainer(); + } + return container; + } + + @Override + public void start() { + super.start(); + System.setProperty("TEST_DB_URL", container.getJdbcUrl()); + System.setProperty("TEST_DB_USERNAME", container.getUsername()); + System.setProperty("TEST_DB_PASSWORD", container.getPassword()); + } + + @Override + public void stop() { + super.stop(); + } +} diff --git a/src/test/java/mate/academy/carsharing/controller/car/CarControllerTest.java b/src/test/java/mate/academy/carsharing/controller/car/CarControllerTest.java new file mode 100644 index 0000000..f085c20 --- /dev/null +++ b/src/test/java/mate/academy/carsharing/controller/car/CarControllerTest.java @@ -0,0 +1,191 @@ +package mate.academy.carsharing.controller.car; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; +import lombok.SneakyThrows; +import mate.academy.carsharing.dto.car.CarRequestDto; +import mate.academy.carsharing.dto.car.CarResponseDto; +import mate.academy.carsharing.model.Car; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CarControllerTest { + protected static MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeAll + static void beforeAll( + @Autowired WebApplicationContext webApplicationContext, + @Autowired DataSource dataSource) throws SQLException { + mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build(); + teardown(dataSource); + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(true); + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("database/create-test-for-rental.sql") + ); + } + } + + @AfterAll + static void afterAll(@Autowired DataSource dataSource) { + teardown(dataSource); + } + + @SneakyThrows + static void teardown(DataSource dataSource) { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(true); + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("database/delete-all.sql") + ); + } + } + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("Get all cars - returns list of cars with status 200") + void getAll_ShouldReturnListOfCars_WhenRequested() throws Exception { + MvcResult result = mockMvc.perform(get("/cars") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String content = result.getResponse().getContentAsString(); + assertNotNull(content); + } + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("Get car by ID - returns car details with status 200 for valid ID") + void getById_ShouldReturnCar_WhenValidIdProvided() throws Exception { + Long validCarId = 1L; + + MvcResult result = mockMvc.perform(get("/cars/{id}", validCarId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + CarResponseDto actual = objectMapper.readValue( + result.getResponse().getContentAsString(), + CarResponseDto.class); + assertNotNull(actual); + assertEquals(validCarId, actual.getId()); + } + + @Test + @WithMockUser(roles = "MANAGER") + @DisplayName("Create car - creates new car and returns 200 for valid request") + void create_ShouldCreateCar_WhenValidRequest() throws Exception { + CarRequestDto requestDto = new CarRequestDto() + .setModel("Tesla Model S") + .setBrand("Tesla") + .setType(Car.Type.SEDAN) + .setDailyFee(new BigDecimal("100.00")) + .setInventory(5); + + MvcResult result = mockMvc.perform(post("/cars") + .content(objectMapper.writeValueAsString(requestDto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + CarResponseDto actual = objectMapper.readValue( + result.getResponse().getContentAsString(), + CarResponseDto.class); + assertNotNull(actual); + assertNotNull(actual.getId()); + assertEquals(requestDto.getModel(), actual.getModel()); + } + + @Test + @WithMockUser(roles = "MANAGER") + @Sql(scripts = "classpath:database/add-tesla-car.sql", + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = "classpath:database/remove-tesla-car.sql", + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + @DisplayName("Update car - updates existing car and returns 200 for valid ID") + void update_ShouldUpdateCar_WhenValidIdProvided() throws Exception { + Long existingCarId = 4L; + CarRequestDto updateDto = new CarRequestDto() + .setModel("Tesla Model X") + .setBrand("Tesla") + .setType(Car.Type.SUV) + .setDailyFee(new BigDecimal("120.00")) + .setInventory(3); + + MvcResult result = mockMvc.perform(put("/cars/{id}", existingCarId) + .content(objectMapper.writeValueAsString(updateDto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + CarResponseDto actual = objectMapper.readValue( + result.getResponse().getContentAsString(), + CarResponseDto.class); + assertNotNull(actual); + assertEquals(existingCarId, actual.getId()); + assertEquals(updateDto.getModel(), actual.getModel()); + } + + @Test + @WithMockUser(roles = "MANAGER") + @Sql(scripts = "classpath:database/add-tesla-car.sql", + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @DisplayName("Delete car - removes car and returns 204 for valid ID") + void delete_ShouldRemoveCar_WhenValidIdProvided() throws Exception { + Long validCarId = 4L; + mockMvc.perform(delete("/cars/{id}", validCarId)) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("Create car - returns 403 when unauthorized user tries to create") + void create_ShouldReturnForbidden_WhenUnauthorized() throws Exception { + CarRequestDto requestDto = new CarRequestDto() + .setModel("Tesla Model S") + .setBrand("Tesla") + .setType(Car.Type.SEDAN) + .setDailyFee(new BigDecimal("100.00")) + .setInventory(5); + + mockMvc.perform(post("/cars") + .content(objectMapper.writeValueAsString(requestDto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/mate/academy/carsharing/controller/rental/RentalControllerTest.java b/src/test/java/mate/academy/carsharing/controller/rental/RentalControllerTest.java new file mode 100644 index 0000000..d895449 --- /dev/null +++ b/src/test/java/mate/academy/carsharing/controller/rental/RentalControllerTest.java @@ -0,0 +1,138 @@ +package mate.academy.carsharing.controller.rental; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.LocalDate; +import javax.sql.DataSource; +import lombok.SneakyThrows; +import mate.academy.carsharing.dto.rental.RentalRequestDto; +import mate.academy.carsharing.dto.rental.RentalResponseDto; +import mate.academy.carsharing.dto.rental.RentalReturnRequestDto; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RentalControllerTest { + private static MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeAll + static void beforeAll( + @Autowired WebApplicationContext applicationContext, + @Autowired DataSource dataSource) throws SQLException { + mockMvc = MockMvcBuilders + .webAppContextSetup(applicationContext) + .apply(springSecurity()) + .build(); + teardown(dataSource); + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(true); + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("database/create-test-for-rental.sql") + ); + } + } + + @AfterAll + static void afterAll(@Autowired DataSource dataSource) { + teardown(dataSource); + } + + @SneakyThrows + static void teardown(DataSource dataSource) { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(true); + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("database/delete-all.sql") + ); + } + } + + @Test + @WithUserDetails(value = "user@example.com", + userDetailsServiceBeanName = "customUserDetailsService") + @DisplayName("Create rental - returns created rental with status 201") + void createRental_ShouldReturnCreatedRental_WhenValidRequest() throws Exception { + RentalRequestDto requestDto = new RentalRequestDto() + .setRentalDate(LocalDate.now()) + .setReturnDate(LocalDate.now().plusDays(7)) + .setCarId(1L); + + MvcResult result = mockMvc.perform(post("/rentals") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()) + .andReturn(); + + RentalResponseDto response = objectMapper.readValue( + result.getResponse().getContentAsString(), + RentalResponseDto.class); + assertNotNull(response); + assertEquals(requestDto.getCarId(), response.getCarId()); + assertEquals(requestDto.getRentalDate(), response.getRentalDate()); + } + + @Test + @WithMockUser(username = "user@example.com", roles = "CUSTOMER") + @DisplayName("Complete rental - returns updated rental with status 200") + void completeRental_ShouldReturnUpdatedRental_WhenValidRequest() throws Exception { + RentalReturnRequestDto requestDto = new RentalReturnRequestDto() + .setRentalId(1L) + .setActualReturnDate(LocalDate.now()); + + mockMvc.perform(post("/rentals/return") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(roles = "MANAGER") + @DisplayName("Create rental - returns 403 when manager tries to create") + void createRental_ShouldReturnForbidden_WhenManagerRole() throws Exception { + RentalRequestDto requestDto = new RentalRequestDto() + .setCarId(1L) + .setRentalDate(LocalDate.now().plusDays(1)) + .setReturnDate(LocalDate.now().plusDays(5)); + + mockMvc.perform(post("/rentals") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("Get rentals - returns 403 when customer tries to get all") + void getAllByUserId_ShouldReturnForbidden_WhenCustomerRole() throws Exception { + mockMvc.perform(get("/rentals") + .param("user_id", "1") + .param("is_active", "true")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/mate/academy/carsharing/controller/user/UserControllerTest.java b/src/test/java/mate/academy/carsharing/controller/user/UserControllerTest.java new file mode 100644 index 0000000..a1965db --- /dev/null +++ b/src/test/java/mate/academy/carsharing/controller/user/UserControllerTest.java @@ -0,0 +1,123 @@ +package mate.academy.carsharing.controller.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; +import lombok.SneakyThrows; +import mate.academy.carsharing.dto.role.RoleNameRequestDto; +import mate.academy.carsharing.dto.user.UserResponseDto; +import mate.academy.carsharing.model.Role; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserControllerTest { + private static MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeAll + static void beforeAll( + @Autowired WebApplicationContext applicationContext, + @Autowired DataSource dataSource) throws SQLException { + mockMvc = MockMvcBuilders + .webAppContextSetup(applicationContext) + .apply(springSecurity()) + .build(); + teardown(dataSource); + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(true); + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("database/create-test-for-rental.sql") + ); + } + } + + @AfterAll + static void afterAll(@Autowired DataSource dataSource) { + teardown(dataSource); + } + + @SneakyThrows + static void teardown(DataSource dataSource) { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(true); + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("database/delete-all.sql") + ); + } + } + + @Test + @WithUserDetails(value = "user@example.com", + userDetailsServiceBeanName = "customUserDetailsService") + @DisplayName("Get current user info - returns user data with status 200") + void getCurrentUserInfo_ShouldReturnUserData_WhenAuthenticated() throws Exception { + MvcResult result = mockMvc.perform(get("/users/me") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + UserResponseDto response = objectMapper.readValue( + result.getResponse().getContentAsString(), + UserResponseDto.class); + assertNotNull(response); + assertEquals("user@example.com", response.getEmail()); + } + + @Test + @WithMockUser(username = "admin@example.com", roles = "MANAGER") + @DisplayName("Update user role - updates role successfully with status 200") + void updateRole_ShouldUpdateRole_WhenManagerRequest() throws Exception { + RoleNameRequestDto requestDto = new RoleNameRequestDto() + .setRoleName(Role.RoleName.MANAGER); + + MvcResult result = mockMvc.perform(put("/users/update/{id}/role", 2L) + .content(objectMapper.writeValueAsString(requestDto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + UserResponseDto response = objectMapper.readValue( + result.getResponse().getContentAsString(), + UserResponseDto.class); + assertNotNull(response); + } + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("Update user role - returns 403 when customer tries to update") + void updateRole_ShouldReturnForbidden_WhenCustomerRequest() throws Exception { + RoleNameRequestDto requestDto = new RoleNameRequestDto() + .setRoleName(Role.RoleName.MANAGER); + + mockMvc.perform(put("/users/update/{id}/role", 3L) + .content(objectMapper.writeValueAsString(requestDto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/mate/academy/carsharing/service/car/CarServiceImplTest.java b/src/test/java/mate/academy/carsharing/service/car/CarServiceImplTest.java new file mode 100644 index 0000000..4222acf --- /dev/null +++ b/src/test/java/mate/academy/carsharing/service/car/CarServiceImplTest.java @@ -0,0 +1,174 @@ +package mate.academy.carsharing.service.car; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import mate.academy.carsharing.dto.car.CarRequestDto; +import mate.academy.carsharing.dto.car.CarResponseDto; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.mapper.CarMapper; +import mate.academy.carsharing.model.Car; +import mate.academy.carsharing.repository.CarRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class CarServiceImplTest { + @Mock + private CarRepository carRepository; + + @Mock + private CarMapper carMapper; + + @InjectMocks + private CarServiceImpl carService; + + private Car car; + private Car oldCar; + private CarRequestDto carRequestDto; + private CarResponseDto carResponseDto; + + @BeforeEach + void setUp() { + car = new Car() + .setId(1L) + .setModel("M5 F90") + .setBrand("BMW") + .setType(Car.Type.SEDAN) + .setInventory(10) + .setDailyFee(BigDecimal.valueOf(250)); + + oldCar = new Car() + .setId(1L) + .setModel("M5 F90") + .setBrand("BMW") + .setType(Car.Type.SEDAN) + .setInventory(5) + .setDailyFee(BigDecimal.valueOf(150)); + + carRequestDto = new CarRequestDto() + .setModel(car.getModel()) + .setBrand(car.getBrand()) + .setType(car.getType()) + .setInventory(car.getInventory()) + .setDailyFee(car.getDailyFee()); + + carResponseDto = new CarResponseDto() + .setId(car.getId()) + .setModel(car.getModel()) + .setBrand(car.getBrand()) + .setType(car.getType()) + .setInventory(car.getInventory()) + .setDailyFee(car.getDailyFee()); + } + + @Test + @DisplayName("Create car - returns car DTO when valid request") + void createCar_ShouldReturnCarDto_WhenValidRequest() { + when(carMapper.toModel(carRequestDto)).thenReturn(car); + when(carRepository.save(car)).thenReturn(car); + when(carMapper.toDto(car)).thenReturn(carResponseDto); + + CarResponseDto result = carService.createCar(carRequestDto); + + assertThat(result).isEqualTo(carResponseDto); + verify(carMapper).toModel(carRequestDto); + verify(carRepository).save(car); + verify(carMapper).toDto(car); + } + + @Test + @DisplayName("Get cars - returns page of cars when valid pageable") + void getCars_ShouldReturnPageOfCars_WhenValidPageable() { + Pageable pageable = PageRequest.of(0, 10); + Page carPage = new PageImpl<>(List.of(car), pageable, 1); + + when(carRepository.findAll(pageable)).thenReturn(carPage); + when(carMapper.toDto(car)).thenReturn(carResponseDto); + + Page result = carService.getCars(pageable); + + assertThat(result.getContent()).hasSize(1).contains(carResponseDto); + verify(carRepository).findAll(pageable); + verify(carMapper).toDto(car); + } + + @Test + @DisplayName("Find by ID - returns car DTO when valid ID") + void findById_ShouldReturnCarDto_WhenValidId() { + when(carRepository.findById(1L)).thenReturn(Optional.of(car)); + when(carMapper.toDto(car)).thenReturn(carResponseDto); + + CarResponseDto result = carService.findById(1L); + + assertThat(result).isEqualTo(carResponseDto); + verify(carRepository).findById(1L); + verify(carMapper).toDto(car); + } + + @Test + @DisplayName("Update by ID - returns updated car DTO when valid ID and request") + void updateById_ShouldReturnUpdatedCarDto_WhenValidIdAndRequest() { + when(carRepository.findById(1L)).thenReturn(Optional.of(oldCar)); + when(carMapper.toDto(oldCar)).thenReturn(carResponseDto); + + CarResponseDto result = carService.updateById(1L, carRequestDto); + + assertThat(result).isEqualTo(carResponseDto); + verify(carRepository).findById(1L); + verify(carMapper).updateModelFromDto(carRequestDto, oldCar); + verify(carRepository).save(oldCar); + verify(carMapper).toDto(oldCar); + } + + @Test + @DisplayName("Delete by ID - successfully deletes car when valid ID") + void deleteById_ShouldDeleteCar_WhenValidId() { + carService.deleteById(1L); + verify(carRepository).deleteById(1L); + } + + @Test + @DisplayName("Find by ID - throws exception when invalid ID") + void findById_ShouldThrowException_WhenInvalidId() { + Long invalidId = 999L; + when(carRepository.findById(invalidId)).thenReturn(Optional.empty()); + + EntityNotFoundException exception = assertThrows( + EntityNotFoundException.class, + () -> carService.findById(invalidId) + ); + + assertThat(exception.getMessage()).isEqualTo("Can't find car with id: " + invalidId); + verify(carRepository).findById(invalidId); + } + + @Test + @DisplayName("Update by ID - throws exception when invalid ID") + void updateById_ShouldThrowException_WhenInvalidId() { + Long invalidId = 999L; + when(carRepository.findById(invalidId)).thenReturn(Optional.empty()); + + EntityNotFoundException exception = assertThrows( + EntityNotFoundException.class, + () -> carService.updateById(invalidId, carRequestDto) + ); + + assertThat(exception.getMessage()).isEqualTo("Can't find car with id: " + invalidId); + verify(carRepository).findById(invalidId); + } +} diff --git a/src/test/java/mate/academy/carsharing/service/rental/RentalServiceImplTest.java b/src/test/java/mate/academy/carsharing/service/rental/RentalServiceImplTest.java new file mode 100644 index 0000000..bb583e0 --- /dev/null +++ b/src/test/java/mate/academy/carsharing/service/rental/RentalServiceImplTest.java @@ -0,0 +1,188 @@ +package mate.academy.carsharing.service.rental; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Optional; +import mate.academy.carsharing.dto.rental.RentalRequestDto; +import mate.academy.carsharing.dto.rental.RentalResponseDto; +import mate.academy.carsharing.dto.rental.RentalReturnRequestDto; +import mate.academy.carsharing.exception.CarNotAvailableException; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.mapper.RentalMapper; +import mate.academy.carsharing.model.Car; +import mate.academy.carsharing.model.Rental; +import mate.academy.carsharing.model.User; +import mate.academy.carsharing.repository.CarRepository; +import mate.academy.carsharing.repository.RentalRepository; +import mate.academy.carsharing.service.telegram.NotificationMessageService; +import mate.academy.carsharing.service.telegram.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class RentalServiceImplTest { + @InjectMocks + private RentalServiceImpl rentalService; + + @Mock + private RentalRepository rentalRepository; + @Mock + private RentalMapper rentalMapper; + @Mock + private CarRepository carRepository; + @Mock + private NotificationService notificationService; + @Mock + private NotificationMessageService notificationMessageService; + + private Car car; + private User user; + private Rental rental; + private RentalRequestDto rentalRequestDto; + + @BeforeEach + void setUp() { + car = new Car(); + car.setId(1L); + car.setInventory(10); + + user = new User(); + user.setId(1L); + + rental = new Rental(); + rental.setId(1L); + rental.setCar(car); + + rentalRequestDto = new RentalRequestDto() + .setCarId(car.getId()) + .setRentalDate(LocalDate.now()) + .setReturnDate(LocalDate.now().plusDays(7)); + } + + @Test + @DisplayName("Create rental - returns rental DTO when valid request") + void createRental_ShouldReturnRentalDto_WhenValidRequest() { + when(carRepository.findById(car.getId())).thenReturn(Optional.of(car)); + when(rentalMapper.toModelWithCarIdAndUserId(any(), any(), any())).thenReturn(rental); + when(rentalRepository.save(rental)).thenReturn(rental); + when(rentalMapper.toDto(rental)).thenReturn(new RentalResponseDto()); + when(notificationMessageService.createRentalNotification(any(), + any())).thenReturn("Message"); + + RentalResponseDto result = rentalService.createRental(user.getId(), rentalRequestDto); + + assertNotNull(result); + verify(carRepository).save(car); + verify(notificationService).sendNotification(any()); + } + + @Test + @DisplayName("Get all rentals - returns active rentals when isActive=true") + void getAllByUserId_ShouldReturnActiveRentals_WhenIsActiveTrue() { + Pageable pageable = Pageable.ofSize(10); + when(rentalRepository.findAllByUserIdAndActualReturnDateIsNull(pageable, user.getId())) + .thenReturn(Page.empty()); + + Page result = rentalService.getAllByUserId(pageable, user.getId(), true); + + assertNotNull(result); + } + + @Test + @DisplayName("Get by ID - returns rental DTO when valid ID") + void getById_ShouldReturnRentalDto_WhenValidId() { + when(rentalRepository.findByIdAndUserId(rental.getId(), user.getId())) + .thenReturn(Optional.of(rental)); + when(rentalMapper.toDto(rental)).thenReturn(new RentalResponseDto()); + + RentalResponseDto result = rentalService.getById(rental.getId(), user.getId()); + + assertNotNull(result); + } + + @Test + @DisplayName("Set return date - returns updated rental when valid request") + void setDataReturn_ShouldReturnUpdatedRental_WhenValidRequest() { + RentalReturnRequestDto returnRequestDto = new RentalReturnRequestDto() + .setRentalId(rental.getId()) + .setActualReturnDate(LocalDate.now()); + + when(rentalRepository.findById(rental.getId())).thenReturn(Optional.of(rental)); + when(carRepository.findById(car.getId())).thenReturn(Optional.of(car)); + when(rentalRepository.save(rental)).thenReturn(rental); + when(notificationMessageService.createReturnNotification(any(), + any())).thenReturn("Message"); + when(rentalMapper.toDto(rental)).thenReturn(new RentalResponseDto()); + + RentalResponseDto result = rentalService.setDataReturn(returnRequestDto); + + assertNotNull(result); + assertEquals(returnRequestDto.getActualReturnDate(), rental.getActualReturnDate()); + verify(carRepository).save(car); + verify(notificationService).sendNotification(any()); + } + + @Test + @DisplayName("Create rental - throws exception when car not available") + void createRental_ShouldThrowException_WhenCarNotAvailable() { + car.setInventory(0); + when(carRepository.findById(car.getId())).thenReturn(Optional.of(car)); + + Exception exception = assertThrows(CarNotAvailableException.class, + () -> rentalService.createRental(user.getId(), rentalRequestDto)); + + assertEquals("Car with id " + car.getId() + " is not available", exception.getMessage()); + } + + @Test + @DisplayName("Create rental - throws exception when car not found") + void createRental_ShouldThrowException_WhenCarNotFound() { + Long invalidId = 999L; + when(carRepository.findById(invalidId)).thenReturn(Optional.empty()); + + Exception exception = assertThrows(EntityNotFoundException.class, + () -> rentalService.createRental(user.getId(), + rentalRequestDto.setCarId(invalidId))); + + assertEquals("Can't find car by id: " + invalidId, exception.getMessage()); + } + + @Test + @DisplayName("Set return date - throws exception when rental not found") + void setDataReturn_ShouldThrowException_WhenRentalNotFound() { + Long invalidId = 999L; + when(rentalRepository.findById(invalidId)).thenReturn(Optional.empty()); + + Exception exception = assertThrows(EntityNotFoundException.class, + () -> rentalService.setDataReturn(new RentalReturnRequestDto() + .setRentalId(invalidId))); + + assertEquals("Can't find rental by id:" + invalidId, exception.getMessage()); + } + + @Test + @DisplayName("Set return date - throws exception when car not found") + void setDataReturn_ShouldThrowException_WhenCarNotFound() { + when(rentalRepository.findById(rental.getId())).thenReturn(Optional.of(rental)); + when(carRepository.findById(car.getId())).thenReturn(Optional.empty()); + + Exception exception = assertThrows(EntityNotFoundException.class, + () -> rentalService.setDataReturn(new RentalReturnRequestDto() + .setRentalId(rental.getId()))); + + assertEquals("Can't find car by id:" + car.getId(), exception.getMessage()); + } +} diff --git a/src/test/java/mate/academy/carsharing/service/user/UserServiceImplTest.java b/src/test/java/mate/academy/carsharing/service/user/UserServiceImplTest.java new file mode 100644 index 0000000..3939626 --- /dev/null +++ b/src/test/java/mate/academy/carsharing/service/user/UserServiceImplTest.java @@ -0,0 +1,192 @@ +package mate.academy.carsharing.service.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.Set; +import mate.academy.carsharing.dto.role.RoleNameRequestDto; +import mate.academy.carsharing.dto.user.UserRegistrationRequestDto; +import mate.academy.carsharing.dto.user.UserResponseDto; +import mate.academy.carsharing.exception.EntityNotFoundException; +import mate.academy.carsharing.exception.RegistrationException; +import mate.academy.carsharing.mapper.UserMapper; +import mate.academy.carsharing.model.Role; +import mate.academy.carsharing.model.User; +import mate.academy.carsharing.repository.RoleRepository; +import mate.academy.carsharing.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + @Mock + private UserRepository userRepository; + @Mock + private UserMapper userMapper; + @Mock + private RoleRepository roleRepository; + @Mock + private PasswordEncoder passwordEncoder; + @InjectMocks + private UserServiceImpl userService; + + private Role customerRole; + private Role managerRole; + private User user; + private UserRegistrationRequestDto userRequestDto; + private UserResponseDto userResponseDto; + private RoleNameRequestDto roleNameRequestDto; + + @BeforeEach + void setUp() { + customerRole = new Role(); + customerRole.setName(Role.RoleName.CUSTOMER); + + managerRole = new Role(); + managerRole.setName(Role.RoleName.MANAGER); + + user = new User(); + user.setId(1L); + user.setEmail("user@example.com"); + user.setPassword("encodedPassword"); + user.setFirstName("John"); + user.setLastName("Doe"); + user.setRoles(Set.of()); + + userRequestDto = new UserRegistrationRequestDto(); + userRequestDto.setEmail(user.getEmail()); + userRequestDto.setPassword("password123"); + userRequestDto.setRepeatPassword("password123"); + userRequestDto.setFirstName(user.getFirstName()); + userRequestDto.setLastName(user.getLastName()); + + userResponseDto = new UserResponseDto(); + userResponseDto.setId(user.getId()); + userResponseDto.setEmail(user.getEmail()); + userResponseDto.setFirstName(user.getFirstName()); + userResponseDto.setLastName(user.getLastName()); + + roleNameRequestDto = new RoleNameRequestDto(); + roleNameRequestDto.setRoleName(managerRole.getName()); + } + + @Test + @DisplayName("Register user - returns user DTO when valid request") + void register_ShouldReturnUserDto_WhenValidRequest() throws RegistrationException { + when(userRepository.existsByEmail(userRequestDto.getEmail())).thenReturn(false); + when(userMapper.toModel(userRequestDto)).thenReturn(user); + when(passwordEncoder.encode(userRequestDto.getPassword())).thenReturn("encodedPassword"); + when(roleRepository.findByName(Role.RoleName.CUSTOMER)) + .thenReturn(Optional.of(customerRole)); + when(userMapper.toUserResponse(user)).thenReturn(userResponseDto); + + UserResponseDto result = userService.register(userRequestDto); + + assertThat(result).isEqualTo(userResponseDto); + verify(userRepository).save(user); + } + + @Test + @DisplayName("Register user - throws exception when email exists") + void register_ShouldThrowException_WhenEmailExists() { + when(userRepository.existsByEmail(userRequestDto.getEmail())).thenReturn(true); + + RegistrationException exception = assertThrows(RegistrationException.class, + () -> userService.register(userRequestDto)); + + assertEquals("Can't register user with existing email: " + userRequestDto.getEmail(), + exception.getMessage()); + } + + @Test + @DisplayName("Update role - returns updated user DTO when valid request") + void updateUserRole_ShouldReturnUpdatedUser_WhenValidRequest() { + User updateRoleUser = new User(); + updateRoleUser.setId(user.getId()); + updateRoleUser.setEmail(user.getEmail()); + updateRoleUser.setPassword(user.getPassword()); + updateRoleUser.setFirstName(user.getFirstName()); + updateRoleUser.setLastName(user.getLastName()); + updateRoleUser.setRoles(Set.of(managerRole)); + + Long validId = 1L; + + when(userRepository.findById(validId)).thenReturn(Optional.of(user)); + when(roleRepository.findByName(roleNameRequestDto.getRoleName())) + .thenReturn(Optional.of(managerRole)); + when(userRepository.save(Mockito.any(User.class))).thenReturn(updateRoleUser); + when(userMapper.toUserResponse(updateRoleUser)).thenReturn(userResponseDto); + + UserResponseDto expected = userResponseDto; + UserResponseDto actual = userService.updateUserRole(validId, roleNameRequestDto); + + assertNotNull(actual); + assertThat(actual).isEqualTo(expected); + + verify(userRepository).findById(validId); + verify(roleRepository).findByName(roleNameRequestDto.getRoleName()); + verify(userRepository).save(user); + verify(userMapper).toUserResponse(updateRoleUser); + } + + @Test + @DisplayName("Update role - throws exception when user not found") + void updateUserRole_ShouldThrowException_WhenUserNotFound() { + Long invalidId = 999L; + when(userRepository.findById(invalidId)).thenReturn(Optional.empty()); + + EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, + () -> userService.updateUserRole(invalidId, roleNameRequestDto)); + + assertEquals("Can't find user by id: " + invalidId, exception.getMessage()); + } + + @Test + @DisplayName("Get user - returns user DTO when valid ID") + void getUser_ShouldReturnUserDto_WhenValidId() { + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(userMapper.toUserResponse(user)).thenReturn(userResponseDto); + + UserResponseDto result = userService.getUser(user.getId()); + + assertThat(result).isEqualTo(userResponseDto); + } + + @Test + @DisplayName("Get user - throws exception when user not found") + void getUser_ShouldThrowException_WhenUserNotFound() { + Long invalidId = 999L; + when(userRepository.findById(invalidId)).thenReturn(Optional.empty()); + + EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, + () -> userService.getUser(invalidId)); + + assertEquals("Can't find user by id: " + invalidId, exception.getMessage()); + } + + @Test + @DisplayName("Update me - returns updated user DTO when valid request") + void updateMe_ShouldReturnUpdatedUser_WhenValidRequest() { + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(passwordEncoder.encode(userRequestDto.getPassword())).thenReturn("newEncodedPassword"); + when(userRepository.save(user)).thenReturn(user); + when(userMapper.toUserResponse(user)).thenReturn(userResponseDto); + + UserResponseDto result = userService.updateMe(user.getId(), userRequestDto); + + assertThat(result).isEqualTo(userResponseDto); + verify(userRepository).save(user); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..a39a18d --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,24 @@ +spring.datasource.url=jdbc:tc:mysql:8.0.37:///car_sharing_test +spring.datasource.username=test +spring.datasource.password=test + +# JWT +jwt.secret=my-very-long-secret-key-that-i-should-store-safely +jwt.expiration=30000000 + +# Stripe +stripe.secret.key=sk_test_51RABxWGgalaRGHX7SrESLaQMZMUuGdwEimFknl\ + UElNq3tFF4UpqFU7L61hmNxj1wZMY1xQVi1aRpykQRBRmeEmzh00QIMOwb9s +stripe.public.key=pk_test_51RABxWGgalaRGHX7qZOH8HwIl648YUsFNZKxKh\ + lxX5VffvuacA1YEi2LHhwUbviUmZu3MtgCSGICTESOhS2Sr7Ax00x3IIdwuv +stripe.success.url=http://localhost:8080/payments/success/ +stripe.cancel.url=http://localhost:8080/payments/cancel/ + +# Telegram +telegram.bot.name=CarSharingTrokhimBot +telegram.bot.token=7837073313:AAGtmb1ej345p_h1Lnmb8OYlRXDTc5sMAYs +telegram.bot.chat.id=581117817 + +# Payment +payment.session-id=none +payment.session-url=http://none.none diff --git a/src/test/resources/database/add-tesla-car.sql b/src/test/resources/database/add-tesla-car.sql new file mode 100644 index 0000000..5f34b6d --- /dev/null +++ b/src/test/resources/database/add-tesla-car.sql @@ -0,0 +1,3 @@ +DELETE FROM cars WHERE brand = 'Tesla'; +INSERT INTO cars (id,model, brand, type, inventory, daily_fee) +VALUES (4,'Plaid', 'Tesla', 'SEDAN', 9, 180); \ No newline at end of file diff --git a/src/test/resources/database/create-test-for-rental.sql b/src/test/resources/database/create-test-for-rental.sql new file mode 100644 index 0000000..6c5ff48 --- /dev/null +++ b/src/test/resources/database/create-test-for-rental.sql @@ -0,0 +1,16 @@ +INSERT INTO users (id, email, password, first_name, last_name) +VALUES (3, 'user@example.com', 'password', 'Test', 'User'); + +INSERT INTO users_roles(user_id, role_id) +VALUES (3, 1); + +INSERT INTO cars (id,model, brand, type, inventory, daily_fee) +VALUES (1,'I4', 'BMW', 'SEDAN', 5, 120), + (2,'XM', 'BMW', 'SUV', 3, 230), + (3,'Golf', 'Volkswagen', 'HATCHBACK', 10, 60); + +INSERT INTO rentals (id,rental_date, return_date, actual_return_date, car_id, user_id) +VALUES + (1,'2025-03-16', '2025-03-20', NULL, 1, 3), + (2,'2025-03-16', '2025-03-16', '2025-03-16', 2, 3), + (3,'2025-03-16', '2025-03-27', NULL, 3, 3); \ No newline at end of file diff --git a/src/test/resources/database/delete-all.sql b/src/test/resources/database/delete-all.sql new file mode 100644 index 0000000..2f3000b --- /dev/null +++ b/src/test/resources/database/delete-all.sql @@ -0,0 +1,4 @@ +DELETE FROM rentals WHERE user_id = 3; +DELETE FROM cars; +DELETE FROM users_roles WHERE user_id = 3; +DELETE FROM users WHERE id = 3; \ No newline at end of file diff --git a/src/test/resources/database/remove-tesla-car.sql b/src/test/resources/database/remove-tesla-car.sql new file mode 100644 index 0000000..4b11c61 --- /dev/null +++ b/src/test/resources/database/remove-tesla-car.sql @@ -0,0 +1,3 @@ +DELETE +FROM cars +WHERE brand = 'Tesla'; \ No newline at end of file