diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java b/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java new file mode 100644 index 0000000..b6dec2b --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java @@ -0,0 +1,183 @@ +package com.gpt.geumpumtabackend.badge.api; + +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "배지 API", description = """ + 배지 생성/조회/삭제 및 사용자 배지 조회 기능을 제공합니다. + """) +public interface BadgeApi { + + @Operation( + summary = "배지 생성", + description = """ + ADMIN 권한으로 새로운 배지를 생성합니다. + - code: 배지 고유 코드 (중복 불가) + - badgeType: 배지 종류 + - thresholdValue: 누적 시간/연속 일수 계열 배지 기준값 + - rank: 시즌 랭킹 배지 등수 값(예: 1,2,3) + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = BadgeCreateResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = BadgeCreateResponse.class, + description = "배지 생성 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED), + @SwaggerApiFailedResponse(ExceptionType.BADGE_CODE_ALREADY_EXISTS) + } + ) + @PostMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity> createBadge( + @RequestBody @Valid BadgeCreateRequest request + ); + + @Operation( + summary = "전체 배지 조회", + description = "ADMIN 권한으로 전체 배지 목록을 조회합니다." + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = BadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = BadgeResponse.class, + description = "전체 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED) + } + ) + @GetMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity>> getAllBadges(); + + @Operation( + summary = "배지 삭제", + description = """ + ADMIN 권한으로 배지를 삭제합니다. + 이미 사용자에게 지급된 이력이 있으면 삭제할 수 없고 B004(BADGE_IN_USE)를 반환합니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = ResponseBody.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse(description = "배지 삭제 성공"), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_IN_USE) + } + ) + @DeleteMapping("/{badgeId}") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity> deleteBadge( + @PathVariable Long badgeId + ); + + @Operation( + summary = "내 배지 조회", + description = """ + 항상 전체 배지 목록을 반환합니다. + 각 원소는 아래 정보를 포함합니다. + - owned: 사용자의 배지 보유 여부 + - awardedAt: 배지 획득 시각 (owned=false이면 null) + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeStatusResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MyBadgeStatusResponse.class, + description = "내 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND) + } + ) + @GetMapping("/me") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity>> getMyBadges( + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "대표 배지 설정", + description = """ + 보유한 배지 중 하나를 대표 배지로 설정합니다. + 요청은 badgeCode 기준으로 처리됩니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = RepresentativeBadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = RepresentativeBadgeResponse.class, + description = "대표 배지 설정 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_OWNED) + } + ) + @PostMapping("/me/representative-badge") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> setRepresentativeBadge( + @RequestBody RepresentativeBadgeRequest request, + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "미확인 배지 조회", + description = """ + 사용자의 미확인 배지 목록을 조회합니다. + 조회된 배지는 같은 요청 트랜잭션에서 확인 처리(notifiedAt 설정)됩니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MyBadgeResponse.class, + description = "미확인 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND) + } + ) + @GetMapping("/unnotified") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity>> getUnnotifiedBadges( + @Parameter(hidden = true) Long userId + ); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java b/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java new file mode 100644 index 0000000..57ddef5 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java @@ -0,0 +1,90 @@ +package com.gpt.geumpumtabackend.badge.controller; + +import com.gpt.geumpumtabackend.badge.api.BadgeApi; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.global.response.ResponseUtil; +import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/badge") +public class BadgeController implements BadgeApi { + + private final BadgeService badgeService; + + @PostMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity> createBadge( + @RequestBody @Valid BadgeCreateRequest request + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.createBadge(request) + )); + } + + @GetMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity>> getAllBadges() { + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getAllBadges() + )); + } + + @DeleteMapping("/{badgeId}") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity> deleteBadge( + @PathVariable Long badgeId + ) { + badgeService.deleteBadge(badgeId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); + } + + @GetMapping("/me") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity>> getMyBadges( + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getMyBadges(userId) + )); + } + + @PostMapping("/me/representative-badge") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity> setRepresentativeBadge( + @RequestBody RepresentativeBadgeRequest request, + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.setRepresentativeBadge(request, userId) + )); + } + + @GetMapping("/unnotified") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity>> getUnnotifiedBadges( + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getUnnotifiedBadges(userId) + )); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java new file mode 100644 index 0000000..6880a08 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java @@ -0,0 +1,54 @@ +package com.gpt.geumpumtabackend.badge.domain; + +import com.gpt.geumpumtabackend.global.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Badge extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String code; + + private String name; + + private String description; + + private String iconUrl; + + @Enumerated(EnumType.STRING) + private BadgeType badgeType; + + private Long thresholdValue; + + @Column(name = "badge_rank") + private Long rank; + + @Builder + private Badge( + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank + ) { + this.code = code; + this.name = name; + this.description = description; + this.iconUrl = iconUrl; + this.badgeType = badgeType; + this.thresholdValue = thresholdValue; + this.rank = rank; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java new file mode 100644 index 0000000..f352131 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java @@ -0,0 +1,8 @@ +package com.gpt.geumpumtabackend.badge.domain; + +public enum BadgeType { + WELCOME, + STREAK_DAYS, + TOTAL_HOURS, + SEASON_PERSONAL_RANK +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java new file mode 100644 index 0000000..e0aa7ca --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java @@ -0,0 +1,41 @@ +package com.gpt.geumpumtabackend.badge.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "user_badge", + uniqueConstraints = @UniqueConstraint(name="uk_user_badge", columnNames = {"user_id", "badge_id"}) +) +public class UserBadge { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "badge_id") + private Long badgeId; + + private LocalDateTime awardedAt; + + private LocalDateTime notifiedAt; + + public UserBadge(Long userId, Long badgeId, LocalDateTime awardedAt, LocalDateTime notifiedAt) { + this.userId = userId; + this.badgeId = badgeId; + this.awardedAt = awardedAt; + this.notifiedAt = notifiedAt; + } + + public void markNotified(LocalDateTime notifiedAt) { + this.notifiedAt = notifiedAt; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java new file mode 100644 index 0000000..fd22ee1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java @@ -0,0 +1,16 @@ +package com.gpt.geumpumtabackend.badge.dto.request; + +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record BadgeCreateRequest( + @NotBlank String code, + @NotBlank String name, + @NotBlank String description, + @NotBlank String iconUrl, + @NotNull BadgeType badgeType, + Long thresholdValue, + Long rank +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java new file mode 100644 index 0000000..9656ac1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java @@ -0,0 +1,6 @@ +package com.gpt.geumpumtabackend.badge.dto.request; + +public record RepresentativeBadgeRequest( + String badgeCode +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java new file mode 100644 index 0000000..eb5a423 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java @@ -0,0 +1,28 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; + +public record BadgeCreateResponse( + Long id, + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank +) { + public static BadgeCreateResponse from(Badge badge) { + return new BadgeCreateResponse( + badge.getId(), + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + badge.getBadgeType(), + badge.getThresholdValue(), + badge.getRank() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java new file mode 100644 index 0000000..809a0b1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java @@ -0,0 +1,28 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; + +public record BadgeResponse( + Long id, + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank +) { + public static BadgeResponse from(Badge badge) { + return new BadgeResponse( + badge.getId(), + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + badge.getBadgeType(), + badge.getThresholdValue(), + badge.getRank() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java new file mode 100644 index 0000000..edfe683 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java @@ -0,0 +1,12 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import java.time.LocalDateTime; + +public record MyBadgeResponse( + String code, + String name, + String description, + String iconUrl, + LocalDateTime awardedAt +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java new file mode 100644 index 0000000..4c96039 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java @@ -0,0 +1,25 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +import java.time.LocalDateTime; + +public record MyBadgeStatusResponse( + String code, + String name, + String description, + String iconUrl, + boolean owned, + LocalDateTime awardedAt +) { + public static MyBadgeStatusResponse from(Badge badge, LocalDateTime awardedAt) { + return new MyBadgeStatusResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + awardedAt != null, + awardedAt + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java new file mode 100644 index 0000000..7d8efe7 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java @@ -0,0 +1,19 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +public record NewBadgeResponse( + String code, + String name, + String description, + String iconUrl +) { + public static NewBadgeResponse from(Badge badge) { + return new NewBadgeResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java new file mode 100644 index 0000000..b83461d --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java @@ -0,0 +1,19 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +public record RepresentativeBadgeResponse( + String code, + String name, + String description, + String iconUrl +) { + public static RepresentativeBadgeResponse from(Badge badge){ + return new RepresentativeBadgeResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java new file mode 100644 index 0000000..4462546 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java @@ -0,0 +1,18 @@ +package com.gpt.geumpumtabackend.badge.repository; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BadgeRepository extends JpaRepository { + Optional findByBadgeType(BadgeType badgeType); + + List findAllByBadgeType(BadgeType badgeType); + + Optional findByCode(String code); + + boolean existsByCode(String code); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java b/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java new file mode 100644 index 0000000..4a08501 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java @@ -0,0 +1,41 @@ +package com.gpt.geumpumtabackend.badge.repository; + +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.List; + +public interface UserBadgeRepository extends JpaRepository { + @Query(""" + select new com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse( + b.code, b.name, b.description, b.iconUrl, ub.awardedAt + ) + from UserBadge ub + join Badge b on b.id = ub.badgeId + where ub.userId = :userId + order by ub.awardedAt desc + """) + List findMyBadges(Long userId); + + @Query(""" + select new com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse( + b.code, b.name, b.description, b.iconUrl, ub.awardedAt + ) + from UserBadge ub + join Badge b on b.id = ub.badgeId + where ub.userId = :userId + and ub.notifiedAt is null + order by ub.awardedAt desc + """) + List findUnnotifiedBadgeResponses(Long userId); + + boolean existsByUserIdAndBadgeId(Long userId, Long badgeId); + + boolean existsByBadgeId(Long badgeId); + + List findByUserId(Long userId); + + List findByUserIdAndNotifiedAtIsNull(Long userId); + +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java new file mode 100644 index 0000000..4f80e82 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java @@ -0,0 +1,267 @@ +package com.gpt.geumpumtabackend.badge.service; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.badge.repository.BadgeRepository; +import com.gpt.geumpumtabackend.badge.repository.UserBadgeRepository; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.RankType; +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BadgeService { + + private final BadgeRepository badgeRepository; + private final UserBadgeRepository userBadgeRepository; + private final UserRepository userRepository; + private final StudySessionRepository studySessionRepository; + private final StatisticsRepository statisticsRepository; + private final SeasonRankingSnapshotRepository seasonRankingSnapshotRepository; + + private static final long STREAK_MIN_MILLIS = 30L * 60L * 1000L; + + public List getMyBadges(Long userId) { + validateUserExists(userId); + + Map awardedAtByBadgeId = userBadgeRepository.findByUserId(userId).stream() + .collect(Collectors.toMap( + UserBadge::getBadgeId, + UserBadge::getAwardedAt, + (existing, ignored) -> existing + )); + + return badgeRepository.findAll().stream() + .sorted(Comparator.comparing(Badge::getId)) + .map(badge -> MyBadgeStatusResponse.from(badge, awardedAtByBadgeId.get(badge.getId()))) + .toList(); + } + + public List getAllBadges() { + return badgeRepository.findAll().stream() + .map(BadgeResponse::from) + .toList(); + } + + @Transactional + public BadgeCreateResponse createBadge(BadgeCreateRequest request) { + if (badgeRepository.existsByCode(request.code())) { + throw new BusinessException(ExceptionType.BADGE_CODE_ALREADY_EXISTS); + } + + Badge badge = Badge.builder() + .code(request.code()) + .name(request.name()) + .description(request.description()) + .iconUrl(request.iconUrl()) + .badgeType(request.badgeType()) + .thresholdValue(request.thresholdValue()) + .rank(request.rank()) + .build(); + + Badge saved = badgeRepository.save(badge); + return BadgeCreateResponse.from(saved); + } + + @Transactional + public void deleteBadge(Long badgeId) { + Badge badge = badgeRepository.findById(badgeId) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); + + if (userBadgeRepository.existsByBadgeId(badgeId)) { + throw new BusinessException(ExceptionType.BADGE_IN_USE); + } + + badgeRepository.delete(badge); + } + + @Transactional + public RepresentativeBadgeResponse setRepresentativeBadge(RepresentativeBadgeRequest request, Long userId) { + Badge badge = badgeRepository.findByCode(request.badgeCode()) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + if(!userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) + throw new BusinessException(ExceptionType.BADGE_NOT_OWNED); + + user.setRepresentativeBadge(badge.getId()); + return RepresentativeBadgeResponse.from(badge); + } + + @Transactional + public NewBadgeResponse grantWelcomeBadge(Long userId) { + validateUserExists(userId); + + Badge badge = badgeRepository.findByBadgeType(BadgeType.WELCOME) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); + if (userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + return NewBadgeResponse.from(badge); + } + LocalDateTime now = LocalDateTime.now(); + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + return NewBadgeResponse.from(badge); + } + + @Transactional + public List getUnnotifiedBadges(Long userId) { + validateUserExists(userId); + + List responses = userBadgeRepository.findUnnotifiedBadgeResponses(userId); + if (responses.isEmpty()) { + return Collections.emptyList(); + } + + markBadgesNotified(userId); + + return responses; + } + + private void markBadgesNotified(Long userId) { + List userBadges = userBadgeRepository.findByUserIdAndNotifiedAtIsNull(userId); + LocalDateTime now = LocalDateTime.now(); + for (UserBadge userBadge : userBadges) { + userBadge.markNotified(now); + } + userBadgeRepository.saveAll(userBadges); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List grantStudyAchievementBadges(Long userId) { + validateUserExists(userId); + + LocalDateTime now = LocalDateTime.now(); + List newlyGranted = new ArrayList<>(); + + grantTotalHoursBadge(userId, now, newlyGranted); + grantStreakBadge(userId, now, newlyGranted); + + return newlyGranted; + } + + @Transactional + public int grantSeasonRankingBadges(Long seasonId) { + return grantSeasonRankTypeBadges( + seasonId, + RankType.OVERALL, + BadgeType.SEASON_PERSONAL_RANK + ); + } + + private int grantSeasonRankTypeBadges(Long seasonId, RankType rankType, BadgeType badgeType) { + List badges = badgeRepository.findAllByBadgeType(badgeType); + if (badges.isEmpty()) { + return 0; + } + + Map badgeByRank = badges.stream() + .filter(badge -> badge.getRank() != null) + .collect(Collectors.toMap( + badge -> badge.getRank().intValue(), + badge -> badge, + (existing, ignored) -> existing + )); + if (badgeByRank.isEmpty()) { + return 0; + } + + Set targetRanks = badgeByRank.keySet(); + List snapshots = + seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, + rankType, + targetRanks + ); + + int grantedCount = 0; + LocalDateTime now = LocalDateTime.now(); + for (SeasonRankingSnapshot snapshot : snapshots) { + Badge badge = badgeByRank.get(snapshot.getFinalRank()); + if (badge == null) { + continue; + } + if (userBadgeRepository.existsByUserIdAndBadgeId(snapshot.getUserId(), badge.getId())) { + continue; + } + userBadgeRepository.save(new UserBadge(snapshot.getUserId(), badge.getId(), now, null)); + grantedCount++; + } + + return grantedCount; + } + + private void grantTotalHoursBadge(Long userId, LocalDateTime now, List newlyGranted) { + Long totalMillis = studySessionRepository.sumTotalStudyMillisByUserId(userId); + if (totalMillis == null) { + totalMillis = 0L; + } + + List hourBadges = badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS); + for (Badge badge : hourBadges) { + Long thresholdHours = badge.getThresholdValue(); + if (thresholdHours == null) { + continue; + } + long thresholdMillis = thresholdHours * 60L * 60L * 1000L; + if (totalMillis >= thresholdMillis + && !userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + newlyGranted.add(NewBadgeResponse.from(badge)); + } + } + } + + private void grantStreakBadge(Long userId, LocalDateTime now, List newlyGranted) { + int consecutiveDays = statisticsRepository.countCurrentConsecutiveStudyDays( + userId, LocalDate.now(), STREAK_MIN_MILLIS + ); + List streakBadges = badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS); + for (Badge badge : streakBadges) { + Long thresholdDays = badge.getThresholdValue(); + if (thresholdDays == null) { + continue; + } + if (consecutiveDays >= thresholdDays + && !userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + newlyGranted.add(NewBadgeResponse.from(badge)); + } + } + } + + private void validateUserExists(Long userId) { + if (!userRepository.existsById(userId)) { + throw new BusinessException(ExceptionType.USER_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java index 1e55320..2e5ae4f 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java @@ -55,7 +55,7 @@ public enum ExceptionType { // board - BOARD_NOT_FOUND(BAD_REQUEST, "B001", "존재하지 않는 게시물입니다."), + BOARD_NOT_FOUND(BAD_REQUEST, "BD001", "존재하지 않는 게시물입니다."), // Season NO_ACTIVE_SEASON(NOT_FOUND, "SE001", "현재 진행중인 시즌이 없습니다"), @@ -69,6 +69,11 @@ public enum ExceptionType { FCM_INVALID_TOKEN(BAD_REQUEST, "F002", "유효하지 않은 FCM 토큰입니다."), FCM_TOKEN_NOT_FOUND(NOT_FOUND, "F003", "등록된 FCM 토큰이 없습니다."), + // Badge + BADGE_NOT_FOUND(NOT_FOUND, "B001", "배지가 존재하지 않습니다"), + BADGE_NOT_OWNED(FORBIDDEN, "B002", "해당 배지를 소유하지 않습니다"), + BADGE_CODE_ALREADY_EXISTS(CONFLICT, "B003", "이미 존재하는 배지 코드입니다"), + BADGE_IN_USE(CONFLICT, "B004", "이미 지급되어 삭제할 수 없는 배지입니다") ; private final HttpStatus status; diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java index d9a38b0..6cd8101 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Set; public interface SeasonRankingSnapshotRepository extends JpaRepository { @@ -25,6 +26,12 @@ List findBySeasonIdAndRankTypeAndDepartment( int countBySeasonId(Long seasonId); + List findBySeasonIdAndRankTypeAndFinalRankIn( + Long seasonId, + RankType rankType, + Set finalRanks + ); + @Query(value = """ SELECT s.department as department, diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java index c77aade..d05f415 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java @@ -1,5 +1,6 @@ package com.gpt.geumpumtabackend.rank.scheduler; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.rank.domain.Season; import com.gpt.geumpumtabackend.rank.service.SeasonService; import com.gpt.geumpumtabackend.rank.service.SeasonSnapshotService; @@ -14,10 +15,11 @@ @Component @RequiredArgsConstructor @Slf4j -public class SeasonTransitionScheduler { +public class SeasonTransitionScheduler { private final SeasonService seasonService; private final SeasonSnapshotService snapshotService; + private final BadgeService badgeService; private final CacheManager cacheManager; @@ -46,6 +48,14 @@ public void processSeasonTransition() { // 스냅샷 생성 int snapshotCount = snapshotService.createSeasonSnapshot(endedSeasonId); + int grantedBadgeCount = 0; + try { + grantedBadgeCount = badgeService.grantSeasonRankingBadges(endedSeasonId); + } catch (Exception e) { + log.error("[SEASON_BADGE_GRANT_FAILED] seasonId={}", endedSeasonId, e); + } + log.info("[SEASON_TRANSITION] seasonId={}, snapshots={}, rankingBadges={}", + endedSeasonId, snapshotCount, grantedBadgeCount); } catch (Exception e) { log.error("[SEASON_TRANSITION_ERROR] Failed", e); // TODO: 슬랙/이메일 알림 diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java index fa11b4c..d7f9904 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java @@ -265,6 +265,87 @@ MonthlyStatistics getMonthlyStatistics( @Param("userId") Long userId ); + @Query(value = """ + WITH RECURSIVE + bounds AS ( + SELECT DATE_SUB(DATE(:today), INTERVAL 364 DAY) AS start_date, + DATE(:today) AS end_date + ), + days AS ( + SELECT b.start_date AS day_date, + CAST(b.start_date AS DATETIME) AS day_start, + CAST(DATE_ADD(b.start_date, INTERVAL 1 DAY) AS DATETIME) AS day_end + FROM bounds b + UNION ALL + SELECT DATE_ADD(d.day_date, INTERVAL 1 DAY), + DATE_ADD(d.day_start, INTERVAL 1 DAY), + DATE_ADD(d.day_end, INTERVAL 1 DAY) + FROM days d + JOIN bounds b ON d.day_date < b.end_date + ), + sessions_in_window AS ( + SELECT s.start_time, + COALESCE(s.end_time, CAST(DATE_ADD(DATE(:today), INTERVAL 1 DAY) AS DATETIME)) AS end_time + FROM study_session s + JOIN bounds b + ON s.user_id = :userId + AND s.start_time < CAST(DATE_ADD(b.end_date, INTERVAL 1 DAY) AS DATETIME) + AND COALESCE(s.end_time, CAST(DATE_ADD(DATE(:today), INTERVAL 1 DAY) AS DATETIME)) > CAST(b.start_date AS DATETIME) + ), + day_overlap AS ( + SELECT d.day_date, + GREATEST(s.start_time, d.day_start) AS seg_start, + LEAST(s.end_time, d.day_end) AS seg_end + FROM days d + JOIN sessions_in_window s + ON s.start_time < d.day_end + AND s.end_time > d.day_start + ), + per_day AS ( + SELECT d.day_date, + CAST(COALESCE(SUM( + GREATEST(TIMESTAMPDIFF(MICROSECOND, o.seg_start, o.seg_end), 0) + ) / 1000, 0) AS SIGNED) AS day_millis + FROM days d + LEFT JOIN day_overlap o ON o.day_date = d.day_date + GROUP BY d.day_date + ), + qualified AS ( + SELECT day_date + FROM per_day + WHERE day_millis >= :thresholdMillis + ), + numbered AS ( + SELECT day_date, + ROW_NUMBER() OVER (ORDER BY day_date) AS rn + FROM qualified + ), + islands AS ( + SELECT day_date, + DATE_SUB(day_date, INTERVAL rn DAY) AS grp_key + FROM numbered + ), + streaks AS ( + SELECT grp_key, + MIN(day_date) AS streak_start, + MAX(day_date) AS streak_end, + COUNT(*) AS streak_len + FROM islands + GROUP BY grp_key + ) + SELECT COALESCE(( + SELECT streak_len + FROM streaks + WHERE streak_end = DATE(:today) + LIMIT 1 + ), 0) + """, nativeQuery = true) + Integer countCurrentConsecutiveStudyDays( + @Param("userId") Long userId, + @Param("today") LocalDate today, + @Param("thresholdMillis") Long thresholdMillis + ); + @Query(value = """ @@ -283,7 +364,7 @@ SELECT DATE_ADD(d.day_date, INTERVAL 1 DAY), DATE_ADD(d.day_start, INTERVAL 1 DAY), DATE_ADD(d.day_end, INTERVAL 1 DAY) FROM days d - JOIN bounds b ON d.day_date < b.end_at_exclusive + JOIN bounds b ON d.day_date < DATE_SUB(b.end_at_exclusive, INTERVAL 1 DAY) ), sessions_in_window AS ( SELECT s.user_id, s.start_time, s.end_time diff --git a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java index f1c17ae..9486c16 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java @@ -122,6 +122,11 @@ ResponseEntity> startStudySession( - 총 학습 시간 계산 및 저장 - 세션 상태를 FINISHED로 변경 - 랭킹 시스템에 반영 (다음 스케줄링 시) + - 배지 지급은 트랜잭션 커밋 이후 동기적으로 처리 + + 🎖️ **배지 확인 방법:** + - 이 API 응답에는 배지 정보가 포함되지 않습니다. + - 종료 성공 후 `GET /api/v1/badge/unnotified`를 호출해 새 배지를 조회하세요. """ ) @SwaggerApiResponses( @@ -140,4 +145,4 @@ ResponseEntity> endStudySession( @Valid @RequestBody StudyEndRequest request, @Parameter(hidden = true) Long userId ); -} \ No newline at end of file +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java b/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java new file mode 100644 index 0000000..b70637c --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java @@ -0,0 +1,25 @@ +package com.gpt.geumpumtabackend.study.event; + +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StudyBadgeGrantEventListener { + + private final BadgeService badgeService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleStudySessionEnded(StudySessionEndedEvent event) { + try { + badgeService.grantStudyAchievementBadges(event.userId()); + } catch (Exception e) { + log.warn("배지 지급 실패 - userId={}", event.userId(), e); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java b/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java new file mode 100644 index 0000000..12eb793 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java @@ -0,0 +1,4 @@ +package com.gpt.geumpumtabackend.study.event; + +public record StudySessionEndedEvent(Long userId) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java index 94cf4d3..4992402 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java @@ -35,6 +35,13 @@ Long sumCompletedStudySessionByUserId( @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay); + @Query(value = "SELECT COALESCE(SUM(s.total_millis), 0) " + + "FROM study_session s " + + "WHERE s.user_id = :userId " + + "AND s.end_time IS NOT NULL", nativeQuery = true) + Long sumTotalStudyMillisByUserId(@Param("userId") Long userId); + + @EntityGraph(attributePaths = {"user"}) List findAllByStatusAndStartTimeBefore(StudyStatus status, LocalDateTime now); diff --git a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java index 486ae42..78cba78 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java @@ -8,6 +8,7 @@ import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; +import com.gpt.geumpumtabackend.study.event.StudySessionEndedEvent; import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; import com.gpt.geumpumtabackend.user.domain.User; import com.gpt.geumpumtabackend.user.repository.UserRepository; @@ -15,6 +16,7 @@ import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -31,6 +33,8 @@ public class StudySessionService { private final UserRepository userRepository; private final CampusWiFiValidationService wifiValidationService; private final StudyProperties studyProperties; + private final ApplicationEventPublisher eventPublisher; + /* 메인 홈 */ @@ -61,6 +65,7 @@ public void endStudySession(StudyEndRequest endRequest, Long userId) { .orElseThrow(()->new BusinessException(ExceptionType.STUDY_SESSION_NOT_FOUND)); LocalDateTime endTime = LocalDateTime.now(); studysession.endStudySession(endTime); + eventPublisher.publishEvent(new StudySessionEndedEvent(userId)); } private BusinessException mapWiFiValidationException(WiFiValidationResult result) { diff --git a/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java b/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java index 52cd317..23a814c 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java @@ -14,6 +14,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.NicknameVerifyResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import io.swagger.v3.oas.annotations.Operation; @@ -35,10 +36,10 @@ public interface UserApi { description = "GUEST 권한을 가진 사용자는 회원가입 완료를 위해 학번과 학부를 입력합니다." + "사용자의 권한이 USER로 변경되고 accessToken과 refreshToken을 재발급받습니다." ) - @ApiResponse(content = @Content(schema = @Schema(implementation = TokenResponse.class))) + @ApiResponse(content = @Content(schema = @Schema(implementation = CompleteRegistrationResponse.class))) @SwaggerApiResponses( success = @SwaggerApiSuccessResponse( - response = TokenResponse.class, + response = CompleteRegistrationResponse.class, description = "회원가입 완료 및 accessToken과 refreshToken 재발급 완료"), errors = { @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), @@ -48,7 +49,7 @@ public interface UserApi { @PostMapping("/complete-registration") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('GUEST')") - public ResponseEntity> completeRegistration( + public ResponseEntity> completeRegistration( @RequestBody @Valid CompleteRegistrationRequest request, @Parameter(hidden = true) Long userId ); diff --git a/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java b/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java index 9f633b7..5418be9 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java @@ -9,6 +9,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.NicknameVerifyResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.service.UserService; @@ -35,11 +36,11 @@ public class UserController implements UserApi { @PostMapping("/complete-registration") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('GUEST')") - public ResponseEntity> completeRegistration( + public ResponseEntity> completeRegistration( @RequestBody @Valid CompleteRegistrationRequest request, Long userId ){ - TokenResponse response = userService.completeRegistration(request, userId); + CompleteRegistrationResponse response = userService.completeRegistration(request, userId); return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); } diff --git a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java index 1acfb78..0c4d403 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java @@ -63,6 +63,8 @@ public class User extends BaseEntity { @Column(length = 255) private String fcmToken; + private Long representativeBadgeId; + @Builder public User(String email, UserRole role, String name, String picture, OAuth2Provider provider, String providerId, Department department) { this.email = email; @@ -106,4 +108,8 @@ public void updateFcmToken(String fcmToken) { public void clearFcmToken() { this.fcmToken = null; } + + public void setRepresentativeBadge(Long badgeId) { + this.representativeBadgeId = badgeId; + } } diff --git a/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java b/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java new file mode 100644 index 0000000..ab4473e --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java @@ -0,0 +1,15 @@ +package com.gpt.geumpumtabackend.user.dto.response; + +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.token.dto.response.TokenResponse; + +import java.util.List; + +public record CompleteRegistrationResponse( + TokenResponse token, + NewBadgeResponse newBadge +) { + public static CompleteRegistrationResponse of(TokenResponse token, NewBadgeResponse newBadge) { + return new CompleteRegistrationResponse(token, newBadge); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java index 1725023..0137a7d 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java @@ -6,6 +6,8 @@ import com.gpt.geumpumtabackend.global.exception.ExceptionType; import com.gpt.geumpumtabackend.global.jwt.JwtHandler; import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.token.domain.Token; import com.gpt.geumpumtabackend.token.dto.response.TokenResponse; import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository; @@ -14,6 +16,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -32,6 +35,7 @@ public class UserService { private final RefreshTokenRepository refreshTokenRepository; private final JwtHandler jwtHandler; private final FcmService fcmService; + private final BadgeService badgeService; private static final Random RANDOM = new Random(); private static final List ADJECTIVES = List.of( @@ -58,7 +62,7 @@ public void generateRandomNickname(User user){ } @Transactional - public TokenResponse completeRegistration(CompleteRegistrationRequest request, Long userId) { + public CompleteRegistrationResponse completeRegistration(CompleteRegistrationRequest request, Long userId) { User user = userRepository.findById(userId) .orElseThrow(()->new BusinessException(ExceptionType.USER_NOT_FOUND)); validateDuplication(request); @@ -68,7 +72,9 @@ public TokenResponse completeRegistration(CompleteRegistrationRequest request, L // 토큰 재발급 JwtUserClaim jwtUserClaim = JwtUserClaim.create(user); Token token = jwtHandler.createTokens(jwtUserClaim); - return TokenResponse.to(token); + TokenResponse tokenResponse = TokenResponse.to(token); + NewBadgeResponse newBadge = badgeService.grantWelcomeBadge(userId); + return CompleteRegistrationResponse.of(tokenResponse, newBadge); } private void validateDuplication(CompleteRegistrationRequest request) { diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java new file mode 100644 index 0000000..aa3e930 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java @@ -0,0 +1,165 @@ +package com.gpt.geumpumtabackend.integration.statistics; + +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider; +import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest; +import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.domain.UserRole; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("Statistics Controller 통합 테스트") +@AutoConfigureMockMvc +class StatisticsControllerIntegrationTest extends BaseIntegrationTest { + + private static final LocalDate BASE_DATE = LocalDate.of(2024, 1, 10); + private static final long ONE_HOUR_MILLIS = 60 * 60 * 1000L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtHandler jwtHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudySessionRepository studySessionRepository; + + private User testUser; + private User otherUser; + private String accessToken; + + @BeforeEach + void setUp() { + testUser = createUser("테스트유저", "stats@kumoh.ac.kr", Department.SOFTWARE); + otherUser = createUser("다른유저", "other-stats@kumoh.ac.kr", Department.COMPUTER_ENGINEERING); + + JwtUserClaim claim = new JwtUserClaim(testUser.getId(), UserRole.USER, false); + Token token = jwtHandler.createTokens(claim); + accessToken = token.getAccessToken(); + } + + private User createUser(String name, String email, Department department) { + User user = User.builder() + .name(name) + .email(email) + .department(department) + .role(UserRole.USER) + .picture("profile.jpg") + .provider(OAuth2Provider.GOOGLE) + .providerId("provider-" + email) + .build(); + return userRepository.save(user); + } + + private StudySession createStudySession(User user, LocalDateTime startTime, LocalDateTime endTime) { + StudySession session = new StudySession(); + session.startStudySession(startTime, user); + session.endStudySession(endTime); + return studySessionRepository.save(session); + } + + @Test + @DisplayName("일간 통계를 2시간 슬롯과 최대 집중 시간으로 조회한다") + void 일간_통계를_조회한다() throws Exception { + LocalDateTime startTime = BASE_DATE.atTime(1, 0); + LocalDateTime endTime = BASE_DATE.atTime(3, 0); + createStudySession(testUser, startTime, endTime); + + createStudySession(otherUser, BASE_DATE.atTime(2, 0), BASE_DATE.atTime(4, 0)); + + mockMvc.perform(get("/api/v1/statistics/day") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.statisticsList", hasSize(12))) + .andExpect(jsonPath("$.data.statisticsList[0].slotStart").value("00:00")) + .andExpect(jsonPath("$.data.statisticsList[0].slotEnd").value("02:00")) + .andExpect(jsonPath("$.data.statisticsList[0].millisecondsStudied").value((int) ONE_HOUR_MILLIS)) + .andExpect(jsonPath("$.data.statisticsList[1].slotStart").value("02:00")) + .andExpect(jsonPath("$.data.statisticsList[1].slotEnd").value("04:00")) + .andExpect(jsonPath("$.data.statisticsList[1].millisecondsStudied").value((int) ONE_HOUR_MILLIS)) + .andExpect(jsonPath("$.data.dayMaxFocusAndFullTimeStatistics.totalStudyMillis").value((int) (ONE_HOUR_MILLIS * 2))) + .andExpect(jsonPath("$.data.dayMaxFocusAndFullTimeStatistics.maxFocusMillis").value((int) (ONE_HOUR_MILLIS * 2))); + } + + @Test + @DisplayName("주간 통계의 총합/연속일/평균을 계산한다") + void 주간_통계를_조회한다() throws Exception { + LocalDate weekDate = BASE_DATE; + LocalDate monday = weekDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + + createStudySession(testUser, monday.atTime(10, 0), monday.atTime(11, 0)); + createStudySession(testUser, monday.plusDays(1).atTime(12, 0), monday.plusDays(1).atTime(13, 0)); + + mockMvc.perform(get("/api/v1/statistics/week") + .param("date", weekDate.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.weeklyStatistics.totalWeekMillis").value((int) (ONE_HOUR_MILLIS * 2))) + .andExpect(jsonPath("$.data.weeklyStatistics.maxConsecutiveStudyDays").value(2)) + .andExpect(jsonPath("$.data.weeklyStatistics.averageDailyMillis").value(1_028_571)); + } + + @Test + @DisplayName("월간 통계의 총합/연속일/평균/공부일수를 계산한다") + void 월간_통계를_조회한다() throws Exception { + createStudySession(testUser, BASE_DATE.withDayOfMonth(2).atTime(9, 0), BASE_DATE.withDayOfMonth(2).atTime(10, 0)); + createStudySession(testUser, BASE_DATE.withDayOfMonth(3).atTime(14, 0), BASE_DATE.withDayOfMonth(3).atTime(16, 0)); + createStudySession(testUser, BASE_DATE.withDayOfMonth(5).atTime(20, 0), BASE_DATE.withDayOfMonth(5).atTime(21, 0)); + + mockMvc.perform(get("/api/v1/statistics/month") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.monthlyStatistics.totalMonthMillis").value((int) 14_400_000L)) + .andExpect(jsonPath("$.data.monthlyStatistics.averageDailyMillis").value(464_516)) + .andExpect(jsonPath("$.data.monthlyStatistics.maxConsecutiveStudyDays").value(2)) + .andExpect(jsonPath("$.data.monthlyStatistics.studiedDays").value(3)); + } + + @Test + @DisplayName("잔디 통계는 4개월 범위의 날짜별 레벨을 반환한다") + void 잔디_통계를_조회한다() throws Exception { + createStudySession(testUser, BASE_DATE.withDayOfMonth(2).atTime(9, 0), BASE_DATE.withDayOfMonth(2).atTime(10, 0)); + + mockMvc.perform(get("/api/v1/statistics/grass") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.grassStatistics", hasSize(123))) + .andExpect(jsonPath("$.data.grassStatistics[?(@.date=='2024-01-02')].level", + hasItem(greaterThanOrEqualTo(1)))); + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java new file mode 100644 index 0000000..9f3dd32 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java @@ -0,0 +1,549 @@ +package com.gpt.geumpumtabackend.unit.badge.service; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.repository.BadgeRepository; +import com.gpt.geumpumtabackend.badge.repository.UserBadgeRepository; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.RankType; +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BadgeService 단위 테스트") +class BadgeServiceTest { + + @Mock + private BadgeRepository badgeRepository; + + @Mock + private UserBadgeRepository userBadgeRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private StudySessionRepository studySessionRepository; + + @Mock + private StatisticsRepository statisticsRepository; + + @Mock + private SeasonRankingSnapshotRepository seasonRankingSnapshotRepository; + + @InjectMocks + private BadgeService badgeService; + + @Test + @DisplayName("관리자가 배지를 생성하면 저장 후 응답을 반환한다") + void 배지를_생성하면_응답을_반환한다() { + // Given + BadgeCreateRequest request = new BadgeCreateRequest( + "WELCOME_001", + "웰컴 배지", + "회원가입 축하 배지", + "https://example.com/welcome.png", + BadgeType.WELCOME, + 0L, + null + ); + Badge savedBadge = createBadge("WELCOME_001", BadgeType.WELCOME, null); + ReflectionTestUtils.setField(savedBadge, "id", 100L); + + when(badgeRepository.existsByCode(request.code())).thenReturn(false); + when(badgeRepository.save(any(Badge.class))).thenReturn(savedBadge); + + // When + BadgeCreateResponse response = badgeService.createBadge(request); + + // Then + assertThat(response.id()).isEqualTo(100L); + assertThat(response.code()).isEqualTo("WELCOME_001"); + verify(badgeRepository).save(any(Badge.class)); + } + + @Test + @DisplayName("중복 코드로 배지 생성 시 BADGE_CODE_ALREADY_EXISTS 예외가 발생한다") + void 중복코드_배지생성시_예외발생() { + // Given + BadgeCreateRequest request = new BadgeCreateRequest( + "WELCOME_001", + "웰컴 배지", + "회원가입 축하 배지", + "https://example.com/welcome.png", + BadgeType.WELCOME, + 0L, + null + ); + when(badgeRepository.existsByCode(request.code())).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> badgeService.createBadge(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.BADGE_CODE_ALREADY_EXISTS); + verify(badgeRepository, never()).save(any(Badge.class)); + } + + @Test + @DisplayName("관리자가 전체 배지를 조회하면 배지 목록을 반환한다") + void 전체_배지를_조회하면_목록을_반환한다() { + // Given + Badge badge1 = createBadge("WELCOME_001", BadgeType.WELCOME, null); + Badge badge2 = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, null); + when(badgeRepository.findAll()).thenReturn(List.of(badge1, badge2)); + + // When + List responses = badgeService.getAllBadges(); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(BadgeResponse::code) + .containsExactly("WELCOME_001", "TOTAL_HOURS_50"); + } + + @Test + @DisplayName("내 배지 조회 시 전체 배지를 반환하고 보유 여부를 표시한다") + void 내배지_조회시_전체배지와_보유여부를_반환한다() { + // Given + Long userId = 1L; + Badge badge1 = createBadge("WELCOME_001", BadgeType.WELCOME, null); + Badge badge2 = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge1, "id", 1L); + ReflectionTestUtils.setField(badge2, "id", 2L); + LocalDateTime awardedAt = LocalDateTime.of(2026, 2, 1, 10, 0); + UserBadge ownedBadge = new UserBadge(userId, 1L, awardedAt, awardedAt); + + when(userRepository.existsById(userId)).thenReturn(true); + when(badgeRepository.findAll()).thenReturn(List.of(badge2, badge1)); + when(userBadgeRepository.findByUserId(userId)).thenReturn(List.of(ownedBadge)); + + // When + List responses = badgeService.getMyBadges(userId); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(MyBadgeStatusResponse::code) + .containsExactly("WELCOME_001", "TOTAL_HOURS_50"); + assertThat(responses.get(0).owned()).isTrue(); + assertThat(responses.get(0).awardedAt()).isEqualTo(awardedAt); + assertThat(responses.get(1).owned()).isFalse(); + assertThat(responses.get(1).awardedAt()).isNull(); + } + + @Test + @DisplayName("지급된 이력이 없는 배지는 삭제한다") + void 지급이력_없는_배지는_삭제한다() { + // Given + Long badgeId = 30L; + Badge badge = createBadge("TOTAL_HOURS_100", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge, "id", badgeId); + when(badgeRepository.findById(badgeId)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByBadgeId(badgeId)).thenReturn(false); + + // When + badgeService.deleteBadge(badgeId); + + // Then + verify(badgeRepository, times(1)).delete(badge); + } + + @Test + @DisplayName("이미 지급된 배지는 삭제 시 BADGE_IN_USE 예외가 발생한다") + void 이미_지급된_배지는_삭제할수없다() { + // Given + Long badgeId = 31L; + Badge badge = createBadge("TOTAL_HOURS_200", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge, "id", badgeId); + when(badgeRepository.findById(badgeId)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByBadgeId(badgeId)).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> badgeService.deleteBadge(badgeId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.BADGE_IN_USE); + verify(badgeRepository, never()).delete(any(Badge.class)); + } + + @Test + @DisplayName("회원가입 후 웰컴 배지가 정상 반환된다") + void 회원가입후_웰컴배지가_반환된다() { + // Given + Long userId = 1L; + Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(badgeRepository.findByBadgeType(BadgeType.WELCOME)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + NewBadgeResponse granted = badgeService.grantWelcomeBadge(userId); + + // Then + assertThat(granted.code()).isEqualTo(badge.getCode()); + + // mock 메서드 호출 시 전달된 인자를 캡처해서, 전달된 데이터 검증 + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBadge.class); + verify(userBadgeRepository, times(1)).save(captor.capture()); + UserBadge saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getBadgeId()).isEqualTo(badge.getId()); + assertThat(saved.getNotifiedAt()).isNotNull(); + } + + @Test + @DisplayName("미확인 배지 조회 시 배지를 반환하고 확인 처리한다") + void 미확인_배지_조회시_배지를_반환하고_확인처리한다() { + // Given + Long userId = 2L; + Badge badge1 = createBadge("SEASON_2025_RANK_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + Badge badge2 = createBadge("SEASON_2025_RANK_2", BadgeType.SEASON_PERSONAL_RANK, 2L); + LocalDateTime awardedAt1 = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime awardedAt2 = LocalDateTime.of(2026, 1, 2, 11, 0); + UserBadge userBadge1 = new UserBadge(userId, badge1.getId(), awardedAt1, null); + UserBadge userBadge2 = new UserBadge(userId, badge2.getId(), awardedAt2, null); + + when(userRepository.existsById(userId)).thenReturn(true); + when(userBadgeRepository.findUnnotifiedBadgeResponses(userId)).thenReturn(List.of( + new MyBadgeResponse(badge1.getCode(), badge1.getName(), badge1.getDescription(), badge1.getIconUrl(), awardedAt1), + new MyBadgeResponse(badge2.getCode(), badge2.getName(), badge2.getDescription(), badge2.getIconUrl(), awardedAt2) + )); + when(userBadgeRepository.findByUserIdAndNotifiedAtIsNull(userId)).thenReturn(List.of(userBadge1, userBadge2)); + when(userBadgeRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.getUnnotifiedBadges(userId); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses) + .extracting(MyBadgeResponse::code) + .containsExactlyInAnyOrder(badge1.getCode(), badge2.getCode()); + assertThat(responses) + .extracting(MyBadgeResponse::awardedAt) + .containsExactlyInAnyOrder(awardedAt1, awardedAt2); + + // mock 메서드 호출 시 전달된 인자를 캡처해서, 전달된 데이터 검증 + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userBadgeRepository, times(1)).saveAll(listCaptor.capture()); + List saved = listCaptor.getValue(); + assertThat(saved).hasSize(2); + assertThat(saved).allSatisfy(ub -> assertThat(ub.getNotifiedAt()).isNotNull()); + LocalDateTime notifiedAt = saved.get(0).getNotifiedAt(); + assertThat(saved) + .extracting(UserBadge::getNotifiedAt) + .allMatch(time -> time.equals(notifiedAt)); + } + + @Test + @DisplayName("대표 배지 설정 시 배지 코드로 조회해 대표 배지를 설정한다") + void 대표_배지_설정시_코드로_배지를_찾아_설정한다() { + // Given + Long userId = 10L; + Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); + User user = mock(User.class); + + when(badgeRepository.findByCode("WELCOME_001")).thenReturn(Optional.of(badge)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(true); + + // When + badgeService.setRepresentativeBadge(new com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest("WELCOME_001"), userId); + + // Then + verify(user, times(1)).setRepresentativeBadge(badge.getId()); + } + + @Test + @DisplayName("누적 공부시간 기준 배지를 미보유하면 발급한다") + void 누적_공부시간_기준_배지를_미보유시_발급한다() { + // Given + Long userId = 3L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 50L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).code()).isEqualTo(badge.getCode()); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("누적 공부시간 기준 배지를 이미 보유하면 발급하지 않는다") + void 누적_공부시간_기준_배지를_이미_보유한_경우_발급하지_않는다() { + // Given + Long userId = 4L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 60L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(true); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("누적 공부시간 기준 미달이면 배지를 발급하지 않는다") + void 누적_공부시간_기준_미달이면_배지를_발급하지_않는다() { + // Given + Long userId = 5L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 49L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("연속 공부일수 기준 배지를 미보유하면 발급한다") + void 연속_공부일수_기준_배지를_미보유시_발급한다() { + // Given + Long userId = 6L; + Badge badge = createBadge("STREAK_DAYS_7", BadgeType.STREAK_DAYS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 7L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(0L); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of()); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(7); + when(badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS)).thenReturn(List.of(badge)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).code()).isEqualTo(badge.getCode()); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("연속 공부일수 기준 미달이면 배지를 발급하지 않는다") + void 연속_공부일수_기준_미달이면_배지를_발급하지_않는다() { + // Given + Long userId = 7L; + Badge badge = createBadge("STREAK_DAYS_7", BadgeType.STREAK_DAYS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 7L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(0L); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of()); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(6); + when(badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS)).thenReturn(List.of(badge)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 전체랭킹 배지를 지급한다") + void 시즌_전체랭킹_배지를_지급한다() { + // Given + Long seasonId = 100L; + Long userId = 11L; + Badge overallRank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + SeasonRankingSnapshot snapshot = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(userId) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(overallRank1Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1))) + .thenReturn(List.of(snapshot)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, overallRank1Badge.getId())) + .thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isEqualTo(1); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 랭킹 배지를 이미 보유하면 지급하지 않는다") + void 시즌_랭킹_배지를_이미_보유하면_지급하지_않는다() { + // Given + Long seasonId = 101L; + Long userId = 12L; + Badge overallRank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + SeasonRankingSnapshot snapshot = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(userId) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(overallRank1Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1))) + .thenReturn(List.of(snapshot)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, overallRank1Badge.getId())) + .thenReturn(true); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isZero(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 전체랭킹 1,2,3등 유저에게 각 등수 배지를 지급한다") + void 시즌_전체랭킹_1_2_3등에_각각_해당_배지를_지급한다() { + // Given + Long seasonId = 102L; + Long user1 = 21L; + Long user2 = 22L; + Long user3 = 23L; + + Badge rank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + Badge rank2Badge = createBadge("SEASON_OVERALL_2", BadgeType.SEASON_PERSONAL_RANK, 2L); + Badge rank3Badge = createBadge("SEASON_OVERALL_3", BadgeType.SEASON_PERSONAL_RANK, 3L); + + SeasonRankingSnapshot snapshot1 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user1) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(3_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + SeasonRankingSnapshot snapshot2 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user2) + .rankType(RankType.OVERALL) + .finalRank(2) + .finalTotalMillis(2_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + SeasonRankingSnapshot snapshot3 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user3) + .rankType(RankType.OVERALL) + .finalRank(3) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(rank1Badge, rank2Badge, rank3Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1, 2, 3))) + .thenReturn(List.of(snapshot3, snapshot1, snapshot2)); + when(userBadgeRepository.existsByUserIdAndBadgeId(anyLong(), anyLong())) + .thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isEqualTo(3); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBadge.class); + verify(userBadgeRepository, times(3)).save(captor.capture()); + List saved = captor.getAllValues(); + Map badgeByUser = saved.stream() + .collect(Collectors.toMap(UserBadge::getUserId, UserBadge::getBadgeId)); + + assertThat(badgeByUser).containsEntry(user1, rank1Badge.getId()); + assertThat(badgeByUser).containsEntry(user2, rank2Badge.getId()); + assertThat(badgeByUser).containsEntry(user3, rank3Badge.getId()); + } + + private Badge createBadge(String code, BadgeType type, Long rank) { + Badge badge = Badge.builder() + .code(code) + .name("badge-name") + .description("badge-desc") + .iconUrl("http://example.com/badge.png") + .badgeType(type) + .thresholdValue(10L) + .rank(rank) + .build(); + ReflectionTestUtils.setField(badge, "id", rank); + return badge; + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java index f1676b3..e570cc1 100644 --- a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java +++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java @@ -2,9 +2,12 @@ import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.study.config.StudyProperties; import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; +import com.gpt.geumpumtabackend.study.event.StudySessionEndedEvent; import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; import com.gpt.geumpumtabackend.study.service.StudySessionService; import com.gpt.geumpumtabackend.user.domain.Department; @@ -12,6 +15,7 @@ import com.gpt.geumpumtabackend.user.repository.UserRepository; import com.gpt.geumpumtabackend.wifi.dto.WiFiValidationResult; import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; +import org.springframework.context.ApplicationEventPublisher; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -42,6 +46,12 @@ class StudySessionServiceTest { @Mock private CampusWiFiValidationService wifiValidationService; + + @Mock + private StudyProperties studyProperties; + + @Mock + private ApplicationEventPublisher eventPublisher; @InjectMocks private StudySessionService studySessionService; @@ -236,6 +246,48 @@ class StudySessionCalculation { } } + @Nested + @DisplayName("공부 종료") + class EndStudySession { + + @Test + @DisplayName("공부 종료 시 세션을 종료하고 AFTER_COMMIT 이벤트를 발행한다") + void 공부종료시_세션종료후_이벤트를_발행한다() { + // Given + Long userId = 1L; + Long sessionId = 10L; + User testUser = createTestUser(userId, "테스트사용자", Department.SOFTWARE); + StudySession session = new StudySession(); + session.startStudySession(LocalDateTime.now().minusHours(1), testUser); + + given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) + .willReturn(Optional.of(session)); + + // When + studySessionService.endStudySession(new StudyEndRequest(sessionId), userId); + + // Then + assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.FINISHED); + verify(eventPublisher).publishEvent(new StudySessionEndedEvent(userId)); + } + + @Test + @DisplayName("세션이 없으면 STUDY_SESSION_NOT_FOUND 예외가 발생한다") + void 세션이_없으면_예외가_발생한다() { + // Given + Long userId = 1L; + Long sessionId = 11L; + given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) + .willReturn(Optional.empty()); + + // When + assertThatThrownBy(() -> studySessionService.endStudySession(new StudyEndRequest(sessionId), userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.STUDY_SESSION_NOT_FOUND); + verify(eventPublisher, never()).publishEvent(any()); + } + } + // 테스트 데이터 생성 헬퍼 메서드 private User createTestUser(Long id, String name, Department department) { User user = User.builder() @@ -259,4 +311,4 @@ private User createTestUser(Long id, String name, Department department) { return user; } -} \ No newline at end of file +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java index ee697a8..e97da4b 100644 --- a/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java +++ b/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java @@ -1,5 +1,7 @@ package com.gpt.geumpumtabackend.unit.user.service; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.fcm.service.FcmService; import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; @@ -15,6 +17,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.repository.UserRepository; import com.gpt.geumpumtabackend.user.service.UserService; @@ -51,6 +54,9 @@ class UserServiceTest { @Mock private FcmService fcmService; + @Mock + private BadgeService badgeService; + @InjectMocks private UserService userService; @@ -129,19 +135,27 @@ class CompleteRegistration { .accessToken("access-token") .refreshToken("refresh-token") .build(); + NewBadgeResponse newBadge = new NewBadgeResponse( + "WELCOME_001", + "웰컴 배지", + "회원가입 기념 배지", + "https://example.com/welcome.png" + ); given(userRepository.findById(userId)).willReturn(Optional.of(user)); given(userRepository.existsBySchoolEmail(request.email())).willReturn(false); given(userRepository.existsByStudentId(request.studentId())).willReturn(false); given(userRepository.existsByNickname(any())).willReturn(false); given(jwtHandler.createTokens(any(JwtUserClaim.class))).willReturn(token); + given(badgeService.grantWelcomeBadge(userId)).willReturn(newBadge); // When - TokenResponse response = userService.completeRegistration(request, userId); + CompleteRegistrationResponse response = userService.completeRegistration(request, userId); // Then - assertThat(response.accessToken()).isEqualTo("access-token"); - assertThat(response.refreshToken()).isEqualTo("refresh-token"); + assertThat(response.token().accessToken()).isEqualTo("access-token"); + assertThat(response.token().refreshToken()).isEqualTo("refresh-token"); + assertThat(response.newBadge().code()).isEqualTo("WELCOME_001"); assertThat(user.getSchoolEmail()).isEqualTo(request.email()); assertThat(user.getStudentId()).isEqualTo(request.studentId()); assertThat(user.getDepartment()).isEqualTo(Department.SOFTWARE); @@ -152,6 +166,7 @@ class CompleteRegistration { claim.role().equals(UserRole.USER) && !claim.withdrawn() )); + verify(badgeService).grantWelcomeBadge(userId); } @Test