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;