diff --git a/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java b/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java index 6cfd0ff..f6d11c6 100644 --- a/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java +++ b/src/main/java/com/seong/shoutlink/domain/auth/service/AuthService.java @@ -2,19 +2,22 @@ import com.seong.shoutlink.domain.auth.JwtProvider; import com.seong.shoutlink.domain.auth.PasswordEncoder; +import com.seong.shoutlink.domain.auth.service.event.CreateMemberEvent; import com.seong.shoutlink.domain.auth.service.request.CreateMemberCommand; import com.seong.shoutlink.domain.auth.service.request.LoginCommand; import com.seong.shoutlink.domain.auth.service.response.CreateMemberResponse; import com.seong.shoutlink.domain.auth.service.response.LoginResponse; import com.seong.shoutlink.domain.auth.service.response.TokenResponse; +import com.seong.shoutlink.domain.common.EventPublisher; +import com.seong.shoutlink.domain.exception.ErrorCode; +import com.seong.shoutlink.domain.exception.ShoutLinkException; import com.seong.shoutlink.domain.member.Member; import com.seong.shoutlink.domain.member.MemberRole; import com.seong.shoutlink.domain.member.service.MemberRepository; -import com.seong.shoutlink.domain.exception.ErrorCode; -import com.seong.shoutlink.domain.exception.ShoutLinkException; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -26,6 +29,7 @@ public class AuthService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; private final JwtProvider jwtProvider; + private final EventPublisher eventPublisher; private void validatePassword(CreateMemberCommand command) { if(!PASSWORD_PATTEN.matcher(command.password()).matches()) { @@ -34,6 +38,7 @@ private void validatePassword(CreateMemberCommand command) { } } + @Transactional public CreateMemberResponse createMember(CreateMemberCommand command) { validatePassword(command); memberRepository.findByEmail(command.email()) @@ -49,7 +54,9 @@ public CreateMemberResponse createMember(CreateMemberCommand command) { passwordEncoder.encode(command.password()), command.nickname(), MemberRole.ROLE_USER); - return new CreateMemberResponse(memberRepository.save(member)); + Long memberId = memberRepository.save(member); + eventPublisher.publishEvent(new CreateMemberEvent(memberId)); + return new CreateMemberResponse(memberId); } public LoginResponse login(LoginCommand command) { diff --git a/src/main/java/com/seong/shoutlink/domain/auth/service/event/CreateMemberEvent.java b/src/main/java/com/seong/shoutlink/domain/auth/service/event/CreateMemberEvent.java new file mode 100644 index 0000000..ffbb7a8 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/auth/service/event/CreateMemberEvent.java @@ -0,0 +1,7 @@ +package com.seong.shoutlink.domain.auth.service.event; + +import com.seong.shoutlink.domain.common.Event; + +public record CreateMemberEvent(Long memberId) implements Event { + +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/LinkBundle.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/LinkBundle.java new file mode 100644 index 0000000..ea8e4d5 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/LinkBundle.java @@ -0,0 +1,19 @@ +package com.seong.shoutlink.domain.linkbundle; + +import com.seong.shoutlink.domain.member.Member; +import lombok.Getter; + +@Getter +public class LinkBundle { + + private Long linkBundleId; + private String description; + private boolean isDefault; + private Member member; + + public LinkBundle(String description, boolean isDefault, Member member) { + this.description = description; + this.isDefault = isDefault; + this.member = member; + } +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleEntity.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleEntity.java new file mode 100644 index 0000000..3a7e3ec --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleEntity.java @@ -0,0 +1,43 @@ +package com.seong.shoutlink.domain.linkbundle.repository; + +import com.seong.shoutlink.domain.linkbundle.LinkBundle; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LinkBundleEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long linkBundleId; + + private String description; + private boolean isDefault; + private Long memberId; + + private LinkBundleEntity( + Long linkBundleId, + String description, + boolean isDefault, + Long memberId) { + this.linkBundleId = linkBundleId; + this.description = description; + this.isDefault = isDefault; + this.memberId = memberId; + } + + public static LinkBundleEntity create(LinkBundle linkBundle) { + return new LinkBundleEntity( + linkBundle.getLinkBundleId(), + linkBundle.getDescription(), + linkBundle.isDefault(), + linkBundle.getMember().getMemberId()); + } +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleJpaRepository.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleJpaRepository.java new file mode 100644 index 0000000..b346769 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleJpaRepository.java @@ -0,0 +1,7 @@ +package com.seong.shoutlink.domain.linkbundle.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LinkBundleJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleRepositoryImpl.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleRepositoryImpl.java new file mode 100644 index 0000000..c42d1fb --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/repository/LinkBundleRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.seong.shoutlink.domain.linkbundle.repository; + +import com.seong.shoutlink.domain.linkbundle.LinkBundle; +import com.seong.shoutlink.domain.linkbundle.service.LinkBundleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LinkBundleRepositoryImpl implements LinkBundleRepository { + + private final LinkBundleJpaRepository linkBundleJpaRepository; + + @Override + public Long save(LinkBundle linkBundle) { + LinkBundleEntity linkBundleEntity = LinkBundleEntity.create(linkBundle); + linkBundleJpaRepository.save(linkBundleEntity); + return linkBundleEntity.getLinkBundleId(); + } +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleRepository.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleRepository.java new file mode 100644 index 0000000..2961404 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleRepository.java @@ -0,0 +1,8 @@ +package com.seong.shoutlink.domain.linkbundle.service; + +import com.seong.shoutlink.domain.linkbundle.LinkBundle; + +public interface LinkBundleRepository { + + Long save(LinkBundle linkBundle); +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleService.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleService.java new file mode 100644 index 0000000..c830da8 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleService.java @@ -0,0 +1,28 @@ +package com.seong.shoutlink.domain.linkbundle.service; + +import com.seong.shoutlink.domain.exception.ErrorCode; +import com.seong.shoutlink.domain.exception.ShoutLinkException; +import com.seong.shoutlink.domain.linkbundle.LinkBundle; +import com.seong.shoutlink.domain.linkbundle.service.response.CreateLinkBundleCommand; +import com.seong.shoutlink.domain.linkbundle.service.response.CreateLinkBundleResponse; +import com.seong.shoutlink.domain.member.Member; +import com.seong.shoutlink.domain.member.service.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LinkBundleService { + + private final MemberRepository memberRepository; + private final LinkBundleRepository linkBundleRepository; + + @Transactional + public CreateLinkBundleResponse createLinkBundle(CreateLinkBundleCommand command) { + Member member = memberRepository.findById(command.memberId()) + .orElseThrow(() -> new ShoutLinkException("존재하지 않는 사용자입니다.", ErrorCode.NOT_FOUND)); + LinkBundle linkBundle = new LinkBundle(command.description(), command.isDefault(), member); + return new CreateLinkBundleResponse(linkBundleRepository.save(linkBundle)); + } +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/service/response/CreateLinkBundleCommand.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/response/CreateLinkBundleCommand.java new file mode 100644 index 0000000..3d7ef6f --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/response/CreateLinkBundleCommand.java @@ -0,0 +1,5 @@ +package com.seong.shoutlink.domain.linkbundle.service.response; + +public record CreateLinkBundleCommand(Long memberId, String description, boolean isDefault) { + +} diff --git a/src/main/java/com/seong/shoutlink/domain/linkbundle/service/response/CreateLinkBundleResponse.java b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/response/CreateLinkBundleResponse.java new file mode 100644 index 0000000..a3551a3 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/linkbundle/service/response/CreateLinkBundleResponse.java @@ -0,0 +1,5 @@ +package com.seong.shoutlink.domain.linkbundle.service.response; + +public record CreateLinkBundleResponse(Long linkBundleId) { + +} diff --git a/src/main/java/com/seong/shoutlink/global/config/EventConfig.java b/src/main/java/com/seong/shoutlink/global/config/EventConfig.java index cb5f9dc..4684613 100644 --- a/src/main/java/com/seong/shoutlink/global/config/EventConfig.java +++ b/src/main/java/com/seong/shoutlink/global/config/EventConfig.java @@ -1,6 +1,8 @@ package com.seong.shoutlink.global.config; import com.seong.shoutlink.domain.common.EventPublisher; +import com.seong.shoutlink.domain.linkbundle.service.LinkBundleService; +import com.seong.shoutlink.global.event.SpringEventListener; import com.seong.shoutlink.global.event.SpringEventPublisher; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; @@ -13,4 +15,9 @@ public class EventConfig { public EventPublisher eventPublisher(ApplicationEventPublisher applicationEventPublisher) { return new SpringEventPublisher(applicationEventPublisher); } + + @Bean + public SpringEventListener springEventListener(LinkBundleService linkBundleService) { + return new SpringEventListener(linkBundleService); + } } diff --git a/src/main/java/com/seong/shoutlink/global/event/SpringEventListener.java b/src/main/java/com/seong/shoutlink/global/event/SpringEventListener.java new file mode 100644 index 0000000..e8fe60d --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/event/SpringEventListener.java @@ -0,0 +1,26 @@ +package com.seong.shoutlink.global.event; + +import com.seong.shoutlink.domain.auth.service.event.CreateMemberEvent; +import com.seong.shoutlink.domain.linkbundle.service.LinkBundleService; +import com.seong.shoutlink.domain.linkbundle.service.response.CreateLinkBundleCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +public class SpringEventListener { + + private static final String DEFAULT_LINK_BUNDLE = "기본"; + + private final LinkBundleService linkBundleService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void createDefaultLinkBundle(CreateMemberEvent event) { + CreateLinkBundleCommand command + = new CreateLinkBundleCommand(event.memberId(), DEFAULT_LINK_BUNDLE, true); + linkBundleService.createLinkBundle(command); + } +} diff --git a/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java b/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java index c34f681..31d52c1 100644 --- a/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/seong/shoutlink/domain/auth/service/AuthServiceTest.java @@ -9,12 +9,13 @@ import com.seong.shoutlink.domain.auth.service.request.LoginCommand; import com.seong.shoutlink.domain.auth.service.response.CreateMemberResponse; import com.seong.shoutlink.domain.auth.service.response.LoginResponse; +import com.seong.shoutlink.domain.common.StubEventPublisher; +import com.seong.shoutlink.domain.exception.ErrorCode; +import com.seong.shoutlink.domain.exception.ShoutLinkException; import com.seong.shoutlink.domain.member.Member; import com.seong.shoutlink.domain.member.MemberRole; import com.seong.shoutlink.domain.member.repository.StubMemberRepository; import com.seong.shoutlink.global.auth.jwt.JJwtProvider; -import com.seong.shoutlink.domain.exception.ErrorCode; -import com.seong.shoutlink.domain.exception.ShoutLinkException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -37,6 +38,7 @@ class CreateMemberTest { Member savedMember; StubMemberRepository memberRepository; AuthService authService; + StubEventPublisher eventPublisher; @BeforeEach void setUp() { @@ -46,7 +48,12 @@ void setUp() { MemberRole memberRole = MemberRole.ROLE_USER; savedMember = new Member(email, password, nickname, memberRole); memberRepository = new StubMemberRepository(savedMember); - authService = new AuthService(memberRepository, passwordEncoder, jwtProvider); + eventPublisher = new StubEventPublisher(); + authService = new AuthService( + memberRepository, + passwordEncoder, + jwtProvider, + eventPublisher); } @ParameterizedTest @@ -121,6 +128,20 @@ void duplicateNickname_WhenNicknameIsDuplicate() { .extracting(e -> ((ShoutLinkException) e).getErrorCode()) .isEqualTo(ErrorCode.DUPLICATE_NICKNAME); } + + @Test + @DisplayName("성공: 회원 생성 이벤트 발행함") + void publishCreateMemberEvent() { + //given + CreateMemberCommand command + = new CreateMemberCommand("email@email.com", "asdf123!", "nickname"); + + //when + CreateMemberResponse response = authService.createMember(command); + + //then + assertThat(eventPublisher.getPublishEventCount()).isEqualTo(1); + } } @Nested @@ -131,6 +152,7 @@ class LoginTest { Member savedMember; StubMemberRepository memberRepository; AuthService authService; + StubEventPublisher eventPublisher; @BeforeEach void setUp() { @@ -141,7 +163,12 @@ void setUp() { MemberRole memberRole = MemberRole.ROLE_USER; savedMember = new Member(1L, email, password, nickname, memberRole); memberRepository = new StubMemberRepository(savedMember); - authService = new AuthService(memberRepository, passwordEncoder, jwtProvider); + eventPublisher = new StubEventPublisher(); + authService = new AuthService( + memberRepository, + passwordEncoder, + jwtProvider, + eventPublisher); } @Test diff --git a/src/test/java/com/seong/shoutlink/domain/linkbundle/repository/FakeLinkBundleRepository.java b/src/test/java/com/seong/shoutlink/domain/linkbundle/repository/FakeLinkBundleRepository.java new file mode 100644 index 0000000..afe49e0 --- /dev/null +++ b/src/test/java/com/seong/shoutlink/domain/linkbundle/repository/FakeLinkBundleRepository.java @@ -0,0 +1,22 @@ +package com.seong.shoutlink.domain.linkbundle.repository; + +import com.seong.shoutlink.domain.linkbundle.LinkBundle; +import com.seong.shoutlink.domain.linkbundle.service.LinkBundleRepository; +import java.util.HashMap; +import java.util.Map; + +public class FakeLinkBundleRepository implements LinkBundleRepository { + + private final Map memory = new HashMap<>(); + + @Override + public Long save(LinkBundle linkBundle) { + long nextId = getNextId(); + memory.put(nextId, linkBundle); + return nextId; + } + + private long getNextId() { + return memory.keySet().size() + 1; + } +} diff --git a/src/test/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleServiceTest.java b/src/test/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleServiceTest.java new file mode 100644 index 0000000..27dafa1 --- /dev/null +++ b/src/test/java/com/seong/shoutlink/domain/linkbundle/service/LinkBundleServiceTest.java @@ -0,0 +1,74 @@ +package com.seong.shoutlink.domain.linkbundle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchException; + +import com.seong.shoutlink.domain.exception.ErrorCode; +import com.seong.shoutlink.domain.exception.ShoutLinkException; +import com.seong.shoutlink.domain.linkbundle.repository.FakeLinkBundleRepository; +import com.seong.shoutlink.domain.linkbundle.service.response.CreateLinkBundleCommand; +import com.seong.shoutlink.domain.linkbundle.service.response.CreateLinkBundleResponse; +import com.seong.shoutlink.domain.member.Member; +import com.seong.shoutlink.domain.member.repository.StubMemberRepository; +import com.seong.shoutlink.fixture.MemberFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class LinkBundleServiceTest { + + @Nested + @DisplayName("createLinkBundle 메서드 호출 시") + class CreateLinkBundleTest { + + private Member savedMember; + private LinkBundleService linkBundleService; + private StubMemberRepository memberRepository; + private FakeLinkBundleRepository linkBundleRepository; + + @BeforeEach + void setUp() { + savedMember = MemberFixture.member(); + memberRepository = new StubMemberRepository(savedMember); + linkBundleRepository = new FakeLinkBundleRepository(); + linkBundleService = new LinkBundleService(memberRepository, linkBundleRepository); + } + + @Test + @DisplayName("성공: 링크 번들 생성됨") + void createLinkBundle() { + //given + CreateLinkBundleCommand command = new CreateLinkBundleCommand( + savedMember.getMemberId(), + "간단한 설명", + true); + + //when + CreateLinkBundleResponse response = linkBundleService.createLinkBundle(command); + + //then + assertThat(response.linkBundleId()).isEqualTo(1); + } + + @Test + @DisplayName("예외(NotFound): 존재하지 않는 사용자") + void notFound_WhenMemberNotFound() { + //given + Long notFoundMember = savedMember.getMemberId() + 1; + CreateLinkBundleCommand command = new CreateLinkBundleCommand( + notFoundMember, + "간단한 설명", + true); + + //when + Exception exception = catchException(() -> linkBundleService.createLinkBundle(command)); + + //then + assertThat(exception) + .isInstanceOf(ShoutLinkException.class) + .extracting(e -> ((ShoutLinkException) e).getErrorCode()) + .isEqualTo(ErrorCode.NOT_FOUND); + } + } +} diff --git a/src/test/java/com/seong/shoutlink/global/event/SpringEventListenerTest.java b/src/test/java/com/seong/shoutlink/global/event/SpringEventListenerTest.java new file mode 100644 index 0000000..57e01ed --- /dev/null +++ b/src/test/java/com/seong/shoutlink/global/event/SpringEventListenerTest.java @@ -0,0 +1,54 @@ +package com.seong.shoutlink.global.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.seong.shoutlink.domain.auth.service.AuthService; +import com.seong.shoutlink.domain.auth.service.request.CreateMemberCommand; +import com.seong.shoutlink.domain.linkbundle.repository.LinkBundleEntity; +import com.seong.shoutlink.domain.member.Member; +import com.seong.shoutlink.fixture.MemberFixture; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.RecordApplicationEvents; + +@SpringBootTest +@RecordApplicationEvents +class SpringEventListenerTest { + + @Autowired + private AuthService authService; + + @Autowired + private EntityManager em; + + @Nested + @DisplayName("createMemberEvent 수신 시") + class CreateMemberEventTest { + + @Test + @DisplayName("성공: 기본 링크 번들 생성됨") + void createDefaultLinkBundle() { + //given + Member member = MemberFixture.member(); + CreateMemberCommand createMemberCommand = new CreateMemberCommand( + member.getEmail(), + member.getPassword(), + member.getNickname()); + + //when + authService.createMember(createMemberCommand); + + //then + LinkBundleEntity linkBundleEntity = em.createQuery( + "select lb from LinkBundleEntity lb where lb.memberId = :memberId", + LinkBundleEntity.class) + .setParameter("memberId", member.getMemberId()) + .getSingleResult(); + assertThat(linkBundleEntity.getDescription()).isEqualTo("기본"); + } + } +}