Skip to content

Commit 2d9a73d

Browse files
authored
Merge pull request #129 from NMNB-Team/v1/feat/#121-buy-badge
[feat] 뱃지 조회 기능 구현
2 parents 1d9132e + aa56273 commit 2d9a73d

16 files changed

Lines changed: 637 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package nmnb.application.domain.badge.controller
2+
3+
import io.swagger.v3.oas.annotations.Operation
4+
import io.swagger.v3.oas.annotations.Parameter
5+
import io.swagger.v3.oas.annotations.responses.ApiResponse
6+
import io.swagger.v3.oas.annotations.responses.ApiResponses
7+
import io.swagger.v3.oas.annotations.tags.Tag
8+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoListResponse
9+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoResponse
10+
import nmnb.application.domain.badge.service.BadgeService
11+
import nmnb.common.handler.annotation.AuthUser
12+
import nmnb.common.response.base.BaseResponse
13+
import nmnb.common.response.status.SuccessStatus
14+
import nmnb.domain.user.User
15+
import org.springframework.web.bind.annotation.GetMapping
16+
import org.springframework.web.bind.annotation.PathVariable
17+
import org.springframework.web.bind.annotation.RequestMapping
18+
import org.springframework.web.bind.annotation.RestController
19+
20+
@RestController
21+
@Tag(name = "Badge 🎞️", description = "뱃지 관련 API")
22+
@RequestMapping("/v1/api")
23+
class BadgeController(
24+
private val badgeService: BadgeService,
25+
) {
26+
27+
@Operation(summary = "뱃지 조회 API", description = "사용자가 구매 가능한 모든 뱃지 목록과 구매 여부를 조회합니다._숙희")
28+
@ApiResponses(ApiResponse(responseCode = "COMMON200", description = "성공입니다."))
29+
@GetMapping("/badge")
30+
fun getBadges(
31+
@Parameter(name = "user", hidden = true) @AuthUser user: User,
32+
): BaseResponse<BadgeInfoListResponse> {
33+
return BaseResponse.onSuccess(
34+
SuccessStatus.OK,
35+
badgeService.getBadges(user.id!!),
36+
)
37+
}
38+
39+
@Operation(summary = "특정 뱃지 조회 API", description = "특정한 뱃지의 정보를 조회합니다._숙희")
40+
@ApiResponses(
41+
ApiResponse(responseCode = "COMMON200", description = "성공입니다."),
42+
ApiResponse(responseCode = "BADGE400", description = "뱃지를 찾을 수 없습니다."),
43+
)
44+
@GetMapping("/badge/{badgeId}")
45+
fun getBadge(
46+
@PathVariable(name = "badgeId") badgeId: Long,
47+
@Parameter(name = "user", hidden = true) @AuthUser user: User,
48+
): BaseResponse<BadgeInfoResponse> {
49+
return BaseResponse.onSuccess(
50+
SuccessStatus.OK,
51+
badgeService.getBadge(user.id!!, badgeId),
52+
)
53+
}
54+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package nmnb.application.domain.badge.controller.dto.response
2+
3+
import nmnb.common.utils.DateTimeUtils
4+
import nmnb.domain.badge.Badge
5+
import java.time.LocalDateTime
6+
7+
data class BadgeInfoListResponse(
8+
val badgeInfos: List<BadgeInfoResponse>,
9+
)
10+
11+
data class BadgeInfoResponse(
12+
val id: Long,
13+
val name: String,
14+
val description: String,
15+
val imageUrl: String,
16+
val price: Int,
17+
val isPurchased: Boolean,
18+
val purchasedAt: String? = null,
19+
) {
20+
companion object {
21+
fun of(badge: Badge, isPurchased: Boolean, purchasedAt: LocalDateTime? = null): BadgeInfoResponse {
22+
return BadgeInfoResponse(
23+
id = badge.id!!,
24+
name = badge.name,
25+
description = badge.description,
26+
imageUrl = badge.imageUrl,
27+
price = badge.price,
28+
isPurchased = isPurchased,
29+
purchasedAt = purchasedAt?.let { DateTimeUtils.formatDate(it) },
30+
)
31+
}
32+
}
33+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package nmnb.application.domain.badge.service
2+
3+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoListResponse
4+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoResponse
5+
6+
interface BadgeService {
7+
fun getBadges(userId: String): BadgeInfoListResponse
8+
fun getBadge(userId: String, badgeId: Long): BadgeInfoResponse
9+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package nmnb.application.domain.badge.service
2+
3+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoListResponse
4+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoResponse
5+
import nmnb.common.response.exception.BadgeException
6+
import nmnb.common.response.status.ErrorStatus
7+
import nmnb.domain.badge.Badge
8+
import nmnb.domain.badge.UserBadge
9+
import nmnb.domain.badge.repository.BadgeRepository
10+
import org.springframework.stereotype.Service
11+
import org.springframework.transaction.annotation.Transactional
12+
13+
@Transactional(readOnly = true)
14+
@Service
15+
class BadgeServiceImpl(
16+
private val badgeRepository: BadgeRepository,
17+
private val userBadgeService: UserBadgeService,
18+
) : BadgeService {
19+
override fun getBadges(userId: String): BadgeInfoListResponse {
20+
val badges = badgeRepository.findByActiveIsTrueOrderByPriceAsc()
21+
// 사용자가 구매한 배지들을 Map으로 변환 (badgeId -> UserBadge)
22+
val userBadges = userBadgeService.findByUserId(userId)
23+
.associateBy { it.badge.id }
24+
25+
return createBadgeInfoListResponse(badges, userBadges)
26+
}
27+
28+
override fun getBadge(userId: String, badgeId: Long): BadgeInfoResponse {
29+
val badge = badgeRepository.findById(badgeId)
30+
.orElseThrow { BadgeException(ErrorStatus.BADGE_NOTFOUND) }
31+
32+
val userBadges = userBadgeService.findByUserId(userId)
33+
.associateBy { it.badge.id }
34+
35+
return createBadgeInfoResponse(badge, userBadges)
36+
}
37+
38+
private fun createBadgeInfoResponse(
39+
badge: Badge,
40+
userBadges: Map<Long?, UserBadge>,
41+
): BadgeInfoResponse {
42+
val userBadge = userBadges[badge.id]
43+
44+
return BadgeInfoResponse.of(
45+
badge = badge,
46+
isPurchased = userBadge != null,
47+
purchasedAt = userBadge?.purchasedAt,
48+
)
49+
}
50+
51+
private fun createBadgeInfoListResponse(
52+
badges: List<Badge>,
53+
userBadges: Map<Long?, UserBadge>,
54+
): BadgeInfoListResponse {
55+
val badgeResponses = createBadgeResponses(badges, userBadges)
56+
return BadgeInfoListResponse(badgeResponses)
57+
}
58+
59+
private fun createBadgeResponses(
60+
badges: List<Badge>,
61+
userBadges: Map<Long?, UserBadge>,
62+
) = badges.map { badge ->
63+
createBadgeInfoResponse(badge, userBadges)
64+
}
65+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package nmnb.application.domain.badge.service
2+
3+
import nmnb.domain.badge.UserBadge
4+
import nmnb.domain.badge.repository.UserBadgeRepository
5+
import org.springframework.stereotype.Service
6+
import org.springframework.transaction.annotation.Transactional
7+
8+
@Transactional(readOnly = true)
9+
@Service
10+
class UserBadgeService(
11+
private val userBadgeRepository: UserBadgeRepository,
12+
) {
13+
fun findByUserId(userId: String): List<UserBadge> =
14+
userBadgeRepository.findByUserId(userId)
15+
}

nmnb-application/src/main/kotlin/nmnb/application/global/config/SecurityConfig.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class SecurityConfig(
3535
"/v1/api/users/blocks",
3636
"/v1/api/videos/**",
3737
"/v1/api/users/me/videos",
38+
"/v1/api/badge/**",
3839
)
3940

4041
@Bean

nmnb-application/src/test/kotlin/nmnb/application/ControllerTestSupport.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package nmnb.application
22

33
import com.fasterxml.jackson.databind.ObjectMapper
4+
import nmnb.application.domain.badge.controller.BadgeController
5+
import nmnb.application.domain.badge.service.BadgeService
46
import nmnb.application.domain.block.controller.BlockController
57
import nmnb.application.domain.block.service.UserBlockService
68
import nmnb.application.domain.like.controller.LikeController
@@ -33,6 +35,7 @@ import org.springframework.boot.test.mock.mockito.MockBean
3335
import org.springframework.context.annotation.Import
3436
import org.springframework.data.redis.core.StringRedisTemplate
3537
import org.springframework.test.context.ContextConfiguration
38+
import org.springframework.test.util.ReflectionTestUtils
3639
import org.springframework.test.web.servlet.MockMvc
3740

3841
@WebMvcTest(
@@ -43,6 +46,7 @@ import org.springframework.test.web.servlet.MockMvc
4346
AuthController::class,
4447
ReportController::class,
4548
BlockController::class,
49+
BadgeController::class,
4650
],
4751
)
4852
@Import(
@@ -87,6 +91,9 @@ abstract class ControllerTestSupport {
8791
@MockBean
8892
lateinit var userBlockService: UserBlockService
8993

94+
@MockBean
95+
lateinit var badgeService: BadgeService
96+
9097
@MockBean
9198
protected lateinit var userRepository: UserRepository
9299

@@ -101,6 +108,8 @@ abstract class ControllerTestSupport {
101108

102109
fun mockUserAuthentication(user: User, accessToken: String, deviceId: String) {
103110
whenever(jwtProvider.getEmailWithValidation(accessToken)).thenReturn(user.email)
111+
whenever(blacklistService.isBlacklisted(accessToken)).thenReturn(false)
112+
ReflectionTestUtils.setField(user, "id", "test-user-id")
104113
whenever(userRepository.findByEmail(user.email)).thenReturn(user)
105114
whenever(jwtProvider.getClaimFromToken(accessToken, JwtConstants.DEVICE_ID_CLAIM_KEY)).thenReturn(deviceId)
106115
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package nmnb.application.domain.badge.controller
2+
3+
import nmnb.application.ControllerTestSupport
4+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoListResponse
5+
import nmnb.application.domain.badge.controller.dto.response.BadgeInfoResponse
6+
import nmnb.common.response.status.SuccessStatus
7+
import nmnb.domain.user.User
8+
import org.junit.jupiter.api.DisplayName
9+
import org.junit.jupiter.api.Test
10+
import org.mockito.kotlin.any
11+
import org.mockito.kotlin.whenever
12+
import org.springframework.http.MediaType
13+
import org.springframework.security.test.context.support.WithMockUser
14+
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
15+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
16+
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
17+
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
18+
import org.mockito.kotlin.eq
19+
import nmnb.common.response.exception.BadgeException
20+
import nmnb.common.response.status.ErrorStatus
21+
22+
class BadgeControllerTest : ControllerTestSupport() {
23+
24+
@WithMockUser
25+
@DisplayName("뱃지 조회에 성공한다")
26+
@Test
27+
fun getBadges() {
28+
// given
29+
val deviceId = "deviceId"
30+
val accessToken = "access.token"
31+
val user = User.fixture()
32+
33+
mockUserAuthentication(user, accessToken, deviceId)
34+
35+
val badgeInfoListResponse = BadgeInfoListResponse(
36+
badgeInfos = listOf(
37+
BadgeInfoResponse(
38+
id = 1L,
39+
name = "멍1 Badge",
40+
description = "멍멍이1 배지",
41+
imageUrl = "mong.png",
42+
price = 100,
43+
isPurchased = false,
44+
purchasedAt = null,
45+
),
46+
),
47+
)
48+
49+
whenever(badgeService.getBadges(any())).thenReturn(badgeInfoListResponse)
50+
51+
// when & then
52+
mockMvc.perform(
53+
get("/v1/api/badge")
54+
.contentType(MediaType.APPLICATION_JSON)
55+
.header("X-Access-Token", accessToken)
56+
.header("Device-Id", deviceId)
57+
.with(csrf()),
58+
)
59+
.andExpect(status().isOk)
60+
.andExpect(jsonPath("$.code").value(SuccessStatus.OK.code))
61+
.andExpect(jsonPath("$.result.badgeInfos").isArray)
62+
.andExpect(jsonPath("$.result.badgeInfos[0].id").value(1))
63+
.andExpect(jsonPath("$.result.badgeInfos[0].name").value("멍1 Badge"))
64+
}
65+
66+
@WithMockUser
67+
@DisplayName("뱃지가 없는 경우 빈 리스트를 반환한다")
68+
@Test
69+
fun getBadgesWhenEmpty() {
70+
// given
71+
val deviceId = "deviceId"
72+
val accessToken = "access.token"
73+
val user = User.fixture()
74+
75+
mockUserAuthentication(user, accessToken, deviceId)
76+
77+
val emptyBadgeInfoListResponse = BadgeInfoListResponse(badgeInfos = emptyList())
78+
79+
whenever(badgeService.getBadges(any()))
80+
.thenReturn(emptyBadgeInfoListResponse)
81+
82+
// when & then
83+
mockMvc.perform(
84+
get("/v1/api/badge")
85+
.contentType(MediaType.APPLICATION_JSON)
86+
.header("X-Access-Token", accessToken)
87+
.header("Device-Id", deviceId)
88+
.with(csrf()),
89+
)
90+
.andExpect(status().isOk)
91+
.andExpect(jsonPath("$.code").value(SuccessStatus.OK.code))
92+
.andExpect(jsonPath("$.result.badgeInfos").isArray)
93+
.andExpect(jsonPath("$.result.badgeInfos").isEmpty)
94+
}
95+
96+
@WithMockUser
97+
@DisplayName("특정 뱃지 단건 조회에 성공한다")
98+
@Test
99+
fun getBadge() {
100+
// given
101+
val deviceId = "deviceId"
102+
val accessToken = "access.token"
103+
val user = User.fixture()
104+
105+
mockUserAuthentication(user, accessToken, deviceId)
106+
107+
val badgeId = 1L
108+
val response = BadgeInfoResponse(
109+
id = 1L,
110+
name = "멍1 Badge",
111+
description = "멍멍이1 배지",
112+
imageUrl = "mong.png",
113+
price = 100,
114+
isPurchased = false,
115+
purchasedAt = null,
116+
)
117+
118+
whenever(badgeService.getBadge(eq(user.id!!), eq(badgeId))).thenReturn(response)
119+
120+
// when & then
121+
mockMvc.perform(
122+
get("/v1/api/badge/$badgeId")
123+
.contentType(MediaType.APPLICATION_JSON)
124+
.header("X-Access-Token", accessToken)
125+
.header("Device-Id", deviceId)
126+
.with(csrf()),
127+
)
128+
.andExpect(status().isOk)
129+
.andExpect(jsonPath("$.code").value(SuccessStatus.OK.code))
130+
.andExpect(jsonPath("$.result.id").value(badgeId))
131+
}
132+
133+
@WithMockUser
134+
@DisplayName("존재하지 않는 뱃지 조회시 404를 반환한다")
135+
@Test
136+
fun getBadge_notFound() {
137+
// given
138+
val deviceId = "deviceId"
139+
val accessToken = "access.token"
140+
val user = User.fixture()
141+
mockUserAuthentication(user, accessToken, deviceId)
142+
143+
val badgeId = 404L
144+
whenever(badgeService.getBadge(eq(user.id!!), eq(badgeId))).thenThrow(BadgeException(ErrorStatus.BADGE_NOTFOUND))
145+
146+
// when & then
147+
mockMvc.perform(
148+
get("/v1/api/badge/$badgeId")
149+
.contentType(MediaType.APPLICATION_JSON)
150+
.header("X-Access-Token", accessToken)
151+
.header("Device-Id", deviceId)
152+
.with(csrf()),
153+
)
154+
.andExpect(status().isNotFound)
155+
.andExpect(jsonPath("$.code").value(ErrorStatus.BADGE_NOTFOUND.code))
156+
}
157+
}

0 commit comments

Comments
 (0)