diff --git a/Dockerfile b/Dockerfile index d7c1ecb..0b5c53f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ -FROM eclipse-temurin:17-jdk AS builder +FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR application ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} application.jar RUN java -Djarmode=layertools -jar application.jar extract -FROM eclipse-temurin:17-jdk +FROM eclipse-temurin:21-jre-alpine 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 +EXPOSE 8080 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0da67b5..ad48022 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,8 @@ services: app: build: . depends_on: - - mysqldb + mysqldb: + condition: service_healthy restart: on-failure env_file: .env ports: diff --git a/pom.xml b/pom.xml index d050c3c..3658a65 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,12 @@ spring-boot-starter-security + + org.springframework.security + spring-security-test + test + + org.liquibase liquibase-core @@ -120,6 +126,18 @@ org.springframework.boot spring-boot-docker-compose + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + mysql + test + diff --git a/src/main/java/com/origin/bookstore/config/SecurityConfig.java b/src/main/java/com/origin/bookstore/config/SecurityConfig.java index f1b8fca..65a3bcb 100644 --- a/src/main/java/com/origin/bookstore/config/SecurityConfig.java +++ b/src/main/java/com/origin/bookstore/config/SecurityConfig.java @@ -5,7 +5,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.Customizer; 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; @@ -44,7 +43,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest() .authenticated() ) - .httpBasic(Customizer.withDefaults()) + .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtAuthenticationFilter, diff --git a/src/main/java/com/origin/bookstore/dto/book/BookDto.java b/src/main/java/com/origin/bookstore/dto/book/BookDto.java index 0f7f38e..36ce8f8 100644 --- a/src/main/java/com/origin/bookstore/dto/book/BookDto.java +++ b/src/main/java/com/origin/bookstore/dto/book/BookDto.java @@ -2,11 +2,13 @@ import java.math.BigDecimal; import java.util.Set; +import lombok.Builder; import lombok.Getter; import lombok.Setter; @Getter @Setter +@Builder public class BookDto { private Long id; private String title; diff --git a/src/main/java/com/origin/bookstore/dto/book/CreateBookRequestDto.java b/src/main/java/com/origin/bookstore/dto/book/CreateBookRequestDto.java index 02dac44..ce5f2e4 100644 --- a/src/main/java/com/origin/bookstore/dto/book/CreateBookRequestDto.java +++ b/src/main/java/com/origin/bookstore/dto/book/CreateBookRequestDto.java @@ -8,11 +8,13 @@ import jakarta.validation.constraints.Size; import java.math.BigDecimal; import java.util.Set; +import lombok.Builder; import lombok.Getter; import lombok.Setter; @Getter @Setter +@Builder public class CreateBookRequestDto { @NotBlank @Size(min = 1, max = 100) diff --git a/src/main/java/com/origin/bookstore/model/Book.java b/src/main/java/com/origin/bookstore/model/Book.java index 06e7ac1..762d78a 100644 --- a/src/main/java/com/origin/bookstore/model/Book.java +++ b/src/main/java/com/origin/bookstore/model/Book.java @@ -12,8 +12,11 @@ import java.math.BigDecimal; import java.util.HashSet; import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; import org.hibernate.annotations.SQLDelete; @@ -24,6 +27,9 @@ @SQLRestriction("is_deleted=false") @Getter @Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor @Table(name = "books") public class Book { @Id diff --git a/src/main/java/com/origin/bookstore/model/Category.java b/src/main/java/com/origin/bookstore/model/Category.java index ae4f354..b406c61 100644 --- a/src/main/java/com/origin/bookstore/model/Category.java +++ b/src/main/java/com/origin/bookstore/model/Category.java @@ -6,17 +6,23 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; @Entity -@Table(name = "categories") @SQLDelete(sql = "UPDATE categories SET is_deleted = true WHERE id = ?") @SQLRestriction("is_deleted=false") @Getter @Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "categories") public class Category { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1a77d19..32fd618 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,10 +6,11 @@ spring.datasource.url=jdbc:mysql://localhost:3306/book_store spring.jpa.hibernate.ddl-auto=validate spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect +spring.jpa.properties.hibernate.format_sql=true spring.jpa.show-sql=true spring.liquibase.enabled=true spring.liquibase.change-log=classpath:/db/changelog/db.changelog-master.yaml -spring.config.import=file:.env[.properties] +spring.config.import=optional:file:.env[.properties] spring.docker.compose.enabled=false diff --git a/src/test/java/com/origin/bookstore/BookStoreApplicationTests.java b/src/test/java/com/origin/bookstore/BookStoreApplicationTests.java deleted file mode 100644 index 4f40b86..0000000 --- a/src/test/java/com/origin/bookstore/BookStoreApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.origin.bookstore; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class BookStoreApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/origin/bookstore/config/CustomMySqlContainer.java b/src/test/java/com/origin/bookstore/config/CustomMySqlContainer.java new file mode 100644 index 0000000..dc85ac8 --- /dev/null +++ b/src/test/java/com/origin/bookstore/config/CustomMySqlContainer.java @@ -0,0 +1,31 @@ +package com.origin.bookstore.config; + +import org.testcontainers.containers.MySQLContainer; + +public class CustomMySqlContainer extends MySQLContainer { + private static final String DB_IMAGE = "mysql:8.0"; + + private static CustomMySqlContainer container; + + private CustomMySqlContainer() { + super(DB_IMAGE); + } + + public static synchronized 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() {} +} diff --git a/src/test/java/com/origin/bookstore/controller/BookControllerTest.java b/src/test/java/com/origin/bookstore/controller/BookControllerTest.java new file mode 100644 index 0000000..ebb2b43 --- /dev/null +++ b/src/test/java/com/origin/bookstore/controller/BookControllerTest.java @@ -0,0 +1,256 @@ +package com.origin.bookstore.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.origin.bookstore.dto.book.CreateBookRequestDto; +import com.origin.bookstore.util.TestUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +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 java.util.Set; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@SpringBootTest +@AutoConfigureMockMvc +public class BookControllerTest { + private static final String API_PATH = + "/books"; + private static final String API_PATH_ID = + "/books/{id}"; + private static final String ADD_BOOKS_PATH = + "/database/books/add-books-with-categories.sql"; + private static final String REMOVE_BOOKS_PATH = + "/database/books/remove-books-with-categories.sql"; + private static final String API_SEARCH_PATH = + "/books/search"; + private static final String ID_JSON_PATH = + "$.id"; + private static final String TITLE_JSON_PATH = + "$.title"; + private static final String AUTHOR_JSON_PATH = + "$.author"; + private static final String CONTENT_JSON_PATH = + "$.content"; + private static final String ADMIN_ROLE = "ADMIN"; + private static final String USER_ROLE = "USER"; + private static final Integer BOOK_ID = 7; + private static final Integer INVALID_BOOK_ID = 999; + private static final String BOOK_AUTHOR = "Sam Sapiol"; + private static final String BOOK_TITLE = "Python Basics"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("Should successfully create book") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void createBook_Request_ReturnsBookDto() throws Exception { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + requestDto.setCategoryIds(Set.of(4L)); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(post(API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath(TITLE_JSON_PATH).value(requestDto.getTitle())) + .andExpect(jsonPath(AUTHOR_JSON_PATH).value(requestDto.getAuthor())); + } + + @Test + @DisplayName("Should return bad request if book doesn't have a title") + @WithMockUser(roles = ADMIN_ROLE) + void createBookWithoutTitle_Request_ReturnsBadRequest() throws Exception { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + requestDto.setTitle(null); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(post(API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest() + ); + } + + @Test + @DisplayName("Should return forbidden if user tries to create a book") + @WithMockUser(roles = USER_ROLE) + void createBookByUser_Request_ReturnsForbidden() throws Exception { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + requestDto.setCategoryIds(Set.of(5L)); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(post(API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isForbidden() + ); + } + + @Test + @DisplayName("Should successfully get all books") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void findAllBooks_Request_ReturnsBookDtos() throws Exception { + mockMvc.perform(get(API_PATH)) + .andExpect(status().isOk()) + .andExpect(jsonPath(CONTENT_JSON_PATH).isArray()) + .andExpect(jsonPath(CONTENT_JSON_PATH + ".length()").value(1)); + } + + @Test + @DisplayName("Should successfully get a book by id") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void findBookById_Request_ReturnsBookDto() throws Exception { + mockMvc.perform(get(API_PATH_ID, BOOK_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isOk()) + + .andExpect(jsonPath(ID_JSON_PATH, is(BOOK_ID))) + .andExpect(jsonPath(TITLE_JSON_PATH, is(BOOK_TITLE))) + .andExpect(jsonPath(AUTHOR_JSON_PATH, is(BOOK_AUTHOR))); + } + + @Test + @DisplayName("Should return 404 if book not found by id") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void findBookByInvalidId_Request_ReturnsNotFound() throws Exception { + mockMvc.perform(get(API_PATH_ID, INVALID_BOOK_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isNotFound() + ); + } + + @Test + @DisplayName("Should successfully delete a book by id") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void deleteBook_Request_ReturnsNoContent() throws Exception { + mockMvc.perform(delete(API_PATH_ID, BOOK_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("Should return forbidden when user is deleting a book") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void deleteBookByUser_Request_ReturnsForbidden() throws Exception { + mockMvc.perform(delete(API_PATH_ID, BOOK_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Should successfully update a book by id") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void updateBook_Request_ReturnsBookDto() throws Exception { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(put(API_PATH_ID, BOOK_ID) + .content(json) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath(ID_JSON_PATH, is(BOOK_ID))) + .andExpect(jsonPath(TITLE_JSON_PATH, is(requestDto.getTitle()))) + .andExpect(jsonPath(AUTHOR_JSON_PATH, is(requestDto.getAuthor())) + ); + } + + @Test + @DisplayName("Should return 404 when updating invalid book") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void updateInvalidBook_Request_ReturnsNotFound() throws Exception { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(put(API_PATH_ID, INVALID_BOOK_ID) + .content(json) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound() + ); + } + + @Test + @DisplayName("Should return forbidden when user updating a book") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void updateBookByUser_Request_ReturnsForbidden() throws Exception { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(put(API_PATH_ID, BOOK_ID) + .content(json) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden() + ); + } + + @Test + @DisplayName("Should return books matching search parameters") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOKS_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void searchBooks_ValidParameters_ReturnsMatchingBooks() throws Exception { + + mockMvc.perform(get(API_SEARCH_PATH) + .param("authors", BOOK_AUTHOR) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath(CONTENT_JSON_PATH, hasSize(1))) + .andExpect(jsonPath(CONTENT_JSON_PATH + "[0].title", is(BOOK_TITLE))) + .andExpect(jsonPath( CONTENT_JSON_PATH + "[0].author", is(BOOK_AUTHOR)) + ); + } +} diff --git a/src/test/java/com/origin/bookstore/controller/CategoryControllerTest.java b/src/test/java/com/origin/bookstore/controller/CategoryControllerTest.java new file mode 100644 index 0000000..f65cf70 --- /dev/null +++ b/src/test/java/com/origin/bookstore/controller/CategoryControllerTest.java @@ -0,0 +1,266 @@ +package com.origin.bookstore.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.origin.bookstore.dto.category.CreateCategoryRequestDto; +import com.origin.bookstore.util.TestUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +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 static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class CategoryControllerTest { + private static final String API_PATH = + "/categories"; + private static final String API_PATH_ID = + "/categories/{id}"; + private static final String CATEGORY_ID_BOOKS_API_PATH_ID = + "/categories/{id}/books"; + private static final String ADD_CATEGORY_PATH = + "/database/categories/add-category-to-categories-table.sql"; + private static final String REMOVE_CATEGORY_PATH = + "/database/categories/remove-category-from-categories-table.sql"; + private static final String ADD_BOOK_PATH = + "/database/books/add-books-with-categories.sql"; + private static final String REMOVE_BOOK_PATH = + "/database/books/remove-books-with-categories.sql"; + private static final String ID_JSON_PATH = + "$.id"; + private static final String NAME_JSON_PATH = + "$.name"; + private static final String CONTENT_JSON_PATH = + "$.content"; + private static final String ADMIN_ROLE = "ADMIN"; + private static final String USER_ROLE = "USER"; + private static final Integer CATEGORY_ID = 2; + private static final Integer INVALID_CATEGORY_ID = 456; + private static final String CATEGORY_NAME = "Fiction"; + private static final String BOOK_TITLE = "Python Basics"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("Should successfully create category and return 201") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void createCategory_Request_ReturnsCreated() throws Exception { + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(post(API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andExpect(jsonPath(NAME_JSON_PATH).value(requestDto.name()) + ); + } + + @Test + @DisplayName("Should return forbidden if user tries to create category") + @WithMockUser(roles = USER_ROLE) + void createCategoryByUser_Request_ReturnsForbidden() throws Exception { + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(post(API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isForbidden() + ); + } + + @Test + @DisplayName("Should return bad request if category doesn't have a name") + @WithMockUser(roles = ADMIN_ROLE) + void createCategory_Request_ReturnsBadRequest() throws Exception { + CreateCategoryRequestDto requestDto = + new CreateCategoryRequestDto(null, "some description"); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(post(API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest() + ); + } + + @Test + @DisplayName("Should successfully get all categories") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void findAllCategories_Request_ReturnsCategoryDtos() throws Exception { + mockMvc.perform(get(API_PATH)) + .andExpect(status().isOk()) + .andExpect(jsonPath(CONTENT_JSON_PATH).isArray()) + .andExpect(jsonPath(CONTENT_JSON_PATH + ".length()").value(1) + ); + } + + @Test + @DisplayName("Should successfully get a category by id") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void findCategoryById_Request_ReturnsCategoryDto() throws Exception { + mockMvc.perform(get(API_PATH_ID, CATEGORY_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isOk()) + + .andExpect(jsonPath(ID_JSON_PATH, is(CATEGORY_ID))) + .andExpect(jsonPath(NAME_JSON_PATH, is(CATEGORY_NAME)) + ); + } + + @Test + @DisplayName("Should return 404 if category not found by id") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void findCategoryByInvalidId_Request_ReturnsNotFound() throws Exception { + mockMvc.perform(get(API_PATH_ID, INVALID_CATEGORY_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isNotFound() + ); + } + + @Test + @DisplayName("Should successfully delete a category by id") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void deleteCategory_Request_ReturnsNoContent() throws Exception { + mockMvc.perform(delete(API_PATH_ID, CATEGORY_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("Should return forbidden when user is deleting a category") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void deleteCategoryByUser_Request_ReturnsForbidden() throws Exception { + mockMvc.perform(delete(API_PATH_ID, CATEGORY_ID) + .contentType(MediaType.APPLICATION_JSON)) + + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Should successfully update a category by id") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void updateCategory_Request_ReturnsCategoryDto() throws Exception { + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(put(API_PATH_ID, CATEGORY_ID) + .content(json) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath(ID_JSON_PATH, is(CATEGORY_ID))) + .andExpect(jsonPath(NAME_JSON_PATH, is(requestDto.name())) + ); + } + + @Test + @DisplayName("Should return 404 when updating invalid category") + @WithMockUser(roles = ADMIN_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void updateInvalidCategory_Request_ReturnsNotFound() throws Exception { + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(put(API_PATH_ID, INVALID_CATEGORY_ID) + .content(json) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound() + ); + } + + @Test + @DisplayName("Should return forbidden when user updating a category") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void updateCategoryByUser_Request_ReturnsForbidden() throws Exception { + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + String json = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(put(API_PATH_ID, CATEGORY_ID) + .content(json) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden() + ); + } + + @Test + @DisplayName("Should return books by category id") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void getBooksByCategoryId_Request_ReturnsPageOfBookDtos() throws Exception { + int categoryId = 4; + mockMvc.perform(get(CATEGORY_ID_BOOKS_API_PATH_ID, categoryId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].title").value(BOOK_TITLE) + ); + } + + @Test + @DisplayName("Should return empty page by invalid category id") + @WithMockUser(roles = USER_ROLE) + @Sql(scripts = ADD_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void getBooksByInvalidCategoryId_Request_ReturnsEmptyPage() throws Exception { + mockMvc.perform(get(CATEGORY_ID_BOOKS_API_PATH_ID, INVALID_CATEGORY_ID) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isEmpty() + ); + } +} diff --git a/src/test/java/com/origin/bookstore/repository/BookRepositoryTest.java b/src/test/java/com/origin/bookstore/repository/BookRepositoryTest.java new file mode 100644 index 0000000..e6f26a4 --- /dev/null +++ b/src/test/java/com/origin/bookstore/repository/BookRepositoryTest.java @@ -0,0 +1,86 @@ +package com.origin.bookstore.repository; + +import com.origin.bookstore.model.Book; +import com.origin.bookstore.model.Category; +import com.origin.bookstore.repository.book.BookRepository; +import com.origin.bookstore.repository.category.CategoryRepository; +import com.origin.bookstore.util.TestUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.jdbc.Sql; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class BookRepositoryTest { + private static final String ADD_BOOK_PATH = + "/database/books/add-books-with-categories.sql"; + private static final String REMOVE_BOOK_PATH = + "/database/books/remove-books-with-categories.sql"; + @Autowired + private BookRepository bookRepository; + + @Test + @DisplayName("Should save book with category and then find it by id") + void saveThenFind_ReturnsValidBook() { + Category category = TestUtil.createCategory(); + + Book book = TestUtil.createBook(); + book.setCategories(Set.of(category)); + + Book savedBook = bookRepository.save(book); + Book foundBook = bookRepository.findById(savedBook.getId()) + .orElseThrow(() -> new AssertionError("Book not found!")); + + assertEquals(1, foundBook.getCategories().size()); + } + + @Test + @Sql(scripts = ADD_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + @DisplayName("Soft deleting book by id") + void delete_ShouldMarkAsDeleted() { + bookRepository.deleteById(1L); + + assertTrue(bookRepository.findById(1L).isEmpty(), "Book should be soft deleted!"); + } + + @Test + @Sql(scripts = ADD_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_BOOK_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + @DisplayName("Should find book by category id") + void findBookByCategoryId_ReturnsValidBook() { + List books = bookRepository.findAllByCategoriesId(4L, Pageable.ofSize(1)).toList(); + + assertEquals(1, books.size()); + } + + @Test + @DisplayName("Should throw exception when saving books with same isbn") + void saveBooksBySameIsbn_ThrowsException() { + Category category = TestUtil.createCategory(); + + Book book = TestUtil.createBook(); + book.setCategories(Set.of(category)); + + bookRepository.save(book); + + Book book1 = TestUtil.createBook(); + + assertThrows(DataIntegrityViolationException.class, () -> bookRepository.save(book1)); + } +} diff --git a/src/test/java/com/origin/bookstore/repository/CategoryRepositoryTest.java b/src/test/java/com/origin/bookstore/repository/CategoryRepositoryTest.java new file mode 100644 index 0000000..722d65c --- /dev/null +++ b/src/test/java/com/origin/bookstore/repository/CategoryRepositoryTest.java @@ -0,0 +1,62 @@ +package com.origin.bookstore.repository; + +import com.origin.bookstore.model.Category; +import com.origin.bookstore.repository.category.CategoryRepository; +import com.origin.bookstore.util.TestUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.jdbc.Sql; +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class CategoryRepositoryTest { + private static final String ADD_CATEGORY_PATH = + "/database/categories/add-category-to-categories-table.sql"; + private static final String REMOVE_CATEGORY_PATH = + "/database/categories/remove-category-from-categories-table.sql"; + + @Autowired + private CategoryRepository categoryRepository; + + @Test + @DisplayName("Save then find a category by id") + void saveAndFind_ValidCategory_ReturnsCategory() { + Category category = TestUtil.createCategory(); + + Category savedCategory = categoryRepository.save(category); + Category category1 = categoryRepository.findById(savedCategory.getId()) + .orElseThrow(() -> new AssertionError("Category not found!")); + + assertEquals("Chemistry", category1.getName()); + } + + @Test + @Sql(scripts = ADD_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = REMOVE_CATEGORY_PATH, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + @DisplayName("Soft deleting category by id") + void delete_ShouldMarkAsDeleted() { + Long id = 1L; + categoryRepository.deleteById(id); + + assertTrue(categoryRepository.findById(id).isEmpty(), "Category should be soft-deleted!"); + } + + @Test + @DisplayName("Should throw exception when saving categories with same name") + void saveCategoriesBySameName_ThrowsException() { + Category category = TestUtil.createCategory(); + + Category duplicatecategory = TestUtil.createCategory(); + + categoryRepository.save(category); + + assertThrows(DataIntegrityViolationException.class, () -> categoryRepository.save(duplicatecategory)); + } +} diff --git a/src/test/java/com/origin/bookstore/service/BookServiceTest.java b/src/test/java/com/origin/bookstore/service/BookServiceTest.java new file mode 100644 index 0000000..828b326 --- /dev/null +++ b/src/test/java/com/origin/bookstore/service/BookServiceTest.java @@ -0,0 +1,218 @@ +package com.origin.bookstore.service; + +import com.origin.bookstore.dto.book.BookDto; +import com.origin.bookstore.dto.book.BookSearchParameters; +import com.origin.bookstore.dto.book.CreateBookRequestDto; +import com.origin.bookstore.exception.EntityNotFoundException; +import com.origin.bookstore.mapper.BookMapper; +import com.origin.bookstore.model.Book; +import com.origin.bookstore.model.Category; +import com.origin.bookstore.repository.book.BookRepository; +import com.origin.bookstore.repository.book.BookSpecificationBuilder; +import com.origin.bookstore.repository.category.CategoryRepository; +import com.origin.bookstore.service.impl.BookServiceImpl; +import com.origin.bookstore.util.TestUtil; +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; +import org.springframework.data.jpa.domain.Specification; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class BookServiceTest { + private static final Pageable pageable = PageRequest.of(0, 10); + public static final Long VALID_BOOK_ID = 1L; + public static final Long INVALID_ID = 456L; + + @Mock + private BookRepository bookRepository; + + @Mock + private BookMapper bookMapper; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private BookSpecificationBuilder bookSpecificationBuilder; + + @InjectMocks + private BookServiceImpl bookService; + + @Test + @DisplayName("Should save and return the exact book") + void save_ValidBook_ReturnsResponseDto() { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + Book book = TestUtil.createBook(); + Category category = TestUtil.createCategory(); + BookDto expectedDto = TestUtil.createBookDto(); + + when(bookMapper.toModel(requestDto)).thenReturn(book); + when(categoryRepository.findAllById(requestDto.getCategoryIds())) + .thenReturn(List.of(category)); + when(bookRepository.save(book)).thenReturn(book); + when(bookMapper.toDto(book)).thenReturn(expectedDto); + + BookDto actual = bookService.save(requestDto); + + assertEquals(expectedDto, actual); + verify(bookRepository).save(book); + } + + @Test + @DisplayName("Throws the exception if categories not found") + void save_ValidBookWithInvalidCategory_ThrowsException() { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + requestDto.setCategoryIds(Set.of(INVALID_ID)); + Book book = TestUtil.createBook(); + + when(bookMapper.toModel(requestDto)).thenReturn(book); + when(categoryRepository.findAllById(requestDto.getCategoryIds())) + .thenReturn(List.of()); + + EntityNotFoundException ex = + assertThrows(EntityNotFoundException.class, + () -> bookService.save(requestDto)); + + assertEquals("One or more categories not found!", ex.getMessage()); + verify(bookRepository, never()).save(any()); + } + + @Test + @DisplayName("Should return a page of response book DTOs") + void findAll_ValidBooks_ReturnsResponseDtos() { + Book book = TestUtil.createBook(); + BookDto bookDto = TestUtil.createBookDto(); + + Page bookPage = new PageImpl<>(List.of(book), pageable, 1); + + when(bookRepository.findAll(pageable)).thenReturn(bookPage); + when(bookMapper.toDto(book)).thenReturn(bookDto); + + Page actual = bookService.findAll(pageable); + + assertNotNull(actual); + assertEquals(1, actual.getContent().size()); + assertEquals(bookDto.getTitle(), actual.getContent().get(0).getTitle()); + + verify(bookRepository).findAll(pageable); + verify(bookMapper).toDto(book); + } + + @Test + @DisplayName("Should return a book by id") + void findById_ReturnsValidBook() { + Book book = TestUtil.createBook(); + BookDto expectedDto = TestUtil.createBookDto(); + + when(bookRepository.findById(VALID_BOOK_ID)).thenReturn(Optional.of(book)); + when(bookMapper.toDto(book)).thenReturn(expectedDto); + + BookDto actual = bookService.findById(VALID_BOOK_ID); + + assertNotNull(actual); + assertEquals(expectedDto, actual); + + verify(bookRepository).findById(VALID_BOOK_ID); + verify(bookMapper).toDto(book); + } + + @Test + @DisplayName("Should return a book by id") + void findByWrongId_ThrowsException() { + + when(bookRepository.findById(INVALID_ID)).thenReturn(Optional.empty()); + EntityNotFoundException ex = + assertThrows(EntityNotFoundException.class, + () -> bookService.findById(INVALID_ID)); + + assertEquals("Can't find book by id: " + INVALID_ID, ex.getMessage()); + verify(bookRepository).findById(INVALID_ID); + } + + @Test + @DisplayName("Soft delete a book by id") + void deleteBookBy_ValidId_ChecksRepository() { + Book book = TestUtil.createBook(); + + when(bookRepository.findById(VALID_BOOK_ID)).thenReturn(Optional.of(book)); + + bookService.deleteById(VALID_BOOK_ID); + + verify(bookRepository).delete(book); + } + + @Test + @DisplayName("Should return an updated book by id") + void updateBookBy_ValidId_ReturnsBookResponseDto() { + Book book = TestUtil.createBook(); + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + BookDto excepted = TestUtil.createBookDto(); + + when(bookRepository.findById(VALID_BOOK_ID)).thenReturn(Optional.of(book)); + when(bookRepository.save(book)).thenReturn(book); + when(bookMapper.toDto(book)).thenReturn(excepted); + + BookDto actual = bookService.updateBook(VALID_BOOK_ID, requestDto); + + assertEquals(excepted, actual); + verify(bookRepository).findById(VALID_BOOK_ID); + verify(bookMapper).updateBook(requestDto, book); + verify(bookRepository).save(book); + verify(bookMapper).toDto(book); + } + + @Test + @DisplayName("Throws an exception if book not found") + void update_InvalidBook_ThrowsException() { + CreateBookRequestDto requestDto = TestUtil.createBookRequestDto(); + + when(bookRepository.findById(INVALID_ID)).thenReturn(Optional.empty()); + EntityNotFoundException ex = + assertThrows(EntityNotFoundException.class, + () -> bookService.updateBook(INVALID_ID, requestDto)); + + assertEquals("Can't find and update book by id: " + INVALID_ID, ex.getMessage()); + verify(bookRepository).findById(INVALID_ID); + } + + @Test + @DisplayName("Should return a page of found books") + void searchBooks_ReturnsValidBooks() { + Book book = TestUtil.createBook(); + + BookSearchParameters parameters = + new BookSearchParameters(book.getTitle(), + book.getAuthor(), + book.getIsbn()); + + BookDto bookDto = TestUtil.createBookDto(); + Specification specification = (root, query, criteriaBuilder) -> null; + Page bookPage = new PageImpl<>(List.of(book), pageable, 1); + + when(bookSpecificationBuilder.build(parameters)).thenReturn(specification); + when(bookRepository.findAll(specification, pageable)).thenReturn(bookPage); + when(bookMapper.toDto(book)).thenReturn(bookDto); + + Page actualPage = bookService.search(parameters, pageable); + + assertNotNull(actualPage); + assertEquals(1, actualPage.getContent().size()); + assertEquals(bookDto, actualPage.getContent().get(0)); + + verify(bookSpecificationBuilder).build(parameters); + verify(bookRepository).findAll(specification, pageable); + } +} diff --git a/src/test/java/com/origin/bookstore/service/CategoryServiceTest.java b/src/test/java/com/origin/bookstore/service/CategoryServiceTest.java new file mode 100644 index 0000000..2ca940c --- /dev/null +++ b/src/test/java/com/origin/bookstore/service/CategoryServiceTest.java @@ -0,0 +1,187 @@ +package com.origin.bookstore.service; + +import com.origin.bookstore.dto.book.BookDto; +import com.origin.bookstore.dto.category.CategoryDto; +import com.origin.bookstore.dto.category.CreateCategoryRequestDto; +import com.origin.bookstore.exception.EntityNotFoundException; +import com.origin.bookstore.mapper.BookMapper; +import com.origin.bookstore.mapper.CategoryMapper; +import com.origin.bookstore.model.Book; +import com.origin.bookstore.model.Category; +import com.origin.bookstore.repository.book.BookRepository; +import com.origin.bookstore.repository.category.CategoryRepository; +import com.origin.bookstore.service.impl.CategoryServiceImpl; +import com.origin.bookstore.util.TestUtil; +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; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class CategoryServiceTest { + private static final Pageable pageable = PageRequest.of(0, 10); + public static final Long VALID_CATEGORY_ID = 1L; + + @Mock + private CategoryMapper categoryMapper; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private BookRepository bookRepository; + + @Mock + private BookMapper bookMapper; + + @InjectMocks + private CategoryServiceImpl categoryService; + + @Test + @DisplayName("Should return all categories") + void findAll_ReturnsCategoriesResponsePage() { + Category category = TestUtil.createCategory(); + CategoryDto categoryDto = TestUtil.createCategoryDto(); + Page categoryPage = new PageImpl<>(List.of(category), pageable, 1); + + when(categoryRepository.findAll(pageable)).thenReturn(categoryPage); + when(categoryMapper.toDto(category)).thenReturn(categoryDto); + + Page actual = categoryService.findAll(pageable); + + assertNotNull(actual); + assertEquals(1, actual.getContent().size()); + assertEquals(categoryDto.name(), actual.getContent().get(0).name()); + + verify(categoryRepository).findAll(pageable); + verify(categoryMapper).toDto(category); + } + + @Test + @DisplayName("Should return category by id") + void find_CategoryById_ReturnsCategoryResponse() { + Category category = TestUtil.createCategory(); + CategoryDto categoryDto = TestUtil.createCategoryDto(); + + when(categoryMapper.toDto(category)) + .thenReturn(categoryDto); + when(categoryRepository.findById(VALID_CATEGORY_ID)) + .thenReturn(Optional.of(category)); + + categoryService.getById(VALID_CATEGORY_ID); + + verify(categoryMapper).toDto(category); + verify(categoryRepository).findById(VALID_CATEGORY_ID); + } + + @Test + @DisplayName("Throws an exception if category not found") + void find_CategoryByInvalidId_ThrowsException() { + + when(categoryRepository.findById(VALID_CATEGORY_ID)) + .thenReturn(Optional.empty()); + + EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, + () -> categoryService.getById(VALID_CATEGORY_ID)); + + assertEquals("Category with id: " + + VALID_CATEGORY_ID + " not found!", ex.getMessage()); + + verify(categoryRepository).findById(VALID_CATEGORY_ID); + verifyNoInteractions(categoryMapper); + } + + @Test + @DisplayName("Should save a category and return response dto") + void save_ValidCategory_ReturnsResponseDto() { + Category category = TestUtil.createCategory(); + CategoryDto expected = TestUtil.createCategoryDto(); + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + + when(categoryRepository.save(category)).thenReturn(category); + when(categoryMapper.toEntity(requestDto)).thenReturn(category); + when(categoryMapper.toDto(category)).thenReturn(expected); + + CategoryDto actual = categoryService.save(requestDto); + + assertEquals(expected, actual); + verify(categoryRepository).save(category); + verify(categoryMapper).toDto(category); + } + + @Test + @DisplayName("Should update a category") + void update_ValidCategory_ReturnsResponseDto() { + Category category = TestUtil.createCategory(); + CategoryDto expected = TestUtil.createCategoryDto(); + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + + when(categoryRepository.findById(VALID_CATEGORY_ID)).thenReturn(Optional.of(category)); + when(categoryRepository.save(category)).thenReturn(category); + when(categoryMapper.toDto(category)).thenReturn(expected); + + CategoryDto actual = categoryService.update(VALID_CATEGORY_ID, requestDto); + + assertEquals(expected, actual); + verify(categoryRepository).findById(VALID_CATEGORY_ID); + verify(categoryRepository).save(category); + verify(categoryMapper).toDto(category); + } + + @Test + @DisplayName("Throws an exception if category not found") + void update_InvalidCategory_ThrowsException() { + CreateCategoryRequestDto requestDto = TestUtil.createCategoryRequestDto(); + + when(categoryRepository.findById(VALID_CATEGORY_ID)).thenReturn(Optional.empty()); + EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, + () -> categoryService.update(VALID_CATEGORY_ID, requestDto)); + + assertEquals("Category with id: " + VALID_CATEGORY_ID + " not found!", + ex.getMessage()); + verify(categoryRepository).findById(VALID_CATEGORY_ID); + } + + @Test + @DisplayName("Soft delete a category by id") + void deleteCategoryBy_ValidId_ChecksRepository() { + Category category = TestUtil.createCategory(); + + when(categoryRepository.findById(VALID_CATEGORY_ID)).thenReturn(Optional.of(category)); + + categoryService.deleteById(VALID_CATEGORY_ID); + + verify(categoryRepository).delete(category); + } + + @Test + @DisplayName("Should return a page of books by category id") + void getBooksBy_CategoryId_ReturnsBookResponseDto() { + Book book = TestUtil.createBook(); + BookDto bookDto = TestUtil.createBookDto(); + Page bookPage = new PageImpl<>(List.of(book), pageable, 1); + + when(bookRepository.findAllByCategoriesId(VALID_CATEGORY_ID, pageable)).thenReturn(bookPage); + when(bookMapper.toDto(book)).thenReturn(bookDto); + + Page actual = categoryService.getBooksByCategoryId(VALID_CATEGORY_ID, pageable); + + assertNotNull(actual); + assertEquals(1, actual.getContent().size()); + assertEquals(bookDto, actual.getContent().get(0)); + + verify(bookRepository).findAllByCategoriesId(VALID_CATEGORY_ID, pageable); + verify(bookMapper).toDto(book); + } +} diff --git a/src/test/java/com/origin/bookstore/util/TestUtil.java b/src/test/java/com/origin/bookstore/util/TestUtil.java new file mode 100644 index 0000000..8fc94b2 --- /dev/null +++ b/src/test/java/com/origin/bookstore/util/TestUtil.java @@ -0,0 +1,62 @@ +package com.origin.bookstore.util; + +import com.origin.bookstore.dto.book.BookDto; +import com.origin.bookstore.dto.book.CreateBookRequestDto; +import com.origin.bookstore.dto.category.CategoryDto; +import com.origin.bookstore.dto.category.CreateCategoryRequestDto; +import com.origin.bookstore.model.Book; +import com.origin.bookstore.model.Category; +import java.math.BigDecimal; +import java.util.Set; + +public class TestUtil { + public static Book createBook() { + return Book.builder() + .title("Chemistry for Beginners") + .author("W. W.") + .isbn("978-0195183429") + .price(BigDecimal.valueOf(24.99)) + .build(); + } + + public static Category createCategory() { + return Category.builder() + .name("Chemistry") + .description("Books for chemistry") + .build(); + } + + public static CreateBookRequestDto createBookRequestDto() { + return CreateBookRequestDto.builder() + .title("Chemistry for Beginners") + .author("W. W.") + .isbn("978-0195183429") + .price(BigDecimal.valueOf(24.99)) + .categoryIds(Set.of(1L)) + .build(); + } + + public static BookDto createBookDto() { + return BookDto.builder() + .title("Chemistry for Beginners") + .author("W. W.") + .isbn("978-0195183429") + .price(BigDecimal.valueOf(24.99)) + .build(); + } + + public static CreateCategoryRequestDto createCategoryRequestDto() { + return new CreateCategoryRequestDto( + "Chemistry", + "Books for chemistry" + ); + } + + public static CategoryDto createCategoryDto() { + return new CategoryDto( + 1L, + "Chemistry", + "Books for chemistry" + ); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index c594293..d1bc8b8 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,7 +1,9 @@ spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password=password +spring.datasource.username=root +spring.datasource.password=root1337 spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true jwt.secret=bookstoreproject1234567890wwwwww jwt.expiration=300000 diff --git a/src/test/resources/database/books/add-books-with-categories.sql b/src/test/resources/database/books/add-books-with-categories.sql new file mode 100644 index 0000000..511b5dd --- /dev/null +++ b/src/test/resources/database/books/add-books-with-categories.sql @@ -0,0 +1,8 @@ +INSERT INTO categories (id, name) +VALUES (4, 'Programming'); + +INSERT INTO books (id, title, author, isbn, price) +VALUES (7, 'Python Basics', 'Sam Sapiol', '978-0143128977', 9.99); + +INSERT INTO books_categories (book_id, category_id) +VALUES (7, 4); \ No newline at end of file diff --git a/src/test/resources/database/books/remove-books-with-categories.sql b/src/test/resources/database/books/remove-books-with-categories.sql new file mode 100644 index 0000000..255b1e6 --- /dev/null +++ b/src/test/resources/database/books/remove-books-with-categories.sql @@ -0,0 +1,5 @@ +DELETE FROM books_categories; + +DELETE FROM books; + +DELETE FROM categories; diff --git a/src/test/resources/database/categories/add-category-to-categories-table.sql b/src/test/resources/database/categories/add-category-to-categories-table.sql new file mode 100644 index 0000000..ca66232 --- /dev/null +++ b/src/test/resources/database/categories/add-category-to-categories-table.sql @@ -0,0 +1,2 @@ +INSERT INTO categories (id, name) +VALUES (2, 'Fiction'); \ No newline at end of file diff --git a/src/test/resources/database/categories/remove-category-from-categories-table.sql b/src/test/resources/database/categories/remove-category-from-categories-table.sql new file mode 100644 index 0000000..b5685e7 --- /dev/null +++ b/src/test/resources/database/categories/remove-category-from-categories-table.sql @@ -0,0 +1 @@ +DELETE FROM categories;