diff --git a/src/main/java/me/jjeda/mall/cart/configs/RedisConfig.java b/src/main/java/me/jjeda/mall/cart/configs/RedisConfig.java new file mode 100644 index 0000000..6b75f94 --- /dev/null +++ b/src/main/java/me/jjeda/mall/cart/configs/RedisConfig.java @@ -0,0 +1,41 @@ +package me.jjeda.mall.cart.configs; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + @Value("${spring.redis.host}") + private String redisHost; + + @Value("${spring.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory, ObjectMapper objectMapper) { + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(serializer); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(serializer); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/me/jjeda/mall/cart/controller/CartController.java b/src/main/java/me/jjeda/mall/cart/controller/CartController.java new file mode 100644 index 0000000..e2ce21c --- /dev/null +++ b/src/main/java/me/jjeda/mall/cart/controller/CartController.java @@ -0,0 +1,42 @@ +package me.jjeda.mall.cart.controller; + +import lombok.RequiredArgsConstructor; +import me.jjeda.mall.accounts.dto.AccountDto; +import me.jjeda.mall.cart.domain.CartItem; +import me.jjeda.mall.cart.service.CartService; +import me.jjeda.mall.common.CurrentUser; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/api/carts") +public class CartController { + private final CartService cartService; + + @GetMapping + public ResponseEntity getCart(@CurrentUser AccountDto accountDto) { + return ResponseEntity.ok(cartService.getCart(String.valueOf(accountDto.getEmail()))); + } + + @PutMapping("/items/new") + public ResponseEntity addItem(@CurrentUser AccountDto accountDto, @RequestBody CartItem cartItem) { + return ResponseEntity.ok(cartService.addItem(String.valueOf(accountDto.getEmail()), cartItem)); + } + + @PutMapping("/items") + public ResponseEntity removeItem(@CurrentUser AccountDto accountDto, @RequestBody CartItem cartItem) { + return ResponseEntity.ok(cartService.removeItem(String.valueOf(accountDto.getEmail()), cartItem)); + } + + @DeleteMapping + public ResponseEntity deleteCart(@CurrentUser AccountDto accountDto) { + cartService.deleteCart(String.valueOf(accountDto.getId())); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/me/jjeda/mall/cart/domain/Cart.java b/src/main/java/me/jjeda/mall/cart/domain/Cart.java new file mode 100644 index 0000000..df7a5df --- /dev/null +++ b/src/main/java/me/jjeda/mall/cart/domain/Cart.java @@ -0,0 +1,34 @@ +package me.jjeda.mall.cart.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Builder +@RedisHash("cart") +@NoArgsConstructor(access = AccessLevel.PACKAGE) +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class Cart { + + @Id + private String id; + + private List cartItemList; + + private Cart(String id) { + this.id = id; + this.cartItemList = new ArrayList<>(); + } + + public static Cart of(String id) { + return new Cart(id); + } +} diff --git a/src/main/java/me/jjeda/mall/cart/domain/CartItem.java b/src/main/java/me/jjeda/mall/cart/domain/CartItem.java new file mode 100644 index 0000000..46cbec3 --- /dev/null +++ b/src/main/java/me/jjeda/mall/cart/domain/CartItem.java @@ -0,0 +1,18 @@ +package me.jjeda.mall.cart.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import me.jjeda.mall.items.domain.Item; + +@Getter +@EqualsAndHashCode +@Builder +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class CartItem { + private Item item; + private int price; + private int quantity; +} diff --git a/src/main/java/me/jjeda/mall/cart/repository/CartRedisRepository.java b/src/main/java/me/jjeda/mall/cart/repository/CartRedisRepository.java new file mode 100644 index 0000000..4f1df7d --- /dev/null +++ b/src/main/java/me/jjeda/mall/cart/repository/CartRedisRepository.java @@ -0,0 +1,7 @@ +package me.jjeda.mall.cart.repository; + +import me.jjeda.mall.cart.domain.Cart; +import org.springframework.data.repository.CrudRepository; + +public interface CartRedisRepository extends CrudRepository { +} diff --git a/src/main/java/me/jjeda/mall/cart/service/CartService.java b/src/main/java/me/jjeda/mall/cart/service/CartService.java new file mode 100644 index 0000000..925fda0 --- /dev/null +++ b/src/main/java/me/jjeda/mall/cart/service/CartService.java @@ -0,0 +1,83 @@ +package me.jjeda.mall.cart.service; + +import lombok.RequiredArgsConstructor; +import me.jjeda.mall.cart.domain.Cart; +import me.jjeda.mall.cart.domain.CartItem; +import me.jjeda.mall.cart.repository.CartRedisRepository; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.stereotype.Service; + +import javax.persistence.EntityNotFoundException; +import javax.transaction.Transactional; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CartService { + private final CartRedisRepository cartRedisRepository; + private final RedisTemplate redisTemplate; + + public Cart getCart(String id) { + return cartRedisRepository.findById(id).orElseThrow(EntityNotFoundException::new); + } + + public Cart addItem(String id, CartItem cartItem) { + + final String key = String.format("cart:%s", id); + // "_class", "id" 필드를 제외하고 "cartItemList[n]" 의 필드개수 6개로 나누어주면 상품개수 + int cartItemSize = (int) ((redisTemplate.opsForHash().size(key) - 2) / 6); + boolean hasKey = redisTemplate.hasKey(key); + final Map map; + + if (hasKey) { + map = convertCartItemToMap(cartItemSize, cartItem); + } else { + map = convertCartItemToMap(0, cartItem); + } + + redisTemplate.execute(new SessionCallback() { + @Override + public Object execute(RedisOperations redisOperations) throws DataAccessException { + try { + redisOperations.watch(key); + redisOperations.multi(); + redisOperations.opsForHash().putAll(key, map); + } catch (Exception e) { + redisOperations.discard(); + return null; + } + return redisOperations.exec(); + } + }); + + return getCart(id); + } + + private Map convertCartItemToMap(int size, CartItem cartItem) { + Map map = new HashMap<>(); + map.put(String.format("cartItemList.[%d].item.id", size), cartItem.getItem().getId()); + map.put(String.format("cartItemList.[%d].item.name", size), cartItem.getItem().getName()); + map.put(String.format("cartItemList.[%d].item.price", size), cartItem.getItem().getPrice()); + map.put(String.format("cartItemList.[%d].item.stockQuantity", size), cartItem.getItem().getStockQuantity()); + map.put(String.format("cartItemList.[%d].price", size), cartItem.getPrice()); + map.put(String.format("cartItemList.[%d].quantity", size), cartItem.getQuantity()); + return map; + } + + public Cart removeItem(String id, CartItem cartItem) { + Cart cart = getCart(id); + List cartItemList = cart.getCartItemList(); + cartItemList.remove(cartItem); + + return cartRedisRepository.save(cart); + } + + public void deleteCart(String id) { + cartRedisRepository.delete(getCart(id)); + } +} diff --git a/src/main/java/me/jjeda/mall/items/domain/Item.java b/src/main/java/me/jjeda/mall/items/domain/Item.java index 135887b..577d48f 100644 --- a/src/main/java/me/jjeda/mall/items/domain/Item.java +++ b/src/main/java/me/jjeda/mall/items/domain/Item.java @@ -3,6 +3,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -22,6 +23,7 @@ @Entity @Getter @Setter @Builder +@EqualsAndHashCode(exclude = "name") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED) public class Item { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2ab2656..ad3abd2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,3 @@ spring.redis.port=6379 +spring.redis.host=localhost diff --git a/src/test/java/me/jjeda/mall/cart/controller/CartControllerTest.java b/src/test/java/me/jjeda/mall/cart/controller/CartControllerTest.java new file mode 100644 index 0000000..d6fe618 --- /dev/null +++ b/src/test/java/me/jjeda/mall/cart/controller/CartControllerTest.java @@ -0,0 +1,202 @@ +package me.jjeda.mall.cart.controller; + +import me.jjeda.mall.accounts.Service.AccountService; +import me.jjeda.mall.accounts.common.BaseControllerTest; +import me.jjeda.mall.accounts.domain.AccountRole; +import me.jjeda.mall.accounts.dto.AccountDto; +import me.jjeda.mall.accounts.repository.AccountRepository; +import me.jjeda.mall.cart.domain.Cart; +import me.jjeda.mall.cart.domain.CartItem; +import me.jjeda.mall.cart.repository.CartRedisRepository; +import me.jjeda.mall.common.TestDescription; +import me.jjeda.mall.common.model.Address; +import me.jjeda.mall.items.domain.Item; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.common.util.Jackson2JsonParser; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.util.NestedServletException; + +import java.util.List; +import java.util.Set; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +public class CartControllerTest extends BaseControllerTest { + + @Autowired + private AccountService accountService; + + @Autowired + private CartRedisRepository cartRedisRepository; + + @Autowired + private AccountRepository accountRepository; + + Item item1 = Item.builder() + .id(1L) + .name("아이템1") + .price(10000) + .stockQuantity(100) + .build(); + Item item2 = Item.builder() + .id(2L) + .name("아이템2") + .price(20000) + .stockQuantity(200) + .build(); + + CartItem cartItem1 = CartItem.builder() + .item(item1) + .price(item1.getPrice() * 2) + .quantity(2) + .build(); + CartItem cartItem2 = CartItem.builder() + .item(item2) + .price(item2.getPrice() * 3) + .quantity(3) + .build(); + + @Before + public void generateAccount() { + AccountDto accountDto = AccountDto.builder() + .accountRole(Set.of(AccountRole.USER)) + .address(new Address("a", "b", "c")) + .email("jjeda@naver.com") + .nickname("jjeda") + .phone("01012341234") + .password("pass") + .build(); + accountService.saveAccount(accountDto); + } + + @After + public void setUp() { + cartRedisRepository.deleteAll(); + accountRepository.deleteAll(); + } + + private String getAccessToken() throws Exception { + ResultActions perform = this.mockMvc.perform(post("/oauth/token") + .with(httpBasic("temp", "pass")) + .param("username", "jjeda@naver.com") + .param("password", "pass") + .param("grant_type", "password")); + + var responseBody = perform.andReturn().getResponse().getContentAsString(); + Jackson2JsonParser parser = new Jackson2JsonParser(); + + return "bearer " + parser.parseMap(responseBody).get("access_token").toString(); + } + + @Test + @TestDescription("정상적으로 장바구니를 만드는 테스트") + public void createCart() throws Exception { + + mockMvc.perform(post("/api/carts") + .header(HttpHeaders.AUTHORIZATION, getAccessToken())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("id").exists()) + .andExpect(jsonPath("cartItemList").exists()); + } + + @Test + @TestDescription("정상적으로 장바구니를 불러오는는 테스트") + public void getCart() throws Exception { + + //given + Cart cart = Cart.builder() + .id("jjeda@naver.com") + .cartItemList(List.of(cartItem1, cartItem2)) + .build(); + cartRedisRepository.save(cart); + + //when & then + mockMvc.perform(get("/api/carts") + .header(HttpHeaders.AUTHORIZATION, getAccessToken())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("id").exists()) + .andExpect(jsonPath("cartItemList").exists()) + .andExpect(jsonPath("cartItemList[0].item").exists()); + } + + @Test + @TestDescription("정상적으로 장바구니에 상품을 추가하는 테스트") + public void add_item() throws Exception { + + //given + Cart cart = Cart.builder() + .id("jjeda@naver.com") + .cartItemList(List.of(cartItem1)) + .build(); + cartRedisRepository.save(cart); + + //when & then + mockMvc.perform(put("/api/carts/items/new") + .header(HttpHeaders.AUTHORIZATION, getAccessToken()) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(cartItem2))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("id").exists()) + .andExpect(jsonPath("cartItemList").exists()) + .andExpect(jsonPath("cartItemList[1].price").value(cartItem2.getPrice())) + .andExpect(jsonPath("cartItemList[1].quantity").value(cartItem2.getQuantity())); + } + + @Test + @TestDescription("정상적으로 장바구니에 상품을 제거하는 테스트") + public void remove_item() throws Exception { + + //given + Cart cart = Cart.builder() + .id("jjeda@naver.com") + .cartItemList(List.of(cartItem1,cartItem2)) + .build(); + cartRedisRepository.save(cart); + + //when & then + mockMvc.perform(put("/api/carts/items") + .header(HttpHeaders.AUTHORIZATION, getAccessToken()) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(cartItem2))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("id").exists()) + .andExpect(jsonPath("cartItemList[0]").exists()) + .andExpect(jsonPath("cartItemList[1]").doesNotExist()); + } + + @Test(expected = NestedServletException.class) + @TestDescription("정상적으로 장바구니 삭제하는 테스트") + public void delete_cart() throws Exception { + + //given + Cart cart = Cart.builder() + .id("jjeda@naver.com") + .cartItemList(List.of(cartItem1, cartItem2)) + .build(); + cartRedisRepository.save(cart); + + //when & then + mockMvc.perform(delete("/api/carts") + .header(HttpHeaders.AUTHORIZATION, getAccessToken())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/carts") + .header(HttpHeaders.AUTHORIZATION, getAccessToken())); + } +} \ No newline at end of file diff --git a/src/test/java/me/jjeda/mall/cart/service/CartServiceTest.java b/src/test/java/me/jjeda/mall/cart/service/CartServiceTest.java new file mode 100644 index 0000000..9f0cd6b --- /dev/null +++ b/src/test/java/me/jjeda/mall/cart/service/CartServiceTest.java @@ -0,0 +1,176 @@ +package me.jjeda.mall.cart.service; + +import me.jjeda.mall.cart.domain.Cart; +import me.jjeda.mall.cart.domain.CartItem; +import me.jjeda.mall.cart.repository.CartRedisRepository; +import me.jjeda.mall.common.TestDescription; +import me.jjeda.mall.items.domain.Item; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + + +@RunWith(MockitoJUnitRunner.class) +public class CartServiceTest { + + @InjectMocks + private CartService cartService; + @Mock + private CartRedisRepository cartRedisRepository; + + @Test + @TestDescription("정상적으로 장바구니 생성하는 테스트") + public void initCart_success() { + //when + Cart cart = cartService.initCart("jjeda"); + + //then + assertThat(cart.getId()).isEqualTo("jjeda"); + assertThat(cart.getCartItemList().size()).isEqualTo(0); + } + + @Test + @TestDescription("장바구니가 이미 만들어져 있을 때 불러오는 테스트") + public void initCart_existed_cart() { + //given + Cart newCart = Cart.of("jjeda"); + List list = newCart.getCartItemList(); + CartItem cartItem = CartItem.builder().build(); + list.add(cartItem); + + given(cartRedisRepository.existsById("jjeda")).willReturn(Boolean.TRUE); + given(cartRedisRepository.findById("jjeda")).willReturn(Optional.of(newCart)); + + //when + Cart cart = cartService.initCart("jjeda"); + + //then + assertThat(cart.getId()).isEqualTo("jjeda"); + assertThat(cart.getCartItemList().get(0)).isEqualTo(cartItem); + } + + @Test + @TestDescription("장바구니에 상품을 추가하는 테스트") + public void add_item() { + Cart newCart = Cart.of("jjeda"); + CartItem cartItem = CartItem.builder().build(); + given(cartRedisRepository.findById("jjeda")).willReturn(Optional.of(newCart)); + + //when + cartService.addItem("jjeda", cartItem); + + //then + assertThat(newCart.getCartItemList().get(0)).isEqualTo(cartItem); + + } + + @Test + @TestDescription("장바구니에 상품을 제거하는 테스트") + public void remove_item_success() { + //given + Cart newCart = Cart.of("jjeda"); + List list = newCart.getCartItemList(); + Item item = Item.builder() + .id(1L) + .stockQuantity(100) + .price(5000) + .name("아이템") + .build(); + CartItem cartItem = CartItem.builder() + .item(item) + .price(item.getPrice() * 2) + .quantity(2) + .build(); + list.add(cartItem); + given(cartRedisRepository.findById("jjeda")).willReturn(Optional.of(newCart)); + + // 상품, 개수, 가격이 모두 일치해야 같은 상품으로 취급 + CartItem newCartItem = CartItem.builder() + .item(item) + .quantity(2) + .price(10000) + .build(); + + //when + cartService.removeItem("jjeda", newCartItem); + + //then + assertThat(newCart.getCartItemList().size()).isEqualTo(0); + } + + @Test + @TestDescription("같은 상품이라도 장바구니 정보와 일치하지 않으면 제거되지 않는 테스트1") + public void remove_item_fail1() { + //given + Cart newCart = Cart.of("jjeda"); + List list = newCart.getCartItemList(); + Item item = Item.builder() + .id(1L) + .stockQuantity(100) + .price(5000) + .name("아이템") + .build(); + CartItem cartItem = CartItem.builder() + .item(item) + .price(item.getPrice() * 2) + .quantity(2) + .build(); + list.add(cartItem); + given(cartRedisRepository.findById("jjeda")).willReturn(Optional.of(newCart)); + + // 상품, 개수, 가격이 모두 일치해야 같은 상품으로 취급 + CartItem newCartItem = CartItem.builder() + .item(item) + .quantity(1) //수량이 다르면 + .price(10000) + .build(); + + //when + cartService.removeItem("jjeda", newCartItem); + + //then + assertThat(newCart.getCartItemList().size()).isNotEqualTo(0); + } + + @Test + @TestDescription("같은 상품이라도 장바구니 정보와 일치하지 않으면 제거되지 않는 테스트2") + public void remove_item_fail2() { + //given + Cart newCart = Cart.of("jjeda"); + List list = newCart.getCartItemList(); + Item item = Item.builder() + .id(1L) + .stockQuantity(100) + .price(5000) + .name("아이템") + .build(); + CartItem cartItem = CartItem.builder() + .item(item) + .price(item.getPrice() * 2) + .quantity(2) + .build(); + list.add(cartItem); + given(cartRedisRepository.findById("jjeda")).willReturn(Optional.of(newCart)); + + // 상품, 개수, 가격이 모두 일치해야 같은 상품으로 취급 + CartItem newCartItem = CartItem.builder() + .item(item) + .quantity(2) + .price(20000) //가격이 다르면 + .build(); + + //when + cartService.removeItem("jjeda", newCartItem); + + //then + assertThat(newCart.getCartItemList().size()).isNotEqualTo(0); + } +} \ No newline at end of file