diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index 03ba61e49..e7d1b2ffb 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -295,6 +295,10 @@ public enum SwaggerResponseDescription { ATTENDANCE_CHECK_WRITE_LIMIT_EXCEEDED ))), + ATTENDANCE_CHECK_SHOW(new LinkedHashSet<>(Set.of( + ROOM_ACCESS_FORBIDDEN + ))), + ; private final Set errorCodeList; SwaggerResponseDescription(Set errorCodeList) { diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java index 94f7f01ef..f62435f6e 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java @@ -6,8 +6,10 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse; import konkuk.thip.roompost.adapter.in.web.response.RecordPinResponse; import konkuk.thip.roompost.adapter.in.web.response.RoomPostSearchResponse; +import konkuk.thip.roompost.application.port.in.AttendanceCheckShowUseCase; import konkuk.thip.roompost.application.port.in.RecordPinUseCase; import konkuk.thip.roompost.application.port.in.RoomPostSearchUseCase; import konkuk.thip.roompost.application.port.in.dto.record.RecordPinQuery; @@ -18,8 +20,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import static konkuk.thip.common.swagger.SwaggerResponseDescription.RECORD_PIN; -import static konkuk.thip.common.swagger.SwaggerResponseDescription.RECORD_SEARCH; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.*; @Tag(name = "RoomPost Query API", description = "방 게시글 조회 관련 API") @RestController @@ -28,6 +29,7 @@ public class RoomPostQueryController { private final RoomPostSearchUseCase roomPostSearchUseCase; private final RecordPinUseCase recordPinUseCase; + private final AttendanceCheckShowUseCase attendanceCheckShowUseCase; @Operation( summary = "방의 게시글(기록, 투표) 목록 조회", @@ -81,4 +83,17 @@ public BaseResponse pinRecord( return BaseResponse.ok(recordPinUseCase.pinRecord(new RecordPinQuery(roomId, recordId, userId))); } + @Operation( + summary = "오늘의 한마디 조회", + description = "방 참여자가 오늘의 한마디를 조회합니다." + ) + @ExceptionDescription(ATTENDANCE_CHECK_SHOW) + @GetMapping("/rooms/{roomId}/daily-greeting") + public BaseResponse showDailyGreeting( + @Parameter(description = "게시글을 조회할 방 ID", example = "1") @PathVariable final Long roomId, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(required = false) final String cursor, + @Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(attendanceCheckShowUseCase.showDailyGreeting(userId, roomId, cursor)); + } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/AttendanceCheckShowResponse.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/AttendanceCheckShowResponse.java new file mode 100644 index 000000000..85edb6ed9 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/AttendanceCheckShowResponse.java @@ -0,0 +1,40 @@ +package konkuk.thip.roompost.adapter.in.web.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +@Schema( + description = "오늘의 한마디 조회 응답. 작성 시각 기준으로 최신순으로 정렬하여 응답합니다." +) +public record AttendanceCheckShowResponse( + @Schema( + description = "오늘의 한마디 목록, 10개씩 끊어서 응답합니다." + ) + List todayCommentList, + + @Schema( + description = "다음 페이지 조회를 위한 커서(없으면 null)" + ) + String nextCursor, + + boolean isLast +) { + public record AttendanceCheckShowDto( + Long attendanceCheckId, + Long creatorId, + String creatorNickname, + String creatorProfileImageUrl, + String todayComment, + + @Schema(description = "작성 시각(상대 시간 등 가공된 문자열)", example = "5분 전") + String postDate, + + @Schema(description = "작성 날짜(yyyy-MM-dd), 이걸로 날짜별로 끊어서 화면에 보여주시면 됩니다.", example = "2025-08-17") + LocalDate date, // 해당 오늘의 한마디 데이터의 작성 날짜 + + @Schema(description = "현재 사용자가 해당 글을 작성했는지 여부") + boolean isWriter + ) { } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java index 779131e78..bd9eb4418 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java @@ -1,12 +1,16 @@ package konkuk.thip.roompost.adapter.out.persistence; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.roompost.adapter.out.mapper.AttendanceCheckMapper; import konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck.AttendanceCheckJpaRepository; import konkuk.thip.roompost.application.port.out.AttendanceCheckQueryPort; +import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.util.List; import static konkuk.thip.common.entity.StatusType.ACTIVE; @@ -24,4 +28,17 @@ public int countAttendanceChecksOnTodayByUser(Long userId, Long roomId) { return attendanceCheckJpaRepository.countByUserIdAndRoomIdAndCreatedAtBetween(userId, roomId, startOfDay, endOfDay, ACTIVE); } + + @Override + public CursorBasedList findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor) { + LocalDateTime lastCreateAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + int size = cursor.getPageSize(); + + List attendanceCheckQueryDtos = attendanceCheckJpaRepository.findAttendanceChecksByCreatedAtDesc(roomId, lastCreateAt, size); + + return CursorBasedList.of(attendanceCheckQueryDtos, size, attendanceCheckQueryDto -> { + Cursor nextCursor = new Cursor(List.of(attendanceCheckQueryDto.createdAt().toString())); + return nextCursor.toEncodedString(); + }); + } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java index 0e7a7fc46..31aa2637a 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java @@ -8,7 +8,7 @@ import java.time.LocalDateTime; -public interface AttendanceCheckJpaRepository extends JpaRepository { +public interface AttendanceCheckJpaRepository extends JpaRepository, AttendanceCheckQueryRepository { // TODO : count 값을 매번 쿼리를 통해 계산하는게 아니라 DB에 저장 or redis 캐시에 저장하는 방법도 좋을 듯 @Query("SELECT COUNT(a) FROM AttendanceCheckJpaEntity a " + diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java new file mode 100644 index 000000000..a0c250171 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java @@ -0,0 +1,11 @@ +package konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck; + +import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto; + +import java.time.LocalDateTime; +import java.util.List; + +public interface AttendanceCheckQueryRepository { + + List findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size); +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepositoryImpl.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepositoryImpl.java new file mode 100644 index 000000000..393d3568c --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepositoryImpl.java @@ -0,0 +1,50 @@ +package konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.roompost.adapter.out.jpa.QAttendanceCheckJpaEntity; +import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto; +import konkuk.thip.roompost.application.port.out.dto.QAttendanceCheckQueryDto; +import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class AttendanceCheckQueryRepositoryImpl implements AttendanceCheckQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size) { + QAttendanceCheckJpaEntity attendanceCheck = QAttendanceCheckJpaEntity.attendanceCheckJpaEntity; + QUserJpaEntity user = QUserJpaEntity.userJpaEntity; + QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; + + BooleanExpression roomPredicate = attendanceCheck.roomJpaEntity.roomId.eq(roomId); + BooleanExpression cursorPredicate = (lastCreatedAt == null) ? null : attendanceCheck.createdAt.lt(lastCreatedAt); + + return jpaQueryFactory + .select(new QAttendanceCheckQueryDto( + attendanceCheck.attendanceCheckId, + user.userId, + user.nickname, + alias.imageUrl, + attendanceCheck.todayComment, + attendanceCheck.createdAt + )) + .from(attendanceCheck) + .join(attendanceCheck.userJpaEntity, user) + .join(user.aliasForUserJpaEntity, alias) + .where(roomPredicate, cursorPredicate) + .orderBy( + attendanceCheck.createdAt.desc() + ) + .limit(size + 1) // 다음 페이지 존재 여부를 확인하기 위해 + .fetch(); + } +} diff --git a/src/main/java/konkuk/thip/roompost/application/mapper/AttendanceCheckQueryMapper.java b/src/main/java/konkuk/thip/roompost/application/mapper/AttendanceCheckQueryMapper.java new file mode 100644 index 000000000..922d6b622 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/mapper/AttendanceCheckQueryMapper.java @@ -0,0 +1,28 @@ +package konkuk.thip.roompost.application.mapper; + +import konkuk.thip.common.util.DateUtil; +import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse; +import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto; +import org.mapstruct.*; + +import java.util.List; + +@Mapper( + componentModel = "spring", + imports = DateUtil.class, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface AttendanceCheckQueryMapper { + + @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))") + @Mapping(target = "date", expression = "java(dto.createdAt().toLocalDate())") + @Mapping(target = "isWriter", source = "dto.creatorId", qualifiedByName = "isWriter") + AttendanceCheckShowResponse.AttendanceCheckShowDto toAttendanceCheckShowDto(AttendanceCheckQueryDto dto, @Context Long userId); + + List toAttendanceCheckShowResponse(List dtos, @Context Long userId); + + @Named("isWriter") + default boolean isWriter(Long creatorId, @Context Long userId) { + return creatorId != null && creatorId.equals(userId); + } +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/AttendanceCheckShowUseCase.java b/src/main/java/konkuk/thip/roompost/application/port/in/AttendanceCheckShowUseCase.java new file mode 100644 index 000000000..d988ae8f0 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/AttendanceCheckShowUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.roompost.application.port.in; + +import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse; + +public interface AttendanceCheckShowUseCase { + + AttendanceCheckShowResponse showDailyGreeting(Long userId, Long roomId, String cursorStr); +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java index d86d32f97..529977e3c 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java @@ -1,7 +1,12 @@ package konkuk.thip.roompost.application.port.out; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto; + public interface AttendanceCheckQueryPort { int countAttendanceChecksOnTodayByUser(Long userId, Long roomId); + CursorBasedList findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor); } diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/dto/AttendanceCheckQueryDto.java b/src/main/java/konkuk/thip/roompost/application/port/out/dto/AttendanceCheckQueryDto.java new file mode 100644 index 000000000..fe7176d44 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/out/dto/AttendanceCheckQueryDto.java @@ -0,0 +1,17 @@ +package konkuk.thip.roompost.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; + +import java.time.LocalDateTime; + +public record AttendanceCheckQueryDto( + Long attendanceCheckId, + Long creatorId, + String creatorNickname, + String creatorProfileImageUrl, + String todayComment, + LocalDateTime createdAt +) { + @QueryProjection + public AttendanceCheckQueryDto {} +} diff --git a/src/main/java/konkuk/thip/roompost/application/service/AttendanceCheckShowService.java b/src/main/java/konkuk/thip/roompost/application/service/AttendanceCheckShowService.java new file mode 100644 index 000000000..acd7fb1bf --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/service/AttendanceCheckShowService.java @@ -0,0 +1,43 @@ +package konkuk.thip.roompost.application.service; + +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.room.application.service.validator.RoomParticipantValidator; +import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse; +import konkuk.thip.roompost.application.mapper.AttendanceCheckQueryMapper; +import konkuk.thip.roompost.application.port.in.AttendanceCheckShowUseCase; +import konkuk.thip.roompost.application.port.out.AttendanceCheckQueryPort; +import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AttendanceCheckShowService implements AttendanceCheckShowUseCase { + + private static final int PAGE_SIZE = 10; + private final RoomParticipantValidator roomParticipantValidator; + private final AttendanceCheckQueryPort attendanceCheckQueryPort; + private final AttendanceCheckQueryMapper attendanceCheckQueryMapper; + + @Override + @Transactional(readOnly = true) + public AttendanceCheckShowResponse showDailyGreeting(Long userId, Long roomId, String cursorStr) { + // 1. 유저가 방 멤버가 맞는지 검사 + roomParticipantValidator.validateUserIsRoomMember(roomId, userId); + + // 2. Cursor 생성 + Cursor cursor = Cursor.from(cursorStr, PAGE_SIZE); + + // 3. 오늘의 한마디 조회 + CursorBasedList dtos = attendanceCheckQueryPort.findAttendanceChecksByCreatedAtDesc(roomId, cursor); + + // 4. response 로 매핑 후 반환 + return new AttendanceCheckShowResponse( + attendanceCheckQueryMapper.toAttendanceCheckShowResponse(dtos.contents(), userId), + dtos.nextCursor(), + dtos.isLast() + ); + } +} diff --git a/src/test/java/konkuk/thip/roompost/adapter/in/web/AttendanceCheckShowApiTest.java b/src/test/java/konkuk/thip/roompost/adapter/in/web/AttendanceCheckShowApiTest.java new file mode 100644 index 000000000..b2e096a7b --- /dev/null +++ b/src/test/java/konkuk/thip/roompost/adapter/in/web/AttendanceCheckShowApiTest.java @@ -0,0 +1,237 @@ +package konkuk.thip.roompost.adapter.in.web; + +import com.jayway.jsonpath.JsonPath; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.roompost.adapter.out.jpa.AttendanceCheckJpaEntity; +import konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck.AttendanceCheckJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.AfterEach; +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.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import static konkuk.thip.common.exception.code.ErrorCode.ROOM_ACCESS_FORBIDDEN; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 오늘의 한마디 조회 api 통합 테스트") +class AttendanceCheckShowApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired private AttendanceCheckJpaRepository attendanceCheckJpaRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @AfterEach + void tearDown() { + attendanceCheckJpaRepository.deleteAllInBatch(); + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + categoryJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + aliasJpaRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("방의 출석체크(= 오늘의 한마디) 조회 요청하면, [오늘의 한마디 작성자 정보, 오늘의 한마디 정보] 등을 최신순으로 반환한다.") + void attendance_check_show_test() throws Exception { + //given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + CategoryJpaEntity c0 = categoryJpaRepository.save(TestEntityFactory.createScienceCategory(a0)); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createRoom(book, c0)); + + // me, user1 이 room에 참여중인 상황 + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, me, RoomParticipantRole.MEMBER, 0.0)); + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, user1, RoomParticipantRole.MEMBER, 0.0)); + + // me, user1가 room에 오늘의 한마디 작성함 (me : ac1, ac3 작성, user1 : ac2 작성) + AttendanceCheckJpaEntity ac1 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한1", room, me)); + AttendanceCheckJpaEntity ac2 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한2", room, user1)); + AttendanceCheckJpaEntity ac3 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한3", room, me)); + + // ac1 -> ac2 -> ac3 순으로 작성 + LocalDateTime base = LocalDateTime.now(); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(30)), ac1.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(20)), ac2.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(10)), ac3.getAttendanceCheckId()); + + //when //then + mockMvc.perform(get("/rooms/{roomId}/daily-greeting", room.getRoomId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.todayCommentList", hasSize(3))) + // 정렬 순서 : 오늘의 한마디 작성 시각 기준 최신순 + .andExpect(jsonPath("$.data.todayCommentList[0].creatorId", is(me.getUserId().intValue()))) + .andExpect(jsonPath("$.data.todayCommentList[0].todayComment", is("오한3"))) + .andExpect(jsonPath("$.data.todayCommentList[1].creatorId", is(user1.getUserId().intValue()))) + .andExpect(jsonPath("$.data.todayCommentList[1].todayComment", is("오한2"))) + .andExpect(jsonPath("$.data.todayCommentList[2].creatorId", is(me.getUserId().intValue()))) + .andExpect(jsonPath("$.data.todayCommentList[2].todayComment", is("오한1"))); + } + + @Test + @DisplayName("방의 멤버가 아닌 사람이 오늘의 한마디 조회 요청을 보낼 경우, 403 error가 발생한다.") + void attendance_check_show_no_room_member() throws Exception { + //given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + CategoryJpaEntity c0 = categoryJpaRepository.save(TestEntityFactory.createScienceCategory(a0)); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createRoom(book, c0)); + + // user1 이 room에 참여중인 상황 (me는 아님) + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, user1, RoomParticipantRole.MEMBER, 0.0)); + + // me, user1가 room에 오늘의 한마디 작성함 (me : ac1, ac3 작성, user1 : ac2 작성) + AttendanceCheckJpaEntity ac2 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한2", room, user1)); + + //when //then + mockMvc.perform(get("/rooms/{roomId}/daily-greeting", room.getRoomId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message", containsString(ROOM_ACCESS_FORBIDDEN.getMessage()))); + } + + @Test + @DisplayName("오늘의 한마디 조회는 작성 시각 기준 최신순 정렬 & 커서 기반 페이지네이션으로 동작한다.") + void attendance_check_show_page_test() throws Exception { + //given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + + CategoryJpaEntity c0 = categoryJpaRepository.save(TestEntityFactory.createScienceCategory(a0)); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createRoom(book, c0)); + + // user1 이 room에 참여중인 상황 (me는 아님) + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, me, RoomParticipantRole.MEMBER, 0.0)); + + // me가 room에 오늘의 한마디 작성함 + AttendanceCheckJpaEntity ac1 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한1", room, me)); + AttendanceCheckJpaEntity ac2 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한2", room, me)); + AttendanceCheckJpaEntity ac3 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한3", room, me)); + AttendanceCheckJpaEntity ac4 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한4", room, me)); + AttendanceCheckJpaEntity ac5 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한5", room, me)); + AttendanceCheckJpaEntity ac6 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한6", room, me)); + AttendanceCheckJpaEntity ac7 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한7", room, me)); + AttendanceCheckJpaEntity ac8 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한8", room, me)); + AttendanceCheckJpaEntity ac9 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한9", room, me)); + AttendanceCheckJpaEntity ac10 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한10", room, me)); + AttendanceCheckJpaEntity ac11 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한11", room, me)); + AttendanceCheckJpaEntity ac12 = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck("오한12", room, me)); + + // ac1 -> ac2 -> ,,, -> ac12 순으로 작성 + LocalDateTime base = LocalDateTime.now(); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(60)), ac1.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(55)), ac2.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), ac3.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(45)), ac4.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), ac5.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(35)), ac6.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(30)), ac7.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(25)), ac8.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(20)), ac9.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(15)), ac10.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(10)), ac11.getAttendanceCheckId()); + jdbcTemplate.update( + "UPDATE attendance_checks SET created_at = ? WHERE attendancecheck_id = ?", + Timestamp.valueOf(base.minusMinutes(5)), ac12.getAttendanceCheckId()); + + //when //then + MvcResult firstResult = mockMvc.perform(get("/rooms/{roomId}/daily-greeting", room.getRoomId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.todayCommentList", hasSize(10))) + // 정렬 순서 : 오늘의 한마디 작성 시각 기준 최신순 + .andExpect(jsonPath("$.data.todayCommentList[0].todayComment", is("오한12"))) + .andExpect(jsonPath("$.data.todayCommentList[1].todayComment", is("오한11"))) + .andExpect(jsonPath("$.data.todayCommentList[2].todayComment", is("오한10"))) + .andExpect(jsonPath("$.data.todayCommentList[3].todayComment", is("오한9"))) + .andExpect(jsonPath("$.data.todayCommentList[4].todayComment", is("오한8"))) + .andExpect(jsonPath("$.data.todayCommentList[5].todayComment", is("오한7"))) + .andExpect(jsonPath("$.data.todayCommentList[6].todayComment", is("오한6"))) + .andExpect(jsonPath("$.data.todayCommentList[7].todayComment", is("오한5"))) + .andExpect(jsonPath("$.data.todayCommentList[8].todayComment", is("오한4"))) + .andExpect(jsonPath("$.data.todayCommentList[9].todayComment", is("오한3"))) + .andReturn(); + + String responseBody = firstResult.getResponse().getContentAsString(); + String nextCursor = JsonPath.read(responseBody, "$.data.nextCursor"); + + mockMvc.perform(get("/rooms/{roomId}/daily-greeting", room.getRoomId().intValue()) + .requestAttr("userId", me.getUserId()) + .param("cursor", nextCursor)) // 2페이지 요청 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.todayCommentList", hasSize(2))) + // 정렬 순서 : 오늘의 한마디 작성 시각 기준 최신순 + .andExpect(jsonPath("$.data.todayCommentList[0].todayComment", is("오한2"))) + .andExpect(jsonPath("$.data.todayCommentList[1].todayComment", is("오한1"))); + } +}