From 424036f77f15b03f5f78f54289db911a2a4759cb Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Mon, 5 Oct 2026 05:14:02 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B0=98=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EA=B1=B4=EC=88=98?= =?UTF-8?q?=EC=97=90=20=EA=B8=B0=EC=A1=B4=20=EA=B2=80=EC=83=89=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbound/general/posting/dto/PostingListFilterDto.java | 3 +++ .../posting/persistence/PostingQueryRepositoryImpl.java | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java index 17aeed6f..0c343aaa 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java @@ -45,4 +45,7 @@ public class PostingListFilterDto { @Parameter(description = "급여순 정렬 여부") private Boolean payAmountSort; + + @Parameter(description = "검색 키워드 (공고 제목 또는 업장명, 대소문자 무시; 생략하거나 공백만 있으면 전체 조회)") + private String searchKeyword; } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index bdf49f0d..88ad8534 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java @@ -82,7 +82,8 @@ public long getCountOfPostings(PostingListFilterDto filter) { ltePayAmount(qPosting, filter.getMaxPayAmount()), gteStartTime(qPostingSchedule, filter.getStartTime()), lteEndTime(qPostingSchedule, filter.getEndTime()), - workingDaysIn(qPostingSchedule, filter.getWorkingDays()) + workingDaysIn(qPostingSchedule, filter.getWorkingDays()), + containsSearchKeyword(qPosting, qWorkspace, filter.getSearchKeyword()) ) .fetchOne(); @@ -132,7 +133,8 @@ public List getPostingsWithCursor(CursorPageRequest Date: Wed, 21 Oct 2026 15:45:37 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20[ALT-308]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EC=95=9E=EB=92=A4=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=9C=EA=B1=B0=20=ED=9B=84=20=EA=B2=80=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbound/general/posting/dto/PostingListFilterDto.java | 2 +- .../posting/persistence/PostingQueryRepositoryImpl.java | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java index 0c343aaa..5c322157 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java @@ -46,6 +46,6 @@ public class PostingListFilterDto { @Parameter(description = "급여순 정렬 여부") private Boolean payAmountSort; - @Parameter(description = "검색 키워드 (공고 제목 또는 업장명, 대소문자 무시; 생략하거나 공백만 있으면 전체 조회)") + @Parameter(description = "검색 키워드 (공고 제목 또는 업장명, 대소문자 무시, 앞뒤 공백 제거; 생략하거나 공백만 있으면 전체 조회)") private String searchKeyword; } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index 88ad8534..d2466443 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Repository; import java.math.BigDecimal; @@ -565,11 +566,12 @@ private BooleanExpression withinBounds(QWorkspace qWorkspace, CoordinateDto coor } private BooleanExpression containsSearchKeyword(QPosting qPosting, QWorkspace qWorkspace, String searchKeyword) { - if (ObjectUtils.isEmpty(searchKeyword) || searchKeyword.isBlank()) { + String keyword = StringUtils.trimToNull(searchKeyword); + if (keyword == null) { return null; } - return qPosting.title.containsIgnoreCase(searchKeyword) - .or(qWorkspace.businessName.containsIgnoreCase(searchKeyword)); + return qPosting.title.containsIgnoreCase(keyword) + .or(qWorkspace.businessName.containsIgnoreCase(keyword)); } private OrderSpecifier[] getOrderSpecifiersForMapList(QPosting qPosting, PostingSortType sortType) { From 0276326858459034a251f623405865e10f86af01 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 21 Oct 2026 18:22:07 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20[ALT-308]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=A7=88=EC=A7=80=EB=A7=89=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EC=BB=A4=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=82=B4=EB=A0=A4=EC=A3=BC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/GetPostingsWithCursor.java | 19 +-- .../usecase/GetPostingsWithCursorTests.java | 136 ++++++++++++++++++ 2 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java index f4c2e421..86791c98 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java @@ -30,7 +30,7 @@ public CursorPaginatedApiResponse execute(CursorPageRequ if (ObjectUtils.isNotEmpty(request.cursor())) { cursorDto = CursorUtil.decodeCursor(request.cursor(), CursorDto.class, objectMapper); } - CursorPageRequest pageRequest = CursorPageRequest.of(cursorDto, request.pageSize()); + CursorPageRequest pageRequest = CursorPageRequest.of(cursorDto, request.pageSize() + 1); long count = postingQueryRepository.getCountOfPostings(filter); if (count == 0) { @@ -42,16 +42,19 @@ public CursorPaginatedApiResponse execute(CursorPageRequ return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(request.pageSize(), (int) count)); } - PostingListResponse last = postings.getLast(); - CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of( - CursorUtil.encodeCursor(new CursorDto(last.getId(), last.getCreatedAt()), objectMapper), - pageRequest.pageSize(), - (int) count - ); + List page = postings.subList(0, Math.min(postings.size(), request.pageSize())); + boolean hasNext = !page.isEmpty() && postings.size() > page.size(); + + String nextCursor = null; + if (hasNext) { + PostingListResponse last = page.getLast(); + nextCursor = CursorUtil.encodeCursor(new CursorDto(last.getId(), last.getCreatedAt()), objectMapper); + } + CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of(nextCursor, request.pageSize(), (int) count); return CursorPaginatedApiResponse.of( pageResponseDto, - postings.stream() + page.stream() .map(PostingListResponseDto::from) .toList() ); diff --git a/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java b/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java new file mode 100644 index 00000000..294d51f1 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java @@ -0,0 +1,136 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequest; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.general.posting.dto.PostingListFilterDto; +import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.PostingListResponse; +import com.dreamteam.alter.common.util.CursorUtil; +import com.dreamteam.alter.domain.posting.port.outbound.PostingQueryRepository; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessType; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.fasterxml.jackson.databind.ObjectMapper; +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.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class GetPostingsWithCursorTests { + @Mock PostingQueryRepository postingQueryRepository; + @Spy ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + @InjectMocks GetPostingsWithCursor useCase; + + private PostingListResponse row(Long id) { + PostingListResponse row = mock(PostingListResponse.class); + Workspace workspace = mock(Workspace.class); + lenient().when(workspace.getBusinessType()).thenReturn(BusinessType.create("카페", "설명")); + lenient().when(row.getWorkspace()).thenReturn(workspace); + lenient().when(row.getSchedules()).thenReturn(List.of()); + lenient().when(row.getId()).thenReturn(id); + lenient().when(row.getCreatedAt()).thenReturn(LocalDateTime.of(2026, 10, 3, 12, 0).minusDays(id)); + return row; + } + + private AppActor actor() { + AppActor actor = mock(AppActor.class); + lenient().when(actor.getUser()).thenReturn(mock(User.class)); + return actor; + } + + @Test + void lastPageShorterThanPageSizeHasNoCursor() { + given(postingQueryRepository.getCountOfPostings(any())).willReturn(2L); + List rows = List.of(row(1L), row(2L)); + given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) + .willReturn(rows); + + var result = useCase.execute(new CursorPageRequestDto(null, 10), new PostingListFilterDto(), actor()); + + assertThat(result.page().cursor()).isNull(); + assertThat(result.page().pageSize()).isEqualTo(10); + assertThat(result.page().totalCount()).isEqualTo(2); + assertThat(result.data()).hasSize(2); + } + + @Test + void lastPageExactlyPageSizeHasNoCursor() { + given(postingQueryRepository.getCountOfPostings(any())).willReturn(2L); + List rows = List.of(row(1L), row(2L)); + given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) + .willReturn(rows); + + var result = useCase.execute(new CursorPageRequestDto(null, 2), new PostingListFilterDto(), actor()); + + assertThat(result.page().cursor()).isNull(); + assertThat(result.data()).hasSize(2); + } + + @Test + void middlePageDropsExtraRowAndPointsCursorAtLastReturnedRow() { + given(postingQueryRepository.getCountOfPostings(any())).willReturn(3L); + List rows = List.of(row(1L), row(2L), row(3L)); + given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) + .willReturn(rows); + + var result = useCase.execute(new CursorPageRequestDto(null, 2), new PostingListFilterDto(), actor()); + + assertThat(result.data()).extracting(dto -> dto.getId()).containsExactly(1L, 2L); + assertThat(result.page().totalCount()).isEqualTo(3); + CursorDto cursor = CursorUtil.decodeCursor(result.page().cursor(), CursorDto.class, objectMapper); + assertThat(cursor.getId()).isEqualTo(2L); + } + + @Test + void repositoryIsAskedForOneMoreRowThanPageSize() { + given(postingQueryRepository.getCountOfPostings(any())).willReturn(1L); + List rows = List.of(row(1L)); + given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) + .willReturn(rows); + + useCase.execute(new CursorPageRequestDto(null, 5), new PostingListFilterDto(), actor()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(CursorPageRequest.class); + verify(postingQueryRepository).getPostingsWithCursor(captor.capture(), any(), any()); + assertThat(captor.getValue().pageSize()).isEqualTo(6); + } + + @Test + void noMatchingPostingsReturnsEmptyWithoutCursor() { + given(postingQueryRepository.getCountOfPostings(any())).willReturn(0L); + + var result = useCase.execute(new CursorPageRequestDto(null, 10), new PostingListFilterDto(), actor()); + + assertThat(result.page().cursor()).isNull(); + assertThat(result.page().totalCount()).isZero(); + assertThat(result.data()).isEmpty(); + } + + @Test + void zeroPageSizeReturnsNoRowsAndNoCursor() { + given(postingQueryRepository.getCountOfPostings(any())).willReturn(3L); + List rows = List.of(row(1L)); + given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())).willReturn(rows); + + var result = useCase.execute(new CursorPageRequestDto(null, 0), new PostingListFilterDto(), actor()); + + assertThat(result.page().cursor()).isNull(); + assertThat(result.data()).isEmpty(); + } +} From 4e34975c4fc58b06eb55f89e738cd146f9ddf7f8 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 28 Oct 2026 20:23:14 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20[ALT-308]=20=EC=A0=84=EA=B0=81=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=EB=A7=8C=20=EC=9E=85=EB=A0=A5=ED=95=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=EB=8A=94=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../posting/persistence/PostingQueryRepositoryImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index d2466443..2f74b750 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java @@ -566,7 +566,7 @@ private BooleanExpression withinBounds(QWorkspace qWorkspace, CoordinateDto coor } private BooleanExpression containsSearchKeyword(QPosting qPosting, QWorkspace qWorkspace, String searchKeyword) { - String keyword = StringUtils.trimToNull(searchKeyword); + String keyword = StringUtils.stripToNull(searchKeyword); if (keyword == null) { return null; } From e927c7eef85f45d6a8b352161fbe9979e8e66c5e Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 28 Oct 2026 20:23:14 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test:=20[ALT-308]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=BB=A4=EC=84=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=98=95=EC=8B=9D=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BB=A4=EC=84=9C=20createdAt=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/GetPostingsWithCursorTests.java | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java b/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java index 294d51f1..c33f0647 100644 --- a/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java +++ b/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java @@ -12,6 +12,7 @@ import com.dreamteam.alter.domain.workspace.entity.BusinessType; import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -26,11 +27,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@DisplayName("GetPostingsWithCursor - 일반 공고 목록 커서 조회") class GetPostingsWithCursorTests { @Mock PostingQueryRepository postingQueryRepository; @Spy ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); @@ -54,14 +56,18 @@ private AppActor actor() { } @Test + @DisplayName("마지막 페이지가 페이지 크기보다 작으면 커서를 내려주지 않는다") void lastPageShorterThanPageSizeHasNoCursor() { + // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(2L); List rows = List.of(row(1L), row(2L)); given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) .willReturn(rows); + // when var result = useCase.execute(new CursorPageRequestDto(null, 10), new PostingListFilterDto(), actor()); + // then assertThat(result.page().cursor()).isNull(); assertThat(result.page().pageSize()).isEqualTo(10); assertThat(result.page().totalCount()).isEqualTo(2); @@ -69,67 +75,88 @@ void lastPageShorterThanPageSizeHasNoCursor() { } @Test + @DisplayName("마지막 페이지가 페이지 크기와 같아도 다음 행이 없으면 커서를 내려주지 않는다") void lastPageExactlyPageSizeHasNoCursor() { + // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(2L); List rows = List.of(row(1L), row(2L)); given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) .willReturn(rows); + // when var result = useCase.execute(new CursorPageRequestDto(null, 2), new PostingListFilterDto(), actor()); + // then assertThat(result.page().cursor()).isNull(); assertThat(result.data()).hasSize(2); } @Test + @DisplayName("다음 페이지가 있으면 추가로 조회한 행은 빼고, 커서는 마지막으로 반환한 행을 가리킨다") void middlePageDropsExtraRowAndPointsCursorAtLastReturnedRow() { + // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(3L); List rows = List.of(row(1L), row(2L), row(3L)); given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) .willReturn(rows); + // when var result = useCase.execute(new CursorPageRequestDto(null, 2), new PostingListFilterDto(), actor()); + // then assertThat(result.data()).extracting(dto -> dto.getId()).containsExactly(1L, 2L); assertThat(result.page().totalCount()).isEqualTo(3); CursorDto cursor = CursorUtil.decodeCursor(result.page().cursor(), CursorDto.class, objectMapper); assertThat(cursor.getId()).isEqualTo(2L); + assertThat(cursor.getCreatedAt()).isEqualTo(rows.get(1).getCreatedAt()); } @Test + @DisplayName("다음 페이지 여부를 알기 위해 페이지 크기보다 한 행 더 조회한다") void repositoryIsAskedForOneMoreRowThanPageSize() { + // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(1L); List rows = List.of(row(1L)); given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) .willReturn(rows); + // when useCase.execute(new CursorPageRequestDto(null, 5), new PostingListFilterDto(), actor()); + // then @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(CursorPageRequest.class); - verify(postingQueryRepository).getPostingsWithCursor(captor.capture(), any(), any()); + then(postingQueryRepository).should().getPostingsWithCursor(captor.capture(), any(), any()); assertThat(captor.getValue().pageSize()).isEqualTo(6); } @Test + @DisplayName("조건에 맞는 공고가 없으면 커서 없이 빈 목록을 반환한다") void noMatchingPostingsReturnsEmptyWithoutCursor() { + // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(0L); + // when var result = useCase.execute(new CursorPageRequestDto(null, 10), new PostingListFilterDto(), actor()); + // then assertThat(result.page().cursor()).isNull(); assertThat(result.page().totalCount()).isZero(); assertThat(result.data()).isEmpty(); } @Test + @DisplayName("페이지 크기가 0이면 행과 커서를 내려주지 않는다") void zeroPageSizeReturnsNoRowsAndNoCursor() { + // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(3L); List rows = List.of(row(1L)); given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())).willReturn(rows); + // when var result = useCase.execute(new CursorPageRequestDto(null, 0), new PostingListFilterDto(), actor()); + // then assertThat(result.page().cursor()).isNull(); assertThat(result.data()).isEmpty(); } From 72b23734b006e92a5ab5709918e94454521eda06 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 28 Oct 2026 21:36:00 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20[ALT-308]=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EA=B3=B5=EA=B3=A0=20=EB=AA=A9=EB=A1=9D=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=20Swagger=20=EC=84=A4=EB=AA=85=EC=9D=84=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbound/general/posting/dto/PostingMapListFilterDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingMapListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingMapListFilterDto.java index 7cfd9e53..96f578df 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingMapListFilterDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingMapListFilterDto.java @@ -21,7 +21,7 @@ public class PostingMapListFilterDto { @Parameter(description = "지도 우측 하단 좌표") private CoordinateDto coordinate2; - @Parameter(description = "검색 키워드 (공고 제목 또는 업장명)") + @Parameter(description = "검색 키워드 (공고 제목 또는 업장명, 대소문자 무시, 앞뒤 공백 제거; 생략하거나 공백만 있으면 전체 조회)") private String searchKeyword; @NotNull From 7b598088f7f5172ec2dc79b0a0ec962723c6e298 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 28 Oct 2026 22:24:03 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test:=20[ALT-308]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=BB=A4=EC=84=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EA=B8=B0=EC=A1=B4=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/GetPostingsWithCursorTests.java | 59 ++++--------------- 1 file changed, 12 insertions(+), 47 deletions(-) diff --git a/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java b/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java index c33f0647..69666537 100644 --- a/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java +++ b/src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursorTests.java @@ -56,8 +56,8 @@ private AppActor actor() { } @Test - @DisplayName("마지막 페이지가 페이지 크기보다 작으면 커서를 내려주지 않는다") - void lastPageShorterThanPageSizeHasNoCursor() { + @DisplayName("다음 페이지 유무와 관계없이 마지막으로 반환한 행을 가리키는 커서를 내려준다") + void cursorPointsAtLastReturnedRowRegardlessOfNextPage() { // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(2L); List rows = List.of(row(1L), row(2L)); @@ -68,52 +68,17 @@ void lastPageShorterThanPageSizeHasNoCursor() { var result = useCase.execute(new CursorPageRequestDto(null, 10), new PostingListFilterDto(), actor()); // then - assertThat(result.page().cursor()).isNull(); + assertThat(result.data()).extracting(dto -> dto.getId()).containsExactly(1L, 2L); assertThat(result.page().pageSize()).isEqualTo(10); assertThat(result.page().totalCount()).isEqualTo(2); - assertThat(result.data()).hasSize(2); - } - - @Test - @DisplayName("마지막 페이지가 페이지 크기와 같아도 다음 행이 없으면 커서를 내려주지 않는다") - void lastPageExactlyPageSizeHasNoCursor() { - // given - given(postingQueryRepository.getCountOfPostings(any())).willReturn(2L); - List rows = List.of(row(1L), row(2L)); - given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) - .willReturn(rows); - - // when - var result = useCase.execute(new CursorPageRequestDto(null, 2), new PostingListFilterDto(), actor()); - - // then - assertThat(result.page().cursor()).isNull(); - assertThat(result.data()).hasSize(2); - } - - @Test - @DisplayName("다음 페이지가 있으면 추가로 조회한 행은 빼고, 커서는 마지막으로 반환한 행을 가리킨다") - void middlePageDropsExtraRowAndPointsCursorAtLastReturnedRow() { - // given - given(postingQueryRepository.getCountOfPostings(any())).willReturn(3L); - List rows = List.of(row(1L), row(2L), row(3L)); - given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())) - .willReturn(rows); - - // when - var result = useCase.execute(new CursorPageRequestDto(null, 2), new PostingListFilterDto(), actor()); - - // then - assertThat(result.data()).extracting(dto -> dto.getId()).containsExactly(1L, 2L); - assertThat(result.page().totalCount()).isEqualTo(3); CursorDto cursor = CursorUtil.decodeCursor(result.page().cursor(), CursorDto.class, objectMapper); assertThat(cursor.getId()).isEqualTo(2L); assertThat(cursor.getCreatedAt()).isEqualTo(rows.get(1).getCreatedAt()); } @Test - @DisplayName("다음 페이지 여부를 알기 위해 페이지 크기보다 한 행 더 조회한다") - void repositoryIsAskedForOneMoreRowThanPageSize() { + @DisplayName("요청한 페이지 크기 그대로 조회한다") + void repositoryIsAskedForRequestedPageSize() { // given given(postingQueryRepository.getCountOfPostings(any())).willReturn(1L); List rows = List.of(row(1L)); @@ -127,7 +92,7 @@ void repositoryIsAskedForOneMoreRowThanPageSize() { @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(CursorPageRequest.class); then(postingQueryRepository).should().getPostingsWithCursor(captor.capture(), any(), any()); - assertThat(captor.getValue().pageSize()).isEqualTo(6); + assertThat(captor.getValue().pageSize()).isEqualTo(5); } @Test @@ -146,18 +111,18 @@ void noMatchingPostingsReturnsEmptyWithoutCursor() { } @Test - @DisplayName("페이지 크기가 0이면 행과 커서를 내려주지 않는다") - void zeroPageSizeReturnsNoRowsAndNoCursor() { + @DisplayName("커서 이후 남은 공고가 없으면 커서 없이 빈 목록을 반환한다") + void noRowsAfterCursorReturnsEmptyWithoutCursor() { // given - given(postingQueryRepository.getCountOfPostings(any())).willReturn(3L); - List rows = List.of(row(1L)); - given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())).willReturn(rows); + given(postingQueryRepository.getCountOfPostings(any())).willReturn(2L); + given(postingQueryRepository.getPostingsWithCursor(any(), any(), any())).willReturn(List.of()); // when - var result = useCase.execute(new CursorPageRequestDto(null, 0), new PostingListFilterDto(), actor()); + var result = useCase.execute(new CursorPageRequestDto(null, 10), new PostingListFilterDto(), actor()); // then assertThat(result.page().cursor()).isNull(); + assertThat(result.page().totalCount()).isEqualTo(2); assertThat(result.data()).isEmpty(); } } From 240c0a5d44cd9c52fd1c05875a03f85c2a695004 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 28 Oct 2026 22:24:03 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20[ALT-308]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=BB=A4=EC=84=9C=EB=A5=BC=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9C=A0=EB=AC=B4?= =?UTF-8?q?=EC=99=80=20=EA=B4=80=EA=B3=84=EC=97=86=EC=9D=B4=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=ED=96=89=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/GetPostingsWithCursor.java | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java index 86791c98..f4c2e421 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingsWithCursor.java @@ -30,7 +30,7 @@ public CursorPaginatedApiResponse execute(CursorPageRequ if (ObjectUtils.isNotEmpty(request.cursor())) { cursorDto = CursorUtil.decodeCursor(request.cursor(), CursorDto.class, objectMapper); } - CursorPageRequest pageRequest = CursorPageRequest.of(cursorDto, request.pageSize() + 1); + CursorPageRequest pageRequest = CursorPageRequest.of(cursorDto, request.pageSize()); long count = postingQueryRepository.getCountOfPostings(filter); if (count == 0) { @@ -42,19 +42,16 @@ public CursorPaginatedApiResponse execute(CursorPageRequ return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(request.pageSize(), (int) count)); } - List page = postings.subList(0, Math.min(postings.size(), request.pageSize())); - boolean hasNext = !page.isEmpty() && postings.size() > page.size(); - - String nextCursor = null; - if (hasNext) { - PostingListResponse last = page.getLast(); - nextCursor = CursorUtil.encodeCursor(new CursorDto(last.getId(), last.getCreatedAt()), objectMapper); - } - CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of(nextCursor, request.pageSize(), (int) count); + PostingListResponse last = postings.getLast(); + CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of( + CursorUtil.encodeCursor(new CursorDto(last.getId(), last.getCreatedAt()), objectMapper), + pageRequest.pageSize(), + (int) count + ); return CursorPaginatedApiResponse.of( pageResponseDto, - page.stream() + postings.stream() .map(PostingListResponseDto::from) .toList() );