Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/plan/#28-goal-computed-fields-fix/checklist.md
Original file line number Diff line number Diff line change
@@ -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만 수정 가능 제약으로 미수행
19 changes: 19 additions & 0 deletions docs/plan/#28-goal-computed-fields-fix/plan.md
Original file line number Diff line number Diff line change
@@ -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 검증
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any>(),
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<String> =
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<String, Any>(),
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)
}
}