diff --git a/docs/lld/LLD-0022-weekly-challenge-api.md b/docs/lld/LLD-0022-weekly-challenge-api.md index 0bbf965..7aa9012 100644 --- a/docs/lld/LLD-0022-weekly-challenge-api.md +++ b/docs/lld/LLD-0022-weekly-challenge-api.md @@ -12,7 +12,7 @@ ## 1. 목적 / 배경 -주간 챌린지는 그룹 단위로 특정 기간 동안 사진 인증을 수행하는 챌린지다. +주간 챌린지는 전역 원본을 한 번 운영하고, 각 그룹이 연결된 별도 진행 항목에서 사진 인증을 수행하는 챌린지다. 앱은 이번 주 챌린지 목록, 챌린지 상세, 그룹원 인증 이미지, 내 인증 업로드 결과를 조회할 수 있어야 한다. ## 2. 범위 @@ -23,27 +23,28 @@ - 주간 챌린지 상세 조회. - 그룹원 인증 이미지 조회. - 주간 챌린지 인증 imageKey와 이미지 crop region 저장. -- 운영자 주간 사진 챌린지 생성. +- 운영자 전역 주간 사진 챌린지 등록·조회·수정·삭제. - 그룹 구성원 권한 검증. - 중복 인증 방지. - Swagger 성공/예외 응답 문서화. ### Out of scope -- 주간 챌린지 자동 배정/생성 배치. +- 완료된 전역 주간 챌린지의 재사용 정책. - 챌린지 완료 자동 판정. - 공유용 합성 이미지 생성. - 주간 챌린지 완료 알림 발송. ## 3. 데이터 모델 -- `challenge`: 주간 챌린지 마스터. `challengeType = PHOTO`. -- `group_challenge`: 그룹에서 특정 기간 동안 진행하는 챌린지 인스턴스. +- `challenge`: 사진 챌린지 제목과 설명 메타데이터. `challengeType = PHOTO`. +- `global_weekly_challenge`: 전역 운영 원본. `challenge_id`, 시작일, 마감일을 소유한다. +- `group_challenge`: 그룹에서 특정 기간 동안 진행하는 챌린지 인스턴스. 전역 항목은 `global_weekly_challenge_id`로 원본을 연결하고, 기존 그룹별 항목은 이 값이 `null`인 레거시 데이터로 유지한다. - `challenge_proof`: 그룹 챌린지에 대한 회원별 사진 인증 기록. - `image_key`: 원본 이미지 key. - `crop_x`, `crop_y`, `crop_width`, `crop_height`: 원본 이미지 기준 관심 영역 정규화 좌표. 없으면 null. -운영자는 관리자 API로 임의의 `PHOTO` 타입 챌린지와 그룹 챌린지 인스턴스를 함께 생성한다. +운영자는 전역 관리자 API로 원본을 한 번 생성한다. 생성 시 모든 활성 그룹에 연결된 진행 인스턴스를 만들고, 새 그룹 생성 시 진행 중인 원본도 자동으로 연결한다. 관리자 API는 `X-Admin-Api-Key` 헤더가 `ADMIN_API_KEY` 환경변수와 일치해야 호출할 수 있다. ## 4. API 동작 @@ -109,10 +110,10 @@ POST /api/v1/groups/{groupId}/weekly-challenges/{groupChallengeId}/proofs 인증은 `IN_PROGRESS` 상태이며 시작일과 마감일 사이에 있는 챌린지에서만 가능하다. -### 운영자 주간 사진 챌린지 생성 +### 운영자 전역 주간 사진 챌린지 관리 ```http -POST /api/v1/admin/groups/{groupId}/weekly-challenges +POST /api/v1/admin/weekly-challenges X-Admin-Api-Key: {ADMIN_API_KEY} ``` @@ -125,7 +126,15 @@ X-Admin-Api-Key: {ADMIN_API_KEY} } ``` -요청한 그룹에 사진 챌린지 템플릿과 진행 인스턴스를 생성한다. 현재는 운영 입력을 위한 API만 제공하며, 별도 관리자 웹 화면은 이 API 위에 추가할 수 있다. +전역 원본과 사진 챌린지 메타데이터를 생성하고, 모든 활성 그룹에 원본을 참조하는 진행 인스턴스를 생성한다. + +```http +GET /api/v1/admin/weekly-challenges +PUT /api/v1/admin/weekly-challenges/{globalWeeklyChallengeId} +DELETE /api/v1/admin/weekly-challenges/{globalWeeklyChallengeId} +``` + +수정은 원본과 연결된 그룹 진행 인스턴스의 기간에 반영한다. 삭제는 원본과 사진 챌린지 메타데이터를 비활성화하며, 연결된 그룹 인증 데이터는 보존한다. 기존 그룹별 관리자 API는 레거시 그룹별 항목과의 호환성을 위해 유지한다. ## 5. 예외 코드 diff --git a/src/main/java/cmc/mody/challenge/application/AdminChallengeService.java b/src/main/java/cmc/mody/challenge/application/AdminChallengeService.java index 47405df..37493b3 100644 --- a/src/main/java/cmc/mody/challenge/application/AdminChallengeService.java +++ b/src/main/java/cmc/mody/challenge/application/AdminChallengeService.java @@ -68,6 +68,7 @@ public WeeklyChallengeListResult getWeeklyChallenges(Long groupId) { .collect(Collectors.toMap(Challenge::getId, Function.identity())); return new WeeklyChallengeListResult(groupChallenges.stream() + .filter(groupChallenge -> groupChallenge.getGlobalWeeklyChallengeId() == null) .filter(groupChallenge -> challengesById.containsKey(groupChallenge.getChallengeId())) .map(groupChallenge -> WeeklyChallengeResult.from(groupChallenge, challengesById.get(groupChallenge.getChallengeId()))) .toList()); @@ -82,6 +83,7 @@ public WeeklyChallengeResult updateWeeklyChallenge( validateGroup(groupId); GroupChallenge groupChallenge = groupChallengeRepository.findByIdAndGroupIdAndDeletedAtIsNull(groupChallengeId, groupId) .orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND)); + validateLegacyWeeklyChallenge(groupChallenge); Challenge challenge = challengeRepository.findByIdAndChallengeTypeAndDeletedAtIsNull( groupChallenge.getChallengeId(), ChallengeType.PHOTO @@ -97,6 +99,7 @@ public void deleteWeeklyChallenge(Long groupId, Long groupChallengeId) { validateGroup(groupId); GroupChallenge groupChallenge = groupChallengeRepository.findByIdAndGroupIdAndDeletedAtIsNull(groupChallengeId, groupId) .orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND)); + validateLegacyWeeklyChallenge(groupChallenge); groupChallenge.delete(); } @@ -106,6 +109,12 @@ private void validateGroup(Long groupId) { .orElseThrow(() -> new GeneralException(ErrorStatus.GROUP_NOT_FOUND)); } + private void validateLegacyWeeklyChallenge(GroupChallenge groupChallenge) { + if (groupChallenge.getGlobalWeeklyChallengeId() != null) { + throw new GeneralException(ErrorStatus.CHALLENGE_VALIDATION_FAILED); + } + } + public record WeeklyChallengeCreateCommand(String title, String description, LocalDate startsOn, LocalDate endsOn) { } diff --git a/src/main/java/cmc/mody/challenge/application/GlobalWeeklyChallengeService.java b/src/main/java/cmc/mody/challenge/application/GlobalWeeklyChallengeService.java new file mode 100644 index 0000000..1ae4cd2 --- /dev/null +++ b/src/main/java/cmc/mody/challenge/application/GlobalWeeklyChallengeService.java @@ -0,0 +1,197 @@ +package cmc.mody.challenge.application; + +import cmc.mody.challenge.domain.Challenge; +import cmc.mody.challenge.domain.ChallengeType; +import cmc.mody.challenge.domain.GlobalWeeklyChallenge; +import cmc.mody.challenge.domain.GroupChallenge; +import cmc.mody.challenge.infrastructure.repository.ChallengeRepository; +import cmc.mody.challenge.infrastructure.repository.GlobalWeeklyChallengeRepository; +import cmc.mody.challenge.infrastructure.repository.GroupChallengeRepository; +import cmc.mody.common.api.exception.GeneralException; +import cmc.mody.common.api.status.ErrorStatus; +import cmc.mody.common.id.IdGenerator; +import cmc.mody.grouping.domain.ModyGroup; +import cmc.mody.grouping.infrastructure.repository.ModyGroupRepository; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GlobalWeeklyChallengeService { + private final IdGenerator idGenerator; + private final ChallengeRepository challengeRepository; + private final GlobalWeeklyChallengeRepository globalWeeklyChallengeRepository; + private final GroupChallengeRepository groupChallengeRepository; + private final ModyGroupRepository modyGroupRepository; + + @Transactional + public GlobalWeeklyChallengeCreateResult create(GlobalWeeklyChallengeCommand command) { + Challenge challenge = challengeRepository.save(new Challenge( + idGenerator.nextId(), + ChallengeType.PHOTO, + command.title(), + command.description() + )); + GlobalWeeklyChallenge globalWeeklyChallenge = globalWeeklyChallengeRepository.save(new GlobalWeeklyChallenge( + idGenerator.nextId(), + challenge.getId(), + command.startsOn(), + command.endsOn() + )); + int linkedGroupCount = linkToActiveGroups(globalWeeklyChallenge); + return GlobalWeeklyChallengeCreateResult.from(globalWeeklyChallenge, challenge, linkedGroupCount); + } + + @Transactional(readOnly = true) + public GlobalWeeklyChallengeListResult getAll() { + List challenges = globalWeeklyChallengeRepository + .findByDeletedAtIsNullOrderByStartsOnDescIdDesc() + .stream() + .filter(GlobalWeeklyChallenge::isActive) + .map(this::toResult) + .toList(); + return new GlobalWeeklyChallengeListResult(challenges); + } + + @Transactional + public GlobalWeeklyChallengeResult update(Long globalWeeklyChallengeId, GlobalWeeklyChallengeCommand command) { + GlobalWeeklyChallenge globalWeeklyChallenge = getActiveGlobalWeeklyChallenge(globalWeeklyChallengeId); + Challenge challenge = getWeeklyChallenge(globalWeeklyChallenge.getChallengeId()); + challenge.update(command.title(), command.description()); + globalWeeklyChallenge.updatePeriod(command.startsOn(), command.endsOn()); + groupChallengeRepository.findByGlobalWeeklyChallengeIdAndDeletedAtIsNull(globalWeeklyChallengeId) + .forEach(groupChallenge -> groupChallenge.updatePeriod(command.startsOn(), command.endsOn())); + return toResult(globalWeeklyChallenge); + } + + @Transactional + public void delete(Long globalWeeklyChallengeId) { + GlobalWeeklyChallenge globalWeeklyChallenge = getActiveGlobalWeeklyChallenge(globalWeeklyChallengeId); + Challenge challenge = getWeeklyChallenge(globalWeeklyChallenge.getChallengeId()); + globalWeeklyChallenge.delete(); + challenge.delete(); + } + + @Transactional + public void initializeForNewGroup(Long groupId) { + LocalDate today = LocalDate.now(); + globalWeeklyChallengeRepository + .findByStartsOnLessThanEqualAndEndsOnGreaterThanEqualAndDeletedAtIsNull(today, today) + .stream() + .filter(GlobalWeeklyChallenge::isActive) + .filter(globalWeeklyChallenge -> !groupChallengeRepository + .existsByGroupIdAndGlobalWeeklyChallengeIdAndDeletedAtIsNull(groupId, globalWeeklyChallenge.getId())) + .forEach(globalWeeklyChallenge -> groupChallengeRepository.save(new GroupChallenge( + idGenerator.nextId(), + groupId, + globalWeeklyChallenge.getChallengeId(), + globalWeeklyChallenge.getId(), + globalWeeklyChallenge.getStartsOn(), + globalWeeklyChallenge.getEndsOn() + ))); + } + + private int linkToActiveGroups(GlobalWeeklyChallenge globalWeeklyChallenge) { + List groups = modyGroupRepository.findByDeletedAtIsNullOrderByCreatedAtDesc() + .stream() + .filter(ModyGroup::isActive) + .toList(); + List groupChallenges = groups.stream() + .map(group -> new GroupChallenge( + idGenerator.nextId(), + group.getId(), + globalWeeklyChallenge.getChallengeId(), + globalWeeklyChallenge.getId(), + globalWeeklyChallenge.getStartsOn(), + globalWeeklyChallenge.getEndsOn() + )) + .toList(); + groupChallengeRepository.saveAll(groupChallenges); + return groupChallenges.size(); + } + + private GlobalWeeklyChallengeResult toResult(GlobalWeeklyChallenge globalWeeklyChallenge) { + Challenge challenge = getWeeklyChallenge(globalWeeklyChallenge.getChallengeId()); + long linkedGroupCount = groupChallengeRepository + .countByGlobalWeeklyChallengeIdAndDeletedAtIsNull(globalWeeklyChallenge.getId()); + return GlobalWeeklyChallengeResult.from(globalWeeklyChallenge, challenge, linkedGroupCount); + } + + private GlobalWeeklyChallenge getActiveGlobalWeeklyChallenge(Long globalWeeklyChallengeId) { + return globalWeeklyChallengeRepository.findByIdAndDeletedAtIsNull(globalWeeklyChallengeId) + .filter(GlobalWeeklyChallenge::isActive) + .orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND)); + } + + private Challenge getWeeklyChallenge(Long challengeId) { + return challengeRepository.findByIdAndChallengeTypeAndDeletedAtIsNull(challengeId, ChallengeType.PHOTO) + .filter(Challenge::isActive) + .orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND)); + } + + public record GlobalWeeklyChallengeCommand( + String title, + String description, + LocalDate startsOn, + LocalDate endsOn + ) { + } + + public record GlobalWeeklyChallengeCreateResult( + Long globalWeeklyChallengeId, + Long challengeId, + String title, + String description, + LocalDate startsOn, + LocalDate endsOn, + int linkedGroupCount + ) { + private static GlobalWeeklyChallengeCreateResult from( + GlobalWeeklyChallenge globalWeeklyChallenge, + Challenge challenge, + int linkedGroupCount + ) { + return new GlobalWeeklyChallengeCreateResult( + globalWeeklyChallenge.getId(), + challenge.getId(), + challenge.getTitle(), + challenge.getDescription(), + globalWeeklyChallenge.getStartsOn(), + globalWeeklyChallenge.getEndsOn(), + linkedGroupCount + ); + } + } + + public record GlobalWeeklyChallengeListResult(List challenges) { + } + + public record GlobalWeeklyChallengeResult( + Long globalWeeklyChallengeId, + Long challengeId, + String title, + String description, + LocalDate startsOn, + LocalDate endsOn, + long linkedGroupCount + ) { + private static GlobalWeeklyChallengeResult from( + GlobalWeeklyChallenge globalWeeklyChallenge, + Challenge challenge, + long linkedGroupCount + ) { + return new GlobalWeeklyChallengeResult( + globalWeeklyChallenge.getId(), + challenge.getId(), + challenge.getTitle(), + challenge.getDescription(), + globalWeeklyChallenge.getStartsOn(), + globalWeeklyChallenge.getEndsOn(), + linkedGroupCount + ); + } + } +} diff --git a/src/main/java/cmc/mody/challenge/domain/GlobalWeeklyChallenge.java b/src/main/java/cmc/mody/challenge/domain/GlobalWeeklyChallenge.java new file mode 100644 index 0000000..dcb5d10 --- /dev/null +++ b/src/main/java/cmc/mody/challenge/domain/GlobalWeeklyChallenge.java @@ -0,0 +1,43 @@ +package cmc.mody.challenge.domain; + +import cmc.mody.common.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "global_weekly_challenge", + indexes = { + @Index(name = "idx_global_weekly_challenge_period", columnList = "starts_on, ends_on") + } +) +public class GlobalWeeklyChallenge extends BaseEntity { + @Column(name = "challenge_id", nullable = false, unique = true) + private Long challengeId; + + @Column(name = "starts_on", nullable = false) + private LocalDate startsOn; + + @Column(name = "ends_on", nullable = false) + private LocalDate endsOn; + + public GlobalWeeklyChallenge(Long id, Long challengeId, LocalDate startsOn, LocalDate endsOn) { + super(id); + this.challengeId = challengeId; + this.startsOn = startsOn; + this.endsOn = endsOn; + } + + public void updatePeriod(LocalDate startsOn, LocalDate endsOn) { + this.startsOn = startsOn; + this.endsOn = endsOn; + } +} diff --git a/src/main/java/cmc/mody/challenge/domain/GroupChallenge.java b/src/main/java/cmc/mody/challenge/domain/GroupChallenge.java index d7b688e..82b369b 100644 --- a/src/main/java/cmc/mody/challenge/domain/GroupChallenge.java +++ b/src/main/java/cmc/mody/challenge/domain/GroupChallenge.java @@ -30,6 +30,9 @@ public class GroupChallenge extends BaseEntity { @Column(name = "challenge_id", nullable = false) private Long challengeId; + @Column(name = "global_weekly_challenge_id") + private Long globalWeeklyChallengeId; + @Column(name = "starts_on", nullable = false) private LocalDate startsOn; @@ -47,9 +50,21 @@ public class GroupChallenge extends BaseEntity { private LocalDateTime endedAt; public GroupChallenge(Long id, Long groupId, Long challengeId, LocalDate startsOn, LocalDate endsOn) { + this(id, groupId, challengeId, null, startsOn, endsOn); + } + + public GroupChallenge( + Long id, + Long groupId, + Long challengeId, + Long globalWeeklyChallengeId, + LocalDate startsOn, + LocalDate endsOn + ) { super(id); this.groupId = groupId; this.challengeId = challengeId; + this.globalWeeklyChallengeId = globalWeeklyChallengeId; this.startsOn = startsOn; this.endsOn = endsOn; } diff --git a/src/main/java/cmc/mody/challenge/infrastructure/repository/GlobalWeeklyChallengeRepository.java b/src/main/java/cmc/mody/challenge/infrastructure/repository/GlobalWeeklyChallengeRepository.java new file mode 100644 index 0000000..37ed341 --- /dev/null +++ b/src/main/java/cmc/mody/challenge/infrastructure/repository/GlobalWeeklyChallengeRepository.java @@ -0,0 +1,16 @@ +package cmc.mody.challenge.infrastructure.repository; + +import cmc.mody.challenge.domain.GlobalWeeklyChallenge; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GlobalWeeklyChallengeRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + + List findByDeletedAtIsNullOrderByStartsOnDescIdDesc(); + + List + findByStartsOnLessThanEqualAndEndsOnGreaterThanEqualAndDeletedAtIsNull(LocalDate startsOn, LocalDate endsOn); +} diff --git a/src/main/java/cmc/mody/challenge/infrastructure/repository/GroupChallengeRepository.java b/src/main/java/cmc/mody/challenge/infrastructure/repository/GroupChallengeRepository.java index ca26ade..a09492c 100644 --- a/src/main/java/cmc/mody/challenge/infrastructure/repository/GroupChallengeRepository.java +++ b/src/main/java/cmc/mody/challenge/infrastructure/repository/GroupChallengeRepository.java @@ -12,6 +12,12 @@ public interface GroupChallengeRepository extends JpaRepository { List findByGroupIdAndDeletedAtIsNull(Long groupId); + boolean existsByGroupIdAndGlobalWeeklyChallengeIdAndDeletedAtIsNull(Long groupId, Long globalWeeklyChallengeId); + + List findByGlobalWeeklyChallengeIdAndDeletedAtIsNull(Long globalWeeklyChallengeId); + + long countByGlobalWeeklyChallengeIdAndDeletedAtIsNull(Long globalWeeklyChallengeId); + Optional findByGroupIdAndChallengeIdInAndGroupChallengeStatusAndDeletedAtIsNull( Long groupId, Collection challengeIds, diff --git a/src/main/java/cmc/mody/challenge/presentation/GlobalWeeklyChallengeAdminController.java b/src/main/java/cmc/mody/challenge/presentation/GlobalWeeklyChallengeAdminController.java new file mode 100644 index 0000000..e034459 --- /dev/null +++ b/src/main/java/cmc/mody/challenge/presentation/GlobalWeeklyChallengeAdminController.java @@ -0,0 +1,152 @@ +package cmc.mody.challenge.presentation; + +import cmc.mody.challenge.application.GlobalWeeklyChallengeService; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeCommand; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeCreateResult; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeListResult; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeResult; +import cmc.mody.common.admin.AdminAccessService; +import cmc.mody.common.api.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/weekly-challenges") +public class GlobalWeeklyChallengeAdminController { + private static final String ADMIN_API_KEY_HEADER = "X-Admin-Api-Key"; + + private final AdminAccessService adminAccessService; + private final GlobalWeeklyChallengeService globalWeeklyChallengeService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createWeeklyChallenge( + @RequestHeader(name = ADMIN_API_KEY_HEADER, required = false) String adminApiKey, + @Valid @RequestBody GlobalWeeklyChallengeRequest request + ) { + adminAccessService.validate(adminApiKey); + return ApiResponse.created(GlobalWeeklyChallengeCreateResponse.from(globalWeeklyChallengeService.create(request.toCommand()))); + } + + @GetMapping + public ApiResponse getWeeklyChallenges( + @RequestHeader(name = ADMIN_API_KEY_HEADER, required = false) String adminApiKey + ) { + adminAccessService.validate(adminApiKey); + return ApiResponse.ok(GlobalWeeklyChallengeListResponse.from(globalWeeklyChallengeService.getAll())); + } + + @PutMapping("/{globalWeeklyChallengeId}") + public ApiResponse updateWeeklyChallenge( + @RequestHeader(name = ADMIN_API_KEY_HEADER, required = false) String adminApiKey, + @PathVariable Long globalWeeklyChallengeId, + @Valid @RequestBody GlobalWeeklyChallengeRequest request + ) { + adminAccessService.validate(adminApiKey); + return ApiResponse.ok(GlobalWeeklyChallengeResponse.from( + globalWeeklyChallengeService.update(globalWeeklyChallengeId, request.toCommand()) + )); + } + + @DeleteMapping("/{globalWeeklyChallengeId}") + public ApiResponse deleteWeeklyChallenge( + @RequestHeader(name = ADMIN_API_KEY_HEADER, required = false) String adminApiKey, + @PathVariable Long globalWeeklyChallengeId + ) { + adminAccessService.validate(adminApiKey); + globalWeeklyChallengeService.delete(globalWeeklyChallengeId); + return ApiResponse.ok(); + } + + public record GlobalWeeklyChallengeRequest( + @NotBlank(message = "챌린지 제목은 필수입니다.") + @Size(max = 50, message = "챌린지 제목은 50자 이하여야 합니다.") + String title, + @NotBlank(message = "챌린지 설명은 필수입니다.") + @Size(max = 500, message = "챌린지 설명은 500자 이하여야 합니다.") + String description, + @NotNull(message = "시작일은 필수입니다.") + LocalDate startsOn, + @NotNull(message = "마감일은 필수입니다.") + LocalDate endsOn + ) { + @AssertTrue(message = "마감일은 시작일과 같거나 이후여야 합니다.") + public boolean isPeriodValid() { + return startsOn == null || endsOn == null || !endsOn.isBefore(startsOn); + } + + private GlobalWeeklyChallengeCommand toCommand() { + return new GlobalWeeklyChallengeCommand(title, description, startsOn, endsOn); + } + } + + public record GlobalWeeklyChallengeCreateResponse( + Long globalWeeklyChallengeId, + Long challengeId, + String title, + String description, + LocalDate startsOn, + LocalDate endsOn, + int linkedGroupCount + ) { + private static GlobalWeeklyChallengeCreateResponse from(GlobalWeeklyChallengeCreateResult result) { + return new GlobalWeeklyChallengeCreateResponse( + result.globalWeeklyChallengeId(), + result.challengeId(), + result.title(), + result.description(), + result.startsOn(), + result.endsOn(), + result.linkedGroupCount() + ); + } + } + + public record GlobalWeeklyChallengeListResponse(List challenges) { + private static GlobalWeeklyChallengeListResponse from(GlobalWeeklyChallengeListResult result) { + return new GlobalWeeklyChallengeListResponse( + result.challenges().stream().map(GlobalWeeklyChallengeResponse::from).toList() + ); + } + } + + public record GlobalWeeklyChallengeResponse( + Long globalWeeklyChallengeId, + Long challengeId, + String title, + String description, + LocalDate startsOn, + LocalDate endsOn, + long linkedGroupCount + ) { + private static GlobalWeeklyChallengeResponse from(GlobalWeeklyChallengeResult result) { + return new GlobalWeeklyChallengeResponse( + result.globalWeeklyChallengeId(), + result.challengeId(), + result.title(), + result.description(), + result.startsOn(), + result.endsOn(), + result.linkedGroupCount() + ); + } + } +} diff --git a/src/main/java/cmc/mody/grouping/application/GroupService.java b/src/main/java/cmc/mody/grouping/application/GroupService.java index 356395e..0ad389a 100644 --- a/src/main/java/cmc/mody/grouping/application/GroupService.java +++ b/src/main/java/cmc/mody/grouping/application/GroupService.java @@ -4,6 +4,7 @@ import cmc.mody.common.api.status.ErrorStatus; import cmc.mody.common.id.IdGenerator; import cmc.mody.common.upload.ImageUrlResolver; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService; import cmc.mody.challenge.application.StepChallengeService; import cmc.mody.grouping.domain.GroupMember; import cmc.mody.grouping.domain.GroupMemberStatus; @@ -42,6 +43,7 @@ public class GroupService { private final ModyGroupRepository modyGroupRepository; private final GroupMemberRepository groupMemberRepository; private final StepChallengeService stepChallengeService; + private final GlobalWeeklyChallengeService globalWeeklyChallengeService; private final NotificationRequestService notificationRequestService; private final ActivityRecordRepository activityRecordRepository; private final ActivityRecordGroupRepository activityRecordGroupRepository; @@ -68,6 +70,7 @@ public GroupCreateResult createGroup(Long memberId, GroupCreateCommand command) )); groupMemberRepository.save(newGroupMember(member, group.getId())); stepChallengeService.initializeDefaultStepChallenge(group.getId()); + globalWeeklyChallengeService.initializeForNewGroup(group.getId()); member.completeGroupOnboarding(); return new GroupCreateResult(group.getId(), group.getCode(), group.getName()); diff --git a/src/main/resources/db/migration/V14__create_global_weekly_challenge.sql b/src/main/resources/db/migration/V14__create_global_weekly_challenge.sql new file mode 100644 index 0000000..d1f2c85 --- /dev/null +++ b/src/main/resources/db/migration/V14__create_global_weekly_challenge.sql @@ -0,0 +1,19 @@ +CREATE TABLE global_weekly_challenge ( + id BIGINT NOT NULL, + challenge_id BIGINT NOT NULL, + starts_on DATE NOT NULL, + ends_on DATE NOT NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, + status VARCHAR(20) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_global_weekly_challenge_challenge (challenge_id), + KEY idx_global_weekly_challenge_period (starts_on, ends_on) +); + +ALTER TABLE group_challenge + ADD COLUMN global_weekly_challenge_id BIGINT NULL; + +CREATE INDEX idx_group_challenge_global_weekly + ON group_challenge (global_weekly_challenge_id, group_id); diff --git a/src/test/java/cmc/mody/challenge/application/GlobalWeeklyChallengeServiceTest.java b/src/test/java/cmc/mody/challenge/application/GlobalWeeklyChallengeServiceTest.java new file mode 100644 index 0000000..e57f56d --- /dev/null +++ b/src/test/java/cmc/mody/challenge/application/GlobalWeeklyChallengeServiceTest.java @@ -0,0 +1,203 @@ +package cmc.mody.challenge.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeCommand; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeCreateResult; +import cmc.mody.challenge.domain.Challenge; +import cmc.mody.challenge.domain.ChallengeType; +import cmc.mody.challenge.domain.GlobalWeeklyChallenge; +import cmc.mody.challenge.domain.GroupChallenge; +import cmc.mody.challenge.infrastructure.repository.ChallengeRepository; +import cmc.mody.challenge.infrastructure.repository.GlobalWeeklyChallengeRepository; +import cmc.mody.challenge.infrastructure.repository.GroupChallengeRepository; +import cmc.mody.common.id.IdGenerator; +import cmc.mody.grouping.domain.ModyGroup; +import cmc.mody.grouping.infrastructure.repository.ModyGroupRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.stream.StreamSupport; +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.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GlobalWeeklyChallengeServiceTest { + @Mock + private IdGenerator idGenerator; + + @Mock + private ChallengeRepository challengeRepository; + + @Mock + private GlobalWeeklyChallengeRepository globalWeeklyChallengeRepository; + + @Mock + private GroupChallengeRepository groupChallengeRepository; + + @Mock + private ModyGroupRepository modyGroupRepository; + + @Captor + private ArgumentCaptor groupChallengeCaptor; + + @Test + @DisplayName("전역 주간 챌린지를 한 번 등록하고 활성 그룹별 진행 인스턴스를 원본에 연결한다.") + void createGlobalWeeklyChallenge() { + GlobalWeeklyChallengeService service = service(); + LocalDate startsOn = LocalDate.of(2026, 8, 10); + LocalDate endsOn = LocalDate.of(2026, 8, 16); + given(idGenerator.nextId()).willReturn(100L, 200L, 300L, 400L); + given(challengeRepository.save(any(Challenge.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(globalWeeklyChallengeRepository.save(any(GlobalWeeklyChallenge.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(modyGroupRepository.findByDeletedAtIsNullOrderByCreatedAtDesc()).willReturn(List.of( + new ModyGroup(10L, "GROUP001", "첫 번째 그룹"), + new ModyGroup(20L, "GROUP002", "두 번째 그룹") + )); + + GlobalWeeklyChallengeCreateResult result = service.create(new GlobalWeeklyChallengeCommand( + "엘리베이터 안 타고 올라가기", + "계단으로 이동한 사진을 인증해주세요.", + startsOn, + endsOn + )); + + assertThat(result).isEqualTo(new GlobalWeeklyChallengeCreateResult( + 200L, + 100L, + "엘리베이터 안 타고 올라가기", + "계단으로 이동한 사진을 인증해주세요.", + startsOn, + endsOn, + 2 + )); + then(groupChallengeRepository).should().saveAll(argThat(instances -> { + List linkedInstances = StreamSupport.stream(instances.spliterator(), false).toList(); + return linkedInstances.size() == 2 + && linkedInstances.stream().allMatch(instance -> instance.getChallengeId().equals(100L)) + && linkedInstances.stream().allMatch(instance -> instance.getGlobalWeeklyChallengeId().equals(200L)) + && linkedInstances.stream().allMatch(instance -> instance.getStartsOn().equals(startsOn)) + && linkedInstances.stream().allMatch(instance -> instance.getEndsOn().equals(endsOn)); + })); + } + + @Test + @DisplayName("새 그룹은 진행 중인 전역 주간 챌린지 진행 인스턴스를 자동으로 연결한다.") + void initializeForNewGroup() { + GlobalWeeklyChallengeService service = service(); + LocalDate today = LocalDate.now(); + GlobalWeeklyChallenge globalWeeklyChallenge = new GlobalWeeklyChallenge( + 100L, + 200L, + today.minusDays(1), + today.plusDays(5) + ); + given(globalWeeklyChallengeRepository.findByStartsOnLessThanEqualAndEndsOnGreaterThanEqualAndDeletedAtIsNull( + today, + today + )).willReturn(List.of(globalWeeklyChallenge)); + given(groupChallengeRepository.existsByGroupIdAndGlobalWeeklyChallengeIdAndDeletedAtIsNull(10L, 100L)) + .willReturn(false); + given(idGenerator.nextId()).willReturn(300L); + + service.initializeForNewGroup(10L); + + then(groupChallengeRepository).should().save(groupChallengeCaptor.capture()); + GroupChallenge groupChallenge = groupChallengeCaptor.getValue(); + assertThat(groupChallenge.getGroupId()).isEqualTo(10L); + assertThat(groupChallenge.getChallengeId()).isEqualTo(200L); + assertThat(groupChallenge.getGlobalWeeklyChallengeId()).isEqualTo(100L); + } + + @Test + @DisplayName("전역 주간 챌린지를 수정하면 연결된 그룹 진행 인스턴스의 기간도 함께 수정한다.") + void updateGlobalWeeklyChallenge() { + GlobalWeeklyChallengeService service = service(); + GlobalWeeklyChallenge globalWeeklyChallenge = new GlobalWeeklyChallenge( + 100L, + 200L, + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 9) + ); + Challenge challenge = new Challenge(200L, ChallengeType.PHOTO, "기존 제목", "기존 설명"); + GroupChallenge firstGroupChallenge = new GroupChallenge( + 300L, + 10L, + 200L, + 100L, + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 9) + ); + GroupChallenge secondGroupChallenge = new GroupChallenge( + 400L, + 20L, + 200L, + 100L, + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 9) + ); + LocalDate startsOn = LocalDate.of(2026, 8, 10); + LocalDate endsOn = LocalDate.of(2026, 8, 16); + given(globalWeeklyChallengeRepository.findByIdAndDeletedAtIsNull(100L)) + .willReturn(Optional.of(globalWeeklyChallenge)); + given(challengeRepository.findByIdAndChallengeTypeAndDeletedAtIsNull(200L, ChallengeType.PHOTO)) + .willReturn(Optional.of(challenge)); + given(groupChallengeRepository.findByGlobalWeeklyChallengeIdAndDeletedAtIsNull(100L)) + .willReturn(List.of(firstGroupChallenge, secondGroupChallenge)); + given(groupChallengeRepository.countByGlobalWeeklyChallengeIdAndDeletedAtIsNull(100L)).willReturn(2L); + + GlobalWeeklyChallengeService.GlobalWeeklyChallengeResult result = service.update( + 100L, + new GlobalWeeklyChallengeCommand("새 제목", "새 설명", startsOn, endsOn) + ); + + assertThat(challenge.getTitle()).isEqualTo("새 제목"); + assertThat(challenge.getDescription()).isEqualTo("새 설명"); + assertThat(firstGroupChallenge.getStartsOn()).isEqualTo(startsOn); + assertThat(secondGroupChallenge.getEndsOn()).isEqualTo(endsOn); + assertThat(result.linkedGroupCount()).isEqualTo(2L); + } + + @Test + @DisplayName("전역 주간 챌린지를 삭제하면 원본과 사진 챌린지 메타데이터를 함께 비활성화한다.") + void deleteGlobalWeeklyChallenge() { + GlobalWeeklyChallengeService service = service(); + GlobalWeeklyChallenge globalWeeklyChallenge = new GlobalWeeklyChallenge( + 100L, + 200L, + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 9) + ); + Challenge challenge = new Challenge(200L, ChallengeType.PHOTO, "제목", "설명"); + given(globalWeeklyChallengeRepository.findByIdAndDeletedAtIsNull(100L)) + .willReturn(Optional.of(globalWeeklyChallenge)); + given(challengeRepository.findByIdAndChallengeTypeAndDeletedAtIsNull(200L, ChallengeType.PHOTO)) + .willReturn(Optional.of(challenge)); + + service.delete(100L); + + assertThat(globalWeeklyChallenge.isActive()).isFalse(); + assertThat(challenge.isActive()).isFalse(); + then(groupChallengeRepository).shouldHaveNoInteractions(); + } + + private GlobalWeeklyChallengeService service() { + return new GlobalWeeklyChallengeService( + idGenerator, + challengeRepository, + globalWeeklyChallengeRepository, + groupChallengeRepository, + modyGroupRepository + ); + } +} diff --git a/src/test/java/cmc/mody/challenge/application/WeeklyChallengeServiceTest.java b/src/test/java/cmc/mody/challenge/application/WeeklyChallengeServiceTest.java index 7b8cffe..5450599 100644 --- a/src/test/java/cmc/mody/challenge/application/WeeklyChallengeServiceTest.java +++ b/src/test/java/cmc/mody/challenge/application/WeeklyChallengeServiceTest.java @@ -217,10 +217,17 @@ void createWeeklyChallengeProof() { } @Test - @DisplayName("마지막 그룹원이 인증하면 주간 챌린지를 완료 처리하고 완료 알림을 요청한다.") + @DisplayName("전역 원본에 연결된 그룹 진행 항목도 마지막 그룹원이 인증하면 그룹 단위로 완료 처리하고 알림을 요청한다.") void completeWeeklyChallengeWhenAllJoinedMembersProved() { WeeklyChallengeService service = service(); - GroupChallenge groupChallenge = groupChallenge(100L, 10L, 1L); + GroupChallenge groupChallenge = new GroupChallenge( + 100L, + 10L, + 1L, + 900L, + LocalDate.now().minusDays(1), + LocalDate.now().plusDays(5) + ); givenValidGroupMembership(); given(groupChallengeRepository.findByIdAndGroupIdAndDeletedAtIsNull(100L, 10L)) .willReturn(Optional.of(groupChallenge)); @@ -243,6 +250,7 @@ void completeWeeklyChallengeWhenAllJoinedMembersProved() { ); assertThat(groupChallenge.getGroupChallengeStatus()).isEqualTo(GroupChallengeStatus.COMPLETED); + assertThat(groupChallenge.getGlobalWeeklyChallengeId()).isEqualTo(900L); assertThat(groupChallenge.getCompletedAt()).isNotNull(); then(notificationRequestService).should().requestWeeklyChallengeCompleted(10L, "모디", 100L); } diff --git a/src/test/java/cmc/mody/challenge/presentation/GlobalWeeklyChallengeAdminControllerDocsTest.java b/src/test/java/cmc/mody/challenge/presentation/GlobalWeeklyChallengeAdminControllerDocsTest.java new file mode 100644 index 0000000..8c4cd3e --- /dev/null +++ b/src/test/java/cmc/mody/challenge/presentation/GlobalWeeklyChallengeAdminControllerDocsTest.java @@ -0,0 +1,152 @@ +package cmc.mody.challenge.presentation; + +import static cmc.mody.docs.ApiDocumentUtils.commonResponseFields; +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cmc.mody.challenge.application.GlobalWeeklyChallengeService; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeCommand; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeCreateResult; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeListResult; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService.GlobalWeeklyChallengeResult; +import cmc.mody.common.admin.AdminAccessService; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(GlobalWeeklyChallengeAdminController.class) +@AutoConfigureRestDocs +class GlobalWeeklyChallengeAdminControllerDocsTest { + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AdminAccessService adminAccessService; + + @MockitoBean + private GlobalWeeklyChallengeService globalWeeklyChallengeService; + + @Test + void createGlobalWeeklyChallenge() throws Exception { + LocalDate startsOn = LocalDate.of(2026, 8, 10); + LocalDate endsOn = LocalDate.of(2026, 8, 16); + given(globalWeeklyChallengeService.create(new GlobalWeeklyChallengeCommand( + "엘리베이터 안 타고 올라가기", "계단으로 이동한 사진을 인증해주세요.", startsOn, endsOn + ))).willReturn(new GlobalWeeklyChallengeCreateResult( + 10L, 20L, "엘리베이터 안 타고 올라가기", "계단으로 이동한 사진을 인증해주세요.", startsOn, endsOn, 3 + )); + + mockMvc.perform(post("/api/v1/admin/weekly-challenges") + .header("X-Admin-Api-Key", "test-admin-key") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody())) + .andExpect(status().isCreated()) + .andDo(document("admin-global-weekly-challenge-create", + resource(ResourceSnippetParameters.builder() + .tag("Admin Global Weekly Challenge") + .summary("전역 주간 사진 챌린지 생성") + .description("한 번 등록하면 모든 활성 그룹에 연결된 별도 진행 항목이 생성된다.") + .requestFields(requestFields()) + .responseFields(commonResponseFields( + fieldWithPath("result.globalWeeklyChallengeId").type(JsonFieldType.NUMBER).description("전역 원본 id"), + fieldWithPath("result.challengeId").type(JsonFieldType.NUMBER).description("사진 챌린지 id"), + fieldWithPath("result.title").type(JsonFieldType.STRING).description("챌린지 제목"), + fieldWithPath("result.description").type(JsonFieldType.STRING).description("챌린지 설명"), + fieldWithPath("result.startsOn").type(JsonFieldType.STRING).description("시작일"), + fieldWithPath("result.endsOn").type(JsonFieldType.STRING).description("마감일"), + fieldWithPath("result.linkedGroupCount").type(JsonFieldType.NUMBER).description("연결된 그룹 수") + )) + .build()) + )); + } + + @Test + void getGlobalWeeklyChallenges() throws Exception { + given(globalWeeklyChallengeService.getAll()).willReturn(new GlobalWeeklyChallengeListResult(List.of( + new GlobalWeeklyChallengeResult( + 10L, 20L, "엘리베이터 안 타고 올라가기", "계단으로 이동한 사진을 인증해주세요.", + LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 16), 3L + ) + ))); + + mockMvc.perform(get("/api/v1/admin/weekly-challenges").header("X-Admin-Api-Key", "test-admin-key")) + .andExpect(status().isOk()) + .andDo(document("admin-global-weekly-challenge-list", + resource(ResourceSnippetParameters.builder() + .tag("Admin Global Weekly Challenge") + .summary("전역 주간 사진 챌린지 목록 조회") + .responseFields(commonResponseFields( + fieldWithPath("result.challenges[].globalWeeklyChallengeId").type(JsonFieldType.NUMBER) + .description("전역 원본 id"), + fieldWithPath("result.challenges[].challengeId").type(JsonFieldType.NUMBER).description("사진 챌린지 id"), + fieldWithPath("result.challenges[].title").type(JsonFieldType.STRING).description("챌린지 제목"), + fieldWithPath("result.challenges[].description").type(JsonFieldType.STRING).description("챌린지 설명"), + fieldWithPath("result.challenges[].startsOn").type(JsonFieldType.STRING).description("시작일"), + fieldWithPath("result.challenges[].endsOn").type(JsonFieldType.STRING).description("마감일"), + fieldWithPath("result.challenges[].linkedGroupCount").type(JsonFieldType.NUMBER) + .description("연결된 그룹 수") + )) + .build()) + )); + } + + @Test + void updateGlobalWeeklyChallenge() throws Exception { + LocalDate startsOn = LocalDate.of(2026, 8, 10); + LocalDate endsOn = LocalDate.of(2026, 8, 16); + given(globalWeeklyChallengeService.update( + 10L, + new GlobalWeeklyChallengeCommand("엘리베이터 안 타고 올라가기", "계단으로 이동한 사진을 인증해주세요.", startsOn, endsOn) + )).willReturn(new GlobalWeeklyChallengeResult( + 10L, 20L, "엘리베이터 안 타고 올라가기", "계단으로 이동한 사진을 인증해주세요.", startsOn, endsOn, 3L + )); + + mockMvc.perform(put("/api/v1/admin/weekly-challenges/{globalWeeklyChallengeId}", 10L) + .header("X-Admin-Api-Key", "test-admin-key") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody())) + .andExpect(status().isOk()); + } + + @Test + void deleteGlobalWeeklyChallenge() throws Exception { + mockMvc.perform(delete("/api/v1/admin/weekly-challenges/{globalWeeklyChallengeId}", 10L) + .header("X-Admin-Api-Key", "test-admin-key")) + .andExpect(status().isOk()); + } + + private String requestBody() { + return """ + { + "title": "엘리베이터 안 타고 올라가기", + "description": "계단으로 이동한 사진을 인증해주세요.", + "startsOn": "2026-08-10", + "endsOn": "2026-08-16" + } + """; + } + + private org.springframework.restdocs.payload.FieldDescriptor[] requestFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("title").type(JsonFieldType.STRING).description("주간 챌린지 제목"), + fieldWithPath("description").type(JsonFieldType.STRING).description("주간 챌린지 설명"), + fieldWithPath("startsOn").type(JsonFieldType.STRING).description("시작일"), + fieldWithPath("endsOn").type(JsonFieldType.STRING).description("마감일") + }; + } +} diff --git a/src/test/java/cmc/mody/grouping/application/GroupServiceTest.java b/src/test/java/cmc/mody/grouping/application/GroupServiceTest.java index da88cc9..85fea38 100644 --- a/src/test/java/cmc/mody/grouping/application/GroupServiceTest.java +++ b/src/test/java/cmc/mody/grouping/application/GroupServiceTest.java @@ -13,6 +13,7 @@ import cmc.mody.common.upload.ImageUrlResolver; import cmc.mody.common.upload.UploadProperties; import cmc.mody.challenge.application.StepChallengeService; +import cmc.mody.challenge.application.GlobalWeeklyChallengeService; import cmc.mody.grouping.application.GroupService.GroupCreateCommand; import cmc.mody.grouping.application.GroupService.GroupCreateResult; import cmc.mody.grouping.application.GroupService.GroupJoinCommand; @@ -62,6 +63,9 @@ class GroupServiceTest { @Mock private StepChallengeService stepChallengeService; + @Mock + private GlobalWeeklyChallengeService globalWeeklyChallengeService; + @Mock private NotificationRequestService notificationRequestService; @@ -107,6 +111,7 @@ void createGroup() { assertThat(groupMemberCaptor.getValue().getGroupId()).isEqualTo(10L); assertThat(groupMemberCaptor.getValue().getDisplayNickname()).isEqualTo("민석"); then(stepChallengeService).should().initializeDefaultStepChallenge(10L); + then(globalWeeklyChallengeService).should().initializeForNewGroup(10L); assertThat(member.isGroupOnboardingCompleted()).isTrue(); } @@ -278,6 +283,7 @@ private GroupService service() { modyGroupRepository, groupMemberRepository, stepChallengeService, + globalWeeklyChallengeService, notificationRequestService, activityRecordRepository, activityRecordGroupRepository,