diff --git a/src/main/java/com/kustacks/kuring/club/adapter/in/web/ClubQueryApiV2.java b/src/main/java/com/kustacks/kuring/club/adapter/in/web/ClubQueryApiV2.java new file mode 100644 index 000000000..3f4408702 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/in/web/ClubQueryApiV2.java @@ -0,0 +1,119 @@ +package com.kustacks.kuring.club.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.adapter.in.web.dto.ClubDetailResponse; +import com.kustacks.kuring.club.adapter.in.web.dto.ClubDivisionListResponse; +import com.kustacks.kuring.club.adapter.in.web.dto.ClubDivisionResponse; +import com.kustacks.kuring.club.adapter.in.web.dto.ClubListResponse; +import com.kustacks.kuring.club.application.port.in.ClubQueryUseCase; +import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubListCommand; +import com.kustacks.kuring.club.application.port.in.dto.ClubListResult; +import com.kustacks.kuring.common.annotation.RestWebAdapter; +import com.kustacks.kuring.common.data.Cursor; +import com.kustacks.kuring.common.dto.BaseResponse; +import com.kustacks.kuring.common.exception.InvalidStateException; +import com.kustacks.kuring.common.exception.code.ErrorCode; +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.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.Optional; + +import static com.kustacks.kuring.auth.authentication.AuthorizationExtractor.extractAuthorizationValue; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_DETAIL_SEARCH_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_DIVISION_SEARCH_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_LIST_SEARCH_SUCCESS; + +@Tag(name = "Club-Query", description = "동아리 정보 조회") +@Validated +@RequiredArgsConstructor +@RestWebAdapter(path = "/api/v2/clubs") +public class ClubQueryApiV2 { + + private static final String FCM_TOKEN_HEADER_KEY = "User-Token"; + private static final String JWT_TOKEN_HEADER_KEY = "JWT"; + + private final JwtTokenProvider jwtTokenProvider; + private final ClubQueryUseCase clubQueryUseCase; + + @Operation(summary = "동아리 소속 목록 조회", description = "서버가 지원하는 동아리 소속 목록을 조회합니다") + @GetMapping("/divisions") + public ResponseEntity> getSupportedClubDivisions() { + + List divisions = clubQueryUseCase.getClubDivisions() + .stream() + .map(ClubDivisionResponse::from) + .toList(); + + ClubDivisionListResponse response = new ClubDivisionListResponse(divisions); + + return ResponseEntity.ok().body(new BaseResponse<>(CLUB_DIVISION_SEARCH_SUCCESS, response)); + } + + @Operation(summary = "동아리 목록 조회", description = "필터 조건에 맞는 동아리 목록을 커서 페이징으로 조회합니다") + @SecurityRequirement(name = JWT_TOKEN_HEADER_KEY) + @GetMapping + public ResponseEntity> getClubs( + @RequestParam(required = false) String category, + @RequestParam(required = false) String division, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "20") @Min(1) @Max(30) int size, + @RequestParam(defaultValue = "name") String sortBy, + @RequestHeader(value = AuthorizationExtractor.AUTHORIZATION, required = false) String bearerToken + ) { + String email = resolveLoginEmail(bearerToken); + + ClubListCommand command = new ClubListCommand(category, division, Cursor.from(cursor), size, sortBy); + + ClubListResult result = clubQueryUseCase.getClubs(command, email); + + ClubListResponse response = ClubListResponse.from(result); + + return ResponseEntity.ok().body(new BaseResponse<>(CLUB_LIST_SEARCH_SUCCESS, response)); + } + + @Operation(summary = "동아리 상세 조회", description = "특정 동아리의 상세 정보를 조회합니다.") + @SecurityRequirement(name = FCM_TOKEN_HEADER_KEY) + @SecurityRequirement(name = JWT_TOKEN_HEADER_KEY) + @GetMapping("/{id}") + public ResponseEntity> getClubDetail( + @PathVariable Long id, + @RequestHeader(value = FCM_TOKEN_HEADER_KEY, required = false) String userToken, + @RequestHeader(value = AuthorizationExtractor.AUTHORIZATION, required = false) String bearerToken + ) { + String email = resolveLoginEmail(bearerToken); + + ClubDetailResult result = clubQueryUseCase.getClubDetail(id, email); + + ClubDetailResponse response = ClubDetailResponse.from(result); + + return ResponseEntity.ok().body(new BaseResponse<>(CLUB_DETAIL_SEARCH_SUCCESS, response)); + } + + private String resolveLoginEmail(String bearerToken) { + return Optional.ofNullable(bearerToken) + .map(token -> extractAuthorizationValue(token, AuthorizationType.BEARER)) + .map(this::validateJwtAndGetEmail) + .orElse(null); + } + + private String validateJwtAndGetEmail(String jwtToken) { + if (!jwtTokenProvider.validateToken(jwtToken)) { + throw new InvalidStateException(ErrorCode.JWT_INVALID_TOKEN); + } + return jwtTokenProvider.getPrincipal(jwtToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDetailResponse.java b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDetailResponse.java new file mode 100644 index 000000000..02f670769 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDetailResponse.java @@ -0,0 +1,68 @@ +package com.kustacks.kuring.club.adapter.in.web.dto; + +import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult; + +import java.time.LocalDateTime; + +public record ClubDetailResponse( + Long id, + String name, + String summary, + String category, + String division, + int subscriberCount, + boolean isSubscribed, + String instagramUrl, + String youtubeUrl, + String etcUrl, + String description, + String qualifications, + String recruitmentStatus, + LocalDateTime recruitStartAt, + LocalDateTime recruitEndAt, + String applyUrl, + String posterImageUrl, + Location location +) { + + public static ClubDetailResponse from(ClubDetailResult result) { + + Location location = result.location() == null ? + null + : new Location( + result.location().building(), + result.location().room(), + result.location().lon(), + result.location().lat() + ); + + return new ClubDetailResponse( + result.id(), + result.name(), + result.summary(), + result.category().getName(), + result.division().getName(), + result.subscriberCount(), + result.isSubscribed(), + result.instagramUrl(), + result.youtubeUrl(), + result.etcUrl(), + result.description(), + result.qualifications(), + result.recruitmentStatus().getValue(), + result.recruitStartAt(), + result.recruitEndAt(), + result.applyUrl(), + result.posterImageUrl(), + location + ); + } + + public record Location( + String building, + String room, + Double lon, + Double lat + ) { + } +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDivisionListResponse.java b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDivisionListResponse.java new file mode 100644 index 000000000..dc86c9f29 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDivisionListResponse.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.club.adapter.in.web.dto; + +import java.util.List; + +public record ClubDivisionListResponse( + List divisions +) { +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDivisionResponse.java b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDivisionResponse.java new file mode 100644 index 000000000..fea4e2ca2 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDivisionResponse.java @@ -0,0 +1,15 @@ +package com.kustacks.kuring.club.adapter.in.web.dto; + +import com.kustacks.kuring.club.application.port.in.dto.ClubDivisionResult; + +public record ClubDivisionResponse( + String code, + String koreanName +) { + public static ClubDivisionResponse from(ClubDivisionResult result) { + return new ClubDivisionResponse( + result.code(), + result.koreanName() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubItemResponse.java b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubItemResponse.java new file mode 100644 index 000000000..0a4a907d0 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubItemResponse.java @@ -0,0 +1,33 @@ +package com.kustacks.kuring.club.adapter.in.web.dto; + +import com.kustacks.kuring.club.application.port.in.dto.ClubItemResult; + +import java.time.LocalDateTime; + +public record ClubItemResponse( + Long id, + String name, + String summary, + String iconImageUrl, + String category, + String division, + boolean isSubscribed, + int subscriberCount, + LocalDateTime recruitStartDate, + LocalDateTime recruitEndDate +) { + public static ClubItemResponse from(ClubItemResult result) { + return new ClubItemResponse( + result.id(), + result.name(), + result.summary(), + result.iconImageUrl(), + result.category(), + result.division(), + result.isSubscribed(), + result.subscriberCount(), + result.recruitStartDate(), + result.recruitEndDate() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubListResponse.java b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubListResponse.java new file mode 100644 index 000000000..6aafa950e --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubListResponse.java @@ -0,0 +1,26 @@ +package com.kustacks.kuring.club.adapter.in.web.dto; + +import com.kustacks.kuring.club.application.port.in.dto.ClubListResult; + +import java.util.List; + +public record ClubListResponse( + List clubs, + String cursor, + boolean hasNext, + int totalCount +) { + + public static ClubListResponse from(ClubListResult result) { + List clubs = result.clubs().stream() + .map(ClubItemResponse::from) + .toList(); + + return new ClubListResponse( + clubs, + result.cursor(), + result.hasNext(), + result.totalCount() + ); + } +} 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..1886ecaad --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java @@ -0,0 +1,100 @@ +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.dto.ClubDetailDto; +import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel; +import com.kustacks.kuring.club.domain.ClubSubscribe; +import com.kustacks.kuring.common.annotation.PersistenceAdapter; +import com.kustacks.kuring.common.data.Cursor; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@PersistenceAdapter +@RequiredArgsConstructor +public class ClubPersistenceAdapter implements ClubQueryPort { + + private final ClubRepository clubRepository; + private final ClubSubscribeRepository clubSubscribeRepository; + + @Override + public List searchClubs( + String category, + List divisions, + Cursor cursor, + int size, + String sortBy, + LocalDateTime now + ) { + return clubRepository.searchClubs( + category, + divisions, + cursor == null ? null : cursor.getStringCursor(), + size, + sortBy, + now + ); + } + + @Override + public int countClubs(String category, List divisions) { + return clubRepository.countClubs(category, divisions); + } + + @Override + public Optional findClubDetailById(Long id) { + return clubRepository.findClubDetailById(id); + } + + @Override + public int countSubscribers(Long clubId) { + return clubSubscribeRepository.countByClubId(clubId); + } + + @Override + public boolean existsSubscription(Long clubId, Long loginUserId) { + return clubSubscribeRepository.existsByClubIdAndUser_LoginUserId(clubId, loginUserId); + } + + @Override + public Map countSubscribersByClubIds(List clubIds) { + + if (clubIds == null || clubIds.isEmpty()) { + return Map.of(); + } + + List subscriptions = clubSubscribeRepository.findByClubIdIn(clubIds); + + return subscriptions.stream() + .collect(Collectors.groupingBy( + sub -> sub.getClub().getId(), + Collectors.collectingAndThen( + Collectors.counting(), + Long::intValue + ) + )); + } + + @Override + public Map findSubscribedClubIds( + List clubIds, + Long loginUserId + ) { + + if (clubIds == null || clubIds.isEmpty() || loginUserId == null) { + return Map.of(); + } + + List subscriptions = clubSubscribeRepository.findByClubIdInAndUser_LoginUserId(clubIds, loginUserId); + + return subscriptions.stream() + .collect(Collectors.toMap( + sub -> sub.getClub().getId(), + sub -> true + )); + } +} 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..cc65e55a8 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepository.java @@ -0,0 +1,17 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto; +import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface ClubQueryRepository { + + List searchClubs(String category, List divisions, String cursor, int size, String sortBy, LocalDateTime now); + + int countClubs(String category, List divisions); + + Optional findClubDetailById(Long id); +} 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..0a652d2d8 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java @@ -0,0 +1,307 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto; +import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel; +import com.kustacks.kuring.club.application.port.out.dto.QClubReadModel; +import com.kustacks.kuring.club.domain.ClubCategory; +import com.kustacks.kuring.club.domain.ClubDivision; +import com.kustacks.kuring.club.domain.ClubRecruitmentStatus; +import com.kustacks.kuring.club.domain.ClubSnsType; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static com.kustacks.kuring.club.domain.QClub.club; +import static com.kustacks.kuring.club.domain.QClubSns.clubSns; + +@RequiredArgsConstructor +class ClubQueryRepositoryImpl implements ClubQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + @Transactional(readOnly = true) + public List searchClubs( + String category, + List divisions, + String cursor, + int size, + String sortBy, + LocalDateTime now + ) { + + return queryFactory + .select(new QClubReadModel( + club.id, + club.name, + club.summary, + club.posterImagePath, + club.category, + club.division, + club.recruitStartAt, + club.recruitEndAt + )) + .from(club) + .where( + categoryEq(category), + divisionIn(divisions), + cursorCondition(sortBy, cursor, now) + ) + .orderBy(getOrderSpecifiers(sortBy, now)) + .limit(size) + .fetch(); + } + + @Override + @Transactional(readOnly = true) + public int countClubs(String category, List divisions) { + + Long count = queryFactory + .select(club.count()) + .from(club) + .where( + categoryEq(category), + divisionIn(divisions) + ) + .fetchOne(); + + return count == null ? 0 : count.intValue(); + } + + @Override + @Transactional(readOnly = true) + public Optional findClubDetailById(Long id) { + + LocalDateTime now = LocalDateTime.now(); + + List tuples = queryFactory + .select( + club.id, + club.name, + club.summary, + club.category, + club.division, + club.description, + club.qualifications, + club.recruitStartAt, + club.recruitEndAt, + club.isAlways, + club.applyUrl, + club.posterImagePath, + club.building, + club.room, + club.lon, + club.lat, + clubSns.type, + clubSns.url + ) + .from(club) + .leftJoin(club.homepageUrls, clubSns) + .where(club.id.eq(id)) + .fetch(); + + if (tuples.isEmpty()) { + return Optional.empty(); + } + + Tuple first = tuples.get(0); + + String instagram = null; + String youtube = null; + String etc = null; + + for (Tuple t : tuples) { + ClubSnsType type = t.get(clubSns.type); + String url = t.get(clubSns.url); + + if (type == null) continue; + + switch (type) { + case INSTAGRAM -> instagram = url; + case YOUTUBE -> youtube = url; + case ETC -> etc = url; + } + } + + ClubRecruitmentStatus recruitmentStatus = calculateRecruitmentStatus( + first.get(club.recruitStartAt), + first.get(club.recruitEndAt), + first.get(club.isAlways), + now + ); + + return Optional.of( + new ClubDetailDto( + first.get(club.id), + first.get(club.name), + first.get(club.summary), + first.get(club.category), + first.get(club.division), + instagram, + youtube, + etc, + first.get(club.description), + first.get(club.qualifications), + recruitmentStatus, + first.get(club.recruitStartAt), + first.get(club.recruitEndAt), + first.get(club.applyUrl), + first.get(club.posterImagePath), + first.get(club.building), + first.get(club.room), + first.get(club.lon), + first.get(club.lat) + ) + ); + } + + + private BooleanExpression categoryEq(String category) { + if (category == null) return null; + return club.category.eq(ClubCategory.fromName(category)); + } + + private BooleanExpression divisionIn(List divisions) { + if (divisions == null || divisions.isEmpty()) return null; + + return club.division.in( + divisions.stream() + .map(ClubDivision::fromName) + .toList() + ); + } + + private BooleanExpression cursorCondition(String sortBy, String cursor, LocalDateTime now) { + + if (cursor == null || cursor.equals("0")) return null; + + try { + + String[] parts = cursor.split("\\|"); + + return switch (sortBy) { + + case "name" -> { + if (parts.length < 2) yield null; + + String lastName = parts[0]; + Long lastId = Long.parseLong(parts[1]); + + yield club.name.gt(lastName) + .or( + club.name.eq(lastName) + .and(club.id.gt(lastId)) + ); + } + + case "recruitEndDate" -> { + if (parts.length < 3) yield null; + + int lastGroup = Integer.parseInt(parts[0]); + String lastDateStr = parts[1]; + Long lastId = Long.parseLong(parts[2]); + + NumberExpression currentGroup = recruitmentGroup(now); + + BooleanExpression groupCondition = currentGroup.gt(lastGroup); + + BooleanExpression sameGroupCondition; + + if ("null".equals(lastDateStr)) { + sameGroupCondition = currentGroup.eq(lastGroup) + .and(club.id.gt(lastId)); + } else { + LocalDateTime lastDate = LocalDateTime.parse(lastDateStr); + sameGroupCondition = currentGroup.eq(lastGroup) + .and( + club.recruitEndAt.gt(lastDate) + .or( + club.recruitEndAt.eq(lastDate) + .and(club.id.gt(lastId)) + ) + ); + } + yield groupCondition.or(sameGroupCondition); + + } + + default -> { + Long lastId = Long.parseLong(cursor); + yield club.id.gt(lastId); + } + }; + + } catch (Exception e) { + return null; + } + } + + private OrderSpecifier[] getOrderSpecifiers(String sortBy, LocalDateTime now) { + + return switch (sortBy) { + + case "name" -> new OrderSpecifier[]{ + club.name.asc(), + club.id.asc() + }; + + case "recruitEndDate" -> { + + var statusOrder = new CaseBuilder() + .when(club.recruitEndAt.isNull()).then(2) + .when(club.recruitEndAt.lt(now)).then(1) + .otherwise(0); + + yield new OrderSpecifier[]{ + statusOrder.asc(), + club.recruitEndAt.asc().nullsLast(), + club.id.asc() + }; + } + + default -> new OrderSpecifier[]{ + club.id.asc() + }; + }; + } + + private NumberExpression recruitmentGroup(LocalDateTime now) { + return new CaseBuilder() + .when(club.recruitEndAt.isNull()).then(2) + .when(club.recruitEndAt.lt(now)).then(1) + .otherwise(0); + } + + private ClubRecruitmentStatus calculateRecruitmentStatus( + LocalDateTime start, + LocalDateTime end, + Boolean isAlways, + LocalDateTime now + ) { + + if (Boolean.TRUE.equals(isAlways)) { + return ClubRecruitmentStatus.ALWAYS; + } + + if (start != null && now.isBefore(start)) { + return ClubRecruitmentStatus.BEFORE; + } + + if (end != null && now.isAfter(end)) { + return ClubRecruitmentStatus.CLOSED; + } + + return ClubRecruitmentStatus.RECRUITING; + } + + +} 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..253dbded5 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubSubscribeRepository.java @@ -0,0 +1,17 @@ +package com.kustacks.kuring.club.adapter.out.persistence; + +import com.kustacks.kuring.club.domain.ClubSubscribe; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ClubSubscribeRepository extends JpaRepository { + + int countByClubId(Long clubId); + + boolean existsByClubIdAndUser_LoginUserId(Long clubId, Long loginUserId); + + List findByClubIdIn(List clubIds); + + List findByClubIdInAndUser_LoginUserId(List clubIds, Long loginUserId); +} \ No newline at end of file diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/ClubQueryUseCase.java b/src/main/java/com/kustacks/kuring/club/application/port/in/ClubQueryUseCase.java new file mode 100644 index 000000000..c57184ce6 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/ClubQueryUseCase.java @@ -0,0 +1,17 @@ +package com.kustacks.kuring.club.application.port.in; + +import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubDivisionResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubListCommand; +import com.kustacks.kuring.club.application.port.in.dto.ClubListResult; + +import java.util.List; + +public interface ClubQueryUseCase { + List getClubDivisions(); + + ClubListResult getClubs(ClubListCommand command, String email); + + ClubDetailResult getClubDetail(Long id, String email); + +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubDetailResult.java b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubDetailResult.java new file mode 100644 index 000000000..5ecba2b5a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubDetailResult.java @@ -0,0 +1,36 @@ +package com.kustacks.kuring.club.application.port.in.dto; + +import com.kustacks.kuring.club.domain.ClubCategory; +import com.kustacks.kuring.club.domain.ClubDivision; +import com.kustacks.kuring.club.domain.ClubRecruitmentStatus; + +import java.time.LocalDateTime; + +public record ClubDetailResult( + Long id, + String name, + String summary, + ClubCategory category, + ClubDivision division, + int subscriberCount, + boolean isSubscribed, + String instagramUrl, + String youtubeUrl, + String etcUrl, + String description, + String qualifications, + ClubRecruitmentStatus recruitmentStatus, + LocalDateTime recruitStartAt, + LocalDateTime recruitEndAt, + String applyUrl, + String posterImageUrl, + Location location +) { + public record Location( + String building, + String room, + Double lon, + Double lat + ) { + } +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubDivisionResult.java b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubDivisionResult.java new file mode 100644 index 000000000..3a701c47b --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubDivisionResult.java @@ -0,0 +1,15 @@ +package com.kustacks.kuring.club.application.port.in.dto; + +import com.kustacks.kuring.club.domain.ClubDivision; + +public record ClubDivisionResult( + String code, + String koreanName +) { + public static ClubDivisionResult from(ClubDivision division) { + return new ClubDivisionResult( + division.getName(), + division.getKorName() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubItemResult.java b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubItemResult.java new file mode 100644 index 000000000..85132225e --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubItemResult.java @@ -0,0 +1,17 @@ +package com.kustacks.kuring.club.application.port.in.dto; + +import java.time.LocalDateTime; + +public record ClubItemResult( + Long id, + String name, + String summary, + String iconImageUrl, + String category, + String division, + boolean isSubscribed, + int subscriberCount, + LocalDateTime recruitStartDate, + LocalDateTime recruitEndDate +) { +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubListCommand.java b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubListCommand.java new file mode 100644 index 000000000..a1422a099 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubListCommand.java @@ -0,0 +1,24 @@ +package com.kustacks.kuring.club.application.port.in.dto; + +import com.kustacks.kuring.common.data.Cursor; + +import java.util.Arrays; +import java.util.List; + +public record ClubListCommand( + String category, + String division, + Cursor cursor, + int size, + String sortBy +) { + public List divisionList() { + if (division == null || division.isBlank()) { + return null; + } + + return Arrays.stream(division.split(",")) + .map(String::trim) + .toList(); + } +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubListResult.java b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubListResult.java new file mode 100644 index 000000000..a10860e82 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubListResult.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.club.application.port.in.dto; + +import java.util.List; + +public record ClubListResult( + List clubs, + String cursor, + boolean hasNext, + int totalCount +) { +} 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..f3b5fde1a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/out/ClubQueryPort.java @@ -0,0 +1,27 @@ +package com.kustacks.kuring.club.application.port.out; + +import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto; +import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel; +import com.kustacks.kuring.common.data.Cursor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface ClubQueryPort { + + List searchClubs(String category, List divisions, Cursor cursor, int size, String sortBy, LocalDateTime now); + + int countClubs(String category, List divisions); + + Optional findClubDetailById(Long id); + + int countSubscribers(Long clubId); + + boolean existsSubscription(Long clubId, Long loginUserId); + + Map countSubscribersByClubIds(List clubIds); + + Map findSubscribedClubIds(List clubIds, Long loginUserId); +} diff --git a/src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubDetailDto.java b/src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubDetailDto.java new file mode 100644 index 000000000..83f9c589f --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubDetailDto.java @@ -0,0 +1,84 @@ +package com.kustacks.kuring.club.application.port.out.dto; + +import com.kustacks.kuring.club.domain.ClubCategory; +import com.kustacks.kuring.club.domain.ClubDivision; +import com.kustacks.kuring.club.domain.ClubRecruitmentStatus; +import com.querydsl.core.annotations.QueryProjection; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ClubDetailDto { + + private Long id; + private String name; + private String summary; + private ClubCategory category; + private ClubDivision division; + + private String instagramUrl; + private String youtubeUrl; + private String etcUrl; + + private String description; + private String qualifications; + private ClubRecruitmentStatus recruitmentStatus; + + private LocalDateTime recruitStartAt; + private LocalDateTime recruitEndAt; + + private String applyUrl; + private String posterImagePath; + + private String building; + private String room; + private Double lon; + private Double lat; + + @QueryProjection + public ClubDetailDto( + Long id, + String name, + String summary, + ClubCategory category, + ClubDivision division, + String instagramUrl, + String youtubeUrl, + String etcUrl, + String description, + String qualifications, + ClubRecruitmentStatus recruitmentStatus, + LocalDateTime recruitStartAt, + LocalDateTime recruitEndAt, + String applyUrl, + String posterImagePath, + String building, + String room, + Double lon, + Double lat + ) { + this.id = id; + this.name = name; + this.summary = summary; + this.category = category; + this.division = division; + this.instagramUrl = instagramUrl; + this.youtubeUrl = youtubeUrl; + this.etcUrl = etcUrl; + this.description = description; + this.qualifications = qualifications; + this.recruitmentStatus = recruitmentStatus; + this.recruitStartAt = recruitStartAt; + this.recruitEndAt = recruitEndAt; + this.applyUrl = applyUrl; + this.posterImagePath = posterImagePath; + this.building = building; + this.room = room; + this.lon = lon; + this.lat = lat; + } +} \ No newline at end of file diff --git a/src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubReadModel.java b/src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubReadModel.java new file mode 100644 index 000000000..e55a3a54c --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubReadModel.java @@ -0,0 +1,42 @@ +package com.kustacks.kuring.club.application.port.out.dto; + +import com.kustacks.kuring.club.domain.ClubCategory; +import com.kustacks.kuring.club.domain.ClubDivision; +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ClubReadModel { + + private final Long id; + private final String name; + private final String summary; + private final String iconImageUrl; + private final ClubCategory category; + private final ClubDivision division; + private final LocalDateTime recruitStartDate; + private final LocalDateTime recruitEndDate; + + @QueryProjection + public ClubReadModel( + Long id, + String name, + String summary, + String iconImageUrl, + ClubCategory category, + ClubDivision division, + LocalDateTime recruitStartDate, + LocalDateTime recruitEndDate + ) { + this.id = id; + this.name = name; + this.summary = summary; + this.iconImageUrl = iconImageUrl; + this.category = category; + this.division = division; + this.recruitStartDate = recruitStartDate; + this.recruitEndDate = recruitEndDate; + } +} diff --git a/src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java b/src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java new file mode 100644 index 000000000..a9f65ac86 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java @@ -0,0 +1,183 @@ +package com.kustacks.kuring.club.application.service; + +import com.kustacks.kuring.club.application.port.in.ClubQueryUseCase; +import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubDivisionResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubItemResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubListCommand; +import com.kustacks.kuring.club.application.port.in.dto.ClubListResult; +import com.kustacks.kuring.club.application.port.out.ClubQueryPort; +import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto; +import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel; +import com.kustacks.kuring.club.domain.ClubDivision; +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.common.data.CursorBasedList; +import com.kustacks.kuring.common.exception.NotFoundException; +import com.kustacks.kuring.user.application.port.out.RootUserQueryPort; +import com.kustacks.kuring.user.domain.RootUser; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.kustacks.kuring.common.exception.code.ErrorCode.CLUB_NOT_FOUND; + +@UseCase +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ClubQueryService implements ClubQueryUseCase { + + private final ClubQueryPort clubQueryPort; + private final RootUserQueryPort rootUserQueryPort; + + @Override + public List getClubDivisions() { + return Arrays.stream(ClubDivision.values()) + .map(ClubDivisionResult::from) + .toList(); + } + + @Override + public ClubListResult getClubs(ClubListCommand command, String email) { + + Optional optionalRootUser = rootUserQueryPort.findRootUserByEmail(email); + Long loginUserId = optionalRootUser.map(RootUser::getId).orElse(null); + + int limit = Math.min(command.size(), 30); + + LocalDateTime now = LocalDateTime.now(); + + CursorBasedList cursorBasedList = CursorBasedList.of( + limit, + club -> generateCursor(club, command.sortBy(), now), + searchSize -> clubQueryPort.searchClubs( + command.category(), + command.divisionList(), + command.cursor(), + searchSize, + command.sortBy(), + now + ) + ); + + List clubIds = cursorBasedList.getContents() + .stream() + .map(ClubReadModel::getId) + .toList(); + + Map subscriberCountMap = clubQueryPort.countSubscribersByClubIds(clubIds); + + Map subscribedMap = loginUserId != null + ? clubQueryPort.findSubscribedClubIds(clubIds, loginUserId) + : Map.of(); + + + List items = + cursorBasedList.getContents() + .stream() + .map(r -> new ClubItemResult( + r.getId(), + r.getName(), + r.getSummary(), + r.getIconImageUrl(), + r.getCategory().getName(), + r.getDivision().getName(), + subscribedMap.getOrDefault(r.getId(), false), + subscriberCountMap.getOrDefault(r.getId(), 0), + r.getRecruitStartDate(), + r.getRecruitEndDate() + )) + .toList(); + + + int totalCount = clubQueryPort.countClubs(command.category(), command.divisionList()); + + return new ClubListResult( + items, + cursorBasedList.getEndCursor(), + cursorBasedList.hasNext(), + totalCount + ); + } + + @Override + public ClubDetailResult getClubDetail(Long id, String email) { + + Optional optionalRootUser = rootUserQueryPort.findRootUserByEmail(email); + Long loginUserId = optionalRootUser.map(RootUser::getId).orElse(null); + + ClubDetailDto dto = clubQueryPort.findClubDetailById(id) + .orElseThrow(() -> new NotFoundException(CLUB_NOT_FOUND)); + + int subscriberCount = clubQueryPort.countSubscribers(id); + + boolean isSubscribed = false; + if (loginUserId != null) { + isSubscribed = clubQueryPort.existsSubscription(id, loginUserId); + } + + ClubDetailResult.Location location = hasLocation(dto) ? + new ClubDetailResult.Location( + dto.getBuilding(), + dto.getRoom(), + dto.getLon(), + dto.getLat() + ) + : null; + + return new ClubDetailResult( + dto.getId(), + dto.getName(), + dto.getSummary(), + dto.getCategory(), + dto.getDivision(), + subscriberCount, + isSubscribed, + dto.getInstagramUrl(), + dto.getYoutubeUrl(), + dto.getEtcUrl(), + dto.getDescription(), + dto.getQualifications(), + dto.getRecruitmentStatus(), + dto.getRecruitStartAt(), + dto.getRecruitEndAt(), + dto.getApplyUrl(), + dto.getPosterImagePath(), + location + ); + } + + private boolean hasLocation(ClubDetailDto dto) { + return dto.getBuilding() != null + || dto.getRoom() != null + || dto.getLon() != null + || dto.getLat() != null; + } + + private String generateCursor(ClubReadModel club, String sortBy, LocalDateTime now) { + return switch (sortBy) { + case "name" -> club.getName() + "|" + club.getId(); + case "recruitEndDate" -> { + int group; + if (club.getRecruitEndDate() == null) { + group = 2; + } else if (club.getRecruitEndDate().isBefore(now)) { + group = 1; + } else { + group = 0; + } + + String datePart = club.getRecruitEndDate() == null + ? "null" + : club.getRecruitEndDate().toString(); + + yield group + "|" + datePart + "|" + club.getId(); + } + default -> club.getId().toString(); + }; + } +} diff --git a/src/main/java/com/kustacks/kuring/club/domain/ClubRecruitmentStatus.java b/src/main/java/com/kustacks/kuring/club/domain/ClubRecruitmentStatus.java new file mode 100644 index 000000000..a38e88f3e --- /dev/null +++ b/src/main/java/com/kustacks/kuring/club/domain/ClubRecruitmentStatus.java @@ -0,0 +1,18 @@ +package com.kustacks.kuring.club.domain; + +import lombok.Getter; + +@Getter +public enum ClubRecruitmentStatus { + + ALWAYS("always"), + BEFORE("before"), + RECRUITING("recruiting"), + CLOSED("closed"); + + private final String value; + + ClubRecruitmentStatus(String value) { + this.value = value; + } +} \ No newline at end of file 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..2484bb05a 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,11 @@ public enum ResponseCodeAndMessages { ACADEMIC_EVENT_SEARCH_SUCCESS(HttpStatus.OK.value(), "학사일정 조회에 성공했습니다."), ACADEMIC_EVENT_NOTIFICATION_UPDATE_SUCCESS(HttpStatus.OK.value(), "학사일정 알림 설정이 변경되었습니다."), + /* Club */ + CLUB_DIVISION_SEARCH_SUCCESS(HttpStatus.OK.value(), "지원하는 동아리 소속 조회에 성공하였습니다"), + CLUB_LIST_SEARCH_SUCCESS(HttpStatus.OK.value(), "동아리 목록 조회에 성공하였습니다"), + CLUB_DETAIL_SEARCH_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 184dce6dc..fe3c30365 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 @@ -59,7 +59,7 @@ public enum ErrorCode { CLUB_CATEGORY_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "서버에서 지원하지 않는 동아리 카테고리입니다."), CLUB_DIVISION_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "서버에서 지원하지 않는 동아리 소속입니다."), - + CLUB_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 동아리를 찾을 수 없습니다."), STAFF_SCRAPER_EXCEED_RETRY_LIMIT("교직원 업데이트 재시도 횟수를 초과했습니다."), STAFF_SCRAPER_CANNOT_SCRAP("건국대학교 홈페이지가 불안정합니다. 교직원 정보를 가져올 수 없습니다."), @@ -113,8 +113,7 @@ public enum ErrorCode { QUESTION_COUNT_NOT_ENOUGH(HttpStatus.TOO_MANY_REQUESTS, "남은 질문 횟수가 부족합니다."), STORAGE_S3_SDK_PROBLEM(HttpStatus.INTERNAL_SERVER_ERROR, "S3 클라이언트 통신 간 에러가 발생했습니다."), - FILE_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일을 읽어들이는데 문제가 발생했습니다.") - ; + FILE_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일을 읽어들이는데 문제가 발생했습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java b/src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java new file mode 100644 index 000000000..a339e7651 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java @@ -0,0 +1,308 @@ +package com.kustacks.kuring.club.application.service; + +import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubDivisionResult; +import com.kustacks.kuring.club.application.port.in.dto.ClubListCommand; +import com.kustacks.kuring.club.application.port.in.dto.ClubListResult; +import com.kustacks.kuring.club.application.port.out.ClubQueryPort; +import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto; +import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel; +import com.kustacks.kuring.club.domain.ClubCategory; +import com.kustacks.kuring.club.domain.ClubDivision; +import com.kustacks.kuring.club.domain.ClubRecruitmentStatus; +import com.kustacks.kuring.common.data.Cursor; +import com.kustacks.kuring.user.application.port.out.RootUserQueryPort; +import com.kustacks.kuring.user.domain.RootUser; +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 java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("서비스 : ClubQueryService") +@ExtendWith(MockitoExtension.class) +class ClubQueryServiceTest { + + @Mock + private ClubQueryPort clubQueryPort; + + @Mock + private RootUserQueryPort rootUserQueryPort; + + @InjectMocks + private ClubQueryService clubQueryService; + + private List mockReadModels = + List.of( + new ClubReadModel( + 1L, + "쿠링", + "건국대 공지사항 앱 만드는 개발 동아리", + "icon-url-1", + ClubCategory.ACADEMIC, + ClubDivision.CENTRAL, + LocalDateTime.of(2025, 3, 1, 0, 0), + LocalDateTime.of(2025, 3, 31, 23, 59) + ), + new ClubReadModel( + 2L, + "쿠잇", + "건국대 개발 동아리", + "icon-url-2", + ClubCategory.ACADEMIC, + ClubDivision.ENGINEERING, + LocalDateTime.of(2025, 3, 1, 0, 0), + LocalDateTime.of(2025, 3, 31, 23, 59) + ), + new ClubReadModel( + 3L, + "DIUS", + "건국대 공과대학 댄스 동아리", + "icon-url-3", + ClubCategory.CULTURE_ART, + ClubDivision.ENGINEERING, + LocalDateTime.of(2025, 2, 20, 0, 0), + LocalDateTime.of(2025, 3, 31, 23, 59) + ) + ); + + @Test + @DisplayName("동아리 소속 목록을 정상적으로 조회한다") + void getClubDivisions_success() { + // when + List results = clubQueryService.getClubDivisions(); + + List expected = + Arrays.stream(ClubDivision.values()) + .map(d -> new ClubDivisionResult(d.getName(), d.getKorName())) + .toList(); + + // then + assertThat(results) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @Test + @DisplayName("동아리 목록을 정상적으로 조회한다") + void getClubs_success() { + + // given + String category = "academic"; + String divisions = "central,engineering"; + Cursor cursor = Cursor.from(null); + int size = 10; + String sortBy = "name"; + + String email = "test@test.com"; + Long loginUserId = 100L; + + RootUser rootUser = mock(RootUser.class); + when(rootUser.getId()).thenReturn(loginUserId); + when(rootUserQueryPort.findRootUserByEmail(email)) + .thenReturn(Optional.of(rootUser)); + + ClubListCommand command = new ClubListCommand(category, divisions, cursor, size, sortBy); + + List divisionList = List.of("central", "engineering"); + + when(clubQueryPort.searchClubs( + eq(category), + eq(divisionList), + eq(cursor), + eq(size + 1), + eq(sortBy), + any(LocalDateTime.class) + )).thenReturn(mockReadModels); + + when(clubQueryPort.countClubs(category, divisionList)) + .thenReturn(2); + + when(clubQueryPort.countSubscribersByClubIds(any())) + .thenReturn(Map.of( + 1L, 10, + 2L, 10, + 3L, 10 + )); + + when(clubQueryPort.findSubscribedClubIds(any(), anyLong())) + .thenReturn(Map.of( + 1L, true, + 2L, true, + 3L, true + )); + + // when + ClubListResult result = clubQueryService.getClubs(command, email); + + // then + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.clubs()).hasSize(3); + assertThat(result.hasNext()).isFalse(); + assertThat(result.cursor()).isNull(); + assertThat(result.clubs().get(0).subscriberCount()).isEqualTo(10); + assertThat(result.clubs().get(0).isSubscribed()).isTrue(); + + verify(rootUserQueryPort).findRootUserByEmail(email); + verify(clubQueryPort).searchClubs(eq(category), eq(divisionList), eq(cursor), eq(size + 1), eq(sortBy), any(LocalDateTime.class)); + verify(clubQueryPort).countClubs(category, divisionList); + } + + @Test + @DisplayName("비로그인 사용자는 목록에서 구독 여부를 조회하지 않는다") + void getClubs_withoutLogin() { + //given + String category = "academic"; + String divisions = "central,engineering"; + Cursor cursor = Cursor.from(null); + int size = 10; + String sortBy = "name"; + + ClubListCommand command = new ClubListCommand(category, divisions, cursor, size, sortBy); + + List divisionList = List.of("central", "engineering"); + + when(clubQueryPort.searchClubs( + eq(category), + eq(divisionList), + eq(cursor), + eq(size + 1), + eq(sortBy), + any(LocalDateTime.class) + )).thenReturn(mockReadModels); + + when(clubQueryPort.countClubs(category, divisionList)) + .thenReturn(2); + + when(clubQueryPort.countSubscribersByClubIds(any())) + .thenReturn(Map.of( + 1L, 5, + 2L, 5, + 3L, 5 + )); + + //when + ClubListResult result = clubQueryService.getClubs(command, null); + + //then + assertThat(result.clubs().get(0).isSubscribed()).isFalse(); + verify(clubQueryPort, never()).existsSubscription(anyLong(), anyLong()); + } + + @Test + @DisplayName("동아리 상세 정보를 정상적으로 조회한다") + void getClubDetail_success() { + // given + Long clubId = 1L; + + String email = "test@test.com"; + Long loginUserId = 100L; + + RootUser rootUser = mock(RootUser.class); + when(rootUser.getId()).thenReturn(loginUserId); + when(rootUserQueryPort.findRootUserByEmail(email)) + .thenReturn(Optional.of(rootUser)); + + ClubDetailDto dto = new ClubDetailDto( + 1L, + "쿠링", + "건국대 공지사항 앱 만드는 개발 동아리", + ClubCategory.ACADEMIC, + ClubDivision.CENTRAL, + "instagram-url", + "youtube-url", + null, + "상세 설명", + "지원 자격", + ClubRecruitmentStatus.RECRUITING, + LocalDateTime.of(2025, 3, 1, 0, 0), + LocalDateTime.of(2025, 3, 31, 23, 59), + "apply-url", + "poster-path", + "공학관", + "101호", + 127.0, + 37.5 + ); + + when(clubQueryPort.findClubDetailById(clubId)) + .thenReturn(Optional.of(dto)); + + when(clubQueryPort.countSubscribers(clubId)) + .thenReturn(10); + + when(clubQueryPort.existsSubscription(clubId, loginUserId)) + .thenReturn(true); + + // when + ClubDetailResult result = clubQueryService.getClubDetail(clubId, email); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.name()).isEqualTo("쿠링"); + assertThat(result.subscriberCount()).isEqualTo(10); + assertThat(result.isSubscribed()).isTrue(); + assertThat(result.location().building()).isEqualTo("공학관"); + assertThat(result.recruitmentStatus()) + .isEqualTo(ClubRecruitmentStatus.RECRUITING); + assertThat(result.category()) + .isEqualTo(ClubCategory.ACADEMIC); + assertThat(result.division()) + .isEqualTo(ClubDivision.CENTRAL); + + verify(rootUserQueryPort).findRootUserByEmail(email); + verify(clubQueryPort).findClubDetailById(clubId); + verify(clubQueryPort).countSubscribers(clubId); + verify(clubQueryPort).existsSubscription(clubId, loginUserId); + } + + @Test + @DisplayName("비로그인 사용자는 구독 여부를 조회하지 않는다") + void getClubDetail_withoutLogin() { + + Long clubId = 1L; + + ClubDetailDto dto = new ClubDetailDto( + 1L, "쿠링", "건국대 공지사항 앱 만드는 개발 동아리", + ClubCategory.ACADEMIC, ClubDivision.CENTRAL, + null, null, null, + null, null, + ClubRecruitmentStatus.RECRUITING, + null, null, + null, null, + null, null, + null, null + ); + + when(clubQueryPort.findClubDetailById(clubId)) + .thenReturn(Optional.of(dto)); + + when(clubQueryPort.countSubscribers(clubId)) + .thenReturn(5); + + // when + ClubDetailResult result = + clubQueryService.getClubDetail(clubId, null); + + // then + assertThat(result.isSubscribed()).isFalse(); + verify(clubQueryPort).countSubscribers(clubId); + verify(clubQueryPort, never()).existsSubscription(anyLong(), any()); + assertThat(result.subscriberCount()).isEqualTo(5); + } +}