From 9da7b535553aaae3c6ea989fdba1cd49d48522f3 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Tue, 24 Mar 2026 23:44:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B3=84=20=EB=AA=A9=ED=91=9C?= =?UTF-8?q?(DailyGoal)=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EB=B0=8F=20API=20=EA=B5=AC=ED=98=84=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 특정 날짜에 목표를 배치하는 DailyGoal 기능을 goal BC 내부에 구현. 내부 도메인 모델(DailyGoal 엔티티)은 유지하되, GraphQL API는 Goal 중심으로 단순화하여 myGoals 필터(GoalFilter)로 조회하는 방식 채택. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/plan/#38-daily-goal/checklist.md | 13 ++ docs/plan/#38-daily-goal/plan.md | 36 ++++++ .../goal/application/service/GoalService.kt | 37 ++++++ .../team/loop/goal/domain/model/DailyGoal.kt | 16 +++ .../goal/domain/model/DailyGoalCommand.kt | 19 +++ .../loop/goal/domain/model/DailyGoalId.kt | 12 ++ .../team/loop/goal/domain/model/GoalQuery.kt | 6 + .../domain/repository/DailyGoalRepository.kt | 19 +++ .../persistence/DailyGoalTable.kt | 19 +++ .../persistence/ExposedDailyGoalRepository.kt | 58 +++++++++ .../persistence/ExposedGoalRepository.kt | 32 ++++- .../datafetcher/GoalDataFetcher.kt | 44 ++++++- .../migration/V5__Create_daily_goal_table.sql | 10 ++ src/main/resources/schema/goal.graphqls | 54 +++++++- .../application/service/GoalServiceTest.kt | 120 +++++++++++++++++- .../loop/goal/domain/model/DailyGoalIdTest.kt | 36 ++++++ .../loop/goal/domain/model/DailyGoalTest.kt | 57 +++++++++ 17 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 docs/plan/#38-daily-goal/checklist.md create mode 100644 docs/plan/#38-daily-goal/plan.md create mode 100644 src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoal.kt create mode 100644 src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalCommand.kt create mode 100644 src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalId.kt create mode 100644 src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt create mode 100644 src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/DailyGoalTable.kt create mode 100644 src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt create mode 100644 src/main/resources/db/migration/V5__Create_daily_goal_table.sql create mode 100644 src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalIdTest.kt create mode 100644 src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalTest.kt diff --git a/docs/plan/#38-daily-goal/checklist.md b/docs/plan/#38-daily-goal/checklist.md new file mode 100644 index 0000000..c7e9aed --- /dev/null +++ b/docs/plan/#38-daily-goal/checklist.md @@ -0,0 +1,13 @@ +# 일별 목표(DailyGoal) 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] Domain, Application 테스트 코드 작성 완료 (TDD) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 +- [x] DGS Codegen으로 GraphQL 타입 자동 생성 (수동 작성 금지) + +## 선택 항목 +- [x] Flyway 마이그레이션 작성 (V5__Create_daily_goal_table.sql) +- [x] Goal + Date + Member 유니크 제약 적용 diff --git a/docs/plan/#38-daily-goal/plan.md b/docs/plan/#38-daily-goal/plan.md new file mode 100644 index 0000000..89d7eea --- /dev/null +++ b/docs/plan/#38-daily-goal/plan.md @@ -0,0 +1,36 @@ +# 일별 목표(DailyGoal) 구현 계획 + +> Issue: #38 + +## 단계 + +- [x] 1단계: Domain — DailyGoal 엔티티, VO, Command, Query, Repository 인터페이스 (TDD) +- [x] 2단계: Application — GoalService에 DailyGoal 메서드 추가 (TDD) +- [x] 3단계: Infrastructure — DailyGoalTable, ExposedDailyGoalRepository, Flyway 마이그레이션 +- [x] 4단계: Presentation — GraphQL 스키마 확장, GoalDataFetcher 업데이트 +- [x] 5단계: 검증 — 전체 테스트 통과, 아키텍처 준수 확인 + +## 리팩토링: DailyGoal API를 Goal 중심으로 단순화 + +GraphQL의 선택적 필드 조회 특성을 활용하여, 별도 DailyGoal 타입을 API에서 제거하고 Goal에 통합. +내부 도메인 모델(DailyGoal 엔티티)은 유지하되, 스키마 표면은 Goal 중심으로 변경. + +- [x] 6단계: GraphQL 스키마 — DailyGoal 타입 제거, myGoals에 날짜 필터 추가, mutation 시그니처 변경 +- [x] 7단계: Domain — DailyGoalCommand.Remove를 goalId+memberId+date 기반으로 변경, GoalQuery에 assignedDate 추가 +- [x] 8단계: Application/Infrastructure — 변경된 Command/Query 반영 +- [x] 9단계: Presentation — GoalDataFetcher 리팩토링 (DailyGoal 리졸버 제거, myGoals 필터 적용) +- [x] 10단계: 검증 — 전체 테스트 통과 확인 + +## GoalFilter 확장: AND 조건 필터 추가 + +GoalFilter에 ids 등 다양한 조건을 추가하고, 모든 조건은 AND로 결합. + +- [x] 11단계: GoalFilter 확장 — 스키마, GoalQuery, Repository, DataFetcher 일괄 변경 +- [x] 12단계: 검증 — 전체 테스트 통과 확인 + +## GoalFilter 추가 필드: id, title, 우선순위 규칙 + +id(단건) > ids(복수) > title 순 우선. assignedDate는 항상 AND. 이후 추가 조건도 AND. + +- [x] 13단계: GoalFilter에 id, title 추가 및 우선순위 로직 구현 +- [x] 14단계: 검증 diff --git a/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt b/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt index 2f273f4..6ef6ece 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt @@ -1,11 +1,15 @@ package kr.io.team.loop.goal.application.service +import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.exception.AccessDeniedException +import kr.io.team.loop.common.domain.exception.DuplicateEntityException import kr.io.team.loop.common.domain.exception.EntityNotFoundException +import kr.io.team.loop.goal.domain.model.DailyGoalCommand import kr.io.team.loop.goal.domain.model.Goal import kr.io.team.loop.goal.domain.model.GoalCommand import kr.io.team.loop.goal.domain.model.GoalQuery +import kr.io.team.loop.goal.domain.repository.DailyGoalRepository import kr.io.team.loop.goal.domain.repository.GoalRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional @Service class GoalService( private val goalRepository: GoalRepository, + private val dailyGoalRepository: DailyGoalRepository, ) { @Transactional fun create(command: GoalCommand.Create): Goal = goalRepository.save(command) @@ -20,6 +25,11 @@ class GoalService( @Transactional(readOnly = true) fun findAll(query: GoalQuery): List = goalRepository.findAll(query) + @Transactional(readOnly = true) + fun findById(id: GoalId): Goal = + goalRepository.findById(id) + ?: throw EntityNotFoundException("Goal not found: ${id.value}") + @Transactional fun update( command: GoalCommand.Update, @@ -47,4 +57,31 @@ class GoalService( } goalRepository.delete(command) } + + @Transactional + fun addDailyGoal(command: DailyGoalCommand.Add): Goal { + val goal = + goalRepository.findById(command.goalId) + ?: throw EntityNotFoundException("Goal not found: ${command.goalId.value}") + if (!goal.isOwnedBy(command.memberId)) { + throw AccessDeniedException("Goal does not belong to member: ${command.memberId.value}") + } + if (dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(command.goalId, command.memberId, command.date)) { + throw DuplicateEntityException( + "DailyGoal already exists for goal ${command.goalId.value} on ${command.date}", + ) + } + dailyGoalRepository.save(command) + return goal + } + + @Transactional + fun removeDailyGoal(command: DailyGoalCommand.Remove) { + if (!dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(command.goalId, command.memberId, command.date)) { + throw EntityNotFoundException( + "DailyGoal not found for goal ${command.goalId.value} on ${command.date}", + ) + } + dailyGoalRepository.delete(command) + } } diff --git a/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoal.kt b/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoal.kt new file mode 100644 index 0000000..d0a0f50 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoal.kt @@ -0,0 +1,16 @@ +package kr.io.team.loop.goal.domain.model + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.common.domain.MemberId +import java.time.Instant + +data class DailyGoal( + val id: DailyGoalId, + val goalId: GoalId, + val memberId: MemberId, + val date: LocalDate, + val createdAt: Instant, +) { + fun isOwnedBy(memberId: MemberId): Boolean = this.memberId == memberId +} diff --git a/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalCommand.kt b/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalCommand.kt new file mode 100644 index 0000000..a058924 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalCommand.kt @@ -0,0 +1,19 @@ +package kr.io.team.loop.goal.domain.model + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.common.domain.MemberId + +sealed interface DailyGoalCommand { + data class Add( + val goalId: GoalId, + val memberId: MemberId, + val date: LocalDate, + ) : DailyGoalCommand + + data class Remove( + val goalId: GoalId, + val memberId: MemberId, + val date: LocalDate, + ) : DailyGoalCommand +} diff --git a/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalId.kt b/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalId.kt new file mode 100644 index 0000000..1d91dd0 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalId.kt @@ -0,0 +1,12 @@ +package kr.io.team.loop.goal.domain.model + +import kr.io.team.loop.common.domain.exception.InvalidInputException + +@JvmInline +value class DailyGoalId( + val value: Long, +) { + init { + if (value <= 0) throw InvalidInputException("DailyGoalId must be positive") + } +} diff --git a/src/main/kotlin/kr/io/team/loop/goal/domain/model/GoalQuery.kt b/src/main/kotlin/kr/io/team/loop/goal/domain/model/GoalQuery.kt index ba6d755..ee7f47e 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/domain/model/GoalQuery.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/domain/model/GoalQuery.kt @@ -1,7 +1,13 @@ package kr.io.team.loop.goal.domain.model +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId data class GoalQuery( val memberId: MemberId? = null, + val id: GoalId? = null, + val ids: List? = null, + val title: String? = null, + val assignedDate: LocalDate? = null, ) diff --git a/src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt b/src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt new file mode 100644 index 0000000..90ab0c3 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt @@ -0,0 +1,19 @@ +package kr.io.team.loop.goal.domain.repository + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.goal.domain.model.DailyGoal +import kr.io.team.loop.goal.domain.model.DailyGoalCommand + +interface DailyGoalRepository { + fun save(command: DailyGoalCommand.Add): DailyGoal + + fun delete(command: DailyGoalCommand.Remove) + + fun existsByGoalIdAndMemberIdAndDate( + goalId: GoalId, + memberId: MemberId, + date: LocalDate, + ): Boolean +} diff --git a/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/DailyGoalTable.kt b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/DailyGoalTable.kt new file mode 100644 index 0000000..24fd013 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/DailyGoalTable.kt @@ -0,0 +1,19 @@ +package kr.io.team.loop.goal.infrastructure.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.date +import org.jetbrains.exposed.v1.datetime.timestampWithTimeZone + +object DailyGoalTable : Table("daily_goal") { + val dailyGoalId = long("daily_goal_id").autoIncrement() + val goalId = long("goal_id") + val memberId = long("member_id").index() + val date = date("date") + val createdAt = timestampWithTimeZone("created_at") + + override val primaryKey = PrimaryKey(dailyGoalId) + + init { + uniqueIndex(goalId, memberId, date) + } +} diff --git a/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt new file mode 100644 index 0000000..39a9b8a --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt @@ -0,0 +1,58 @@ +package kr.io.team.loop.goal.infrastructure.persistence + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.goal.domain.model.DailyGoal +import kr.io.team.loop.goal.domain.model.DailyGoalCommand +import kr.io.team.loop.goal.domain.model.DailyGoalId +import kr.io.team.loop.goal.domain.repository.DailyGoalRepository +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.deleteWhere +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 ExposedDailyGoalRepository : DailyGoalRepository { + override fun save(command: DailyGoalCommand.Add): DailyGoal { + val now = OffsetDateTime.now() + val row = + DailyGoalTable.insert { + it[goalId] = command.goalId.value + it[memberId] = command.memberId.value + it[date] = command.date + it[createdAt] = now + } + return DailyGoal( + id = DailyGoalId(row[DailyGoalTable.dailyGoalId]), + goalId = command.goalId, + memberId = command.memberId, + date = command.date, + createdAt = now.toInstant(), + ) + } + + override fun delete(command: DailyGoalCommand.Remove) { + DailyGoalTable.deleteWhere { + (goalId eq command.goalId.value) and + (memberId eq command.memberId.value) and + (date eq command.date) + } + } + + override fun existsByGoalIdAndMemberIdAndDate( + goalId: GoalId, + memberId: MemberId, + date: LocalDate, + ): Boolean = + DailyGoalTable + .selectAll() + .where { + (DailyGoalTable.goalId eq goalId.value) and + (DailyGoalTable.memberId eq memberId.value) and + (DailyGoalTable.date eq date) + }.count() > 0 +} diff --git a/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedGoalRepository.kt b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedGoalRepository.kt index 2a064e3..e543215 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedGoalRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedGoalRepository.kt @@ -7,9 +7,14 @@ import kr.io.team.loop.goal.domain.model.GoalCommand import kr.io.team.loop.goal.domain.model.GoalQuery import kr.io.team.loop.goal.domain.model.GoalTitle import kr.io.team.loop.goal.domain.repository.GoalRepository +import org.jetbrains.exposed.v1.core.JoinType +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.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.like import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll @@ -50,10 +55,29 @@ class ExposedGoalRepository : GoalRepository { } override fun findAll(query: GoalQuery): List { - var statement = GoalTable.selectAll() - query.memberId?.let { statement = statement.where { GoalTable.memberId eq it.value } } - return statement - .orderBy(GoalTable.createdAt, SortOrder.DESC) + val needsJoin = query.assignedDate != null + val base = + if (needsJoin) { + GoalTable.join(DailyGoalTable, JoinType.INNER, GoalTable.goalId, DailyGoalTable.goalId) + } else { + GoalTable + } + var condition: Op = Op.TRUE + query.memberId?.let { condition = condition and (GoalTable.memberId eq it.value) } + // 목표 선택 조건: id > ids > title 우선순위 + when { + query.id != null -> condition = condition and (GoalTable.goalId eq query.id.value) + query.ids != null -> condition = condition and (GoalTable.goalId inList query.ids.map { it.value }) + query.title != null -> condition = condition and (GoalTable.title like "%${query.title}%") + } + // AND 조건 + query.assignedDate?.let { condition = condition and (DailyGoalTable.date eq it) } + val orderColumn = if (needsJoin) DailyGoalTable.createdAt else GoalTable.createdAt + val orderDirection = if (needsJoin) SortOrder.ASC else SortOrder.DESC + return base + .selectAll() + .where(condition) + .orderBy(orderColumn, orderDirection) .map { it.toGoal() } } diff --git a/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt index ef064be..0884358 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt @@ -4,12 +4,17 @@ 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 kr.io.team.loop.codegen.types.AddDailyGoalInput import kr.io.team.loop.codegen.types.CreateGoalInput +import kr.io.team.loop.codegen.types.GoalFilter +import kr.io.team.loop.codegen.types.RemoveDailyGoalInput import kr.io.team.loop.codegen.types.UpdateGoalInput import kr.io.team.loop.common.config.Authorize import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.goal.application.service.GoalService +import kr.io.team.loop.goal.domain.model.DailyGoalCommand import kr.io.team.loop.goal.domain.model.Goal import kr.io.team.loop.goal.domain.model.GoalCommand import kr.io.team.loop.goal.domain.model.GoalQuery @@ -22,9 +27,17 @@ class GoalDataFetcher( ) { @DgsQuery fun myGoals( + @InputArgument filter: GoalFilter?, @Authorize memberId: Long, ): List { - val query = GoalQuery(memberId = MemberId(memberId)) + val query = + GoalQuery( + memberId = MemberId(memberId), + id = filter?.id?.let { GoalId(it.toLong()) }, + ids = filter?.ids?.map { GoalId(it.toLong()) }, + title = filter?.title, + assignedDate = filter?.assignedDate?.let { LocalDate.parse(it) }, + ) return goalService.findAll(query).map { it.toGraphql() } } @@ -64,6 +77,35 @@ class GoalDataFetcher( return true } + @DgsMutation + fun addDailyGoal( + @InputArgument input: AddDailyGoalInput, + @Authorize memberId: Long, + ): GoalGraphql { + val command = + DailyGoalCommand.Add( + goalId = GoalId(input.goalId.toLong()), + memberId = MemberId(memberId), + date = LocalDate.parse(input.date), + ) + return goalService.addDailyGoal(command).toGraphql() + } + + @DgsMutation + fun removeDailyGoal( + @InputArgument input: RemoveDailyGoalInput, + @Authorize memberId: Long, + ): Boolean { + val command = + DailyGoalCommand.Remove( + goalId = GoalId(input.goalId.toLong()), + memberId = MemberId(memberId), + date = LocalDate.parse(input.date), + ) + goalService.removeDailyGoal(command) + return true + } + private fun Goal.toGraphql(): GoalGraphql = GoalGraphql( id = id.value.toString(), diff --git a/src/main/resources/db/migration/V5__Create_daily_goal_table.sql b/src/main/resources/db/migration/V5__Create_daily_goal_table.sql new file mode 100644 index 0000000..02f5932 --- /dev/null +++ b/src/main/resources/db/migration/V5__Create_daily_goal_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE daily_goal ( + daily_goal_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + goal_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + date DATE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX idx_daily_goal_member_id ON daily_goal (member_id); +CREATE UNIQUE INDEX uq_daily_goal_goal_member_date ON daily_goal (goal_id, member_id, date); diff --git a/src/main/resources/schema/goal.graphqls b/src/main/resources/schema/goal.graphqls index dbca378..a1acc73 100644 --- a/src/main/resources/schema/goal.graphqls +++ b/src/main/resources/schema/goal.graphqls @@ -1,6 +1,13 @@ extend type Query { - "현재 사용자의 목표 목록을 조회한다." - myGoals: [Goal!]! + """ + 현재 사용자의 목표 목록을 조회한다. 필터를 지정하면 조건에 맞는 목표만 반환한다. + id, ids, title은 목표 선택 조건으로 우선순위에 따라 하나만 적용된다 (id > ids > title). + assignedDate 등 이후 추가되는 조건은 선택 결과에 AND로 결합된다. + """ + myGoals( + "목표 조회 필터 (선택)" + filter: GoalFilter + ): [Goal!]! } extend type Mutation { @@ -12,6 +19,18 @@ extend type Mutation { "목표를 삭제한다. (본인 목표만)" deleteGoal(id: ID!): Boolean! + + "특정 날짜에 목표를 배치한다. (인증 필수, 동일 목표+날짜 중복 불가)" + addDailyGoal( + "일별 목표 추가 입력" + input: AddDailyGoalInput! + ): Goal! + + "특정 날짜에서 목표 배치를 제거한다. (본인 목표만)" + removeDailyGoal( + "제거할 일별 목표 입력" + input: RemoveDailyGoalInput! + ): Boolean! } "목표" @@ -32,6 +51,21 @@ type Goal { achievementRate: Float! } +""" +목표 조회 필터. id, ids, title은 목표 선택 조건으로 우선순위에 따라 하나만 적용된다 (id > ids > title). +assignedDate 등 이후 추가되는 조건은 선택 결과에 AND로 결합된다. +""" +input GoalFilter { + "단일 목표 ID. 지정 시 해당 목표만 반환 (최우선)" + id: ID + "목표 ID 목록. 지정 시 해당 목표만 반환 (id 미지정 시 적용)" + ids: [ID!] + "목표 제목 검색 (부분 일치). id, ids 미지정 시 적용" + title: String + "배치된 날짜 (YYYY-MM-DD). 지정 시 해당 날짜에 배치된 목표만 반환 (AND 조건)" + assignedDate: String +} + "목표 생성 입력" input CreateGoalInput { "목표 제목 (공백만 불가)" @@ -45,3 +79,19 @@ input UpdateGoalInput { "새 목표 제목 (공백만 불가)" title: String! } + +"일별 목표 추가 입력" +input AddDailyGoalInput { + "배치할 목표 ID" + goalId: ID! + "배치할 날짜 (YYYY-MM-DD)" + date: String! +} + +"일별 목표 제거 입력" +input RemoveDailyGoalInput { + "제거할 목표 ID" + goalId: ID! + "제거할 날짜 (YYYY-MM-DD)" + date: String! +} diff --git a/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt b/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt index 21e6b54..5f361d8 100644 --- a/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt @@ -8,14 +8,20 @@ import io.mockk.every import io.mockk.justRun import io.mockk.mockk import io.mockk.verify +import kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.exception.AccessDeniedException +import kr.io.team.loop.common.domain.exception.DuplicateEntityException import kr.io.team.loop.common.domain.exception.EntityNotFoundException +import kr.io.team.loop.goal.domain.model.DailyGoal +import kr.io.team.loop.goal.domain.model.DailyGoalCommand +import kr.io.team.loop.goal.domain.model.DailyGoalId import kr.io.team.loop.goal.domain.model.Goal import kr.io.team.loop.goal.domain.model.GoalCommand import kr.io.team.loop.goal.domain.model.GoalQuery import kr.io.team.loop.goal.domain.model.GoalTitle +import kr.io.team.loop.goal.domain.repository.DailyGoalRepository import kr.io.team.loop.goal.domain.repository.GoalRepository import java.time.Instant @@ -23,7 +29,8 @@ class GoalServiceTest : BehaviorSpec({ val goalRepository = mockk() - val goalService = GoalService(goalRepository) + val dailyGoalRepository = mockk() + val goalService = GoalService(goalRepository, dailyGoalRepository) val memberId = MemberId(1L) val otherMemberId = MemberId(2L) @@ -150,4 +157,115 @@ class GoalServiceTest : } } } + + val date = LocalDate(2026, 3, 24) + val savedDailyGoal = + DailyGoal( + id = DailyGoalId(1L), + goalId = GoalId(1L), + memberId = memberId, + date = date, + createdAt = Instant.now(), + ) + + Given("일별 목표 추가 시") { + When("유효한 입력이면") { + val command = DailyGoalCommand.Add(goalId = GoalId(1L), memberId = memberId, date = date) + every { goalRepository.findById(GoalId(1L)) } returns savedGoal + every { dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(GoalId(1L), memberId, date) } returns false + every { dailyGoalRepository.save(command) } returns savedDailyGoal + + val result = goalService.addDailyGoal(command) + + Then("해당 목표를 반환한다") { + result.id.value shouldBe 1L + result.title.value shouldBe "영어 공부" + } + } + + When("존재하지 않는 목표이면") { + val command = DailyGoalCommand.Add(goalId = GoalId(99L), memberId = memberId, date = date) + every { goalRepository.findById(GoalId(99L)) } returns null + + Then("EntityNotFoundException이 발생한다") { + shouldThrow { + goalService.addDailyGoal(command) + } + } + } + + When("본인 목표가 아니면") { + val command = DailyGoalCommand.Add(goalId = GoalId(1L), memberId = otherMemberId, date = date) + every { goalRepository.findById(GoalId(1L)) } returns savedGoal + + Then("AccessDeniedException이 발생한다") { + shouldThrow { + goalService.addDailyGoal(command) + } + } + } + + When("이미 같은 날짜에 같은 목표가 추가되어 있으면") { + val command = DailyGoalCommand.Add(goalId = GoalId(1L), memberId = memberId, date = date) + every { goalRepository.findById(GoalId(1L)) } returns savedGoal + every { dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(GoalId(1L), memberId, date) } returns true + + Then("DuplicateEntityException이 발생한다") { + shouldThrow { + goalService.addDailyGoal(command) + } + } + } + } + + Given("일별 목표 제거 시") { + When("해당 날짜에 목표가 배치되어 있으면") { + val command = DailyGoalCommand.Remove(goalId = GoalId(1L), memberId = memberId, date = date) + every { dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(GoalId(1L), memberId, date) } returns true + justRun { dailyGoalRepository.delete(command) } + + goalService.removeDailyGoal(command) + + Then("삭제가 수행된다") { + verify { dailyGoalRepository.delete(command) } + } + } + + When("해당 날짜에 목표가 배치되어 있지 않으면") { + val command = DailyGoalCommand.Remove(goalId = GoalId(99L), memberId = memberId, date = date) + every { dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(GoalId(99L), memberId, date) } returns + false + + Then("EntityNotFoundException이 발생한다") { + shouldThrow { + goalService.removeDailyGoal(command) + } + } + } + } + + Given("assignedDate 필터로 목표 조회 시") { + When("해당 날짜에 배치된 목표가 있으면") { + val query = GoalQuery(memberId = memberId, assignedDate = date) + every { goalRepository.findAll(query) } returns listOf(savedGoal) + + val result = goalService.findAll(query) + + Then("배치된 목표만 반환한다") { + result shouldHaveSize 1 + result[0].title.value shouldBe "영어 공부" + } + } + + When("해당 날짜에 배치된 목표가 없으면") { + val query = GoalQuery(memberId = memberId, assignedDate = LocalDate(2026, 1, 1)) + every { goalRepository.findAll(query) } returns emptyList() + + val result = goalService.findAll(query) + + Then("빈 목록을 반환한다") { + result shouldHaveSize 0 + } + } + } }) diff --git a/src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalIdTest.kt b/src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalIdTest.kt new file mode 100644 index 0000000..8d8d2f2 --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalIdTest.kt @@ -0,0 +1,36 @@ +package kr.io.team.loop.goal.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 DailyGoalIdTest : + BehaviorSpec({ + + Given("DailyGoalId 생성 시") { + When("양수이면") { + val id = DailyGoalId(1L) + + Then("정상 생성된다") { + id.value shouldBe 1L + } + } + + When("0이면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + DailyGoalId(0L) + } + } + } + + When("음수이면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + DailyGoalId(-1L) + } + } + } + } + }) diff --git a/src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalTest.kt b/src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalTest.kt new file mode 100644 index 0000000..9aa29f7 --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalTest.kt @@ -0,0 +1,57 @@ +package kr.io.team.loop.goal.domain.model + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.common.domain.MemberId +import java.time.Instant + +class DailyGoalTest : + BehaviorSpec({ + + Given("DailyGoal 생성 시") { + When("유효한 값이면") { + val dailyGoal = + DailyGoal( + id = DailyGoalId(1L), + goalId = GoalId(1L), + memberId = MemberId(1L), + date = LocalDate(2026, 3, 24), + createdAt = Instant.now(), + ) + + Then("정상 생성된다") { + dailyGoal.id.value shouldBe 1L + dailyGoal.goalId.value shouldBe 1L + dailyGoal.memberId.value shouldBe 1L + dailyGoal.date shouldBe LocalDate(2026, 3, 24) + dailyGoal.createdAt shouldNotBe null + } + } + } + + Given("DailyGoal의 소유자 확인 시") { + val dailyGoal = + DailyGoal( + id = DailyGoalId(1L), + goalId = GoalId(1L), + memberId = MemberId(1L), + date = LocalDate(2026, 3, 24), + createdAt = Instant.now(), + ) + + When("본인이면") { + Then("true를 반환한다") { + dailyGoal.isOwnedBy(MemberId(1L)) shouldBe true + } + } + + When("본인이 아니면") { + Then("false를 반환한다") { + dailyGoal.isOwnedBy(MemberId(2L)) shouldBe false + } + } + } + })