Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TokenPurchase extends BaseEntity {

private static final long REFUND_EXPIRY_YEARS = 1;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down Expand Up @@ -176,7 +178,11 @@ public boolean isNotRefundable() {
}

public boolean isRefundable() {
return state == TokenPurchaseState.REFUNDABLE && purchaseCount.equals(remainingCount);
return state == TokenPurchaseState.REFUNDABLE && purchaseCount.equals(remainingCount) && !isRefundExpired();
}

public boolean isRefundExpired() {
return getCreatedAt().plusYears(REFUND_EXPIRY_YEARS).isBefore(LocalDateTime.now());
}
Comment on lines 180 to 186
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

도메인 모델 내에서 LocalDateTime.now()를 직접 호출하면 시스템 시계에 대한 숨은 의존성이 생겨 테스트하기 어려운 코드가 될 수 있습니다. 시간과 관련된 로직은 외부에서 현재 시간을 주입받는 형태로 변경하는 것을 고려해보세요. 이렇게 하면 테스트 시 시간을 제어하기 용이해져 코드의 안정성과 예측 가능성을 높일 수 있습니다.

예를 들어, isRefundableisRefundExpired 메서드가 LocalDateTime을 인자로 받도록 수정하고, 서비스 레이어에서 LocalDateTime.now()를 전달하는 방식입니다. 이 변경은 isNotRefundable 등 관련 메서드들의 시그니처 변경을 수반합니다. 이렇게 수정하면 TokenPurchaseTest에서 리플렉션을 사용하여 createdAt을 설정하는 대신, 테스트 시간을 명시적으로 전달하여 더 깔끔한 테스트를 작성할 수 있습니다.

Suggested change
public boolean isRefundable() {
return state == TokenPurchaseState.REFUNDABLE && purchaseCount.equals(remainingCount);
return state == TokenPurchaseState.REFUNDABLE && purchaseCount.equals(remainingCount) && !isRefundExpired();
}
public boolean isRefundExpired() {
return getCreatedAt().plusYears(REFUND_EXPIRY_YEARS).isBefore(LocalDateTime.now());
}
public boolean isRefundable(java.time.LocalDateTime now) {
return state == TokenPurchaseState.REFUNDABLE && purchaseCount.equals(remainingCount) && !isRefundExpired(now);
}
public boolean isRefundExpired(java.time.LocalDateTime now) {
return getCreatedAt().plusYears(REFUND_EXPIRY_YEARS).isBefore(now);
}


public boolean isNotOwnedBy(Long memberId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@Getter
public enum TokenPurchaseState {
REFUNDABLE("환불 가능"),
REFUND_EXPIRED("환불 기한 만료"),
USABLE("사용 중"),
EXHAUSTED("사용 완료"),
REFUNDED("환불 완료");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.samhap.kokomen.product.domain.TokenProduct;
import com.samhap.kokomen.token.domain.TokenPurchase;
import com.samhap.kokomen.token.domain.TokenPurchaseState;

public record TokenPurchaseResponse(
Long id,
Expand All @@ -23,9 +24,16 @@ public static TokenPurchaseResponse from(TokenPurchase tokenPurchase) {
tokenPurchase.getProductName(),
tokenPurchase.getPurchaseCount(),
tokenPurchase.getRemainingCount(),
tokenPurchase.getState().getDisplayMessage(),
resolveDisplayState(tokenPurchase),
tokenPurchase.getPaymentMethod(),
tokenPurchase.getEasyPayProvider()
);
}

private static String resolveDisplayState(TokenPurchase tokenPurchase) {
if (tokenPurchase.getState() == TokenPurchaseState.REFUNDABLE && tokenPurchase.isRefundExpired()) {
return TokenPurchaseState.REFUND_EXPIRED.getDisplayMessage();
}
return tokenPurchase.getState().getDisplayMessage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ public void refundTokens(Long memberId, Long tokenPurchaseId, TokenRefundRequest
throw new BadRequestException("본인의 토큰 구매 내역만 환불할 수 있습니다.");
}

if (tokenPurchase.isRefundExpired()) {
throw new BadRequestException("구매일로부터 1년이 경과하여 환불이 불가합니다.");
}

if (tokenPurchase.isNotRefundable()) {
throw new BadRequestException("환불 불가능한 상태입니다. 환불 가능한 토큰은 사용하지 않은 상태여야 합니다.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class TokenControllerTest extends BaseControllerTest {
private TokenService tokenService;
@Autowired
private TokenPurchaseRepository tokenPurchaseRepository;
@Autowired
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
Comment on lines +57 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

타입 선언은 import를 사용하면 가독성이 더 좋습니다.

필드에서 FQCN 직접 사용 대신 import로 정리하면 테스트 클래스 읽기가 수월해집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java`
around lines 57 - 58, Replace the fully-qualified type declaration on the
jdbcTemplate field in TokenControllerTest with an import: add import for
org.springframework.jdbc.core.JdbcTemplate at the top and change the field to
"private JdbcTemplate jdbcTemplate;" (keeping the `@Autowired` annotation and
field name intact) so the class uses the simple type name for improved
readability.


@Test
void 토큰_구매_DTO_검증_실패() throws Exception {
Expand Down Expand Up @@ -628,6 +630,42 @@ class TokenControllerTest extends BaseControllerTest {
));
}

@Test
void 구매일로부터_1년_경과한_토큰_환불_실패() throws Exception {
// given
Member member = memberRepository.save(MemberFixtureBuilder.builder().build());
tokenService.createTokensForNewMember(member.getId());

TokenPurchase tokenPurchase = tokenPurchaseRepository.save(
TokenPurchaseFixtureBuilder.builder()
.memberId(member.getId())
.count(10)
.remainingCount(10)
.state(TokenPurchaseState.REFUNDABLE)
.build()
);

// created_at을 1년 전으로 변경 (native SQL로 @CreatedDate 필드 직접 수정)
jdbcTemplate.update(
"UPDATE token_purchase SET created_at = ? WHERE id = ?",
java.time.LocalDateTime.now().minusYears(1).minusDays(1),
tokenPurchase.getId()
);

MockHttpSession session = new MockHttpSession();
session.setAttribute("MEMBER_ID", member.getId());

TokenRefundRequest request = new TokenRefundRequest(RefundReasonCode.CHANGE_OF_MIND, null);

// when & then
mockMvc.perform(patch("/api/v1/token-purchases/{tokenPurchaseId}/refund", tokenPurchase.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.header("Cookie", "JSESSIONID=" + session.getId())
.session(session))
.andExpect(status().isBadRequest());
}
Comment on lines +633 to +667
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

실패 원인까지 검증해 테스트 의도를 고정해 주세요.

현재는 400만 확인해서, 만료 외 다른 BadRequest로도 통과할 수 있습니다. 만료 메시지(또는 에러 코드)까지 함께 검증하는 편이 안전합니다.

🔧 보강 예시
         mockMvc.perform(patch("/api/v1/token-purchases/{tokenPurchaseId}/refund", tokenPurchase.getId())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(objectMapper.writeValueAsString(request))
                         .header("Cookie", "JSESSIONID=" + session.getId())
                         .session(session))
-                .andExpect(status().isBadRequest());
+                .andExpect(status().isBadRequest())
+                .andExpect(result -> assertThat(result.getResponse().getContentAsString())
+                        .contains("구매일로부터 1년이 경과하여 환불이 불가합니다."));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java`
around lines 633 - 667, The test 구매일로부터_1년_경과한_토큰_환불_실패 currently only asserts
status 400 which could be triggered by other validation errors; update
TokenControllerTest (the test method 구매일로부터_1년_경과한_토큰_환불_실패) to also assert the
failure reason by checking the response body for the specific error code or
message your controller returns for expired refunds (e.g. an "expired" error
code or message like "TOKEN_REFUND_EXPIRED" or "refund period expired") using
mockMvc.perform(...).andExpect(...). For example, after the existing
.andExpect(status().isBadRequest()) add an assertion that inspects the JSON
error field (jsonPath("$.code") or jsonPath("$.message")) or the response string
to ensure the error matches the expiration case so the test cannot pass for
unrelated BadRequest causes.


@Test
void 타인의_토큰_환불_실패() throws Exception {
// given
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.samhap.kokomen.token.domain;

import static org.assertj.core.api.Assertions.assertThat;

import com.samhap.kokomen.global.fixture.token.TokenPurchaseFixtureBuilder;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;

class TokenPurchaseTest {

@Test
void 구매일로부터_1년_이내이면_환불_가능하다() {
// given
TokenPurchase tokenPurchase = TokenPurchaseFixtureBuilder.builder()
.state(TokenPurchaseState.REFUNDABLE)
.count(10)
.remainingCount(10)
.build();
setCreatedAt(tokenPurchase, LocalDateTime.now().minusMonths(6));

// when & then
assertThat(tokenPurchase.isRefundable()).isTrue();
assertThat(tokenPurchase.isRefundExpired()).isFalse();
}

@Test
void 구매일로부터_1년이_경과하면_환불_불가능하다() {
// given
TokenPurchase tokenPurchase = TokenPurchaseFixtureBuilder.builder()
.state(TokenPurchaseState.REFUNDABLE)
.count(10)
.remainingCount(10)
.build();
setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1).minusDays(1));

// when & then
assertThat(tokenPurchase.isRefundable()).isFalse();
assertThat(tokenPurchase.isRefundExpired()).isTrue();
}

@Test
void 구매일로부터_정확히_1년이면_환불_가능하다() {
// given
TokenPurchase tokenPurchase = TokenPurchaseFixtureBuilder.builder()
.state(TokenPurchaseState.REFUNDABLE)
.count(10)
.remainingCount(10)
.build();
setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1).plusMinutes(1));
Comment on lines +43 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

테스트 이름과 설정 시간이 불일치합니다.

구매일로부터_정확히_1년이면_환불_가능하다인데, 실제 데이터는 minusYears(1).plusMinutes(1)로 “정확히 1년”이 아닙니다. 테스트명을 바꾸거나 시간을 정확히 1년으로 맞춰 주세요.

🔧 수정 예시
-    void 구매일로부터_정확히_1년이면_환불_가능하다() {
+    void 구매일로부터_1년_미만이면_환불_가능하다() {
...
-        setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1).plusMinutes(1));
+        setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1).plusMinutes(1));

또는

-        setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1).plusMinutes(1));
+        setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/com/samhap/kokomen/token/domain/TokenPurchaseTest.java` around
lines 43 - 50, The test method 구매일로부터_정확히_1년이면_환불_가능하다 has a mismatch between
its name and setup: it sets createdAt to
LocalDateTime.now().minusYears(1).plusMinutes(1) which is not exactly one year;
either change the createdAt value to LocalDateTime.now().minusYears(1) (adjust
any helper call setCreatedAt(tokenPurchase, ...)) or rename the test to reflect
“약 1년”/“1년보다 조금 지난 경우”; update the TokenPurchaseFixtureBuilder usage and the
setCreatedAt call accordingly so the test name and setup are consistent.


// when & then
assertThat(tokenPurchase.isRefundable()).isTrue();
assertThat(tokenPurchase.isRefundExpired()).isFalse();
}

private void setCreatedAt(TokenPurchase tokenPurchase, LocalDateTime createdAt) {
try {
Field field = tokenPurchase.getClass().getSuperclass().getDeclaredField("createdAt");
field.setAccessible(true);
field.set(tokenPurchase, createdAt);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException("createdAt 필드 설정 실패", e);
}
}
}