diff --git a/src/main/java/com/sniptext/domain/template/controller/TrashController.java b/src/main/java/com/sniptext/domain/template/controller/TrashController.java new file mode 100644 index 0000000..da97640 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/TrashController.java @@ -0,0 +1,42 @@ +package com.sniptext.domain.template.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.sniptext.domain.template.controller.dto.TrashListResponse; +import com.sniptext.domain.template.service.TrashQueryService; +import com.sniptext.domain.template.service.dto.query.TrashListQuery; +import com.sniptext.global.response.ApiResponse; +import com.sniptext.global.security.LoginUser; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; + +/** + * 휴지통 (로그인 필요, 캐시 금지). + */ +@RestController +@RequestMapping("/api/v1/trash/templates") +@RequiredArgsConstructor +public class TrashController { + + private final TrashQueryService trashQueryService; + + @GetMapping + public ResponseEntity> getTrash( + @LoginUser Long userId, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "20") + @Min(value = 1, message = "TRASH400_1") @Max(value = 50, message = "TRASH400_1") int size) { + + TrashListResponse response = TrashListResponse.from( + trashQueryService.getTrash(new TrashListQuery(userId, cursor, size))); + + return ApiResponse.success(TrashSuccessCode.TRASH_LIST_FOUND, response); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java b/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java new file mode 100644 index 0000000..f6f14a3 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.template.controller; + +import org.springframework.http.HttpStatus; + +import com.sniptext.global.response.ResultCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TrashSuccessCode implements ResultCode { + + TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TrashListResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TrashListResponse.java new file mode 100644 index 0000000..9755a2e --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TrashListResponse.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.template.controller.dto; + +import java.util.List; + +import com.sniptext.domain.template.service.dto.result.TrashListResult; + +/** 명세 15번 휴지통 목록 */ +public record TrashListResponse(long totalCount, List templates, boolean hasNext, + String nextCursor) { + + public static TrashListResponse from(TrashListResult result) { + return new TrashListResponse( + result.totalCount(), + result.templates().stream().map(TrashTemplateResponse::from).toList(), + result.hasNext(), + result.nextCursor() + ); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TrashTemplateResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TrashTemplateResponse.java new file mode 100644 index 0000000..2da6a33 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TrashTemplateResponse.java @@ -0,0 +1,26 @@ +package com.sniptext.domain.template.controller.dto; + +import java.time.LocalDateTime; + +import com.sniptext.domain.template.service.dto.result.TrashTemplateResult; + +/** 명세 15번 휴지통 항목. 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다 */ +public record TrashTemplateResponse( + Long templateId, + String title, + String shortcut, + LocalDateTime deletedAt, + LocalDateTime purgeAt +) { + + public static TrashTemplateResponse from(TrashTemplateResult result) { + return new TrashTemplateResponse( + result.templateId(), + result.title(), + result.shortcut(), + result.deletedAt(), + result.purgeAt() + ); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java b/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java new file mode 100644 index 0000000..2e91d14 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java @@ -0,0 +1,21 @@ +package com.sniptext.domain.template.exception; + +import org.springframework.http.HttpStatus; + +import com.sniptext.global.response.ResultCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TrashErrorCode implements ResultCode { + + /** 커서를 해석할 수 없거나 size가 1~50 밖 */ + INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "TRASH400_1", "잘못된 페이지 요청이에요."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java index f751467..97badfe 100644 --- a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java +++ b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java @@ -1,13 +1,17 @@ package com.sniptext.domain.template.repository; +import java.time.LocalDateTime; import java.util.Collection; +import java.util.List; import java.util.Set; +import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.repository.dto.TrashTemplateRow; public interface TemplateRepository extends JpaRepository { @@ -27,4 +31,32 @@ public interface TemplateRepository extends JpaRepository { @Query("select t.activeShortcutKey from Template t where t.userId = :userId and t.activeShortcutKey in :keys") Set findActiveShortcutKeys(@Param("userId") Long userId, @Param("keys") Collection keys); + /** 휴지통 개수. idx_templates_user_deleted (user_id, deleted_at)를 탄다 */ + long countByUserIdAndDeletedAtIsNotNull(Long userId); + + /** + * 휴지통 첫 페이지. 최근 삭제순, 같은 시각이면 ID가 큰 것부터. + * idx_templates_user_deleted는 뒤에 기본 키가 붙어 있어 이 정렬을 인덱스 역방향 스캔으로 처리한다 + */ + @Query(""" + select new com.sniptext.domain.template.repository.dto.TrashTemplateRow( + t.id, t.title.value, t.shortcut.value, t.deletedAt) + from Template t + where t.userId = :userId and t.deletedAt is not null + order by t.deletedAt desc, t.id desc + """) + List findTrashFirstPage(@Param("userId") Long userId, Limit limit); + + /** 휴지통 다음 페이지. 정렬이 같고, 커서가 가리키는 항목 바로 다음부터 읽는다. deleted_at 비교가 있어 휴지통만 걸린다 */ + @Query(""" + select new com.sniptext.domain.template.repository.dto.TrashTemplateRow( + t.id, t.title.value, t.shortcut.value, t.deletedAt) + from Template t + where t.userId = :userId + and (t.deletedAt < :deletedAt or (t.deletedAt = :deletedAt and t.id < :id)) + order by t.deletedAt desc, t.id desc + """) + List findTrashAfter(@Param("userId") Long userId, @Param("deletedAt") LocalDateTime deletedAt, + @Param("id") Long id, Limit limit); + } diff --git a/src/main/java/com/sniptext/domain/template/repository/dto/TrashTemplateRow.java b/src/main/java/com/sniptext/domain/template/repository/dto/TrashTemplateRow.java new file mode 100644 index 0000000..283c995 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/repository/dto/TrashTemplateRow.java @@ -0,0 +1,10 @@ +package com.sniptext.domain.template.repository.dto; + +import java.time.LocalDateTime; + +/** + * 휴지통 목록 한 행. 성능 때문이 아니라 본문을 읽지 않기 위한 조회 전용 객체다. + * 엔티티로 읽으면 내보내지도 않는 본문을 복호화하고, 복호화에 실패한 행 하나가 목록 전체를 500으로 만든다. + */ +public record TrashTemplateRow(Long id, String title, String shortcut, LocalDateTime deletedAt) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/TrashCursor.java b/src/main/java/com/sniptext/domain/template/service/TrashCursor.java new file mode 100644 index 0000000..c3752dd --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/TrashCursor.java @@ -0,0 +1,45 @@ +package com.sniptext.domain.template.service; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 휴지통 목록의 다음 페이지 위치. 이 페이지 마지막 항목의 (삭제 시각, ID)를 이어 붙여 Base64 URL로 인코딩한다. + * 화면은 값을 해석하지 않고 nextCursor를 그대로 돌려보낸다. + * 같은 초 안에서도 순서가 갈리므로 시각은 DATETIME(6)과 같은 마이크로초까지 넣는다. + * 해석할 때 자릿수를 고정해 DB가 받지 못하는 연도(+12026)가 쿼리까지 가지 않게 한다. + */ +record TrashCursor(LocalDateTime deletedAt, Long id) { + + private static final String SEPARATOR = "_"; + private static final DateTimeFormatter DELETED_AT_FORMAT = DateTimeFormatter.ofPattern( + "uuuu-MM-dd'T'HH:mm:ss.SSSSSS"); + private static final Pattern FORMAT = Pattern.compile( + "(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6})" + SEPARATOR + "(\\d{1,19})"); + + public String encode() { + String raw = DELETED_AT_FORMAT.format(deletedAt) + SEPARATOR + id; + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + /** 서버가 만든 형식이 아니면 비어 있다. 어떤 에러로 응답할지는 서비스가 정한다 */ + public static Optional decode(String cursor) { + try { + String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + Matcher matcher = FORMAT.matcher(raw); + if (!matcher.matches()) { + return Optional.empty(); + } + return Optional.of(new TrashCursor(LocalDateTime.parse(matcher.group(1)), Long.valueOf(matcher.group(2)))); + } catch (IllegalArgumentException | DateTimeParseException exception) { + return Optional.empty(); + } + } + +} diff --git a/src/main/java/com/sniptext/domain/template/service/TrashQueryService.java b/src/main/java/com/sniptext/domain/template/service/TrashQueryService.java new file mode 100644 index 0000000..392427a --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/TrashQueryService.java @@ -0,0 +1,53 @@ +package com.sniptext.domain.template.service; + +import java.util.List; + +import org.springframework.data.domain.Limit; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.template.exception.TrashErrorCode; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.repository.dto.TrashTemplateRow; +import com.sniptext.domain.template.service.dto.query.TrashListQuery; +import com.sniptext.domain.template.service.dto.result.TrashListResult; +import com.sniptext.domain.template.service.dto.result.TrashTemplateResult; +import com.sniptext.global.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrashQueryService { + + private final TemplateRepository templateRepository; + + /** 요청한 개수보다 1개 더 읽어 다음 페이지가 있는지 판단한다 */ + public TrashListResult getTrash(TrashListQuery query) { + List rows = findPageWithOneMore(query); + boolean hasNext = rows.size() > query.size(); + List page = hasNext ? rows.subList(0, query.size()) : rows; + + return TrashListResult.of( + templateRepository.countByUserIdAndDeletedAtIsNotNull(query.userId()), + page.stream().map(TrashTemplateResult::from).toList(), + hasNext ? nextCursor(page.getLast()) : null + ); + } + + private List findPageWithOneMore(TrashListQuery query) { + Limit limit = Limit.of(query.size() + 1); + if (query.cursor() == null || query.cursor().isEmpty()) { + return templateRepository.findTrashFirstPage(query.userId(), limit); + } + TrashCursor cursor = TrashCursor.decode(query.cursor()) + .orElseThrow(() -> new BusinessException(TrashErrorCode.INVALID_PAGE_REQUEST)); + return templateRepository.findTrashAfter(query.userId(), cursor.deletedAt(), cursor.id(), limit); + } + + private static String nextCursor(TrashTemplateRow last) { + return new TrashCursor(last.deletedAt(), last.id()).encode(); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/query/TrashListQuery.java b/src/main/java/com/sniptext/domain/template/service/dto/query/TrashListQuery.java new file mode 100644 index 0000000..5e0143e --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/query/TrashListQuery.java @@ -0,0 +1,4 @@ +package com.sniptext.domain.template.service.dto.query; + +public record TrashListQuery(Long userId, String cursor, int size) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TrashListResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashListResult.java new file mode 100644 index 0000000..cc5d8d2 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashListResult.java @@ -0,0 +1,12 @@ +package com.sniptext.domain.template.service.dto.result; + +import java.util.List; + +public record TrashListResult(long totalCount, List templates, boolean hasNext, + String nextCursor) { + + public static TrashListResult of(long totalCount, List templates, String nextCursor) { + return new TrashListResult(totalCount, List.copyOf(templates), nextCursor != null, nextCursor); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TrashTemplateResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashTemplateResult.java new file mode 100644 index 0000000..073db53 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashTemplateResult.java @@ -0,0 +1,27 @@ +package com.sniptext.domain.template.service.dto.result; + +import java.time.LocalDateTime; + +import com.sniptext.domain.template.repository.dto.TrashTemplateRow; +import com.sniptext.domain.template.support.TrashRetention; + +/** 휴지통 항목. 본문은 화면에 보이지 않아 내보내지 않는다 */ +public record TrashTemplateResult( + Long templateId, + String title, + String shortcut, + LocalDateTime deletedAt, + LocalDateTime purgeAt +) { + + public static TrashTemplateResult from(TrashTemplateRow row) { + return new TrashTemplateResult( + row.id(), + row.title(), + row.shortcut(), + row.deletedAt(), + TrashRetention.purgeAt(row.deletedAt()) + ); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/support/TrashRetention.java b/src/main/java/com/sniptext/domain/template/support/TrashRetention.java new file mode 100644 index 0000000..efff25a --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/support/TrashRetention.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.template.support; + +import java.time.LocalDateTime; + +/** + * 휴지통 보관 규칙. 삭제하고 30일이 지나면 자동 영구 삭제 대상이 된다. + * 목록의 예정 시각과 휴지통 정리 배치가 같은 기간을 쓰도록 여기 한 곳에 둔다. + */ +public final class TrashRetention { + + private static final int RETENTION_DAYS = 30; + + private TrashRetention() { + } + + public static LocalDateTime purgeAt(LocalDateTime deletedAt) { + return deletedAt.plusDays(RETENTION_DAYS); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TrashControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TrashControllerTest.java new file mode 100644 index 0000000..1f822a9 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TrashControllerTest.java @@ -0,0 +1,397 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +import com.jayway.jsonpath.JsonPath; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.user.entity.User; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import jakarta.persistence.EntityManager; + +/** 명세 15번. 정렬, 커서 페이지 나눔, 응답 형식을 실제 필터·MySQL까지 거쳐 확인한다. */ +@Transactional +@Import(MutableClockConfiguration.class) +class TrashControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/trash/templates"; + private static final String NO_STORE = "no-store"; + private static final String INVALID_PAGE_CODE = "TRASH400_1"; + private static final String INVALID_PAGE_MESSAGE = "잘못된 페이지 요청이에요."; + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 7, 10, 58, 0, 123_456_000); + private static final List ITEM_FIELD_ORDER = List.of( + "templateId", "title", "shortcut", "deletedAt", "purgeAt"); + private static final List RESULT_FIELD_ORDER = List.of( + "totalCount", "templates", "hasNext", "nextCursor"); + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private MutableClock clock; + + private Long userId; + private Long otherUserId; + + @BeforeEach + void setUp() { + clock.setTo(LocalDateTime.of(2026, 10, 25, 10, 0)); + userId = saveUser("owner@sniptext.com"); + otherUserId = saveUser("other@sniptext.com"); + } + + @Test + @DisplayName("조회하면 200, 명세 순서의 필드와 +09:00이 붙은 시각, 캐시 금지 헤더") + void list() throws Exception { + Long templateId = saveTrashed(userId, "/회의", DELETED_AT); + + MvcResult result = list(userId) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TRASH200_1")) + .andExpect(jsonPath("$.message").value("휴지통 조회에 성공했습니다.")) + .andExpect(jsonPath("$.result.length()").value(RESULT_FIELD_ORDER.size())) + .andExpect(jsonPath("$.result.totalCount").value(1)) + .andExpect(jsonPath("$.result.hasNext").value(false)) + .andExpect(jsonPath("$.result.nextCursor").isEmpty()) + .andExpect(jsonPath("$.result.templates.length()").value(1)) + .andExpect(jsonPath("$.result.templates[0].length()").value(ITEM_FIELD_ORDER.size())) + .andExpect(jsonPath("$.result.templates[0].templateId").value(templateId)) + .andExpect(jsonPath("$.result.templates[0].title").value(TemplateFixture.TITLE)) + .andExpect(jsonPath("$.result.templates[0].shortcut").value("/회의")) + .andExpect(jsonPath("$.result.templates[0].deletedAt").value("2026-10-07T10:58:00+09:00")) + .andExpect(jsonPath("$.result.templates[0].purgeAt").value("2026-11-06T10:58:00+09:00")) + .andReturn(); + + assertFieldOrder(body(result), RESULT_FIELD_ORDER); + assertFieldOrder(body(result), ITEM_FIELD_ORDER); + } + + @Test + @DisplayName("최근 삭제순이고, 같은 시각이면 ID가 큰 것이 먼저다") + void order() throws Exception { + Long older = saveTrashed(userId, "/오래", DELETED_AT.minusDays(1)); + Long tiedFirst = saveTrashed(userId, "/동률1", DELETED_AT); + Long tiedSecond = saveTrashed(userId, "/동률2", DELETED_AT); + Long newer = saveTrashed(userId, "/최근", DELETED_AT.plusDays(1)); + + assertThat(templateIds(body(list(userId).andExpect(status().isOk()).andReturn()))) + .containsExactly(newer, tiedSecond, tiedFirst, older); + } + + @Test + @DisplayName("전체 개수는 이번 페이지 개수가 아니라 본인 휴지통 전체다") + void totalCountIsNotPageSize() throws Exception { + saveTrashed(userId, "/가나", DELETED_AT); + saveTrashed(userId, "/다라", DELETED_AT); + saveTrashed(userId, "/마바", DELETED_AT); + + list(userId, "size", "1") + .andExpect(jsonPath("$.result.totalCount").value(3)) + .andExpect(jsonPath("$.result.templates.length()").value(1)); + } + + @Test + @DisplayName("예정 시각이 지난 항목도 배치가 지우기 전까지 그대로 보인다") + void purgeAtPassed() throws Exception { + saveTrashed(userId, "/지남", LocalDateTime.now(clock).minusDays(31)); + + list(userId) + .andExpect(jsonPath("$.result.totalCount").value(1)) + .andExpect(jsonPath("$.result.templates[0].purgeAt").value("2026-10-24T10:00:00+09:00")); + } + + @Test + @DisplayName("휴지통이 비어 있으면 빈 목록, 전체 개수 0, 다음 페이지 없음") + void emptyTrash() throws Exception { + list(userId) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.totalCount").value(0)) + .andExpect(jsonPath("$.result.templates", empty())) + .andExpect(jsonPath("$.result.hasNext").value(false)) + .andExpect(jsonPath("$.result.nextCursor").isEmpty()); + } + + @Test + @DisplayName("활성 템플릿과 다른 회원의 휴지통은 보이지 않고 개수에도 안 들어간다") + void excludesActiveAndOthers() throws Exception { + Long mine = saveTrashed(userId, "/내것", DELETED_AT); + saveTrashed(otherUserId, "/남의것", DELETED_AT); + templateRepository.saveAndFlush(TemplateFixture.template(userId, "/활성")); + + list(userId) + .andExpect(jsonPath("$.result.totalCount").value(1)) + .andExpect(jsonPath("$.result.templates[*].templateId", contains(mine.intValue()))); + } + + @Test + @DisplayName("size를 안 보내면 20개씩 준다") + void defaultSize() throws Exception { + saveTrashedCount(21); + + list(userId) + .andExpect(jsonPath("$.result.templates.length()").value(20)) + .andExpect(jsonPath("$.result.hasNext").value(true)); + } + + @Test + @DisplayName("size 1을 받아 1개를 준다") + void minSize() throws Exception { + saveTrashedCount(2); + + list(userId, "size", "1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.templates.length()").value(1)) + .andExpect(jsonPath("$.result.hasNext").value(true)); + } + + @Test + @DisplayName("size 50을 받아 50개를 준다") + void maxSize() throws Exception { + saveTrashedCount(51); + + list(userId, "size", "50") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.templates.length()").value(50)) + .andExpect(jsonPath("$.result.hasNext").value(true)); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "51", "-1"}) + @DisplayName("size가 1~50 밖이면 TRASH400_1") + void sizeOutOfRange(String size) throws Exception { + expectError(list(userId, "size", size), 400, INVALID_PAGE_CODE, INVALID_PAGE_MESSAGE); + } + + @ParameterizedTest + @ValueSource(strings = {"abc", "1.5"}) + @DisplayName("숫자가 아닌 size는 전역 처리의 COMMON400_1") + void sizeNotNumber(String size) throws Exception { + expectError(list(userId, "size", size), 400, "COMMON400_1", "잘못된 요청입니다."); + } + + @Test + @DisplayName("int 범위를 넘는 size는 숫자로 바꾸지 못해 COMMON400_1") + void sizeOverflow() throws Exception { + expectError(list(userId, "size", "99999999999"), 400, "COMMON400_1", "잘못된 요청입니다."); + } + + @Test + @DisplayName("비어 있는 size는 기본값, 비어 있는 cursor는 첫 요청이다") + void emptyParams() throws Exception { + saveTrashedCount(21); + + list(userId, "size", "", "cursor", "") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.templates.length()").value(20)); + } + + @Test + @DisplayName("남은 개수와 size가 같으면 마지막 페이지다") + void lastPageExactSize() throws Exception { + saveTrashedCount(2); + + list(userId, "size", "2") + .andExpect(jsonPath("$.result.templates.length()").value(2)) + .andExpect(jsonPath("$.result.hasNext").value(false)) + .andExpect(jsonPath("$.result.nextCursor").isEmpty()); + } + + @Test + @DisplayName("페이지를 끝까지 넘기면 누락·중복 없이 처음 순서대로 모두 나온다") + void walkAllPages() throws Exception { + saveTrashed(userId, "/가나", DELETED_AT.minusDays(1)); + saveTrashed(userId, "/다라", DELETED_AT); + saveTrashed(userId, "/마바", DELETED_AT); + saveTrashed(userId, "/사아", DELETED_AT); + saveTrashed(userId, "/자차", DELETED_AT.plusDays(1)); + List expected = templateIds(body(list(userId).andReturn())); + + assertThat(expected).hasSize(5); + assertThat(collectPages(2)).containsExactlyElementsOf(expected); + } + + @Test + @DisplayName("같은 초 안에서 마이크로초만 다른 삭제 시각이 페이지 경계에 걸려도 건너뛰지 않는다") + void walkPagesWithinSameSecond() throws Exception { + Long earlier = saveTrashed(userId, "/앞쪽", DELETED_AT); + Long later = saveTrashed(userId, "/뒤쪽", DELETED_AT.plusNanos(1_000)); + + assertThat(collectPages(1)).containsExactly(later, earlier); + } + + @Test + @DisplayName("페이지 사이에 복구·영구 삭제·새 삭제가 일어나도 남은 항목이 누락·중복되지 않는다") + void changesBetweenPages() throws Exception { + Long oldest = saveTrashed(userId, "/가나", DELETED_AT.minusDays(4)); + Long purged = saveTrashed(userId, "/다라", DELETED_AT.minusDays(3)); + Long restored = saveTrashed(userId, "/마바", DELETED_AT.minusDays(2)); + Long secondOnFirstPage = saveTrashed(userId, "/사아", DELETED_AT.minusDays(1)); + Long firstOnFirstPage = saveTrashed(userId, "/자차", DELETED_AT); + + MvcResult firstPage = list(userId, "size", "2").andReturn(); + assertThat(templateIds(body(firstPage))).containsExactly(firstOnFirstPage, secondOnFirstPage); + + restore(restored); + restore(secondOnFirstPage); + templateRepository.deleteById(purged); + entityManager.flush(); + saveTrashed(userId, "/타파", DELETED_AT.plusDays(1)); + + MvcResult secondPage = list(userId, "size", "2", "cursor", nextCursor(firstPage)).andReturn(); + + assertThat(templateIds(body(secondPage))).containsExactly(oldest); + } + + @ParameterizedTest + @ValueSource(strings = {"!!!", "abc", "MjAyNi0xMC0wNw"}) + @DisplayName("해석할 수 없는 커서는 TRASH400_1") + void invalidCursor(String cursor) throws Exception { + expectError(list(userId, "cursor", cursor), 400, INVALID_PAGE_CODE, INVALID_PAGE_MESSAGE); + } + + @ParameterizedTest + @ValueSource(strings = {"0000-01-01T00:00:00.000000_1", "9999-12-31T23:59:59.999999_9223372036854775807"}) + @DisplayName("형식이 맞는 커서는 값이 극단이어도 DB 비교에서 실패하지 않는다") + void extremeCursor(String decoded) throws Exception { + String cursor = Base64.getUrlEncoder().withoutPadding() + .encodeToString(decoded.getBytes(StandardCharsets.UTF_8)); + + list(userId, "cursor", cursor).andExpect(status().isOk()); + } + + @Test + @DisplayName("본문을 복호화할 수 없는 행이 있어도 본문을 내보내지 않는 목록은 그대로 보인다") + void undecryptableContent() throws Exception { + Long broken = saveTrashed(userId, "/깨짐", DELETED_AT); + entityManager.createNativeQuery("update templates set content = 'not-encrypted' where id = :id") + .setParameter("id", broken) + .executeUpdate(); + entityManager.clear(); + + list(userId) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.templates[*].templateId", contains(broken.intValue()))); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1, 캐시 금지") + void unauthenticated() throws Exception { + expectError(mockMvc.perform(get(PATH)), 401, "COMMON401_1", "로그인이 필요해요."); + } + + private ResultActions list(Long requesterId, String... params) throws Exception { + MockHttpServletRequestBuilder request = get(PATH).header(HttpHeaders.AUTHORIZATION, bearer(requesterId)); + for (int index = 0; index < params.length; index += 2) { + request = request.param(params[index], params[index + 1]); + } + return mockMvc.perform(request); + } + + private List collectPages(int size) throws Exception { + List collected = new ArrayList<>(); + MvcResult page = list(userId, "size", String.valueOf(size)).andExpect(status().isOk()).andReturn(); + collected.addAll(templateIds(body(page))); + while (nextCursor(page) != null) { + page = list(userId, "size", String.valueOf(size), "cursor", nextCursor(page)) + .andExpect(status().isOk()) + .andReturn(); + collected.addAll(templateIds(body(page))); + } + return collected; + } + + private static void expectError(ResultActions actions, int status, String code, String message) throws Exception { + actions.andExpect(status().is(status)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(code)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.result").isEmpty()); + } + + private static String body(MvcResult result) throws Exception { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + private static String nextCursor(MvcResult result) throws Exception { + return JsonPath.read(body(result), "$.result.nextCursor"); + } + + private static List templateIds(String json) { + List ids = JsonPath.read(json, "$.result.templates[*].templateId"); + return ids.stream().map(Number::longValue).toList(); + } + + private static void assertFieldOrder(String json, List fields) { + List positions = new ArrayList<>(); + for (String field : fields) { + int position = json.indexOf("\"" + field + "\""); + assertThat(position).as(field).isNotNegative(); + positions.add(position); + } + assertThat(positions).isSorted(); + } + + private Long saveTrashed(Long ownerId, String shortcut, LocalDateTime deletedAt) { + Long id = templateRepository.saveAndFlush(TemplateFixture.trashedTemplate(ownerId, shortcut, deletedAt)).getId(); + entityManager.clear(); + return id; + } + + /** 단축어 /t1, /t2 ... 로 같은 시각에 휴지통을 채운다 */ + private void saveTrashedCount(int count) { + for (int number = 1; number <= count; number++) { + saveTrashed(userId, "/t" + number, DELETED_AT); + } + } + + /** 복구 API(16번)가 아직 없어 행만 활성으로 되돌린다 */ + private void restore(Long templateId) { + entityManager.createNativeQuery("update templates set deleted_at = null where id = :id") + .setParameter("id", templateId) + .executeUpdate(); + entityManager.clear(); + } + + private Long saveUser(String email) { + User user = UserFixture.user(email); + entityManager.persist(user); + entityManager.flush(); + return user.getId(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/exception/TrashErrorCodeTest.java b/src/test/java/com/sniptext/domain/template/exception/TrashErrorCodeTest.java new file mode 100644 index 0000000..77dfa73 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/exception/TrashErrorCodeTest.java @@ -0,0 +1,19 @@ +package com.sniptext.domain.template.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class TrashErrorCodeTest { + + @Test + @DisplayName("잘못된 페이지 요청은 명세의 결과 코드와 문구다") + void invalidPageRequest() { + assertThat(TrashErrorCode.INVALID_PAGE_REQUEST.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(TrashErrorCode.INVALID_PAGE_REQUEST.getCode()).isEqualTo("TRASH400_1"); + assertThat(TrashErrorCode.INVALID_PAGE_REQUEST.getMessage()).isEqualTo("잘못된 페이지 요청이에요."); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/repository/TrashTemplateRepositoryTest.java b/src/test/java/com/sniptext/domain/template/repository/TrashTemplateRepositoryTest.java new file mode 100644 index 0000000..5213545 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/repository/TrashTemplateRepositoryTest.java @@ -0,0 +1,144 @@ +package com.sniptext.domain.template.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; + +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.context.annotation.Import; +import org.springframework.data.domain.Limit; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.template.repository.dto.TrashTemplateRow; +import com.sniptext.domain.user.entity.User; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import jakarta.persistence.EntityManager; + +/** 휴지통 조회의 정렬과 커서 조건을 실제 MySQL로 검증한다. 삭제 시각은 DATETIME(6) 그대로 비교되어야 한다. */ +@Transactional +@Import(MutableClockConfiguration.class) +class TrashTemplateRepositoryTest extends IntegrationTestSupport { + + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 7, 10, 58, 0, 123_456_000); + private static final Limit LIMIT = Limit.of(10); + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private MutableClock clock; + + private Long userId; + private Long otherUserId; + + @BeforeEach + void setUp() { + clock.setTo(LocalDateTime.of(2026, 10, 25, 10, 0)); + userId = saveUser("owner@sniptext.com"); + otherUserId = saveUser("other@sniptext.com"); + } + + @Test + @DisplayName("첫 페이지는 최근 삭제순이고, 같은 시각이면 ID가 큰 것이 먼저다") + void firstPageOrder() { + Long older = saveTrashed(userId, "/오래", DELETED_AT.minusSeconds(1)); + Long tiedFirst = saveTrashed(userId, "/동률1", DELETED_AT); + Long tiedSecond = saveTrashed(userId, "/동률2", DELETED_AT); + Long newer = saveTrashed(userId, "/최근", DELETED_AT.plusSeconds(1)); + + assertThat(idsOf(templateRepository.findTrashFirstPage(userId, LIMIT))) + .containsExactly(newer, tiedSecond, tiedFirst, older); + } + + @Test + @DisplayName("목록에 필요한 값을 그대로 읽고, 삭제 시각은 마이크로초까지 유지한다") + void rowValues() { + Long id = saveTrashed(userId, "/회의", DELETED_AT); + + assertThat(templateRepository.findTrashFirstPage(userId, LIMIT)) + .containsExactly(new TrashTemplateRow(id, TemplateFixture.TITLE, "/회의", DELETED_AT)); + } + + @Test + @DisplayName("첫 페이지는 넘긴 개수만큼만 읽는다") + void firstPageLimit() { + saveTrashed(userId, "/가나", DELETED_AT); + saveTrashed(userId, "/다라", DELETED_AT); + + assertThat(templateRepository.findTrashFirstPage(userId, Limit.of(1))).hasSize(1); + } + + @Test + @DisplayName("활성 템플릿과 다른 회원의 휴지통은 읽지 않는다") + void onlyOwnTrash() { + Long mine = saveTrashed(userId, "/내것", DELETED_AT); + saveTrashed(otherUserId, "/남의것", DELETED_AT); + templateRepository.saveAndFlush(TemplateFixture.template(userId, "/활성")); + + assertThat(idsOf(templateRepository.findTrashFirstPage(userId, LIMIT))).containsExactly(mine); + assertThat(idsOf(templateRepository.findTrashAfter(userId, DELETED_AT.plusDays(1), 1L, LIMIT))) + .containsExactly(mine); + } + + @Test + @DisplayName("다음 페이지는 커서보다 삭제가 오래된 항목과, 같은 시각의 더 작은 ID만 읽는다") + void nextPageCondition() { + Long older = saveTrashed(userId, "/오래", DELETED_AT.minusSeconds(1)); + Long tiedFirst = saveTrashed(userId, "/동률1", DELETED_AT); + Long cursor = saveTrashed(userId, "/커서", DELETED_AT); + saveTrashed(userId, "/동률3", DELETED_AT); + saveTrashed(userId, "/최근", DELETED_AT.plusSeconds(1)); + + assertThat(idsOf(templateRepository.findTrashAfter(userId, DELETED_AT, cursor, LIMIT))) + .containsExactly(tiedFirst, older); + } + + @Test + @DisplayName("마이크로초만 다른 삭제 시각도 구분한다") + void microsecondPrecision() { + Long earlier = saveTrashed(userId, "/앞쪽", DELETED_AT); + Long later = saveTrashed(userId, "/뒤쪽", DELETED_AT.plusNanos(1_000)); + + assertThat(idsOf(templateRepository.findTrashAfter(userId, DELETED_AT.plusNanos(1_000), later, LIMIT))) + .containsExactly(earlier); + } + + @Test + @DisplayName("휴지통 개수는 본인 휴지통만 센다") + void count() { + saveTrashed(userId, "/가나", DELETED_AT); + saveTrashed(userId, "/다라", DELETED_AT); + saveTrashed(otherUserId, "/마바", DELETED_AT); + templateRepository.saveAndFlush(TemplateFixture.template(userId, "/활성")); + + assertThat(templateRepository.countByUserIdAndDeletedAtIsNotNull(userId)).isEqualTo(2L); + } + + private Long saveTrashed(Long ownerId, String shortcut, LocalDateTime deletedAt) { + return templateRepository.saveAndFlush(TemplateFixture.trashedTemplate(ownerId, shortcut, deletedAt)).getId(); + } + + private List idsOf(List rows) { + return rows.stream().map(TrashTemplateRow::id).toList(); + } + + private Long saveUser(String email) { + User user = UserFixture.user(email); + entityManager.persist(user); + entityManager.flush(); + return user.getId(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/service/TrashCursorTest.java b/src/test/java/com/sniptext/domain/template/service/TrashCursorTest.java new file mode 100644 index 0000000..447a19f --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/service/TrashCursorTest.java @@ -0,0 +1,73 @@ +package com.sniptext.domain.template.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class TrashCursorTest { + + private static final LocalDateTime MICROS = LocalDateTime.of(2026, 10, 7, 10, 58, 0, 123_456_000); + + @Test + @DisplayName("인코딩한 커서를 해석하면 마이크로초까지 같은 값이다") + void roundTrip() { + String encoded = new TrashCursor(MICROS, 18L).encode(); + + assertThat(TrashCursor.decode(encoded)).contains(new TrashCursor(MICROS, 18L)); + } + + @Test + @DisplayName("초와 소수가 0인 시각도 그대로 돌아온다") + void roundTripWithoutFraction() { + LocalDateTime wholeMinute = LocalDateTime.of(2026, 10, 7, 10, 58); + + String encoded = new TrashCursor(wholeMinute, 18L).encode(); + + assertThat(TrashCursor.decode(encoded)).contains(new TrashCursor(wholeMinute, 18L)); + } + + @Test + @DisplayName("쿼리 문자열에 그대로 넣을 수 있게 URL 안전 문자만 쓰고 채움 문자가 없다") + void urlSafe() { + assertThat(new TrashCursor(MICROS, Long.MAX_VALUE).encode()).matches("[A-Za-z0-9_-]+"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "!!!", "한글"}) + @DisplayName("Base64가 아닌 값은 해석하지 못한다") + void notBase64(String cursor) { + assertThat(TrashCursor.decode(cursor)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "abc", + "2026-10-07T10:58:00.123456", + "2026-10-07T10:58:00.123456_", + "_18", + "18_2026-10-07T10:58:00.123456", + "2026-10-07T10:58:00.123456_abc", + "2026-10-07T10:58:00.123456_-1", + "2026-10-07T10:58:00.123456_99999999999999999999", + "2026-02-30T10:58:00.123456_18", + "2026-10-07T24:58:00.123456_18", + "2026-10-07T10:58:00_18", + "2026-10-07T10:58:00.1234567_18", + "+12026-10-07T10:58:00.123456_18", + "-2026-10-07T10:58:00.123456_18" + }) + @DisplayName("서버가 만든 형식이 아니면 해석하지 못한다") + void malformed(String decoded) { + String cursor = Base64.getUrlEncoder().encodeToString(decoded.getBytes(StandardCharsets.UTF_8)); + + assertThat(TrashCursor.decode(cursor)).isEmpty(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/service/TrashQueryServiceTest.java b/src/test/java/com/sniptext/domain/template/service/TrashQueryServiceTest.java new file mode 100644 index 0000000..1c7a784 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/service/TrashQueryServiceTest.java @@ -0,0 +1,137 @@ +package com.sniptext.domain.template.service; + +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.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.Limit; + +import com.sniptext.domain.template.exception.TrashErrorCode; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.repository.dto.TrashTemplateRow; +import com.sniptext.domain.template.service.dto.query.TrashListQuery; +import com.sniptext.domain.template.service.dto.result.TrashListResult; +import com.sniptext.domain.template.service.dto.result.TrashTemplateResult; +import com.sniptext.global.exception.BusinessException; + +@ExtendWith(MockitoExtension.class) +class TrashQueryServiceTest { + + private static final Long USER_ID = 1L; + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 7, 10, 58, 0, 123_456_000); + + @Mock + private TemplateRepository templateRepository; + + @InjectMocks + private TrashQueryService trashQueryService; + + @Test + @DisplayName("커서가 없으면 첫 페이지를 다음 항목 확인용 1개까지 더 읽는다") + void firstPage() { + given(templateRepository.findTrashFirstPage(USER_ID, Limit.of(3))).willReturn(List.of(trashed(9L, "/가나"))); + + TrashListResult result = trashQueryService.getTrash(new TrashListQuery(USER_ID, null, 2)); + + assertThat(result.templates()).extracting(TrashTemplateResult::templateId).containsExactly(9L); + verify(templateRepository, never()).findTrashAfter(any(), any(), any(), any()); + } + + @Test + @DisplayName("빈 커서는 첫 요청으로 본다") + void emptyCursor() { + given(templateRepository.findTrashFirstPage(USER_ID, Limit.of(3))).willReturn(List.of()); + + TrashListResult result = trashQueryService.getTrash(new TrashListQuery(USER_ID, "", 2)); + + assertThat(result.templates()).isEmpty(); + } + + @Test + @DisplayName("커서가 있으면 커서에 담긴 삭제 시각과 ID 다음부터 읽는다") + void nextPage() { + String cursor = new TrashCursor(DELETED_AT, 18L).encode(); + given(templateRepository.findTrashAfter(USER_ID, DELETED_AT, 18L, Limit.of(3))) + .willReturn(List.of(trashed(9L, "/가나"))); + + TrashListResult result = trashQueryService.getTrash(new TrashListQuery(USER_ID, cursor, 2)); + + assertThat(result.templates()).extracting(TrashTemplateResult::templateId).containsExactly(9L); + verify(templateRepository, never()).findTrashFirstPage(any(), any()); + } + + @Test + @DisplayName("더 읽은 항목이 있으면 잘라내고, 커서는 이 페이지 마지막 항목을 가리킨다") + void hasNext() { + given(templateRepository.findTrashFirstPage(USER_ID, Limit.of(3))) + .willReturn(List.of(trashed(9L, "/가나"), trashed(8L, "/다라"), trashed(7L, "/마바"))); + + TrashListResult result = trashQueryService.getTrash(new TrashListQuery(USER_ID, null, 2)); + + assertThat(result.templates()).extracting(TrashTemplateResult::templateId).containsExactly(9L, 8L); + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(new TrashCursor(DELETED_AT, 8L).encode()); + } + + @Test + @DisplayName("요청한 개수만큼만 있으면 마지막 페이지다") + void lastPage() { + given(templateRepository.findTrashFirstPage(USER_ID, Limit.of(3))) + .willReturn(List.of(trashed(9L, "/가나"), trashed(8L, "/다라"))); + + TrashListResult result = trashQueryService.getTrash(new TrashListQuery(USER_ID, null, 2)); + + assertThat(result.hasNext()).isFalse(); + assertThat(result.nextCursor()).isNull(); + } + + @Test + @DisplayName("전체 개수는 페이지 크기와 상관없이 따로 센 값이다") + void totalCount() { + given(templateRepository.findTrashFirstPage(USER_ID, Limit.of(2))).willReturn(List.of(trashed(9L, "/가나"))); + given(templateRepository.countByUserIdAndDeletedAtIsNotNull(USER_ID)).willReturn(7L); + + TrashListResult result = trashQueryService.getTrash(new TrashListQuery(USER_ID, null, 1)); + + assertThat(result.totalCount()).isEqualTo(7L); + } + + @Test + @DisplayName("자동 영구 삭제 예정 시각은 삭제 시각의 30일 뒤다") + void purgeAt() { + given(templateRepository.findTrashFirstPage(USER_ID, Limit.of(2))).willReturn(List.of(trashed(9L, "/가나"))); + + TrashListResult result = trashQueryService.getTrash(new TrashListQuery(USER_ID, null, 1)); + + assertThat(result.templates().getFirst().purgeAt()).isEqualTo(DELETED_AT.plusDays(30)); + } + + @Test + @DisplayName("해석할 수 없는 커서는 TRASH400_1이고 DB를 읽지 않는다") + void invalidCursor() { + assertThatThrownBy(() -> trashQueryService.getTrash(new TrashListQuery(USER_ID, "!!!", 2))) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getResultCode()) + .isEqualTo(TrashErrorCode.INVALID_PAGE_REQUEST); + + verify(templateRepository, never()).findTrashFirstPage(any(), any()); + verify(templateRepository, never()).findTrashAfter(any(), any(), any(), any()); + } + + private static TrashTemplateRow trashed(Long id, String shortcut) { + return new TrashTemplateRow(id, "견적 안내", shortcut, DELETED_AT); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/support/TrashRetentionTest.java b/src/test/java/com/sniptext/domain/template/support/TrashRetentionTest.java new file mode 100644 index 0000000..1a8554c --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/support/TrashRetentionTest.java @@ -0,0 +1,21 @@ +package com.sniptext.domain.template.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class TrashRetentionTest { + + @Test + @DisplayName("자동 영구 삭제 예정 시각은 삭제 시각의 30일 뒤다") + void purgeAt() { + LocalDateTime deletedAt = LocalDateTime.of(2026, 10, 7, 10, 58, 0, 123_456_000); + + assertThat(TrashRetention.purgeAt(deletedAt)) + .isEqualTo(LocalDateTime.of(2026, 11, 6, 10, 58, 0, 123_456_000)); + } + +} diff --git a/src/test/java/com/sniptext/fixture/TemplateFixture.java b/src/test/java/com/sniptext/fixture/TemplateFixture.java index ba52970..9337696 100644 --- a/src/test/java/com/sniptext/fixture/TemplateFixture.java +++ b/src/test/java/com/sniptext/fixture/TemplateFixture.java @@ -1,5 +1,7 @@ package com.sniptext.fixture; +import java.time.LocalDateTime; + import com.sniptext.domain.template.entity.Shortcut; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.entity.TemplateContent; @@ -29,4 +31,11 @@ public static Template templateFromGallery(Long userId, Long sourceGalleryTempla Shortcut.create(SHORTCUT), TemplateContent.create(CONTENT)); } + /** 휴지통에 있는 템플릿. 삭제 API를 거치지 않고 삭제 시각을 정해 만든다 */ + public static Template trashedTemplate(Long userId, String shortcut, LocalDateTime deletedAt) { + Template template = template(userId, shortcut); + template.moveToTrash(deletedAt); + return template; + } + }