diff --git a/Book-Store.pdf b/Book-Store.pdf new file mode 100644 index 0000000..b86a7da Binary files /dev/null and b/Book-Store.pdf differ diff --git a/Book-Store.png b/Book-Store.png new file mode 100644 index 0000000..28b4105 Binary files /dev/null and b/Book-Store.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dff39e4 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# 🎯 Project Overview +This application provides functionalities for managing users, books, categories, orders, and shopping carts. The system ensures secure access with role-based authorization (USER and ADMIN). The app is designed for book sales, allowing users to: + +Create accounts and log in. +Purchase books conveniently and quickly from home. +Track the status of orders and know when they arrive at the post office. +This is my first large-scale application, and I faced numerous challenges during its development. Each part was difficult but immensely educational, and I gained a lot of knowledge that will be invaluable for my future projects. + +# πŸš€ Technologies Used +- Spring Boot +- Spring Security +- Spring Data JPA +- JWT (JSON Web Token) +- Swagger +- Liquibase +- MapStruct +- Lombok +- Hibernate Validator +- MySQL + +# πŸ“– API Endpoints +1. AuthenticationController +- ```POST /auth/registration``` – Register a new user. +- ```POST /auth/login``` – User authentication (login). +2. BookController +- ```GET /books``` – Retrieve all books with pagination (ADMIN access only). +- ```GET /books/{id}``` – Retrieve a book by its ID. +- ```POST /books``` – Create a new book (ADMIN access only). +- ```PUT /books/{id}``` – Update a book by its ID (ADMIN access only). +- ```DELETE /books/{id}``` – Delete a book by its ID (ADMIN access only). +- ```GET /books/search``` – Search books by parameters. +3. CategoryController +- ```POST /categories``` – Create a new category (ADMIN access only). +- ```GET /categories``` – Retrieve all categories. +- ```GET /categories/{id}``` – Retrieve a category by its ID. +- ```PUT /categories/{id}``` – Update a category by its ID (ADMIN access only). +- ```DELETE /categories/{id}``` – Delete a category by its ID (ADMIN access only). +- ```GET /categories/{id}/books``` – Retrieve all books in a category by its ID. +4. OrderController +- ```POST /orders``` – Create a new order (USER access only). +- ```GET /orders``` – Retrieve all orders of the logged-in user (USER access only). +- ```PATCH /orders/{id}``` – Update the status of an order (ADMIN access only). +- ```GET /orders/{orderId}/items``` – Retrieve all items from a specific order (USER access only). +- ```GET /orders/{orderId}/items/{itemId}``` – Retrieve a specific item from an order (USER access only). +5. ShoppingCartController +- ```GET /cart``` – Retrieve the current user's shopping cart. +- ```POST /cart``` – Add a book to the shopping cart. +- ```PUT /cart/items/{cartItemId}``` – Update the quantity of items in the cart. +- ```DELETE /cart/items/{cartItemId}``` – Remove an item from the cart. + +# Models and relations + +![Book-Store](./Book-Store.png) + +# πŸ“š Getting Started +I have always loved books, especially those that help understand the meaning of human existence and provide fuel for thought. Inspired by this passion, I decided to create my own application, where users can purchase books from the comfort of their homes. + +# πŸ“ΉVideo Overview of Program Functionality +You can also find a video of the program at this link: https://www.loom.com/share/0c519d24efc04b64acf9a3e9096b40cb?sid=e3750a8b-c044-4b13-b382-535497228e21 + +# Contacts +- Email: greqit.work@gmail.com +- [LinkedIn](https://www.linkedin.com/in/ivan-prystaia-7099a22b1/) diff --git a/docker-compose.yml b/docker-compose.yml index ae895a6..18673a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,11 @@ version: "3.8" services: + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + mysqldb: platform: linux/amd64 image: mysql @@ -18,6 +24,8 @@ services: depends_on: mysqldb: condition: service_healthy + redis: + condition: service_started restart: on-failure image: book-store build: . @@ -33,7 +41,10 @@ services: "spring.jpa.properties.hibernate.dialect" : "$SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT", "spring.datasource.driver-class-name" : "$SPRING_DATASOURSE_DRIVER_CLASS_NAME", "spring.jpa.hibernate.ddl-auto" : "$SPRING_JPA_HIBERNATE_DDL_AUTO", - "jwt.expiration": "$JWT_EXPIRATION_MS", - "jwt.secret": "$JWT_SECRET" + "jwt.access.expiration": "$JWT_ACCESS_EXPIRATION_MS", + "jwt.refresh.expiration": "$JWT_REFRESH_EXPIRATION_MS", + "jwt.secret": "$JWT_SECRET", + "spring.data.redis.host": "redis", + "spring.data.redis.port": 6379 }' JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" diff --git a/pom.xml b/pom.xml index d17cb27..8582478 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ 3.3.1 - mare.academy + mate.academy Spring-Boot-web 0.0.1-SNAPSHOT Spring-Boot-web @@ -39,6 +39,10 @@ 8.0.1.Final 3.3.2 0.11.5 + 3.1.1 + + https://raw.githubusercontent.com/mate-academy/style-guides/master/java/checkstyle.xml + @@ -60,6 +64,17 @@ 1.6.14 + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.1.0 + + + + io.swagger.core.v3 + swagger-annotations + 2.2.25 + io.jsonwebtoken @@ -84,6 +99,11 @@ spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-data-redis + + org.hibernate.validator hibernate-validator @@ -121,11 +141,10 @@ test - - mysql - mysql-connector-java - 8.0.33 + com.mysql + mysql-connector-j + 9.1.0 @@ -224,6 +243,27 @@ + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven.checkstyle.plugin.version} + + + compile + + check + + + + + **/src/main/java/** + **/target/generated-sources/** + ${maven.checkstyle.plugin.configLocation} + true + true + false + + diff --git a/src/main/java/mate/academy/springbootwebgreqit/config/SecurityConfig.java b/src/main/java/mate/academy/springbootwebgreqit/config/SecurityConfig.java index cf61a2c..fbac8df 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/config/SecurityConfig.java +++ b/src/main/java/mate/academy/springbootwebgreqit/config/SecurityConfig.java @@ -1,6 +1,7 @@ package mate.academy.springbootwebgreqit.config; import lombok.RequiredArgsConstructor; +import mate.academy.springbootwebgreqit.security.BookRateLimitFilter; import mate.academy.springbootwebgreqit.security.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,6 +23,7 @@ public class SecurityConfig { private final UserDetailsService userDetailsService; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final BookRateLimitFilter bookRateLimitFilter; @Bean public PasswordEncoder getPasswordEncoder() { @@ -46,6 +48,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(bookRateLimitFilter, JwtAuthenticationFilter.class) .userDetailsService(userDetailsService) .build(); } diff --git a/src/main/java/mate/academy/springbootwebgreqit/controller/AuthenticationController.java b/src/main/java/mate/academy/springbootwebgreqit/controller/AuthenticationController.java index b689b75..86192b7 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/controller/AuthenticationController.java +++ b/src/main/java/mate/academy/springbootwebgreqit/controller/AuthenticationController.java @@ -1,7 +1,12 @@ package mate.academy.springbootwebgreqit.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import mate.academy.springbootwebgreqit.dto.user.RefreshTokenRequestDto; import mate.academy.springbootwebgreqit.dto.user.UserLoginRequestDto; import mate.academy.springbootwebgreqit.dto.user.UserLoginResponseDto; import mate.academy.springbootwebgreqit.dto.user.UserRegistrationRequestDto; @@ -13,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Auth managemant", description = "Endpoint to auth") @RestController @RequestMapping("/auth") @RequiredArgsConstructor @@ -20,15 +26,33 @@ public class AuthenticationController { private final UserService userService; private final AuthenticationService authenticationService; + @Operation(summary = "Register a new user") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "User registered successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input data") + }) @PostMapping("/registration") - public UserResponseDto register(@RequestBody - @Valid UserRegistrationRequestDto requestBody) { + public UserResponseDto register(@RequestBody @Valid UserRegistrationRequestDto requestBody) { return userService.register(requestBody); } + @Operation(summary = "Authenticate a user") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "User authenticated successfully"), + @ApiResponse(responseCode = "401", description = "Invalid credentials") + }) @PostMapping("/login") - public UserLoginResponseDto login(@RequestBody - @Valid UserLoginRequestDto response) { + public UserLoginResponseDto login(@RequestBody @Valid UserLoginRequestDto response) { return authenticationService.authenticate(response); } + + @Operation(summary = "Refresh access token using refresh token") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Tokens issued successfully"), + @ApiResponse(responseCode = "401", description = "Invalid refresh token") + }) + @PostMapping("/refresh") + public UserLoginResponseDto refresh(@RequestBody @Valid RefreshTokenRequestDto request) { + return authenticationService.refreshAccessToken(request); + } } diff --git a/src/main/java/mate/academy/springbootwebgreqit/controller/BookController.java b/src/main/java/mate/academy/springbootwebgreqit/controller/BookController.java index ca81229..1440833 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/controller/BookController.java +++ b/src/main/java/mate/academy/springbootwebgreqit/controller/BookController.java @@ -1,6 +1,8 @@ package mate.academy.springbootwebgreqit.controller; -import io.swagger.annotations.ApiOperation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import mate.academy.springbootwebgreqit.dto.BookDto; @@ -27,40 +29,68 @@ public class BookController { private final BookService bookService; @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get all books with pagination") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Books retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) @GetMapping - @ApiOperation(value = "get all books with pagination") public Page getAll(Pageable pageable) { return bookService.findAll(pageable); } + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @Operation(summary = "Get book by id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Book retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Book not found") + }) @GetMapping("/{id}") - @ApiOperation(value = "Get book by id") public BookDto getBookById(@Valid @PathVariable Long id) { return bookService.findById(id); } @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create a book") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Book created successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input data") + }) @PostMapping - @ApiOperation(value = "create a book") public BookDto createBook(@Valid @RequestBody CreateBookRequestDto requestDto) { return bookService.save(requestDto); } - @PutMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") - public BookDto updateBook(@PathVariable Long id, @RequestBody @Valid UpdateBookRequestDto updateBookRequestDto) { + @Operation(summary = "Update a book") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Book updated successfully"), + @ApiResponse(responseCode = "404", description = "Book not found") + }) + @PutMapping("/{id}") + public BookDto updateBook(@PathVariable Long id, + @RequestBody @Valid UpdateBookRequestDto updateBookRequestDto) { return bookService.update(updateBookRequestDto); } @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete a book") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Book deleted successfully"), + @ApiResponse(responseCode = "404", description = "Book not found") + }) @DeleteMapping("/{id}") - @ApiOperation(value = "delete a book") public void deleteBook(@PathVariable Long id) { bookService.deleteById(id); } + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @Operation(summary = "Search a book") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Books retrieved successfully"), + @ApiResponse(responseCode = "400", description = "Invalid search parameters") + }) @GetMapping("/search") - @ApiOperation(value = "search a book") public Page searchBooks(BookSearchParameters searchParameters, Pageable pageable) { return bookService.search(searchParameters, pageable); } diff --git a/src/main/java/mate/academy/springbootwebgreqit/controller/CategoryController.java b/src/main/java/mate/academy/springbootwebgreqit/controller/CategoryController.java index 8f184af..408ce00 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/controller/CategoryController.java +++ b/src/main/java/mate/academy/springbootwebgreqit/controller/CategoryController.java @@ -1,5 +1,8 @@ package mate.academy.springbootwebgreqit.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import mate.academy.springbootwebgreqit.dto.BookDtoWithoutCategotyIds; @@ -26,34 +29,65 @@ public class CategoryController { private final CategoryService categoryService; @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create a new category") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Category created successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input data") + }) @PostMapping public CategoryDto createCategory(@Valid @RequestBody CreateCategoryRequestDto categoryDto) { return categoryService.save(categoryDto); } @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get all categories") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Categories retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) @GetMapping public List getAll() { return categoryService.findAll(); } + @Operation(summary = "Get category by id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Category retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Category not found") + }) @GetMapping("/{id}") public CategoryDto getCategoryById(@Valid @PathVariable Long id) { return categoryService.getById(id); } @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Update a category") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Category updated successfully"), + @ApiResponse(responseCode = "404", description = "Category not found") + }) @PutMapping("/{id}") - public CategoryDto updateCategory(@PathVariable Long id, @RequestBody @Valid UpdateCategoryRequestDto categoryDto) { + public CategoryDto updateCategory(@PathVariable Long id, + @RequestBody @Valid UpdateCategoryRequestDto categoryDto) { return categoryService.update(id, categoryDto); } @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete a category") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Category deleted successfully"), + @ApiResponse(responseCode = "404", description = "Category not found") + }) @DeleteMapping("/{id}") public void deleteCategory(@PathVariable Long id) { categoryService.deleteById(id); } + @Operation(summary = "Get books by category id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Books retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Category not found") + }) @GetMapping("/{id}/books") public List getBooksByCategoryId(@PathVariable Long id) { return categoryService.findBooksByCategoryId(id); diff --git a/src/main/java/mate/academy/springbootwebgreqit/controller/OrderController.java b/src/main/java/mate/academy/springbootwebgreqit/controller/OrderController.java index 35fbf29..fb3c1c6 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/controller/OrderController.java +++ b/src/main/java/mate/academy/springbootwebgreqit/controller/OrderController.java @@ -1,6 +1,8 @@ package mate.academy.springbootwebgreqit.controller; -import io.swagger.annotations.ApiOperation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import mate.academy.springbootwebgreqit.dto.order.CreateOrderRequestDto; @@ -28,14 +30,22 @@ public class OrderController { private final OrderService orderService; @PostMapping - @ApiOperation("Make order") + @Operation(summary = "Make order") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Order created successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input data") + }) @PreAuthorize("hasRole('USER')") public OrderResponseDto addOrder(@RequestParam Long userId, @RequestBody CreateOrderRequestDto createOrderRequestDto) { return orderService.addOrder(userId, createOrderRequestDto); } - @ApiOperation("Get all orders") + @Operation(summary = "Get all orders") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Orders retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) @GetMapping @PreAuthorize("hasRole('USER')") public List getAllOrders(@RequestParam Long userId) { @@ -44,26 +54,37 @@ public List getAllOrders(@RequestParam Long userId) { @PreAuthorize("hasRole('ADMIN')") @PatchMapping("/{id}") - @ApiOperation("Update order status by id") + @Operation(summary = "Update order status by id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Order status updated successfully"), + @ApiResponse(responseCode = "404", description = "Order not found") + }) public OrderResponseDto updateOrderStatus(@PathVariable Long id, @RequestBody @Valid OrderRequestDto requestDto) { return orderService.updateOrderStatus(id, requestDto); } + @Operation(summary = "Get all items from order by order id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Order items retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Order not found") + }) @GetMapping("/{orderId}/items") - @ApiOperation("Get all items from order by order id") @PreAuthorize("hasRole('USER')") public List getAllItemsFromOrder(@PathVariable Long orderId, Authentication authentication) { return orderService.getAllItemsFromOrder(orderId); } + @Operation(summary = "Get item from order by order id and item id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Order item retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Order or item not found") + }) @GetMapping("{orderId}/items/{itemId}") - @ApiOperation("Get item from order by order id and item id") @PreAuthorize("hasRole('USER')") public OrderItemResponseDto getItemFromOrderById(@PathVariable Long orderId, - @PathVariable Long itemId, - Authentication authentication) { + @PathVariable Long itemId) { return orderService.getItemFromOrderById(orderId, itemId); } } diff --git a/src/main/java/mate/academy/springbootwebgreqit/controller/ShoppingCartController.java b/src/main/java/mate/academy/springbootwebgreqit/controller/ShoppingCartController.java index b9c4c97..a7b1a24 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/controller/ShoppingCartController.java +++ b/src/main/java/mate/academy/springbootwebgreqit/controller/ShoppingCartController.java @@ -1,9 +1,15 @@ package mate.academy.springbootwebgreqit.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import mate.academy.springbootwebgreqit.dto.cartitem.CartItemRequestDto; +import mate.academy.springbootwebgreqit.dto.shoppingcart.UpdateCartItemDto; import mate.academy.springbootwebgreqit.dto.shoppingcart.ShoppingCartDto; import mate.academy.springbootwebgreqit.service.ShoppingCartService; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -11,7 +17,6 @@ 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -20,26 +25,46 @@ public class ShoppingCartController { private final ShoppingCartService shoppingCartService; - @GetMapping - public ShoppingCartDto getShoppingCartForCurrentUser(@RequestParam Long userId) { - return shoppingCartService.getShoppingCartForCurrentUser(userId); + @Operation(summary = "Get shopping cart for current user") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Shopping cart retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) + @GetMapping("/{shoppingCartId}") + public ShoppingCartDto getShoppingCartForCurrentUser(Authentication authentication, + @PathVariable Long shoppingCartId) { + return shoppingCartService.getShoppingCartForCurrentUser(authentication, shoppingCartId); } + @Operation(summary = "Add book to shopping cart") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Book added to shopping cart"), + @ApiResponse(responseCode = "400", description = "Invalid input data") + }) @PostMapping public ShoppingCartDto addBookToShoppingCart(@RequestBody CartItemRequestDto cartItem, - @RequestParam Long userId) { - return shoppingCartService.addBookToShoppingCart(cartItem, userId); + Authentication authentication) { + return shoppingCartService.addBookToShoppingCart(cartItem, authentication); } + @Operation(summary = "Update cart item quantity") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Cart item quantity updated successfully"), + @ApiResponse(responseCode = "404", description = "Cart item not found") + }) @PutMapping("/items/{cartItemId}") public ShoppingCartDto updateCartItemQuantity(@PathVariable Long cartItemId, - @RequestParam int quantity, - @RequestParam Long userId) { - return shoppingCartService.updateCartItemQuantity(cartItemId, quantity, userId); + @RequestBody @Valid UpdateCartItemDto quantity) { + return shoppingCartService.updateCartItemQuantity(cartItemId, quantity); } + @Operation(summary = "Remove cart item") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Cart item removed successfully"), + @ApiResponse(responseCode = "404", description = "Cart item not found") + }) @DeleteMapping("/items/{cartItemId}") - public void removeCartItem(@PathVariable Long cartItemId, @RequestParam Long userId) { - shoppingCartService.removeCartItem(cartItemId, userId); + public void removeCartItem(@PathVariable Long cartItemId, Authentication authentication) { + shoppingCartService.removeCartItem(cartItemId, authentication); } } diff --git a/src/main/java/mate/academy/springbootwebgreqit/dto/category/CreateCategoryRequestDto.java b/src/main/java/mate/academy/springbootwebgreqit/dto/category/CreateCategoryRequestDto.java index 69f177b..43b6dd9 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/dto/category/CreateCategoryRequestDto.java +++ b/src/main/java/mate/academy/springbootwebgreqit/dto/category/CreateCategoryRequestDto.java @@ -1,7 +1,6 @@ package mate.academy.springbootwebgreqit.dto.category; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.Data; @Data diff --git a/src/main/java/mate/academy/springbootwebgreqit/dto/shoppingcart/UpdateCartItemDto.java b/src/main/java/mate/academy/springbootwebgreqit/dto/shoppingcart/UpdateCartItemDto.java new file mode 100644 index 0000000..b4ecbfd --- /dev/null +++ b/src/main/java/mate/academy/springbootwebgreqit/dto/shoppingcart/UpdateCartItemDto.java @@ -0,0 +1,10 @@ +package mate.academy.springbootwebgreqit.dto.shoppingcart; + +import jakarta.validation.constraints.Positive; +import lombok.Data; + +@Data +public class UpdateCartItemDto { + @Positive + private int quantity; +} diff --git a/src/main/java/mate/academy/springbootwebgreqit/dto/user/RefreshTokenRequestDto.java b/src/main/java/mate/academy/springbootwebgreqit/dto/user/RefreshTokenRequestDto.java new file mode 100644 index 0000000..6823974 --- /dev/null +++ b/src/main/java/mate/academy/springbootwebgreqit/dto/user/RefreshTokenRequestDto.java @@ -0,0 +1,10 @@ +package mate.academy.springbootwebgreqit.dto.user; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class RefreshTokenRequestDto { + @NotBlank + private String refreshToken; +} diff --git a/src/main/java/mate/academy/springbootwebgreqit/dto/user/UserLoginResponseDto.java b/src/main/java/mate/academy/springbootwebgreqit/dto/user/UserLoginResponseDto.java index 670023e..82f0088 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/dto/user/UserLoginResponseDto.java +++ b/src/main/java/mate/academy/springbootwebgreqit/dto/user/UserLoginResponseDto.java @@ -2,9 +2,12 @@ import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data @AllArgsConstructor +@NoArgsConstructor public class UserLoginResponseDto { - private String token; + private String accessToken; + private String refreshToken; } diff --git a/src/main/java/mate/academy/springbootwebgreqit/exception/GlobalExceptionHandler.java b/src/main/java/mate/academy/springbootwebgreqit/exception/GlobalExceptionHandler.java index 437c057..b73b33a 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/exception/GlobalExceptionHandler.java +++ b/src/main/java/mate/academy/springbootwebgreqit/exception/GlobalExceptionHandler.java @@ -7,15 +7,18 @@ import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +@RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @Override protected ResponseEntity handleMethodArgumentNotValid( @@ -48,6 +51,11 @@ public ResponseEntity handleRegistrationException(RegistrationException return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage()); } + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getMessage()); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { Map body = new LinkedHashMap<>(); diff --git a/src/main/java/mate/academy/springbootwebgreqit/model/Book.java b/src/main/java/mate/academy/springbootwebgreqit/model/Book.java index f2994c6..05635c2 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/model/Book.java +++ b/src/main/java/mate/academy/springbootwebgreqit/model/Book.java @@ -63,6 +63,7 @@ public class Book { ) @ToString.Exclude @EqualsAndHashCode.Exclude + @JsonIgnore private Set categories = new HashSet<>(); private boolean isDeleted = false; diff --git a/src/main/java/mate/academy/springbootwebgreqit/model/CartItem.java b/src/main/java/mate/academy/springbootwebgreqit/model/CartItem.java index 11aa5e4..88c053b 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/model/CartItem.java +++ b/src/main/java/mate/academy/springbootwebgreqit/model/CartItem.java @@ -28,6 +28,7 @@ public class CartItem { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "book_id", nullable = false) + @JsonIgnore private Book book; private int quantity; } diff --git a/src/main/java/mate/academy/springbootwebgreqit/model/User.java b/src/main/java/mate/academy/springbootwebgreqit/model/User.java index fe41696..ce07cf5 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/model/User.java +++ b/src/main/java/mate/academy/springbootwebgreqit/model/User.java @@ -1,7 +1,6 @@ package mate.academy.springbootwebgreqit.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -11,7 +10,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; -import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; @@ -46,10 +44,6 @@ public class User implements UserDetails { @Column(name = "is_deleted") private boolean isDeleted = false; - @JoinColumn(name = "shopping_card_id") - @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY) - private ShoppingCart shoppingCart; - @ManyToMany(fetch = FetchType.EAGER) @JoinTable( name = "users_roles", diff --git a/src/main/java/mate/academy/springbootwebgreqit/ratelimit/BookRateLimiterService.java b/src/main/java/mate/academy/springbootwebgreqit/ratelimit/BookRateLimiterService.java new file mode 100644 index 0000000..2428169 --- /dev/null +++ b/src/main/java/mate/academy/springbootwebgreqit/ratelimit/BookRateLimiterService.java @@ -0,0 +1,46 @@ +package mate.academy.springbootwebgreqit.ratelimit; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BookRateLimiterService { + static final String KEY_USER_PREFIX = "rate:books:user:"; + static final String KEY_IP_PREFIX = "rate:books:ip:"; + + private final StringRedisTemplate stringRedisTemplate; + + @Value("${rate-limit.books.authenticated-per-minute:10}") + private int authenticatedPerMinute; + + @Value("${rate-limit.books.anonymous-per-minute:2}") + private int anonymousPerMinute; + + @Value("${rate-limit.books.window-seconds:60}") + private long windowSeconds; + + public RateLimitResult checkAuthenticated(Long userId) { + String key = KEY_USER_PREFIX + userId; + return incrementAndCheck(key, authenticatedPerMinute); + } + + public RateLimitResult checkAnonymous(String clientIp) { + String key = KEY_IP_PREFIX + clientIp; + return incrementAndCheck(key, anonymousPerMinute); + } + + private RateLimitResult incrementAndCheck(String key, int maxPerWindow) { + Long count = stringRedisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + stringRedisTemplate.expire(key, Duration.ofSeconds(windowSeconds)); + } + if (count != null && count > maxPerWindow) { + return RateLimitResult.LIMITED; + } + return RateLimitResult.ALLOWED; + } +} diff --git a/src/main/java/mate/academy/springbootwebgreqit/ratelimit/RateLimitResult.java b/src/main/java/mate/academy/springbootwebgreqit/ratelimit/RateLimitResult.java new file mode 100644 index 0000000..33bbf33 --- /dev/null +++ b/src/main/java/mate/academy/springbootwebgreqit/ratelimit/RateLimitResult.java @@ -0,0 +1,6 @@ +package mate.academy.springbootwebgreqit.ratelimit; + +public enum RateLimitResult { + ALLOWED, + LIMITED +} diff --git a/src/main/java/mate/academy/springbootwebgreqit/repository/OrderItemReposirory.java b/src/main/java/mate/academy/springbootwebgreqit/repository/OrderItemReposirory.java index 884389a..944044c 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/repository/OrderItemReposirory.java +++ b/src/main/java/mate/academy/springbootwebgreqit/repository/OrderItemReposirory.java @@ -2,8 +2,11 @@ import mate.academy.springbootwebgreqit.model.OrderItem; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + import java.util.Optional; +@Repository public interface OrderItemReposirory extends JpaRepository { Optional findByOrderId(Long orderId); diff --git a/src/main/java/mate/academy/springbootwebgreqit/repository/OrderRepository.java b/src/main/java/mate/academy/springbootwebgreqit/repository/OrderRepository.java index dbc4a49..bbe80a2 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/repository/OrderRepository.java +++ b/src/main/java/mate/academy/springbootwebgreqit/repository/OrderRepository.java @@ -3,8 +3,11 @@ import mate.academy.springbootwebgreqit.model.Order; import mate.academy.springbootwebgreqit.model.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + import java.util.List; +@Repository public interface OrderRepository extends JpaRepository { List findByUser(User user); } diff --git a/src/main/java/mate/academy/springbootwebgreqit/repository/ShoppingCartRepository.java b/src/main/java/mate/academy/springbootwebgreqit/repository/ShoppingCartRepository.java index 2c2f319..5163ae3 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/repository/ShoppingCartRepository.java +++ b/src/main/java/mate/academy/springbootwebgreqit/repository/ShoppingCartRepository.java @@ -1,12 +1,18 @@ package mate.academy.springbootwebgreqit.repository; +import mate.academy.springbootwebgreqit.model.CartItem; import mate.academy.springbootwebgreqit.model.ShoppingCart; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.Set; +@Repository public interface ShoppingCartRepository extends JpaRepository { @EntityGraph(attributePaths = {"cartItems", "cartItems.book"}) Optional findByUserId(Long userId); + + Optional findByCartItems(Set cartItems); } diff --git a/src/main/java/mate/academy/springbootwebgreqit/security/AuthenticationService.java b/src/main/java/mate/academy/springbootwebgreqit/security/AuthenticationService.java index d348aad..0a29b31 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/security/AuthenticationService.java +++ b/src/main/java/mate/academy/springbootwebgreqit/security/AuthenticationService.java @@ -1,14 +1,16 @@ package mate.academy.springbootwebgreqit.security; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import mate.academy.springbootwebgreqit.dto.user.RefreshTokenRequestDto; import mate.academy.springbootwebgreqit.dto.user.UserLoginRequestDto; import mate.academy.springbootwebgreqit.dto.user.UserLoginResponseDto; import mate.academy.springbootwebgreqit.repository.UserRepository; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service @@ -22,7 +24,24 @@ public UserLoginResponseDto authenticate(UserLoginRequestDto request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) ); - String token = jwtUtil.generateToken(request.getEmail()); - return new UserLoginResponseDto(token); + String email = authentication.getName(); + String accessToken = jwtUtil.generateAccessToken(email); + String refreshToken = jwtUtil.generateRefreshToken(email); + return new UserLoginResponseDto(accessToken, refreshToken); + } + + @Transactional(readOnly = true) + public UserLoginResponseDto refreshAccessToken(RefreshTokenRequestDto requestDto) { + String refreshToken = requestDto.getRefreshToken(); + if (!jwtUtil.isRefreshTokenValid(refreshToken)) { + throw new BadCredentialsException("Invalid or expired refresh token"); + } + String email = jwtUtil.getUsername(refreshToken); + userRepository.findByEmail(email) + .orElseThrow(() -> new BadCredentialsException("User no longer exists")); + return new UserLoginResponseDto( + jwtUtil.generateAccessToken(email), + jwtUtil.generateRefreshToken(email) + ); } } diff --git a/src/main/java/mate/academy/springbootwebgreqit/security/BookRateLimitFilter.java b/src/main/java/mate/academy/springbootwebgreqit/security/BookRateLimitFilter.java new file mode 100644 index 0000000..07581bd --- /dev/null +++ b/src/main/java/mate/academy/springbootwebgreqit/security/BookRateLimitFilter.java @@ -0,0 +1,65 @@ +package mate.academy.springbootwebgreqit.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 mate.academy.springbootwebgreqit.model.User; +import mate.academy.springbootwebgreqit.ratelimit.BookRateLimiterService; +import mate.academy.springbootwebgreqit.ratelimit.RateLimitResult; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class BookRateLimitFilter extends OncePerRequestFilter { + private final BookRateLimiterService bookRateLimiterService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + if (!request.getServletPath().startsWith("/books")) { + filterChain.doFilter(request, response); + return; + } + + RateLimitResult result; + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (isAuthenticatedUser(auth)) { + User user = (User) auth.getPrincipal(); + result = bookRateLimiterService.checkAuthenticated(user.getId()); + } else { + result = bookRateLimiterService.checkAnonymous(resolveClientIp(request)); + } + + if (result == RateLimitResult.LIMITED) { + response.sendError(HttpStatus.TOO_MANY_REQUESTS.value()); + return; + } + filterChain.doFilter(request, response); + } + + private boolean isAuthenticatedUser(Authentication auth) { + return auth != null + && auth.isAuthenticated() + && !(auth instanceof AnonymousAuthenticationToken) + && auth.getPrincipal() instanceof User; + } + + private String resolveClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].strip(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/mate/academy/springbootwebgreqit/security/CustomUserDetailService.java b/src/main/java/mate/academy/springbootwebgreqit/security/CustomUserDetailService.java index ddc3f33..a24ee83 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/security/CustomUserDetailService.java +++ b/src/main/java/mate/academy/springbootwebgreqit/security/CustomUserDetailService.java @@ -5,6 +5,7 @@ import mate.academy.springbootwebgreqit.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 @@ -15,6 +16,6 @@ public class CustomUserDetailService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String email) throws EntityNotFoundException { return userRepository.findByEmail(email) - .orElseThrow(() -> new EntityNotFoundException("Can't find user by email")); + .orElseThrow(() -> new UsernameNotFoundException("Can't find user by email")); } } diff --git a/src/main/java/mate/academy/springbootwebgreqit/security/JwtAuthenticationFilter.java b/src/main/java/mate/academy/springbootwebgreqit/security/JwtAuthenticationFilter.java index 89f550c..516b750 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/security/JwtAuthenticationFilter.java +++ b/src/main/java/mate/academy/springbootwebgreqit/security/JwtAuthenticationFilter.java @@ -32,7 +32,7 @@ protected void doFilterInternal( ) throws ServletException, IOException { String token = getToken(request); - if (token != null && jwtUtil.isValidToken(token)) { + if (token != null && jwtUtil.isAccessTokenValid(token)) { String username = jwtUtil.getUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); Authentication authentication = new UsernamePasswordAuthenticationToken( diff --git a/src/main/java/mate/academy/springbootwebgreqit/security/JwtUtil.java b/src/main/java/mate/academy/springbootwebgreqit/security/JwtUtil.java index 57fd094..7512540 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/security/JwtUtil.java +++ b/src/main/java/mate/academy/springbootwebgreqit/security/JwtUtil.java @@ -1,69 +1,93 @@ package mate.academy.springbootwebgreqit.security; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - import java.nio.charset.StandardCharsets; import java.security.Key; import java.util.Date; import java.util.function.Function; import java.util.logging.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; @Component public class JwtUtil { private static final Logger logger = Logger.getLogger(JwtUtil.class.getName()); + private static final String TOKEN_TYPE_CLAIM = "typ"; + private static final String ACCESS = "access"; + private static final String REFRESH = "refresh"; + private final Key secret; - @Value("${jwt.expiration}") - private long expiration; + + @Value("${jwt.access.expiration}") + private long accessExpirationMs; + + @Value("${jwt.refresh.expiration}") + private long refreshExpirationMs; public JwtUtil(@Value("${jwt.secret}") String secretString) { this.secret = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8)); } - public String generateToken(String username) { + public String generateAccessToken(String username) { + return buildToken(username, ACCESS, accessExpirationMs); + } + + public String generateRefreshToken(String username) { + return buildToken(username, REFRESH, refreshExpirationMs); + } + + private String buildToken(String username, String type, long ttlMs) { return Jwts.builder() .setSubject(username) + .claim(TOKEN_TYPE_CLAIM, type) .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .setExpiration(new Date(System.currentTimeMillis() + ttlMs)) .signWith(secret) .compact(); } - public boolean isValidToken(String token) { + public boolean isAccessTokenValid(String token) { + return validateTokenOfType(token, ACCESS); + } + + public boolean isRefreshTokenValid(String token) { + return validateTokenOfType(token, REFRESH); + } + + private boolean validateTokenOfType(String token, String expectedType) { try { - Jws claimsJwts = Jwts.parserBuilder() - .setSigningKey(secret) - .build() - .parseClaimsJws(token); - boolean isExpired = claimsJwts.getBody().getExpiration().before(new Date()); - if (isExpired) { + Claims claims = parseClaimsJws(token); + if (!expectedType.equals(claims.get(TOKEN_TYPE_CLAIM, String.class))) { + return false; + } + boolean expired = claims.getExpiration().before(new Date()); + if (expired) { logger.warning("Token is expired"); } - return !isExpired; - } catch (JwtException e) { + return !expired; + } catch (JwtException | IllegalArgumentException e) { logger.severe("Invalid JWT token: " + e.getMessage()); return false; - } catch (IllegalArgumentException e) { - logger.severe("Token is null or empty: " + e.getMessage()); - return false; } } public String getUsername(String token) { - return getClaimsFromToken(token, Claims::getSubject); + return getClaim(token, Claims::getSubject); + } + + private T getClaim(String token, Function resolver) { + Claims claims = parseClaimsJws(token); + return resolver.apply(claims); } - private T getClaimsFromToken(String token, Function claimsResolver) { - final Claims claims = Jwts.parserBuilder() + private Claims parseClaimsJws(String token) { + return Jwts.parserBuilder() .setSigningKey(secret) .build() .parseClaimsJws(token) .getBody(); - return claimsResolver.apply(claims); } } diff --git a/src/main/java/mate/academy/springbootwebgreqit/service/ShoppingCartService.java b/src/main/java/mate/academy/springbootwebgreqit/service/ShoppingCartService.java index ee79370..3345ae4 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/service/ShoppingCartService.java +++ b/src/main/java/mate/academy/springbootwebgreqit/service/ShoppingCartService.java @@ -1,18 +1,24 @@ package mate.academy.springbootwebgreqit.service; import mate.academy.springbootwebgreqit.dto.cartitem.CartItemRequestDto; +import mate.academy.springbootwebgreqit.dto.shoppingcart.UpdateCartItemDto; import mate.academy.springbootwebgreqit.dto.shoppingcart.ShoppingCartDto; import mate.academy.springbootwebgreqit.model.ShoppingCart; import mate.academy.springbootwebgreqit.model.User; +import org.springframework.security.core.Authentication; public interface ShoppingCartService { - ShoppingCartDto getShoppingCartForCurrentUser(Long userId); + ShoppingCartDto getShoppingCartForCurrentUser(Authentication authentication, + Long shoppingCartId); - ShoppingCartDto addBookToShoppingCart(CartItemRequestDto cartItem, Long userId); + ShoppingCartDto addBookToShoppingCart(CartItemRequestDto cartItem, + Authentication authentication); - ShoppingCartDto updateCartItemQuantity(Long cartItemId, int quantity, Long userId); + ShoppingCartDto updateCartItemQuantity(Long cartItemId, + UpdateCartItemDto quantity); - ShoppingCartDto removeCartItem(Long cartItemId, Long userId); + ShoppingCartDto removeCartItem(Long cartItemId, + Authentication authentication); ShoppingCart createShoppingCart(User user); } diff --git a/src/main/java/mate/academy/springbootwebgreqit/service/impl/ShoppingCartServiceImpl.java b/src/main/java/mate/academy/springbootwebgreqit/service/impl/ShoppingCartServiceImpl.java index 64a0119..7e5d9e8 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/service/impl/ShoppingCartServiceImpl.java +++ b/src/main/java/mate/academy/springbootwebgreqit/service/impl/ShoppingCartServiceImpl.java @@ -3,6 +3,7 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import mate.academy.springbootwebgreqit.dto.cartitem.CartItemRequestDto; +import mate.academy.springbootwebgreqit.dto.shoppingcart.UpdateCartItemDto; import mate.academy.springbootwebgreqit.dto.shoppingcart.ShoppingCartDto; import mate.academy.springbootwebgreqit.exception.EntityNotFoundException; import mate.academy.springbootwebgreqit.mapper.CartItemMapper; @@ -16,8 +17,10 @@ import mate.academy.springbootwebgreqit.repository.UserRepository; import mate.academy.springbootwebgreqit.service.ShoppingCartService; import org.hibernate.Hibernate; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import java.util.Optional; +import java.util.Set; @Service @RequiredArgsConstructor @@ -31,20 +34,18 @@ public class ShoppingCartServiceImpl implements ShoppingCartService { @Transactional @Override - public ShoppingCartDto getShoppingCartForCurrentUser(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - ShoppingCart shoppingCart = user.getShoppingCart(); + public ShoppingCartDto getShoppingCartForCurrentUser(Authentication authentication, + Long shoppingCartId) { + User user = userRepository.findByEmail(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + + authentication.getName())); + ShoppingCart shoppingCart = shoppingCartRepository.findById(shoppingCartId) + .orElseThrow(() -> new EntityNotFoundException("Shopping cart not " + + "found for user with id: " + shoppingCartId)); if (shoppingCart == null) { - throw new EntityNotFoundException("Shopping cart not found for user with ID " - + userId); + throw new EntityNotFoundException("Shopping cart not found for user with email: " + + user.getEmail()); } - Hibernate.initialize(shoppingCart.getCartItems()); - Hibernate.initialize(user.getRoles()); - shoppingCart.getCartItems().forEach(cartItem -> - Hibernate.initialize(cartItem.getBook().getCategories())); - shoppingCart.getCartItems().forEach(cartItem -> - Hibernate.initialize(cartItem.getBook().getCartItems())); return shoppingCartMapper.toDto(shoppingCart); } @@ -52,8 +53,11 @@ public ShoppingCartDto getShoppingCartForCurrentUser(Long userId) { @Override public ShoppingCartDto addBookToShoppingCart( CartItemRequestDto cartItemDto, - Long userId) { - ShoppingCart shoppingCart = shoppingCartRepository.findByUserId(userId) + Authentication authentication) { + User user = userRepository.findByEmail(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + + authentication.getName())); + ShoppingCart shoppingCart = shoppingCartRepository.findByUserId(user.getId()) .orElseThrow(() -> new EntityNotFoundException("Shopping cart not found")); Optional existingItemOpt = shoppingCart.getCartItems().stream() @@ -71,67 +75,54 @@ public ShoppingCartDto addBookToShoppingCart( newCartItem.setShoppingCart(shoppingCart); newCartItem.setBook(bookRepository.findById(cartItemDto.getBookId()) .orElseThrow(() -> new EntityNotFoundException("Book not found"))); - Hibernate.initialize(shoppingCart.getUser()); - Hibernate.initialize(shoppingCart.getUser().getRoles()); newCartItem.setQuantity(cartItemDto.getQuantity()); newCartItem.setShoppingCart(shoppingCart); shoppingCart.getCartItems().add(newCartItem); } ); - ShoppingCart savedshoppingCart = shoppingCartRepository.save(shoppingCart); - Hibernate.initialize(savedshoppingCart.getCartItems()); - savedshoppingCart.getCartItems().forEach(cartItem -> - Hibernate.initialize(cartItem.getBook().getCategories())); - savedshoppingCart.getCartItems().forEach(cartItem -> - Hibernate.initialize(cartItem.getBook().getCartItems())); - - return shoppingCartMapper.toDto(savedshoppingCart); + shoppingCartRepository.save(shoppingCart); + return shoppingCartMapper.toDto(shoppingCart); } @Transactional @Override public ShoppingCartDto updateCartItemQuantity(Long cartItemId, - int quantity, Long userId) { - if (quantity <= 0) { + UpdateCartItemDto quantity) { + if (quantity.getQuantity() <= 0) { throw new EntityNotFoundException("Quantity must be a positive integer"); } - User user = userRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException("User not found")); - ShoppingCart shoppingCart = user.getShoppingCart(); - if (shoppingCart == null) { - throw new EntityNotFoundException("Shopping cart not found for user with ID " - + userId); - } + CartItem cartItemToFindShoppingCart = cartItemRepository.findById(cartItemId) + .orElseThrow(() -> new EntityNotFoundException("Cart item not found with id: " + + cartItemId)); + + ShoppingCart shoppingCart = shoppingCartRepository + .findByCartItems(Set.of(cartItemToFindShoppingCart)) + .orElseThrow(() -> new EntityNotFoundException("Shopping cart " + + "not found by cart item with id: " + cartItemToFindShoppingCart)); CartItem cartItem = shoppingCart.getCartItems().stream() .filter(item -> item.getId().equals(cartItemId)) .findFirst() .orElseThrow(() -> new EntityNotFoundException("CartItem with ID " + cartItemId + " not found in the user's shopping cart")); - cartItem.setQuantity(quantity); - Hibernate.initialize(shoppingCart.getCartItems()); - Hibernate.initialize(user.getRoles()); - - shoppingCart.getCartItems().forEach(ci -> - Hibernate.initialize(cartItem.getBook().getCategories())); - shoppingCart.getCartItems().forEach(ci -> - Hibernate.initialize(cartItem.getBook().getCartItems())); - + cartItem.setQuantity(quantity.getQuantity()); cartItemRepository.save(cartItem); - ShoppingCart savedShoppingCart = shoppingCartRepository.save(shoppingCart); + shoppingCartRepository.save(shoppingCart); - return shoppingCartMapper.toDto(savedShoppingCart); + return shoppingCartMapper.toDto(shoppingCart); } @Transactional @Override - public ShoppingCartDto removeCartItem(Long cartItemId, Long userId) { - ShoppingCart shoppingCart = shoppingCartRepository.findByUserId(userId) + public ShoppingCartDto removeCartItem(Long cartItemId, Authentication authentication) { + User user = userRepository.findByEmail(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + + authentication.getName())); + + ShoppingCart shoppingCart = shoppingCartRepository.findByUserId(user.getId()) .orElseThrow(() -> new EntityNotFoundException("Shopping cart not found")); - User user = userRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException("User not found")); CartItem cartItem = shoppingCart.getCartItems().stream() .filter(item -> item.getId().equals(cartItemId)) @@ -142,14 +133,6 @@ public ShoppingCartDto removeCartItem(Long cartItemId, Long userId) { shoppingCart.getCartItems().remove(cartItem); ShoppingCart savedshoppingCart = shoppingCartRepository.save(shoppingCart); - Hibernate.initialize(shoppingCart.getCartItems()); - Hibernate.initialize(user.getRoles()); - - shoppingCart.getCartItems().forEach(ci -> - Hibernate.initialize(cartItem.getBook().getCategories())); - shoppingCart.getCartItems().forEach(ci -> - Hibernate.initialize(cartItem.getBook().getCartItems())); - return shoppingCartMapper.toDto(savedshoppingCart); } diff --git a/src/main/java/mate/academy/springbootwebgreqit/service/impl/UserServiceImpl.java b/src/main/java/mate/academy/springbootwebgreqit/service/impl/UserServiceImpl.java index 6ae5234..41312a4 100644 --- a/src/main/java/mate/academy/springbootwebgreqit/service/impl/UserServiceImpl.java +++ b/src/main/java/mate/academy/springbootwebgreqit/service/impl/UserServiceImpl.java @@ -48,7 +48,7 @@ public UserResponseDto register(UserRegistrationRequestDto requestDto) { user.setRoles(roles); userRepository.save(user); ShoppingCart shoppingCart = shoppingCartService.createShoppingCart(user); - user.setShoppingCart(shoppingCart); + shoppingCart.setUser(user); return userMapper.toDto(user); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a6d1391..0edc950 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,4 +1,3 @@ -spring.application.name=Spring-boot-web-greqit spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/book-store spring.datasource.username=root @@ -9,6 +8,18 @@ spring.jpa.show-sql=true spring.jpa.open-in-view=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +spring.liquibase.change-log=/db/changelog/db.changelog-master.yaml + +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true -jwt.expiration=1800000 jwt.secret=haveAGoodDayIfYouReadIt243509349584398534025 +jwt.access.expiration=900000 +jwt.refresh.expiration=604800000 + +spring.data.redis.host=localhost +spring.data.redis.port=6379 + +rate-limit.books.authenticated-per-minute=10 +rate-limit.books.anonymous-per-minute=2 +rate-limit.books.window-seconds=60 diff --git a/src/main/resources/db/changelog/changes/02-create-table-users-roles-shoppingcarts.yaml b/src/main/resources/db/changelog/changes/02-create-table-users-roles-shoppingcarts.yaml index 1e36e67..9c3c24f 100644 --- a/src/main/resources/db/changelog/changes/02-create-table-users-roles-shoppingcarts.yaml +++ b/src/main/resources/db/changelog/changes/02-create-table-users-roles-shoppingcarts.yaml @@ -41,9 +41,6 @@ databaseChangeLog: name: is_deleted type: boolean defaultValue: false - - column: - name: shopping_card_id - type: bigint - createTable: tableName: roles diff --git a/src/main/resources/liquibase.properties b/src/main/resources/liquibase.properties index e83e494..2b801bc 100644 --- a/src/main/resources/liquibase.properties +++ b/src/main/resources/liquibase.properties @@ -1,4 +1,4 @@ url=jdbc:mysql://localhost:3306/book-store username=root password=1234567 -changelog-file=/db/chandelog/db.changelog-master.yaml +changelog-file=/db/changelog/db.changelog-master.yaml diff --git a/src/test/java/mate/academy/springbootwebgreqit/SpringBootWebGreqitApplicationTests.java b/src/test/java/mate/academy/springbootwebgreqit/SpringBootWebGreqitApplicationTests.java index 4768630..1e21918 100644 --- a/src/test/java/mate/academy/springbootwebgreqit/SpringBootWebGreqitApplicationTests.java +++ b/src/test/java/mate/academy/springbootwebgreqit/SpringBootWebGreqitApplicationTests.java @@ -1,10 +1,15 @@ package mate.academy.springbootwebgreqit; +import mate.academy.springbootwebgreqit.ratelimit.BookRateLimiterService; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; @SpringBootTest class SpringBootWebGreqitApplicationTests { + @MockBean + private BookRateLimiterService bookRateLimiterService; + @Test void contextLoads() { } diff --git a/src/test/java/mate/academy/springbootwebgreqit/controller/BookControllerTest.java b/src/test/java/mate/academy/springbootwebgreqit/controller/BookControllerTest.java index 12386b8..e254109 100644 --- a/src/test/java/mate/academy/springbootwebgreqit/controller/BookControllerTest.java +++ b/src/test/java/mate/academy/springbootwebgreqit/controller/BookControllerTest.java @@ -1,6 +1,7 @@ package mate.academy.springbootwebgreqit.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import mate.academy.springbootwebgreqit.ratelimit.BookRateLimiterService; import mate.academy.springbootwebgreqit.dto.BookDto; import mate.academy.springbootwebgreqit.dto.CreateBookRequestDto; import mate.academy.springbootwebgreqit.dto.UpdateBookRequestDto; @@ -11,6 +12,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -32,6 +34,8 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class BookControllerTest { protected static MockMvc mockMvc; + @MockBean + private BookRateLimiterService bookRateLimiterService; @Autowired private ObjectMapper objectMapper; diff --git a/src/test/java/mate/academy/springbootwebgreqit/controller/CategoryControllerTest.java b/src/test/java/mate/academy/springbootwebgreqit/controller/CategoryControllerTest.java index 04c3b33..1a94787 100644 --- a/src/test/java/mate/academy/springbootwebgreqit/controller/CategoryControllerTest.java +++ b/src/test/java/mate/academy/springbootwebgreqit/controller/CategoryControllerTest.java @@ -8,12 +8,14 @@ import mate.academy.springbootwebgreqit.dto.category.UpdateCategoryRequestDto; import mate.academy.springbootwebgreqit.model.Book; import mate.academy.springbootwebgreqit.model.Category; +import mate.academy.springbootwebgreqit.ratelimit.BookRateLimiterService; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; 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.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.jdbc.Sql; @@ -33,6 +35,9 @@ class CategoryControllerTest { protected static MockMvc mockMvc; + @MockBean + private BookRateLimiterService bookRateLimiterService; + @Autowired private ObjectMapper objectMapper; diff --git a/src/test/java/mate/academy/springbootwebgreqit/controller/ShoppingCartControllerTest.java b/src/test/java/mate/academy/springbootwebgreqit/controller/ShoppingCartControllerTest.java new file mode 100644 index 0000000..704e3f9 --- /dev/null +++ b/src/test/java/mate/academy/springbootwebgreqit/controller/ShoppingCartControllerTest.java @@ -0,0 +1,128 @@ +package mate.academy.springbootwebgreqit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import mate.academy.springbootwebgreqit.dto.cartitem.CartItemRequestDto; +import mate.academy.springbootwebgreqit.dto.shoppingcart.UpdateCartItemDto; +import mate.academy.springbootwebgreqit.ratelimit.BookRateLimiterService; +import mate.academy.springbootwebgreqit.security.CustomUserDetailService; +import mate.academy.springbootwebgreqit.service.ShoppingCartService; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +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; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ShoppingCartControllerTest { + protected static MockMvc mockMvc; + + @MockBean + private BookRateLimiterService bookRateLimiterService; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ShoppingCartService shoppingCartService; + @Autowired + private CustomUserDetailService customUserDetailService; + + @BeforeAll + static void beforeAll(@Autowired WebApplicationContext webApplicationContext) { + mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build(); + } + + + @Test + @Sql(scripts = {"/data-sql/create-books.sql", + "/data-sql/create-users.sql", "/data-sql/create-cart-item.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = "/data-sql/clear-tables-for-sh.sql", executionPhase = + Sql.ExecutionPhase.AFTER_TEST_METHOD) + @WithMockUser(username = "john.doe@example.com", roles = {"USER"}) + @DisplayName("Get shopping cart for the current user") + void getShoppingCart_ShouldReturnCartForUser() throws Exception { + Long shoppingCartId = 1L; + mockMvc.perform(get("/cart/{shoppingCartId}", shoppingCartId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.id").value(1L)) + .andReturn(); + } + + @Test + @Sql(scripts = {"/data-sql/create-books.sql", "/data-sql/create-users.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = "/data-sql/clear-tables-for-sh.sql", executionPhase = + Sql.ExecutionPhase.AFTER_TEST_METHOD) + @WithMockUser(username = "john.doe@example.com", roles = {"USER"}) + @DisplayName("Add book to shopping cart") + void addBookToShoppingCart_ShouldReturnUpdatedCart() throws Exception { + CartItemRequestDto cartItemRequestDto = new CartItemRequestDto(); + cartItemRequestDto.setBookId(1L); + cartItemRequestDto.setQuantity(2); + + String jsonRequest = objectMapper.writeValueAsString(cartItemRequestDto); + + MvcResult result = mockMvc.perform(post("/cart") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.cartItems[0].quantity") + .value(cartItemRequestDto.getQuantity())) + .andReturn(); + } + + + @Test + @Sql(scripts = {"/data-sql/create-books.sql", + "/data-sql/create-users.sql", "/data-sql/create-cart-item.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = "/data-sql/clear-tables-for-sh.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + @WithMockUser(username = "john.doe@example.com", roles = {"USER"}) + @DisplayName("Update cart item quantity") + void updateCartItemQuantity_ShouldReturnUpdatedQuantity() throws Exception { + Long cartItemId = 1L; + UpdateCartItemDto quantityDto = new UpdateCartItemDto(); + quantityDto.setQuantity(3); + + String jsonRequest = objectMapper.writeValueAsString(quantityDto); + + mockMvc.perform(put("/cart/items/{cartItemId}", cartItemId) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.cartItems[0].quantity").value(quantityDto.getQuantity())) + .andReturn(); + } + + @Test + @Sql(scripts = {"/data-sql/create-books.sql", "/data-sql/create-users.sql", + "/data-sql/create-cart-item.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = "/data-sql/clear-tables-for-sh.sql", executionPhase = + Sql.ExecutionPhase.AFTER_TEST_METHOD) + @WithMockUser(username = "john.doe@example.com", roles = {"USER"}) + @DisplayName("Remove item from cart") + void removeItemFromCart_ShouldReturnEmptyCart() throws Exception { + Long cartItemId = 1L; + + mockMvc.perform(delete("/cart/items/{cartItemId}", cartItemId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/mate/academy/springbootwebgreqit/ratelimit/BookRateLimiterServiceTest.java b/src/test/java/mate/academy/springbootwebgreqit/ratelimit/BookRateLimiterServiceTest.java new file mode 100644 index 0000000..c9137c7 --- /dev/null +++ b/src/test/java/mate/academy/springbootwebgreqit/ratelimit/BookRateLimiterServiceTest.java @@ -0,0 +1,86 @@ +package mate.academy.springbootwebgreqit.ratelimit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BookRateLimiterServiceTest { + @Mock + private StringRedisTemplate stringRedisTemplate; + @Mock + private ValueOperations valueOperations; + + private BookRateLimiterService bookRateLimiterService; + + @BeforeEach + void setUp() { + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + bookRateLimiterService = new BookRateLimiterService(stringRedisTemplate); + ReflectionTestUtils.setField(bookRateLimiterService, "authenticatedPerMinute", 10); + ReflectionTestUtils.setField(bookRateLimiterService, "anonymousPerMinute", 2); + ReflectionTestUtils.setField(bookRateLimiterService, "windowSeconds", 60L); + } + + @Test + @DisplayName("Authenticated: count within limit returns ALLOWED (200 flow)") + void checkAuthenticated_withinLimit_returnsAllowed() { + when(valueOperations.increment(BookRateLimiterService.KEY_USER_PREFIX + "1")).thenReturn(5L); + + RateLimitResult result = bookRateLimiterService.checkAuthenticated(1L); + + assertEquals(RateLimitResult.ALLOWED, result); + verify(stringRedisTemplate, never()).expire(any(String.class), any(Duration.class)); + } + + @Test + @DisplayName("Authenticated: count over limit returns LIMITED (429 flow)") + void checkAuthenticated_limitExceeded_returnsLimited() { + when(valueOperations.increment(BookRateLimiterService.KEY_USER_PREFIX + "2")).thenReturn(11L); + + RateLimitResult result = bookRateLimiterService.checkAuthenticated(2L); + + assertEquals(RateLimitResult.LIMITED, result); + verify(stringRedisTemplate, never()).expire(any(String.class), any(Duration.class)); + } + + @Test + @DisplayName("Anonymous: count within limit returns ALLOWED (200 flow)") + void checkAnonymous_withinLimit_returnsAllowed() { + String ip = "192.168.1.1"; + when(valueOperations.increment(BookRateLimiterService.KEY_IP_PREFIX + ip)).thenReturn(1L); + + RateLimitResult result = bookRateLimiterService.checkAnonymous(ip); + + assertEquals(RateLimitResult.ALLOWED, result); + verify(stringRedisTemplate).expire(eq(BookRateLimiterService.KEY_IP_PREFIX + ip), + eq(Duration.ofSeconds(60L))); + } + + @Test + @DisplayName("Anonymous: count over limit returns LIMITED (429 flow)") + void checkAnonymous_limitExceeded_returnsLimited() { + String ip = "10.0.0.5"; + when(valueOperations.increment(BookRateLimiterService.KEY_IP_PREFIX + ip)).thenReturn(3L); + + RateLimitResult result = bookRateLimiterService.checkAnonymous(ip); + + assertEquals(RateLimitResult.LIMITED, result); + verify(stringRedisTemplate, never()).expire(any(String.class), any(Duration.class)); + } + +} diff --git a/src/test/java/mate/academy/springbootwebgreqit/service/ShoppingCartServiceTest.java b/src/test/java/mate/academy/springbootwebgreqit/service/ShoppingCartServiceTest.java new file mode 100644 index 0000000..1e23d89 --- /dev/null +++ b/src/test/java/mate/academy/springbootwebgreqit/service/ShoppingCartServiceTest.java @@ -0,0 +1,180 @@ +package mate.academy.springbootwebgreqit.service; + +import mate.academy.springbootwebgreqit.dto.cartitem.CartItemRequestDto; +import mate.academy.springbootwebgreqit.dto.shoppingcart.UpdateCartItemDto; +import mate.academy.springbootwebgreqit.dto.shoppingcart.ShoppingCartDto; +import mate.academy.springbootwebgreqit.exception.EntityNotFoundException; +import mate.academy.springbootwebgreqit.mapper.CartItemMapper; +import mate.academy.springbootwebgreqit.mapper.ShoppingCartMapper; +import mate.academy.springbootwebgreqit.model.Book; +import mate.academy.springbootwebgreqit.model.CartItem; +import mate.academy.springbootwebgreqit.model.Category; +import mate.academy.springbootwebgreqit.model.ShoppingCart; +import mate.academy.springbootwebgreqit.model.User; +import mate.academy.springbootwebgreqit.repository.BookRepository; +import mate.academy.springbootwebgreqit.repository.CartItemRepository; +import mate.academy.springbootwebgreqit.repository.ShoppingCartRepository; +import mate.academy.springbootwebgreqit.repository.UserRepository; +import mate.academy.springbootwebgreqit.service.impl.ShoppingCartServiceImpl; +import org.junit.jupiter.api.BeforeEach; +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.security.core.Authentication; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ShoppingCartServiceTest { + + @Mock + private CartItemRepository cartItemRepository; + @Mock + private ShoppingCartRepository shoppingCartRepository; + @Mock + private ShoppingCartMapper shoppingCartMapper; + @Mock + private CartItemMapper cartItemMapper; + @Mock + private BookRepository bookRepository; + @Mock + private UserRepository userRepository; + + @InjectMocks + private ShoppingCartServiceImpl shoppingCartService; + + private User user; + private ShoppingCart shoppingCart; + private ShoppingCartDto shoppingCartDto; + private CartItemRequestDto cartItemRequestDto; + private Book book; + private CartItem cartItem; + private Authentication authentication; + private UpdateCartItemDto requestUpdateQuantityDto; + private Category category; + + @BeforeEach + void setUp() { + user = new User(); + user.setId(1L); + user.setEmail("test@example.com"); + + shoppingCart = new ShoppingCart(); + shoppingCart.setId(1L); + shoppingCart.setUser(user); + + shoppingCartDto = new ShoppingCartDto(); + + + cartItemRequestDto = new CartItemRequestDto(); + cartItemRequestDto.setBookId(1L); + cartItemRequestDto.setQuantity(2); + cartItemRequestDto.setShoppingCartId(shoppingCart.getId()); + + book = new Book(); + book.setId(1L); + book.setTitle("wallet"); + book.setAuthor("John"); + book.setIsbn("9283234577892"); + book.setPrice(BigDecimal.valueOf(60.99)); + book.setDescription("Updated description"); + book.setCoverImage("https://example.com/updated-cover-.jpg"); + book.setCategories(Collections.singleton(category)); + + category = new Category(); + category.setId(1L); + category.setName("Roman"); + + cartItem = new CartItem(); + cartItem.setId(1L); + cartItem.setBook(book); + cartItem.setQuantity(2); + cartItem.setShoppingCart(shoppingCart); + shoppingCart.getCartItems().add(cartItem); + + authentication = mock(Authentication.class); + + requestUpdateQuantityDto = new UpdateCartItemDto(); + requestUpdateQuantityDto.setQuantity(3); + } + + @Test + void getShoppingCartForCurrentUser_ShouldReturnShoppingCartDto() { + when(userRepository.findByEmail(authentication.getName())).thenReturn(Optional.of(user)); + when(shoppingCartRepository.findById(shoppingCart.getId())).thenReturn(Optional.of(shoppingCart)); + when(shoppingCartMapper.toDto(shoppingCart)).thenReturn(shoppingCartDto); + + ShoppingCartDto result = shoppingCartService.getShoppingCartForCurrentUser(authentication, shoppingCart.getId()); + + assertNotNull(result); + verify(shoppingCartRepository).findById(shoppingCart.getId()); + verify(shoppingCartMapper).toDto(shoppingCart); + } + + @Test + void getShoppingCartForCurrentUser_ShouldThrowEntityNotFoundException_WhenShoppingCartNotFound() { + when(userRepository.findByEmail(authentication.getName())).thenReturn(Optional.of(user)); + when(shoppingCartRepository.findById(shoppingCart.getId())).thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> shoppingCartService.getShoppingCartForCurrentUser(authentication, shoppingCart.getId())); + } + + @Test + void updateCartItemQuantity_ShouldUpdateQuantityAndReturnShoppingCartDto() { + int newQuantity = requestUpdateQuantityDto.getQuantity(); + when(cartItemRepository.findById(cartItem.getId())).thenReturn(Optional.of(cartItem)); + when(cartItemRepository.save(cartItem)).thenReturn(cartItem); + when(shoppingCartMapper.toDto(shoppingCart)).thenReturn(shoppingCartDto); + when(shoppingCartRepository.findByCartItems(Set.of(cartItem))).thenReturn(Optional.of(shoppingCart)); + + ShoppingCartDto result = shoppingCartService.updateCartItemQuantity(cartItem.getId(), requestUpdateQuantityDto); + + assertNotNull(result); + assertEquals(newQuantity, cartItem.getQuantity(), "Quantity should be updated in the cart item"); + verify(cartItemRepository).save(cartItem); + verify(shoppingCartRepository).save(shoppingCart); + verify(shoppingCartMapper).toDto(shoppingCart); + verifyNoMoreInteractions(cartItemRepository, shoppingCartRepository, shoppingCartMapper); + } + + + @Test + void removeCartItem_ShouldRemoveItemAndReturnShoppingCartDto() { + shoppingCart.setCartItems(new HashSet<>(Set.of(cartItem))); + + when(userRepository.findByEmail(authentication.getName())).thenReturn(Optional.of(user)); + when(shoppingCartRepository.findByUserId(user.getId())).thenReturn(Optional.of(shoppingCart)); + when(shoppingCartRepository.save(shoppingCart)).thenReturn(shoppingCart); + when(shoppingCartMapper.toDto(shoppingCart)).thenReturn(shoppingCartDto); + + ShoppingCartDto result = shoppingCartService.removeCartItem(cartItem.getId(), authentication); + + assertNotNull(result); + assertFalse(shoppingCart.getCartItems().contains(cartItem), "Cart item should be removed from the cart"); + verify(shoppingCartRepository).findByUserId(user.getId()); + verify(shoppingCartRepository).save(shoppingCart); + verify(shoppingCartMapper).toDto(shoppingCart); + } + + + @Test + void createShoppingCart_ShouldCreateAndReturnShoppingCart() { + when(shoppingCartRepository.save(any(ShoppingCart.class))).thenReturn(shoppingCart); + + ShoppingCart result = shoppingCartService.createShoppingCart(user); + + assertNotNull(result); + assertEquals(user, result.getUser()); + verify(shoppingCartRepository).save(result); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 7a6c017..bc36df2 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -5,5 +5,10 @@ spring.datasource.password=password spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -jwt.expiration=1800000 jwt.secret=haveAGoodDayIfYouReadIt243509349584398534025 +jwt.access.expiration=900000 +jwt.refresh.expiration=604800000 + +spring.autoconfigure.exclude=\ + org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,\ + org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration diff --git a/src/test/resources/data-sql/clear-tables-for-sh.sql b/src/test/resources/data-sql/clear-tables-for-sh.sql new file mode 100644 index 0000000..63582c0 --- /dev/null +++ b/src/test/resources/data-sql/clear-tables-for-sh.sql @@ -0,0 +1,11 @@ +-- Delete from cart_items +DELETE FROM cart_items; + +-- Delete from shopping_carts +DELETE FROM shopping_carts; + +-- Delete from books +DELETE FROM books; + +-- Delete from users; +DELETE FROM users; diff --git a/src/test/resources/data-sql/clear-tables.sql b/src/test/resources/data-sql/clear-tables.sql index 2b2a583..106cba8 100644 --- a/src/test/resources/data-sql/clear-tables.sql +++ b/src/test/resources/data-sql/clear-tables.sql @@ -1,3 +1,5 @@ DELETE FROM books_categories; DELETE FROM books; -DELETE FROM categories; \ No newline at end of file +DELETE FROM categories; +DELETE FROM shopping_carts; +DELETE FROM users; diff --git a/src/test/resources/data-sql/create-book.sql b/src/test/resources/data-sql/create-book.sql index 811b040..e5cd4b1 100644 --- a/src/test/resources/data-sql/create-book.sql +++ b/src/test/resources/data-sql/create-book.sql @@ -2,4 +2,4 @@ INSERT INTO books (id, title, author, isbn, price, description, cover_image) VALUES (1, 'wallet', 'John', '9283234577892', 60.99, 'Updated description', 'https://example.com/updated-cover-.jpg'); INSERT INTO books_categories (book_id, category_id) -VALUES (1, 1); \ No newline at end of file +VALUES (1, 1); diff --git a/src/test/resources/data-sql/create-books.sql b/src/test/resources/data-sql/create-books.sql new file mode 100644 index 0000000..67b9368 --- /dev/null +++ b/src/test/resources/data-sql/create-books.sql @@ -0,0 +1,2 @@ +INSERT INTO books (id, title, author, isbn, price, description, cover_image) +VALUES (1, 'Sample Book', 'John Author', '1234567890', 10.99, 'A sample book description.', 'https://example.com/book-cover.jpg'); diff --git a/src/test/resources/data-sql/create-cart-item.sql b/src/test/resources/data-sql/create-cart-item.sql new file mode 100644 index 0000000..cba9555 --- /dev/null +++ b/src/test/resources/data-sql/create-cart-item.sql @@ -0,0 +1 @@ +INSERT INTO cart_items (id, shopping_cart_id, book_id, quantity) VALUES (1, 1, 1, 2); diff --git a/src/test/resources/data-sql/create-category.sql b/src/test/resources/data-sql/create-category.sql index afd0770..b3d6282 100644 --- a/src/test/resources/data-sql/create-category.sql +++ b/src/test/resources/data-sql/create-category.sql @@ -1 +1 @@ -INSERT INTO categories (id, name, description) VALUES (1, 'Roman', 'Some description'); \ No newline at end of file +INSERT INTO categories (id, name, description) VALUES (1, 'Roman', 'Some description'); diff --git a/src/test/resources/data-sql/create-users.sql b/src/test/resources/data-sql/create-users.sql new file mode 100644 index 0000000..a34cfee --- /dev/null +++ b/src/test/resources/data-sql/create-users.sql @@ -0,0 +1,6 @@ +-- Insert user +INSERT INTO users (id, first_name, last_name, email, password, shipping_address) +VALUES (1, 'John', 'Doe', 'john.doe@example.com', 'password123', '123 Main St, Springfield'); + +-- Insert shopping cart +INSERT INTO shopping_carts (id, user_id) VALUES (1, 1);