diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java index 31362849..a4b0401e 100644 --- a/src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java @@ -1,7 +1,9 @@ package com.depromeet.stonebed.domain.missionRecord.api; import com.depromeet.stonebed.domain.missionRecord.application.MissionRecordService; +import com.depromeet.stonebed.domain.missionRecord.dto.request.MissionRecordCalendarRequest; import com.depromeet.stonebed.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarResponse; import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCreateResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,4 +36,11 @@ public MissionRecordCreateResponse completeMission( public void deleteMissionRecord(@PathVariable Long recordId) { missionRecordService.deleteMissionRecord(recordId); } + + @Operation(summary = "캘린더 형식의 미션 기록 조회", description = "회원의 미션 기록을 페이징하여 조회한다.") + @PostMapping("/calendar") + public MissionRecordCalendarResponse getMissionRecordsForCalendar( + @Valid @RequestBody MissionRecordCalendarRequest request) { + return missionRecordService.getMissionRecordsForCalendar(request.cursor(), request.limit()); + } } diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java index e2194f30..8e93d4fc 100644 --- a/src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java @@ -7,11 +7,21 @@ import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; import com.depromeet.stonebed.domain.missionRecord.domain.MissionStatus; import com.depromeet.stonebed.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarDto; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarResponse; import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCreateResponse; import com.depromeet.stonebed.global.error.ErrorCode; import com.depromeet.stonebed.global.error.exception.CustomException; import com.depromeet.stonebed.global.util.MemberUtil; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,10 +34,13 @@ public class MissionRecordService { private final MissionRecordRepository missionRecordRepository; private final MemberUtil memberUtil; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + public MissionRecordCreateResponse completeMission(MissionRecordCreateRequest request) { Mission mission = findMissionById(request.missionId()); - Member member = memberUtil.getCurrentMember(); + final Member member = memberUtil.getCurrentMember(); MissionRecord missionRecord = MissionRecord.builder() @@ -50,10 +63,50 @@ public void deleteMissionRecord(Long recordId) { missionRecordRepository.delete(missionRecord); } - // 단일 미션 조회 메서드 private Mission findMissionById(Long missionId) { return missionRepository .findById(missionId) .orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND)); } + + @Transactional(readOnly = true) + public MissionRecordCalendarResponse getMissionRecordsForCalendar(String cursor, int limit) { + final Member member = memberUtil.getCurrentMember(); + Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.ASC, "createdAt")); + + List records = getMissionRecords(cursor, member, pageable); + + List calendarData = + records.stream() + .map(record -> MissionRecordCalendarDto.from(record, DATE_FORMATTER)) + .toList(); + + String nextCursor = getNextCursor(records); + + return MissionRecordCalendarResponse.from(calendarData, nextCursor); + } + + private List getMissionRecords(String cursor, Member member, Pageable pageable) { + if (cursor == null) { + return missionRecordRepository.findByMemberIdWithPagination(member.getId(), pageable); + } + + try { + LocalDateTime cursorDate = LocalDate.parse(cursor, DATE_FORMATTER).atStartOfDay(); + return missionRecordRepository.findByMemberIdAndCreatedAtFromWithPagination( + member.getId(), cursorDate, pageable); + } catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_CURSOR_DATE_FORMAT); + } + } + + private String getNextCursor(List records) { + if (records.isEmpty()) { + return null; + } + + MissionRecord lastRecord = records.get(records.size() - 1); + LocalDate nextCursorDate = lastRecord.getCreatedAt().toLocalDate().plusDays(1); + return nextCursorDate.format(DATE_FORMATTER); + } } diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java index 1448a033..5dda6c07 100644 --- a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java @@ -3,4 +3,5 @@ import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; import org.springframework.data.jpa.repository.JpaRepository; -public interface MissionRecordRepository extends JpaRepository {} +public interface MissionRecordRepository + extends JpaRepository, MissionRecordRepositoryCustom {} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryCustom.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryCustom.java new file mode 100644 index 00000000..ee8b2b36 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryCustom.java @@ -0,0 +1,13 @@ +package com.depromeet.stonebed.domain.missionRecord.dao; + +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Pageable; + +public interface MissionRecordRepositoryCustom { + List findByMemberIdWithPagination(Long memberId, Pageable pageable); + + List findByMemberIdAndCreatedAtFromWithPagination( + Long memberId, LocalDateTime createdAt, Pageable pageable); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryImpl.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryImpl.java new file mode 100644 index 00000000..eaf3e6aa --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.depromeet.stonebed.domain.missionRecord.dao; + +import static com.depromeet.stonebed.domain.missionRecord.domain.QMissionRecord.missionRecord; + +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MissionRecordRepositoryImpl implements MissionRecordRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findByMemberIdWithPagination(Long memberId, Pageable pageable) { + return queryFactory + .selectFrom(missionRecord) + .where(isMemberId(memberId)) + .orderBy(missionRecord.createdAt.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + @Override + public List findByMemberIdAndCreatedAtFromWithPagination( + Long memberId, LocalDateTime createdAt, Pageable pageable) { + return queryFactory + .selectFrom(missionRecord) + .where(missionRecord.member.id.eq(memberId).and(createdAtFrom(createdAt))) + .orderBy(missionRecord.createdAt.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + private BooleanExpression isMemberId(Long memberId) { + return missionRecord.member.id.eq(memberId); + } + + private BooleanExpression createdAtFrom(LocalDateTime createdAt) { + return missionRecord.createdAt.goe(createdAt); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java index 3c280765..53a043da 100644 --- a/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java @@ -42,6 +42,9 @@ public class MissionRecord extends BaseTimeEntity { @Column(name = "status", nullable = false) private MissionStatus status; + @Column(name = "booster_value") + private int boosterValue; + @Builder public MissionRecord(Member member, Mission mission, String imageUrl, MissionStatus status) { this.member = member; diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/request/MissionRecordCalendarRequest.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/request/MissionRecordCalendarRequest.java new file mode 100644 index 00000000..dd267c42 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/request/MissionRecordCalendarRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.stonebed.domain.missionRecord.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record MissionRecordCalendarRequest( + @Schema(description = "커서 위치", example = "2024-01-01") String cursor, + @NotNull @Min(1) @Schema(description = "페이지 당 항목 수", example = "30") int limit) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarDto.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarDto.java new file mode 100644 index 00000000..75da124a --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarDto.java @@ -0,0 +1,18 @@ +package com.depromeet.stonebed.domain.missionRecord.dto.response; + +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.format.DateTimeFormatter; + +public record MissionRecordCalendarDto( + @Schema(description = "이미지 ID") Long imageId, + @Schema(description = "이미지 URL") String imageUrl, + @Schema(description = "미션 수행 일자") String missionDate) { + + public static MissionRecordCalendarDto from( + MissionRecord missionRecord, DateTimeFormatter formatter) { + String formattedDate = missionRecord.getCreatedAt().format(formatter); + return new MissionRecordCalendarDto( + missionRecord.getId(), missionRecord.getImageUrl(), formattedDate); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java new file mode 100644 index 00000000..cc2da93c --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java @@ -0,0 +1,18 @@ +package com.depromeet.stonebed.domain.missionRecord.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record MissionRecordCalendarResponse( + @Schema( + description = "미션 기록 데이터 리스트", + example = + "[{ 'imageId': 1, 'imageUrl': 'http://example.com/image1.jpg', 'missionDate': '2024-01-01' }]") + List list, + @Schema(description = "커서 위치", example = "2024-01-03") String nextCursor) { + + public static MissionRecordCalendarResponse from( + List list, String nextCursor) { + return new MissionRecordCalendarResponse(list, nextCursor); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java b/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java index 29bc9798..d72716fc 100644 --- a/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java +++ b/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java @@ -27,6 +27,7 @@ public enum ErrorCode { // mission MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 미션을 찾을 수 없습니다."), MISSION_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 미션 기록을 찾을 수 없습니다."), + INVALID_CURSOR_DATE_FORMAT(HttpStatus.BAD_REQUEST, "잘못된 커서 날짜 형식입니다."), ; private final HttpStatus httpStatus; diff --git a/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java b/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java index 3eb52cc2..f7863d27 100644 --- a/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java +++ b/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java @@ -1,13 +1,16 @@ package com.depromeet.stonebed.domain.mission.dao; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; +import com.depromeet.stonebed.TestQuerydslConfig; import com.depromeet.stonebed.domain.mission.domain.Mission; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; @DataJpaTest +@Import(TestQuerydslConfig.class) public class MissionRepositoryTest { @Autowired private MissionRepository missionRepository; diff --git a/src/test/java/com/depromeet/stonebed/domain/missionRecord/MissionRecordServiceTest.java b/src/test/java/com/depromeet/stonebed/domain/missionRecord/MissionRecordServiceTest.java new file mode 100644 index 00000000..dbf1c34f --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/domain/missionRecord/MissionRecordServiceTest.java @@ -0,0 +1,151 @@ +package com.depromeet.stonebed.domain.missionRecord; + +import static org.assertj.core.api.BDDAssertions.then; +import static org.mockito.Mockito.*; + +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.mission.dao.MissionRepository; +import com.depromeet.stonebed.domain.mission.domain.Mission; +import com.depromeet.stonebed.domain.missionRecord.application.MissionRecordService; +import com.depromeet.stonebed.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionStatus; +import com.depromeet.stonebed.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarResponse; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCreateResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import com.depromeet.stonebed.global.util.MemberUtil; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.FieldReflectionArbitraryIntrospector; +import java.util.List; +import java.util.Optional; +import net.jqwik.api.Arbitraries; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@ExtendWith(MockitoExtension.class) +public class MissionRecordServiceTest { + + @InjectMocks private MissionRecordService missionRecordService; + + @Mock private MissionRepository missionRepository; + + @Mock private MissionRecordRepository missionRecordRepository; + + @Mock private MemberUtil memberUtil; + + private FixtureMonkey fixtureMonkey; + + @BeforeEach + void setUp() { + fixtureMonkey = + FixtureMonkey.builder() + .objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE) + .defaultNotNull(true) + .build(); + } + + @Test + void 미션기록_성공() { + // given + Long missionId = Arbitraries.longs().greaterOrEqual(1L).sample(); + MissionRecordCreateRequest request = new MissionRecordCreateRequest(missionId); + + Mission mission = fixtureMonkey.giveMeOne(Mission.class); + Member member = fixtureMonkey.giveMeOne(Member.class); + MissionRecord missionRecord = + fixtureMonkey + .giveMeBuilder(MissionRecord.class) + .set("mission", mission) + .set("member", member) + .set("status", MissionStatus.COMPLETED) + .sample(); + + when(missionRepository.findById(missionId)).thenReturn(Optional.of(mission)); + when(memberUtil.getCurrentMember()).thenReturn(member); + when(missionRecordRepository.save(any(MissionRecord.class))).thenReturn(missionRecord); + + // when + MissionRecordCreateResponse response = missionRecordService.completeMission(request); + + // then + then(response).isNotNull(); + then(response.recordId()).isEqualTo(missionRecord.getId()); + then(response.missionTitle()).isEqualTo(missionRecord.getMission().getTitle()); + + verify(missionRepository).findById(request.missionId()); + verify(memberUtil).getCurrentMember(); + verify(missionRecordRepository).save(any(MissionRecord.class)); + } + + @Test + void 미션기록삭제_성공() { + // given + Long recordId = Arbitraries.longs().greaterOrEqual(1L).sample(); + MissionRecord missionRecord = fixtureMonkey.giveMeOne(MissionRecord.class); + + when(missionRecordRepository.findById(recordId)).thenReturn(Optional.of(missionRecord)); + + // when + missionRecordService.deleteMissionRecord(recordId); + + // then + verify(missionRecordRepository).findById(recordId); + verify(missionRecordRepository).delete(missionRecord); + } + + @Test + void 미션기록삭제_실패() { + // given + Long recordId = Arbitraries.longs().greaterOrEqual(1L).sample(); + + when(missionRecordRepository.findById(recordId)).thenReturn(Optional.empty()); + + // when & then + CustomException exception = + org.junit.jupiter.api.Assertions.assertThrows( + CustomException.class, + () -> missionRecordService.deleteMissionRecord(recordId)); + + then(exception.getErrorCode()).isEqualTo(ErrorCode.MISSION_RECORD_NOT_FOUND); + + verify(missionRecordRepository).findById(recordId); + verify(missionRecordRepository, never()).delete(any(MissionRecord.class)); + } + + @Test + void 캘린더미션기록조회_성공() { + // given + Member member = fixtureMonkey.giveMeOne(Member.class); + List missionRecords = fixtureMonkey.giveMe(MissionRecord.class, 5); + + when(memberUtil.getCurrentMember()).thenReturn(member); + when(missionRecordRepository.findByMemberIdWithPagination(anyLong(), any(Pageable.class))) + .thenReturn(missionRecords); + + String cursor = null; + int limit = 5; + + // when + MissionRecordCalendarResponse response = + missionRecordService.getMissionRecordsForCalendar(cursor, limit); + + // then + then(response).isNotNull(); + then(response.list()).isNotEmpty(); + + verify(memberUtil).getCurrentMember(); + verify(missionRecordRepository) + .findByMemberIdWithPagination( + member.getId(), + PageRequest.of(0, limit, Sort.by(Sort.Direction.ASC, "createdAt"))); + } +}