diff --git a/docs/plan/#28-goal-computed-fields-fix/checklist.md b/docs/plan/#28-goal-computed-fields-fix/checklist.md new file mode 100644 index 0000000..d73cf9b --- /dev/null +++ b/docs/plan/#28-goal-computed-fields-fix/checklist.md @@ -0,0 +1,12 @@ +# goal BC computed fields 하드코딩 수정 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] GoalDataFetcher에서 하드코딩 0 값 문서화 (Codegen 제약으로 constructor 값 제거 불가, @DgsData 오버라이드 동작 명시) +- [x] computed fields가 task BC의 DataLoader를 통해 실제 값으로 해석됨 (GoalDataFetcherTest로 검증) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 + +## 선택 항목 (해당 시) +- [ ] GraphQL 스키마 타입 소유권 정리 (goal BC → task BC extend) — goal BC만 수정 가능 제약으로 미수행 diff --git a/docs/plan/#28-goal-computed-fields-fix/plan.md b/docs/plan/#28-goal-computed-fields-fix/plan.md new file mode 100644 index 0000000..558ecb3 --- /dev/null +++ b/docs/plan/#28-goal-computed-fields-fix/plan.md @@ -0,0 +1,19 @@ +# goal BC computed fields 하드코딩 수정 계획 + +> Issue: #28 + +## 분석 결과 + +`GoalDataFetcher.toGraphql()`에서 `totalTaskCount = 0`, `completedTaskCount = 0`, `achievementRate = 0.0`을 하드코딩하고 있음. + +task BC에 이미 `GoalTaskStatsDataFetcher`(`@DgsData(parentType = "Goal")`)와 `GoalTaskStatsDataLoader`가 구현되어 있어, DGS의 field-level resolver가 하드코딩 값을 오버라이드함. 그러나 goal BC 코드만 보면 거짓 데이터를 반환하는 것처럼 보이는 문제가 있음. + +**근본 원인**: computed fields가 `goal.graphqls`에 정의되어 있어 DGS Codegen이 `Goal` 클래스 생성자에 필수 파라미터로 포함시킴. 이로 인해 `GoalDataFetcher`가 불필요하게 이 필드들의 값을 설정해야 함. + +## 단계 + +- [x] 1단계: 분석 및 계획 수립 +- [x] 2단계: GoalDataFetcher의 하드코딩 값에 @DgsData 오버라이드 동작 문서화 +- [x] 3단계: `GoalDataFetcher.toGraphql()`에서 하드코딩 값 제거 (Codegen 제약으로 comment 처리) +- [x] 4단계: Presentation 통합 테스트 작성 (computed fields가 DataLoader로 해석되는지 검증) +- [x] 5단계: 전체 테스트 통과 확인 및 checklist 검증 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 54126c6..ef064be 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 @@ -70,6 +70,8 @@ class GoalDataFetcher( title = title.value, createdAt = createdAt.toString(), updatedAt = updatedAt?.toString(), + // Constructor-required placeholders — always overridden by + // @DgsData resolvers in GoalTaskStatsDataFetcher (task BC) totalTaskCount = 0, completedTaskCount = 0, achievementRate = 0.0, diff --git a/src/test/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcherTest.kt b/src/test/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcherTest.kt new file mode 100644 index 0000000..64e2509 --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcherTest.kt @@ -0,0 +1,180 @@ +package kr.io.team.loop.goal.presentation.datafetcher + +import com.netflix.graphql.dgs.DgsQueryExecutor +import com.netflix.graphql.dgs.test.EnableDgsTest +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import kr.io.team.loop.common.config.AuthorizeArgumentResolver +import kr.io.team.loop.common.config.JwtTokenProvider +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.Goal +import kr.io.team.loop.goal.domain.model.GoalQuery +import kr.io.team.loop.goal.domain.model.GoalTitle +import kr.io.team.loop.task.application.dto.GoalTaskStatsDto +import kr.io.team.loop.task.application.service.TaskService +import kr.io.team.loop.task.presentation.datafetcher.GoalTaskStatsDataFetcher +import kr.io.team.loop.task.presentation.dataloader.GoalTaskStatsDataLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import java.time.Instant + +@SpringBootTest( + classes = [ + GoalDataFetcher::class, + GoalTaskStatsDataFetcher::class, + GoalTaskStatsDataLoader::class, + AuthorizeArgumentResolver::class, + ], +) +@EnableDgsTest +class GoalDataFetcherTest { + @Autowired + lateinit var dgsQueryExecutor: DgsQueryExecutor + + @MockkBean + lateinit var goalService: GoalService + + @MockkBean + lateinit var taskService: TaskService + + @MockkBean + lateinit var jwtTokenProvider: JwtTokenProvider + + private val memberId = 1L + private val testToken = "test-token" + + private fun authHeaders(): HttpHeaders = + HttpHeaders().apply { + setBearerAuth(testToken) + } + + private fun setupAuth() { + every { jwtTokenProvider.validateToken(testToken) } returns true + every { jwtTokenProvider.getMemberIdFromToken(testToken) } returns memberId + } + + @Test + fun `myGoals computed fields are resolved by DataLoader, not hardcoded values`() { + setupAuth() + + val goal = + Goal( + id = GoalId(1L), + title = GoalTitle("Test Goal"), + memberId = MemberId(memberId), + createdAt = Instant.parse("2026-01-01T00:00:00Z"), + updatedAt = null, + ) + every { goalService.findAll(GoalQuery(memberId = MemberId(memberId))) } returns listOf(goal) + every { taskService.getStatsByGoalIds(setOf(GoalId(1L))) } returns + mapOf( + GoalId(1L) to + GoalTaskStatsDto( + goalId = GoalId(1L), + totalCount = 10, + completedCount = 7, + ), + ) + + val result = + dgsQueryExecutor.executeAndGetDocumentContext( + """ + { + myGoals { + id + title + totalTaskCount + completedTaskCount + achievementRate + } + } + """.trimIndent(), + emptyMap(), + authHeaders(), + ) + + val totalTaskCount: Int = result.read("data.myGoals[0].totalTaskCount") + val completedTaskCount: Int = result.read("data.myGoals[0].completedTaskCount") + val achievementRate: Double = result.read("data.myGoals[0].achievementRate") + + assertThat(totalTaskCount).isEqualTo(10) + assertThat(completedTaskCount).isEqualTo(7) + assertThat(achievementRate).isEqualTo(70.0) + } + + @Test + fun `myGoals returns basic fields correctly`() { + setupAuth() + + val goal = + Goal( + id = GoalId(1L), + title = GoalTitle("영어 공부"), + memberId = MemberId(memberId), + createdAt = Instant.parse("2026-01-01T00:00:00Z"), + updatedAt = null, + ) + every { goalService.findAll(GoalQuery(memberId = MemberId(memberId))) } returns listOf(goal) + every { taskService.getStatsByGoalIds(any()) } returns emptyMap() + + val titles: List = + dgsQueryExecutor.executeAndExtractJsonPath( + """ + { + myGoals { + id + title + } + } + """.trimIndent(), + "data.myGoals[*].title", + authHeaders(), + ) + + assertThat(titles).containsExactly("영어 공부") + } + + @Test + fun `myGoals computed fields default to 0 when no tasks exist`() { + setupAuth() + + val goal = + Goal( + id = GoalId(1L), + title = GoalTitle("Empty Goal"), + memberId = MemberId(memberId), + createdAt = Instant.parse("2026-01-01T00:00:00Z"), + updatedAt = null, + ) + every { goalService.findAll(GoalQuery(memberId = MemberId(memberId))) } returns listOf(goal) + every { taskService.getStatsByGoalIds(setOf(GoalId(1L))) } returns emptyMap() + + val result = + dgsQueryExecutor.executeAndGetDocumentContext( + """ + { + myGoals { + totalTaskCount + completedTaskCount + achievementRate + } + } + """.trimIndent(), + emptyMap(), + authHeaders(), + ) + + val totalTaskCount: Int = result.read("data.myGoals[0].totalTaskCount") + val completedTaskCount: Int = result.read("data.myGoals[0].completedTaskCount") + val achievementRate: Double = result.read("data.myGoals[0].achievementRate") + + assertThat(totalTaskCount).isEqualTo(0) + assertThat(completedTaskCount).isEqualTo(0) + assertThat(achievementRate).isEqualTo(0.0) + } +}