diff --git a/src/main/java/com/heddy/adapter/in/web/sharing/controller/ShareController.java b/src/main/java/com/heddy/adapter/in/web/sharing/controller/ShareController.java index 9856683..d79a73e 100644 --- a/src/main/java/com/heddy/adapter/in/web/sharing/controller/ShareController.java +++ b/src/main/java/com/heddy/adapter/in/web/sharing/controller/ShareController.java @@ -75,11 +75,13 @@ public ResponseEntity> create( @ApiDocs.ListQuery @Operation(summary = "내 공유 목록 조회", description = "내 공유를 최신순으로 페이지 조회합니다. 상태 필터는 생략할 수 있고, " - + "결과가 없으면 200과 빈 items를 반환합니다. share_url 은 저장된 것이 없어 " + + "결과가 없으면 200과 빈 items를 반환합니다. ACTIVE 필터는 만료 시각이 " + + "지나지 않은 것만 돌려줍니다. share_url 은 저장된 것이 없어 " + "생성 응답으로만 내려간다.") public ApiResponse> list( @AuthenticationPrincipal UUID userId, - @Parameter(description = "상태 필터(ACTIVE/REVOKED). 생략 시 전체") + @Parameter(description = "상태 필터(ACTIVE/REVOKED). 생략 시 전체. " + + "ACTIVE 는 만료되지 않은 것만 포함한다") @RequestParam(required = false) ShareStatus status, @Parameter(description = "0부터 시작하는 페이지 번호. 음수면 400 INVALID_REQUEST") @RequestParam(defaultValue = "0") int page, diff --git a/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java b/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java index 32d7d79..18d3fbd 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java @@ -32,6 +32,10 @@ interface ShareJpaRepository extends JpaRepository { Page findByUserIdAndStatus(UUID userId, String status, Pageable pageable); + /** ACTIVE 필터 전용. 상태가 ACTIVE 라도 만료 시각이 지났으면 더 이상 열리지 않는다. */ + Page findByUserIdAndStatusAndExpiresAtAfter( + UUID userId, String status, Instant now, Pageable pageable); + void deleteAllByUserId(UUID userId); /** diff --git a/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java index 3011286..45a8c1e 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java +++ b/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java @@ -60,11 +60,9 @@ public Optional findByTokenHash(String tokenHash) { } @Override - public SharePage findPage(UUID userId, ShareStatus status, int page, int size) { - Page result = status == null - ? shareRepository.findByUserId(userId, pageRequest(page, size)) - : shareRepository.findByUserIdAndStatus(userId, status.name(), - pageRequest(page, size)); + public SharePage findPage( + UUID userId, ShareStatus status, int page, int size, Instant now) { + Page result = findEntityPage(userId, status, pageRequest(page, size), now); return new SharePage( result.getContent().stream().map(ShareEntity::toDomain).toList(), result.getTotalElements()); @@ -86,6 +84,22 @@ public Set findSharedRecordIds(UUID ownerId, Collection recordIds, I ownerId, recordIds, ShareStatus.ACTIVE.name(), now); } + /** + * ACTIVE 만 만료 조건이 붙는다. REVOKED 는 철회 시각이 이미 상태로 굳었고, 전체 조회는 + * 만료된 링크까지 보여 주는 것이 목록의 목적이라 그대로 둔다. + */ + private Page findEntityPage( + UUID userId, ShareStatus status, PageRequest pageRequest, Instant now) { + if (status == null) { + return shareRepository.findByUserId(userId, pageRequest); + } + if (status == ShareStatus.ACTIVE) { + return shareRepository.findByUserIdAndStatusAndExpiresAtAfter( + userId, status.name(), now, pageRequest); + } + return shareRepository.findByUserIdAndStatus(userId, status.name(), pageRequest); + } + private PageRequest pageRequest(int page, int size) { int boundedSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); return PageRequest.of(Math.max(page, 0), boundedSize, Sort.by( diff --git a/src/main/java/com/heddy/application/sharing/service/ShareService.java b/src/main/java/com/heddy/application/sharing/service/ShareService.java index ef330c4..e206b43 100644 --- a/src/main/java/com/heddy/application/sharing/service/ShareService.java +++ b/src/main/java/com/heddy/application/sharing/service/ShareService.java @@ -82,7 +82,7 @@ public ListSharesUseCase.Result list(ListSharesUseCase.Query query) { throw new ApplicationException(ErrorCode.INVALID_REQUEST); } SharePage page = shareRepositoryPort.findPage( - query.requesterId(), query.status(), query.page(), query.size()); + query.requesterId(), query.status(), query.page(), query.size(), Instant.now()); return new ListSharesUseCase.Result( page.items(), query.page(), query.size(), page.totalElements()); } diff --git a/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java b/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java index d0bf6e2..b92dc2c 100644 --- a/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java +++ b/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java @@ -4,6 +4,7 @@ import com.heddy.domain.sharing.model.SharePage; import com.heddy.domain.sharing.model.ShareStatus; +import java.time.Instant; import java.util.Optional; import java.util.UUID; @@ -30,8 +31,14 @@ public interface ShareRepositoryPort { */ void detachSavedStyle(UUID savedStyleId); - /** 소유자의 공유를 최신순으로 페이지 조회한다. 상태 필터는 생략 가능하다. */ - SharePage findPage(UUID userId, ShareStatus status, int page, int size); + /** + * 소유자의 공유를 최신순으로 페이지 조회한다. 상태 필터는 생략 가능하다. + * + *

ACTIVE 필터는 {@code expiresAt > now} 까지 함께 본다. 만료는 상태가 아니라 시각 + * 비교로 판정하는 것이 이 도메인의 설계라(V19 주석) 상태 컬럼만 보면 이미 죽은 링크가 + * 활성으로 잡힌다. + */ + SharePage findPage(UUID userId, ShareStatus status, int page, int size, Instant now); /** 회원 탈퇴 시 공개 링크와 연결 행을 함께 제거한다. */ void deleteAllByUserId(UUID userId); diff --git a/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java index c770d92..9ddab08 100644 --- a/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java @@ -2,6 +2,7 @@ import com.heddy.domain.sharing.model.Share; import com.heddy.domain.sharing.model.ShareFieldType; +import com.heddy.domain.sharing.model.SharePage; import com.heddy.domain.sharing.model.ShareStatus; import com.heddy.support.PostgresIntegrationTest; import org.junit.jupiter.api.BeforeEach; @@ -108,6 +109,49 @@ void answersWithoutQueryingWhenThereAreNoRecords() { assertThat(adapter.findSharedRecordIds(OWNER_ID, List.of(), NOW)).isEmpty(); } + // ------------------------------------------------------------- 목록 상태 필터 + + /** + * ACTIVE 필터는 상태만 보면 안 된다. 만료는 상태가 아니라 시각 비교로 판정하는 설계라 + * 이미 열리지 않는 링크가 상태 컬럼에는 그대로 ACTIVE 로 남아 있다. + */ + @Test + void excludesExpiredSharesFromTheActiveFilter() { + UUID recordId = insertRecord(OWNER_ID); + insertShare(OWNER_ID, recordId, ShareStatus.ACTIVE, NOW.minusSeconds(1)); + insertShare(OWNER_ID, recordId, ShareStatus.ACTIVE, NOW.plusSeconds(60)); + + SharePage page = adapter.findPage(OWNER_ID, ShareStatus.ACTIVE, 0, 20, NOW); + + assertThat(page.items()).hasSize(1); + assertThat(page.totalElements()).isEqualTo(1); + assertThat(page.items().getFirst().expiresAt()).isEqualTo(NOW.plusSeconds(60)); + } + + /** 만료 시각이 현재와 같으면 이미 지난 것으로 본다. 공개 조회 판정과 같은 기준이다. */ + @Test + void treatsAnExpiryEqualToNowAsExpiredInTheActiveFilter() { + insertShare(OWNER_ID, insertRecord(OWNER_ID), ShareStatus.ACTIVE, NOW); + + assertThat(adapter.findPage(OWNER_ID, ShareStatus.ACTIVE, 0, 20, NOW).items()).isEmpty(); + } + + /** 전체 조회는 만료 여부로 거르지 않는다. 만료된 링크도 내 공유 이력이다. */ + @Test + void keepsExpiredSharesWhenNoStatusFilterIsGiven() { + insertShare(OWNER_ID, insertRecord(OWNER_ID), ShareStatus.ACTIVE, NOW.minusSeconds(1)); + + assertThat(adapter.findPage(OWNER_ID, null, 0, 20, NOW).items()).hasSize(1); + } + + /** REVOKED 필터에는 만료 조건이 붙지 않는다. 철회 시각이 이미 상태로 굳어 있다. */ + @Test + void keepsExpiredSharesInTheRevokedFilter() { + insertShare(OWNER_ID, insertRecord(OWNER_ID), ShareStatus.REVOKED, NOW.minusSeconds(1)); + + assertThat(adapter.findPage(OWNER_ID, ShareStatus.REVOKED, 0, 20, NOW).items()).hasSize(1); + } + // ------------------------------------------------------------------ 헬퍼 private void insertUser(UUID userId, String email) { diff --git a/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java b/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java index b0d78a9..7c30d76 100644 --- a/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java +++ b/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java @@ -39,6 +39,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.within; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; @@ -171,7 +173,8 @@ void rejectsSelectionWithoutFieldsBeforeOwnershipQueries() { @Test void listsOwnSharesWithStatusFilterAndPagination() { List items = List.of(share(ShareStatus.REVOKED), share(ShareStatus.ACTIVE)); - given(shareRepositoryPort.findPage(USER_ID, ShareStatus.ACTIVE, 0, 20)) + given(shareRepositoryPort.findPage(eq(USER_ID), eq(ShareStatus.ACTIVE), eq(0), eq(20), + any(Instant.class))) .willReturn(new SharePage(items, 2)); ListSharesUseCase.Result result = service.list( @@ -185,7 +188,8 @@ void listsOwnSharesWithStatusFilterAndPagination() { @Test void answersAnEmptyPageInsteadOfAnError() { - given(shareRepositoryPort.findPage(USER_ID, null, 0, 20)) + given(shareRepositoryPort.findPage(eq(USER_ID), isNull(), eq(0), eq(20), + any(Instant.class))) .willReturn(new SharePage(List.of(), 0)); ListSharesUseCase.Result result = service.list(