Skip to content

Commit

Permalink
fix: ✏️ 사용자 삭제 로직 추가 (#143)
Browse files Browse the repository at this point in the history
* rename: oauth bulk delete 메서드 컨벤션에 맞게 in_query 접미사 추가

* feat: 사용자 아이디 기반 spending 삭제 bulk 메서드 추가

* feat: 사용자 아이디 기반 spending_custom_category 삭제 bulk 메서드 추가

* fix: 사용자 삭제 서비스 내에서 지출 데이터 삭제 로직 추가

* feat: device_token delete bulk 메서드 추가

* fix: device_token_service 사용하지 않는 메서드 제거

* fix: 사용자 삭제 시, device token 비활성화 추가

* test: 사용자 삭제 테스트 시 디바이스 삭제 -> 비활성화 테스트로 수정

* fix: user_id로 device_token 조회 시, join되는 문제 제거

* test: spending 삭제 테스트

* fix: 불필요한 delete_at is null 옵션 제거 (이미 sql_restriction으로 쿼리에 반영됨)
  • Loading branch information
psychology50 authored Aug 4, 2024
1 parent 5566c29 commit 100ad7c
Show file tree
Hide file tree
Showing 10 changed files with 148 additions and 51 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package kr.co.pennyway.api.apis.users.service;

import kr.co.pennyway.domain.domains.device.service.DeviceTokenService;
import kr.co.pennyway.domain.domains.oauth.service.OauthService;
import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService;
import kr.co.pennyway.domain.domains.spending.service.SpendingService;
import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
import kr.co.pennyway.domain.domains.user.exception.UserErrorException;
import kr.co.pennyway.domain.domains.user.service.UserService;
Expand All @@ -22,14 +25,31 @@
public class UserDeleteService {
private final UserService userService;
private final OauthService oauthService;
private final DeviceTokenService deviceTokenService;

private final SpendingService spendingService;
private final SpendingCustomCategoryService spendingCustomCategoryService;

/**
* 사용자와 관련한 모든 데이터를 삭제(soft delete)하는 메서드
* <p>
* hard delete가 수행되어야 할 데이터는 삭제하지 않으며, 사용자 데이터 유지 기간이 만료될 때 DBA가 수행한다.
*
* @param userId
* @todo [2024-05-03] 채팅 기능이 추가되는 경우 채팅방장 탈퇴를 제한해야 하며, 추가로 삭제될 엔티티 삭제 로직을 추가해야 한다.
*/
@Transactional
public void execute(Long userId) {
if (!userService.isExistUser(userId)) throw new UserErrorException(UserErrorCode.NOT_FOUND);

// TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리

oauthService.deleteOauthsByUserId(userId);

oauthService.deleteOauthsByUserIdInQuery(userId);
deviceTokenService.deleteDevicesByUserIdInQuery(userId);

spendingService.deleteSpendingsByUserIdInQuery(userId);
spendingCustomCategoryService.deleteSpendingCustomCategoriesByUserIdInQuery(userId);

userService.deleteUser(userId);
}
}
Original file line number Diff line number Diff line change
@@ -1,40 +1,48 @@
package kr.co.pennyway.api.apis.users.usecase;

import com.querydsl.jpa.impl.JPAQueryFactory;
import kr.co.pennyway.api.apis.users.service.UserDeleteService;
import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
import kr.co.pennyway.api.config.TestJpaConfig;
import kr.co.pennyway.api.config.fixture.DeviceTokenFixture;
import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture;
import kr.co.pennyway.api.config.fixture.SpendingFixture;
import kr.co.pennyway.api.config.fixture.UserFixture;
import kr.co.pennyway.domain.config.JpaConfig;
import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
import kr.co.pennyway.domain.domains.device.service.DeviceTokenService;
import kr.co.pennyway.domain.domains.oauth.domain.Oauth;
import kr.co.pennyway.domain.domains.oauth.service.OauthService;
import kr.co.pennyway.domain.domains.oauth.type.Provider;
import kr.co.pennyway.domain.domains.spending.domain.Spending;
import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory;
import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService;
import kr.co.pennyway.domain.domains.spending.service.SpendingService;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
import kr.co.pennyway.domain.domains.user.exception.UserErrorException;
import kr.co.pennyway.domain.domains.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
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.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.util.AssertionErrors.assertEquals;
import static org.springframework.test.util.AssertionErrors.assertTrue;
import static org.springframework.test.util.AssertionErrors.*;

@Slf4j
@ExtendWith(MockitoExtension.class)
@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create")
@ContextConfiguration(classes = {JpaConfig.class, UserDeleteService.class, UserService.class, OauthService.class, DeviceTokenService.class})
@ContextConfiguration(classes = {JpaConfig.class, UserDeleteService.class, UserService.class, OauthService.class, DeviceTokenService.class, SpendingService.class, SpendingCustomCategoryService.class})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(TestJpaConfig.class)
public class UserDeleteServiceTest extends ExternalApiDBTestConfig {
@Autowired
private UserService userService;
Expand All @@ -48,8 +56,11 @@ public class UserDeleteServiceTest extends ExternalApiDBTestConfig {
@Autowired
private UserDeleteService userDeleteService;

@MockBean
private JPAQueryFactory queryFactory;
@Autowired
private SpendingService spendingService;

@Autowired
private SpendingCustomCategoryService spendingCustomCategoryService;

@Test
@Transactional
Expand Down Expand Up @@ -90,14 +101,15 @@ void deleteAccountWithSocialAccounts() {

// when - then
assertDoesNotThrow(() -> userDeleteService.execute(user.getId()));

assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty());
assertTrue("카카오 계정이 삭제되어 있어야 한다.", oauthService.readOauth(kakao.getId()).get().isDeleted());
assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted());
}

@Test
@Transactional
@DisplayName("사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다.")
@DisplayName("사용자 삭제 시, 디바이스 정보는 비활성화되어야 한다.")
void deleteAccountWithDevices() {
// given
User user = UserFixture.GENERAL_USER.toUser();
Expand All @@ -109,7 +121,27 @@ void deleteAccountWithDevices() {
// when - then
assertDoesNotThrow(() -> userDeleteService.execute(user.getId()));
assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty());
assertTrue("디바이스가 삭제되어 있어야 한다.", deviceTokenService.readDeviceByUserIdAndToken(user.getId(), deviceToken.getToken()).isEmpty());
assertFalse("디바이스가 비활성화 있어야 한다.", deviceTokenService.readDeviceByUserIdAndToken(user.getId(), deviceToken.getToken()).get().getActivated());
}

@Test
@Transactional
@DisplayName("사용자가 등록한 지출 정보는 삭제되어야 한다.")
void deleteAccountWithSpending() {
// given
User user = userService.createUser(UserFixture.GENERAL_USER.toUser());

SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user));

