diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 02e3cb7..f5c43c8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,7 +21,7 @@ closes 같은 키워드가 없으면 병합해도 이슈가 닫히지 않습니 ## 테스트 - + ## 확인 체크리스트 diff --git a/CLAUDE.md b/CLAUDE.md index 8df5f3d..3e8f3f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,11 +43,10 @@ docker compose up -d # 로컬 MySQL com.sniptext ├── domain │ ├── auth # 로그인·가입·재발급·로그아웃. SocialAccount, RefreshToken -│ ├── user # User, UserTermsAgreement. 내 정보·설정·사용량, 가입 시 회원 생성 +│ ├── user # User, UserTermsAgreement. 내 정보·사용량, 가입 시 회원 생성 │ ├── template # 내 템플릿 + 휴지통 + 휴지통 정리 배치 │ ├── gallery # 갤러리 템플릿, 내 템플릿으로 저장 -│ ├── ai # AI 톤 교정, 하루 사용량, 핵심 정보 검사 -│ └── plan # 현재 플랜 조회 (PlanQueryService, 테이블 없음) +│ └── ai # AI 톤 교정, 하루 사용량, 핵심 정보 검사 └── global ├── security # 보안 설정, 토큰 필터·발급 도구, 로그인 회원 주입, 개발용 임시 토큰 ├── client # 외부 호출 (google: 코드 교환·ID 토큰 검증, llm: 언어모델) @@ -55,6 +54,7 @@ com.sniptext ├── entity # 공통 시각 부모 엔티티 ├── exception # 전역 예외 처리, 공통 에러 코드 ├── response # 공통 응답, 결과 코드 인터페이스 + ├── policy # 사용 한도 숫자 (UsageLimits) └── crypto # 본문 암호화 변환기 ``` @@ -87,7 +87,7 @@ domain/{도메인}/ - 버전 구분(`V2`)이 필요하면 컨트롤러의 요청·응답 객체에만 붙인다. - 엔티티의 규칙을 쓰는 요청 검증 어노테이션은 `annotation`에 둔다. `controller/dto`에 두면 컨트롤러가 엔티티를 쓰게 되어 ArchUnit에 걸린다. `annotation`은 `entity`·`exception`만 쓰고 `controller`·`service`는 부르지 않는다. - `support`에는 다른 도메인이 써도 되는 순수 규칙만 둔다 (상태·DB·스프링 빈 없음). `service`·`repository`·`controller`를 참조하지 않는다 (ArchUnit이 강제). -- 값을 나타내는 열거형(정렬 기준, 플랜, 카테고리 등)은 그 값을 저장하는 엔티티의 도메인 `entity` 패키지에 둔다. 저장하지 않는 값은 그 값을 쓰는 도메인의 `entity` 패키지에 둔다. +- 값을 나타내는 열거형(카테고리, 약관 종류 등)은 그 값을 저장하는 엔티티의 도메인 `entity` 패키지에 둔다. 저장하지 않는 값은 그 값을 쓰는 도메인의 `entity` 패키지에 둔다. - `entity` 패키지의 열거형은 컨트롤러와 다른 도메인에서 직접 써도 된다. 엔티티 클래스는 안 된다. - 열거형 상수 이름은 API 값이 된다. 명세를 먼저 바꾸지 않고 상수 이름을 바꾸지 않는다. - 의존 규칙은 ArchUnit 테스트가 강제한다. 테스트가 깨지면 구조를 고치고, 테스트를 고치지 않는다. @@ -96,16 +96,14 @@ domain/{도메인}/ | 연결 | 메서드 | 만드는 쪽 → 부르는 쪽 | | --- | --- | --- | -| 현재 플랜 조회 | `PlanResult getCurrentPlan(Long userId)` | plan → template, user, ai | | 갤러리에서 템플릿 생성 | `TemplateShortcutAssignResult createFromGallery(TemplateCreateFromGalleryCommand)` (호출자 트랜잭션 밖에서만) | template → gallery | | 보유 템플릿 개수 | `long countActive(Long userId)` | template → user | | AI 오늘 사용량 | `AiToneUsageResult getTodayUsage(Long userId)` (`AiToneUsageQueryService`) | ai → user | | 가입 시 회원 생성 | `Long signUp(UserSignUpCommand)` | user → auth | -| 플랜 지정값 조회 | `Optional findPlanOverride(Long userId)` (`UserQueryService`) | user → plan | | 회원 행 잠금 | `void lockUser(Long userId)` (`UserCommandService`, 호출자 트랜잭션 필수) | user → template | - **다른 도메인 서비스를 조합하는 조회는 별도 서비스로 분리한다.** 예: 사용량 API는 `UserUsageQueryService` - - 순환 참조를 막기 위해서다. plan → `UserQueryService`인데 사용량 때문에 `UserQueryService` → plan을 넣으면 생성자 주입 순환으로 앱이 뜨지 않는다 + - 순환 참조를 막기 위해서다. 사용량 조합(template·ai 서비스 호출)을 `UserQueryService`에 넣으면, template·ai가 `UserQueryService`를 부르게 될 때 생성자 주입 순환으로 앱이 뜨지 않는다 ## 코드 규칙 @@ -183,16 +181,16 @@ domain/{도메인}/ - `updated_at`은 확장 동기화 기준이라 빠지면 S2에서 동기화가 조용히 틀어진다 - 사용 횟수는 저장하지 않는다. -**한도와 플랜** -- 템플릿 한도는 휴지통 제외 개수 기준 (FREE 5 / PRO 50) +**한도** (플랜 구분 없음, 모든 사용자 동일) +- 템플릿 한도는 휴지통 제외 개수 기준 50개 - 생성·복구·갤러리 저장은 같은 트랜잭션에서 회원 행을 비관적 잠금으로 잡은 뒤 개수 검사 → 저장 - 한도 검사는 `lockUser`를 먼저 부른 같은 트랜잭션 안에서 한다 - `lockUser`는 그 트랜잭션의 첫 조회여야 한다. 대상 조회는 잠금 뒤에 한다 (MySQL 기본 격리 수준은 첫 일반 조회 시점의 스냅샷을 쓰므로, 먼저 읽으면 잠금 뒤 개수 검사가 옛 값을 봐서 한도를 넘는다) -- 현재 플랜 판정과 한도 숫자(템플릿 5/50, AI 하루 10)는 `plan` 도메인의 `PlanQueryService` 한 곳에서만 한다. 지금은 `users.plan_override` 값, 없으면 FREE. 다른 곳에서 플랜이나 한도 숫자를 직접 쓰지 않는다. +- 한도 숫자(템플릿 50, AI 하루 5)는 `global/policy/UsageLimits` 한 곳에만 둔다. 다른 곳에서 한도 숫자를 직접 쓰지 않는다. **AI 톤 교정** -- PRO 전용, 하루 10회 (KST 날짜별 1행) -- 요청 시 `used_count < 10` 조건부 증가로 1회를 먼저 잡고, 외부 AI 오류·20초 타임아웃·혐오 표현 차단이면 되돌린다. +- 모든 사용자 하루 5회 (KST 날짜별 1행) +- 요청 시 `used_count < 한도` 조건부 증가로 1회를 먼저 잡고, 외부 AI 오류·20초 타임아웃·혐오 표현 차단이면 되돌린다. - 출력 토큰 상한 4000을 반드시 설정한다. - **원문과 결과는 저장하지도, 로그에 남기지도 않는다.** 예외 로그에도 넣지 않는다. @@ -204,7 +202,7 @@ domain/{도메인}/ - 웹의 재발급 토큰은 HttpOnly·Secure 쿠키로만 주고받는다. - 로그아웃은 접근 토큰 없이 쿠키만으로 처리하고, 쿠키가 없거나 이미 폐기돼도 성공으로 응답한다. - 개발용 임시 토큰 발급 경로는 `local`·`dev` 프로필에서만 켠다. 프로필 지정은 나열 방식(`@Profile({"local", "dev"})`)으로 쓰고 `"!prod"`는 금지 (프로필을 빠뜨린 서버에서 `!prod`가 참이 되어 운영에 열린다) -- 로컬에서 인증 API 확인: `local` 프로필로 실행 → `POST /api/v1/auth/dev-token` `{"userId":1}` → 받은 토큰을 스웨거 Authorize에 입력. 시드 회원 1번은 FREE, 2번은 PRO +- 로컬에서 인증 API 확인: `local` 프로필로 실행 → `POST /api/v1/auth/dev-token` `{"userId":1}` → 받은 토큰을 스웨거 Authorize에 입력. 시드 회원 1번·2번 모두 일반 회원(한도 동일) - 컨트롤러에서 로그인 회원은 `@LoginUser Long userId`로 받는다. 인증 정보(`SecurityContextHolder`)에서 직접 꺼내지 않는다. Long만 허용한다 (ArchUnit이 강제). 다른 타입으로 쓰면 쿼리 값이 들어가는 구멍이 생긴다. - 접근 토큰이 없거나 만료되면 `COMMON401_1`, 위조·형식 오류·종류가 다른 토큰이면 `COMMON401_2`다. 필터는 사유만 요청 속성에 남기고, 응답은 진입점이 한다. - 통합 테스트에서 인증된 요청은 `IntegrationTestSupport.bearer(userId)`로 만든다 (실제 발급 도구 사용). `@WithMockUser`로는 `@LoginUser`가 채워지지 않는다. @@ -235,7 +233,7 @@ domain/{도메인}/ - 테스트 이름은 `@DisplayName`에 한국어로 쓴다. - 서비스 단위 테스트는 모키토, 레포지토리·통합 테스트는 테스트컨테이너 MySQL (H2 쓰지 않음. 생성 컬럼·잠금 동작이 다르다) - 테스트에서 엔티티는 `src/test/.../fixture/{엔티티}Fixture`로 만든다. 생성 인자가 바뀌어도 테스트가 한꺼번에 깨지지 않게 하기 위해서다 -- 반드시 검증할 것: 본인 소유가 아니면 404, 한도 경계(4→5, 5→6), 단축어 정규화·대소문자 중복, 동시 요청 시 한도 초과 방지 +- 반드시 검증할 것: 본인 소유가 아니면 404, 한도 경계(49→50, 50→51), 단축어 정규화·대소문자 중복, 동시 요청 시 한도 초과 방지 ## 깃·PR diff --git a/src/main/java/com/sniptext/domain/ai/service/AiToneUsageCommandService.java b/src/main/java/com/sniptext/domain/ai/service/AiToneUsageCommandService.java index 021d9b7..4f8499a 100644 --- a/src/main/java/com/sniptext/domain/ai/service/AiToneUsageCommandService.java +++ b/src/main/java/com/sniptext/domain/ai/service/AiToneUsageCommandService.java @@ -12,6 +12,7 @@ import com.sniptext.domain.ai.repository.AiToneDailyUsageRepository; import com.sniptext.domain.ai.service.dto.result.AiToneReserveResult; import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.policy.UsageLimits; import lombok.RequiredArgsConstructor; @@ -33,17 +34,17 @@ public class AiToneUsageCommandService { private final TransactionTemplate transactionTemplate; /** - * 오늘(KST) 횟수를 한도 안에서 1 올려 잡는다. 이미 한도면 AI429_1. - * 한도는 부르는 쪽이 현재 플랜 조회로 얻어 넘긴다 (회원 존재·플랜 검사도 부르는 쪽에서 먼저 한다). + * 오늘(KST) 횟수를 하루 한도(UsageLimits) 안에서 1 올려 잡는다. 이미 한도면 AI429_1. + * 회원 존재 검사는 부르는 쪽에서 먼저 한다. */ - public AiToneReserveResult reserve(Long userId, int limit) { + public AiToneReserveResult reserve(Long userId) { requireNoOuterTransaction(); return transactionTemplate.execute(status -> { LocalDateTime now = LocalDateTime.now(clock); LocalDate usageDate = now.toLocalDate(); aiToneDailyUsageRepository.insertIfAbsent(userId, usageDate, now); - if (aiToneDailyUsageRepository.incrementIfBelow(userId, usageDate, limit, now) == 0) { + if (aiToneDailyUsageRepository.incrementIfBelow(userId, usageDate, UsageLimits.AI_TONE_DAILY_LIMIT, now) == 0) { throw new BusinessException(AiErrorCode.DAILY_LIMIT_EXCEEDED); } int usedCountAfter = aiToneDailyUsageRepository.findUsedCount(userId, usageDate) diff --git a/src/main/java/com/sniptext/domain/ai/service/AiToneUsageQueryService.java b/src/main/java/com/sniptext/domain/ai/service/AiToneUsageQueryService.java index 6efaf64..08b1a8b 100644 --- a/src/main/java/com/sniptext/domain/ai/service/AiToneUsageQueryService.java +++ b/src/main/java/com/sniptext/domain/ai/service/AiToneUsageQueryService.java @@ -8,7 +8,7 @@ import com.sniptext.domain.ai.repository.AiToneDailyUsageRepository; import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; -import com.sniptext.domain.plan.service.PlanQueryService; +import com.sniptext.global.policy.UsageLimits; import lombok.RequiredArgsConstructor; @@ -18,19 +18,14 @@ public class AiToneUsageQueryService { private final AiToneDailyUsageRepository aiToneDailyUsageRepository; - private final PlanQueryService planQueryService; private final Clock clock; - /** - * 오늘(KST) 사용량. 한도는 현재 플랜 조회에서 얻는다. 없는 회원·탈퇴 회원이면 USER404_1. - * 한도가 0(쓸 수 없는 플랜)이면 오늘 행이 있어도 사용 횟수를 0으로 내려준다. 명세처럼 FREE는 항상 0/0/0이고, - * 같은 날 PRO로 쓰다 FREE가 된 회원이 "3/0"으로 보이지 않게 하기 위해서다. 행은 그대로라 다시 PRO가 되면 이어서 센다. - */ + /** 오늘(KST) 사용량. 한도는 모든 사용자에게 같다 (UsageLimits). 오늘 행이 없으면 사용 0이다. */ public AiToneUsageResult getTodayUsage(Long userId) { - int limit = planQueryService.getCurrentPlan(userId).aiToneDailyLimit(); + int limit = UsageLimits.AI_TONE_DAILY_LIMIT; LocalDate today = LocalDate.now(clock); - int usedCount = limit > 0 ? aiToneDailyUsageRepository.findUsedCount(userId, today).orElse(0) : 0; - return new AiToneUsageResult(limit > 0, usedCount, limit, Math.max(limit - usedCount, 0), + int usedCount = aiToneDailyUsageRepository.findUsedCount(userId, today).orElse(0); + return new AiToneUsageResult(usedCount, limit, Math.max(limit - usedCount, 0), today.plusDays(1).atStartOfDay()); } diff --git a/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneUsageResult.java b/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneUsageResult.java index 8c783a6..ba95974 100644 --- a/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneUsageResult.java +++ b/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneUsageResult.java @@ -5,11 +5,10 @@ /** * 오늘 AI 톤 교정 사용량. * - * @param available 현재 플랜에서 쓸 수 있는지 (하루 한도가 0보다 큰지) * @param usedCount 오늘 사용 횟수 * @param limit 하루 한도 * @param remaining 오늘 남은 횟수 (0 미만으로 내려가지 않는다) * @param resetAt 다음 리셋 시각 (다음 날 00:00 KST) */ -public record AiToneUsageResult(boolean available, int usedCount, int limit, int remaining, LocalDateTime resetAt) { +public record AiToneUsageResult(int usedCount, int limit, int remaining, LocalDateTime resetAt) { } diff --git a/src/main/java/com/sniptext/domain/plan/service/PlanQueryService.java b/src/main/java/com/sniptext/domain/plan/service/PlanQueryService.java deleted file mode 100644 index a1920c5..0000000 --- a/src/main/java/com/sniptext/domain/plan/service/PlanQueryService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sniptext.domain.plan.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.sniptext.domain.plan.service.dto.result.PlanResult; -import com.sniptext.domain.user.entity.Plan; -import com.sniptext.domain.user.service.UserQueryService; - -import lombok.RequiredArgsConstructor; - -/** - * 현재 플랜 판정과 플랜별 한도는 이 클래스 한 곳에서만 정한다. 다른 곳에서 플랜이나 한도 숫자를 직접 쓰지 않는다. - * 결제 도입 전에는 회원의 plan_override 값, 없으면 FREE. 결제가 들어오면 판정 안쪽만 구독 기준으로 바꾼다. - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class PlanQueryService { - - private static final int FREE_TEMPLATE_LIMIT = 5; - private static final int FREE_AI_TONE_DAILY_LIMIT = 0; - private static final int PRO_TEMPLATE_LIMIT = 50; - private static final int PRO_AI_TONE_DAILY_LIMIT = 10; - - private final UserQueryService userQueryService; - - /** 없는 회원, 탈퇴 회원이면 USER404_1. */ - public PlanResult getCurrentPlan(Long userId) { - Plan plan = userQueryService.findPlanOverride(userId).orElse(Plan.FREE); - return switch (plan) { - case FREE -> new PlanResult(plan, FREE_TEMPLATE_LIMIT, FREE_AI_TONE_DAILY_LIMIT); - case PRO -> new PlanResult(plan, PRO_TEMPLATE_LIMIT, PRO_AI_TONE_DAILY_LIMIT); - }; - } - -} diff --git a/src/main/java/com/sniptext/domain/plan/service/dto/result/PlanResult.java b/src/main/java/com/sniptext/domain/plan/service/dto/result/PlanResult.java deleted file mode 100644 index 031e355..0000000 --- a/src/main/java/com/sniptext/domain/plan/service/dto/result/PlanResult.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.sniptext.domain.plan.service.dto.result; - -import com.sniptext.domain.user.entity.Plan; - -/** - * 현재 플랜과 그 플랜의 한도. - * - * @param templateLimit 보유 템플릿 한도 (휴지통 제외 개수 기준) - * @param aiToneDailyLimit AI 톤 교정 하루 한도 (0이면 쓸 수 없음) - */ -public record PlanResult(Plan plan, int templateLimit, int aiToneDailyLimit) { -} diff --git a/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java b/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java index 347d14e..015de77 100644 --- a/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java +++ b/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java @@ -10,7 +10,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; -import com.sniptext.domain.plan.service.PlanQueryService; import com.sniptext.domain.template.entity.Shortcut; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.entity.TemplateContent; @@ -23,6 +22,7 @@ import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.policy.UsageLimits; import lombok.RequiredArgsConstructor; @@ -35,7 +35,6 @@ public class TemplateCommandService { private final TemplateRepository templateRepository; private final UserCommandService userCommandService; - private final PlanQueryService planQueryService; private final TransactionTemplate transactionTemplate; @@ -99,8 +98,7 @@ private static void requireNoOuterTransaction() { } private void validateTemplateLimit(Long userId) { - int templateLimit = planQueryService.getCurrentPlan(userId).templateLimit(); - if (templateRepository.countByUserIdAndDeletedAtIsNull(userId) >= templateLimit) { + if (templateRepository.countByUserIdAndDeletedAtIsNull(userId) >= UsageLimits.TEMPLATE_LIMIT) { throw new BusinessException(TemplateErrorCode.TEMPLATE_LIMIT_REACHED); } } diff --git a/src/main/java/com/sniptext/domain/user/entity/Plan.java b/src/main/java/com/sniptext/domain/user/entity/Plan.java deleted file mode 100644 index c57cf86..0000000 --- a/src/main/java/com/sniptext/domain/user/entity/Plan.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.sniptext.domain.user.entity; - -/** 플랜. users.plan_override에 저장하므로 user 도메인에 둔다. 판정은 plan 도메인의 PlanQueryService만 한다. */ -public enum Plan { - - FREE, - PRO - -} diff --git a/src/main/java/com/sniptext/domain/user/entity/TemplateSort.java b/src/main/java/com/sniptext/domain/user/entity/TemplateSort.java deleted file mode 100644 index 7930d65..0000000 --- a/src/main/java/com/sniptext/domain/user/entity/TemplateSort.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sniptext.domain.user.entity; - -/** 내 템플릿 정렬 기준. 회원별로 users.template_sort에 기억한다. */ -public enum TemplateSort { - - /** 최근 사용순 (기본) */ - RECENT_USED, - /** 최근 수정순 */ - RECENT_EDITED - -} diff --git a/src/main/java/com/sniptext/domain/user/entity/User.java b/src/main/java/com/sniptext/domain/user/entity/User.java index 0e06da3..1077847 100644 --- a/src/main/java/com/sniptext/domain/user/entity/User.java +++ b/src/main/java/com/sniptext/domain/user/entity/User.java @@ -6,8 +6,6 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -17,10 +15,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 회원. 가입 완료 시점에만 만든다 (약관 동의 전에는 회원을 저장하지 않는다). - * 정렬 기준은 RECENT_USED, 플랜 지정값은 없음(FREE 판정)으로 시작한다. - */ +/** 회원. 가입 완료 시점에만 만든다 (약관 동의 전에는 회원을 저장하지 않는다). */ @Getter @Entity @Table(name = "users") @@ -44,14 +39,6 @@ public class User extends BaseTimeEntity { @Column(name = "profile_image_url", length = PROFILE_IMAGE_URL_MAX_LENGTH) private String profileImageUrl; - @Enumerated(EnumType.STRING) - @Column(name = "template_sort", nullable = false, length = 20) - private TemplateSort templateSort; - - @Enumerated(EnumType.STRING) - @Column(name = "plan_override", length = 10) - private Plan planOverride; - @Column(name = "last_login_at") private LocalDateTime lastLoginAt; @@ -63,7 +50,6 @@ private User(String email, String nickname, String profileImageUrl) { this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; - this.templateSort = TemplateSort.RECENT_USED; } /** diff --git a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java index 0eebeb1..4c8855b 100644 --- a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java +++ b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java @@ -1,18 +1,13 @@ package com.sniptext.domain.user.service; -import java.util.Optional; - import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.sniptext.domain.user.entity.Plan; -import com.sniptext.domain.user.entity.User; -import com.sniptext.domain.user.exception.UserErrorCode; import com.sniptext.domain.user.repository.UserRepository; -import com.sniptext.global.exception.BusinessException; import lombok.RequiredArgsConstructor; +/** 회원 조회 (다른 도메인이 회원 정보를 읽을 때 쓰는 진입점). */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -20,14 +15,4 @@ public class UserQueryService { private final UserRepository userRepository; - /** - * 회원의 플랜 지정값(plan_override). 지정값이 없으면 빈 값이다. - * 현재 플랜 판정은 plan 도메인의 PlanQueryService가 하므로 여기서 기본값을 정하지 않는다. - */ - public Optional findPlanOverride(Long userId) { - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); - return Optional.ofNullable(user.getPlanOverride()); - } - } diff --git a/src/main/java/com/sniptext/global/policy/UsageLimits.java b/src/main/java/com/sniptext/global/policy/UsageLimits.java new file mode 100644 index 0000000..777ec74 --- /dev/null +++ b/src/main/java/com/sniptext/global/policy/UsageLimits.java @@ -0,0 +1,18 @@ +package com.sniptext.global.policy; + +/** + * 사용 한도. 모든 사용자에게 같다 (플랜 구분 없음). 한도 숫자는 여기 한 곳에서만 쓴다. + * 화면은 숫자를 직접 쓰지 않고 사용량 API의 limit을 쓴다. + */ +public final class UsageLimits { + + /** 보유 템플릿 한도 (휴지통 제외 개수 기준) */ + public static final int TEMPLATE_LIMIT = 50; + + /** AI 톤 교정 하루 한도 (KST 날짜 기준) */ + public static final int AI_TONE_DAILY_LIMIT = 5; + + private UsageLimits() { + } + +} diff --git a/src/main/resources/db/migration/V202611021541__drop_users_plan_override_and_template_sort.sql b/src/main/resources/db/migration/V202611021541__drop_users_plan_override_and_template_sort.sql new file mode 100644 index 0000000..c076e84 --- /dev/null +++ b/src/main/resources/db/migration/V202611021541__drop_users_plan_override_and_template_sort.sql @@ -0,0 +1,4 @@ +-- 플랜 구분 삭제(결제 기능 제거)와 내 템플릿 정렬 고정(생성 순)으로 쓰지 않게 된 컬럼 삭제. ERD v0.8 "마이그레이션" 참고 +ALTER TABLE users + DROP COLUMN plan_override, + DROP COLUMN template_sort; diff --git a/src/main/resources/db/seed/R__dev_seed_users.sql b/src/main/resources/db/seed/R__dev_seed_users.sql index e3d1cd7..afa7e7b 100644 --- a/src/main/resources/db/seed/R__dev_seed_users.sql +++ b/src/main/resources/db/seed/R__dev_seed_users.sql @@ -1,6 +1,6 @@ -- 개발용 시드 회원 (local·dev 전용). 운영 마이그레이션 위치(db/migration)에는 절대 넣지 않는다. --- 개발용 임시 토큰(POST /api/v1/auth/dev-token)으로 이 번호의 토큰을 받아 쓴다. 1번 FREE, 2번 PRO +-- 개발용 임시 토큰(POST /api/v1/auth/dev-token)으로 이 번호의 토큰을 받아 쓴다. 1번·2번 모두 일반 회원(한도 동일) -- 이미 있으면 건너뛴다 (내용을 바꾸면 반복 마이그레이션이 다시 실행되지만 기존 행은 그대로 둔다) -INSERT IGNORE INTO users (id, email, nickname, plan_override, created_at, updated_at) -VALUES (1, 'free@sniptext.local', '무료회원', NULL, '2026-10-26 00:00:00.000000', '2026-10-26 00:00:00.000000'), - (2, 'pro@sniptext.local', '프로회원', 'PRO', '2026-10-26 00:00:00.000000', '2026-10-26 00:00:00.000000'); +INSERT IGNORE INTO users (id, email, nickname, created_at, updated_at) +VALUES (1, 'user1@sniptext.local', '회원1', '2026-10-26 00:00:00.000000', '2026-10-26 00:00:00.000000'), + (2, 'user2@sniptext.local', '회원2', '2026-10-26 00:00:00.000000', '2026-10-26 00:00:00.000000'); diff --git a/src/test/java/com/sniptext/domain/ai/service/AiToneUsageCommandServiceTest.java b/src/test/java/com/sniptext/domain/ai/service/AiToneUsageCommandServiceTest.java index 828266b..76f3e77 100644 --- a/src/test/java/com/sniptext/domain/ai/service/AiToneUsageCommandServiceTest.java +++ b/src/test/java/com/sniptext/domain/ai/service/AiToneUsageCommandServiceTest.java @@ -30,6 +30,7 @@ import com.sniptext.domain.user.repository.UserRepository; import com.sniptext.fixture.UserFixture; import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.policy.UsageLimits; import com.sniptext.support.IntegrationTestSupport; import com.sniptext.support.MutableClock; import com.sniptext.support.MutableClockConfiguration; @@ -42,7 +43,7 @@ @Import(MutableClockConfiguration.class) class AiToneUsageCommandServiceTest extends IntegrationTestSupport { - private static final int LIMIT = 10; + private static final int LIMIT = UsageLimits.AI_TONE_DAILY_LIMIT; /** 동시 선점 테스트 반복 횟수 */ private static final int REPEATS = 10; /** DATETIME(6)과 같은 마이크로초 정밀도 */ @@ -81,66 +82,56 @@ void tearDown() { @Test @DisplayName("오늘 행이 없으면 0회로 만들고 1회를 잡는다") void reserveFirst() { - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); assertThat(reservation).isEqualTo(new AiToneReserveResult(userId, TODAY, 1)); assertThat(usedCount(TODAY)).isEqualTo(1); } @Test - @DisplayName("9회 사용 상태에서 선점하면 10회가 된다") + @DisplayName("4회 사용 상태에서 선점하면 한도인 5회가 된다") void reserveUpToLimit() { - reserveTimes(9); + reserveTimes(LIMIT - 1); - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); - assertThat(reservation.usedCountAfter()).isEqualTo(10); - assertThat(usedCount(TODAY)).isEqualTo(10); + assertThat(reservation.usedCountAfter()).isEqualTo(5); + assertThat(usedCount(TODAY)).isEqualTo(5); } @Test - @DisplayName("10회 사용 상태에서 선점하면 AI429_1이고 횟수는 그대로다") + @DisplayName("5회 사용 상태에서 선점하면 AI429_1이고 횟수는 그대로다") void reserveOverLimit() { - reserveTimes(10); + reserveTimes(LIMIT); - assertThatThrownBy(() -> aiToneUsageCommandService.reserve(userId, LIMIT)) + assertThatThrownBy(() -> aiToneUsageCommandService.reserve(userId)) .isInstanceOf(BusinessException.class) .extracting("resultCode") .isEqualTo(AiErrorCode.DAILY_LIMIT_EXCEEDED); - assertThat(usedCount(TODAY)).isEqualTo(10); - } - - @Test - @DisplayName("한도가 0이면 첫 선점부터 AI429_1이고 행도 남지 않는다") - void reserveWithZeroLimit() { - assertThatThrownBy(() -> aiToneUsageCommandService.reserve(userId, 0)) - .isInstanceOf(BusinessException.class) - .extracting("resultCode") - .isEqualTo(AiErrorCode.DAILY_LIMIT_EXCEEDED); - assertThat(aiToneDailyUsageRepository.findUsedCount(userId, TODAY)).isEmpty(); + assertThat(usedCount(TODAY)).isEqualTo(5); } @Test @DisplayName("다음 날 선점은 새 행에 잡히고 어제 행은 그대로다") void reserveOnNextDay() { - reserveTimes(10); + reserveTimes(LIMIT); clock.setTo(NOW.plusDays(1)); - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); assertThat(reservation).isEqualTo(new AiToneReserveResult(userId, TODAY.plusDays(1), 1)); - assertThat(usedCount(TODAY)).isEqualTo(10); + assertThat(usedCount(TODAY)).isEqualTo(LIMIT); } @Test @DisplayName("다른 회원의 사용량은 내 선점에 영향을 주지 않는다") void otherUserIndependent() { Long otherUserId = saveUser("other@sniptext.com"); - for (int i = 0; i < 10; i++) { - aiToneUsageCommandService.reserve(otherUserId, LIMIT); + for (int i = 0; i < LIMIT; i++) { + aiToneUsageCommandService.reserve(otherUserId); } - assertThat(aiToneUsageCommandService.reserve(userId, LIMIT).usedCountAfter()).isEqualTo(1); + assertThat(aiToneUsageCommandService.reserve(userId).usedCountAfter()).isEqualTo(1); } /** @@ -149,14 +140,14 @@ void otherUserIndependent() { * 한 번이라도 실패하면 잠금 순서를 의심한다. */ @RepeatedTest(REPEATS) - @DisplayName("9회 사용 상태에서 두 요청이 동시에 선점하면 1건만 성공하고 최종 10회다") + @DisplayName("4회 사용 상태에서 두 요청이 동시에 선점하면 1건만 성공하고 최종 5회다") void concurrentReserve() throws Exception { - reserveTimes(9); + reserveTimes(LIMIT - 1); List outcomes = reserveConcurrently(2); assertThat(outcomes).containsExactlyInAnyOrder(Outcome.RESERVED, Outcome.LIMIT_EXCEEDED); - assertThat(usedCount(TODAY)).isEqualTo(10); + assertThat(usedCount(TODAY)).isEqualTo(LIMIT); } @RepeatedTest(REPEATS) @@ -169,7 +160,7 @@ void concurrentReserveWithoutRow() throws Exception { } @RepeatedTest(REPEATS) - @DisplayName("오늘 행이 없을 때 10건이 동시에 선점하면 모두 성공하고 행은 1개, 10회다") + @DisplayName("오늘 행이 없을 때 한도만큼(5건) 동시에 선점하면 모두 성공하고 행은 1개, 5회다") void concurrentReserveUpToLimitWithoutRow() throws Exception { List outcomes = reserveConcurrently(LIMIT); @@ -181,7 +172,7 @@ void concurrentReserveUpToLimitWithoutRow() throws Exception { @DisplayName("반환하면 1 줄어든다") void release() { reserveTimes(3); - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); aiToneUsageCommandService.release(reservation); @@ -192,7 +183,7 @@ void release() { @DisplayName("23:59:59에 선점하고 다음 날 00:00:05에 반환하면 어제 행에서 뺀다") void releaseAfterMidnight() { clock.setTo(LocalDateTime.of(2026, 10, 25, 23, 59, 59)); - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); clock.setTo(LocalDateTime.of(2026, 10, 26, 0, 0, 5)); aiToneUsageCommandService.release(reservation); @@ -204,7 +195,7 @@ void releaseAfterMidnight() { @Test @DisplayName("0회에서 반환해도 음수가 되지 않는다") void releaseAtZero() { - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); aiToneUsageCommandService.release(reservation); aiToneUsageCommandService.release(reservation); @@ -223,11 +214,11 @@ void releaseWithoutRow() { @Test @DisplayName("선점·반환은 updated_at을 그때의 Clock 시각으로 기록한다") void updatedAtFromClock() { - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); assertThat(usage(TODAY).getUpdatedAt()).isEqualTo(NOW); clock.advance(Duration.ofMinutes(1)); - aiToneUsageCommandService.reserve(userId, LIMIT); + aiToneUsageCommandService.reserve(userId); assertThat(usage(TODAY).getUpdatedAt()).isEqualTo(NOW.plusMinutes(1)); clock.advance(Duration.ofMinutes(1)); @@ -238,10 +229,10 @@ void updatedAtFromClock() { @Test @DisplayName("한도에 걸린 선점은 updated_at을 바꾸지 않는다") void updatedAtUnchangedOnLimit() { - reserveTimes(10); + reserveTimes(LIMIT); clock.advance(Duration.ofMinutes(1)); - assertThatThrownBy(() -> aiToneUsageCommandService.reserve(userId, LIMIT)) + assertThatThrownBy(() -> aiToneUsageCommandService.reserve(userId)) .isInstanceOf(BusinessException.class); assertThat(usage(TODAY).getUpdatedAt()).isEqualTo(NOW); } @@ -250,7 +241,7 @@ void updatedAtUnchangedOnLimit() { @DisplayName("바깥 트랜잭션 안에서 선점하면 IllegalStateException이고 행이 생기지 않는다") void reserveInsideOuterTransaction() { assertThatThrownBy(() -> transactionTemplate.executeWithoutResult( - status -> aiToneUsageCommandService.reserve(userId, LIMIT))) + status -> aiToneUsageCommandService.reserve(userId))) .isInstanceOf(IllegalStateException.class) .hasMessage("AI 사용량 선점·반환은 호출자 트랜잭션 밖에서 불러야 한다"); assertThat(aiToneDailyUsageRepository.findUsedCount(userId, TODAY)).isEmpty(); @@ -260,7 +251,7 @@ void reserveInsideOuterTransaction() { @DisplayName("바깥 트랜잭션 안에서 반환하면 IllegalStateException이고 횟수는 그대로다") void releaseInsideOuterTransaction() { reserveTimes(3); - AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId, LIMIT); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(userId); assertThatThrownBy(() -> transactionTemplate.executeWithoutResult( status -> aiToneUsageCommandService.release(reservation))) @@ -271,7 +262,7 @@ void releaseInsideOuterTransaction() { private void reserveTimes(int times) { for (int i = 0; i < times; i++) { - aiToneUsageCommandService.reserve(userId, LIMIT); + aiToneUsageCommandService.reserve(userId); } } @@ -288,7 +279,7 @@ private List reserveConcurrently(int threads) throws Exception { ready.countDown(); await(start); try { - aiToneUsageCommandService.reserve(userId, LIMIT); + aiToneUsageCommandService.reserve(userId); return Outcome.RESERVED; } catch (BusinessException e) { return e.getResultCode() == AiErrorCode.DAILY_LIMIT_EXCEEDED ? Outcome.LIMIT_EXCEEDED : Outcome.OTHER; diff --git a/src/test/java/com/sniptext/domain/ai/service/AiToneUsageQueryServiceTest.java b/src/test/java/com/sniptext/domain/ai/service/AiToneUsageQueryServiceTest.java index 990c98a..af7834d 100644 --- a/src/test/java/com/sniptext/domain/ai/service/AiToneUsageQueryServiceTest.java +++ b/src/test/java/com/sniptext/domain/ai/service/AiToneUsageQueryServiceTest.java @@ -1,7 +1,6 @@ package com.sniptext.domain.ai.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.LocalDateTime; @@ -11,20 +10,16 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcTemplate; import com.sniptext.domain.ai.repository.AiToneDailyUsageRepository; import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; -import com.sniptext.domain.user.entity.Plan; -import com.sniptext.domain.user.exception.UserErrorCode; import com.sniptext.domain.user.repository.UserRepository; import com.sniptext.fixture.UserFixture; -import com.sniptext.global.exception.BusinessException; import com.sniptext.support.IntegrationTestSupport; import com.sniptext.support.MutableClock; import com.sniptext.support.MutableClockConfiguration; -/** 한도는 실제 현재 플랜 조회를 거쳐 얻는다. 선점이 자기 트랜잭션으로 커밋되므로 클래스에 @Transactional을 붙이지 않는다. */ +/** 선점이 자기 트랜잭션으로 커밋되므로 클래스에 @Transactional을 붙이지 않는다. */ @Import(MutableClockConfiguration.class) class AiToneUsageQueryServiceTest extends IntegrationTestSupport { @@ -43,15 +38,15 @@ class AiToneUsageQueryServiceTest extends IntegrationTestSupport { @Autowired private UserRepository userRepository; - @Autowired - private JdbcTemplate jdbcTemplate; - @Autowired private MutableClock clock; + private Long userId; + @BeforeEach void setUp() { clock.setTo(NOW); + userId = userRepository.save(UserFixture.user("usage@sniptext.com")).getId(); } @AfterEach @@ -61,70 +56,45 @@ void tearDown() { } @Test - @DisplayName("FREE면 쓸 수 없고 한도 0, 사용 0, 남은 0") - void free() { - Long userId = saveUser(null); - + @DisplayName("오늘 행이 없으면 0/5, 남은 5") + void withoutRow() { assertThat(aiToneUsageQueryService.getTodayUsage(userId)) - .isEqualTo(new AiToneUsageResult(false, 0, 0, 0, NEXT_MIDNIGHT)); + .isEqualTo(new AiToneUsageResult(0, 5, 5, NEXT_MIDNIGHT)); } @Test - @DisplayName("PRO에 오늘 행이 없으면 0/10, 남은 10") - void proWithoutRow() { - Long userId = saveUser(Plan.PRO); + @DisplayName("오늘 4회 썼으면 4/5, 남은 1") + void usedFour() { + reserveTimes(4); assertThat(aiToneUsageQueryService.getTodayUsage(userId)) - .isEqualTo(new AiToneUsageResult(true, 0, 10, 10, NEXT_MIDNIGHT)); + .isEqualTo(new AiToneUsageResult(4, 5, 1, NEXT_MIDNIGHT)); } @Test - @DisplayName("PRO가 오늘 4회 썼으면 4/10, 남은 6") - void proUsedFour() { - Long userId = saveUser(Plan.PRO); - for (int i = 0; i < 4; i++) { - aiToneUsageCommandService.reserve(userId, 10); - } + @DisplayName("오늘 5회 모두 썼으면 5/5, 남은 0") + void usedAll() { + reserveTimes(5); assertThat(aiToneUsageQueryService.getTodayUsage(userId)) - .isEqualTo(new AiToneUsageResult(true, 4, 10, 6, NEXT_MIDNIGHT)); - } - - @Test - @DisplayName("같은 날 PRO로 3회 쓴 뒤 FREE가 되면 0/0/0, 다시 PRO가 되면 3/10, 남은 7") - void planChangedSameDay() { - Long userId = saveUser(Plan.PRO); - for (int i = 0; i < 3; i++) { - aiToneUsageCommandService.reserve(userId, 10); - } - - changePlanOverride(userId, null); - assertThat(aiToneUsageQueryService.getTodayUsage(userId)) - .isEqualTo(new AiToneUsageResult(false, 0, 0, 0, NEXT_MIDNIGHT)); - - changePlanOverride(userId, Plan.PRO); - assertThat(aiToneUsageQueryService.getTodayUsage(userId)) - .isEqualTo(new AiToneUsageResult(true, 3, 10, 7, NEXT_MIDNIGHT)); + .isEqualTo(new AiToneUsageResult(5, 5, 0, NEXT_MIDNIGHT)); } @Test @DisplayName("어제 사용량은 오늘 사용량에 들어가지 않는다") void yesterdayNotCounted() { - Long userId = saveUser(Plan.PRO); - aiToneUsageCommandService.reserve(userId, 10); + reserveTimes(1); clock.setTo(NOW.plusDays(1)); AiToneUsageResult result = aiToneUsageQueryService.getTodayUsage(userId); assertThat(result.usedCount()).isZero(); - assertThat(result.remaining()).isEqualTo(10); + assertThat(result.remaining()).isEqualTo(5); } @Test @DisplayName("23:59:59.999999의 리셋 시각은 다음 날 00:00, 자정이 되면 그다음 날 00:00") void resetAtBoundary() { - Long userId = saveUser(Plan.PRO); - clock.setTo(LocalDateTime.of(2026, 10, 11, 23, 59, 59, 999_999_000)); assertThat(aiToneUsageQueryService.getTodayUsage(userId).resetAt()).isEqualTo(NEXT_MIDNIGHT); @@ -132,35 +102,10 @@ void resetAtBoundary() { assertThat(aiToneUsageQueryService.getTodayUsage(userId).resetAt()).isEqualTo(NEXT_MIDNIGHT.plusDays(1)); } - @Test - @DisplayName("없는 회원이면 USER404_1") - void missingUser() { - assertThatThrownBy(() -> aiToneUsageQueryService.getTodayUsage(-1L)) - .isInstanceOf(BusinessException.class) - .extracting("resultCode") - .isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - - @Test - @DisplayName("탈퇴 회원이면 USER404_1") - void deletedUser() { - Long userId = userRepository.save( - UserFixture.deletedUser("deleted@sniptext.com", Plan.PRO, LocalDateTime.of(2026, 10, 1, 0, 0))).getId(); - - assertThatThrownBy(() -> aiToneUsageQueryService.getTodayUsage(userId)) - .isInstanceOf(BusinessException.class) - .extracting("resultCode") - .isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - - private Long saveUser(Plan planOverride) { - return userRepository.save(UserFixture.userWithPlanOverride("usage@sniptext.com", planOverride)).getId(); - } - - /** User에는 플랜 지정값을 바꾸는 메서드가 없어(결제 도입 전에는 DB에서 바꾼다) 테스트도 SQL로 바꾼다 */ - private void changePlanOverride(Long userId, Plan planOverride) { - jdbcTemplate.update("UPDATE users SET plan_override = ? WHERE id = ?", - planOverride == null ? null : planOverride.name(), userId); + private void reserveTimes(int times) { + for (int i = 0; i < times; i++) { + aiToneUsageCommandService.reserve(userId); + } } } diff --git a/src/test/java/com/sniptext/domain/plan/service/PlanQueryServiceTest.java b/src/test/java/com/sniptext/domain/plan/service/PlanQueryServiceTest.java deleted file mode 100644 index 698e358..0000000 --- a/src/test/java/com/sniptext/domain/plan/service/PlanQueryServiceTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.sniptext.domain.plan.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -import java.util.Optional; - -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 com.sniptext.domain.plan.service.dto.result.PlanResult; -import com.sniptext.domain.user.entity.Plan; -import com.sniptext.domain.user.exception.UserErrorCode; -import com.sniptext.domain.user.service.UserQueryService; -import com.sniptext.global.exception.BusinessException; - -@ExtendWith(MockitoExtension.class) -class PlanQueryServiceTest { - - private static final Long USER_ID = 1L; - - @Mock - private UserQueryService userQueryService; - - @InjectMocks - private PlanQueryService planQueryService; - - @Test - @DisplayName("플랜 지정값이 없으면 FREE, 템플릿 5개, AI 하루 0회") - void noOverride() { - given(userQueryService.findPlanOverride(USER_ID)).willReturn(Optional.empty()); - - assertThat(planQueryService.getCurrentPlan(USER_ID)).isEqualTo(new PlanResult(Plan.FREE, 5, 0)); - } - - @Test - @DisplayName("플랜 지정값이 FREE면 FREE, 템플릿 5개, AI 하루 0회") - void freeOverride() { - given(userQueryService.findPlanOverride(USER_ID)).willReturn(Optional.of(Plan.FREE)); - - assertThat(planQueryService.getCurrentPlan(USER_ID)).isEqualTo(new PlanResult(Plan.FREE, 5, 0)); - } - - @Test - @DisplayName("플랜 지정값이 PRO면 PRO, 템플릿 50개, AI 하루 10회") - void proOverride() { - given(userQueryService.findPlanOverride(USER_ID)).willReturn(Optional.of(Plan.PRO)); - - assertThat(planQueryService.getCurrentPlan(USER_ID)).isEqualTo(new PlanResult(Plan.PRO, 50, 10)); - } - - @Test - @DisplayName("회원이 없으면 USER404_1") - void missingUser() { - given(userQueryService.findPlanOverride(USER_ID)) - .willThrow(new BusinessException(UserErrorCode.USER_NOT_FOUND)); - - assertThatThrownBy(() -> planQueryService.getCurrentPlan(USER_ID)) - .isInstanceOf(BusinessException.class) - .extracting("resultCode") - .isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - -} diff --git a/src/test/java/com/sniptext/domain/template/controller/TemplateControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TemplateControllerTest.java index 8934db5..0a8441e 100644 --- a/src/test/java/com/sniptext/domain/template/controller/TemplateControllerTest.java +++ b/src/test/java/com/sniptext/domain/template/controller/TemplateControllerTest.java @@ -30,7 +30,6 @@ import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.repository.TemplateRepository; -import com.sniptext.domain.user.entity.Plan; import com.sniptext.domain.user.entity.User; import com.sniptext.fixture.TemplateFixture; import com.sniptext.fixture.UserFixture; @@ -72,7 +71,7 @@ class TemplateControllerTest extends IntegrationTestSupport { @BeforeEach void setUp() { clock.setTo(FIXED); - userId = saveUser("free@sniptext.com", null); + userId = saveUser("owner@sniptext.com"); } @Test @@ -176,25 +175,25 @@ void trashedShortcutReusable() throws Exception { @Test @DisplayName("다른 회원이 쓰는 단축어로는 저장할 수 있다") void otherUsersShortcut() throws Exception { - Long otherUserId = saveUser("other@sniptext.com", null); + Long otherUserId = saveUser("other@sniptext.com"); templateRepository.saveAndFlush(TemplateFixture.template(otherUserId, "/견적")); create(userId, TITLE, "/견적", CONTENT).andExpect(status().isCreated()); } @Test - @DisplayName("FREE는 4개에서 5번째를 저장하고, 6번째는 TEMPLATE403_1") - void freeLimit() throws Exception { - saveTemplates(userId, 4); + @DisplayName("49개에서 50번째를 저장하고, 51번째는 TEMPLATE403_1") + void limit() throws Exception { + saveTemplates(userId, 49); - create(userId, TITLE, "/다섯째", CONTENT).andExpect(status().isCreated()); - expectError(create(userId, TITLE, "/여섯째", CONTENT), 403, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."); + create(userId, TITLE, "/쉰째", CONTENT).andExpect(status().isCreated()); + expectError(create(userId, TITLE, "/쉰한째", CONTENT), 403, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."); } @Test @DisplayName("한도에 도달했고 단축어도 겹치면 한도 에러(TEMPLATE403_1)가 먼저다") void limitBeforeDuplicate() throws Exception { - saveTemplates(userId, 5); + saveTemplates(userId, 50); expectError(create(userId, TITLE, "/t1", CONTENT), 403, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."); } @@ -202,7 +201,7 @@ void limitBeforeDuplicate() throws Exception { @Test @DisplayName("휴지통 템플릿은 한도 개수에 들어가지 않는다") void trashNotCounted() throws Exception { - saveTemplates(userId, 5); + saveTemplates(userId, 50); templateRepository.findAll().stream() .filter(template -> template.getUserId().equals(userId)) .findFirst() @@ -210,17 +209,7 @@ void trashNotCounted() throws Exception { .moveToTrash(FIXED); templateRepository.flush(); - create(userId, TITLE, "/다섯째", CONTENT).andExpect(status().isCreated()); - } - - @Test - @DisplayName("PRO(plan_override)는 49개에서 50번째를 저장하고, 51번째는 TEMPLATE403_1") - void proLimit() throws Exception { - Long proUserId = saveUser("pro@sniptext.com", Plan.PRO); - saveTemplates(proUserId, 49); - - create(proUserId, TITLE, "/쉰째", CONTENT).andExpect(status().isCreated()); - expectError(create(proUserId, TITLE, "/쉰한째", CONTENT), 403, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."); + create(userId, TITLE, "/쉰째", CONTENT).andExpect(status().isCreated()); } @Test @@ -264,8 +253,8 @@ private void saveTemplates(Long ownerId, int count) { templateRepository.flush(); } - private Long saveUser(String email, Plan planOverride) { - User user = UserFixture.userWithPlanOverride(email, planOverride); + 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/TemplateCommandServiceTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java index 15de786..6cecddf 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java @@ -33,8 +33,6 @@ import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; -import com.sniptext.domain.plan.service.PlanQueryService; -import com.sniptext.domain.plan.service.dto.result.PlanResult; import com.sniptext.domain.template.entity.Shortcut; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.exception.TemplateErrorCode; @@ -43,9 +41,9 @@ import com.sniptext.domain.template.service.dto.command.TemplateCreateFromGalleryCommand; import com.sniptext.domain.template.service.dto.result.TemplateCreateResult; import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; -import com.sniptext.domain.user.entity.Plan; import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.policy.UsageLimits; @ExtendWith(MockitoExtension.class) class TemplateCommandServiceTest { @@ -53,7 +51,6 @@ class TemplateCommandServiceTest { private static final Long USER_ID = 1L; private static final Long TEMPLATE_ID = 12L; private static final LocalDateTime NOW = LocalDateTime.of(2026, 10, 7, 10, 30); - private static final PlanResult FREE = new PlanResult(Plan.FREE, 5, 0); private static final String CONTENT = "안녕하세요 {{고객명}}님,\n견적은 {{금액}}원입니다. {{고객명}}님 {{고객 명}}"; private static final TemplateCreateCommand COMMAND = new TemplateCreateCommand(USER_ID, " 견적 안내 ", "/Quote", CONTENT); @@ -64,9 +61,6 @@ class TemplateCommandServiceTest { @Mock private UserCommandService userCommandService; - @Mock - private PlanQueryService planQueryService; - @Mock private TransactionTemplate transactionTemplate; @@ -76,7 +70,7 @@ class TemplateCommandServiceTest { @Test @DisplayName("값 객체 규칙을 거쳐 저장하고, 저장된 값과 첫 등장 순·중복 제거한 변수를 돌려준다") void create() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenSaveSucceeds(); TemplateCreateResult result = templateCommandService.create(COMMAND); @@ -89,11 +83,10 @@ void create() { List.of("고객명", "금액"), null, NOW, NOW)); } - @ParameterizedTest - @CsvSource({"FREE, 5, 4", "PRO, 50, 49"}) - @DisplayName("활성 개수가 한도보다 하나 적으면 저장한다") - void belowLimit(Plan plan, int limit, long activeCount) { - givenActiveCount(new PlanResult(plan, limit, 0), activeCount); + @Test + @DisplayName("활성 개수가 49개면 50번째를 저장한다") + void belowLimit() { + givenActiveCount(49); givenSaveSucceeds(); TemplateCreateResult result = templateCommandService.create(COMMAND); @@ -101,11 +94,10 @@ void belowLimit(Plan plan, int limit, long activeCount) { assertThat(result.templateId()).isEqualTo(TEMPLATE_ID); } - @ParameterizedTest - @CsvSource({"FREE, 5, 5", "PRO, 50, 50"}) - @DisplayName("활성 개수가 한도에 도달했으면 TEMPLATE403_1이고 저장하지 않는다") - void limitReached(Plan plan, int limit, long activeCount) { - givenActiveCount(new PlanResult(plan, limit, 0), activeCount); + @Test + @DisplayName("활성 개수가 한도(50개)에 도달했으면 TEMPLATE403_1이고 저장하지 않는다") + void limitReached() { + givenActiveCount(50); assertThatThrownBy(() -> templateCommandService.create(COMMAND)) .isInstanceOf(BusinessException.class) @@ -115,16 +107,15 @@ void limitReached(Plan plan, int limit, long activeCount) { } @Test - @DisplayName("회원 행을 잠근 뒤에 플랜 한도와 개수, 단축어 중복을 확인하고 저장한다") + @DisplayName("회원 행을 잠근 뒤에 한도 개수, 단축어 중복을 확인하고 저장한다") void locksBeforeLimitCheck() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenSaveSucceeds(); templateCommandService.create(COMMAND); - InOrder inOrder = inOrder(userCommandService, planQueryService, templateRepository); + InOrder inOrder = inOrder(userCommandService, templateRepository); inOrder.verify(userCommandService).lockUser(USER_ID); - inOrder.verify(planQueryService).getCurrentPlan(USER_ID); inOrder.verify(templateRepository).countByUserIdAndDeletedAtIsNull(USER_ID); inOrder.verify(templateRepository).existsByUserIdAndActiveShortcutKey(USER_ID, "/quote"); inOrder.verify(templateRepository).saveAndFlush(any()); @@ -133,7 +124,7 @@ void locksBeforeLimitCheck() { @Test @DisplayName("활성 템플릿에 같은 단축어 키가 있으면 저장 전에 TEMPLATE409_1이고 저장하지 않는다") void duplicateShortcutBeforeSave() { - givenActiveCount(FREE, 0); + givenActiveCount(0); given(templateRepository.existsByUserIdAndActiveShortcutKey(USER_ID, "/quote")).willReturn(true); assertThatThrownBy(() -> templateCommandService.create(COMMAND)) @@ -146,7 +137,7 @@ void duplicateShortcutBeforeSave() { @Test @DisplayName("미리 조회를 통과했어도 저장에서 활성 단축어 유니크 위반이 나면 TEMPLATE409_1이고, 예외 메시지에 겹친 단축어가 남지 않는다") void duplicateShortcut() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenSaveFails(ConstraintKind.UNIQUE, "templates.uk_templates_user_active_shortcut"); assertThatThrownBy(() -> templateCommandService.create(COMMAND)) @@ -165,7 +156,7 @@ void duplicateShortcut() { }) @DisplayName("단축어 유니크(종류 UNIQUE, 이름 정확히 일치)가 아닌 무결성 위반은 바꾸지 않고 그대로 던진다 (코드 실수라 500)") void otherIntegrityViolation(ConstraintKind kind, String constraintName) { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenSaveFails(kind, constraintName); assertThatThrownBy(() -> templateCommandService.create(COMMAND)) @@ -192,7 +183,7 @@ void setUp() { @Test @DisplayName("충돌이 없으면 원본 단축어로 출처와 함께 저장하고 shortcutChanged는 false다") void noConflict() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenUsedKeys(GALLERY_COMMAND.shortcut()); givenSaveSucceeds(); @@ -209,7 +200,7 @@ void noConflict() { @Test @DisplayName("충돌이 있으면 비어 있는 첫 번호를 붙여 저장하고 shortcutChanged는 true다") void conflict() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenUsedKeys(GALLERY_COMMAND.shortcut(), "/견적", "/견적2"); givenSaveSucceeds(); @@ -222,7 +213,7 @@ void conflict() { @DisplayName("자모 분리형 원본은 글자가 같으므로 충돌이 없으면 shortcutChanged가 false이고 원본도 NFC로 돌려준다") void decomposedOriginalIsNotChanged() { String decomposed = Normalizer.normalize(SHORTCUT, Normalizer.Form.NFD); - givenActiveCount(FREE, 0); + givenActiveCount(0); givenUsedKeys(decomposed); givenSaveSucceeds(); @@ -237,7 +228,7 @@ void decomposedOriginalIsNotChanged() { @Test @DisplayName("활성 개수가 한도에 도달했으면 TEMPLATE403_1이고 단축어 조회와 저장을 하지 않는다") void limitReached() { - givenActiveCount(FREE, 5); + givenActiveCount(UsageLimits.TEMPLATE_LIMIT); assertThatThrownBy(() -> templateCommandService.createFromGallery(GALLERY_COMMAND)) .isInstanceOf(BusinessException.class) @@ -250,7 +241,7 @@ void limitReached() { @Test @DisplayName("후보가 모두 차면 TEMPLATE409_2이고 저장하지 않는다") void numbersExhausted() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenUsedKeys(GALLERY_COMMAND.shortcut(), Shortcut.create(GALLERY_COMMAND.shortcut()).candidateKeys().toArray(String[]::new)); @@ -264,15 +255,14 @@ void numbersExhausted() { @Test @DisplayName("회원 행을 잠근 뒤에 한도, 사용 중인 키를 확인하고 저장한다") void locksBeforeLimitCheck() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenUsedKeys(GALLERY_COMMAND.shortcut()); givenSaveSucceeds(); templateCommandService.createFromGallery(GALLERY_COMMAND); - InOrder inOrder = inOrder(userCommandService, planQueryService, templateRepository); + InOrder inOrder = inOrder(userCommandService, templateRepository); inOrder.verify(userCommandService).lockUser(USER_ID); - inOrder.verify(planQueryService).getCurrentPlan(USER_ID); inOrder.verify(templateRepository).countByUserIdAndDeletedAtIsNull(USER_ID); inOrder.verify(templateRepository).findActiveShortcutKeys(any(), any()); inOrder.verify(templateRepository).saveAndFlush(any()); @@ -281,7 +271,7 @@ void locksBeforeLimitCheck() { @Test @DisplayName("미리 조회를 통과했어도 저장에서 활성 단축어 유니크 위반이 나면 TEMPLATE409_1이고, 예외 메시지에 겹친 단축어가 남지 않는다") void duplicateShortcut() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenUsedKeys(GALLERY_COMMAND.shortcut()); givenSaveFails(ConstraintKind.UNIQUE, "templates.uk_templates_user_active_shortcut"); @@ -296,7 +286,7 @@ void duplicateShortcut() { @Test @DisplayName("단축어 유니크가 아닌 무결성 위반은 바꾸지 않고 그대로 던진다 (코드 실수라 500)") void otherIntegrityViolation() { - givenActiveCount(FREE, 0); + givenActiveCount(0); givenUsedKeys(GALLERY_COMMAND.shortcut()); givenSaveFails(ConstraintKind.FOREIGN_KEY, "templates.fk_templates_gallery"); @@ -312,8 +302,7 @@ private void givenUsedKeys(String shortcut, String... usedKeys) { } - private void givenActiveCount(PlanResult plan, long activeCount) { - given(planQueryService.getCurrentPlan(USER_ID)).willReturn(plan); + private void givenActiveCount(long activeCount) { given(templateRepository.countByUserIdAndDeletedAtIsNull(USER_ID)).willReturn(activeCount); } diff --git a/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java index b669e07..fd059e3 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java @@ -81,48 +81,42 @@ void sameShortcutAtOnce() throws Exception { } @Test - @DisplayName("FREE 4개 상태에서 다른 단축어로 동시에 2건이면 1건만 저장되고 1건은 TEMPLATE403_1, 최종 5개") + @DisplayName("49개 상태에서 다른 단축어로 동시에 2건이면 1건만 저장되고 1건은 TEMPLATE403_1, 최종 50개") void limitAtOnce() throws Exception { - for (int i = 1; i <= 4; i++) { - templateRepository.save(TemplateFixture.template(userId, "/t" + i)); - } + saveTemplates(49); List results = runAtOnce( () -> create("/다섯째"), () -> create("/여섯째")); assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TEMPLATE403_1"); - assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(5); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); } @Test - @DisplayName("FREE 4개 상태에서 갤러리 저장이 동시에 2건이면 1건만 저장되고 1건은 TEMPLATE403_1, 최종 5개") + @DisplayName("49개 상태에서 갤러리 저장이 동시에 2건이면 1건만 저장되고 1건은 TEMPLATE403_1, 최종 50개") void galleryLimitAtOnce() throws Exception { - for (int i = 1; i <= 4; i++) { - templateRepository.save(TemplateFixture.template(userId, "/t" + i)); - } + saveTemplates(49); List results = runAtOnce( () -> saveFromGallery("/다섯째"), () -> saveFromGallery("/여섯째")); assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TEMPLATE403_1"); - assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(5); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); } @Test - @DisplayName("FREE 4개 상태에서 새 템플릿 저장과 갤러리 저장이 동시에 오면 1건만 저장되고 1건은 TEMPLATE403_1, 최종 5개") + @DisplayName("49개 상태에서 새 템플릿 저장과 갤러리 저장이 동시에 오면 1건만 저장되고 1건은 TEMPLATE403_1, 최종 50개") void createAndGalleryLimitAtOnce() throws Exception { - for (int i = 1; i <= 4; i++) { - templateRepository.save(TemplateFixture.template(userId, "/t" + i)); - } + saveTemplates(49); List results = runAtOnce( () -> create("/다섯째"), () -> saveFromGallery("/여섯째")); assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TEMPLATE403_1"); - assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(5); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); } @Test @@ -137,6 +131,13 @@ void sameShortcutGalleryAtOnce() throws Exception { .containsExactlyInAnyOrder("/견적", "/견적2"); } + /** 단축어 /t1, /t2 ... 로 활성 템플릿을 미리 채운다 */ + private void saveTemplates(int count) { + for (int i = 1; i <= count; i++) { + templateRepository.save(TemplateFixture.template(userId, "/t" + i)); + } + } + private String create(String shortcut) { try { templateCommandService.create(new TemplateCreateCommand(userId, "견적 안내", shortcut, CONTENT)); diff --git a/src/test/java/com/sniptext/domain/template/service/TemplateCreateFromGalleryTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateCreateFromGalleryTest.java index f2deda0..bf6caaf 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateCreateFromGalleryTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateCreateFromGalleryTest.java @@ -23,14 +23,11 @@ import org.springframework.transaction.support.TransactionTemplate; import com.sniptext.domain.gallery.repository.GalleryTemplateRepository; -import com.sniptext.domain.plan.service.PlanQueryService; -import com.sniptext.domain.plan.service.dto.result.PlanResult; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.exception.TemplateErrorCode; import com.sniptext.domain.template.repository.TemplateRepository; import com.sniptext.domain.template.service.dto.command.TemplateCreateFromGalleryCommand; import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; -import com.sniptext.domain.user.entity.Plan; import com.sniptext.domain.user.repository.UserRepository; import com.sniptext.fixture.GalleryTemplateFixture; import com.sniptext.fixture.TemplateFixture; @@ -47,8 +44,6 @@ class TemplateCreateFromGalleryTest extends IntegrationTestSupport { private static final String TITLE = "프리랜서 견적 안내"; private static final String CONTENT = "안녕하세요 {{고객명}}님"; private static final LocalDateTime TRASHED_AT = LocalDateTime.of(2026, 10, 25, 10, 0); - /** 2~99 전부를 채우려면 플랜 한도(PRO 50)보다 많은 행이 필요해 한도만 넓혀 둔다 */ - private static final PlanResult WIDE_LIMIT = new PlanResult(Plan.PRO, 200, 10); @Autowired private TemplateCommandService templateCommandService; @@ -56,9 +51,6 @@ class TemplateCreateFromGalleryTest extends IntegrationTestSupport { @MockitoSpyBean private TemplateRepository templateRepository; - @MockitoSpyBean - private PlanQueryService planQueryService; - @Autowired private UserRepository userRepository; @@ -74,7 +66,7 @@ class TemplateCreateFromGalleryTest extends IntegrationTestSupport { @BeforeEach void setUp() { - userId = saveFreeUser("gallery-save@sniptext.com"); + userId = saveUser("gallery-save@sniptext.com"); galleryTemplateId = galleryTemplateRepository.save(GalleryTemplateFixture.published(1)).getId(); } @@ -116,39 +108,39 @@ void sameGalleryTemplateRepeatedly() { } @Test - @DisplayName("FREE 4개에서 5개째는 저장된다") + @DisplayName("49개에서 50개째는 저장된다") void belowLimit() { - saveTemplates(userId, "/t1", "/t2", "/t3", "/t4"); + saveNumberedTemplates(49); TemplateShortcutAssignResult result = save("/견적"); assertThat(result.templateId()).isNotNull(); - assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(5); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); } @Test - @DisplayName("FREE 5개에서 6개째는 TEMPLATE403_1이고 행이 늘지 않는다") + @DisplayName("50개에서 51개째는 TEMPLATE403_1이고 행이 늘지 않는다") void limitReached() { - saveTemplates(userId, "/t1", "/t2", "/t3", "/t4", "/t5"); + saveNumberedTemplates(50); assertErrorCode(() -> save("/견적"), TemplateErrorCode.TEMPLATE_LIMIT_REACHED); - assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(5); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); } @Test @DisplayName("휴지통 템플릿은 한도에 세지 않는다") void trashNotCountedInLimit() { - saveTemplates(userId, "/t1", "/t2", "/t3", "/t4"); - saveTrashed(userId, "/t5"); + saveNumberedTemplates(49); + saveTrashed(userId, "/휴지통"); assertThat(save("/견적").templateId()).isNotNull(); } + /** 2~99를 모두 채우면 활성 99개라 한도(50)도 이미 넘는다. 실제 행 그대로 한도가 먼저 걸린다 */ @Test @DisplayName("한도와 자동 번호 소진이 겹치면 한도가 먼저다") void limitBeforeExhausted() { - willReturn(new PlanResult(Plan.PRO, 99, 10)).given(planQueryService).getCurrentPlan(userId); fillOriginalAndNumbersUpTo("/견적", 99); assertErrorCode(() -> save("/견적"), TemplateErrorCode.TEMPLATE_LIMIT_REACHED); @@ -197,7 +189,7 @@ void caseInsensitiveNumberedConflict() { @Test @DisplayName("다른 회원의 같은 단축어는 영향이 없다") void otherUserShortcutIgnored() { - Long otherUserId = saveFreeUser("gallery-other@sniptext.com"); + Long otherUserId = saveUser("gallery-other@sniptext.com"); saveTemplates(otherUserId, "/견적"); TemplateShortcutAssignResult result = save("/견적"); @@ -226,13 +218,11 @@ void numberedShortcut(String original, String expected) { @Test @DisplayName("번호가 두 자리가 되면 원본을 한 글자 더 잘라 / 뒤 10자를 맞춘다") void twoDigitNumberTruncates() { - Long proUserId = savePlanUser("gallery-pro@sniptext.com", Plan.PRO); List shortcuts = new ArrayList<>(List.of("/견적요청서작성안내문")); IntStream.rangeClosed(2, 9).forEach(number -> shortcuts.add("/견적요청서작성안내" + number)); - saveTemplates(proUserId, shortcuts.toArray(String[]::new)); + saveTemplates(userId, shortcuts.toArray(String[]::new)); - TemplateShortcutAssignResult result = templateCommandService.createFromGallery( - new TemplateCreateFromGalleryCommand(proUserId, galleryTemplateId, TITLE, "/견적요청서작성안내문", CONTENT)); + TemplateShortcutAssignResult result = save("/견적요청서작성안내문"); assertThat(result.shortcut()).isEqualTo("/견적요청서작성안10"); assertThat(result.shortcutChanged()).isTrue(); @@ -241,8 +231,8 @@ void twoDigitNumberTruncates() { @Test @DisplayName("98번까지 차 있으면 마지막 번호 99를 쓴다") void lastNumber() { - willReturn(WIDE_LIMIT).given(planQueryService).getCurrentPlan(userId); fillOriginalAndNumbersUpTo("/견적", 98); + givenActiveCountBelowLimit(); assertThat(save("/견적").shortcut()).isEqualTo("/견적99"); } @@ -250,12 +240,13 @@ void lastNumber() { @Test @DisplayName("2~99가 모두 차면 TEMPLATE409_2이고 행이 늘지 않는다") void numbersExhausted() { - willReturn(WIDE_LIMIT).given(planQueryService).getCurrentPlan(userId); fillOriginalAndNumbersUpTo("/견적", 99); + givenActiveCountBelowLimit(); assertErrorCode(() -> save("/견적"), TemplateErrorCode.SHORTCUT_NUMBER_EXHAUSTED); - assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(99); + assertThat(templateRepository.findAll().stream().filter(template -> template.getUserId().equals(userId))) + .hasSize(99); } @Test @@ -291,16 +282,25 @@ private TemplateShortcutAssignResult save(String shortcut) { new TemplateCreateFromGalleryCommand(userId, galleryTemplateId, TITLE, shortcut, CONTENT)); } - private Long saveFreeUser(String email) { - return savePlanUser(email, null); - } - - private Long savePlanUser(String email, Plan plan) { - Long id = userRepository.save(UserFixture.userWithPlanOverride(email, plan)).getId(); + private Long saveUser(String email) { + Long id = userRepository.save(UserFixture.user(email)).getId(); userIds.add(id); return id; } + /** + * 2~99를 실제 행으로 채우면 활성 개수가 한도(50)를 넘어 번호 규칙까지 가지 못한다. + * 개수 조회만 0으로 바꿔 한도를 통과시키고, 사용 중인 키 조회와 저장은 실제 MySQL로 둔다. + */ + private void givenActiveCountBelowLimit() { + willReturn(0L).given(templateRepository).countByUserIdAndDeletedAtIsNull(userId); + } + + /** 단축어 /t1, /t2 ... 로 활성 템플릿을 미리 채운다 */ + private void saveNumberedTemplates(int count) { + IntStream.rangeClosed(1, count).forEach(number -> saveTemplates(userId, "/t" + number)); + } + private void saveTemplates(Long ownerId, String... shortcuts) { for (String shortcut : shortcuts) { templateRepository.save(TemplateFixture.template(ownerId, shortcut)); diff --git a/src/test/java/com/sniptext/domain/user/entity/UserTest.java b/src/test/java/com/sniptext/domain/user/entity/UserTest.java index 9668357..d532dc9 100644 --- a/src/test/java/com/sniptext/domain/user/entity/UserTest.java +++ b/src/test/java/com/sniptext/domain/user/entity/UserTest.java @@ -18,15 +18,13 @@ class UserTest { private static final String PROFILE = "https://lh3.googleusercontent.com/a/sample"; @Test - @DisplayName("회원을 만들면 받은 값이 그대로 들어가고 정렬은 최근 사용순, 플랜 지정값·탈퇴 시각은 없다") + @DisplayName("회원을 만들면 받은 값이 그대로 들어가고 로그인 시각·탈퇴 시각은 없다") void create() { User user = User.create(EMAIL, NICKNAME, PROFILE); assertThat(user.getEmail()).isEqualTo(EMAIL); assertThat(user.getNickname()).isEqualTo(NICKNAME); assertThat(user.getProfileImageUrl()).isEqualTo(PROFILE); - assertThat(user.getTemplateSort()).isEqualTo(TemplateSort.RECENT_USED); - assertThat(user.getPlanOverride()).isNull(); assertThat(user.getLastLoginAt()).isNull(); assertThat(user.getDeletedAt()).isNull(); } diff --git a/src/test/java/com/sniptext/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/sniptext/domain/user/service/UserQueryServiceTest.java deleted file mode 100644 index 5985586..0000000 --- a/src/test/java/com/sniptext/domain/user/service/UserQueryServiceTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.sniptext.domain.user.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.time.LocalDateTime; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.transaction.annotation.Transactional; - -import com.sniptext.domain.user.entity.Plan; -import com.sniptext.domain.user.entity.User; -import com.sniptext.domain.user.exception.UserErrorCode; -import com.sniptext.domain.user.repository.UserRepository; -import com.sniptext.fixture.UserFixture; -import com.sniptext.global.exception.BusinessException; -import com.sniptext.support.IntegrationTestSupport; - -@Transactional -class UserQueryServiceTest extends IntegrationTestSupport { - - @Autowired - private UserQueryService userQueryService; - - @Autowired - private UserRepository userRepository; - - @Test - @DisplayName("플랜 지정값이 있으면 그 값을, 없으면 빈 값을 돌려준다") - void findPlanOverride() { - User pro = userRepository.save(UserFixture.userWithPlanOverride("pro@sniptext.com", Plan.PRO)); - User none = userRepository.save(UserFixture.user("none@sniptext.com")); - - assertThat(userQueryService.findPlanOverride(pro.getId())).contains(Plan.PRO); - assertThat(userQueryService.findPlanOverride(none.getId())).isEmpty(); - } - - @Test - @DisplayName("없는 회원의 플랜 지정값을 찾으면 USER404_1") - void missingUser() { - assertThatThrownBy(() -> userQueryService.findPlanOverride(-1L)) - .isInstanceOf(BusinessException.class) - .extracting("resultCode") - .isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - - @Test - @DisplayName("탈퇴 회원의 플랜 지정값을 찾으면 USER404_1") - void deletedUser() { - User deleted = userRepository.save( - UserFixture.deletedUser("deleted@sniptext.com", LocalDateTime.of(2026, 10, 1, 0, 0))); - - assertThatThrownBy(() -> userQueryService.findPlanOverride(deleted.getId())) - .isInstanceOf(BusinessException.class) - .extracting("resultCode") - .isEqualTo(UserErrorCode.USER_NOT_FOUND); - } - -} diff --git a/src/test/java/com/sniptext/fixture/UserFixture.java b/src/test/java/com/sniptext/fixture/UserFixture.java index 778b82b..164a308 100644 --- a/src/test/java/com/sniptext/fixture/UserFixture.java +++ b/src/test/java/com/sniptext/fixture/UserFixture.java @@ -4,13 +4,11 @@ import org.springframework.test.util.ReflectionTestUtils; -import com.sniptext.domain.user.entity.Plan; import com.sniptext.domain.user.entity.User; /** * 테스트용 회원. 생성 인자가 바뀌면 여기만 고친다. - * 플랜 지정값과 탈퇴 시각은 아직 바꾸는 기능(결제·탈퇴)이 없어서 필드를 직접 넣는다. - * 그 기능이 생기면 엔티티 메서드로 바꾼다. + * 탈퇴 시각은 아직 바꾸는 기능(탈퇴)이 없어서 필드를 직접 넣는다. 그 기능이 생기면 엔티티 메서드로 바꾼다. */ public final class UserFixture { @@ -29,20 +27,8 @@ public static User user(String email) { return User.create(email, NICKNAME, PROFILE_IMAGE_URL); } - /** plan_override를 지정한 회원. 결제 도입 전 테스트 계정을 PRO로 만드는 것과 같다 */ - public static User userWithPlanOverride(String email, Plan planOverride) { - User user = user(email); - ReflectionTestUtils.setField(user, "planOverride", planOverride); - return user; - } - public static User deletedUser(String email, LocalDateTime deletedAt) { - return deletedUser(email, null, deletedAt); - } - - /** 플랜 지정값이 있는 탈퇴 회원. PRO여도 탈퇴했으면 회원을 찾지 못하는지 볼 때 쓴다 */ - public static User deletedUser(String email, Plan planOverride, LocalDateTime deletedAt) { - User user = userWithPlanOverride(email, planOverride); + User user = user(email); ReflectionTestUtils.setField(user, "deletedAt", deletedAt); return user; } diff --git a/src/test/java/com/sniptext/global/security/DevTokenLocalProfileTest.java b/src/test/java/com/sniptext/global/security/DevTokenLocalProfileTest.java index 9db23d8..1daa332 100644 --- a/src/test/java/com/sniptext/global/security/DevTokenLocalProfileTest.java +++ b/src/test/java/com/sniptext/global/security/DevTokenLocalProfileTest.java @@ -31,14 +31,14 @@ class DevTokenLocalProfileTest extends IntegrationTestSupport { private JdbcTemplate jdbcTemplate; @Test - @DisplayName("시드 회원 2명이 들어간다: 1번은 플랜 지정 없음(FREE), 2번은 PRO") + @DisplayName("시드 회원 2명이 들어간다: 1번·2번 모두 일반 회원") void seedUsers() { List> users = jdbcTemplate.queryForList( - "SELECT id, email, plan_override FROM users WHERE email LIKE '%@sniptext.local' ORDER BY id"); + "SELECT id, email FROM users WHERE email LIKE '%@sniptext.local' ORDER BY id"); assertThat(users).hasSize(2); - assertThat(users.get(0)).containsEntry("id", 1L).containsEntry("plan_override", null); - assertThat(users.get(1)).containsEntry("id", 2L).containsEntry("plan_override", "PRO"); + assertThat(users.get(0)).containsEntry("id", 1L).containsEntry("email", "user1@sniptext.local"); + assertThat(users.get(1)).containsEntry("id", 2L).containsEntry("email", "user2@sniptext.local"); } @Test