diff --git a/docs/plan/#16-review/checklist.md b/docs/plan/#16-review/checklist.md new file mode 100644 index 0000000..1f7443a --- /dev/null +++ b/docs/plan/#16-review/checklist.md @@ -0,0 +1,12 @@ +# Review(회고) BC 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] 테스트 코드 작성 완료 (Domain, Application 필수) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 + +## 선택 항목 (해당 시) +- [x] Flyway 마이그레이션 작성 +- [ ] API 엔드포인트 동작 확인 diff --git a/docs/plan/#16-review/plan.md b/docs/plan/#16-review/plan.md new file mode 100644 index 0000000..fe110b8 --- /dev/null +++ b/docs/plan/#16-review/plan.md @@ -0,0 +1,14 @@ +# Review(회고) BC 구현 계획 + +> Issue: #16 + +## 단계 + +- [x] Step 0: 준비 — 마이그레이션, GraphQL 스키마, Codegen 실행 +- [x] Step 1: Domain Layer (TDD) — ReviewId, PeriodKey, Review, ReviewType, StepType, ReviewStep, ReviewCommand, ReviewQuery, ReviewRepository +- [x] Step 2: Application Layer (TDD) — ReviewStatsDto, ReviewService (create, findAll, getStats) +- [x] Step 3: Infrastructure Layer — ReviewTable (JSONB), ExposedReviewRepository +- [x] Step 4: Presentation Layer — ReviewDataFetcher +- [x] Step 5: 검증 — 전체 테스트 통과, 빌드 성공 +- [x] Step 6: Flyway 마이그레이션 JSONB 복원 (TIMESTAMPTZ는 H2 미지원, 표준 SQL 유지) +- [x] Step 7: ReviewService.findAll 리팩토링 — 함수형 스타일로 정리 diff --git a/src/main/kotlin/kr/io/team/loop/review/application/dto/ReviewStatsDto.kt b/src/main/kotlin/kr/io/team/loop/review/application/dto/ReviewStatsDto.kt new file mode 100644 index 0000000..9544443 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/application/dto/ReviewStatsDto.kt @@ -0,0 +1,6 @@ +package kr.io.team.loop.review.application.dto + +data class ReviewStatsDto( + val totalCount: Long, + val consecutiveDays: Int, +) diff --git a/src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt b/src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt new file mode 100644 index 0000000..eb502e7 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt @@ -0,0 +1,62 @@ +package kr.io.team.loop.review.application.service + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.minus +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.common.domain.exception.DuplicateEntityException +import kr.io.team.loop.review.application.dto.ReviewStatsDto +import kr.io.team.loop.review.domain.model.Review +import kr.io.team.loop.review.domain.model.ReviewCommand +import kr.io.team.loop.review.domain.model.ReviewQuery +import kr.io.team.loop.review.domain.repository.ReviewRepository +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ReviewService( + private val reviewRepository: ReviewRepository, +) { + @Transactional + fun create(command: ReviewCommand.Create): Review = + try { + reviewRepository.save(command) + } catch (e: DataIntegrityViolationException) { + throw DuplicateEntityException("Review already exists for this period") + } + + @Transactional(readOnly = true) + fun findAll(query: ReviewQuery): List { + val reviews = reviewRepository.findAll(query.copy(stepType = null)) + val stepType = query.stepType ?: return reviews + return reviews.filter { it.containsStepType(stepType) } + } + + @Transactional(readOnly = true) + fun getStats( + memberId: MemberId, + today: LocalDate, + ): ReviewStatsDto { + val totalCount = reviewRepository.countByMemberId(memberId) + val reviews = reviewRepository.findAll(ReviewQuery(memberId = memberId)) + val consecutiveDays = calculateConsecutiveDays(reviews, today) + return ReviewStatsDto( + totalCount = totalCount, + consecutiveDays = consecutiveDays, + ) + } + + private fun calculateConsecutiveDays( + reviews: List, + today: LocalDate, + ): Int { + val reviewDates = reviews.map { it.startDate }.toSet() + var count = 0 + var current = today + while (current in reviewDates) { + count++ + current = current.minus(1, kotlinx.datetime.DateTimeUnit.DAY) + } + return count + } +} diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/PeriodKey.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/PeriodKey.kt new file mode 100644 index 0000000..62e4b8d --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/PeriodKey.kt @@ -0,0 +1,17 @@ +package kr.io.team.loop.review.domain.model + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.exception.InvalidInputException + +@JvmInline +value class PeriodKey( + val value: String, +) { + init { + if (value.isBlank()) throw InvalidInputException("PeriodKey must not be blank") + } + + companion object { + fun daily(date: LocalDate): PeriodKey = PeriodKey("DAILY:$date") + } +} diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt new file mode 100644 index 0000000..4e834ac --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt @@ -0,0 +1,21 @@ +package kr.io.team.loop.review.domain.model + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.MemberId +import java.time.Instant + +data class Review( + val id: ReviewId, + val reviewType: ReviewType, + val memberId: MemberId, + val steps: List, + val startDate: LocalDate, + val endDate: LocalDate?, + val periodKey: PeriodKey, + val createdAt: Instant, + val updatedAt: Instant?, +) { + fun isOwnedBy(memberId: MemberId): Boolean = this.memberId == memberId + + fun containsStepType(stepType: StepType): Boolean = steps.any { it.type == stepType } +} diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt new file mode 100644 index 0000000..1dd54fd --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt @@ -0,0 +1,12 @@ +package kr.io.team.loop.review.domain.model + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.MemberId + +sealed interface ReviewCommand { + data class Create( + val memberId: MemberId, + val steps: List, + val date: LocalDate, + ) : ReviewCommand +} diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewId.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewId.kt new file mode 100644 index 0000000..8b00857 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewId.kt @@ -0,0 +1,12 @@ +package kr.io.team.loop.review.domain.model + +import kr.io.team.loop.common.domain.exception.InvalidInputException + +@JvmInline +value class ReviewId( + val value: Long, +) { + init { + if (value <= 0) throw InvalidInputException("ReviewId must be positive") + } +} diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewQuery.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewQuery.kt new file mode 100644 index 0000000..44df0cd --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewQuery.kt @@ -0,0 +1,13 @@ +package kr.io.team.loop.review.domain.model + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.MemberId + +data class ReviewQuery( + val memberId: MemberId? = null, + val reviewType: ReviewType? = null, + val stepType: StepType? = null, + val date: LocalDate? = null, + val startDate: LocalDate? = null, + val endDate: LocalDate? = null, +) diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewStep.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewStep.kt new file mode 100644 index 0000000..1eb16da --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewStep.kt @@ -0,0 +1,6 @@ +package kr.io.team.loop.review.domain.model + +data class ReviewStep( + val type: StepType, + val content: String, +) diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewType.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewType.kt new file mode 100644 index 0000000..2983c1a --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewType.kt @@ -0,0 +1,5 @@ +package kr.io.team.loop.review.domain.model + +enum class ReviewType { + DAILY, +} diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/StepType.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/StepType.kt new file mode 100644 index 0000000..6657923 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/StepType.kt @@ -0,0 +1,7 @@ +package kr.io.team.loop.review.domain.model + +enum class StepType { + KEEP, + PROBLEM, + TRY, +} diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt b/src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt new file mode 100644 index 0000000..fbc6464 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt @@ -0,0 +1,16 @@ +package kr.io.team.loop.review.domain.repository + +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.review.domain.model.Review +import kr.io.team.loop.review.domain.model.ReviewCommand +import kr.io.team.loop.review.domain.model.ReviewQuery + +interface ReviewRepository { + fun save(command: ReviewCommand.Create): Review + + fun findAll(query: ReviewQuery): List + + fun findById(id: kr.io.team.loop.review.domain.model.ReviewId): Review? + + fun countByMemberId(memberId: MemberId): Long +} diff --git a/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt b/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt new file mode 100644 index 0000000..4643340 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt @@ -0,0 +1,104 @@ +package kr.io.team.loop.review.infrastructure.persistence + +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.review.domain.model.PeriodKey +import kr.io.team.loop.review.domain.model.Review +import kr.io.team.loop.review.domain.model.ReviewCommand +import kr.io.team.loop.review.domain.model.ReviewId +import kr.io.team.loop.review.domain.model.ReviewQuery +import kr.io.team.loop.review.domain.model.ReviewStep +import kr.io.team.loop.review.domain.model.ReviewType +import kr.io.team.loop.review.domain.model.StepType +import kr.io.team.loop.review.domain.repository.ReviewRepository +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.count +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.greaterEq +import org.jetbrains.exposed.v1.core.lessEq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.springframework.stereotype.Repository +import java.time.OffsetDateTime + +@Repository +class ExposedReviewRepository : ReviewRepository { + override fun save(command: ReviewCommand.Create): Review { + val now = OffsetDateTime.now() + val periodKey = PeriodKey.daily(command.date) + val stepsJson = command.steps.map { StepJson(type = it.type.name, content = it.content) } + + val row = + ReviewTable.insert { + it[reviewType] = ReviewType.DAILY.name + it[memberId] = command.memberId.value + it[steps] = stepsJson + it[startDate] = command.date + it[this.periodKey] = periodKey.value + it[createdAt] = now + } + + return Review( + id = ReviewId(row[ReviewTable.reviewId]), + reviewType = ReviewType.DAILY, + memberId = command.memberId, + steps = command.steps, + startDate = command.date, + endDate = null, + periodKey = periodKey, + createdAt = now.toInstant(), + updatedAt = null, + ) + } + + override fun findAll(query: ReviewQuery): List { + var condition: Op = Op.TRUE + query.memberId?.let { condition = condition and (ReviewTable.memberId eq it.value) } + query.reviewType?.let { condition = condition and (ReviewTable.reviewType eq it.name) } + query.date?.let { + condition = condition and (ReviewTable.startDate eq it) + } + query.startDate?.let { condition = condition and (ReviewTable.startDate greaterEq it) } + query.endDate?.let { condition = condition and (ReviewTable.startDate lessEq it) } + + return ReviewTable + .selectAll() + .where(condition) + .orderBy(ReviewTable.startDate, SortOrder.DESC) + .map { it.toReview() } + } + + override fun findById(id: ReviewId): Review? = + ReviewTable + .selectAll() + .where { ReviewTable.reviewId eq id.value } + .singleOrNull() + ?.toReview() + + override fun countByMemberId(memberId: MemberId): Long = + ReviewTable + .selectAll() + .where { ReviewTable.memberId eq memberId.value } + .count() + + private fun ResultRow.toReview(): Review = + Review( + id = ReviewId(this[ReviewTable.reviewId]), + reviewType = ReviewType.valueOf(this[ReviewTable.reviewType]), + memberId = MemberId(this[ReviewTable.memberId]), + steps = + this[ReviewTable.steps].map { stepJson -> + ReviewStep( + type = StepType.valueOf(stepJson.type), + content = stepJson.content, + ) + }, + startDate = this[ReviewTable.startDate], + endDate = this[ReviewTable.endDate], + periodKey = PeriodKey(this[ReviewTable.periodKey]), + createdAt = this[ReviewTable.createdAt].toInstant(), + updatedAt = this[ReviewTable.updatedAt]?.toInstant(), + ) +} diff --git a/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ReviewTable.kt b/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ReviewTable.kt new file mode 100644 index 0000000..d2cdf8a --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ReviewTable.kt @@ -0,0 +1,38 @@ +package kr.io.team.loop.review.infrastructure.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.date +import org.jetbrains.exposed.v1.datetime.timestampWithTimeZone +import org.jetbrains.exposed.v1.json.jsonb +import tools.jackson.module.kotlin.jacksonObjectMapper +import tools.jackson.module.kotlin.readValue + +private val objectMapper = jacksonObjectMapper() + +object ReviewTable : Table("review") { + val reviewId = long("review_id").autoIncrement() + val reviewType = text("review_type") + val memberId = long("member_id").index() + val steps = + jsonb>( + "steps", + serialize = { objectMapper.writeValueAsString(it) }, + deserialize = { objectMapper.readValue(it) }, + ) + val startDate = date("start_date") + val endDate = date("end_date").nullable() + val periodKey = text("period_key") + val createdAt = timestampWithTimeZone("created_at") + val updatedAt = timestampWithTimeZone("updated_at").nullable() + + override val primaryKey = PrimaryKey(reviewId) + + init { + uniqueIndex(memberId, periodKey) + } +} + +data class StepJson( + val type: String, + val content: String, +) diff --git a/src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt new file mode 100644 index 0000000..8396c80 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt @@ -0,0 +1,101 @@ +package kr.io.team.loop.review.presentation.datafetcher + +import com.netflix.graphql.dgs.DgsComponent +import com.netflix.graphql.dgs.DgsMutation +import com.netflix.graphql.dgs.DgsQuery +import com.netflix.graphql.dgs.InputArgument +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kr.io.team.loop.codegen.types.CreateReviewInput +import kr.io.team.loop.codegen.types.ReviewFilter +import kr.io.team.loop.codegen.types.ReviewStepOutput +import kr.io.team.loop.common.config.Authorize +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.review.application.service.ReviewService +import kr.io.team.loop.review.domain.model.Review +import kr.io.team.loop.review.domain.model.ReviewCommand +import kr.io.team.loop.review.domain.model.ReviewQuery +import kr.io.team.loop.review.domain.model.ReviewStep +import kotlin.time.Clock +import kr.io.team.loop.codegen.types.Review as ReviewGraphql +import kr.io.team.loop.codegen.types.ReviewStats as ReviewStatsGraphql +import kr.io.team.loop.codegen.types.ReviewType as ReviewTypeGraphql +import kr.io.team.loop.codegen.types.StepType as StepTypeGraphql +import kr.io.team.loop.review.domain.model.ReviewType as ReviewTypeDomain +import kr.io.team.loop.review.domain.model.StepType as StepTypeDomain + +@DgsComponent +class ReviewDataFetcher( + private val reviewService: ReviewService, +) { + @DgsQuery + fun myReviews( + @InputArgument filter: ReviewFilter, + @Authorize memberId: Long, + ): List { + val query = + ReviewQuery( + memberId = MemberId(memberId), + reviewType = filter.reviewType?.let { ReviewTypeDomain.valueOf(it.name) }, + stepType = filter.stepType?.let { StepTypeDomain.valueOf(it.name) }, + date = filter.date?.let { LocalDate.parse(it) }, + startDate = filter.startDate?.let { LocalDate.parse(it) }, + endDate = filter.endDate?.let { LocalDate.parse(it) }, + ) + return reviewService.findAll(query).map { it.toGraphql() } + } + + @DgsQuery + fun myReviewStats( + @Authorize memberId: Long, + ): ReviewStatsGraphql { + val today = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + val stats = reviewService.getStats(MemberId(memberId), today) + return ReviewStatsGraphql( + totalCount = stats.totalCount.toInt(), + consecutiveDays = stats.consecutiveDays, + ) + } + + @DgsMutation + fun createReview( + @InputArgument input: CreateReviewInput, + @Authorize memberId: Long, + ): ReviewGraphql { + val command = + ReviewCommand.Create( + memberId = MemberId(memberId), + steps = + input.steps.map { step -> + ReviewStep( + type = StepTypeDomain.valueOf(step.type.name), + content = step.content, + ) + }, + date = LocalDate.parse(input.date), + ) + return reviewService.create(command).toGraphql() + } + + private fun Review.toGraphql(): ReviewGraphql = + ReviewGraphql( + id = id.value.toString(), + reviewType = ReviewTypeGraphql.valueOf(reviewType.name), + steps = + steps.map { step -> + ReviewStepOutput( + type = StepTypeGraphql.valueOf(step.type.name), + content = step.content, + ) + }, + startDate = startDate.toString(), + endDate = endDate?.toString(), + createdAt = createdAt.toString(), + updatedAt = updatedAt?.toString(), + ) +} diff --git a/src/main/resources/db/migration/V4__Create_review_table.sql b/src/main/resources/db/migration/V4__Create_review_table.sql new file mode 100644 index 0000000..1d0e829 --- /dev/null +++ b/src/main/resources/db/migration/V4__Create_review_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE review ( + review_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + review_type TEXT NOT NULL, + member_id BIGINT NOT NULL, + steps JSONB NOT NULL, + start_date DATE NOT NULL, + end_date DATE, + period_key TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE, + + UNIQUE (member_id, period_key) +); + +CREATE INDEX idx_review_member_id ON review (member_id); diff --git a/src/main/resources/schema/review.graphqls b/src/main/resources/schema/review.graphqls new file mode 100644 index 0000000..ed93d43 --- /dev/null +++ b/src/main/resources/schema/review.graphqls @@ -0,0 +1,98 @@ +extend type Query { + "현재 사용자의 회고 목록을 조회한다. 필터 조건은 AND로 결합된다." + myReviews( + "회고 조회 필터" + filter: ReviewFilter! + ): [Review!]! + + "현재 사용자의 회고 통계를 조회한다." + myReviewStats: ReviewStats! +} + +extend type Mutation { + "새 KPT 회고를 생성한다. (인증 필수)" + createReview( + "생성할 회고 정보" + input: CreateReviewInput! + ): Review! +} + +"""회고""" +type Review { + "회고 ID" + id: ID! + "회고 유형" + reviewType: ReviewType! + "회고 단계 목록 (KPT)" + steps: [ReviewStepOutput!]! + "시작 날짜 (YYYY-MM-DD)" + startDate: String! + "종료 날짜 (YYYY-MM-DD)" + endDate: String + "생성일시" + createdAt: String! + "수정일시" + updatedAt: String +} + +"""회고 단계""" +type ReviewStepOutput { + "단계 유형" + type: StepType! + "단계 내용" + content: String! +} + +"""회고 통계""" +type ReviewStats { + "총 회고 수" + totalCount: Int! + "연속 회고일수" + consecutiveDays: Int! +} + +"""회고 유형""" +enum ReviewType { + "일일 회고" + DAILY +} + +"""회고 단계 유형""" +enum StepType { + "유지할 점" + KEEP + "개선할 점" + PROBLEM + "앞으로의 다짐" + TRY +} + +"""회고 조회 필터. 모든 조건은 선택적이며 AND로 결합된다.""" +input ReviewFilter { + "회고 유형 필터" + reviewType: ReviewType + "포함된 단계 유형 필터 (앱 레벨 JSONB 필터)" + stepType: StepType + "특정 날짜 (YYYY-MM-DD)" + date: String + "시작 날짜 (YYYY-MM-DD, inclusive)" + startDate: String + "종료 날짜 (YYYY-MM-DD, inclusive)" + endDate: String +} + +"""회고 생성 입력""" +input CreateReviewInput { + "회고 단계 목록 (최소 1개)" + steps: [ReviewStepInput!]! + "회고 날짜 (YYYY-MM-DD, 필수)" + date: String! +} + +"""회고 단계 입력""" +input ReviewStepInput { + "단계 유형" + type: StepType! + "단계 내용 (1~1000자)" + content: String! +} diff --git a/src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt b/src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt new file mode 100644 index 0000000..df068c4 --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt @@ -0,0 +1,211 @@ +package kr.io.team.loop.review.application.service + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.common.domain.exception.DuplicateEntityException +import kr.io.team.loop.review.domain.model.PeriodKey +import kr.io.team.loop.review.domain.model.Review +import kr.io.team.loop.review.domain.model.ReviewCommand +import kr.io.team.loop.review.domain.model.ReviewId +import kr.io.team.loop.review.domain.model.ReviewQuery +import kr.io.team.loop.review.domain.model.ReviewStep +import kr.io.team.loop.review.domain.model.ReviewType +import kr.io.team.loop.review.domain.model.StepType +import kr.io.team.loop.review.domain.repository.ReviewRepository +import org.springframework.dao.DataIntegrityViolationException +import java.time.Instant + +class ReviewServiceTest : + BehaviorSpec({ + + val reviewRepository = mockk() + val reviewService = ReviewService(reviewRepository) + + val memberId = MemberId(1L) + val today = LocalDate(2026, 2, 20) + + val savedReview = + Review( + id = ReviewId(1L), + reviewType = ReviewType.DAILY, + memberId = memberId, + steps = + listOf( + ReviewStep(type = StepType.KEEP, content = "코드 리뷰 잘 했다"), + ReviewStep(type = StepType.PROBLEM, content = "늦게 일어났다"), + ReviewStep(type = StepType.TRY, content = "일찍 자기"), + ), + startDate = today, + endDate = null, + periodKey = PeriodKey.daily(today), + createdAt = Instant.now(), + updatedAt = null, + ) + + Given("회고 생성 시") { + When("유효한 입력이면") { + val command = + ReviewCommand.Create( + memberId = memberId, + steps = + listOf( + ReviewStep(type = StepType.KEEP, content = "코드 리뷰 잘 했다"), + ReviewStep(type = StepType.PROBLEM, content = "늦게 일어났다"), + ReviewStep(type = StepType.TRY, content = "일찍 자기"), + ), + date = today, + ) + every { reviewRepository.save(command) } returns savedReview + + val result = reviewService.create(command) + + Then("생성된 회고를 반환한다") { + result.id.value shouldBe 1L + result.reviewType shouldBe ReviewType.DAILY + result.steps shouldHaveSize 3 + result.memberId shouldBe memberId + } + } + + When("같은 날짜에 이미 회고가 존재하면") { + val command = + ReviewCommand.Create( + memberId = memberId, + steps = listOf(ReviewStep(type = StepType.KEEP, content = "좋은 점")), + date = today, + ) + every { reviewRepository.save(command) } throws + DataIntegrityViolationException("Unique constraint violated") + + Then("DuplicateEntityException이 발생한다") { + shouldThrow { + reviewService.create(command) + } + } + } + } + + Given("회고 목록 조회 시") { + When("해당 사용자의 회고가 있으면") { + val query = ReviewQuery(memberId = memberId) + every { reviewRepository.findAll(query) } returns listOf(savedReview) + + val result = reviewService.findAll(query) + + Then("회고 목록을 반환한다") { + result shouldHaveSize 1 + result[0].reviewType shouldBe ReviewType.DAILY + } + } + + When("stepType 필터가 있으면 앱 레벨에서 필터링한다") { + val reviewWithoutTry = + savedReview.copy( + id = ReviewId(2L), + steps = listOf(ReviewStep(type = StepType.KEEP, content = "좋은 점")), + ) + val query = ReviewQuery(memberId = memberId, stepType = StepType.TRY) + // Repository는 stepType 없이 조회 (DB 레벨 필터만) + val dbQuery = query.copy(stepType = null) + every { reviewRepository.findAll(dbQuery) } returns listOf(savedReview, reviewWithoutTry) + + val result = reviewService.findAll(query) + + Then("해당 stepType을 포함하는 회고만 반환한다") { + result shouldHaveSize 1 + result[0].id.value shouldBe 1L + } + } + + When("회고가 없으면") { + val query = ReviewQuery(memberId = MemberId(99L)) + every { reviewRepository.findAll(query) } returns emptyList() + + val result = reviewService.findAll(query) + + Then("빈 목록을 반환한다") { + result shouldHaveSize 0 + } + } + } + + Given("회고 통계 조회 시") { + When("회고가 있으면") { + every { reviewRepository.countByMemberId(memberId) } returns 5L + val reviewDates = + listOf( + LocalDate(2026, 2, 20), + LocalDate(2026, 2, 19), + LocalDate(2026, 2, 18), + ) + val reviews = + reviewDates.mapIndexed { index, date -> + savedReview.copy( + id = ReviewId((index + 1).toLong()), + startDate = date, + periodKey = PeriodKey.daily(date), + ) + } + val query = ReviewQuery(memberId = memberId) + every { reviewRepository.findAll(query) } returns reviews + + val result = reviewService.getStats(memberId, today) + + Then("총 회고 수와 연속 회고일수를 반환한다") { + result.totalCount shouldBe 5L + result.consecutiveDays shouldBe 3 + } + } + + When("회고가 없으면") { + every { reviewRepository.countByMemberId(memberId) } returns 0L + val query = ReviewQuery(memberId = memberId) + every { reviewRepository.findAll(query) } returns emptyList() + + val result = reviewService.getStats(memberId, today) + + Then("0을 반환한다") { + result.totalCount shouldBe 0L + result.consecutiveDays shouldBe 0 + } + } + + When("연속이 끊긴 경우") { + every { reviewRepository.countByMemberId(memberId) } returns 3L + val reviews = + listOf( + savedReview.copy( + id = ReviewId(1L), + startDate = LocalDate(2026, 2, 20), + periodKey = PeriodKey.daily(LocalDate(2026, 2, 20)), + ), + // 2026-02-19 없음 (연속 끊김) + savedReview.copy( + id = ReviewId(2L), + startDate = LocalDate(2026, 2, 18), + periodKey = PeriodKey.daily(LocalDate(2026, 2, 18)), + ), + savedReview.copy( + id = ReviewId(3L), + startDate = LocalDate(2026, 2, 17), + periodKey = PeriodKey.daily(LocalDate(2026, 2, 17)), + ), + ) + val query = ReviewQuery(memberId = memberId) + every { reviewRepository.findAll(query) } returns reviews + + val result = reviewService.getStats(memberId, today) + + Then("연속 회고일수는 today부터 연속된 일수만 카운트한다") { + result.totalCount shouldBe 3L + result.consecutiveDays shouldBe 1 + } + } + } + }) diff --git a/src/test/kotlin/kr/io/team/loop/review/domain/model/PeriodKeyTest.kt b/src/test/kotlin/kr/io/team/loop/review/domain/model/PeriodKeyTest.kt new file mode 100644 index 0000000..e63ce80 --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/review/domain/model/PeriodKeyTest.kt @@ -0,0 +1,39 @@ +package kr.io.team.loop.review.domain.model + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.exception.InvalidInputException + +class PeriodKeyTest : + BehaviorSpec({ + + Given("PeriodKey.daily 팩토리 메서드") { + When("날짜를 전달하면") { + val periodKey = PeriodKey.daily(LocalDate(2026, 2, 20)) + + Then("DAILY:{날짜} 형식으로 생성된다") { + periodKey.value shouldBe "DAILY:2026-02-20" + } + } + } + + Given("PeriodKey 생성 시") { + When("빈 문자열이면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + PeriodKey("") + } + } + } + + When("공백 문자열이면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + PeriodKey(" ") + } + } + } + } + }) diff --git a/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewIdTest.kt b/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewIdTest.kt new file mode 100644 index 0000000..31fb507 --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewIdTest.kt @@ -0,0 +1,36 @@ +package kr.io.team.loop.review.domain.model + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import kr.io.team.loop.common.domain.exception.InvalidInputException + +class ReviewIdTest : + BehaviorSpec({ + + Given("ReviewId 생성 시") { + When("양수 값이면") { + val reviewId = ReviewId(1L) + + Then("정상 생성된다") { + reviewId.value shouldBe 1L + } + } + + When("0이면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + ReviewId(0L) + } + } + } + + When("음수이면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + ReviewId(-1L) + } + } + } + } + }) diff --git a/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt b/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt new file mode 100644 index 0000000..ee304ad --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt @@ -0,0 +1,68 @@ +package kr.io.team.loop.review.domain.model + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.MemberId +import java.time.Instant + +class ReviewTest : + BehaviorSpec({ + + val memberId = MemberId(1L) + val otherMemberId = MemberId(2L) + + val review = + Review( + id = ReviewId(1L), + reviewType = ReviewType.DAILY, + memberId = memberId, + steps = + listOf( + ReviewStep(type = StepType.KEEP, content = "코드 리뷰 잘 했다"), + ReviewStep(type = StepType.PROBLEM, content = "늦게 일어났다"), + ReviewStep(type = StepType.TRY, content = "일찍 자기"), + ), + startDate = LocalDate(2026, 2, 20), + endDate = null, + periodKey = PeriodKey.daily(LocalDate(2026, 2, 20)), + createdAt = Instant.now(), + updatedAt = null, + ) + + Given("Review 소유권 확인 시") { + When("본인 회고이면") { + Then("true를 반환한다") { + review.isOwnedBy(memberId) shouldBe true + } + } + + When("다른 사용자의 회고이면") { + Then("false를 반환한다") { + review.isOwnedBy(otherMemberId) shouldBe false + } + } + } + + Given("Review에 특정 StepType 포함 여부 확인 시") { + When("KEEP이 포함되어 있으면") { + Then("true를 반환한다") { + review.containsStepType(StepType.KEEP) shouldBe true + } + } + + When("포함되지 않은 StepType이면") { + val reviewWithoutTry = + review.copy( + steps = + listOf( + ReviewStep(type = StepType.KEEP, content = "좋은 점"), + ), + ) + + Then("false를 반환한다") { + reviewWithoutTry.containsStepType(StepType.TRY) shouldBe false + } + } + } + })