diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7606011..0c44771 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -67,12 +67,14 @@ jobs: V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }} V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }} V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }} + V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} + V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: 2222 - envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL + envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD script: | echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin @@ -98,6 +100,8 @@ jobs: printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env + printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env + printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env chmod +x ~/scripts/deploy.sh bash ~/scripts/deploy.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 956a7e9..a16f8a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,10 @@ name: CI on: pull_request: - branches: [main] + branches: [main, develop] + +permissions: + contents: read jobs: test: diff --git a/README.md b/README.md index ff95b64..f2d1299 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,9 @@ open -a Docker && docker start redis # Docker 데몬이 꺼져 있으면 먼 배포 아키텍처

-- **CI** (`ci.yml`): PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증 -- **CD** (`cd.yml`): `main` 브랜치 push 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행 +- 작업 브랜치 → `develop` PR/병합 → (여러 작업 누적 후) `develop` → `main` PR/병합 순으로 운영 서버에 배포됩니다. +- **CI** (`ci.yml`): `main`/`develop`으로의 PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증 +- **CD** (`cd.yml`): `main` 브랜치 push(= `develop` → `main` 병합) 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행 - Nginx가 `be-blue`(:8080)/`be-green`(:8081) 중 활성 슬롯으로만 트래픽을 전달하고, Redis는 두 슬롯이 공유합니다. Data Pipeline(`data`)도 같은 Docker Compose에 포함되어 별도 인스턴스 없이 함께 배포됩니다. - 배포 시 standby 슬롯에 새 이미지를 pull → `/actuator/health` 체크 통과 → Nginx upstream 전환 → 이전 슬롯 정지 순으로 무중단 배포합니다. - DB는 별도 Lightsail 인스턴스(Managed PostgreSQL)로 분리되어 두 슬롯이 공통으로 바라보고, 4xx/5xx 에러·공연 데이터 수집 결과·문의 접수는 각각 Discord Webhook으로 알림됩니다. diff --git a/build.gradle b/build.gradle index 78e0038..94fccae 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.flywaydb:flyway-database-postgresql' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' @@ -40,6 +43,7 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/Coming/Backend/BackendApplication.java b/src/main/java/com/Coming/Backend/BackendApplication.java index 43fa2ad..1153006 100644 --- a/src/main/java/com/Coming/Backend/BackendApplication.java +++ b/src/main/java/com/Coming/Backend/BackendApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java index acb8070..7d9421e 100644 --- a/src/main/java/com/Coming/Backend/auth/entity/User.java +++ b/src/main/java/com/Coming/Backend/auth/entity/User.java @@ -9,7 +9,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -51,26 +50,13 @@ public class User extends BaseTimeEntity { @Column(name = "birth_year") private Integer birthYear; - @Column(name = "agreed_terms") - private Boolean agreedTerms; - - @Column(name = "agreed_privacy") - private Boolean agreedPrivacy; - @Column(name = "agreed_marketing") private Boolean agreedMarketing; - @Column(name = "agreed_at") - private LocalDateTime agreedAt; - - public void completeRegistration(String nickname, int birthYear, - boolean agreedTerms, boolean agreedPrivacy, boolean agreedMarketing) { + public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) { this.nickname = nickname; this.birthYear = birthYear; - this.agreedTerms = agreedTerms; - this.agreedPrivacy = agreedPrivacy; this.agreedMarketing = agreedMarketing; - this.agreedAt = LocalDateTime.now(); this.role = UserRole.USER; } @@ -91,14 +77,15 @@ public void updateEmail(String email) { this.email = email; } + public boolean hasNoEmail() { + return email == null || email.isBlank(); + } + public void reactivate() { this.status = UserStatus.ACTIVE; this.role = UserRole.PENDING; this.nickname = null; this.birthYear = null; - this.agreedTerms = null; - this.agreedPrivacy = null; this.agreedMarketing = null; - this.agreedAt = null; } } diff --git a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java index cf1542f..d462660 100644 --- a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java +++ b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.Coming.Backend.auth.repository; import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; @@ -14,4 +15,6 @@ public interface UserRepository extends JpaRepository { List findAllByIdIn(Collection ids); boolean existsByNickname(String nickname); + + List findByStatus(UserStatus status); } diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 9439be4..8d5a6b3 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -25,6 +25,14 @@ import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -43,6 +51,8 @@ public class AuthService { private final UserFollowArtistRepository userFollowArtistRepository; private final UserConcertCalendarRepository userConcertCalendarRepository; private final InquiryRepository inquiryRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final UserPolicyAgreementRepository userPolicyAgreementRepository; /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. @@ -128,11 +138,11 @@ public TokenResponse register(Long userId, RegisterRequest request) { user.completeRegistration( request.nickname(), request.birthYear(), - request.agreedTerms(), - request.agreedPrivacy(), Boolean.TRUE.equals(request.agreedMarketing()) ); try { + recordPolicyAgreement(userId, PolicyType.TERMS); + recordPolicyAgreement(userId, PolicyType.PRIVACY); userRepository.flush(); } catch (DataIntegrityViolationException e) { throw new NicknameDuplicateException(); @@ -142,6 +152,25 @@ public TokenResponse register(Long userId, RegisterRequest request) { return new TokenResponse(accessToken); } + /** + * 현재 시행 중인 정책 버전에 대한 사용자 동의 이력을 기록한다. 이미 동의 이력이 있으면 건너뛴다(탈퇴 후 재가입 대비). + */ + private void recordPolicyAgreement(Long userId, PolicyType type) { + PolicyDocument currentPolicy = policyDocumentRepository + .findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc(type, LocalDate.now()) + .orElseThrow(PolicyNotFoundException::new); + if (userPolicyAgreementRepository.existsByUserIdAndPolicyId(userId, currentPolicy.getId())) { + return; + } + userPolicyAgreementRepository.save( + UserPolicyAgreement.builder() + .userId(userId) + .policyId(currentPolicy.getId()) + .agreedAt(LocalDateTime.now()) + .build() + ); + } + private void validateRegisterRequest(RegisterRequest request) { if (!Boolean.TRUE.equals(request.agreedTerms()) || !Boolean.TRUE.equals(request.agreedPrivacy())) { throw new TermsNotAgreedException(); diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index bf2a072..8e27d57 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -118,7 +118,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/artists/**", "/api/concerts/**", "/api/releases/**", - "/api/calendar" + "/api/calendar", + "/api/mentions/search", + "/api/posts/**", + "/api/search", + "/api/entities/**" ).permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/auth/register").hasRole("PENDING") diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index dfd5f65..29abd30 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -49,12 +49,28 @@ public enum ErrorCode { INVALID_INQUIRY_STATUS(HttpStatus.BAD_REQUEST, "변경 불가능한 문의 상태입니다."), TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 대상입니다."), + // Post + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + ALREADY_RECOMMENDED(HttpStatus.CONFLICT, "이미 추천한 게시글입니다."), + NOT_RECOMMENDED(HttpStatus.BAD_REQUEST, "추천하지 않은 게시글입니다."), + POST_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "본문은 10000자를 초과할 수 없습니다."), + + // Comment + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + INVALID_REPLY_DEPTH(HttpStatus.BAD_REQUEST, "답글에는 답글을 작성할 수 없습니다."), + ALREADY_LIKED(HttpStatus.CONFLICT, "이미 좋아요한 댓글입니다."), + NOT_LIKED(HttpStatus.BAD_REQUEST, "좋아요하지 않은 댓글입니다."), + // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."), PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."), + // Policy + POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."), + POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 정책입니다."), + // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다."), diff --git a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java index b856be4..559824b 100644 --- a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java +++ b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java @@ -35,6 +35,10 @@ public interface ConcertRepository extends JpaRepository { @Query("UPDATE Concert c SET c.viewCount = c.viewCount + 1 WHERE c.id = :id") void incrementViewCount(@Param("id") Long id); + @Query(value = "SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC, c.id DESC", + countQuery = "SELECT COUNT(c) FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q") + Page searchByTitleForMention(@Param("hidden") Collection hidden, @Param("q") String q, Pageable pageable); + @Query("SELECT c FROM Concert c WHERE c.startDate <= :lastDay AND c.endDate >= :firstDay AND c.status NOT IN :hidden ORDER BY c.startDate ASC") List findByDateRange(@Param("firstDay") LocalDate firstDay, @Param("lastDay") LocalDate lastDay, @Param("hidden") Collection hidden); diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java new file mode 100644 index 0000000..7077595 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java @@ -0,0 +1,54 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class PolicyNotificationBatchConfig { + + private static final int CHUNK_SIZE = 20; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + private final PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; + private final PolicyNotificationMailProcessor policyNotificationMailProcessor; + private final PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Bean + public Job policyNotificationJob() { + return new JobBuilder("policyNotificationJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(targetCreationStep()) + .next(mailSendStep()) + .build(); + } + + @Bean + public Step targetCreationStep() { + return new StepBuilder("targetCreationStep", jobRepository) + .tasklet(policyNotificationTargetCreationTasklet, transactionManager) + .build(); + } + + @Bean + public Step mailSendStep() { + return new StepBuilder("mailSendStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(policyNotificationPendingTargetReader) + .processor(policyNotificationMailProcessor) + .writer(policyNotificationTargetWriter) + .transactionManager(transactionManager) + .build(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java new file mode 100644 index 0000000..784d555 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java @@ -0,0 +1,51 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 실행한다. + * AFTER_COMMIT 콜백과 같은 스레드에서 배치(자체 트랜잭션 포함)를 직접 실행하면 커밋 중이던 + * 트랜잭션 동기화 상태와 충돌해 JobInterruptedException이 발생하므로, 별도 스레드에서 실행하고 + * join으로 대기해 호출자 입장에서는 동기로 완료를 기다리게 한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationJobTrigger { + + private final JobOperator jobOperator; + private final Job policyNotificationJob; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPolicyRegistered(PolicyRegisteredEvent event) { + Thread jobThread = Thread.ofVirtual().start(() -> runJob(event.policyId())); + try { + jobThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("정책 알림 배치 대기 중 인터럽트 발생 — policyId: {}", event.policyId(), e); + } + } + + private void runJob(Long policyId) { + try { + JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext( + new JobParametersBuilder() + .addLong("policyId", policyId) + .toJobParameters() + ); + jobOperator.run(policyNotificationJob, jobParameters); + } catch (Exception e) { + log.error("정책 알림 배치 실행 실패 — policyId: {}", policyId, e); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java new file mode 100644 index 0000000..00c0ef1 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java @@ -0,0 +1,45 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다. + */ +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationMailProcessor implements ItemProcessor { + + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNotificationSender policyNotificationSender; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private PolicyDocument policyDocument; + + @Override + public PolicyNotificationTarget process(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + policyNotificationSender.sendAndMark(target, user, resolvePolicyDocument()); + return target; + } + + private PolicyDocument resolvePolicyDocument() { + if (policyDocument == null) { + policyDocument = policyDocumentRepository.findById(policyId).orElseThrow(PolicyNotFoundException::new); + } + return policyDocument; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java new file mode 100644 index 0000000..f98831b --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.Iterator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 정책 알림 대상을 id 오름차순 커서 기반으로 읽는다. + * offset 페이징을 사용하면 처리된 항목이 PENDING에서 벗어나며 결과 집합이 줄어들어 + * 다음 페이지 조회 시 대상을 건너뛰는 문제가 있어, 마지막으로 읽은 id를 커서로 다음 배치를 조회한다. + */ +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationPendingTargetReader implements ItemReader { + + private static final int PAGE_SIZE = 20; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private Long lastId = 0L; + private Iterator currentBatch = List.of().iterator(); + + @Override + public PolicyNotificationTarget read() { + if (!currentBatch.hasNext()) { + List batch = policyNotificationTargetRepository + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + policyId, NotificationStatus.PENDING, lastId, PageRequest.of(0, PAGE_SIZE)); + if (batch.isEmpty()) { + return null; + } + lastId = batch.get(batch.size() - 1).getId(); + currentBatch = batch.iterator(); + } + return currentBatch.next(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java new file mode 100644 index 0000000..0aa0d58 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java @@ -0,0 +1,55 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 발송 실패(FAILED)한 정책 알림 대상을 최대 3회까지 1시간 간격으로 재시도한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationRetryScheduler { + + private static final int MAX_RETRY_COUNT = 3; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNotificationSender policyNotificationSender; + + @Scheduled(fixedRate = 3_600_000) + @Transactional + public void retryFailedNotifications() { + List retryTargets = policyNotificationTargetRepository + .findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, MAX_RETRY_COUNT); + if (retryTargets.isEmpty()) { + return; + } + log.info("정책 알림 재시도 시작 — 대상 건수: {}", retryTargets.size()); + for (PolicyNotificationTarget target : retryTargets) { + retryOne(target); + } + } + + private void retryOne(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + PolicyDocument policyDocument = policyDocumentRepository.findById(target.getPolicyId()).orElse(null); + if (policyDocument == null) { + target.markFailed(); + return; + } + policyNotificationSender.sendAndMark(target, user, policyDocument); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java new file mode 100644 index 0000000..de60499 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java @@ -0,0 +1,36 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 정책 알림 대상에게 메일 발송을 시도하고 결과에 따라 상태(SENT/FAILED)를 마킹한다. + * 배치 Step2 Processor와 재시도 스케줄러가 공통으로 사용한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationSender { + + private final PolicyNoticeMailSender policyNoticeMailSender; + + public void sendAndMark(PolicyNotificationTarget target, User user, PolicyDocument policyDocument) { + if (user == null || user.hasNoEmail()) { + log.warn("수신 이메일이 없어 발송 실패 처리 — targetId: {}", target.getId()); + target.markFailed(); + return; + } + try { + policyNoticeMailSender.send(user.getEmail(), policyDocument); + target.markSent(); + } catch (Exception e) { + log.warn("정책 변경 고지 메일 발송 실패 — targetId: {}", target.getId(), e); + target.markFailed(); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java new file mode 100644 index 0000000..7a20476 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java @@ -0,0 +1,62 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * 정책 발행 시 ACTIVE 유저 중 아직 발송 대상이 아닌 유저를 PENDING 상태의 PolicyNotificationTarget으로 등록한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetCreationTasklet implements Tasklet { + + private final UserRepository userRepository; + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + Long policyId = chunkContext.getStepContext().getStepExecution().getJobParameters().getLong("policyId"); + + Set existingTargetUserIds = policyNotificationTargetRepository.findByPolicyId(policyId).stream() + .map(PolicyNotificationTarget::getUserId) + .collect(Collectors.toSet()); + + List activeUsers = userRepository.findByStatus(UserStatus.ACTIVE); + int created = 0; + for (User user : activeUsers) { + if (existingTargetUserIds.contains(user.getId())) { + continue; + } + if (user.hasNoEmail()) { + log.warn("이메일이 없어 정책 알림 대상에서 제외 — userId: {}", user.getId()); + continue; + } + policyNotificationTargetRepository.save( + PolicyNotificationTarget.builder() + .policyId(policyId) + .userId(user.getId()) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build() + ); + created++; + } + log.info("정책 알림 대상 생성 완료 — policyId: {}, 생성 건수: {}", policyId, created); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java new file mode 100644 index 0000000..b9f946e --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java @@ -0,0 +1,20 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.Chunk; +import org.springframework.batch.infrastructure.item.ItemWriter; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetWriter implements ItemWriter { + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public void write(Chunk chunk) { + policyNotificationTargetRepository.saveAll(chunk.getItems()); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java new file mode 100644 index 0000000..ad7d817 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java @@ -0,0 +1,32 @@ +package com.Coming.Backend.policy.controller; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.service.PolicyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Policy") +@RestController +@RequestMapping("/api/admin/policies") +@RequiredArgsConstructor +public class PolicyController { + + private final PolicyService policyService; + + @Operation(summary = "정책 버전 등록") + @ApiResponse(responseCode = "409", description = "POLICY_VERSION_DUPLICATE") + @PostMapping + public ResponseEntity registerPolicy(@Valid @RequestBody PolicyRegisterRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(policyService.registerPolicy(request)); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java new file mode 100644 index 0000000..2ab920d --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java @@ -0,0 +1,27 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record PolicyRegisterRequest( + @NotNull + PolicyType type, + + @NotBlank + @Size(max = 50) + String version, + + @NotNull + LocalDate effectiveDate, + + @NotBlank + String changeSummary, + + @NotBlank + @Size(max = 500) + String detailUrl +) { +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java new file mode 100644 index 0000000..722f989 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java @@ -0,0 +1,25 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; + +public record PolicyResponse( + Long id, + PolicyType type, + String version, + LocalDate effectiveDate, + String changeSummary, + String detailUrl +) { + public static PolicyResponse from(PolicyDocument policyDocument) { + return new PolicyResponse( + policyDocument.getId(), + policyDocument.getType(), + policyDocument.getVersion(), + policyDocument.getEffectiveDate(), + policyDocument.getChangeSummary(), + policyDocument.getDetailUrl() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java new file mode 100644 index 0000000..774b45c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum NotificationStatus { + PENDING, SENT, FAILED +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java new file mode 100644 index 0000000..2058304 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java @@ -0,0 +1,46 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +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; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_document") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyDocument extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private PolicyType type; + + @Column(name = "version", nullable = false, length = 50) + private String version; + + @Column(name = "effective_date", nullable = false) + private LocalDate effectiveDate; + + @Column(name = "change_summary", nullable = false, columnDefinition = "text") + private String changeSummary; + + @Column(name = "detail_url", nullable = false, length = 500) + private String detailUrl; +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java new file mode 100644 index 0000000..7a6ba98 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -0,0 +1,58 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +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; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_notification_target") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyNotificationTarget extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private NotificationStatus status; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + public void markSent() { + this.status = NotificationStatus.SENT; + this.sentAt = LocalDateTime.now(); + } + + public void markFailed() { + if (this.status == NotificationStatus.FAILED) { + this.retryCount += 1; + } + this.status = NotificationStatus.FAILED; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java new file mode 100644 index 0000000..d6eaf4a --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum PolicyType { + TERMS, PRIVACY +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java new file mode 100644 index 0000000..2c9158c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java @@ -0,0 +1,36 @@ +package com.Coming.Backend.policy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_policy_agreement") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserPolicyAgreement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "agreed_at", nullable = false) + private LocalDateTime agreedAt; +} diff --git a/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java new file mode 100644 index 0000000..60e0886 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.policy.event; + +public record PolicyRegisteredEvent(Long policyId) { +} diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java new file mode 100644 index 0000000..4318931 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyNotFoundException extends BusinessException { + + public PolicyNotFoundException() { + super(ErrorCode.POLICY_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java new file mode 100644 index 0000000..ab706a5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyVersionDuplicateException extends BusinessException { + + public PolicyVersionDuplicateException() { + super(ErrorCode.POLICY_VERSION_DUPLICATE); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java new file mode 100644 index 0000000..cd4ae23 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -0,0 +1,71 @@ +package com.Coming.Backend.policy.mail; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Component +@RequiredArgsConstructor +public class PolicyNoticeMailSender { + + private static final String TEMPLATE_NAME = "mail/policy-change-notice"; + private static final String FROM_NAME = "커밍"; + private static final String LOGO_CONTENT_ID = "coming-logo"; + private static final String LOGO_PATH = "mail-assets/logo.png"; + + private final JavaMailSender javaMailSender; + private final TemplateEngine templateEngine; + + @Value("${spring.mail.username}") + private String fromAddress; + + /** + * 정책 변경 고지 메일을 발송한다. + * + * @throws IllegalStateException 메일 메시지 생성에 실패한 경우 + */ + public void send(String toEmail, PolicyDocument policyDocument) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setTo(toEmail); + helper.setFrom(fromAddress, FROM_NAME); + helper.setSubject(buildSubject(policyDocument)); + helper.setText(renderHtml(policyDocument), true); + helper.addInline(LOGO_CONTENT_ID, new ClassPathResource(LOGO_PATH)); + javaMailSender.send(message); + } catch (MessagingException | UnsupportedEncodingException e) { + throw new IllegalStateException("정책 변경 고지 메일 메시지 생성에 실패했습니다.", e); + } + } + + private String buildSubject(PolicyDocument policyDocument) { + return "[커밍] " + labelOf(policyDocument.getType()) + " 변경 안내"; + } + + private String renderHtml(PolicyDocument policyDocument) { + Context context = new Context(); + context.setVariable("policyTypeLabel", labelOf(policyDocument.getType())); + context.setVariable("version", policyDocument.getVersion()); + context.setVariable("effectiveDate", policyDocument.getEffectiveDate()); + context.setVariable("changeSummary", policyDocument.getChangeSummary()); + context.setVariable("detailUrl", policyDocument.getDetailUrl()); + context.setVariable("fromAddress", fromAddress); + context.setVariable("logoContentId", LOGO_CONTENT_ID); + return templateEngine.process(TEMPLATE_NAME, context); + } + + private String labelOf(PolicyType type) { + return type == PolicyType.TERMS ? "이용약관" : "개인정보처리방침"; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java new file mode 100644 index 0000000..9b76ace --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java @@ -0,0 +1,15 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyDocumentRepository extends JpaRepository { + + boolean existsByTypeAndVersion(PolicyType type, String version); + + Optional findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + PolicyType type, LocalDate today); +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java new file mode 100644 index 0000000..ae0fdb1 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -0,0 +1,17 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyNotificationTargetRepository extends JpaRepository { + + List findByPolicyId(Long policyId); + + List findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + Long policyId, NotificationStatus status, Long id, Pageable pageable); + + List findByStatusAndRetryCountLessThan(NotificationStatus status, int retryCount); +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java new file mode 100644 index 0000000..32f5ebb --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java @@ -0,0 +1,9 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserPolicyAgreementRepository extends JpaRepository { + + boolean existsByUserIdAndPolicyId(Long userId, Long policyId); +} diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java new file mode 100644 index 0000000..12ce0f0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.service; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyService { + + private final PolicyDocumentRepository policyDocumentRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 새 정책 버전을 등록한다. 등록 완료(커밋) 후 정책 알림 배치가 트리거된다. + * + * @throws PolicyVersionDuplicateException 동일 type·version이 이미 등록된 경우 + */ + @Transactional + public PolicyResponse registerPolicy(PolicyRegisterRequest request) { + if (policyDocumentRepository.existsByTypeAndVersion(request.type(), request.version())) { + throw new PolicyVersionDuplicateException(); + } + PolicyDocument policyDocument = PolicyDocument.builder() + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .build(); + PolicyDocument saved; + try { + saved = policyDocumentRepository.saveAndFlush(policyDocument); + } catch (DataIntegrityViolationException e) { + throw new PolicyVersionDuplicateException(); + } + eventPublisher.publishEvent(new PolicyRegisteredEvent(saved.getId())); + return PolicyResponse.from(saved); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/CommentController.java b/src/main/java/com/Coming/Backend/post/controller/CommentController.java new file mode 100644 index 0000000..9af008e --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/CommentController.java @@ -0,0 +1,55 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.service.CommentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Comment") +@RestController +@RequestMapping("/api/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 삭제") + @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") + @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND") + @DeleteMapping("/{commentId}") + public ResponseEntity delete( + @AuthenticationPrincipal Long userId, + @PathVariable Long commentId) { + commentService.delete(userId, commentId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "댓글 좋아요") + @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND") + @ApiResponse(responseCode = "409", description = "ALREADY_LIKED") + @PostMapping("/{commentId}/like") + public ResponseEntity like( + @AuthenticationPrincipal Long userId, + @PathVariable Long commentId) { + return ResponseEntity.ok(commentService.like(userId, commentId)); + } + + @Operation(summary = "댓글 좋아요 취소") + @ApiResponse(responseCode = "400", description = "NOT_LIKED") + @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND") + @DeleteMapping("/{commentId}/like") + public ResponseEntity unlike( + @AuthenticationPrincipal Long userId, + @PathVariable Long commentId) { + return ResponseEntity.ok(commentService.unlike(userId, commentId)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java b/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java new file mode 100644 index 0000000..e073db3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java @@ -0,0 +1,41 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/entities") +@RequiredArgsConstructor +public class EntityPostController { + + private final PostService postService; + + @Operation(summary = "엔티티별 게시글 백링크 조회") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (허용되지 않은 sort 값)") + @GetMapping("/{type}/{id}/posts") + public ResponseEntity> getBacklinks( + @PathVariable EntityType type, + @PathVariable Long id, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.getBacklinks(type, id, sort, page, size)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/MentionController.java b/src/main/java/com/Coming/Backend/post/controller/MentionController.java new file mode 100644 index 0000000..de08232 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/MentionController.java @@ -0,0 +1,40 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.MentionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/mentions") +@RequiredArgsConstructor +public class MentionController { + + private final MentionService mentionService; + + @Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성, 무한 스크롤)") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백, page 음수 또는 limit 범위 초과)") + @GetMapping("/search") + public ResponseEntity> search( + @RequestParam EntityType type, + @RequestParam @NotBlank String q, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(20) int limit) { + return ResponseEntity.ok(mentionService.search(type, q, page, limit)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java b/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java new file mode 100644 index 0000000..adfd403 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java @@ -0,0 +1,57 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.service.CommentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Comment") +@Validated +@RestController +@RequestMapping("/api/posts/{postId}/comments") +@RequiredArgsConstructor +public class PostCommentController { + + private final CommentService commentService; + + @Operation(summary = "게시글 댓글·답글 목록 조회") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @GetMapping + public ResponseEntity> getComments( + @PathVariable Long postId, + @AuthenticationPrincipal Long userId, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(commentService.getComments(postId, userId, page, size)); + } + + @Operation(summary = "댓글 또는 답글 작성") + @ApiResponse(responseCode = "400", description = "INVALID_REPLY_DEPTH") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND | COMMENT_NOT_FOUND") + @PostMapping + public ResponseEntity create( + @PathVariable Long postId, + @AuthenticationPrincipal Long userId, + @Valid @RequestBody CommentCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(commentService.create(userId, postId, request)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java new file mode 100644 index 0000000..782fb39 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -0,0 +1,129 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + @Operation(summary = "게시글 작성") + @PostMapping + public ResponseEntity create( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PostCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(postService.create(userId, request)); + } + + @Operation(summary = "게시글 상세 조회") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @GetMapping("/{id}") + public ResponseEntity getDetail( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(postService.getDetail(id, userId)); + } + + @Operation(summary = "게시글 목록 조회") + @GetMapping + public ResponseEntity> getList( + @RequestParam(required = false) PostCategory category, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.getList(category, page, size)); + } + + @Operation(summary = "이번 주 인기 게시글 조회") + @GetMapping("/popular") + public ResponseEntity> getPopular( + @RequestParam(defaultValue = "7") @Min(1) int days, + @RequestParam(defaultValue = "5") @Min(1) @Max(100) int limit) { + return ResponseEntity.ok(postService.getPopular(days, limit)); + } + + @Operation(summary = "최근 많이 언급된 태그 조회") + @GetMapping("/trending-tags") + public ResponseEntity> getTrendingTags( + @RequestParam(defaultValue = "7") @Min(1) int days, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int limit) { + return ResponseEntity.ok(postService.getTrendingTags(days, limit)); + } + + @Operation(summary = "게시글 수정") + @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @PatchMapping("/{id}") + public ResponseEntity update( + @AuthenticationPrincipal Long userId, + @PathVariable Long id, + @Valid @RequestBody PostUpdateRequest request) { + postService.update(userId, id, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "게시글 삭제") + @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @DeleteMapping("/{id}") + public ResponseEntity delete( + @AuthenticationPrincipal Long userId, + @PathVariable Long id) { + postService.delete(userId, id); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "게시글 추천") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @ApiResponse(responseCode = "409", description = "ALREADY_RECOMMENDED") + @PostMapping("/{id}/recommend") + public ResponseEntity recommend( + @AuthenticationPrincipal Long userId, + @PathVariable Long id) { + return ResponseEntity.ok(postService.recommend(userId, id)); + } + + @Operation(summary = "게시글 추천 취소") + @ApiResponse(responseCode = "400", description = "NOT_RECOMMENDED") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @DeleteMapping("/{id}/recommend") + public ResponseEntity unrecommend( + @AuthenticationPrincipal Long userId, + @PathVariable Long id) { + return ResponseEntity.ok(postService.unrecommend(userId, id)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/SearchController.java b/src/main/java/com/Coming/Backend/post/controller/SearchController.java new file mode 100644 index 0000000..951a798 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/SearchController.java @@ -0,0 +1,38 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/search") +@RequiredArgsConstructor +public class SearchController { + + private final PostService postService; + + @Operation(summary = "게시글 통합 검색") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 trim 후 2자 미만)") + @GetMapping + public ResponseEntity> search( + @RequestParam @NotBlank String q, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.search(q, page, size)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java new file mode 100644 index 0000000..dacbf3a --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java @@ -0,0 +1,13 @@ +package com.Coming.Backend.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentCreateRequest( + @NotBlank + @Size(max = 500, message = "댓글은 500자를 초과할 수 없습니다") + String content, + + Long parentCommentId +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java new file mode 100644 index 0000000..d993711 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record CommentCreateResponse(Long id) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java new file mode 100644 index 0000000..04a9e84 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record CommentLikeCountResponse(Long likeCount) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java new file mode 100644 index 0000000..6ba29a3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java @@ -0,0 +1,16 @@ +package com.Coming.Backend.post.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record CommentResponse( + Long id, + String authorNickname, + boolean isAuthor, + String content, + Long likeCount, + Boolean isLiked, + LocalDateTime createdAt, + List replies +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java new file mode 100644 index 0000000..dbee09f --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java @@ -0,0 +1,14 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; + +public record EntityCardResponse( + EntityType type, + Long id, + String title, + String subtitle, + String thumbnailUrl, + // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null. + Long releaseGroupId +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java b/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java new file mode 100644 index 0000000..39dee73 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java @@ -0,0 +1,13 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; +import jakarta.validation.constraints.NotNull; + +public record EntityTagRequest( + @NotNull + EntityType entityType, + + @NotNull + Long entityId +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..9581d77 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java @@ -0,0 +1,25 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record PostCreateRequest( + @NotNull + PostCategory category, + + @NotBlank + @Size(max = 255) + String title, + + @NotNull + Object content, + + @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다") + List<@NotNull @Valid EntityTagRequest> entityTags +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java new file mode 100644 index 0000000..8f94de8 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record PostCreateResponse(Long id) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java new file mode 100644 index 0000000..50e4068 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java @@ -0,0 +1,23 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; + +import java.time.LocalDateTime; +import java.util.List; + +public record PostDetailResponse( + Long id, + String authorNickname, + PostCategory category, + String title, + Object content, + List entityTags, + Long recommendCount, + Long viewCount, + Long commentCount, + Boolean isRecommended, + boolean isAuthor, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java new file mode 100644 index 0000000..bcfdb68 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java @@ -0,0 +1,17 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; + +public record PostEntityTagResponse( + EntityType entityType, + Long entityId, + String title, + String subtitle, + String thumbnailUrl, + // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null. + Long releaseGroupId +) { + public static PostEntityTagResponse of(EntityType entityType, Long entityId, EntityCardResponse card) { + return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl(), card.releaseGroupId()); + } +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java new file mode 100644 index 0000000..fe3370c --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java @@ -0,0 +1,18 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; + +import java.time.LocalDateTime; +import java.util.List; + +public record PostSummaryResponse( + Long id, + String authorNickname, + PostCategory category, + String title, + List entityTags, + Long recommendCount, + Long viewCount, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..8f19709 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java @@ -0,0 +1,23 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record PostUpdateRequest( + PostCategory category, + + @Pattern(regexp = "(?s).*\\S.*", message = "제목은 공백일 수 없습니다") + @Size(max = 255) + String title, + + Object content, + + @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다") + List<@NotNull @Valid EntityTagRequest> entityTags +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java b/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java new file mode 100644 index 0000000..74380d3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record RecommendCountResponse(Long recommendCount) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java new file mode 100644 index 0000000..fa8b764 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; + +public record TrendingTagResponse( + EntityType entityType, + Long entityId, + String title, + Long count +) { +} diff --git a/src/main/java/com/Coming/Backend/post/entity/Comment.java b/src/main/java/com/Coming/Backend/post/entity/Comment.java new file mode 100644 index 0000000..dde5037 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/Comment.java @@ -0,0 +1,73 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Comment extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "parent_comment_id") + private Long parentCommentId; + + @Column(name = "content", nullable = false, columnDefinition = "text") + private String content; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "is_deleted", nullable = false) + private boolean deleted; + + private static final String DELETED_CONTENT_PLACEHOLDER = "삭제된 댓글입니다"; + + public boolean isAuthoredBy(Long userId) { + return userId != null && this.userId.equals(userId); + } + + public boolean isReply() { + return parentCommentId != null; + } + + public void softDelete() { + this.deleted = true; + } + + /** + * 소프트 삭제된 댓글은 본문 대신 플레이스홀더를 노출한다. + */ + public String getDisplayContent() { + return deleted ? DELETED_CONTENT_PLACEHOLDER : content; + } + + /** + * 소프트 삭제된 댓글은 작성자 정보를 노출하지 않는다(isAuthoredBy와 달리 삭제 여부까지 반영). + */ + public boolean isVisibleAuthor(Long userId) { + return !deleted && isAuthoredBy(userId); + } +} diff --git a/src/main/java/com/Coming/Backend/post/entity/CommentLike.java b/src/main/java/com/Coming/Backend/post/entity/CommentLike.java new file mode 100644 index 0000000..acdd70e --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/CommentLike.java @@ -0,0 +1,33 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comment_like") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class CommentLike extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "comment_id", nullable = false) + private Long commentId; +} diff --git a/src/main/java/com/Coming/Backend/post/entity/EntityType.java b/src/main/java/com/Coming/Backend/post/entity/EntityType.java new file mode 100644 index 0000000..a6f1bac --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/EntityType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.post.entity; + +public enum EntityType { + CONCERT, ARTIST, RELEASE, TRACK +} diff --git a/src/main/java/com/Coming/Backend/post/entity/Post.java b/src/main/java/com/Coming/Backend/post/entity/Post.java new file mode 100644 index 0000000..9c42023 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/Post.java @@ -0,0 +1,68 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseTimeEntity; +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; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "post") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Post extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 20) + private PostCategory category; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "content", nullable = false, columnDefinition = "jsonb") + private String content; + + @Column(name = "content_text", nullable = false, columnDefinition = "text") + private String contentText; + + @Column(name = "recommend_count", nullable = false) + private Long recommendCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "comment_count", nullable = false) + private Long commentCount; + + public boolean isAuthoredBy(Long userId) { + return userId != null && this.userId.equals(userId); + } + + public void update(PostCategory category, String title, String content, String contentText) { + if (category != null) this.category = category; + if (title != null) this.title = title; + if (content != null) this.content = content; + if (contentText != null) this.contentText = contentText; + } +} diff --git a/src/main/java/com/Coming/Backend/post/entity/PostCategory.java b/src/main/java/com/Coming/Backend/post/entity/PostCategory.java new file mode 100644 index 0000000..704c495 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/PostCategory.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.post.entity; + +public enum PostCategory { + REVIEW, INFO, FREE +} diff --git a/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java b/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java new file mode 100644 index 0000000..f53d02d --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java @@ -0,0 +1,39 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +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; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_entity_tag") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PostEntityTag extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Enumerated(EnumType.STRING) + @Column(name = "entity_type", nullable = false, length = 20) + private EntityType entityType; + + @Column(name = "entity_id", nullable = false) + private Long entityId; +} diff --git a/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java b/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java new file mode 100644 index 0000000..7751b94 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java @@ -0,0 +1,33 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_recommend") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PostRecommend extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "post_id", nullable = false) + private Long postId; +} diff --git a/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java new file mode 100644 index 0000000..21f680d --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class AlreadyLikedException extends BusinessException { + + public AlreadyLikedException() { + super(ErrorCode.ALREADY_LIKED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java new file mode 100644 index 0000000..c453a63 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class AlreadyRecommendedException extends BusinessException { + + public AlreadyRecommendedException() { + super(ErrorCode.ALREADY_RECOMMENDED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java new file mode 100644 index 0000000..d32b1ec --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class CommentForbiddenException extends BusinessException { + + public CommentForbiddenException() { + super(ErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java b/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java new file mode 100644 index 0000000..2b7de00 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class CommentNotFoundException extends BusinessException { + + public CommentNotFoundException() { + super(ErrorCode.COMMENT_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java b/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java new file mode 100644 index 0000000..958b703 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class InvalidReplyDepthException extends BusinessException { + + public InvalidReplyDepthException() { + super(ErrorCode.INVALID_REPLY_DEPTH); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java b/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java new file mode 100644 index 0000000..8c0c6e1 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class NotLikedException extends BusinessException { + + public NotLikedException() { + super(ErrorCode.NOT_LIKED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java new file mode 100644 index 0000000..0caf1b7 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class NotRecommendedException extends BusinessException { + + public NotRecommendedException() { + super(ErrorCode.NOT_RECOMMENDED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java b/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java new file mode 100644 index 0000000..717ce5d --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PostContentTooLongException extends BusinessException { + + public PostContentTooLongException() { + super(ErrorCode.POST_CONTENT_TOO_LONG); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java new file mode 100644 index 0000000..2a9adb7 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PostForbiddenException extends BusinessException { + + public PostForbiddenException() { + super(ErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java b/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java new file mode 100644 index 0000000..1d11181 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PostNotFoundException extends BusinessException { + + public PostNotFoundException() { + super(ErrorCode.POST_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java new file mode 100644 index 0000000..b476773 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java @@ -0,0 +1,26 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.CommentLike; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CommentLikeRepository extends JpaRepository { + + boolean existsByUserIdAndCommentId(Long userId, Long commentId); + + Optional findByUserIdAndCommentId(Long userId, Long commentId); + + @Query("SELECT cl.commentId FROM CommentLike cl WHERE cl.userId = :userId AND cl.commentId IN :commentIds") + List findLikedCommentIds(@Param("userId") Long userId, @Param("commentIds") Collection commentIds); + + void deleteByCommentIdIn(Collection commentIds); + + @Modifying + @Query("DELETE FROM CommentLike cl WHERE cl.commentId IN (SELECT c.id FROM Comment c WHERE c.postId = :postId)") + void deleteByCommentPostId(@Param("postId") Long postId); +} diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java new file mode 100644 index 0000000..7fa285a --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java @@ -0,0 +1,35 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.Comment; +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c WHERE c.postId = :postId AND c.parentCommentId IS NULL ORDER BY c.createdAt ASC, c.id ASC") + Page findTopLevelByPostId(@Param("postId") Long postId, Pageable pageable); + + List findByParentCommentIdInOrderByCreatedAtAscIdAsc(Collection parentCommentIds); + + @Query("SELECT c.id FROM Comment c WHERE c.postId = :postId") + List findIdsByPostId(@Param("postId") Long postId); + + void deleteByPostId(Long postId); + + @Modifying + @Query("UPDATE Comment c SET c.likeCount = c.likeCount + 1 WHERE c.id = :id") + void incrementLikeCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Comment c SET c.likeCount = c.likeCount - 1 WHERE c.id = :id") + void decrementLikeCount(@Param("id") Long id); + + @Query("SELECT c.likeCount FROM Comment c WHERE c.id = :id") + Long findLikeCountById(@Param("id") Long id); +} diff --git a/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java b/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java new file mode 100644 index 0000000..1bad34a --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java @@ -0,0 +1,6 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.EntityType; + +public record EntityTagCount(EntityType entityType, Long entityId, Long count) { +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java new file mode 100644 index 0000000..90eea81 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java @@ -0,0 +1,34 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.PostEntityTag; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +public interface PostEntityTagRepository extends JpaRepository { + + List findByPostId(Long postId); + + List findByPostIdIn(Collection postIds); + + void deleteByPostId(Long postId); + + /** + * 태그 자체가 아닌 게시글 작성일(post.createdAt) 기준으로 필터링한다. + * 태그는 게시글 수정 시 삭제 후 재삽입되므로, 태그의 createdAt으로 필터링하면 + * 단순 수정만으로 최근 언급인 것처럼 집계될 수 있다. + */ + @Query(""" + SELECT new com.Coming.Backend.post.repository.EntityTagCount(t.entityType, t.entityId, COUNT(t)) + FROM PostEntityTag t + WHERE t.postId IN (SELECT p.id FROM Post p WHERE p.createdAt >= :since) + GROUP BY t.entityType, t.entityId + ORDER BY COUNT(t) DESC + """) + List findTrendingEntityTags(@Param("since") LocalDateTime since, Pageable pageable); +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java new file mode 100644 index 0000000..8b833e0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java @@ -0,0 +1,13 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.PostRecommend; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRecommendRepository extends JpaRepository { + + boolean existsByUserIdAndPostId(Long userId, Long postId); + + long deleteByUserIdAndPostId(Long userId, Long postId); + + void deleteByPostId(Long postId); +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java new file mode 100644 index 0000000..2a6b258 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -0,0 +1,77 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface PostRepository extends JpaRepository { + + @Query("SELECT p FROM Post p WHERE (:category IS NULL OR p.category = :category) ORDER BY p.createdAt DESC") + Page findPosts(@Param("category") PostCategory category, Pageable pageable); + + @Query("SELECT p FROM Post p WHERE p.createdAt >= :since ORDER BY p.recommendCount DESC, p.createdAt DESC") + List findPopularPosts(@Param("since") LocalDateTime since, Pageable pageable); + + @Modifying + @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id") + void incrementViewCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Post p SET p.recommendCount = p.recommendCount + 1 WHERE p.id = :id") + void incrementRecommendCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Post p SET p.recommendCount = p.recommendCount - 1 WHERE p.id = :id") + void decrementRecommendCount(@Param("id") Long id); + + @Query("SELECT p.recommendCount FROM Post p WHERE p.id = :id") + Long findRecommendCountById(@Param("id") Long id); + + @Modifying + @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :id") + void incrementCommentCount(@Param("id") Long id); + + /** + * RELEASE 조회 시, 해당 릴리즈에 속한 트랙(Track.releaseGroupId)이 태그된 게시글도 함께 포함한다. + * 트랙 앵커가 앨범 상세 페이지로 귀결되는 구조(`/releases/{releaseGroupId}#track-{id}`)이므로, + * 트랙을 언급한 글도 해당 앨범의 관련 게시글로 노출되어야 사용자에게 자연스럽다. + */ + @Query(""" + SELECT p FROM Post p + WHERE p.id IN ( + SELECT t.postId FROM PostEntityTag t + WHERE (t.entityType = :entityType AND t.entityId = :entityId) + OR (:entityType = com.Coming.Backend.post.entity.EntityType.RELEASE + AND t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK + AND t.entityId IN (SELECT tr.id FROM Track tr WHERE tr.releaseGroupId = :entityId)) + ) + """) + Page findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable); + + @Query(""" + SELECT p FROM Post p + WHERE LOWER(p.title) LIKE :q OR LOWER(p.contentText) LIKE :q + OR p.id IN ( + SELECT t.postId FROM PostEntityTag t + WHERE (t.entityType = com.Coming.Backend.post.entity.EntityType.ARTIST + AND t.entityId IN (SELECT a.id FROM Artist a WHERE LOWER(a.name) LIKE :q)) + OR (t.entityType = com.Coming.Backend.post.entity.EntityType.CONCERT + AND t.entityId IN (SELECT c.id FROM Concert c WHERE LOWER(c.title) LIKE :q)) + OR (t.entityType = com.Coming.Backend.post.entity.EntityType.RELEASE + AND t.entityId IN (SELECT r.id FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q)) + OR (t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK + AND t.entityId IN (SELECT tr.id FROM Track tr WHERE LOWER(tr.title) LIKE :q)) + ) + ORDER BY p.createdAt DESC + """) + Page searchPosts(@Param("q") String q, Pageable pageable); +} diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java new file mode 100644 index 0000000..31a4138 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -0,0 +1,210 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.entity.Comment; +import com.Coming.Backend.post.entity.CommentLike; +import com.Coming.Backend.post.exception.AlreadyLikedException; +import com.Coming.Backend.post.exception.CommentForbiddenException; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.InvalidReplyDepthException; +import com.Coming.Backend.post.exception.NotLikedException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + + /** + * 게시글의 댓글·답글 목록을 조회한다. 최상위 댓글만 페이지네이션 대상이며, 답글은 각 최상위 댓글에 전체 포함된다. + * + * @param userId 인증 사용자 ID. null이면 isLiked는 null, isAuthor는 false로 반환된다. + */ + public PageResponse getComments(Long postId, Long userId, int page, int size) { + if (!postRepository.existsById(postId)) { + throw new PostNotFoundException(); + } + + Pageable pageable = PageRequest.of(page, size); + Page topLevelPage = commentRepository.findTopLevelByPostId(postId, pageable); + List topLevelComments = topLevelPage.getContent(); + + List topLevelIds = topLevelComments.stream().map(Comment::getId).toList(); + Map> repliesByParentId = topLevelIds.isEmpty() + ? Map.of() + : commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(topLevelIds).stream() + .collect(Collectors.groupingBy(Comment::getParentCommentId)); + + List allComments = Stream.concat( + topLevelComments.stream(), + repliesByParentId.values().stream().flatMap(List::stream) + ).toList(); + Map nicknameByUserId = findNicknames(allComments); + + // 소프트 삭제된 댓글은 좋아요 여부를 노출하지 않으므로 배치 조회 대상에서 제외한다. + List notDeletedComments = allComments.stream().filter(comment -> !comment.isDeleted()).toList(); + Set likedCommentIds = findLikedCommentIds(userId, notDeletedComments); + + List content = topLevelComments.stream() + .map(comment -> toResponse(comment, repliesByParentId.getOrDefault(comment.getId(), List.of()), + userId, nicknameByUserId, likedCommentIds)) + .toList(); + + return new PageResponse<>(content, topLevelPage.getNumber(), topLevelPage.getSize(), + topLevelPage.getTotalElements(), topLevelPage.getTotalPages()); + } + + /** + * 댓글 또는 답글을 작성한다. parentCommentId가 주어지면 답글로 작성하며, 상위 댓글이 이미 답글이면 + * InvalidReplyDepthException을 던진다(2단계 이상 중첩 금지). + */ + @Transactional + public CommentCreateResponse create(Long userId, Long postId, CommentCreateRequest request) { + if (!postRepository.existsById(postId)) { + throw new PostNotFoundException(); + } + + Long parentCommentId = request.parentCommentId(); + if (parentCommentId != null) { + Comment parent = commentRepository.findById(parentCommentId) + .filter(comment -> comment.getPostId().equals(postId)) + .orElseThrow(CommentNotFoundException::new); + if (parent.isReply()) { + throw new InvalidReplyDepthException(); + } + } + + Comment comment = Comment.builder() + .postId(postId) + .userId(userId) + .parentCommentId(parentCommentId) + .content(request.content()) + .likeCount(0L) + .deleted(false) + .build(); + commentRepository.save(comment); + // commentCount는 생성 시에만 증가하는 단조 카운터다. 삭제는 항상 소프트 삭제(행 유지)라 감소시키지 않는다. + postRepository.incrementCommentCount(postId); + + return new CommentCreateResponse(comment.getId()); + } + + /** + * 댓글 또는 답글을 삭제한다. 작성자 본인만 삭제할 수 있다. + * 하드 삭제 대신 소프트 삭제로 처리해 답글이 달린 댓글이 삭제돼도 답글은 그대로 유지된다. + */ + @Transactional + public void delete(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + if (!comment.isAuthoredBy(userId)) { + throw new CommentForbiddenException(); + } + comment.softDelete(); + } + + /** + * 댓글에 좋아요를 남긴다. 이미 좋아요한 댓글이면 AlreadyLikedException을 던진다. + */ + @Transactional + public CommentLikeCountResponse like(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + if (comment.isDeleted()) { + throw new CommentNotFoundException(); + } + if (commentLikeRepository.existsByUserIdAndCommentId(userId, commentId)) { + throw new AlreadyLikedException(); + } + try { + commentLikeRepository.save(CommentLike.builder() + .userId(userId) + .commentId(commentId) + .build()); + } catch (DataIntegrityViolationException e) { + throw new AlreadyLikedException(); + } + commentRepository.incrementLikeCount(commentId); + return new CommentLikeCountResponse(commentRepository.findLikeCountById(commentId)); + } + + /** + * 댓글 좋아요를 취소한다. 좋아요한 적 없으면 NotLikedException을 던진다. + */ + @Transactional + public CommentLikeCountResponse unlike(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + if (comment.isDeleted()) { + throw new CommentNotFoundException(); + } + CommentLike like = commentLikeRepository.findByUserIdAndCommentId(userId, commentId) + .orElseThrow(NotLikedException::new); + commentLikeRepository.delete(like); + commentRepository.decrementLikeCount(commentId); + return new CommentLikeCountResponse(commentRepository.findLikeCountById(commentId)); + } + + private CommentResponse toResponse(Comment comment, List replies, Long userId, + Map nicknameByUserId, Set likedCommentIds) { + List replyResponses = replies.stream() + .map(reply -> toResponse(reply, List.of(), userId, nicknameByUserId, likedCommentIds)) + .toList(); + + String authorNickname = nicknameByUserId.get(comment.getUserId()); + Boolean isLiked = userId == null || comment.isDeleted() ? null : likedCommentIds.contains(comment.getId()); + + return new CommentResponse( + comment.getId(), + authorNickname, + comment.isVisibleAuthor(userId), + comment.getDisplayContent(), + comment.getLikeCount(), + isLiked, + comment.getCreatedAt(), + replyResponses + ); + } + + private Map findNicknames(List comments) { + if (comments.isEmpty()) { + return Map.of(); + } + Set userIds = comments.stream().map(Comment::getUserId).collect(Collectors.toSet()); + return userRepository.findAllByIdIn(userIds).stream() + .collect(Collectors.toMap(User::getId, User::getNickname)); + } + + private Set findLikedCommentIds(Long userId, List comments) { + if (userId == null || comments.isEmpty()) { + return Set.of(); + } + List commentIds = comments.stream().map(Comment::getId).toList(); + return new HashSet<>(commentLikeRepository.findLikedCommentIds(userId, commentIds)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java new file mode 100644 index 0000000..22c8575 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java @@ -0,0 +1,112 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EntityLookupService { + + private final ConcertRepository concertRepository; + private final ArtistRepository artistRepository; + private final ReleaseGroupRepository releaseGroupRepository; + private final TrackRepository trackRepository; + + /** + * entityType·entityId 키 목록에 대응하는 엔티티 카드 정보를 일괄 조회한다. + * 참조 대상이 삭제된 경우 결과 Map에서 해당 키가 제외된다. + */ + public Map findCards(Collection keys) { + Map result = new HashMap<>(); + + concertRepository.findAllById(idsOf(keys, EntityType.CONCERT)).forEach(concert -> + result.put(new EntityKey(EntityType.CONCERT, concert.getId()), toCard(concert))); + + artistRepository.findAllById(idsOf(keys, EntityType.ARTIST)).forEach(artist -> + result.put(new EntityKey(EntityType.ARTIST, artist.getId()), toCard(artist))); + + List releases = releaseGroupRepository.findAllById(idsOf(keys, EntityType.RELEASE)); + Map artistNames = artistRepository.findAllById( + releases.stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); + releases.forEach(release -> + result.put(new EntityKey(EntityType.RELEASE, release.getId()), + toCard(release, artistNames.get(release.getArtistId())))); + + List tracks = trackRepository.findAllById(idsOf(keys, EntityType.TRACK)); + toTrackCardsById(tracks).forEach((trackId, card) -> + result.put(new EntityKey(EntityType.TRACK, trackId), card)); + + return result; + } + + /** + * 트랙 목록이 속한 앨범·아티스트 정보를 배치 조회해 트랙 id별 카드로 변환한다. + * 앨범이 삭제되어 참조가 끊긴 트랙은 제목만 채운 카드를 반환한다. + */ + Map toTrackCardsById(Collection tracks) { + Map releaseGroupsById = releaseGroupRepository.findAllById( + tracks.stream().map(Track::getReleaseGroupId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(ReleaseGroup::getId, Function.identity())); + Map artistNames = artistRepository.findAllById( + releaseGroupsById.values().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); + + return tracks.stream().collect(Collectors.toMap(Track::getId, track -> { + ReleaseGroup releaseGroup = releaseGroupsById.get(track.getReleaseGroupId()); + String artistName = releaseGroup != null ? artistNames.get(releaseGroup.getArtistId()) : null; + return toCard(track, releaseGroup, artistName); + })); + } + + private Set idsOf(Collection keys, EntityType type) { + return keys.stream() + .filter(key -> key.type() == type) + .map(EntityKey::id) + .collect(Collectors.toSet()); + } + + EntityCardResponse toCard(Concert concert) { + String subtitle = concert.getStartDate() + " · " + concert.getVenueName(); + return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl(), null); + } + + EntityCardResponse toCard(Artist artist) { + return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl(), null); + } + + EntityCardResponse toCard(ReleaseGroup release, String artistName) { + return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl(), null); + } + + EntityCardResponse toCard(Track track, ReleaseGroup releaseGroup, String artistName) { + if (releaseGroup == null) { + return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), null, null, null); + } + String subtitle = artistName != null ? artistName + " · " + releaseGroup.getTitle() : releaseGroup.getTitle(); + return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), subtitle, releaseGroup.getCoverUrl(), releaseGroup.getId()); + } + + public record EntityKey(EntityType type, Long id) { + } +} diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java new file mode 100644 index 0000000..b97ddcc --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -0,0 +1,94 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.entity.ConcertStatus; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.Coming.Backend.concert.entity.ConcertStatus.EXCLUDED; +import static com.Coming.Backend.concert.entity.ConcertStatus.PENDING; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MentionService { + + private static final List HIDDEN_STATUSES = List.of(EXCLUDED, PENDING); + + private final ConcertRepository concertRepository; + private final ArtistRepository artistRepository; + private final ReleaseGroupRepository releaseGroupRepository; + private final TrackRepository trackRepository; + private final EntityLookupService entityLookupService; + + /** + * 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 페이지 단위로 검색한다. + * 무한 스크롤 조회를 위해 id를 tie-breaker로 사용해 페이지 간 정렬을 안정적으로 유지한다. + */ + public PageResponse search(EntityType type, String q, int page, int size) { + return switch (type) { + case CONCERT -> searchConcerts(q, PageRequest.of(page, size)); + case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); + case RELEASE -> searchReleases(q, PageRequest.of(page, size)); + case TRACK -> searchTracks(q, PageRequest.of(page, size)); + }; + } + + private PageResponse searchConcerts(String q, Pageable pageable) { + String likeQ = toLikePattern(q); + Page concerts = concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable); + return PageResponse.from(concerts.map(entityLookupService::toCard)); + } + + private PageResponse searchArtists(String q, Pageable pageable) { + Page artists = artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable); + return PageResponse.from(artists.map(entityLookupService::toCard)); + } + + private PageResponse searchReleases(String q, Pageable pageable) { + Page releases = releaseGroupRepository + .searchByTitleForMention(toLikePattern(q), pageable); + Map artistNames = artistRepository.findAllById( + releases.getContent().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); + return PageResponse.from( + releases.map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId()))) + ); + } + + private PageResponse searchTracks(String q, Pageable pageable) { + Page tracks = trackRepository.searchByTitleForMention(toLikePattern(q), pageable); + Map cardsByTrackId = entityLookupService.toTrackCardsById(tracks.getContent()); + return PageResponse.from(tracks.map(track -> cardsByTrackId.get(track.getId()))); + } + + private String toLikePattern(String q) { + return "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%"; + } + + private String escapeLikeWildcards(String q) { + return q.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } +} diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java new file mode 100644 index 0000000..4b7076e --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -0,0 +1,391 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.dto.EntityTagRequest; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostEntityTagResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.post.entity.PostRecommend; +import com.Coming.Backend.post.exception.AlreadyRecommendedException; +import com.Coming.Backend.post.exception.NotRecommendedException; +import com.Coming.Backend.post.exception.PostContentTooLongException; +import com.Coming.Backend.post.exception.PostForbiddenException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.EntityTagCount; +import com.Coming.Backend.post.repository.PostEntityTagRepository; +import com.Coming.Backend.post.repository.PostRecommendRepository; +import com.Coming.Backend.post.repository.PostRepository; +import com.Coming.Backend.post.util.TiptapTextExtractor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostService { + + private final PostRepository postRepository; + private final PostEntityTagRepository postEntityTagRepository; + private final PostRecommendRepository postRecommendRepository; + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final UserRepository userRepository; + private final EntityLookupService entityLookupService; + + /** + * content 컬럼(jsonb)의 String ↔ Object 변환 전용. Spring이 HTTP 메시지 변환에 사용하는 + * Jackson 인스턴스(3.x)와 무관하게 항상 Jackson 2 databind로 직렬화·역직렬화한다. + */ + private final ObjectMapper contentObjectMapper = new ObjectMapper(); + + private static final int MAX_CONTENT_TEXT_LENGTH = 10000; + + /** + * content 원본(jsonb 직렬화 문자열) 크기 상한. contentText는 텍스트 노드만 추출한 값이라 + * 구조만 방대한 JSON으로 MAX_CONTENT_TEXT_LENGTH 검증을 우회할 수 있어 별도로 제한한다. + */ + private static final int MAX_CONTENT_LENGTH = 50000; + + private static final int MIN_SEARCH_QUERY_LENGTH = 2; + + /** + * 게시글을 생성한다. + * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 + * 조회 시점에 EntityLookupService가 조용히 제외한다. + */ + @Transactional + public PostCreateResponse create(Long userId, PostCreateRequest request) { + List tags = request.entityTags() == null ? List.of() : request.entityTags(); + + String contentText = TiptapTextExtractor.extract(request.content()); + validateContentTextLength(contentText); + String content = writeContent(request.content()); + validateContentLength(content); + + Post post = Post.builder() + .userId(userId) + .category(request.category()) + .title(request.title()) + .content(content) + .contentText(contentText) + .recommendCount(0L) + .viewCount(0L) + .commentCount(0L) + .build(); + postRepository.save(post); + saveEntityTags(post.getId(), tags); + + return new PostCreateResponse(post.getId()); + } + + /** + * 게시글 상세를 조회한다. 조회할 때마다 viewCount가 1 증가한다(중복 조회 방지 없음). + * + * @param userId 인증 사용자 ID. null이면 isRecommended는 null, isAuthor는 false로 반환된다. + */ + @Transactional + public PostDetailResponse getDetail(Long id, Long userId) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + postRepository.incrementViewCount(id); + + List tags = postEntityTagRepository.findByPostId(id); + List entityTags = mapEntityTagsByPost(tags).getOrDefault(id, List.of()); + + String authorNickname = userRepository.findById(post.getUserId()) + .map(User::getNickname) + .orElse(null); + Boolean isRecommended = userId == null ? null : postRecommendRepository.existsByUserIdAndPostId(userId, id); + + return new PostDetailResponse( + post.getId(), + authorNickname, + post.getCategory(), + post.getTitle(), + readContent(post.getContent()), + entityTags, + post.getRecommendCount(), + post.getViewCount() + 1, + post.getCommentCount(), + isRecommended, + post.isAuthoredBy(userId), + post.getCreatedAt(), + post.getUpdatedAt() + ); + } + + /** + * 게시글 목록을 최신순으로 조회한다. + * + * @param category null이면 전체 카테고리 + */ + public PageResponse getList(PostCategory category, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page result = postRepository.findPosts(category, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + + /** + * Post 목록을 PostSummaryResponse 목록으로 변환한다. entityTags·authorNickname을 배치 조회해 채운다. + * 백링크·통합검색 등 다른 조회 API에서도 재사용한다. + */ + public List toSummaryResponses(List posts) { + if (posts.isEmpty()) { + return List.of(); + } + List postIds = posts.stream().map(Post::getId).toList(); + Map> tagsByPost = + mapEntityTagsByPost(postEntityTagRepository.findByPostIdIn(postIds)); + Map nicknameByUserId = userRepository.findAllByIdIn( + posts.stream().map(Post::getUserId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(User::getId, User::getNickname)); + + return posts.stream().map(post -> new PostSummaryResponse( + post.getId(), + nicknameByUserId.get(post.getUserId()), + post.getCategory(), + post.getTitle(), + tagsByPost.getOrDefault(post.getId(), List.of()), + post.getRecommendCount(), + post.getViewCount(), + post.getCreatedAt() + )).toList(); + } + + /** + * 특정 엔티티(공연·아티스트·발매)에 태그된 게시글을 백링크로 조회한다. + * + * @param sort "recommend"(추천순) 또는 "latest"(최신순). 그 외 값이면 InvalidInputException. + */ + public PageResponse getBacklinks(EntityType entityType, Long entityId, String sort, int page, int size) { + Sort sortOrder = switch (sort) { + case "recommend" -> Sort.by(Sort.Direction.DESC, "recommendCount"); + case "latest" -> Sort.by(Sort.Direction.DESC, "createdAt"); + default -> throw new InvalidInputException(); + }; + Pageable pageable = PageRequest.of(page, size, sortOrder); + Page result = postRepository.findByEntityTag(entityType, entityId, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + + /** + * 최근 N일 이내 작성된 게시글을 추천수 내림차순으로 상위 K개 조회한다. + */ + public List getPopular(int days, int limit) { + LocalDateTime since = LocalDateTime.now().minusDays(days); + List posts = postRepository.findPopularPosts(since, PageRequest.of(0, limit)); + return toSummaryResponses(posts); + } + + /** + * 최근 N일 이내 작성된 게시글에 태그된 엔티티를 언급 빈도 내림차순으로 상위 K개 조회한다. + */ + public List getTrendingTags(int days, int limit) { + LocalDateTime since = LocalDateTime.now().minusDays(days); + List counts = postEntityTagRepository.findTrendingEntityTags(since, PageRequest.of(0, limit)); + if (counts.isEmpty()) { + return List.of(); + } + + List keys = counts.stream() + .map(count -> new EntityLookupService.EntityKey(count.entityType(), count.entityId())) + .toList(); + Map cards = entityLookupService.findCards(keys); + + return counts.stream() + .filter(count -> cards.containsKey(new EntityLookupService.EntityKey(count.entityType(), count.entityId()))) + .map(count -> { + EntityCardResponse card = cards.get(new EntityLookupService.EntityKey(count.entityType(), count.entityId())); + return new TrendingTagResponse(count.entityType(), count.entityId(), card.title(), count.count()); + }) + .toList(); + } + + /** + * 게시글 제목·본문·태그된 엔티티명을 통합 검색한다. 최신순 고정. + * + * @param q trim 후 2자 미만이면 InvalidInputException. + */ + public PageResponse search(String q, int page, int size) { + String trimmedQ = q.trim(); + if (trimmedQ.length() < MIN_SEARCH_QUERY_LENGTH) { + throw new InvalidInputException(); + } + Pageable pageable = PageRequest.of(page, size); + String likeQ = "%" + escapeLikeWildcards(trimmedQ.toLowerCase(Locale.ROOT)) + "%"; + Page result = postRepository.searchPosts(likeQ, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + + /** + * 게시글을 수정한다. 작성자 본인만 수정할 수 있다. + * category·title·content는 null이면 기존값을 유지하고, entityTags는 null이면 기존 태그를 유지한다. + * entityTags가 주어지면 기존 태그를 전체 삭제 후 재삽입한다. + */ + @Transactional + public void update(Long userId, Long id, PostUpdateRequest request) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + if (!post.isAuthoredBy(userId)) { + throw new PostForbiddenException(); + } + + String contentText = request.content() != null ? TiptapTextExtractor.extract(request.content()) : null; + if (contentText != null) { + validateContentTextLength(contentText); + } + String content = request.content() != null ? writeContent(request.content()) : null; + if (content != null) { + validateContentLength(content); + } + post.update(request.category(), request.title(), content, contentText); + + if (request.entityTags() != null) { + postEntityTagRepository.deleteByPostId(id); + saveEntityTags(id, request.entityTags()); + } + } + + /** + * 게시글을 삭제한다. 작성자 본인만 삭제할 수 있다. + * 게시글이 사라지면 그 밑의 댓글·좋아요·추천은 다시 보여줄 곳이 없으므로 함께 물리 삭제한다. + */ + @Transactional + public void delete(Long userId, Long id) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + if (!post.isAuthoredBy(userId)) { + throw new PostForbiddenException(); + } + commentLikeRepository.deleteByCommentPostId(id); + commentRepository.deleteByPostId(id); + postRecommendRepository.deleteByPostId(id); + postEntityTagRepository.deleteByPostId(id); + postRepository.delete(post); + } + + /** + * 게시글을 추천한다. 이미 추천한 게시글이면 AlreadyRecommendedException을 던진다. + */ + @Transactional + public RecommendCountResponse recommend(Long userId, Long id) { + postRepository.findById(id).orElseThrow(PostNotFoundException::new); + if (postRecommendRepository.existsByUserIdAndPostId(userId, id)) { + throw new AlreadyRecommendedException(); + } + try { + postRecommendRepository.save(PostRecommend.builder() + .userId(userId) + .postId(id) + .build()); + } catch (DataIntegrityViolationException e) { + throw new AlreadyRecommendedException(); + } + postRepository.incrementRecommendCount(id); + return new RecommendCountResponse(postRepository.findRecommendCountById(id)); + } + + /** + * 게시글 추천을 취소한다. 추천한 적 없으면 NotRecommendedException을 던진다. + */ + @Transactional + public RecommendCountResponse unrecommend(Long userId, Long id) { + postRepository.findById(id).orElseThrow(PostNotFoundException::new); + long deletedCount = postRecommendRepository.deleteByUserIdAndPostId(userId, id); + if (deletedCount == 0) { + throw new NotRecommendedException(); + } + postRepository.decrementRecommendCount(id); + return new RecommendCountResponse(postRepository.findRecommendCountById(id)); + } + + private void validateContentTextLength(String contentText) { + if (contentText.length() > MAX_CONTENT_TEXT_LENGTH) { + throw new PostContentTooLongException(); + } + } + + private void validateContentLength(String content) { + if (content.length() > MAX_CONTENT_LENGTH) { + throw new PostContentTooLongException(); + } + } + + private String escapeLikeWildcards(String q) { + return q.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } + + private String writeContent(Object content) { + try { + return contentObjectMapper.writeValueAsString(content); + } catch (JsonProcessingException e) { + throw new IllegalStateException("게시글 content 직렬화에 실패했습니다.", e); + } + } + + private Object readContent(String content) { + try { + return contentObjectMapper.readValue(content, Object.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("저장된 게시글 content가 유효한 JSON이 아닙니다.", e); + } + } + + private void saveEntityTags(Long postId, List tags) { + tags.stream().distinct().forEach(tag -> postEntityTagRepository.save(PostEntityTag.builder() + .postId(postId) + .entityType(tag.entityType()) + .entityId(tag.entityId()) + .build())); + } + + private Map> mapEntityTagsByPost(List tags) { + if (tags.isEmpty()) { + return Map.of(); + } + List keys = tags.stream() + .map(tag -> new EntityLookupService.EntityKey(tag.getEntityType(), tag.getEntityId())) + .toList(); + Map cards = entityLookupService.findCards(keys); + + return tags.stream() + .filter(tag -> cards.containsKey(new EntityLookupService.EntityKey(tag.getEntityType(), tag.getEntityId()))) + .collect(Collectors.groupingBy( + PostEntityTag::getPostId, + Collectors.mapping( + tag -> PostEntityTagResponse.of(tag.getEntityType(), tag.getEntityId(), + cards.get(new EntityLookupService.EntityKey(tag.getEntityType(), tag.getEntityId()))), + Collectors.toList()))); + } +} diff --git a/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java b/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java new file mode 100644 index 0000000..3455b01 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java @@ -0,0 +1,46 @@ +package com.Coming.Backend.post.util; + +import java.util.List; +import java.util.Map; + +public final class TiptapTextExtractor { + + private TiptapTextExtractor() { + } + + /** + * Tiptap 문서(JSON을 역직렬화한 Map/List 트리)에서 텍스트 노드만 추출해 검색용 텍스트를 반환한다. + * 같은 문단 안에서 서식(굵게 등)으로 나뉜 인접 텍스트 노드는 원문 그대로 붙여 쓰고, + * 문단 등 블록 경계에서만 공백을 넣는다. 멘션 등 텍스트 노드가 아닌 노드는 무시한다. + */ + public static String extract(Object content) { + StringBuilder builder = new StringBuilder(); + collect(content, builder); + return builder.toString().trim(); + } + + private static void collect(Object node, StringBuilder builder) { + if (node instanceof List list) { + list.forEach(child -> collect(child, builder)); + return; + } + if (!(node instanceof Map map)) { + return; + } + if (isTextNode(map)) { + if (map.get("text") instanceof String textValue) { + builder.append(textValue); + } + return; + } + int lengthBeforeBlock = builder.length(); + collect(map.get("content"), builder); + if (builder.length() > lengthBeforeBlock) { + builder.append(' '); + } + } + + private static boolean isTextNode(Map node) { + return "text".equals(node.get("type")); + } +} diff --git a/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java b/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java index 5cb8a56..0c7b057 100644 --- a/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java +++ b/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java @@ -65,4 +65,8 @@ Page searchReleases(@Param("artistId") Long artistId, @Param("type") String type, @Param("q") String q, Pageable pageable); + + @Query(value = "SELECT r FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\' ORDER BY r.id ASC", + countQuery = "SELECT COUNT(r) FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\'") + Page searchByTitleForMention(@Param("q") String q, Pageable pageable); } diff --git a/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java b/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java index 9883bf2..004609f 100644 --- a/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java +++ b/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java @@ -1,7 +1,11 @@ package com.Coming.Backend.release.repository; import com.Coming.Backend.release.entity.Track; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -11,4 +15,8 @@ public interface TrackRepository extends JpaRepository { List findByReleaseGroupIdOrderByPosition(Long releaseGroupId); List findByReleaseGroupIdInOrderByPosition(Collection releaseGroupIds); + + @Query(value = "SELECT t FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\' ORDER BY t.id ASC", + countQuery = "SELECT COUNT(t) FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\'") + Page searchByTitleForMention(@Param("q") String q, Pageable pageable); } diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 9640fc2..7b55b7b 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -2,4 +2,9 @@ discord: webhook: 5xx-url: ${DISCORD_WEBHOOK_5XX_URL} 4xx-url: ${DISCORD_WEBHOOK_4XX_URL} - inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} \ No newline at end of file + inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} + +spring: + mail: + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 66b29c4..b4078e3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -61,6 +61,27 @@ spring: pageable: max-page-size: 50 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + connectiontimeout: 5000 + timeout: 10000 + starttls: + enable: true + required: true + + batch: + job: + enabled: false + jdbc: + initialize-schema: never + jwt: secret: ${JWT_SECRET} access-token-expiry: 1800000 diff --git a/src/main/resources/db/migration/V30__create_post_tables.sql b/src/main/resources/db/migration/V30__create_post_tables.sql new file mode 100644 index 0000000..84faf5c --- /dev/null +++ b/src/main/resources/db/migration/V30__create_post_tables.sql @@ -0,0 +1,34 @@ +CREATE TABLE post ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + category varchar(20) NOT NULL, + title varchar(255) NOT NULL, + content jsonb NOT NULL, + content_text text NOT NULL, + recommend_count bigint NOT NULL DEFAULT 0, + view_count bigint NOT NULL DEFAULT 0, + created_at timestamp, + updated_at timestamp +); + +CREATE INDEX idx_post_user_id ON post (user_id); +CREATE INDEX idx_post_category ON post (category); + +CREATE TABLE post_entity_tag ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL, + entity_type varchar(20) NOT NULL, + entity_id bigint NOT NULL, + created_at timestamp +); + +CREATE INDEX idx_post_entity_tag_post_id ON post_entity_tag (post_id); +CREATE INDEX idx_post_entity_tag_entity_type_entity_id ON post_entity_tag (entity_type, entity_id); + +CREATE TABLE post_recommend ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + post_id bigint NOT NULL, + created_at timestamp, + UNIQUE (user_id, post_id) +); diff --git a/src/main/resources/db/migration/V31__create_comment_tables.sql b/src/main/resources/db/migration/V31__create_comment_tables.sql new file mode 100644 index 0000000..7771905 --- /dev/null +++ b/src/main/resources/db/migration/V31__create_comment_tables.sql @@ -0,0 +1,23 @@ +CREATE TABLE comment ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL, + user_id bigint NOT NULL, + parent_comment_id bigint, + content text NOT NULL, + like_count bigint NOT NULL DEFAULT 0, + is_deleted boolean NOT NULL DEFAULT false, + created_at timestamp +); + +CREATE INDEX idx_comment_post_id ON comment (post_id); +CREATE INDEX idx_comment_parent_comment_id ON comment (parent_comment_id); + +CREATE TABLE comment_like ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + comment_id bigint NOT NULL, + created_at timestamp, + UNIQUE (user_id, comment_id) +); + +ALTER TABLE post ADD COLUMN comment_count bigint NOT NULL DEFAULT 0; diff --git a/src/main/resources/db/migration/V32__create_policy_document.sql b/src/main/resources/db/migration/V32__create_policy_document.sql new file mode 100644 index 0000000..6e1be50 --- /dev/null +++ b/src/main/resources/db/migration/V32__create_policy_document.sql @@ -0,0 +1,11 @@ +CREATE TABLE policy_document ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + type varchar(20) NOT NULL, + version varchar(50) NOT NULL, + effective_date date NOT NULL, + change_summary text NOT NULL, + detail_url varchar(500) NOT NULL, + requires_reconsent boolean NOT NULL DEFAULT false, + created_at timestamp, + UNIQUE (type, version) +); diff --git a/src/main/resources/db/migration/V33__create_user_policy_agreement.sql b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql new file mode 100644 index 0000000..402e046 --- /dev/null +++ b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql @@ -0,0 +1,22 @@ +CREATE TABLE user_policy_agreement ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + policy_id bigint NOT NULL, + agreed_at timestamp NOT NULL, + UNIQUE (user_id, policy_id) +); + +INSERT INTO policy_document (type, version, effective_date, change_summary, detail_url, requires_reconsent, created_at) +VALUES + ('TERMS', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()), + ('PRIVACY', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()); + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'TERMS' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_terms = true AND agreed_at IS NOT NULL; + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'PRIVACY' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_privacy = true AND agreed_at IS NOT NULL; diff --git a/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql new file mode 100644 index 0000000..2b2b9b1 --- /dev/null +++ b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user" DROP COLUMN agreed_terms; +ALTER TABLE "user" DROP COLUMN agreed_privacy; +ALTER TABLE "user" DROP COLUMN agreed_at; diff --git a/src/main/resources/db/migration/V35__create_policy_notification_target.sql b/src/main/resources/db/migration/V35__create_policy_notification_target.sql new file mode 100644 index 0000000..4e01116 --- /dev/null +++ b/src/main/resources/db/migration/V35__create_policy_notification_target.sql @@ -0,0 +1,10 @@ +CREATE TABLE policy_notification_target ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + policy_id bigint NOT NULL, + user_id bigint NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + sent_at timestamp, + retry_count integer NOT NULL DEFAULT 0, + created_at timestamp, + UNIQUE (policy_id, user_id) +); diff --git a/src/main/resources/db/migration/V36__create_spring_batch_schema.sql b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql new file mode 100644 index 0000000..b52b1b2 --- /dev/null +++ b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql @@ -0,0 +1,79 @@ +-- Spring Batch 6.0.3 공식 PostgreSQL 스키마(schema-postgresql.sql)를 그대로 사용한다. +-- spring.batch.jdbc.initialize-schema=never로 설정해 부트 자동 초기화 대신 Flyway로 관리한다. + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO CYCLE; diff --git a/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql new file mode 100644 index 0000000..06d64af --- /dev/null +++ b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql @@ -0,0 +1 @@ +ALTER TABLE policy_document DROP COLUMN requires_reconsent; diff --git a/src/main/resources/mail-assets/logo.png b/src/main/resources/mail-assets/logo.png new file mode 100644 index 0000000..94ec391 Binary files /dev/null and b/src/main/resources/mail-assets/logo.png differ diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html new file mode 100644 index 0000000..4625db7 --- /dev/null +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -0,0 +1,46 @@ + + + + + 정책 변경 안내 + + +
+ 커밍 + +

+ 이용약관 변경 안내 +

+ +

+ 안녕하세요, 커밍입니다.
+ 이용약관이 아래와 같이 변경되어 안내드립니다. +

+ + + + + + + + + + +
시행일자2026-01-01
변경 내용변경 내용 요약
+ +

+ 전체 원문은 아래 링크에서 확인하실 수 있습니다.

+ + https://comingg.com/policy + +

+ +
+ +

+ 본 메일은 관련 법령에 따른 정책 변경 사전 고지 목적으로 발송되는 필수 안내이며, 수신거부가 적용되지 않습니다.
+ 발신: 커밍 (coming@example.com) +

+
+ + diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java index b3dd54f..6443028 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java @@ -24,9 +24,15 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -58,6 +64,12 @@ class AuthServiceRegisterTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String NEW_ACCESS_TOKEN = "new-access-token"; @@ -72,6 +84,17 @@ private User buildPendingUser() { .build(); } + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(type == PolicyType.TERMS ? 1L : 2L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("변경 요약") + .detailUrl("https://coming.example.com/policy") + .build(); + } + // ------------------------------------------------------------------------- // register // ------------------------------------------------------------------------- @@ -83,6 +106,12 @@ void should_return_token_and_change_role_to_user_when_registration_is_valid() { User user = buildPendingUser(); given(userRepository.existsByNickname("IU")).willReturn(false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.TERMS), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.TERMS))); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.PRIVACY), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.PRIVACY))); given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); // when diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index 65fd9b2..9705688 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -2,9 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.Coming.Backend.auth.dto.MarketingUpdateRequest; @@ -29,9 +34,17 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -63,6 +76,12 @@ class AuthServiceTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String REFRESH_TOKEN = "valid-refresh-token"; private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; @@ -80,6 +99,27 @@ private User buildUser() { .build(); } + private User buildPendingUser() { + return User.builder() + .id(USER_ID) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .provider("google") + .providerId("google-123") + .build(); + } + + private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { + return PolicyDocument.builder() + .id(policyId) + .type(type) + .version("1.0") + .effectiveDate(LocalDate.now()) + .changeSummary("최초 시행") + .detailUrl("https://coming.example.com/policy") + .build(); + } + // ------------------------------------------------------------------------- // refreshToken // ------------------------------------------------------------------------- @@ -329,16 +369,13 @@ void should_return_me_response_with_updated_nickname_when_nickname_is_given() { @Test void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_register() { // given - User user = User.builder() - .id(USER_ID) - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .provider("google") - .providerId("google-123") - .build(); + User user = buildPendingUser(); RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + any(PolicyType.class), any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(10L, PolicyType.TERMS))); willThrow(DataIntegrityViolationException.class).given(userRepository).flush(); // when & then @@ -346,4 +383,73 @@ void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_re .isInstanceOf(NicknameDuplicateException.class) .hasMessage(ErrorCode.NICKNAME_DUPLICATE.getMessage()); } + + @Test + void should_save_user_policy_agreement_for_terms_and_privacy_when_register_succeeds() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + + // when + authService.register(USER_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserPolicyAgreement.class); + verify(userPolicyAgreementRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(UserPolicyAgreement::getUserId, UserPolicyAgreement::getPolicyId) + .containsExactlyInAnyOrder(tuple(USER_ID, 10L), tuple(USER_ID, 20L)); + } + + @Test + void should_not_save_user_policy_agreement_when_agreement_already_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 10L)).willReturn(true); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 20L)).willReturn(true); + + // when + authService.register(USER_ID, request); + + // then + verify(userPolicyAgreementRepository, never()).save(any()); + } + + @Test + void should_throw_PolicyNotFoundException_when_no_current_policy_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.register(USER_ID, request)) + .isInstanceOf(PolicyNotFoundException.class) + .hasMessage(ErrorCode.POLICY_NOT_FOUND.getMessage()); + } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java new file mode 100644 index 0000000..87daa75 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java @@ -0,0 +1,61 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.launch.JobOperator; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationJobTriggerTest { + + @InjectMocks + private PolicyNotificationJobTrigger policyNotificationJobTrigger; + + @Mock + private JobOperator jobOperator; + + @Mock + private Job policyNotificationJob; + + @Test + void should_run_job_with_policy_id_when_policy_registered_event_given() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when + policyNotificationJobTrigger.onPolicyRegistered(event); + + // then + ArgumentCaptor jobParametersCaptor = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).run(eq(policyNotificationJob), jobParametersCaptor.capture()); + assertThat(jobParametersCaptor.getValue().getLong("policyId")).isEqualTo(1L); + } + + @Test + void should_not_throw_exception_when_job_operator_run_throws_exception() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + willThrow(new RuntimeException("job launch failed")).given(jobOperator).run(any(Job.class), any(JobParameters.class)); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when & then + assertThatCode(() -> policyNotificationJobTrigger.onPolicyRegistered(event)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java new file mode 100644 index 0000000..b165820 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java @@ -0,0 +1,155 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationMailProcessorTest { + + @InjectMocks + private PolicyNotificationMailProcessor policyNotificationMailProcessor; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNotificationSender policyNotificationSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_call_sender_with_found_user_when_user_exists_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + ArgumentCaptor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + ArgumentCaptor policyDocumentCaptor = ArgumentCaptor.forClass(PolicyDocument.class); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result).isSameAs(target); + verify(policyNotificationSender).sendAndMark(targetCaptor.capture(), userCaptor.capture(), policyDocumentCaptor.capture()); + assertThat(targetCaptor.getValue()).isSameAs(target); + assertThat(userCaptor.getValue()).isSameAs(user); + assertThat(policyDocumentCaptor.getValue()).isSameAs(policyDocument); + } + + @Test + void should_call_sender_with_null_user_when_user_not_found_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationMailProcessor.process(target); + + // then + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); + } + + @Test + void should_call_policy_document_repository_only_once_when_process_called_twice_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationMailProcessor.process(buildTarget(1L)); + policyNotificationMailProcessor.process(buildTarget(1L)); + + // then + verify(policyDocumentRepository, times(1)).findById(POLICY_ID); + } + + @Test + void should_throw_policy_not_found_exception_when_policy_document_not_found_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + + given(userRepository.findById(1L)).willReturn(Optional.of(buildUser(1L, "iu@coming.com"))); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> policyNotificationMailProcessor.process(target)) + .isInstanceOf(PolicyNotFoundException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java new file mode 100644 index 0000000..dd15ff9 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java @@ -0,0 +1,101 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +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.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationPendingTargetReaderTest { + + @InjectMocks + private PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long id) { + return PolicyNotificationTarget.builder() + .id(id) + .policyId(POLICY_ID) + .userId(id) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_return_items_in_order_when_first_batch_fetched_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + PolicyNotificationTarget target1 = buildTarget(1L); + PolicyNotificationTarget target2 = buildTarget(2L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of(target1, target2)); + + // when + PolicyNotificationTarget firstResult = policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget secondResult = policyNotificationPendingTargetReader.read(); + + // then + assertThat(firstResult).isSameAs(target1); + assertThat(secondResult).isSameAs(target2); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20)); + } + + @Test + void should_fetch_next_batch_using_last_read_id_as_cursor_when_current_batch_exhausted_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + List firstBatch = List.of(buildTarget(19L), buildTarget(20L)); + PolicyNotificationTarget target21 = buildTarget(21L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(firstBatch); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20))) + .willReturn(List.of(target21)); + + // when + policyNotificationPendingTargetReader.read(); + policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget resultFromNextBatch = policyNotificationPendingTargetReader.read(); + + // then + assertThat(resultFromNextBatch).isSameAs(target21); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20)); + } + + @Test + void should_return_null_when_repository_returns_empty_batch_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of()); + + // when + PolicyNotificationTarget result = policyNotificationPendingTargetReader.read(); + + // then + assertThat(result).isNull(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java new file mode 100644 index 0000000..ecb8879 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -0,0 +1,154 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationRetrySchedulerTest { + + @InjectMocks + private PolicyNotificationRetryScheduler policyNotificationRetryScheduler; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNotificationSender policyNotificationSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.FAILED) + .retryCount(1) + .build(); + } + + @Test + void should_do_nothing_when_no_retry_targets_given() { + // given + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(Collections.emptyList()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verifyNoInteractions(userRepository, policyDocumentRepository, policyNotificationSender); + } + + @Test + void should_mark_failed_without_calling_sender_when_policy_document_not_found_given() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNotificationSender, never()).sendAndMark(any(), any(), any()); + } + + @Test + void should_call_sender_with_found_user_and_policy_document_when_both_exist_given() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verify(policyNotificationSender).sendAndMark(target, user, policyDocument); + } + + @Test + void should_call_sender_with_null_user_when_user_not_found_but_policy_document_found_given() { + // given + PolicyNotificationTarget target = buildTarget(1L); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java new file mode 100644 index 0000000..42b5d00 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -0,0 +1,155 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import java.time.LocalDate; +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; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationSenderTest { + + @InjectMocks + private PolicyNotificationSender policyNotificationSender; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .build(); + } + + private PolicyNotificationTarget buildTarget() { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_mark_failed_when_user_is_null_given() { + // given + PolicyNotificationTarget target = buildTarget(); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, null, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_user_has_no_email_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, null); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_sent_when_send_succeeds_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(target.getSentAt()).isNotNull(); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_without_incrementing_retry_count_when_initial_send_throws_exception_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(0); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_already_failed_send_throws_exception_again_given() { + // given + PolicyNotificationTarget target = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.FAILED) + .retryCount(0) + .build(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(1); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java new file mode 100644 index 0000000..18a3702 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java @@ -0,0 +1,151 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.JobInstance; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.StepExecution; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetCreationTaskletTest { + + @InjectMocks + private PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private User buildActiveUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private ChunkContext buildChunkContext(Long policyId) { + JobParameters jobParameters = new JobParametersBuilder() + .addLong("policyId", policyId) + .toJobParameters(); + JobInstance jobInstance = new JobInstance(1L, "policyNotificationJob"); + JobExecution jobExecution = new JobExecution(1L, jobInstance, jobParameters); + StepExecution stepExecution = new StepExecution(1L, "targetCreationStep", jobExecution); + return new ChunkContext(new StepContext(stepExecution)); + } + + @Test + void should_save_pending_target_when_active_user_with_email_and_no_existing_target_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + ArgumentCaptor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository).save(targetCaptor.capture()); + PolicyNotificationTarget savedTarget = targetCaptor.getValue(); + assertThat(savedTarget.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(savedTarget.getUserId()).isEqualTo(1L); + assertThat(savedTarget.getStatus()).isEqualTo(NotificationStatus.PENDING); + assertThat(savedTarget.getRetryCount()).isZero(); + } + + @Test + void should_skip_user_when_target_already_exists_for_policy_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + PolicyNotificationTarget existingTarget = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of(existingTarget)); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_skip_user_when_email_is_null_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, null); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_return_finished_when_execute_completes_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of()); + + // when + RepeatStatus result = policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java new file mode 100644 index 0000000..1d3f3eb --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java @@ -0,0 +1,56 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.infrastructure.item.Chunk; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetWriterTest { + + @InjectMocks + private PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long userId, NotificationStatus status) { + return PolicyNotificationTarget.builder() + .id(userId) + .policyId(POLICY_ID) + .userId(userId) + .status(status) + .retryCount(0) + .build(); + } + + @Test + void should_save_all_chunk_items_when_write_called_given() { + // given + PolicyNotificationTarget sentTarget = buildTarget(1L, NotificationStatus.SENT); + PolicyNotificationTarget failedTarget = buildTarget(2L, NotificationStatus.FAILED); + Chunk chunk = new Chunk<>(List.of(sentTarget, failedTarget)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + + // when + policyNotificationTargetWriter.write(chunk); + + // then + verify(policyNotificationTargetRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).containsExactly(sentTarget, failedTarget); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java new file mode 100644 index 0000000..4fc48b8 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java @@ -0,0 +1,132 @@ +package com.Coming.Backend.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.service.PolicyService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PolicyControllerTest { + + private MockMvc mockMvc; + + @Mock + private PolicyService policyService; + + @InjectMocks + private PolicyController policyController; + + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(policyController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PolicyRegisterRequest validRequest() { + return new PolicyRegisterRequest( + PolicyType.TERMS, + "1.0.0", + LocalDate.of(2026, 1, 1), + "이용약관 개정", + "https://coming.example.com/policies/terms/1.0.0" + ); + } + + @Test + void should_return_201_when_register_request_is_valid() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + PolicyResponse response = new PolicyResponse( + 1L, PolicyType.TERMS, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.type").value("TERMS")) + .andExpect(jsonPath("$.version").value("1.0.0")) + .andExpect(jsonPath("$.effectiveDate").value("2026-01-01")) + .andExpect(jsonPath("$.changeSummary").value("이용약관 개정")) + .andExpect(jsonPath("$.detailUrl").value("https://coming.example.com/policies/terms/1.0.0")); + } + + @Test + void should_return_400_when_type_is_null_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + null, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_version_exceeds_max_length_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + PolicyType.TERMS, "1".repeat(51), LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_policy_version_already_registered() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))) + .willThrow(new PolicyVersionDuplicateException()); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.POLICY_VERSION_DUPLICATE.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java new file mode 100644 index 0000000..7c9218a --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java @@ -0,0 +1,131 @@ +package com.Coming.Backend.policy.mail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@ExtendWith(MockitoExtension.class) +class PolicyNoticeMailSenderTest { + + @InjectMocks + private PolicyNoticeMailSender policyNoticeMailSender; + + @Mock + private JavaMailSender javaMailSender; + + @Mock + private TemplateEngine templateEngine; + + private static final String FROM_ADDRESS = "coming@example.com"; + private static final String TO_EMAIL = "user@example.com"; + + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(1L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("개인정보 수집 항목 변경") + .detailUrl("https://coming.example.com/policy/1.0.0") + .build(); + } + + @Test + void should_send_mail_when_valid_policy_document_given() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), any(Context.class)); + verify(javaMailSender).send(any(MimeMessage.class)); + } + + @Test + void should_pass_policy_document_fields_to_template_context_when_send_called() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + Context capturedContext = contextCaptor.getValue(); + assertThat(capturedContext.getVariable("changeSummary")).isEqualTo(policyDocument.getChangeSummary()); + assertThat(capturedContext.getVariable("effectiveDate")).isEqualTo(policyDocument.getEffectiveDate()); + assertThat(capturedContext.getVariable("detailUrl")).isEqualTo(policyDocument.getDetailUrl()); + } + + @Test + void should_set_terms_label_when_policy_type_is_terms() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("이용약관"); + } + + @Test + void should_set_privacy_label_when_policy_type_is_privacy() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("개인정보처리방침"); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java new file mode 100644 index 0000000..654c346 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -0,0 +1,116 @@ +package com.Coming.Backend.policy.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.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +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.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; + +@ExtendWith(MockitoExtension.class) +class PolicyServiceTest { + + @InjectMocks + private PolicyService policyService; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private static final PolicyType POLICY_TYPE = PolicyType.TERMS; + private static final String VERSION = "1.0.0"; + + private PolicyRegisterRequest buildRequest() { + return new PolicyRegisterRequest( + POLICY_TYPE, + VERSION, + LocalDate.of(2026, 1, 1), + "이용약관 최초 등록", + "https://coming.example.com/policy/terms/1.0.0" + ); + } + + private PolicyDocument buildPolicyDocument(Long id, PolicyRegisterRequest request) { + return PolicyDocument.builder() + .id(id) + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .build(); + } + + @Test + void should_return_policy_response_when_valid_request_given() { + // given + PolicyRegisterRequest request = buildRequest(); + PolicyDocument savedPolicyDocument = buildPolicyDocument(1L, request); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + given(policyDocumentRepository.saveAndFlush(any(PolicyDocument.class))).willReturn(savedPolicyDocument); + + // when + PolicyResponse result = policyService.registerPolicy(request); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.type()).isEqualTo(POLICY_TYPE); + assertThat(result.version()).isEqualTo(VERSION); + assertThat(result.effectiveDate()).isEqualTo(request.effectiveDate()); + assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); + assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); + verify(eventPublisher).publishEvent(new PolicyRegisteredEvent(1L)); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_same_type_and_version_already_exists() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(true); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); + verify(policyDocumentRepository, never()).saveAndFlush(any(PolicyDocument.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_concurrent_insert_violates_unique_constraint() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + willThrow(new DataIntegrityViolationException("duplicate key")) + .given(policyDocumentRepository).saveAndFlush(any(PolicyDocument.class)); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); + verify(eventPublisher, never()).publishEvent(any()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/CommentControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/CommentControllerTest.java new file mode 100644 index 0000000..b6dd055 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/CommentControllerTest.java @@ -0,0 +1,193 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.exception.AlreadyLikedException; +import com.Coming.Backend.post.exception.CommentForbiddenException; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.NotLikedException; +import com.Coming.Backend.post.service.CommentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class CommentControllerTest { + + private MockMvc mockMvc; + + @Mock + private CommentService commentService; + + @InjectMocks + private CommentController commentController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long COMMENT_ID = 100L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(commentController) + .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ------------------------------------------------------------------------- + // DELETE /api/comments/{commentId} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_delete_succeeds() throws Exception { + // given + willDoNothing().given(commentService).delete(eq(USER_ID), eq(COMMENT_ID)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void should_return_404_when_comment_not_found_on_delete() throws Exception { + // given + willThrow(new CommentNotFoundException()).given(commentService).delete(eq(USER_ID), eq(999L)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_403_when_not_author_on_delete() throws Exception { + // given + willThrow(new CommentForbiddenException()).given(commentService).delete(eq(USER_ID), eq(COMMENT_ID)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // POST /api/comments/{commentId}/like + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_like_count_when_like_succeeds() throws Exception { + // given + given(commentService.like(eq(USER_ID), eq(COMMENT_ID))).willReturn(new CommentLikeCountResponse(5L)); + + // when & then + mockMvc.perform(post("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(5)); + } + + @Test + void should_return_404_when_comment_not_found_on_like() throws Exception { + // given + given(commentService.like(eq(USER_ID), eq(999L))).willThrow(new CommentNotFoundException()); + + // when & then + mockMvc.perform(post("/api/comments/{commentId}/like", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_already_liked_on_like() throws Exception { + // given + given(commentService.like(eq(USER_ID), eq(COMMENT_ID))).willThrow(new AlreadyLikedException()); + + // when & then + mockMvc.perform(post("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.ALREADY_LIKED.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // DELETE /api/comments/{commentId}/like + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_like_count_when_unlike_succeeds() throws Exception { + // given + given(commentService.unlike(eq(USER_ID), eq(COMMENT_ID))).willReturn(new CommentLikeCountResponse(3L)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(3)); + } + + @Test + void should_return_404_when_comment_not_found_on_unlike() throws Exception { + // given + given(commentService.unlike(eq(USER_ID), eq(999L))).willThrow(new CommentNotFoundException()); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}/like", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_not_liked_on_unlike() throws Exception { + // given + given(commentService.unlike(eq(USER_ID), eq(COMMENT_ID))).willThrow(new NotLikedException()); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOT_LIKED.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java new file mode 100644 index 0000000..07f3cbf --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java @@ -0,0 +1,119 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.service.PostService; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class EntityPostControllerTest { + + private MockMvc mockMvc; + + @Mock + private PostService postService; + + @InjectMocks + private EntityPostController entityPostController; + + private static final Long ARTIST_ID = 1L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(entityPostController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PostSummaryResponse buildSummary() { + return new PostSummaryResponse( + 10L, "IU", PostCategory.REVIEW, "제목", List.of(), 0L, 0L, LocalDateTime.now()); + } + + @Test + void should_return_200_with_page_response_when_default_sort_given() throws Exception { + // given + PageResponse pageResponse = + new PageResponse<>(List.of(buildSummary()), 0, 20, 1, 1); + given(postService.getBacklinks(eq(EntityType.ARTIST), eq(ARTIST_ID), eq("latest"), eq(0), eq(20))) + .willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/entities/ARTIST/{id}/posts", ARTIST_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("제목")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_call_service_with_recommend_sort_when_sort_query_param_given() throws Exception { + // given + PageResponse pageResponse = + new PageResponse<>(List.of(buildSummary()), 0, 20, 1, 1); + given(postService.getBacklinks(eq(EntityType.ARTIST), eq(ARTIST_ID), eq("recommend"), eq(0), eq(20))) + .willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/entities/ARTIST/{id}/posts", ARTIST_ID) + .param("sort", "recommend") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void should_return_400_when_service_throws_invalid_input_exception() throws Exception { + // given + given(postService.getBacklinks(eq(EntityType.ARTIST), eq(ARTIST_ID), eq("oldest"), eq(0), eq(20))) + .willThrow(new InvalidInputException()); + + // when & then + mockMvc.perform(get("/api/entities/ARTIST/{id}/posts", ARTIST_ID) + .param("sort", "oldest") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_type_path_variable_is_invalid_entity_type() throws Exception { + // when & then + mockMvc.perform(get("/api/entities/INVALID/{id}/posts", ARTIST_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java new file mode 100644 index 0000000..9977e2a --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java @@ -0,0 +1,113 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.MentionService; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +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.PageImpl; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class MentionControllerTest { + + private MockMvc mockMvc; + + @Mock + private MentionService mentionService; + + @InjectMocks + private MentionController mentionController; + + private static final Long CONCERT_ID = 1L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(mentionController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + @Test + void should_return_200_with_entity_card_list_when_valid_request_given() throws Exception { + // given + EntityCardResponse card = new EntityCardResponse( + EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null, null); + given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(0), eq(20))) + .willReturn(PageResponse.from(new PageImpl<>(List.of(card)))); + + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("type", "CONCERT") + .param("q", "아이유") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].type").value("CONCERT")) + .andExpect(jsonPath("$.content[0].id").value(CONCERT_ID)) + .andExpect(jsonPath("$.content[0].title").value("아이유 콘서트")); + } + + @Test + void should_return_400_when_type_is_missing() throws Exception { + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("q", "아이유") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_type_is_invalid_value() throws Exception { + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("type", "INVALID_TYPE") + .param("q", "아이유") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_200_with_empty_array_when_no_search_result_found() throws Exception { + // given + given(mentionService.search(eq(EntityType.ARTIST), eq("없는아티스트"), eq(0), eq(20))) + .willReturn(PageResponse.from(new PageImpl<>(List.of()))); + + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("type", "ARTIST") + .param("q", "없는아티스트") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(0)); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java new file mode 100644 index 0000000..7a39d1a --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java @@ -0,0 +1,227 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.InvalidReplyDepthException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.service.CommentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PostCommentControllerTest { + + private MockMvc mockMvc; + + @Mock + private CommentService commentService; + + @InjectMocks + private PostCommentController postCommentController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long POST_ID = 10L; + private static final Long PARENT_COMMENT_ID = 100L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(postCommentController) + .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private CommentResponse sampleComment() { + return new CommentResponse(PARENT_COMMENT_ID, "IU", true, "좋은 게시글이네요", 0L, false, + LocalDateTime.now(), List.of()); + } + + // ------------------------------------------------------------------------- + // GET /api/posts/{postId}/comments + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_comments_when_authenticated_user_requests() throws Exception { + // given + PageResponse pageResponse = new PageResponse<>(List.of(sampleComment()), 0, 20, 1, 1); + given(commentService.getComments(eq(POST_ID), eq(USER_ID), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts/{postId}/comments", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(PARENT_COMMENT_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_return_200_with_comments_when_anonymous_user_requests() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication(null); + PageResponse pageResponse = new PageResponse<>(List.of(sampleComment()), 0, 20, 1, 1); + given(commentService.getComments(eq(POST_ID), isNull(), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts/{postId}/comments", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_return_404_when_post_not_found_on_get_comments() throws Exception { + // given + given(commentService.getComments(eq(999L), eq(USER_ID), eq(0), eq(20))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(get("/api/posts/{postId}/comments", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // POST /api/posts/{postId}/comments + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_create_request_is_valid() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("좋은 글이네요", null); + given(commentService.create(eq(USER_ID), eq(POST_ID), any(CommentCreateRequest.class))) + .willReturn(new CommentCreateResponse(PARENT_COMMENT_ID)); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(PARENT_COMMENT_ID)); + } + + @Test + void should_return_400_when_content_is_blank_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("", null); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_content_exceeds_max_length_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("가".repeat(501), null); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_404_when_post_not_found_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("좋은 글이네요", null); + given(commentService.create(eq(USER_ID), eq(999L), any(CommentCreateRequest.class))) + .willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_404_when_parent_comment_not_found_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("답글입니다", 999L); + given(commentService.create(eq(USER_ID), eq(POST_ID), any(CommentCreateRequest.class))) + .willThrow(new CommentNotFoundException()); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_reply_to_reply_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("답글의 답글입니다", PARENT_COMMENT_ID); + given(commentService.create(eq(USER_ID), eq(POST_ID), any(CommentCreateRequest.class))) + .willThrow(new InvalidReplyDepthException()); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_REPLY_DEPTH.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java new file mode 100644 index 0000000..615eecd --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -0,0 +1,441 @@ +package com.Coming.Backend.post.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityTagRequest; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.exception.AlreadyRecommendedException; +import com.Coming.Backend.post.exception.NotRecommendedException; +import com.Coming.Backend.post.exception.PostForbiddenException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.service.PostService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PostControllerTest { + + private MockMvc mockMvc; + + @Mock + private PostService postService; + + @InjectMocks + private PostController postController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long POST_ID = 10L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(postController) + .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private Map sampleContent() { + return Map.of("type", "doc", "content", List.of()); + } + + private List entityTags(int count) { + return IntStream.rangeClosed(1, count) + .mapToObj(i -> new EntityTagRequest(EntityType.ARTIST, (long) i)) + .toList(); + } + + // ------------------------------------------------------------------------- + // POST /api/posts + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_create_request_is_valid() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), null); + given(postService.create(eq(USER_ID), any(PostCreateRequest.class))).willReturn(new PostCreateResponse(POST_ID)); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(POST_ID)); + } + + @Test + void should_return_400_when_service_throws_invalid_input_exception_on_create() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), null); + given(postService.create(eq(USER_ID), any(PostCreateRequest.class))).willThrow(new InvalidInputException()); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_create_request_has_more_than_10_entity_tags() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), entityTags(11)); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").value(containsString("태그는 10개를 초과할 수 없습니다"))); + } + + @Test + void should_return_400_when_create_request_has_null_entity_tag_element() throws Exception { + // given: EntityTagRequest 대신 null이 섞인 요청을 직접 JSON으로 구성한다. + String requestJson = """ + {"category":"FREE","title":"자유 제목","content":{"type":"doc","content":[]},"entityTags":[null]} + """; + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_201_when_create_request_has_exactly_10_entity_tags() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), entityTags(10)); + given(postService.create(eq(USER_ID), any(PostCreateRequest.class))).willReturn(new PostCreateResponse(POST_ID)); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(POST_ID)); + verify(postService).create(eq(USER_ID), any(PostCreateRequest.class)); + } + + // ------------------------------------------------------------------------- + // GET /api/posts/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_post_detail_when_post_exists() throws Exception { + // given + PostDetailResponse detail = new PostDetailResponse( + POST_ID, "IU", PostCategory.FREE, "제목", sampleContent(), + List.of(), 0L, 1L, 0L, null, true, LocalDateTime.now(), LocalDateTime.now() + ); + given(postService.getDetail(eq(POST_ID), eq(USER_ID))).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/posts/{id}", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(POST_ID)) + .andExpect(jsonPath("$.title").value("제목")); + } + + @Test + void should_return_404_when_post_not_found_on_get_detail() throws Exception { + // given + given(postService.getDetail(eq(999L), eq(USER_ID))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(get("/api/posts/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/posts + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_page_response_when_getting_list() throws Exception { + // given + PostSummaryResponse summary = new PostSummaryResponse( + POST_ID, "IU", PostCategory.FREE, "제목", List.of(), 0L, 0L, LocalDateTime.now()); + PageResponse pageResponse = new PageResponse<>(List.of(summary), 0, 20, 1, 1); + given(postService.getList(isNull(), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(POST_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + // ------------------------------------------------------------------------- + // GET /api/posts/popular + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_popular_posts_when_default_params_given() throws Exception { + // given + PostSummaryResponse summary = new PostSummaryResponse( + POST_ID, "IU", PostCategory.FREE, "인기 게시글", List.of(), 10L, 0L, LocalDateTime.now()); + given(postService.getPopular(eq(7), eq(5))).willReturn(List.of(summary)); + + // when & then + mockMvc.perform(get("/api/posts/popular").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].id").value(POST_ID)) + .andExpect(jsonPath("$[0].title").value("인기 게시글")); + } + + // ------------------------------------------------------------------------- + // GET /api/posts/trending-tags + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_trending_tags_when_default_params_given() throws Exception { + // given + TrendingTagResponse tag = new TrendingTagResponse(EntityType.ARTIST, 1L, "IU", 5L); + given(postService.getTrendingTags(eq(7), eq(10))).willReturn(List.of(tag)); + + // when & then + mockMvc.perform(get("/api/posts/trending-tags").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].entityType").value("ARTIST")) + .andExpect(jsonPath("$[0].entityId").value(1)) + .andExpect(jsonPath("$[0].title").value("IU")) + .andExpect(jsonPath("$[0].count").value(5)); + } + + // ------------------------------------------------------------------------- + // PATCH /api/posts/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_update_request_is_valid() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", null, null); + willDoNothing().given(postService).update(eq(USER_ID), eq(POST_ID), any(PostUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void should_return_403_when_service_throws_forbidden_exception_on_update() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", null, null); + willThrow(new PostForbiddenException()).given(postService) + .update(eq(USER_ID), eq(POST_ID), any(PostUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_update_request_has_blank_title() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, " ", null, null); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_400_when_update_request_has_more_than_10_entity_tags() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, null, null, entityTags(11)); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").value(containsString("태그는 10개를 초과할 수 없습니다"))); + } + + // ------------------------------------------------------------------------- + // DELETE /api/posts/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_delete_succeeds() throws Exception { + // given + willDoNothing().given(postService).delete(eq(USER_ID), eq(POST_ID)); + + // when & then + mockMvc.perform(delete("/api/posts/{id}", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void should_return_404_when_post_not_found_on_delete() throws Exception { + // given + willThrow(new PostNotFoundException()).given(postService).delete(eq(USER_ID), eq(999L)); + + // when & then + mockMvc.perform(delete("/api/posts/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // POST /api/posts/{id}/recommend + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_recommend_count_when_recommend_succeeds() throws Exception { + // given + given(postService.recommend(eq(USER_ID), eq(POST_ID))).willReturn(new RecommendCountResponse(4L)); + + // when & then + mockMvc.perform(post("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.recommendCount").value(4)); + } + + @Test + void should_return_404_when_post_not_found_on_recommend() throws Exception { + // given + given(postService.recommend(eq(USER_ID), eq(999L))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(post("/api/posts/{id}/recommend", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_post_already_recommended_on_recommend() throws Exception { + // given + given(postService.recommend(eq(USER_ID), eq(POST_ID))).willThrow(new AlreadyRecommendedException()); + + // when & then + mockMvc.perform(post("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.ALREADY_RECOMMENDED.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // DELETE /api/posts/{id}/recommend + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_recommend_count_when_unrecommend_succeeds() throws Exception { + // given + given(postService.unrecommend(eq(USER_ID), eq(POST_ID))).willReturn(new RecommendCountResponse(2L)); + + // when & then + mockMvc.perform(delete("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.recommendCount").value(2)); + } + + @Test + void should_return_404_when_post_not_found_on_unrecommend() throws Exception { + // given + given(postService.unrecommend(eq(USER_ID), eq(999L))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(delete("/api/posts/{id}/recommend", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_post_not_recommended_on_unrecommend() throws Exception { + // given + given(postService.unrecommend(eq(USER_ID), eq(POST_ID))).willThrow(new NotRecommendedException()); + + // when & then + mockMvc.perform(delete("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOT_RECOMMENDED.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java new file mode 100644 index 0000000..3d15e8f --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java @@ -0,0 +1,85 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.service.PostService; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class SearchControllerTest { + + private MockMvc mockMvc; + + @Mock + private PostService postService; + + @InjectMocks + private SearchController searchController; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(searchController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PostSummaryResponse buildSummary() { + return new PostSummaryResponse( + 10L, "IU", PostCategory.REVIEW, "IU 콘서트 후기", List.of(), 0L, 0L, LocalDateTime.now()); + } + + @Test + void should_return_200_with_page_response_when_valid_query_given() throws Exception { + // given + PageResponse pageResponse = + new PageResponse<>(List.of(buildSummary()), 0, 20, 1, 1); + given(postService.search(eq("IU"), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/search") + .param("q", "IU") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("IU 콘서트 후기")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_return_400_when_q_is_missing() throws Exception { + // when & then + mockMvc.perform(get("/api/search") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java new file mode 100644 index 0000000..9d6d646 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java @@ -0,0 +1,215 @@ +package com.Coming.Backend.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.post.entity.Comment; +import com.Coming.Backend.post.entity.CommentLike; +import jakarta.persistence.EntityManager; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class CommentRepositoryTest { + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private CommentLikeRepository commentLikeRepository; + + @Autowired + private EntityManager entityManager; + + private static final Long AUTHOR_ID = 1L; + private static final Long POST_ID = 100L; + private static final Long OTHER_POST_ID = 200L; + + private Comment buildComment(Long postId, Long parentCommentId, String content) { + return buildComment(postId, parentCommentId, content, 0L); + } + + private Comment buildComment(Long postId, Long parentCommentId, String content, long likeCount) { + return Comment.builder() + .postId(postId) + .userId(AUTHOR_ID) + .parentCommentId(parentCommentId) + .content(content) + .likeCount(likeCount) + .deleted(false) + .build(); + } + + @Test + void should_return_only_top_level_comments_ordered_by_created_at_when_finding_top_level_by_post_id() { + // given + Comment first = commentRepository.save(buildComment(POST_ID, null, "첫 댓글")); + Comment second = commentRepository.save(buildComment(POST_ID, null, "두번째 댓글")); + commentRepository.save(buildComment(POST_ID, first.getId(), "답글은 제외")); + commentRepository.save(buildComment(OTHER_POST_ID, null, "다른 게시글 댓글")); + + // when + Page result = commentRepository.findTopLevelByPostId(POST_ID, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Comment::getId) + .containsExactly(first.getId(), second.getId()); + } + + @Test + void should_return_empty_page_when_post_has_no_comments() { + // when + Page result = commentRepository.findTopLevelByPostId(POST_ID, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + + @Test + void should_return_replies_ordered_by_created_at_when_parent_ids_given() { + // given + Comment parent = commentRepository.save(buildComment(POST_ID, null, "부모 댓글")); + Comment firstReply = commentRepository.save(buildComment(POST_ID, parent.getId(), "첫 답글")); + Comment secondReply = commentRepository.save(buildComment(POST_ID, parent.getId(), "두번째 답글")); + + // when + List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(parent.getId())); + + // then + assertThat(replies).extracting(Comment::getId) + .containsExactly(firstReply.getId(), secondReply.getId()); + } + + @Test + void should_return_empty_list_without_error_when_parent_ids_is_empty() { + // given + commentRepository.save(buildComment(POST_ID, null, "댓글")); + + // when + List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of()); + + // then + assertThat(replies).isEmpty(); + } + + @Test + void should_increment_like_count_when_incrementing() { + // given + Comment comment = commentRepository.save(buildComment(POST_ID, null, "댓글")); + + // when + commentRepository.incrementLikeCount(comment.getId()); + entityManager.clear(); + Comment updated = commentRepository.findById(comment.getId()).orElseThrow(); + + // then + assertThat(updated.getLikeCount()).isEqualTo(1L); + } + + @Test + void should_decrement_like_count_when_decrementing() { + // given + Comment comment = commentRepository.save(buildComment(POST_ID, null, "댓글", 1L)); + + // when + commentRepository.decrementLikeCount(comment.getId()); + entityManager.clear(); + Comment updated = commentRepository.findById(comment.getId()).orElseThrow(); + + // then + assertThat(updated.getLikeCount()).isZero(); + } + + @Test + void should_return_only_liked_comment_ids_when_finding_liked_comment_ids() { + // given + Comment liked = commentRepository.save(buildComment(POST_ID, null, "좋아요한 댓글")); + Comment notLiked = commentRepository.save(buildComment(POST_ID, null, "좋아요 안한 댓글")); + commentLikeRepository.save(CommentLike.builder().userId(AUTHOR_ID).commentId(liked.getId()).build()); + + // when + List likedIds = commentLikeRepository.findLikedCommentIds(AUTHOR_ID, List.of(liked.getId(), notLiked.getId())); + + // then + assertThat(likedIds).containsExactly(liked.getId()); + } + + @Test + void should_return_empty_list_without_error_when_comment_ids_is_empty_on_finding_liked_comment_ids() { + // when + List likedIds = commentLikeRepository.findLikedCommentIds(AUTHOR_ID, List.of()); + + // then + assertThat(likedIds).isEmpty(); + } + + @Test + void should_return_only_comment_ids_belonging_to_post_when_finding_ids_by_post_id() { + // given + Comment first = commentRepository.save(buildComment(POST_ID, null, "댓글1")); + Comment second = commentRepository.save(buildComment(POST_ID, first.getId(), "댓글2")); + commentRepository.save(buildComment(OTHER_POST_ID, null, "다른 게시글 댓글")); + + // when + List ids = commentRepository.findIdsByPostId(POST_ID); + + // then + assertThat(ids).containsExactlyInAnyOrder(first.getId(), second.getId()); + } + + @Test + void should_delete_only_comments_belonging_to_post_when_deleting_by_post_id() { + // given + Comment target = commentRepository.save(buildComment(POST_ID, null, "삭제될 댓글")); + Comment other = commentRepository.save(buildComment(OTHER_POST_ID, null, "유지될 댓글")); + + // when + commentRepository.deleteByPostId(POST_ID); + entityManager.flush(); + entityManager.clear(); + + // then + assertThat(commentRepository.findById(target.getId())).isEmpty(); + assertThat(commentRepository.findById(other.getId())).isPresent(); + } + + @Test + void should_delete_only_likes_for_given_comment_ids_when_deleting_by_comment_id_in() { + // given + Comment target = commentRepository.save(buildComment(POST_ID, null, "댓글")); + Comment other = commentRepository.save(buildComment(POST_ID, null, "다른 댓글")); + CommentLike targetLike = commentLikeRepository.save( + CommentLike.builder().userId(AUTHOR_ID).commentId(target.getId()).build()); + CommentLike otherLike = commentLikeRepository.save( + CommentLike.builder().userId(AUTHOR_ID).commentId(other.getId()).build()); + + // when + commentLikeRepository.deleteByCommentIdIn(List.of(target.getId())); + entityManager.flush(); + entityManager.clear(); + + // then + assertThat(commentLikeRepository.findById(targetLike.getId())).isEmpty(); + assertThat(commentLikeRepository.findById(otherLike.getId())).isPresent(); + } + + @Test + void should_return_current_like_count_when_finding_like_count_by_id() { + // given + Comment comment = commentRepository.save(buildComment(POST_ID, null, "댓글", 3L)); + + // when + Long likeCount = commentRepository.findLikeCountById(comment.getId()); + + // then + assertThat(likeCount).isEqualTo(3L); + } +} diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java new file mode 100644 index 0000000..26ec90d --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -0,0 +1,222 @@ +package com.Coming.Backend.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.release.entity.Track; +import com.Coming.Backend.release.repository.TrackRepository; + +import java.util.Locale; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class PostRepositoryTest { + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostEntityTagRepository postEntityTagRepository; + + @Autowired + private ArtistRepository artistRepository; + + @Autowired + private TrackRepository trackRepository; + + private static final Long AUTHOR_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private Post buildPost(String title, String contentText) { + return Post.builder() + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title(title) + .content("{\"type\":\"doc\"}") + .contentText(contentText) + .recommendCount(0L) + .viewCount(0L) + .commentCount(0L) + .build(); + } + + @Test + void should_return_post_when_title_matches_search_query() { + // given + Post post = postRepository.save(buildPost("아이유 단독 콘서트 후기", "정말 좋았다")); + + // when + Page result = postRepository.searchPosts(likeQuery("아이유"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_return_post_when_content_text_matches_search_query() { + // given + Post post = postRepository.save(buildPost("공연 후기", "정말 재밌는 아이유 콘서트였다")); + + // when + Page result = postRepository.searchPosts(likeQuery("아이유"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_return_post_when_tagged_artist_name_matches_search_query() { + // given + Artist artist = artistRepository.save(Artist.builder() + .mbid(UUID.randomUUID().toString()) + .name("아이유") + .isComing(false) + .build()); + Post post = postRepository.save(buildPost("이 가수 좋아요", "최근에 알게 됐어요")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.ARTIST) + .entityId(artist.getId()) + .build()); + + // when + Page result = postRepository.searchPosts(likeQuery("아이유"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_return_post_when_tagged_track_title_matches_search_query() { + // given + Track track = trackRepository.save(Track.builder() + .releaseGroupId(1L) + .title("LILAC") + .position(1) + .build()); + Post post = postRepository.save(buildPost("이 곡 좋아요", "요즘 계속 듣는 중")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.searchPosts(likeQuery("LILAC"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_return_empty_page_when_no_post_matches_search_query() { + // given + postRepository.save(buildPost("다른 제목", "다른 내용")); + + // when + Page result = postRepository.searchPosts(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_return_post_when_tagged_track_belongs_to_release() { + // given + Long releaseGroupId = 1L; + Track track = trackRepository.save(Track.builder() + .releaseGroupId(releaseGroupId) + .title("LILAC") + .position(1) + .build()); + Post post = postRepository.save(buildPost("이 앨범의 이 곡이 좋아요", "명곡이다")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.findByEntityTag(EntityType.RELEASE, releaseGroupId, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_not_return_post_when_tagged_track_belongs_to_other_release() { + // given + Track track = trackRepository.save(Track.builder() + .releaseGroupId(2L) + .title("다른 앨범 트랙") + .position(1) + .build()); + Post post = postRepository.save(buildPost("다른 앨범 곡 얘기", "내용")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.findByEntityTag(EntityType.RELEASE, 1L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).doesNotContain(post.getId()); + } + + @Test + void should_return_current_recommend_count_when_finding_recommend_count_by_id() { + // given + Post post = Post.builder() + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title("제목") + .content("{\"type\":\"doc\"}") + .contentText("내용") + .recommendCount(5L) + .viewCount(0L) + .commentCount(0L) + .build(); + Post saved = postRepository.save(post); + + // when + Long recommendCount = postRepository.findRecommendCountById(saved.getId()); + + // then + assertThat(recommendCount).isEqualTo(5L); + } + + @Test + void should_return_posts_ordered_by_created_at_desc_when_multiple_posts_match() throws InterruptedException { + // given + Post olderPost = postRepository.save(buildPost("아이유 1번째 글", "내용")); + Thread.sleep(10); + Post newerPost = postRepository.save(buildPost("아이유 2번째 글", "내용")); + + // when + Pageable pageable = PageRequest.of(0, 20); + Page result = postRepository.searchPosts(likeQuery("아이유"), pageable); + + // then + assertThat(result.getContent()).extracting(Post::getId) + .containsSubsequence(newerPost.getId(), olderPost.getId()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java new file mode 100644 index 0000000..240a29c --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -0,0 +1,448 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.entity.Comment; +import com.Coming.Backend.post.entity.CommentLike; +import com.Coming.Backend.post.exception.AlreadyLikedException; +import com.Coming.Backend.post.exception.CommentForbiddenException; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.InvalidReplyDepthException; +import com.Coming.Backend.post.exception.NotLikedException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import java.util.List; +import java.util.Optional; +import java.util.Set; +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.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +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; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @InjectMocks + private CommentService commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentLikeRepository commentLikeRepository; + + @Mock + private PostRepository postRepository; + + @Mock + private UserRepository userRepository; + + private static final Long POST_ID = 100L; + private static final Long OTHER_POST_ID = 200L; + private static final Long AUTHOR_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long VIEWER_ID = 3L; + private static final Long COMMENT_ID = 10L; + private static final Long REPLY_ID = 11L; + private static final Long PARENT_ID = 20L; + + private Comment buildComment(Long id, Long postId, Long userId, Long parentCommentId, String content, + long likeCount, boolean deleted) { + return Comment.builder() + .id(id) + .postId(postId) + .userId(userId) + .parentCommentId(parentCommentId) + .content(content) + .likeCount(likeCount) + .deleted(deleted) + .build(); + } + + private User buildUser(Long id, String nickname) { + return User.builder().id(id).nickname(nickname).build(); + } + + // ------------------------------------------------------------------------- + // getComments + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_post_does_not_exist_on_get_comments() { + // given + given(postRepository.existsById(POST_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> commentService.getComments(POST_ID, null, 0, 20)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_return_top_level_comments_with_nested_replies_when_comments_exist() { + // given + Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "탑레벨 댓글", 2L, false); + Comment reply = buildComment(REPLY_ID, POST_ID, OTHER_USER_ID, COMMENT_ID, "답글", 0L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); + + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) + .willReturn(List.of(reply)); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) + .willReturn(List.of(buildUser(AUTHOR_ID, "IU"), buildUser(OTHER_USER_ID, "뷔"))); + + // when + PageResponse response = commentService.getComments(POST_ID, null, 0, 20); + + // then + assertThat(response.content()).hasSize(1); + CommentResponse topResponse = response.content().get(0); + assertThat(topResponse.id()).isEqualTo(COMMENT_ID); + assertThat(topResponse.replies()).hasSize(1); + assertThat(topResponse.replies().get(0).id()).isEqualTo(REPLY_ID); + assertThat(topResponse.replies().get(0).replies()).isEmpty(); + assertThat(response.page()).isEqualTo(0); + assertThat(response.totalElements()).isEqualTo(1); + } + + @Test + void should_return_null_is_liked_and_false_is_author_when_user_id_not_given() { + // given + Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 1L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); + + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) + .willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = commentService.getComments(POST_ID, null, 0, 20); + + // then + CommentResponse topResponse = response.content().get(0); + assertThat(topResponse.isLiked()).isNull(); + assertThat(topResponse.isAuthor()).isFalse(); + } + + @Test + void should_mark_comment_as_liked_when_user_has_liked_it() { + // given + Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 1L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); + + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) + .willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(COMMENT_ID))) + .willReturn(List.of(COMMENT_ID)); + + // when + PageResponse response = commentService.getComments(POST_ID, VIEWER_ID, 0, 20); + + // then + assertThat(response.content().get(0).isLiked()).isTrue(); + } + + @Test + void should_replace_content_but_show_nickname_and_like_count_when_comment_is_deleted() { + // given + Comment deletedTopComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제될 댓글", 3L, true); + Comment reply = buildComment(REPLY_ID, POST_ID, OTHER_USER_ID, COMMENT_ID, "답글", 0L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(deletedTopComment), pageable, 1); + + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) + .willReturn(List.of(reply)); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) + .willReturn(List.of(buildUser(AUTHOR_ID, "지민"), buildUser(OTHER_USER_ID, "뷔"))); + given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(REPLY_ID))) + .willReturn(List.of()); + + // when + PageResponse response = commentService.getComments(POST_ID, VIEWER_ID, 0, 20); + + // then + CommentResponse topResponse = response.content().get(0); + assertThat(topResponse.content()).isEqualTo("삭제된 댓글입니다"); + assertThat(topResponse.authorNickname()).isEqualTo("지민"); + assertThat(topResponse.likeCount()).isEqualTo(3L); + assertThat(topResponse.isLiked()).isNull(); + assertThat(topResponse.isAuthor()).isFalse(); + assertThat(topResponse.replies()).hasSize(1); + assertThat(topResponse.replies().get(0).content()).isEqualTo("답글"); + } + + // ------------------------------------------------------------------------- + // create + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_post_does_not_exist_on_create() { + // given + given(postRepository.existsById(POST_ID)).willReturn(false); + CommentCreateRequest request = new CommentCreateRequest("내용", null); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_save_top_level_comment_and_increment_comment_count_when_parent_comment_id_is_null() { + // given + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.save(any(Comment.class))).willAnswer(invocation -> { + Comment saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", COMMENT_ID); + return saved; + }); + CommentCreateRequest request = new CommentCreateRequest("내용", null); + + // when + CommentCreateResponse response = commentService.create(AUTHOR_ID, POST_ID, request); + + // then + assertThat(response.id()).isEqualTo(COMMENT_ID); + verify(postRepository).incrementCommentCount(POST_ID); + } + + @Test + void should_throw_comment_not_found_exception_when_parent_comment_does_not_exist() { + // given + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.empty()); + CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + verify(commentRepository, never()).save(any(Comment.class)); + verify(postRepository, never()).incrementCommentCount(any()); + } + + @Test + void should_throw_comment_not_found_exception_when_parent_comment_belongs_to_other_post() { + // given + Comment parent = buildComment(PARENT_ID, OTHER_POST_ID, AUTHOR_ID, null, "다른 게시글 댓글", 0L, false); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_invalid_reply_depth_exception_when_parent_comment_is_already_a_reply() { + // given + Comment parent = buildComment(PARENT_ID, POST_ID, AUTHOR_ID, COMMENT_ID, "이미 답글인 댓글", 0L, false); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(InvalidReplyDepthException.class) + .hasMessage(ErrorCode.INVALID_REPLY_DEPTH.getMessage()); + verify(commentRepository, never()).save(any(Comment.class)); + } + + // ------------------------------------------------------------------------- + // delete + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_delete() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.delete(AUTHOR_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_comment_forbidden_exception_when_deleter_is_not_author() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 0L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.delete(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(CommentForbiddenException.class) + .hasMessage(ErrorCode.FORBIDDEN.getMessage()); + } + + @Test + void should_soft_delete_comment_when_author_deletes() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 0L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when + commentService.delete(AUTHOR_ID, COMMENT_ID); + + // then + assertThat(comment.isDeleted()).isTrue(); + verify(commentRepository, never()).delete(any(Comment.class)); + } + + // ------------------------------------------------------------------------- + // like + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_like() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.like(AUTHOR_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_already_liked_exception_when_user_already_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.existsByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(true); + + // when & then + assertThatThrownBy(() -> commentService.like(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(AlreadyLikedException.class) + .hasMessage(ErrorCode.ALREADY_LIKED.getMessage()); + verify(commentLikeRepository, never()).save(any(CommentLike.class)); + verify(commentRepository, never()).incrementLikeCount(any()); + } + + @Test + void should_throw_comment_not_found_exception_when_liking_deleted_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제된 댓글", 5L, true); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.like(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + verify(commentLikeRepository, never()).save(any(CommentLike.class)); + verify(commentRepository, never()).incrementLikeCount(any()); + } + + @Test + void should_increment_like_count_and_return_incremented_count_when_user_has_not_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.existsByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(false); + given(commentRepository.findLikeCountById(COMMENT_ID)).willReturn(6L); + + // when + CommentLikeCountResponse response = commentService.like(OTHER_USER_ID, COMMENT_ID); + + // then + assertThat(response.likeCount()).isEqualTo(6L); + verify(commentRepository).incrementLikeCount(COMMENT_ID); + verify(commentLikeRepository).save(any(CommentLike.class)); + } + + // ------------------------------------------------------------------------- + // unlike + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_unlike() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.unlike(AUTHOR_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_not_liked_exception_when_user_has_not_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.findByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.unlike(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(NotLikedException.class) + .hasMessage(ErrorCode.NOT_LIKED.getMessage()); + verify(commentLikeRepository, never()).delete(any(CommentLike.class)); + verify(commentRepository, never()).decrementLikeCount(any()); + } + + @Test + void should_throw_comment_not_found_exception_when_unliking_deleted_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제된 댓글", 5L, true); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.unlike(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + verify(commentLikeRepository, never()).delete(any(CommentLike.class)); + verify(commentRepository, never()).decrementLikeCount(any()); + } + + @Test + void should_decrement_like_count_and_return_decremented_count_when_user_has_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + CommentLike like = CommentLike.builder().userId(OTHER_USER_ID).commentId(COMMENT_ID).build(); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.findByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(Optional.of(like)); + given(commentRepository.findLikeCountById(COMMENT_ID)).willReturn(4L); + + // when + CommentLikeCountResponse response = commentService.unlike(OTHER_USER_ID, COMMENT_ID); + + // then + assertThat(response.likeCount()).isEqualTo(4L); + verify(commentLikeRepository).delete(like); + verify(commentRepository).decrementLikeCount(COMMENT_ID); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java new file mode 100644 index 0000000..4cd0701 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java @@ -0,0 +1,234 @@ +package com.Coming.Backend.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.EntityLookupService.EntityKey; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; + +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; + +@ExtendWith(MockitoExtension.class) +class EntityLookupServiceTest { + + @InjectMocks + private EntityLookupService entityLookupService; + + @Mock + private ConcertRepository concertRepository; + + @Mock + private ArtistRepository artistRepository; + + @Mock + private ReleaseGroupRepository releaseGroupRepository; + + @Mock + private TrackRepository trackRepository; + + private static final Long CONCERT_ID = 1L; + private static final Long ARTIST_ID = 10L; + private static final Long RELEASE_ID = 100L; + private static final Long RELEASE_ARTIST_ID = 20L; + private static final Long TRACK_ID = 200L; + + private Concert buildConcert(Long id) { + return Concert.builder() + .id(id) + .title("IU Concert : HEREH WORLD TOUR") + .startDate(LocalDate.of(2025, 10, 1)) + .venueName("올림픽공원 케이스포돔") + .posterUrl("https://example.com/concert-poster.jpg") + .build(); + } + + private Artist buildArtist(Long id, String name) { + return Artist.builder() + .id(id) + .mbid("mbid-" + id) + .name(name) + .imageUrl("https://example.com/artist-" + id + ".jpg") + .build(); + } + + private ReleaseGroup buildReleaseGroup(Long id, Long artistId) { + return ReleaseGroup.builder() + .id(id) + .artistId(artistId) + .title("LILAC") + .coverUrl("https://example.com/release-" + id + ".jpg") + .build(); + } + + private Track buildTrack(Long id, Long releaseGroupId) { + return Track.builder() + .id(id) + .releaseGroupId(releaseGroupId) + .title("라일락") + .position(1) + .build(); + } + + @Test + void should_fill_subtitle_with_date_and_venue_and_thumbnail_with_poster_url_when_concert_key_given() { + // given + Concert concert = buildConcert(CONCERT_ID); + given(concertRepository.findAllById(any())).willReturn(List.of(concert)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.CONCERT, CONCERT_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.CONCERT, CONCERT_ID)); + assertThat(card.subtitle()).isEqualTo("2025-10-01 · 올림픽공원 케이스포돔"); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/concert-poster.jpg"); + } + + @Test + void should_return_null_subtitle_and_image_url_as_thumbnail_when_artist_key_given() { + // given + Artist artist = buildArtist(ARTIST_ID, "IU"); + given(artistRepository.findAllById(any())).willReturn(List.of(artist)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.ARTIST, ARTIST_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.ARTIST, ARTIST_ID)); + assertThat(card.subtitle()).isNull(); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/artist-10.jpg"); + } + + @Test + void should_fill_subtitle_with_joined_artist_name_and_thumbnail_with_cover_url_when_release_key_given() { + // given + ReleaseGroup releaseGroup = buildReleaseGroup(RELEASE_ID, RELEASE_ARTIST_ID); + Artist artist = buildArtist(RELEASE_ARTIST_ID, "IU"); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of(releaseGroup)); + given(artistRepository.findAllById(any())).willReturn(List.of(artist)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.RELEASE, RELEASE_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.RELEASE, RELEASE_ID)); + assertThat(card.subtitle()).isEqualTo("IU"); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/release-100.jpg"); + } + + @Test + void should_fill_subtitle_with_artist_and_album_and_thumbnail_with_cover_url_when_track_key_given() { + // given + Track track = buildTrack(TRACK_ID, RELEASE_ID); + ReleaseGroup releaseGroup = buildReleaseGroup(RELEASE_ID, RELEASE_ARTIST_ID); + Artist artist = buildArtist(RELEASE_ARTIST_ID, "IU"); + given(trackRepository.findAllById(any())).willReturn(List.of(track)); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of(releaseGroup)); + given(artistRepository.findAllById(any())).willReturn(List.of(artist)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.TRACK, TRACK_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.TRACK, TRACK_ID)); + assertThat(card.subtitle()).isEqualTo("IU · LILAC"); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/release-100.jpg"); + } + + @Test + void should_fill_only_title_when_track_release_group_not_found() { + // given + Track track = buildTrack(TRACK_ID, RELEASE_ID); + given(trackRepository.findAllById(any())).willReturn(List.of(track)); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of()); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.TRACK, TRACK_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.TRACK, TRACK_ID)); + assertThat(card.title()).isEqualTo("라일락"); + assertThat(card.subtitle()).isNull(); + assertThat(card.thumbnailUrl()).isNull(); + assertThat(card.releaseGroupId()).isNull(); + } + + @Test + void should_fill_all_three_types_when_mixed_keys_given() { + // given + Concert concert = buildConcert(CONCERT_ID); + Artist directArtist = buildArtist(ARTIST_ID, "IU"); + ReleaseGroup releaseGroup = buildReleaseGroup(RELEASE_ID, RELEASE_ARTIST_ID); + Artist releaseArtist = buildArtist(RELEASE_ARTIST_ID, "BTS"); + + given(concertRepository.findAllById(Set.of(CONCERT_ID))).willReturn(List.of(concert)); + given(artistRepository.findAllById(Set.of(ARTIST_ID))).willReturn(List.of(directArtist)); + given(releaseGroupRepository.findAllById(Set.of(RELEASE_ID))).willReturn(List.of(releaseGroup)); + given(artistRepository.findAllById(Set.of(RELEASE_ARTIST_ID))).willReturn(List.of(releaseArtist)); + + List keys = List.of( + new EntityKey(EntityType.CONCERT, CONCERT_ID), + new EntityKey(EntityType.ARTIST, ARTIST_ID), + new EntityKey(EntityType.RELEASE, RELEASE_ID) + ); + + // when + Map result = entityLookupService.findCards(keys); + + // then + assertThat(result).hasSize(3); + assertThat(result.get(new EntityKey(EntityType.CONCERT, CONCERT_ID)).type()).isEqualTo(EntityType.CONCERT); + assertThat(result.get(new EntityKey(EntityType.ARTIST, ARTIST_ID)).type()).isEqualTo(EntityType.ARTIST); + assertThat(result.get(new EntityKey(EntityType.RELEASE, RELEASE_ID)).type()).isEqualTo(EntityType.RELEASE); + assertThat(result.get(new EntityKey(EntityType.RELEASE, RELEASE_ID)).subtitle()).isEqualTo("BTS"); + } + + @Test + void should_exclude_key_from_result_when_entity_does_not_exist() { + // given + given(concertRepository.findAllById(any())).willReturn(List.of()); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.CONCERT, 999L))); + + // then + assertThat(result).isEmpty(); + } + + @Test + void should_return_empty_map_when_keys_is_empty() { + // given + List keys = List.of(); + + // when + Map result = entityLookupService.findCards(keys); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java new file mode 100644 index 0000000..7b0b887 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -0,0 +1,204 @@ +package com.Coming.Backend.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@ExtendWith(MockitoExtension.class) +class MentionServiceTest { + + @InjectMocks + private MentionService mentionService; + + @Mock + private ConcertRepository concertRepository; + + @Mock + private ArtistRepository artistRepository; + + @Mock + private ReleaseGroupRepository releaseGroupRepository; + + @Mock + private TrackRepository trackRepository; + + @Mock + private EntityLookupService entityLookupService; + + private static final int PAGE = 0; + private static final int SIZE = 10; + private static final Long CONCERT_ID = 1L; + private static final Long ARTIST_ID = 10L; + private static final Long RELEASE_ID = 100L; + private static final Long RELEASE_ARTIST_ID = 20L; + private static final Long TRACK_ID = 200L; + + @Test + void should_return_concert_cards_when_type_is_CONCERT() { + // given + String q = "아이유"; + Concert concert = Concert.builder().id(CONCERT_ID).title("아이유 콘서트").build(); + EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null, null); + Page concertPage = new PageImpl<>(List.of(concert), PageRequest.of(PAGE, SIZE), 1); + given(concertRepository.searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class))) + .willReturn(concertPage); + given(entityLookupService.toCard(concert)).willReturn(card); + + // when + PageResponse result = mentionService.search(EntityType.CONCERT, q, PAGE, SIZE); + + // then + verify(concertRepository).searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class)); + assertThat(result.content()).containsExactly(card); + assertThat(result.page()).isEqualTo(PAGE); + assertThat(result.size()).isEqualTo(SIZE); + assertThat(result.totalElements()).isEqualTo(1L); + assertThat(result.totalPages()).isEqualTo(1); + } + + @Test + void should_call_artist_repository_with_raw_query_when_type_is_ARTIST() { + // given + String q = "아이유"; + Artist artist = Artist.builder().id(ARTIST_ID).name("아이유").build(); + Page page = new PageImpl<>(List.of(artist)); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, ARTIST_ID, "아이유", null, null, null); + given(artistRepository.findByNameOrAliasContainingIgnoreCase(eq(q), any(Pageable.class))).willReturn(page); + given(entityLookupService.toCard(artist)).willReturn(card); + + // when + PageResponse result = mentionService.search(EntityType.ARTIST, q, PAGE, SIZE); + + // then + verify(artistRepository).findByNameOrAliasContainingIgnoreCase(eq("아이유"), any(Pageable.class)); + assertThat(result.content()).containsExactly(card); + } + + @Test + void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { + // given + String q = "LILAC"; + ReleaseGroup release = ReleaseGroup.builder().id(RELEASE_ID).artistId(RELEASE_ARTIST_ID).title("LILAC").build(); + Artist releaseArtist = Artist.builder().id(RELEASE_ARTIST_ID).name("IU").build(); + Page page = new PageImpl<>(List.of(release)); + EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null, null); + given(releaseGroupRepository.searchByTitleForMention(eq("%lilac%"), any(Pageable.class))) + .willReturn(page); + given(artistRepository.findAllById(eq(Set.of(RELEASE_ARTIST_ID)))).willReturn(List.of(releaseArtist)); + given(entityLookupService.toCard(release, "IU")).willReturn(card); + + // when + PageResponse result = mentionService.search(EntityType.RELEASE, q, PAGE, SIZE); + + // then + verify(artistRepository).findAllById(eq(Set.of(RELEASE_ARTIST_ID))); + assertThat(result.content()).containsExactly(card); + } + + @Test + void should_return_track_cards_when_type_is_TRACK() { + // given + String q = "라일락"; + Track track = Track.builder().id(TRACK_ID).releaseGroupId(RELEASE_ID).title("라일락").position(1).build(); + Page trackPage = new PageImpl<>(List.of(track)); + EntityCardResponse card = new EntityCardResponse(EntityType.TRACK, TRACK_ID, "라일락", "IU · LILAC", null, RELEASE_ID); + given(trackRepository.searchByTitleForMention(eq("%라일락%"), any(Pageable.class))).willReturn(trackPage); + given(entityLookupService.toTrackCardsById(List.of(track))).willReturn(Map.of(TRACK_ID, card)); + + // when + PageResponse result = mentionService.search(EntityType.TRACK, q, PAGE, SIZE); + + // then + verify(trackRepository).searchByTitleForMention(eq("%라일락%"), any(Pageable.class)); + assertThat(result.content()).containsExactly(card); + } + + @Test + void should_return_empty_list_when_no_search_result_found() { + // given + given(concertRepository.searchByTitleForMention(any(), eq("%없는공연%"), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + PageResponse result = mentionService.search(EntityType.CONCERT, "없는공연", PAGE, SIZE); + + // then + assertThat(result.content()).isEmpty(); + } + + @Test + void should_escape_like_wildcards_when_query_contains_percent_and_underscore() { + // given + given(concertRepository.searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + mentionService.search(EntityType.CONCERT, "50%_off", PAGE, SIZE); + + // then + verify(concertRepository).searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class)); + } + + @Test + void should_build_pageable_with_requested_page_number_when_page_is_not_zero() { + // given + int requestedPage = 1; + int requestedSize = 5; + given(concertRepository.searchByTitleForMention(any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + mentionService.search(EntityType.CONCERT, "아이유", requestedPage, requestedSize); + + // then + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(concertRepository).searchByTitleForMention(any(), any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(requestedPage); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(requestedSize); + } + + @Test + void should_request_id_ascending_sort_when_type_is_ARTIST() { + // given + given(artistRepository.findByNameOrAliasContainingIgnoreCase(any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + mentionService.search(EntityType.ARTIST, "아이유", PAGE, SIZE); + + // then + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(artistRepository).findByNameOrAliasContainingIgnoreCase(any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.ASC, "id")); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java new file mode 100644 index 0000000..18cc419 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -0,0 +1,1020 @@ +package com.Coming.Backend.post.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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.dto.EntityTagRequest; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.post.entity.PostRecommend; +import com.Coming.Backend.post.exception.AlreadyRecommendedException; +import com.Coming.Backend.post.exception.NotRecommendedException; +import com.Coming.Backend.post.exception.PostContentTooLongException; +import com.Coming.Backend.post.exception.PostForbiddenException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.EntityTagCount; +import com.Coming.Backend.post.repository.PostEntityTagRepository; +import com.Coming.Backend.post.repository.PostRecommendRepository; +import com.Coming.Backend.post.repository.PostRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PostServiceTest { + + @InjectMocks + private PostService postService; + + @Mock + private PostRepository postRepository; + + @Mock + private PostEntityTagRepository postEntityTagRepository; + + @Mock + private PostRecommendRepository postRecommendRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentLikeRepository commentLikeRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private EntityLookupService entityLookupService; + + private static final Long POST_ID = 10L; + private static final Long AUTHOR_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + + /** + * Spring이 실제로 @RequestBody를 역직렬화할 때 만드는 것과 동일한 형태(Map/List)의 Tiptap 문서. + * "안녕하세요" 텍스트 노드 하나를 포함한다. + */ + private Map sampleContent() { + return Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "안녕하세요") + )) + ) + ); + } + + /** + * "a"를 length만큼 반복한 텍스트 노드 하나를 포함한 Tiptap 문서. contentText 길이를 정확히 통제하기 위해 사용한다. + */ + private Map contentWithLength(int length) { + return Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "a".repeat(length)) + )) + ) + ); + } + + /** + * 텍스트 노드는 짧지만(contentText 검증 통과), attrs에 padding을 채워 직렬화된 JSON 문자열 자체는 + * 크게 만든 Tiptap 문서. content 원본 크기 상한 검증용. + */ + private Map contentWithLargeStructureAndShortText(int paddingLength) { + return Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", + "attrs", Map.of("padding", "x".repeat(paddingLength)), + "content", List.of( + Map.of("type", "text", "text", "안녕하세요") + )) + ) + ); + } + + private Post buildPost(Long id, Long userId, PostCategory category, String title, long viewCount) { + return Post.builder() + .id(id) + .userId(userId) + .category(category) + .title(title) + .content("{\"type\":\"doc\"}") + .contentText("기존 텍스트") + .recommendCount(0L) + .viewCount(viewCount) + .commentCount(0L) + .build(); + } + + private User buildUser(Long id, String nickname) { + return User.builder().id(id).nickname(nickname).build(); + } + + // ------------------------------------------------------------------------- + // create + // ------------------------------------------------------------------------- + + @Test + void should_create_post_when_review_category_given_without_entity_tags() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), null); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); + } + + @Test + void should_create_post_when_info_category_given_without_entity_tags() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.INFO, "정보 제목", sampleContent(), List.of()); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); + } + + @Test + void should_create_post_when_free_category_given_without_entity_tags() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), null); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); + verify(postRepository).save(captor.capture()); + assertThat(captor.getValue().getContent()).contains("\"type\":\"doc\""); + assertThat(captor.getValue().getContentText()).isEqualTo("안녕하세요"); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); + } + + @Test + void should_save_entity_tags_when_review_category_given_with_entity_tags() { + // given + List tags = List.of( + new EntityTagRequest(EntityType.ARTIST, 1L), + new EntityTagRequest(EntityType.CONCERT, 2L) + ); + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), tags); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postEntityTagRepository, times(2)).save(any(PostEntityTag.class)); + } + + @Test + void should_deduplicate_entity_tags_when_duplicate_tags_given_on_create() { + // given + List tags = List.of( + new EntityTagRequest(EntityType.ARTIST, 1L), + new EntityTagRequest(EntityType.ARTIST, 1L) + ); + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), tags); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + postService.create(AUTHOR_ID, request); + + // then + verify(postEntityTagRepository, times(1)).save(any(PostEntityTag.class)); + } + + @Test + void should_create_post_when_content_text_length_is_exactly_max_length() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "제목", contentWithLength(10000), null); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postRepository).save(any(Post.class)); + } + + @Test + void should_throw_content_too_long_exception_when_content_text_length_exceeds_max_length_on_create() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "제목", contentWithLength(10001), null); + + // when & then + assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) + .isInstanceOf(PostContentTooLongException.class) + .hasMessage(ErrorCode.POST_CONTENT_TOO_LONG.getMessage()); + verify(postRepository, never()).save(any(Post.class)); + } + + @Test + void should_throw_content_too_long_exception_when_raw_content_size_exceeds_limit_despite_short_text_on_create() { + // given: contentText는 10000자 제한을 통과할 만큼 짧지만, 구조(attrs)만 방대해 직렬화된 content가 50000자를 초과한다. + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "제목", + contentWithLargeStructureAndShortText(60000), null); + + // when & then + assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) + .isInstanceOf(PostContentTooLongException.class) + .hasMessage(ErrorCode.POST_CONTENT_TOO_LONG.getMessage()); + verify(postRepository, never()).save(any(Post.class)); + } + + // ------------------------------------------------------------------------- + // getDetail + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_post_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.getDetail(POST_ID, AUTHOR_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_increment_view_count_and_return_incremented_view_count_when_post_found() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 5L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + verify(postRepository).incrementViewCount(POST_ID); + assertThat(response.viewCount()).isEqualTo(6L); + } + + @Test + void should_return_null_recommended_and_false_author_when_user_id_not_given() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + assertThat(response.isRecommended()).isNull(); + assertThat(response.isAuthor()).isFalse(); + verify(postRecommendRepository, never()).existsByUserIdAndPostId(any(), any()); + } + + @Test + void should_return_true_author_when_user_id_matches_post_author() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + given(postRecommendRepository.existsByUserIdAndPostId(AUTHOR_ID, POST_ID)).willReturn(false); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, AUTHOR_ID); + + // then + assertThat(response.isAuthor()).isTrue(); + } + + @Test + void should_return_true_recommended_when_user_has_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(true); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, OTHER_USER_ID); + + // then + assertThat(response.isRecommended()).isTrue(); + } + + @Test + void should_return_false_recommended_when_user_has_not_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(false); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, OTHER_USER_ID); + + // then + assertThat(response.isRecommended()).isFalse(); + } + + @Test + void should_map_entity_tags_using_entity_lookup_service_when_post_found() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + PostEntityTag tag = PostEntityTag.builder() + .postId(POST_ID) + .entityType(EntityType.ARTIST) + .entityId(1L) + .build(); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); + EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of(tag)); + given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + assertThat(response.entityTags()).hasSize(1); + assertThat(response.entityTags().get(0).entityType()).isEqualTo(EntityType.ARTIST); + assertThat(response.entityTags().get(0).entityId()).isEqualTo(1L); + assertThat(response.entityTags().get(0).title()).isEqualTo("IU"); + } + + @Test + void should_include_release_group_id_when_entity_tag_is_track() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + PostEntityTag tag = PostEntityTag.builder() + .postId(POST_ID) + .entityType(EntityType.TRACK) + .entityId(200L) + .build(); + EntityCardResponse card = new EntityCardResponse(EntityType.TRACK, 200L, "라일락", "IU · LILAC", null, 100L); + EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.TRACK, 200L); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of(tag)); + given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + assertThat(response.entityTags()).hasSize(1); + assertThat(response.entityTags().get(0).entityType()).isEqualTo(EntityType.TRACK); + assertThat(response.entityTags().get(0).title()).isEqualTo("라일락"); + assertThat(response.entityTags().get(0).releaseGroupId()).isEqualTo(100L); + } + + // ------------------------------------------------------------------------- + // getList + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_when_getting_list() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 3L); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findPosts(isNull(), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getList(null, 0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("제목"); + assertThat(response.page()).isEqualTo(0); + assertThat(response.size()).isEqualTo(20); + assertThat(response.totalElements()).isEqualTo(1); + } + + // ------------------------------------------------------------------------- + // getBacklinks + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_sorted_by_recommend_count_when_sort_is_recommend() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 3L); + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "recommendCount")); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findByEntityTag(eq(EntityType.ARTIST), eq(1L), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getBacklinks(EntityType.ARTIST, 1L, "recommend", 0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("제목"); + verify(postRepository).findByEntityTag(EntityType.ARTIST, 1L, pageable); + } + + @Test + void should_return_page_response_sorted_by_created_at_when_sort_is_latest() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 3L); + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findByEntityTag(eq(EntityType.CONCERT), eq(2L), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getBacklinks(EntityType.CONCERT, 2L, "latest", 0, 20); + + // then + assertThat(response.content()).hasSize(1); + verify(postRepository).findByEntityTag(EntityType.CONCERT, 2L, pageable); + } + + @Test + void should_throw_invalid_input_exception_when_sort_is_not_allowed_value() { + // when & then + assertThatThrownBy(() -> postService.getBacklinks(EntityType.ARTIST, 1L, "oldest", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).findByEntityTag(any(), any(), any()); + } + + @Test + void should_return_empty_content_when_no_backlinks_found() { + // given + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + given(postRepository.findByEntityTag(eq(EntityType.RELEASE), eq(3L), eq(pageable))).willReturn(Page.empty(pageable)); + + // when + PageResponse response = postService.getBacklinks(EntityType.RELEASE, 3L, "latest", 0, 20); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isZero(); + } + + // ------------------------------------------------------------------------- + // getPopular + // ------------------------------------------------------------------------- + + @Test + void should_return_summary_responses_when_popular_posts_found() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "인기 게시글", 10L); + Pageable pageable = PageRequest.of(0, 5); + given(postRepository.findPopularPosts(any(LocalDateTime.class), eq(pageable))).willReturn(List.of(post)); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + List response = postService.getPopular(7, 5); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).title()).isEqualTo("인기 게시글"); + } + + @Test + void should_return_empty_list_when_no_popular_posts_found() { + // given + Pageable pageable = PageRequest.of(0, 5); + given(postRepository.findPopularPosts(any(LocalDateTime.class), eq(pageable))).willReturn(List.of()); + + // when + List response = postService.getPopular(7, 5); + + // then + assertThat(response).isEmpty(); + verify(postEntityTagRepository, never()).findByPostIdIn(any()); + } + + // ------------------------------------------------------------------------- + // getTrendingTags + // ------------------------------------------------------------------------- + + @Test + void should_return_trending_tags_when_entity_tags_found() { + // given + Pageable pageable = PageRequest.of(0, 10); + EntityTagCount count = new EntityTagCount(EntityType.ARTIST, 1L, 3L); + EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); + given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))).willReturn(List.of(count)); + given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); + + // when + List response = postService.getTrendingTags(7, 10); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).entityType()).isEqualTo(EntityType.ARTIST); + assertThat(response.get(0).entityId()).isEqualTo(1L); + assertThat(response.get(0).title()).isEqualTo("IU"); + assertThat(response.get(0).count()).isEqualTo(3L); + } + + @Test + void should_return_empty_list_when_no_trending_tags_found() { + // given + Pageable pageable = PageRequest.of(0, 10); + given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))).willReturn(List.of()); + + // when + List response = postService.getTrendingTags(7, 10); + + // then + assertThat(response).isEmpty(); + verify(entityLookupService, never()).findCards(any()); + } + + @Test + void should_filter_out_trending_tag_when_entity_reference_is_deleted() { + // given + Pageable pageable = PageRequest.of(0, 10); + EntityTagCount aliveCount = new EntityTagCount(EntityType.ARTIST, 1L, 3L); + EntityTagCount deletedCount = new EntityTagCount(EntityType.CONCERT, 2L, 2L); + EntityLookupService.EntityKey aliveKey = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); + EntityLookupService.EntityKey deletedKey = new EntityLookupService.EntityKey(EntityType.CONCERT, 2L); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); + given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))) + .willReturn(List.of(aliveCount, deletedCount)); + given(entityLookupService.findCards(List.of(aliveKey, deletedKey))).willReturn(Map.of(aliveKey, card)); + + // when + List response = postService.getTrendingTags(7, 10); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).entityId()).isEqualTo(1L); + } + + // ------------------------------------------------------------------------- + // search + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_when_title_matches_search_query() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "IU 콘서트 후기", 3L); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.searchPosts(eq("%iu%"), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.search("IU", 0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("IU 콘서트 후기"); + verify(postRepository).searchPosts("%iu%", pageable); + } + + @Test + void should_return_empty_content_when_no_search_result_found() { + // given + Pageable pageable = PageRequest.of(0, 20); + given(postRepository.searchPosts(eq("%없는검색어%"), eq(pageable))).willReturn(Page.empty(pageable)); + + // when + PageResponse response = postService.search("없는검색어", 0, 20); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isZero(); + } + + @Test + void should_escape_like_wildcards_when_search_query_contains_percent_and_underscore() { + // given + Pageable pageable = PageRequest.of(0, 20); + given(postRepository.searchPosts(eq("%50\\%\\_off%"), eq(pageable))).willReturn(Page.empty(pageable)); + + // when + postService.search("50%_off", 0, 20); + + // then + verify(postRepository).searchPosts("%50\\%\\_off%", pageable); + } + + @Test + void should_throw_invalid_input_exception_when_search_query_length_is_less_than_two() { + // when & then + assertThatThrownBy(() -> postService.search("a", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).searchPosts(any(), any()); + } + + @Test + void should_throw_invalid_input_exception_when_trimmed_search_query_length_is_less_than_two() { + // when & then + assertThatThrownBy(() -> postService.search(" a ", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).searchPosts(any(), any()); + } + + // ------------------------------------------------------------------------- + // update + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_updating_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when & then + assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_post_forbidden_exception_when_updater_is_not_author() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when & then + assertThatThrownBy(() -> postService.update(OTHER_USER_ID, POST_ID, request)) + .isInstanceOf(PostForbiddenException.class) + .hasMessage(ErrorCode.FORBIDDEN.getMessage()); + } + + @Test + void should_update_title_and_content_when_given_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", sampleContent(), null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + assertThat(post.getTitle()).isEqualTo("새 제목"); + assertThat(post.getContent()).contains("\"type\":\"doc\""); + assertThat(post.getContentText()).isEqualTo("안녕하세요"); + } + + @Test + void should_keep_existing_title_and_content_when_null_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + assertThat(post.getTitle()).isEqualTo("기존 제목"); + assertThat(post.getContent()).isEqualTo("{\"type\":\"doc\"}"); + } + + @Test + void should_not_touch_entity_tags_when_entity_tags_is_null_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + verify(postEntityTagRepository, never()).deleteByPostId(POST_ID); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); + } + + @Test + void should_replace_entity_tags_when_entity_tags_given_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + List newTags = List.of( + new EntityTagRequest(EntityType.ARTIST, 1L), + new EntityTagRequest(EntityType.CONCERT, 2L) + ); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, newTags); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + verify(postEntityTagRepository).deleteByPostId(POST_ID); + verify(postEntityTagRepository, times(2)).save(any(PostEntityTag.class)); + } + + @Test + void should_update_category_when_review_category_given_without_entity_tags() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(PostCategory.REVIEW, null, null, null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + assertThat(post.getCategory()).isEqualTo(PostCategory.REVIEW); + } + + @Test + void should_update_content_when_content_text_length_is_exactly_max_length() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, contentWithLength(10000), null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + assertThat(post.getContentText()).hasSize(10000); + } + + @Test + void should_throw_content_too_long_exception_and_keep_existing_fields_when_content_text_length_exceeds_max_length_on_update() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", contentWithLength(10001), null); + + // when & then + assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(PostContentTooLongException.class) + .hasMessage(ErrorCode.POST_CONTENT_TOO_LONG.getMessage()); + assertThat(post.getTitle()).isEqualTo("기존 제목"); + assertThat(post.getContent()).isEqualTo("{\"type\":\"doc\"}"); + assertThat(post.getContentText()).isEqualTo("기존 텍스트"); + } + + // ------------------------------------------------------------------------- + // delete + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_deleting_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.delete(AUTHOR_ID, POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_post_forbidden_exception_when_deleter_is_not_author() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + + // when & then + assertThatThrownBy(() -> postService.delete(OTHER_USER_ID, POST_ID)) + .isInstanceOf(PostForbiddenException.class) + .hasMessage(ErrorCode.FORBIDDEN.getMessage()); + } + + @Test + void should_delete_post_and_entity_tags_when_author_deletes() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + + // when + postService.delete(AUTHOR_ID, POST_ID); + + // then + verify(postEntityTagRepository).deleteByPostId(POST_ID); + verify(postRepository).delete(post); + } + + @Test + void should_delete_comments_comment_likes_and_recommends_when_author_deletes_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + + // when + postService.delete(AUTHOR_ID, POST_ID); + + // then + verify(commentLikeRepository).deleteByCommentPostId(POST_ID); + verify(commentRepository).deleteByPostId(POST_ID); + verify(postRecommendRepository).deleteByPostId(POST_ID); + } + + // ------------------------------------------------------------------------- + // recommend + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_recommending_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.recommend(AUTHOR_ID, POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_already_recommended_exception_when_user_already_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(true); + + // when & then + assertThatThrownBy(() -> postService.recommend(OTHER_USER_ID, POST_ID)) + .isInstanceOf(AlreadyRecommendedException.class) + .hasMessage(ErrorCode.ALREADY_RECOMMENDED.getMessage()); + verify(postRecommendRepository, never()).save(any(PostRecommend.class)); + } + + @Test + void should_throw_already_recommended_exception_when_save_violates_unique_constraint() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(false); + given(postRecommendRepository.save(any(PostRecommend.class))) + .willThrow(new DataIntegrityViolationException("duplicate")); + + // when & then + assertThatThrownBy(() -> postService.recommend(OTHER_USER_ID, POST_ID)) + .isInstanceOf(AlreadyRecommendedException.class) + .hasMessage(ErrorCode.ALREADY_RECOMMENDED.getMessage()); + verify(postRepository, never()).incrementRecommendCount(POST_ID); + } + + @Test + void should_save_recommend_and_return_incremented_count_when_user_has_not_recommended_post() { + // given + Post post = Post.builder() + .id(POST_ID) + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title("제목") + .recommendCount(3L) + .build(); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(false); + given(postRepository.findRecommendCountById(POST_ID)).willReturn(4L); + + // when + RecommendCountResponse response = postService.recommend(OTHER_USER_ID, POST_ID); + + // then + assertThat(response.recommendCount()).isEqualTo(4L); + verify(postRecommendRepository).save(any(PostRecommend.class)); + verify(postRepository).incrementRecommendCount(POST_ID); + } + + // ------------------------------------------------------------------------- + // unrecommend + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_unrecommending_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.unrecommend(AUTHOR_ID, POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_not_recommended_exception_when_user_has_not_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(0L); + + // when & then + assertThatThrownBy(() -> postService.unrecommend(OTHER_USER_ID, POST_ID)) + .isInstanceOf(NotRecommendedException.class) + .hasMessage(ErrorCode.NOT_RECOMMENDED.getMessage()); + verify(postRepository, never()).decrementRecommendCount(POST_ID); + } + + @Test + void should_delete_recommend_and_return_decremented_count_when_user_has_recommended_post() { + // given + Post post = Post.builder() + .id(POST_ID) + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title("제목") + .recommendCount(3L) + .build(); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(1L); + given(postRepository.findRecommendCountById(POST_ID)).willReturn(2L); + + // when + RecommendCountResponse response = postService.unrecommend(OTHER_USER_ID, POST_ID); + + // then + assertThat(response.recommendCount()).isEqualTo(2L); + verify(postRecommendRepository).deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID); + verify(postRepository).decrementRecommendCount(POST_ID); + } +} diff --git a/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java b/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java new file mode 100644 index 0000000..8b95e46 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java @@ -0,0 +1,113 @@ +package com.Coming.Backend.post.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TiptapTextExtractorTest { + + @Test + void should_extract_single_text_node() { + // given + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "안녕하세요") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("안녕하세요"); + } + + @Test + void should_concatenate_adjacent_text_nodes_without_space_within_same_paragraph() { + // given: 굵게 등 서식으로 나뉜 인접 텍스트 노드("안녕"+"하세요")는 원문 그대로 붙여야 한다. + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "안녕"), + Map.of("type", "text", "text", "하세요") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("안녕하세요"); + } + + @Test + void should_join_separate_paragraphs_with_space() { + // given + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "첫 문단") + )), + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "둘째 문단") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("첫 문단 둘째 문단"); + } + + @Test + void should_ignore_non_text_nodes_such_as_mention() { + // given: 실제 작성 내용에 포함된 공백은 텍스트 노드 자체에 들어있으므로 그대로 보존한다. + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "공연 "), + Map.of("type", "mention", "attrs", Map.of("entityType", "CONCERT", "entityId", 1)), + Map.of("type", "text", "text", "다녀왔어요") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("공연 다녀왔어요"); + } + + @Test + void should_return_empty_string_when_no_text_node_exists() { + // given + Map content = Map.of("type", "doc", "content", List.of()); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEmpty(); + } + + @Test + void should_return_empty_string_when_content_is_null() { + // when + String result = TiptapTextExtractor.extract(null); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java b/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java new file mode 100644 index 0000000..f31fabe --- /dev/null +++ b/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java @@ -0,0 +1,88 @@ +package com.Coming.Backend.release.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.release.entity.ReleaseGroup; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ReleaseGroupRepositoryTest { + + @Autowired + private ReleaseGroupRepository releaseGroupRepository; + + private static final Long ARTIST_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private ReleaseGroup buildReleaseGroup(String title) { + return ReleaseGroup.builder() + .artistId(ARTIST_ID) + .title(title) + .build(); + } + + @Test + void should_return_release_when_title_partially_matches_search_query_ignoring_case() { + // given + ReleaseGroup release = releaseGroupRepository.save(buildReleaseGroup("LILAC")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("lil"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId).contains(release.getId()); + } + + @Test + void should_exclude_release_when_title_does_not_match_search_query() { + // given + ReleaseGroup matching = releaseGroupRepository.save(buildReleaseGroup("LILAC")); + ReleaseGroup notMatching = releaseGroupRepository.save(buildReleaseGroup("Palette")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("lil"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId) + .contains(matching.getId()) + .doesNotContain(notMatching.getId()); + } + + @Test + void should_return_empty_page_when_no_release_matches_search_query() { + // given + releaseGroupRepository.save(buildReleaseGroup("LILAC")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_match_wildcard_characters_literally_when_escaped_query_given() { + // given + ReleaseGroup release = releaseGroupRepository.save(buildReleaseGroup("100% Ready")); + releaseGroupRepository.save(buildReleaseGroup("100 Ready")); + String escapedQuery = "%" + "100\\% ready".toLowerCase(Locale.ROOT) + "%"; + + // when + Page result = releaseGroupRepository.searchByTitleForMention(escapedQuery, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId).containsExactly(release.getId()); + } +} diff --git a/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java b/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java new file mode 100644 index 0000000..f46dd80 --- /dev/null +++ b/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java @@ -0,0 +1,89 @@ +package com.Coming.Backend.release.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.release.entity.Track; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class TrackRepositoryTest { + + @Autowired + private TrackRepository trackRepository; + + private static final Long RELEASE_GROUP_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private Track buildTrack(String title) { + return Track.builder() + .releaseGroupId(RELEASE_GROUP_ID) + .title(title) + .position(1) + .build(); + } + + @Test + void should_return_track_when_title_partially_matches_search_query_ignoring_case() { + // given + Track track = trackRepository.save(buildTrack("Dynamite")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("DYNA"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId).contains(track.getId()); + } + + @Test + void should_exclude_track_when_title_does_not_match_search_query() { + // given + Track matching = trackRepository.save(buildTrack("Dynamite")); + Track notMatching = trackRepository.save(buildTrack("Butter")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("Dyna"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId) + .contains(matching.getId()) + .doesNotContain(notMatching.getId()); + } + + @Test + void should_return_empty_page_when_no_track_matches_search_query() { + // given + trackRepository.save(buildTrack("Dynamite")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_match_wildcard_characters_literally_when_escaped_query_given() { + // given + Track track = trackRepository.save(buildTrack("100% Ready")); + trackRepository.save(buildTrack("100 Ready")); + String escapedQuery = "%" + "100\\% ready".toLowerCase(Locale.ROOT) + "%"; + + // when + Page result = trackRepository.searchByTitleForMention(escapedQuery, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId).containsExactly(track.getId()); + } +}