diff --git a/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java new file mode 100644 index 000000000..7a902f170 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java @@ -0,0 +1,61 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.application.port.out.ClubQueryPort; +import com.kustacks.kuring.club.application.port.out.ClubSubscriptionCommandPort; +import com.kustacks.kuring.club.application.port.out.ClubSubscriptionQueryPort; +import com.kustacks.kuring.club.domain.Club; +import com.kustacks.kuring.club.domain.ClubSubscribe; +import com.kustacks.kuring.common.annotation.PersistenceAdapter; +import com.kustacks.kuring.user.domain.RootUser; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@PersistenceAdapter +@RequiredArgsConstructor +public class ClubPersistenceAdapter implements ClubQueryPort, ClubSubscriptionCommandPort, ClubSubscriptionQueryPort { + + private final ClubRepository clubRepository; + private final ClubSubscribeRepository clubSubscribeRepository; + + @Override + public Optional findClubById(Long id) { + return clubRepository.findById(id); + } + + @Override + public List findClubsBetweenDates(LocalDateTime start, LocalDateTime end) { + return clubRepository.findClubsBetweenDates(start, end); + } + + @Override + public List findNextDayRecruitEndClubs(LocalDateTime now) { + LocalDate tomorrow = now.toLocalDate().plusDays(1); + LocalDateTime startInclusive = tomorrow.atStartOfDay(); + LocalDateTime endExclusive = tomorrow.plusDays(1).atStartOfDay(); + return findClubsBetweenDates(startInclusive, endExclusive); + } + + @Override + public boolean existsSubscription(Long rootUserId, Long clubId) { + return clubSubscribeRepository.existsByRootUserIdAndClubId(rootUserId, clubId); + } + + @Override + public void saveSubscription(RootUser rootUser, Club club) { + clubSubscribeRepository.save(new ClubSubscribe(rootUser, club)); + } + + @Override + public void deleteSubscription(RootUser rootUser, Club club) { + clubSubscribeRepository.deleteByRootUserAndClub(rootUser, club); + } + + @Override + public long countSubscriptions(Long rootUserId) { + return clubSubscribeRepository.countByRootUserId(rootUserId); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepository.java b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepository.java new file mode 100644 index 000000000..e358e0f83 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepository.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.domain.Club; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ClubQueryRepository { + + List findClubsBetweenDates(LocalDateTime start, LocalDateTime end); +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java new file mode 100644 index 000000000..a3c80587a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.domain.Club; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.kustacks.kuring.club.domain.QClub.club; + +@RequiredArgsConstructor +class ClubQueryRepositoryImpl implements ClubQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findClubsBetweenDates(LocalDateTime start, LocalDateTime end) { + return queryFactory.selectFrom(club) + .where( + club.isAlways.isFalse(), + recruitEndAtGoe(start), + recruitEndAtLt(end) + ) + .fetch(); + } + + private BooleanExpression recruitEndAtGoe(LocalDateTime start) { + return start != null ? club.recruitEndAt.goe(start) : null; + } + + private BooleanExpression recruitEndAtLt(LocalDateTime end) { + return end != null ? club.recruitEndAt.lt(end) : null; + } +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubRepository.java b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubRepository.java new file mode 100644 index 000000000..6c1aad753 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubRepository.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.domain.Club; +import org.springframework.data.jpa.repository.JpaRepository; + +interface ClubRepository extends JpaRepository, ClubQueryRepository { +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubSubscribeRepository.java b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubSubscribeRepository.java new file mode 100644 index 000000000..4f48c1d6f --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubSubscribeRepository.java @@ -0,0 +1,15 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.domain.Club; +import com.kustacks.kuring.club.domain.ClubSubscribe; +import com.kustacks.kuring.user.domain.RootUser; +import org.springframework.data.jpa.repository.JpaRepository; + +interface ClubSubscribeRepository extends JpaRepository { + + boolean existsByRootUserIdAndClubId(Long rootUserId, Long clubId); + + long countByRootUserId(Long rootUserId); + + void deleteByRootUserAndClub(RootUser rootUser, Club club); +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/ClubNotificationUseCase.java b/src/main/java/com/kustacks/kuring/club/application/port/in/ClubNotificationUseCase.java new file mode 100644 index 000000000..acebfc78e --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/ClubNotificationUseCase.java @@ -0,0 +1,6 @@ +package com.kustacks.kuring.club.application.port.in; + +public interface ClubNotificationUseCase { + + void sendDeadlineNotifications(); +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/ClubSubscriptionUseCase.java b/src/main/java/com/kustacks/kuring/club/application/port/in/ClubSubscriptionUseCase.java new file mode 100644 index 000000000..85891f22d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/ClubSubscriptionUseCase.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.club.application.port.in; + +import com.kustacks.kuring.club.application.port.in.dto.ClubSubscriptionCommand; + +public interface ClubSubscriptionUseCase { + + long addSubscription(ClubSubscriptionCommand command); + + long removeSubscription(ClubSubscriptionCommand command); +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubSubscriptionCommand.java b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubSubscriptionCommand.java new file mode 100644 index 000000000..1175fea28 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubSubscriptionCommand.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.club.application.port.in.dto; + +public record ClubSubscriptionCommand( + String email, + Long clubId +) { +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/out/ClubQueryPort.java b/src/main/java/com/kustacks/kuring/club/application/port/out/ClubQueryPort.java new file mode 100644 index 000000000..8bc65c41c --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/out/ClubQueryPort.java @@ -0,0 +1,16 @@ +package com.kustacks.kuring.club.application.port.out; + +import com.kustacks.kuring.club.domain.Club; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface ClubQueryPort { + + Optional findClubById(Long id); + + List findClubsBetweenDates(LocalDateTime start, LocalDateTime end); + + List findNextDayRecruitEndClubs(LocalDateTime now); +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionCommandPort.java b/src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionCommandPort.java new file mode 100644 index 000000000..75999a458 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionCommandPort.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.club.application.port.out; + +import com.kustacks.kuring.club.domain.Club; +import com.kustacks.kuring.user.domain.RootUser; + +public interface ClubSubscriptionCommandPort { + + void saveSubscription(RootUser rootUser, Club club); + + void deleteSubscription(RootUser rootUser, Club club); +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionQueryPort.java b/src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionQueryPort.java new file mode 100644 index 000000000..d912a4783 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionQueryPort.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.club.application.port.out; + +public interface ClubSubscriptionQueryPort { + + boolean existsSubscription(Long rootUserId, Long clubId); + + long countSubscriptions(Long rootUserId); +} diff --git a/src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java b/src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java new file mode 100644 index 000000000..f8efbe602 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java @@ -0,0 +1,97 @@ +package com.kustacks.kuring.club.application.service; + +import com.kustacks.kuring.club.application.port.in.ClubSubscriptionUseCase; +import com.kustacks.kuring.club.application.port.in.dto.ClubSubscriptionCommand; +import com.kustacks.kuring.club.application.port.out.ClubQueryPort; +import com.kustacks.kuring.club.application.port.out.ClubSubscriptionCommandPort; +import com.kustacks.kuring.club.application.port.out.ClubSubscriptionQueryPort; +import com.kustacks.kuring.club.domain.Club; +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.common.exception.InvalidStateException; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.user.application.port.out.RootUserQueryPort; +import com.kustacks.kuring.user.application.port.out.UserEventPort; +import com.kustacks.kuring.user.application.port.out.UserQueryPort; +import com.kustacks.kuring.user.domain.RootUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@UseCase +@Transactional +@RequiredArgsConstructor +public class ClubCommandService implements ClubSubscriptionUseCase { + + private static final String CLUB_TOPIC_PREFIX = "club."; + + private final ServerProperties serverProperties; + private final ClubQueryPort clubQueryPort; + private final ClubSubscriptionCommandPort clubSubscriptionCommandPort; + private final ClubSubscriptionQueryPort countSubscriptionsQueryPort; + private final RootUserQueryPort rootUserQueryPort; + private final UserQueryPort userQueryPort; + private final UserEventPort userEventPort; + + @Override + public long addSubscription(ClubSubscriptionCommand command) { + RootUser rootUser = findRootUserByEmail(command.email()); + Club club = findClubById(command.clubId()); + + if (isAlreadySubscription(rootUser, club)) { + throw new InvalidStateException(ErrorCode.CLUB_ALREADY_SUBSCRIBED); + } + + clubSubscriptionCommandPort.saveSubscription(rootUser, club); + subscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club)); + + return countSubscriptionsQueryPort.countSubscriptions(rootUser.getId()); + } + + @Override + public long removeSubscription(ClubSubscriptionCommand command) { + RootUser rootUser = findRootUserByEmail(command.email()); + Club club = findClubById(command.clubId()); + + if (!isAlreadySubscription(rootUser, club)) { + throw new InvalidStateException(ErrorCode.CLUB_NOT_SUBSCRIBED); + } + clubSubscriptionCommandPort.deleteSubscription(rootUser, club); + unsubscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club)); + + return countSubscriptionsQueryPort.countSubscriptions(rootUser.getId()); + } + + private boolean isAlreadySubscription(RootUser rootUser, Club club) { + return countSubscriptionsQueryPort.existsSubscription(rootUser.getId(), club.getId()); + } + + private RootUser findRootUserByEmail(String email) { + return rootUserQueryPort.findRootUserByEmail(email) + .orElseThrow(() -> new InvalidStateException(ErrorCode.ROOT_USER_NOT_FOUND)); + } + + private Club findClubById(Long id) { + return clubQueryPort.findClubById(id) + .orElseThrow(() -> new InvalidStateException(ErrorCode.CLUB_NOT_FOUND)); + } + + private void subscribeAllLoggedInDevices(Long rootUserId, String topic) { + userQueryPort.findByLoggedInUserId(rootUserId) + .forEach(user -> userEventPort.subscribeEvent(user.getFcmToken(), topic)); + + log.info("동아리 토픽 구독 완료. rootUserId={}, topic={}", rootUserId, topic); + } + + private void unsubscribeAllLoggedInDevices(Long rootUserId, String topic) { + userQueryPort.findByLoggedInUserId(rootUserId) + .forEach(user -> userEventPort.unsubscribeEvent(user.getFcmToken(), topic)); + + log.info("동아리 토픽 구독 해제 완료. rootUserId={}, topic={}", rootUserId, topic); + } + + private String makeTopic(Club club) { + return serverProperties.ifDevThenAddSuffix(CLUB_TOPIC_PREFIX + club.getId()); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java b/src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java new file mode 100644 index 000000000..80c740a22 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java @@ -0,0 +1,82 @@ +package com.kustacks.kuring.club.application.service; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.kustacks.kuring.club.application.port.in.ClubNotificationUseCase; +import com.kustacks.kuring.club.application.port.out.ClubQueryPort; +import com.kustacks.kuring.club.domain.Club; +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.message.application.port.out.FirebaseMessagingPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static com.kustacks.kuring.message.domain.MessageType.CLUB; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class ClubNotificationService implements ClubNotificationUseCase { + + private static final String CLUB_TOPIC_PREFIX = "club."; + private static final String D_DAY_1_TITLE = "[D-1] %s 동아리 모집"; + private static final String D_DAY_1_BODY = "내일 마감되기 전에 지원하세요!"; + + private final ClubQueryPort clubQueryPort; + private final FirebaseMessagingPort firebaseMessagingPort; + private final ServerProperties serverProperties; + + @Override + public void sendDeadlineNotifications() { + List clubs = findDeadlineClubs(); + clubs.forEach(this::sendDeadLineClubNotification); + } + + private void sendDeadLineClubNotification(Club club) { + try { + Message message = buildMessage(club); + firebaseMessagingPort.send(message); + log.info("동아리 마감 알림 발송 완료. clubId={}", club.getId()); + } catch (Exception e) { + log.error("동아리 마감 알림 발송 실패. clubId={}", club.getId(), e); + } + } + + private List findDeadlineClubs() { + return clubQueryPort.findNextDayRecruitEndClubs(LocalDateTime.now()); + } + + private Message buildMessage(Club club) { + String messageTitle = String.format(D_DAY_1_TITLE, club.getName()); + + return Message.builder() + .setNotification(buildNotification(messageTitle, D_DAY_1_BODY)) + .setTopic(buildTopic(club)) + .putAllData(buildMessageData(messageTitle, D_DAY_1_BODY, club)) + .build(); + } + + private Map buildMessageData(String title, String body, Club club) { + return Map.of( + "clubId", String.valueOf(club.getId()), + "title", title, + "body", body, + "messageType", CLUB.getValue() + ); + } + + private String buildTopic(Club club) { + return serverProperties.ifDevThenAddSuffix(CLUB_TOPIC_PREFIX + club.getId()); + } + + private Notification buildNotification(String title, String body) { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/domain/Club.java b/src/main/java/com/kustacks/kuring/club/domain/Club.java new file mode 100644 index 000000000..dae03fbbf --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/domain/Club.java @@ -0,0 +1,78 @@ +package com.kustacks.kuring.club.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "club") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Club { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 30, nullable = false) + private String name; + + @Column(length = 30, nullable = false) + private String summary; + + @Column(columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ClubCategory category; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ClubDivision division; + + @OneToMany(mappedBy = "club") + private List homepageUrls = new ArrayList<>(); + + @Column(name = "poster_image_path", length = 255) + private String posterImagePath; + + @Column(length = 30) + private String building; + + @Column(length = 30) + private String room; + + private Double lat; + + private Double lon; + + @Column(name = "recruit_start_at") + private LocalDateTime recruitStartAt; + + @Column(name = "recruit_end_at") + private LocalDateTime recruitEndAt; + + @Column(name = "is_always", nullable = false) + private boolean isAlways = false; + + @Column(name = "apply_url", length = 255) + private String applyUrl; + + @Column(columnDefinition = "TEXT") + private String qualifications; + +} diff --git a/src/main/java/com/kustacks/kuring/club/domain/ClubCategory.java b/src/main/java/com/kustacks/kuring/club/domain/ClubCategory.java new file mode 100644 index 000000000..d398e49c6 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/domain/ClubCategory.java @@ -0,0 +1,43 @@ +package com.kustacks.kuring.club.domain; + +import com.kustacks.kuring.common.exception.NotFoundException; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.kustacks.kuring.common.exception.code.ErrorCode.CLUB_CATEGORY_NOT_SUPPORTED; + +@Getter +public enum ClubCategory { + + ACADEMIC("academic", "학술활동"), + CULTURE_ART("culture_art", "문화예술"), + SOCIAL_VALUE("social_value", "사회가치"), + ACTIVITY("activity", "야외활동"); + + private static final Map NAME_MAP; + + static { + NAME_MAP = Collections.unmodifiableMap(Arrays.stream(ClubCategory.values()) + .collect(Collectors.toMap(ClubCategory::getName, ClubCategory::name)) + ); + } + + private final String name; + private final String korName; + + ClubCategory(String name, String korName) { + this.name = name; + this.korName = korName; + } + + public static ClubCategory fromName(String name) { + String findName = Optional.ofNullable(NAME_MAP.get(name)) + .orElseThrow(() -> new NotFoundException(CLUB_CATEGORY_NOT_SUPPORTED)); + return ClubCategory.valueOf(findName); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/domain/ClubDivision.java b/src/main/java/com/kustacks/kuring/club/domain/ClubDivision.java new file mode 100644 index 000000000..730bc213d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/domain/ClubDivision.java @@ -0,0 +1,57 @@ +package com.kustacks.kuring.club.domain; + +import com.kustacks.kuring.common.exception.NotFoundException; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.kustacks.kuring.common.exception.code.ErrorCode.CLUB_DIVISION_NOT_SUPPORTED; + +@Getter +public enum ClubDivision { + + CENTRAL("central", "중앙"), + LIBERAL_ARTS("liberal_arts", "문과대학"), + SCIENCE("science", "이과대학"), + ARCHITECTURE("architecture", "건축대학"), + ENGINEERING("engineering", "공과대학"), + SOCIAL_SCIENCES("social_sciences", "사회과학대학"), + BUSINESS("business", "경영대학"), + REAL_ESTATE("real_estate", "부동산과학원"), + KU_CONVERGENCE("ku_convergence", "KU융합과학기술원"), + SANGHUH_LIFE_SCIENCE("sanghuh_life_science", "상허생명과학대학"), + VETERINARY("veterinary", "수의과대학"), + ART_DESIGN("art_design", "예술디자인대학"), + EDUCATION("education", "사범대학"), + SANGHUH_GENERAL("sanghuh_general", "상허교양대학"), + INTERNATIONAL("international", "국제대학"), + CONVERGENCE_SCI_TECH("convergence_sci_tech", "융합과학기술원"), + LIFE_SCIENCE("life_science", "생명과학대학"), + ETC("etc", "기타"); + + private static final Map NAME_MAP; + + static { + NAME_MAP = Collections.unmodifiableMap(Arrays.stream(values()) + .collect(Collectors.toMap(ClubDivision::getName, ClubDivision::name)) + ); + } + + private final String name; + private final String korName; + + ClubDivision(String name, String korName) { + this.name = name; + this.korName = korName; + } + + public static ClubDivision fromName(String name) { + String findName = Optional.ofNullable(NAME_MAP.get(name)) + .orElseThrow(() -> new NotFoundException(CLUB_DIVISION_NOT_SUPPORTED)); + return ClubDivision.valueOf(findName); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/domain/ClubSns.java b/src/main/java/com/kustacks/kuring/club/domain/ClubSns.java new file mode 100644 index 000000000..08fd1008d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/domain/ClubSns.java @@ -0,0 +1,38 @@ +package com.kustacks.kuring.club.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "club_sns") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ClubSns { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ClubSnsType type; + + @Column(nullable = false, length = 255) + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + private Club club; +} diff --git a/src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java b/src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java new file mode 100644 index 000000000..cf326837f --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.club.domain; + +public enum ClubSnsType { + INSTAGRAM, + YOUTUBE, + ETC +} diff --git a/src/main/java/com/kustacks/kuring/club/domain/ClubSubscribe.java b/src/main/java/com/kustacks/kuring/club/domain/ClubSubscribe.java new file mode 100644 index 000000000..a4ce7796e --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/domain/ClubSubscribe.java @@ -0,0 +1,44 @@ +package com.kustacks.kuring.club.domain; + +import com.kustacks.kuring.user.domain.RootUser; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "club_subscribe", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"club_id", "root_user_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ClubSubscribe { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + private Club club; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "root_user_id", nullable = false) + private RootUser rootUser; + + public ClubSubscribe(RootUser rootUser, Club club) { + this.rootUser = rootUser; + this.club = club; + } +} diff --git a/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java b/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java index 5c019a824..0ec3a5c91 100644 --- a/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java +++ b/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java @@ -62,6 +62,10 @@ public enum ResponseCodeAndMessages { ACADEMIC_EVENT_SEARCH_SUCCESS(HttpStatus.OK.value(), "학사일정 조회에 성공했습니다."), ACADEMIC_EVENT_NOTIFICATION_UPDATE_SUCCESS(HttpStatus.OK.value(), "학사일정 알림 설정이 변경되었습니다."), + /* Club */ + CLUB_SUBSCRIPTION_ADD_SUCCESS(HttpStatus.OK.value(), "구독에 성공했습니다."), + CLUB_SUBSCRIPTION_DELETE_SUCCESS(HttpStatus.OK.value(), "구독이 취소되었습니다."), + /** * ErrorCodes about auth */ diff --git a/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java b/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java index 18b774353..e6f309b3b 100644 --- a/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java +++ b/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java @@ -56,6 +56,14 @@ public enum ErrorCode { CAT_NOT_EXIST_CATEGORY(HttpStatus.BAD_REQUEST, "서버에서 지원하지 않는 카테고리입니다."), + + CLUB_CATEGORY_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "서버에서 지원하지 않는 동아리 카테고리입니다."), + CLUB_DIVISION_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "서버에서 지원하지 않는 동아리 소속입니다."), + CLUB_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 동아리입니다."), + CLUB_ALREADY_SUBSCRIBED(HttpStatus.BAD_REQUEST, "이미 구독한 동아리입니다."), + CLUB_NOT_SUBSCRIBED(HttpStatus.BAD_REQUEST, "구독하지 않은 동아리입니다."), + + STAFF_SCRAPER_EXCEED_RETRY_LIMIT("교직원 업데이트 재시도 횟수를 초과했습니다."), STAFF_SCRAPER_CANNOT_SCRAP("건국대학교 홈페이지가 불안정합니다. 교직원 정보를 가져올 수 없습니다."), STAFF_SCRAPER_CANNOT_PARSE("교직원 페이지 HTML 파싱에 실패했습니다."), diff --git a/src/main/java/com/kustacks/kuring/common/featureflag/KuringFeatures.java b/src/main/java/com/kustacks/kuring/common/featureflag/KuringFeatures.java index 661c8f3f8..87eefa3db 100644 --- a/src/main/java/com/kustacks/kuring/common/featureflag/KuringFeatures.java +++ b/src/main/java/com/kustacks/kuring/common/featureflag/KuringFeatures.java @@ -10,7 +10,8 @@ public enum KuringFeatures { UPDATE_USER(new Feature("update_user")), UPDATE_STAFF(new Feature("update_staff")), UPDATE_ACADEMIC_EVENT(new Feature("update_academic_event")), - NOTIFY_ACADEMIC_EVENT(new Feature("notify_academic_event")); + NOTIFY_ACADEMIC_EVENT(new Feature("notify_academic_event")), + NOTIFY_CLUB_DEADLINE(new Feature("notify_club_deadline")); private final Feature feature; diff --git a/src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java b/src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java index 5ce298555..7e1c3f7ec 100644 --- a/src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java +++ b/src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java @@ -1,14 +1,18 @@ package com.kustacks.kuring.message.application.port.out.dto; -import com.kustacks.kuring.notice.domain.*; -import lombok.*; +import com.kustacks.kuring.notice.domain.DepartmentNotice; +import com.kustacks.kuring.notice.domain.Notice; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.util.Assert; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NoticeMessageDto { - private Long id; + private String id; private String type; @@ -25,7 +29,7 @@ public class NoticeMessageDto { private String baseUrl; @Builder - private NoticeMessageDto(Long id, String articleId, String postedDate, String subject, String category, String categoryKorName, String baseUrl) { + private NoticeMessageDto(String id, String articleId, String postedDate, String subject, String category, String categoryKorName, String baseUrl) { Assert.notNull(id, "id must not be empty"); Assert.notNull(articleId, "articleId must not be null"); Assert.notNull(postedDate, "postedDate must not be null"); @@ -46,7 +50,7 @@ private NoticeMessageDto(Long id, String articleId, String postedDate, String su public static NoticeMessageDto from(Notice notice) { return NoticeMessageDto.builder() - .id(notice.getId()) + .id(String.valueOf(notice.getId())) .articleId(notice.getArticleId()) .postedDate(notice.getPostedDate()) .subject(notice.getSubject()) @@ -58,7 +62,7 @@ public static NoticeMessageDto from(Notice notice) { public static NoticeMessageDto from(DepartmentNotice departmentNotice) { return NoticeMessageDto.builder() - .id(departmentNotice.getId()) + .id(String.valueOf(departmentNotice.getId())) .articleId(departmentNotice.getArticleId()) .postedDate(departmentNotice.getPostedDate()) .subject(departmentNotice.getSubject()) diff --git a/src/main/java/com/kustacks/kuring/message/domain/MessageType.java b/src/main/java/com/kustacks/kuring/message/domain/MessageType.java index 35f830d68..1a0534abf 100644 --- a/src/main/java/com/kustacks/kuring/message/domain/MessageType.java +++ b/src/main/java/com/kustacks/kuring/message/domain/MessageType.java @@ -9,7 +9,8 @@ public enum MessageType { NOTICE("notice"), ADMIN("admin"), - ACADEMIC("academic"); + ACADEMIC("academic"), + CLUB("club"); private final String value; } diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserClubSubscriptionApiV2.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserClubSubscriptionApiV2.java new file mode 100644 index 000000000..fba0ddf41 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserClubSubscriptionApiV2.java @@ -0,0 +1,80 @@ +package com.kustacks.kuring.user.adapter.in.web; + +import com.kustacks.kuring.auth.authentication.AuthorizationExtractor; +import com.kustacks.kuring.auth.authentication.AuthorizationType; +import com.kustacks.kuring.auth.token.JwtTokenProvider; +import com.kustacks.kuring.club.application.port.in.ClubSubscriptionUseCase; +import com.kustacks.kuring.club.application.port.in.dto.ClubSubscriptionCommand; +import com.kustacks.kuring.common.annotation.RestWebAdapter; +import com.kustacks.kuring.common.dto.BaseResponse; +import com.kustacks.kuring.common.exception.InvalidStateException; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.user.adapter.in.web.dto.UserClubSubscriptionCountResponse; +import com.kustacks.kuring.user.adapter.in.web.dto.UserClubSubscriptionRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import static com.kustacks.kuring.auth.authentication.AuthorizationExtractor.extractAuthorizationValue; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_SUBSCRIPTION_ADD_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_SUBSCRIPTION_DELETE_SUCCESS; + +@Tag(name = "User-Club-Subscription", description = "동아리 구독") +@Validated +@RequiredArgsConstructor +@RestWebAdapter(path = "/api/v2/users/subscriptions/clubs") +class UserClubSubscriptionApiV2 { + + private static final String FCM_TOKEN_HEADER_KEY = "User-Token"; + private static final String JWT_TOKEN_HEADER_KEY = "JWT"; + + + private final ClubSubscriptionUseCase clubSubscriptionUseCase; + private final JwtTokenProvider jwtTokenProvider; + + @Operation(summary = "사용자 동아리 구독 추가") + @SecurityRequirement(name = FCM_TOKEN_HEADER_KEY) + @SecurityRequirement(name = JWT_TOKEN_HEADER_KEY) + @PostMapping + public ResponseEntity> addSubscription( + @RequestHeader(FCM_TOKEN_HEADER_KEY) String userToken, + @RequestHeader(AuthorizationExtractor.AUTHORIZATION) String bearerToken, + @Valid @RequestBody UserClubSubscriptionRequest request + ) { + String email = validateJwtAndGetEmail(extractAuthorizationValue(bearerToken, AuthorizationType.BEARER)); + long subscriptionCount = clubSubscriptionUseCase.addSubscription(new ClubSubscriptionCommand(email, request.id())); + + return ResponseEntity.ok(new BaseResponse<>(CLUB_SUBSCRIPTION_ADD_SUCCESS, new UserClubSubscriptionCountResponse(subscriptionCount))); + } + + @Operation(summary = "사용자 동아리 구독 제거") + @SecurityRequirement(name = FCM_TOKEN_HEADER_KEY) + @SecurityRequirement(name = JWT_TOKEN_HEADER_KEY) + @DeleteMapping("/{clubId}") + public ResponseEntity> deleteSubscription( + @RequestHeader(FCM_TOKEN_HEADER_KEY) String userToken, + @RequestHeader(AuthorizationExtractor.AUTHORIZATION) String bearerToken, + @PathVariable Long clubId + ) { + String email = validateJwtAndGetEmail(extractAuthorizationValue(bearerToken, AuthorizationType.BEARER)); + long subscriptionCount = clubSubscriptionUseCase.removeSubscription(new ClubSubscriptionCommand(email, clubId)); + + return ResponseEntity.ok(new BaseResponse<>(CLUB_SUBSCRIPTION_DELETE_SUCCESS, new UserClubSubscriptionCountResponse(subscriptionCount))); + } + + private String validateJwtAndGetEmail(String jwtToken) { + if (!jwtTokenProvider.validateToken(jwtToken)) { + throw new InvalidStateException(ErrorCode.JWT_INVALID_TOKEN); + } + return jwtTokenProvider.getPrincipal(jwtToken); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionCountResponse.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionCountResponse.java new file mode 100644 index 000000000..c114ddbb4 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionCountResponse.java @@ -0,0 +1,6 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +public record UserClubSubscriptionCountResponse( + Long subscriptionCount +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionRequest.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionRequest.java new file mode 100644 index 000000000..a0043fe1f --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionRequest.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import jakarta.validation.constraints.NotNull; + +public record UserClubSubscriptionRequest( + @NotNull Long id +) { +} diff --git a/src/main/java/com/kustacks/kuring/worker/notification/ClubNotificationScheduler.java b/src/main/java/com/kustacks/kuring/worker/notification/ClubNotificationScheduler.java new file mode 100644 index 000000000..11206b023 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/worker/notification/ClubNotificationScheduler.java @@ -0,0 +1,31 @@ +package com.kustacks.kuring.worker.notification; + +import com.kustacks.kuring.club.application.port.in.ClubNotificationUseCase; +import com.kustacks.kuring.common.featureflag.FeatureFlags; +import com.kustacks.kuring.common.featureflag.KuringFeatures; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClubNotificationScheduler { + + private final ClubNotificationUseCase clubNotificationUseCase; + private final FeatureFlags featureFlags; + + @Scheduled(cron = "0 0 18 * * *", zone = "Asia/Seoul") + public void sendClubDeadlineNotifications() { + if (featureFlags.isEnabled(KuringFeatures.NOTIFY_CLUB_DEADLINE.getFeature())) { + log.info("******** 동아리 마감 임박 알림 발송 시작 ********"); + try { + clubNotificationUseCase.sendDeadlineNotifications(); + log.info("******** 동아리 마감 임박 알림 발송 완료 ********"); + } catch (Exception e) { + log.error("동아리 마감 임박 알림 발송 중 오류가 발생했습니다.", e); + } + } + } +} diff --git a/src/main/resources/db/migration/V260206__Create_club_table.sql b/src/main/resources/db/migration/V260206__Create_club_table.sql new file mode 100644 index 000000000..cc6dc2263 --- /dev/null +++ b/src/main/resources/db/migration/V260206__Create_club_table.sql @@ -0,0 +1,58 @@ +CREATE TABLE club +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL, + summary VARCHAR(30) NOT NULL, + description TEXT, + category VARCHAR(30) NOT NULL, + division VARCHAR(30) NOT NULL, + poster_image_path VARCHAR(255), + building VARCHAR(30), + room VARCHAR(30), + lat DOUBLE, + lon DOUBLE, + recruit_start_at DATETIME, + recruit_end_at DATETIME, + is_always BOOLEAN NOT NULL DEFAULT false, + apply_url VARCHAR(255), + qualifications TEXT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE club_subscribe +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + club_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + + CONSTRAINT fk_club_subscribe_club + FOREIGN KEY (club_id) REFERENCES club (id) + ON DELETE CASCADE, + CONSTRAINT fk_club_subscribe_user + FOREIGN KEY (user_id) REFERENCES user (id) + ON DELETE CASCADE, + + CONSTRAINT uk_club_user UNIQUE (club_id, user_id), + + INDEX idx_club_subscribe_user (user_id), + INDEX idx_club_subscribe_club (club_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE club_sns +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + club_id BIGINT NOT NULL, + type VARCHAR(30) NOT NULL, + url VARCHAR(255) NOT NULL, + + CONSTRAINT fk_club_sns_club + FOREIGN KEY (club_id) REFERENCES club (id) + ON DELETE CASCADE, + + INDEX idx_club_sns_club (club_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; \ No newline at end of file diff --git a/src/main/resources/db/migration/V260217__Alter_club_subscribe_to_root_user.sql b/src/main/resources/db/migration/V260217__Alter_club_subscribe_to_root_user.sql new file mode 100644 index 000000000..49e5c38b0 --- /dev/null +++ b/src/main/resources/db/migration/V260217__Alter_club_subscribe_to_root_user.sql @@ -0,0 +1,34 @@ +-- root_user로 바꾸면 데이터 이관을 해야하나, 아직 데이터가 존재하지 않는 개발 단계이므로 데이터 삭제하고 진행. +-- 1) 기존 device(user) 기준 구독 데이터는 유지하지 않는다. +DELETE FROM club_subscribe; + +-- 2) 기존 user FK/unique/index 제약 제거 +ALTER TABLE club_subscribe + DROP FOREIGN KEY fk_club_subscribe_user; + +ALTER TABLE club_subscribe + DROP INDEX uk_club_user; + +ALTER TABLE club_subscribe + DROP INDEX idx_club_subscribe_user; + +-- 3) root_user_id 컬럼 추가 +ALTER TABLE club_subscribe + ADD COLUMN root_user_id BIGINT NOT NULL; + +-- 4) 기존 user_id 컬럼 제거 +ALTER TABLE club_subscribe + DROP COLUMN user_id; + +-- 5) root_user FK 추가 (동아리/계정 삭제 시 구독도 함께 삭제) +ALTER TABLE club_subscribe + ADD CONSTRAINT fk_club_subscribe_root_user + FOREIGN KEY (root_user_id) REFERENCES root_user (id) + ON DELETE CASCADE; + +-- 6) 동아리-계정 구독 중복 방지 unique 제약 추가 +ALTER TABLE club_subscribe + ADD CONSTRAINT uk_club_root_user UNIQUE (club_id, root_user_id); + +-- 7) root_user 기준 조회 성능을 위한 인덱스 추가 +CREATE INDEX idx_club_subscribe_root_user ON club_subscribe (root_user_id); \ No newline at end of file diff --git a/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java b/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java index 4a4fde583..fa8c6da34 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java +++ b/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java @@ -14,40 +14,7 @@ import static com.kustacks.kuring.acceptance.CommonStep.실패_응답_확인; import static com.kustacks.kuring.acceptance.EmailStep.인증코드_인증_요청; import static com.kustacks.kuring.acceptance.EmailStep.회원가입_인증코드_이메일_전송_요청; -import static com.kustacks.kuring.acceptance.UserStep.구독한_학과_목록_조회_요청; -import static com.kustacks.kuring.acceptance.UserStep.남은_질문_횟수_조회; -import static com.kustacks.kuring.acceptance.UserStep.로그아웃_요청; -import static com.kustacks.kuring.acceptance.UserStep.로그아웃_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.로그인_요청; -import static com.kustacks.kuring.acceptance.UserStep.로그인_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.루트유저_남은_질문_횟수_조회; -import static com.kustacks.kuring.acceptance.UserStep.북마크_생성_요청; -import static com.kustacks.kuring.acceptance.UserStep.북마크_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.북마크_조회_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.북마크한_공지_조회_요청; -import static com.kustacks.kuring.acceptance.UserStep.비밀번호_변경_요청; -import static com.kustacks.kuring.acceptance.UserStep.비밀번호_변경_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.사용자_로그인_되어_있음; -import static com.kustacks.kuring.acceptance.UserStep.사용자_정보_조회_요청; -import static com.kustacks.kuring.acceptance.UserStep.사용자_정보_조회_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.사용자_카테고리_구독_목록_조회_요청; -import static com.kustacks.kuring.acceptance.UserStep.사용자_학과_조회_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.사용자_회원가입_요청; -import static com.kustacks.kuring.acceptance.UserStep.액세스_토큰으로_비밀번호_변경_요청; -import static com.kustacks.kuring.acceptance.UserStep.질문_횟수_응답_검증; -import static com.kustacks.kuring.acceptance.UserStep.카테고리_구독_목록_조회_요청_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.카테고리_구독_요청; -import static com.kustacks.kuring.acceptance.UserStep.카테고리_구독_요청_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.피드백_요청_v2; -import static com.kustacks.kuring.acceptance.UserStep.피드백_요청_응답_확인_v2; -import static com.kustacks.kuring.acceptance.UserStep.학과_구독_요청; -import static com.kustacks.kuring.acceptance.UserStep.학과_구독_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.학사일정_알림_토글_요청; -import static com.kustacks.kuring.acceptance.UserStep.학사일정_알림_토글_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.회원_가입_요청; -import static com.kustacks.kuring.acceptance.UserStep.회원_탈퇴_요청; -import static com.kustacks.kuring.acceptance.UserStep.회원_탈퇴_응답_확인; -import static com.kustacks.kuring.acceptance.UserStep.회원가입_응답_확인; +import static com.kustacks.kuring.acceptance.UserStep.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -58,6 +25,7 @@ class UserAcceptanceTest extends IntegrationTestSupport { public static final String NEW_EMAIL = "new-client@konkuk.ac.kr"; + private static final Long TEST_CLUB_ID = 1L; /** * Given: 가입되지 않은 사용자가 있다 @@ -523,4 +491,56 @@ void toggle_academic_event_notification() { // then 학사일정_알림_토글_응답_확인(두번째_토글_응답, true); } -} \ No newline at end of file + + @DisplayName("[v2] 사용자는 동아리를 구독할 수 있다") + @Test + void add_club_subscription_success() { + String accessToken = 사용자_로그인_되어_있음(USER_FCM_TOKEN, USER_EMAIL, USER_PASSWORD); + + var response = 동아리_구독_추가_요청(USER_FCM_TOKEN, accessToken, TEST_CLUB_ID); + + 동아리_구독_추가_성공_응답_확인(response, 1L); + } + + @DisplayName("[v2] 이미 구독한 동아리는 다시 구독할 수 없다") + @Test + void add_club_subscription_fail_when_already_subscribed() { + String accessToken = 사용자_로그인_되어_있음(USER_FCM_TOKEN, USER_EMAIL, USER_PASSWORD); + + 동아리_구독_추가_요청(USER_FCM_TOKEN, accessToken, TEST_CLUB_ID); + var response = 동아리_구독_추가_요청(USER_FCM_TOKEN, accessToken, TEST_CLUB_ID); + + 실패_응답_확인(response, HttpStatus.BAD_REQUEST); + } + + @DisplayName("[v2] 존재하지 않는 동아리 구독은 실패한다") + @Test + void add_club_subscription_fail_when_club_not_found() { + String accessToken = 사용자_로그인_되어_있음(USER_FCM_TOKEN, USER_EMAIL, USER_PASSWORD); + + var response = 동아리_구독_추가_요청(USER_FCM_TOKEN, accessToken, 99999L); + + 실패_응답_확인(response, HttpStatus.BAD_REQUEST); + } + + @DisplayName("[v2] 사용자는 동아리 구독을 취소할 수 있다") + @Test + void remove_club_subscription_success() { + String accessToken = 사용자_로그인_되어_있음(USER_FCM_TOKEN, USER_EMAIL, USER_PASSWORD); + + 동아리_구독_추가_요청(USER_FCM_TOKEN, accessToken, TEST_CLUB_ID); + var response = 동아리_구독_제거_요청(USER_FCM_TOKEN, accessToken, TEST_CLUB_ID); + + 동아리_구독_제거_성공_응답_확인(response, 0L); + } + + @DisplayName("[v2] 구독하지 않은 동아리 취소는 실패한다") + @Test + void remove_club_subscription_fail_when_not_subscribed() { + String accessToken = 사용자_로그인_되어_있음(USER_FCM_TOKEN, USER_EMAIL, USER_PASSWORD); + + var response = 동아리_구독_제거_요청(USER_FCM_TOKEN, accessToken, TEST_CLUB_ID); + + 실패_응답_확인(response, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/test/java/com/kustacks/kuring/acceptance/UserStep.java b/src/test/java/com/kustacks/kuring/acceptance/UserStep.java index ae324c2e2..b1614de6b 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/UserStep.java +++ b/src/test/java/com/kustacks/kuring/acceptance/UserStep.java @@ -4,6 +4,7 @@ import com.kustacks.kuring.user.adapter.in.web.dto.UserAcademicEventNotificationRequest; import com.kustacks.kuring.user.adapter.in.web.dto.UserBookmarkRequest; import com.kustacks.kuring.user.adapter.in.web.dto.UserCategoriesSubscribeRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserClubSubscriptionRequest; import com.kustacks.kuring.user.adapter.in.web.dto.UserDepartmentsSubscribeRequest; import com.kustacks.kuring.user.adapter.in.web.dto.UserFeedbackRequest; import com.kustacks.kuring.user.adapter.in.web.dto.UserLoginRequest; @@ -84,6 +85,27 @@ public class UserStep { .extract(); } + public static ExtractableResponse 동아리_구독_추가_요청(String userToken, String accessToken, Long clubId) { + return RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("User-Token", userToken) + .header("Authorization", "Bearer " + accessToken) + .body(new UserClubSubscriptionRequest(clubId)) + .when().post("/api/v2/users/subscriptions/clubs") + .then().log().all() + .extract(); + } + + public static ExtractableResponse 동아리_구독_제거_요청(String userToken, String accessToken, Long clubId) { + return RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("User-Token", userToken) + .header("Authorization", "Bearer " + accessToken) + .when().delete("/api/v2/users/subscriptions/clubs/{clubId}", clubId) + .then().log().all() + .extract(); + } + public static void 학과_구독_응답_확인(ExtractableResponse response) { assertAll( () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), @@ -92,6 +114,24 @@ public class UserStep { ); } + public static void 동아리_구독_추가_성공_응답_확인(ExtractableResponse response, long expectedCount) { + assertAll( + () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), + () -> assertThat(response.jsonPath().getInt("code")).isEqualTo(200), + () -> assertThat(response.jsonPath().getString("message")).isEqualTo("구독에 성공했습니다."), + () -> assertThat(response.jsonPath().getLong("data.subscriptionCount")).isEqualTo(expectedCount) + ); + } + + public static void 동아리_구독_제거_성공_응답_확인(ExtractableResponse response, long expectedCount) { + assertAll( + () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), + () -> assertThat(response.jsonPath().getInt("code")).isEqualTo(200), + () -> assertThat(response.jsonPath().getString("message")).isEqualTo("구독이 취소되었습니다."), + () -> assertThat(response.jsonPath().getLong("data.subscriptionCount")).isEqualTo(expectedCount) + ); + } + public static void 사용자_학과_조회_응답_확인(ExtractableResponse response, List departments) { assertAll( () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), diff --git a/src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java b/src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java index eeb95e069..de3c6c89b 100644 --- a/src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java +++ b/src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java @@ -173,6 +173,28 @@ void validateCalendarArchitecture() { .importPackages("com.kustacks.kuring.calendar..")); } + @DisplayName("Club Domain 의존성 검증") + @Test + void validateClubDomainDependencies() { + HexagonalArchitecture.boundedContext("com.kustacks.kuring.club") + + .withDomainLayer("domain") + + .withAdaptersLayer("adapter") + .outgoing("out.persistence") + .and() + + .withApplicationLayer("application") + .services("service") + .incomingPorts("port.in") + .outgoingPorts("port.out") + .and() + + .withConfiguration("configuration") + .check(new ClassFileImporter() + .importPackages("com.kustacks.kuring.club..")); + } + @DisplayName("테스트 페키지 의존성 검증") @Test void testPackageDependencies() { diff --git a/src/test/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapterTest.java b/src/test/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapterTest.java new file mode 100644 index 000000000..074d63c0f --- /dev/null +++ b/src/test/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapterTest.java @@ -0,0 +1,54 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClubPersistenceAdapter") +class ClubPersistenceAdapterTest { + + @InjectMocks + private ClubPersistenceAdapter adapter; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubSubscribeRepository clubSubscribeRepository; + + + @DisplayName("내일 마감 동아리 조회는 [내일 00:00, 내일모레 00:00) 범위를 사용한다") + @Test + void find_tomorrow_recruit_end_clubs_with_expected_window() { + //given + LocalDateTime now = LocalDateTime.of(2026, 2, 19, 18, 0, 0); + when(clubRepository.findClubsBetweenDates(any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(List.of()); + + //when + adapter.findNextDayRecruitEndClubs(now); + + //then + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(clubRepository).findClubsBetweenDates(startCaptor.capture(), endCaptor.capture()); + + assertAll( + () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDateTime.of(2026, 2, 20, 0, 0, 0)), + () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDateTime.of(2026, 2, 21, 0, 0, 0)) + ); + } +} diff --git a/src/test/java/com/kustacks/kuring/club/application/service/ClubCommandServiceTest.java b/src/test/java/com/kustacks/kuring/club/application/service/ClubCommandServiceTest.java new file mode 100644 index 000000000..13ea7a931 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/club/application/service/ClubCommandServiceTest.java @@ -0,0 +1,192 @@ +package com.kustacks.kuring.club.application.service; + +import com.kustacks.kuring.club.application.port.in.dto.ClubSubscriptionCommand; +import com.kustacks.kuring.club.application.port.out.ClubQueryPort; +import com.kustacks.kuring.club.application.port.out.ClubSubscriptionCommandPort; +import com.kustacks.kuring.club.application.port.out.ClubSubscriptionQueryPort; +import com.kustacks.kuring.club.domain.Club; +import com.kustacks.kuring.common.exception.InvalidStateException; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.user.application.port.out.RootUserQueryPort; +import com.kustacks.kuring.user.application.port.out.UserEventPort; +import com.kustacks.kuring.user.application.port.out.UserQueryPort; +import com.kustacks.kuring.user.domain.RootUser; +import com.kustacks.kuring.user.domain.User; +import org.junit.jupiter.api.BeforeEach; +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.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClubSubscriptionCommandService") +class ClubCommandServiceTest { + + @InjectMocks + private ClubCommandService service; + + @Mock + private ClubQueryPort clubQueryPort; + + @Mock + private ServerProperties serverProperties; + + @Mock + private ClubSubscriptionCommandPort clubSubscriptionCommandPort; + + @Mock + private ClubSubscriptionQueryPort clubSubscriptionQueryPort; + + @Mock + private RootUserQueryPort rootUserQueryPort; + + @Mock + private UserQueryPort userQueryPort; + + @Mock + private UserEventPort userEventPort; + + private RootUser rootUser; + + private Club club; + + @BeforeEach + void setUp() { + rootUser = rootUser(); + club = club(); + } + + @DisplayName("구독 추가 성공") + @Test + void add_subscription_success() { + //given + User user1 = new User("token-1"); + User user2 = new User("token-2"); + + when(club.getId()).thenReturn(1L); + when(serverProperties.ifDevThenAddSuffix(anyString())).thenReturn("club.1"); + when(rootUserQueryPort.findRootUserByEmail("client@konkuk.ac.kr")) + .thenReturn(Optional.of(rootUser)); + when(clubQueryPort.findClubById(1L)).thenReturn(Optional.of(club)); + when(userQueryPort.findByLoggedInUserId(1L)).thenReturn(List.of(user1, user2)); + when(clubSubscriptionQueryPort.countSubscriptions(1L)).thenReturn(1L); + + //when + long count = service.addSubscription(new ClubSubscriptionCommand("client@konkuk.ac.kr", 1L)); + + //then + assertAll( + () -> assertThat(count).isEqualTo(1L), + () -> verify(userEventPort).subscribeEvent("token-1", "club.1"), + () -> verify(userEventPort).subscribeEvent("token-2", "club.1") + ); + } + + @DisplayName("이미 구독된 동아리는 추가할 수 없다") + @Test + void add_subscription_fail_when_already_subscribed() { + //given + when(club.getId()).thenReturn(1L); + when(rootUserQueryPort.findRootUserByEmail("client@konkuk.ac.kr")) + .thenReturn(Optional.of(rootUser)); + when(clubQueryPort.findClubById(1L)).thenReturn(Optional.of(club)); + when(clubSubscriptionQueryPort.existsSubscription(1L, 1L)).thenReturn(Boolean.TRUE); + + //when & then + assertAll( + () -> assertThatThrownBy(() -> service.addSubscription(new ClubSubscriptionCommand("client@konkuk.ac.kr", 1L))) + .isInstanceOf(InvalidStateException.class) + .extracting(ex -> ((InvalidStateException) ex).getErrorCode()) + .isEqualTo(ErrorCode.CLUB_ALREADY_SUBSCRIBED), + () -> verify(userEventPort, never()).subscribeEvent(anyString(), anyString()) + ); + } + + @DisplayName("구독 제거 성공") + @Test + void remove_subscription_success() { + //given + User user1 = new User("token-1"); + + when(club.getId()).thenReturn(1L); + when(serverProperties.ifDevThenAddSuffix(anyString())).thenReturn("club.1"); + when(rootUserQueryPort.findRootUserByEmail("client@konkuk.ac.kr")) + .thenReturn(Optional.of(rootUser)); + when(clubQueryPort.findClubById(1L)).thenReturn(Optional.of(club)); + when(clubSubscriptionQueryPort.existsSubscription(1L, club.getId())).thenReturn(Boolean.TRUE); + when(userQueryPort.findByLoggedInUserId(1L)).thenReturn(List.of(user1)); + + //when + long count = service.removeSubscription(new ClubSubscriptionCommand("client@konkuk.ac.kr", 1L)); + + //then + assertAll( + () -> assertThat(count).isEqualTo(0L), + () -> verify(userEventPort).unsubscribeEvent("token-1", "club.1") + ); + } + + @DisplayName("구독하지 않은 동아리는 제거할 수 없다") + @Test + void remove_subscription_fail_when_not_subscribed() { + //given + when(club.getId()).thenReturn(1L); + when(rootUserQueryPort.findRootUserByEmail("client@konkuk.ac.kr")) + .thenReturn(Optional.of(rootUser)); + when(clubQueryPort.findClubById(1L)).thenReturn(Optional.of(club)); + when(clubSubscriptionQueryPort.existsSubscription(1L, club.getId())).thenReturn(Boolean.FALSE); + + //when & then + assertAll( + () -> assertThatThrownBy(() -> service.removeSubscription(new ClubSubscriptionCommand("client@konkuk.ac.kr", 1L))) + .isInstanceOf(InvalidStateException.class) + .extracting(ex -> ((InvalidStateException) ex).getErrorCode()) + .isEqualTo(ErrorCode.CLUB_NOT_SUBSCRIBED), + () -> verify(userEventPort, never()).unsubscribeEvent(anyString(), anyString()) + ); + } + + @DisplayName("존재하지 않는 동아리는 구독할 수 없다") + @Test + void add_subscription_fail_when_club_not_found() { + //given + when(rootUserQueryPort.findRootUserByEmail("client@konkuk.ac.kr")) + .thenReturn(Optional.of(rootUser)); + when(clubQueryPort.findClubById(1L)).thenReturn(Optional.empty()); + + //when & then + assertAll( + () -> assertThatThrownBy(() -> service.addSubscription(new ClubSubscriptionCommand("client@konkuk.ac.kr", 1L))) + .isInstanceOf(InvalidStateException.class) + .extracting(ex -> ((InvalidStateException) ex).getErrorCode()) + .isEqualTo(ErrorCode.CLUB_NOT_FOUND), + () -> verify(userEventPort, never()).subscribeEvent(anyString(), anyString()) + ); + } + + private RootUser rootUser() { + RootUser rootUser = new RootUser("client@konkuk.ac.kr", "password", "nickname"); + ReflectionTestUtils.setField(rootUser, "id", 1L); + return rootUser; + } + + private Club club() { + return mock(Club.class); + } +} diff --git a/src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java b/src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java new file mode 100644 index 000000000..2dc0b23ab --- /dev/null +++ b/src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java @@ -0,0 +1,105 @@ +package com.kustacks.kuring.club.application.service; + +import com.google.firebase.messaging.Message; +import com.kustacks.kuring.club.application.port.out.ClubQueryPort; +import com.kustacks.kuring.club.domain.Club; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.message.application.port.out.FirebaseMessagingPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClubNotificationService") +class ClubNotificationServiceTest { + + @InjectMocks + private ClubNotificationService service; + + @Mock + private ClubQueryPort clubQueryPort; + + @Mock + private FirebaseMessagingPort firebaseMessagingPort; + + @Mock + private ServerProperties serverProperties; + + private Club club1; + + private Club club2; + + @BeforeEach + void setUp() { + club1 = club(1L, "쿠링"); + club2 = club(2L, "리드미"); + + when(serverProperties.ifDevThenAddSuffix("club.1")).thenReturn("club.1.dev"); + when(serverProperties.ifDevThenAddSuffix("club.2")).thenReturn("club.2.dev"); + when(clubQueryPort.findNextDayRecruitEndClubs(any(LocalDateTime.class))) + .thenReturn(List.of(club1, club2)); + } + + @DisplayName("내일 마감 대상 목록을 모두 발송한다") + @Test + void send_deadline_notifications_only_for_targets() throws Exception { + //when + service.sendDeadlineNotifications(); + + //then + ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); + verify(firebaseMessagingPort, times(2)).send(captor.capture()); + + Message sent = captor.getAllValues().get(0); // 1번 ID에 대한 Club 메시지 + String topic = (String) ReflectionTestUtils.getField(sent, "topic"); + + Map data = (Map) ReflectionTestUtils.getField(sent, "data"); + assertAll( + () -> assertThat(topic).isEqualTo("club.1.dev"), + () -> assertThat(data).containsEntry("clubId", "1"), + () -> assertThat(data).containsEntry("messageType", "club") + ); + } + + @DisplayName("발송 실패가 발생해도 다른 대상은 계속 발송") + @Test + void should_continue_even_if_one_send_fails() throws Exception { + //given + doThrow(new RuntimeException("send fail")) + .doNothing() + .when(firebaseMessagingPort) + .send(any(Message.class)); + + //when + service.sendDeadlineNotifications(); + + //then + verify(firebaseMessagingPort, times(2)).send(any(Message.class)); + } + + private Club club(Long id, String name) { + Club club = mock(Club.class); + when(club.getId()).thenReturn(id); + when(club.getName()).thenReturn(name); + return club; + } +} diff --git a/src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java b/src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java new file mode 100644 index 000000000..1aeadc874 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java @@ -0,0 +1,40 @@ +package com.kustacks.kuring.club.domain; + +import com.kustacks.kuring.common.exception.NotFoundException; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +@DisplayName("도메인 : ClubCategory") +class ClubCategoryTest { + + @DisplayName("name을 받아 해당 ClubCategory enum으로 변환한다") + @ParameterizedTest + @CsvSource({"academic,ACADEMIC", "culture_art,CULTURE_ART", "social_value,SOCIAL_VALUE", "activity,ACTIVITY"}) + void fromName(String name, ClubCategory clubCategory) { + // when + ClubCategory result = ClubCategory.fromName(name); + + // then + assertThat(result).isEqualTo(clubCategory); + } + + @DisplayName("존재하지 않는 name으로 ClubCategory를 찾으려 하면 예외가 발생한다") + @Test + void fromNameException() { + // given + String name = "invalid"; + + // when + ThrowingCallable actual = () -> ClubCategory.fromName(name); + + // then + assertThatThrownBy(actual) + .isInstanceOf(NotFoundException.class); + } +} diff --git a/src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java b/src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java new file mode 100644 index 000000000..e5d503a01 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java @@ -0,0 +1,40 @@ +package com.kustacks.kuring.club.domain; + +import com.kustacks.kuring.common.exception.NotFoundException; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +@DisplayName("도메인 : ClubDivision") +class ClubDivisionTest { + + @DisplayName("name을 받아 해당 ClubDivision enum으로 변환한다") + @ParameterizedTest + @CsvSource({"central,CENTRAL", "engineering,ENGINEERING", "etc,ETC"}) + void fromName(String name, ClubDivision clubDivision) { + // when + ClubDivision result = ClubDivision.fromName(name); + + // then + assertThat(result).isEqualTo(clubDivision); + } + + @DisplayName("존재하지 않는 name으로 ClubDivision을 찾으려 하면 예외가 발생한다") + @Test + void fromNameException() { + // given + String name = "invalid"; + + // when + ThrowingCallable actual = () -> ClubDivision.fromName(name); + + // then + assertThatThrownBy(actual) + .isInstanceOf(NotFoundException.class); + } +} diff --git a/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java b/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java index 9872aa84d..fb34e8972 100644 --- a/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java +++ b/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java @@ -116,6 +116,7 @@ public void loadData() { log.info("[DatabaseConfigurator] init start"); initAdmin(); + initClub(); initUser(); initRootUser(); initUserCategory(); @@ -130,6 +131,17 @@ public void loadData() { log.info("[DatabaseConfigurator] init complete"); } + private void initClub() { + jdbcTemplate.update( + "INSERT INTO club (name, summary, category, division, is_always) VALUES (?, ?, ?, ?, ?)", + "테스트동아리1", + "테스트 요약", + "ACADEMIC", + "CENTRAL", + false + ); + } + private void setCharsetAllTable() { for (String tableName : tableNames) { jdbcTemplate.execute("ALTER TABLE " + tableName + " CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); diff --git a/src/test/java/com/kustacks/kuring/worker/notification/ClubNotificationSchedulerTest.java b/src/test/java/com/kustacks/kuring/worker/notification/ClubNotificationSchedulerTest.java new file mode 100644 index 000000000..2fb5b544a --- /dev/null +++ b/src/test/java/com/kustacks/kuring/worker/notification/ClubNotificationSchedulerTest.java @@ -0,0 +1,46 @@ +package com.kustacks.kuring.worker.notification; + +import com.kustacks.kuring.club.application.port.in.ClubNotificationUseCase; +import com.kustacks.kuring.common.featureflag.KuringFeatures; +import com.kustacks.kuring.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@DisplayName("통합 테스트 : ClubNotificationScheduler") +class ClubNotificationSchedulerTest extends IntegrationTestSupport { + + @Autowired + private ClubNotificationScheduler scheduler; + + @SpyBean + private ClubNotificationUseCase clubNotificationUseCase; + + @DisplayName("Feature Flag가 OFF이면 동아리 알림을 발송하지 않는다") + @Test + void should_not_send_notification_when_feature_flag_disabled() { + featureFlagsSupport.setMapProperty(KuringFeatures.NOTIFY_CLUB_DEADLINE.getFeature().value(), false); + + assertThatCode(() -> scheduler.sendClubDeadlineNotifications()) + .doesNotThrowAnyException(); + + verify(clubNotificationUseCase, never()).sendDeadlineNotifications(); + } + + @DisplayName("Feature Flag가 ON이면 동아리 알림 발송 유즈케이스를 호출한다") + @Test + void should_call_use_case_when_feature_flag_enabled() { + featureFlagsSupport.setMapProperty(KuringFeatures.NOTIFY_CLUB_DEADLINE.getFeature().value(), true); + + assertThatCode(() -> scheduler.sendClubDeadlineNotifications()) + .doesNotThrowAnyException(); + + verify(clubNotificationUseCase, times(1)).sendDeadlineNotifications(); + } +}