Spending spending1 = spendingService.createSpending(SpendingFixture.GENERAL_SPENDING.toSpending(user));
Spending spending2 = spendingService.createSpending(SpendingFixture.CUSTOM_CATEGORY_SPENDING.toCustomCategorySpending(user, category));
Spending spending3 = spendingService.createSpending(SpendingFixture.MAX_SPENDING.toSpending(user));

// when - then
assertDoesNotThrow(() -> userDeleteService.execute(user.getId()));
assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty());
assertTrue("지출 정보가 삭제되어 있어야 한다.", spendingService.readSpendings(user.getId(), spending1.getSpendAt().getYear(), spending1.getSpendAt().getMonthValue()).isEmpty());
assertTrue("지출 카테고리가 삭제되어 있어야 한다.", spendingCustomCategoryService.readSpendingCustomCategory(category.getId()).isEmpty());
}

private Oauth createOauth(Provider provider, String providerId, User user) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.co.pennyway.api.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

@TestConfiguration
public class TestJpaConfig {
@PersistenceContext
private EntityManager em;

@Bean
@ConditionalOnMissingBean
public JPAQueryFactory testJpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@

import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

public interface DeviceTokenRepository extends JpaRepository<DeviceToken, Long> {
@Query("SELECT d FROM DeviceToken d WHERE d.user.id = :userId AND d.token = :token")
Optional<DeviceToken> findByUser_IdAndToken(Long userId, String token);

List<DeviceToken> findAllByUser_Id(Long userId);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE DeviceToken d SET d.activated = false WHERE d.user.id = :userId")
void deleteAllByUserIdInQuery(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Slf4j
Expand All @@ -26,18 +25,13 @@ public Optional<DeviceToken> readDeviceByUserIdAndToken(Long userId, String toke
return deviceTokenRepository.findByUser_IdAndToken(userId, token);
}

@Transactional(readOnly = true)
public List<DeviceToken> readDevicesByUserId(Long userId) {
return deviceTokenRepository.findAllByUser_Id(userId);
}

@Transactional
public void deleteDevice(Long deviceId) {
deviceTokenRepository.deleteById(deviceId);
public void deleteDevice(DeviceToken deviceToken) {
deviceTokenRepository.delete(deviceToken);
}

@Transactional
public void deleteDevice(DeviceToken deviceToken) {
deviceTokenRepository.delete(deviceToken);
public void deleteDevicesByUserIdInQuery(Long userId) {
deviceTokenRepository.deleteAllByUserIdInQuery(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public void deleteOauth(Oauth oauth) {
}

@Transactional
public void deleteOauthsByUserId(Long userId) {
public void deleteOauthsByUserIdInQuery(Long userId) {
oauthRepository.deleteAllByUser_IdAndDeletedAtNullInQuery(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
Expand All @@ -12,4 +14,9 @@ public interface SpendingCustomCategoryRepository extends JpaRepository<Spending

@Transactional(readOnly = true)
List<SpendingCustomCategory> findAllByUser_Id(Long userId);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE SpendingCustomCategory s SET s.deletedAt = NOW() WHERE s.user.id = :userId")
void deleteAllByUserIdInQuery(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ public interface SpendingRepository extends ExtendedRepository<Spending, Long>,
@Transactional(readOnly = true)
boolean existsByIdAndUser_Id(Long id, Long userId);

@Transactional
@Modifying(clearAutomatically = true)
@Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId AND s.deletedAt IS NULL")
void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId);

@Transactional(readOnly = true)
int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId);

Expand All @@ -29,26 +24,36 @@ public interface SpendingRepository extends ExtendedRepository<Spending, Long>,

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds AND s.deletedAt IS NULL")
void deleteAllByIdAndDeletedAtNullInQuery(List<Long> spendingIds);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory AND s.deletedAt IS NULL")
@Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory")
void updateCategoryByCustomCategoryInQuery(SpendingCategory fromCategory, Long toCategoryId, SpendingCategory custom);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory AND s.deletedAt IS NULL")
@Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory")
void updateCategoryByCategoryInQuery(SpendingCategory fromCategory, SpendingCategory toCategory);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId AND s.deletedAt IS NULL")
@Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId")
void updateCustomCategoryByCustomCategoryInQuery(Long fromCategoryId, Long toCategoryId);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId AND s.deletedAt IS NULL")
@Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId")
void updateCustomCategoryByCategoryInQuery(Long fromCategoryId, SpendingCategory toCategory);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds")
void deleteAllByIdAndDeletedAtNullInQuery(List<Long> spendingIds);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.user.id = :userId")
void deleteAllByUserIdInQuery(Long userId);

@Transactional
@Modifying(clearAutomatically = true)
@Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId")
void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,9 @@ public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) {
public void deleteSpendingCustomCategory(Long categoryId) {
spendingCustomCategoryRepository.deleteById(categoryId);
}

@Transactional
public void deleteSpendingCustomCategoriesByUserIdInQuery(Long userId) {
spendingCustomCategoryRepository.deleteAllByUserIdInQuery(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,26 +123,11 @@ public List<TotalSpendingAmount> readTotalSpendingsAmountByUserId(Long userId) {
return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort);
}

@Transactional
public void deleteSpendingsByCategoryIdInQuery(Long categoryId) {
spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId);
}

@Transactional(readOnly = true)
public boolean isExistsSpending(Long userId, Long spendingId) {
return spendingRepository.existsByIdAndUser_Id(spendingId, userId);
}

@Transactional
public void deleteSpending(Spending spending) {
spendingRepository.delete(spending);
}

@Transactional
public void deleteSpendingsInQuery(List<Long> spendingIds) {
spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds);
}

@Transactional(readOnly = true)
public long countByUserIdAndIdIn(Long userId, List<Long> spendingIds) {
return spendingRepository.countByUserIdAndIdIn(userId, spendingIds);
Expand All @@ -169,4 +154,24 @@ public void updateCustomCategoryByCustomCategory(Long fromId, Long toId) {
public void updateCustomCategoryByCategory(Long fromId, SpendingCategory toCategory) {
spendingRepository.updateCustomCategoryByCategoryInQuery(fromId, toCategory);
}

@Transactional
public void deleteSpending(Spending spending) {
spendingRepository.delete(spending);
}

@Transactional
public void deleteSpendingsInQuery(List<Long> spendingIds) {
spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds);
}

@Transactional
public void deleteSpendingsByUserIdInQuery(Long userId) {
spendingRepository.deleteAllByUserIdInQuery(userId);
}

@Transactional
public void deleteSpendingsByCategoryIdInQuery(Long categoryId) {
spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId);
}
}

0 comments on commit 100ad7c

Please sign in to comment.