Compare commits

..
5 Commits
15 changed files with 547 additions and 18 deletions
@@ -1,11 +1,12 @@
# 선물 관리자 페이지 구현 프롬프트
이 문서는 관리자 페이지의 선물 관련 3개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
공통 전제:
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 3개 하위 메뉴가 있다.
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다.
- `선물함 리스트` → `/gift/list`
- `받을 주소` → `/gift/mailbox`
- `선물 카테고리` → `/gift/category`
- `선물 사이즈` → `/gift/size`
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
@@ -237,7 +238,84 @@ type AdminGiftOperationStatusResponse = {
---
## 2. 선물 카테고리 CRUD 페이지 프롬프트
## 2. 받을 주소 설정 페이지 프롬프트
```text
관리자 선물 받을 주소 설정 페이지를 구현해줘.
목표:
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
- 이 주소는 팬의 선물 상세 API에서 운송장 등록 전 상태일 때 `mailbox`로 노출된다.
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
라우트:
- `/gift/mailbox`
필수 화면 구성:
- 상단 제목: `받을 주소`
- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.`
- 입력 폼:
- 받을 사람 이름 `name`
- 연락처 `phoneNumber`
- 우편번호 `zipCode`
- 주소 `address`
- 상세주소 `addressDetail`
- 저장 버튼: `저장`
초기 조회 API:
- Method: `GET`
- URL: `/api/v2/admin/gift-mailbox`
- response `data`:
```ts
type AdminGiftMailboxResponse = {
name: string;
address: string;
phoneNumber: string;
} | null;
```
- `data`가 null이면 빈 폼을 표시한다.
저장 API:
- Method: `PUT`
- URL: `/api/v2/admin/gift-mailbox`
- Request:
```ts
type AdminGiftMailboxRequest = {
name: string;
phoneNumber: string;
zipCode: string;
address: string;
addressDetail: string | null;
};
```
- Response `data`:
```ts
type AdminGiftMailboxResponse = {
name: string;
address: string;
phoneNumber: string;
};
```
Validation:
- `name`, `phoneNumber`, `zipCode`, `address`는 필수다.
- 공백만 입력할 수 없다.
- 저장 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
저장 성공 UX:
- 성공 토스트를 표시한다.
- 저장 API response 기준으로 화면 값을 갱신한다.
- `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식으로 표시해도 되고, 입력 필드는 사용자가 입력한 값을 유지해도 된다.
테스트/검증:
- 초기 조회 시 null이면 빈 폼을 표시하는지 확인한다.
- 저장 시 PUT body가 정확히 전달되는지 확인한다.
- 필수값 공백 validation을 확인한다.
```
---
## 3. 선물 카테고리 CRUD 페이지 프롬프트
```text
관리자 선물 카테고리 CRUD 페이지를 구현해줘.
@@ -345,7 +423,7 @@ UX 요구사항:
---
## 3. 선물 사이즈 가격 CRUD 페이지 프롬프트
## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트
```text
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
@@ -464,7 +542,8 @@ type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
1. API client 함수와 TypeScript 타입을 먼저 추가한다.
2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다.
3. `/gift/category` 카테고리 CRUD를 구현한다.
4. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
5. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
6. 각 페이지별 loading/empty/error/success 상태를 확인한다.
3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다.
4. `/gift/category` 카테고리 CRUD를 구현한다.
5. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
7. 각 페이지별 loading/empty/error/success 상태를 확인한다.
@@ -15,6 +15,16 @@ CREATE TABLE gift_category (
UNIQUE KEY uk_gift_category_receipt_code (receipt_code)
) COMMENT='선물 카테고리';
CREATE TABLE gift_mailbox (
id BIGINT NOT NULL COMMENT '전역 단일 설정 ID. 항상 1',
name VARCHAR(50) NOT NULL COMMENT '받을 사람 이름',
phone_number VARCHAR(30) NOT NULL COMMENT '받을 연락처',
zip_code VARCHAR(20) NOT NULL COMMENT '받을 우편번호',
address VARCHAR(255) NOT NULL COMMENT '받을 주소',
address_detail VARCHAR(255) NULL COMMENT '받을 상세주소',
PRIMARY KEY (id)
) COMMENT='선물 받을 주소 전역 설정';
CREATE TABLE gift (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
application_no VARCHAR(40) NOT NULL COMMENT '신청번호',
@@ -1309,6 +1309,48 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
- Gate 확인: Phase 9 focused suite, `./gradlew ktlintCheck`, `git diff --check` 모두 성공
- 남은 항목: 없음
## Phase 11: 선물 받을 주소 설정
**Phase 결과:** 관리자는 전역 단일 선물 받을 주소를 등록/수정하고, 팬은 운송장 등록 전 상세에서 해당 주소를 확인할 수 있다.
**선행조건:** `P3-GATE`, `P10-GATE` 완료.
### 구현 항목
#### Task 11.1 받을 주소 관리자 설정 API 구현
**Goal 실행 `P11-T1`:** 전역 단일 받을 주소 조회/저장 API를 구현한다.
- **Files:** Create `GiftMailbox.kt`, `GiftMailboxRepository.kt`; Modify `GiftAdminService.kt`, `AdminGiftController.kt`, `AdminGiftRequest.kt`, `AdminGiftResponse.kt`, `gift-schema.sql`; Test `GiftAdminServiceTest.kt`, `AdminGiftControllerTest.kt`.
- [x] **RED:** 설정 미등록 시 `GET /api/v2/admin/gift-mailbox`가 `data=null`을 반환하는 실패 test를 작성한다.
- [x] **RED:** `PUT /api/v2/admin/gift-mailbox`가 전역 단일 주소를 생성하고, 재호출 시 같은 row를 갱신하는 실패 test를 작성한다.
- [x] **RED:** 이름/연락처/우편번호/주소 공백 입력을 거부하는 실패 test를 작성한다.
- [x] **RED 확인:** focused test 실패를 확인한다.
- [x] **GREEN:** 단일 row persistence, 관리자 service, request/response DTO, controller endpoint를 구현한다.
- [x] **GREEN 확인:** focused test 통과를 확인한다.
#### Task 11.2 선물 상세 받을 주소 노출 구현
**Goal 실행 `P11-T2`:** 보낸 선물의 운송장 등록 전 상세에만 받을 주소를 노출한다.
- **Files:** Modify `GiftQueryService.kt`; Test `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`.
- [x] **RED:** `direction=SENT`이고 `status=RECEIVED`인 상세만 `mailbox`를 반환하는 실패 test를 작성한다.
- [x] **RED:** 받을 주소 미등록 또는 다른 상태/방향이면 `mailbox=null`인 실패 test를 작성한다.
- [x] **RED 확인:** focused test 실패를 확인한다.
- [x] **GREEN:** `GiftQueryService`에서 받을 주소 repository를 조회해 기존 `GiftMailboxResult`로 매핑한다.
- [x] **GREEN 확인:** focused test 통과를 확인한다.
### Phase 11 Gate
**Goal 실행 `P11-GATE`:** 관리자 설정과 사용자 상세 노출을 판정한다.
```bash
./gradlew test --tests "*GiftAdminServiceTest" --tests "*AdminGiftControllerTest" --tests "*GiftQueryServiceTest" --tests "*GiftControllerTest"
./gradlew ktlintCheck
```
**Expected:** 받을 주소 미등록/등록/수정, 상세 `mailbox` 노출 조건, 기존 선물 상세 권한 정책이 통과한다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
@@ -384,8 +384,8 @@ Response:
{
"applicationNo": "A-1002609300001",
"direction": "SENT",
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"status": "RECEIVED",
"statusName": "접수 완료",
"giftInfo": {
"recipientCreatorNickname": "크리에이터닉네임",
"senderNickname": null,
@@ -393,7 +393,7 @@ Response:
"categoryName": "인형",
"applicationNo": "A-1002609300001",
"paidCan": 80,
"tracking": "CJ대한통운_1234567890",
"tracking": null,
"shippingRequestedAt": null
},
"senderInfo": {
@@ -403,11 +403,11 @@ Response:
},
"recipientAddress": null,
"mailbox": {
"name": "소다라이브 사서함",
"address": "구현 후 제공",
"phoneNumber": "구현 후 제공"
"name": "소다라이브 선물 담당자",
"address": "(06234) 서울시 강남구 ..., 3층 선물 접수처",
"phoneNumber": "01012345678"
},
"trackingRequired": false,
"trackingRequired": true,
"recipientAddressRequired": false,
"recipientAddressDeadlineAt": "2026-10-09T03:00:00Z",
"delivery": {
@@ -446,6 +446,8 @@ Response:
}
```
`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 상태가 운송장 등록 전인 `RECEIVED`일 때만 내려준다. 받을 주소가 등록되지 않았거나 다른 상태/방향이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다.
받는 크리에이터 관점 응답 예시는 다음과 같다.
```json
@@ -802,6 +804,40 @@ Response:
크리에이터가 배송지를 아직 입력하지 않은 경우에도 `recipientInfo.nickname`은 내려주고, `recipientInfo.name`, `recipientInfo.phoneNumber`, `recipientInfo.address`는 빈 문자열 `""`로 내려준다.
#### 8.13.3 관리자 선물 받을 주소 설정 API
선물 받을 주소는 전역 단일 설정이다. 팬이 운송장을 등록하기 전에 보낸 선물 상세에서 이 주소를 확인하고 택배를 발송한다.
`GET /api/v2/admin/gift-mailbox`
Response:
```json
{
"name": "소다라이브 선물 담당자",
"address": "(06234) 서울시 강남구 ..., 3층 선물 접수처",
"phoneNumber": "01012345678"
}
```
설정이 없으면 공통 envelope의 `data`는 `null`이다.
`PUT /api/v2/admin/gift-mailbox`
Request:
```json
{
"name": "소다라이브 선물 담당자",
"phoneNumber": "01012345678",
"zipCode": "06234",
"address": "서울시 강남구 ...",
"addressDetail": "3층 선물 접수처"
}
```
Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내려준다. `address`는 기존 주소 응답과 동일하게 `(우편번호) 주소, 상세주소` 형식이다. `name`, `phoneNumber`, `zipCode`, `address`는 필수이며 공백만 입력할 수 없다. `addressDetail`은 선택이다.
`availableActions`는 현재 상태별로 다음 값만 내려준다.
| 현재 상태 | availableActions |
@@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
@@ -45,6 +47,14 @@ class AdminGiftController(
AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo))
)
@GetMapping("/gift-mailbox")
fun getMailbox() = ApiResponse.ok(service.getMailbox()?.let { AdminGiftMailboxResponse.from(it) })
@PutMapping("/gift-mailbox")
fun updateMailbox(@RequestBody request: AdminGiftMailboxRequest) = ApiResponse.ok(
AdminGiftMailboxResponse.from(service.updateMailbox(request.toCommand()))
)
@GetMapping("/gift-categories")
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand
data class AdminGiftCategoryRequest(
@@ -35,6 +36,22 @@ data class AdminGiftSizePriceRequest(
)
}
data class AdminGiftMailboxRequest(
val name: String,
val phoneNumber: String,
val zipCode: String,
val address: String,
val addressDetail: String?
) {
fun toCommand() = GiftMailboxCommand(
name = name,
phoneNumber = phoneNumber,
zipCode = zipCode,
address = address,
addressDetail = addressDetail
)
}
data class AdminGiftMarkUndeliverableRequest(
val reason: String
)
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
@@ -70,6 +71,20 @@ data class AdminGiftOperationStatusResponse(
}
}
data class AdminGiftMailboxResponse(
val name: String,
val address: String,
val phoneNumber: String
) {
companion object {
fun from(result: GiftMailboxResult) = AdminGiftMailboxResponse(
name = result.name,
address = result.address,
phoneNumber = result.phoneNumber
)
}
}
data class AdminGiftListResponse(
val totalCount: Long,
val items: List<AdminGiftListItemResponse>,
@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Table
@Entity
@Table(name = "gift_mailbox")
class GiftMailbox(
@Id
@Column(name = "id", nullable = false)
val id: Long = SINGLETON_ID,
@Column(name = "name", nullable = false, length = 50)
var name: String,
@Column(name = "phone_number", nullable = false, length = 30)
var phoneNumber: String,
@Column(name = "zip_code", nullable = false, length = 20)
var zipCode: String,
@Column(name = "address", nullable = false, length = 255)
var address: String,
@Column(name = "address_detail", length = 255)
var addressDetail: String?
) {
companion object {
const val SINGLETON_ID = 1L
}
}
@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface GiftMailboxRepository : JpaRepository<GiftMailbox, Long>
@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
@@ -41,6 +43,14 @@ data class GiftSizePriceCommand(
val isActive: Boolean
)
data class GiftMailboxCommand(
val name: String,
val phoneNumber: String,
val zipCode: String,
val address: String,
val addressDetail: String?
)
data class GiftSizePriceResult(
val sizeCode: GiftSize,
val sizeName: String,
@@ -62,8 +72,31 @@ class GiftAdminService(
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftMailboxRepository: GiftMailboxRepository,
private val giftPushService: GiftPushService
) {
@Transactional(readOnly = true)
fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult()
@Transactional
fun updateMailbox(command: GiftMailboxCommand): GiftMailboxResult {
validateMailbox(command)
val mailbox = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)
?: GiftMailbox(
name = command.name,
phoneNumber = command.phoneNumber,
zipCode = command.zipCode,
address = command.address,
addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
)
mailbox.name = command.name
mailbox.phoneNumber = command.phoneNumber
mailbox.zipCode = command.zipCode
mailbox.address = command.address
mailbox.addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
return giftMailboxRepository.save(mailbox).toResult()
}
@Transactional(readOnly = true)
fun getCategories(): List<GiftCategoryResult> = categoryRepository.findAll().map { it.toResult() }
@@ -198,6 +231,22 @@ class GiftAdminService(
if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest()
}
private fun validateMailbox(command: GiftMailboxCommand) {
if (
command.name.isBlank() ||
command.name.length > 50 ||
command.phoneNumber.isBlank() ||
command.phoneNumber.length > 30 ||
command.zipCode.isBlank() ||
command.zipCode.length > 20 ||
command.address.isBlank() ||
command.address.length > 255 ||
(command.addressDetail?.length ?: 0) > 255
) {
throw invalidRequest()
}
}
private fun GiftCategory.toResult() = GiftCategoryResult(
categoryId = id ?: throw invalidRequest(),
classificationNumber = classificationNumber,
@@ -217,6 +266,15 @@ class GiftAdminService(
isActive = isActive
)
private fun GiftMailbox.toResult() = GiftMailboxResult(
name = name,
address = formatAddress(zipCode, address, addressDetail),
phoneNumber = phoneNumber
)
private fun formatAddress(zipCode: String, address: String, detail: String?) =
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private companion object {
@@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection
@@ -105,6 +107,7 @@ class GiftQueryService(
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@@ -186,7 +189,7 @@ class GiftQueryService(
),
senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT },
recipientAddress = recipientAddress,
mailbox = null,
mailbox = mailboxFor(direction, gift.status),
trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED,
recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null &&
gift.status !in TERMINAL_STATUSES,
@@ -247,6 +250,17 @@ class GiftQueryService(
return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail))
}
private fun mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? {
if (direction != GiftDirection.SENT || status != GiftStatus.RECEIVED) return null
return giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.let {
GiftMailboxResult(
name = it.name,
address = formatAddress(it.zipCode, it.address, it.addressDetail),
phoneNumber = it.phoneNumber
)
}
}
private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) {
"${courierCompanyName}_$trackingNumber"
} else {
@@ -59,6 +59,54 @@ class AdminGiftControllerTest @Autowired constructor(
categoryRepository.deleteAll()
}
@Test
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
fun shouldManageGiftMailbox() {
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").isEmpty)
mockMvc.perform(
put("/api/v2/admin/gift-mailbox")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"name":"소다라이브 선물 담당자",
"phoneNumber":"01012345678",
"zipCode":"06234",
"address":"서울시 강남구",
"addressDetail":"3층 선물 접수처"
}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
.andExpect(jsonPath("$.data.phoneNumber").value("01012345678"))
.andExpect(jsonPath("$.data.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
}
@Test
@DisplayName("관리자는 공백 받을 주소를 저장할 수 없다")
fun shouldRejectInvalidGiftMailbox() {
mockMvc.perform(
put("/api/v2/admin/gift-mailbox")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"name":" ","phoneNumber":"01012345678","zipCode":"06234","address":"서울시 강남구"}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다")
fun shouldListAdminGifts() {
@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
@@ -60,6 +62,7 @@ class GiftControllerTest @Autowired constructor(
private val giftDeliveryRepository: GiftDeliveryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository,
private val objectMapper: ObjectMapper
) {
@MockBean
@@ -190,6 +193,48 @@ class GiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z"))
}
@Test
@DisplayName("발신자는 운송장 등록 전 선물 상세에서 받을 주소를 조회한다")
fun shouldGetMailboxForSentReceivedGiftDetail() {
val sender = memberRepository.save(Member(password = "password", nickname = "fan"))
val recipient = memberRepository.save(Member(password = "password", nickname = "creator"))
val gift = saveGift(
applicationNo = "G-CONTROLLER-MAILBOX",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
giftDeliveryRepository.save(
GiftDelivery(
giftId = gift.id!!,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
)
)
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
mockMvc.perform(
get("/api/v2/gifts/{applicationNo}", gift.applicationNo)
.with(user(MemberAdapter(sender)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.mailbox.name").value("소다라이브 선물 담당자"))
.andExpect(jsonPath("$.data.mailbox.phoneNumber").value("01012345678"))
.andExpect(jsonPath("$.data.mailbox.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
}
@Test
@DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다")
fun shouldRegisterGift() {
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
@@ -33,7 +34,8 @@ class GiftAdminServiceTest @Autowired constructor(
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository
private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftMailboxRepository: GiftMailboxRepository
) {
private val giftPushService = Mockito.mock(GiftPushService::class.java)
private val service = GiftAdminService(
@@ -41,9 +43,61 @@ class GiftAdminServiceTest @Autowired constructor(
sizePriceRepository,
giftRepository,
giftDeliveryRepository,
giftMailboxRepository,
giftPushService
)
@Test
@DisplayName("받을 주소는 미등록이면 null이고 저장 후 단일 설정으로 갱신된다")
fun shouldUpsertGiftMailbox() {
assertEquals(null, service.getMailbox())
val created = service.updateMailbox(
GiftMailboxCommand(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val updated = service.updateMailbox(
GiftMailboxCommand(
name = "소다라이브 운영팀",
phoneNumber = "01087654321",
zipCode = "04524",
address = "서울시 중구",
addressDetail = null
)
)
assertEquals("소다라이브 선물 담당자", created.name)
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", created.address)
assertEquals("01012345678", created.phoneNumber)
assertEquals("소다라이브 운영팀", updated.name)
assertEquals("(04524) 서울시 중구", updated.address)
assertEquals(1, giftMailboxRepository.count())
}
@Test
@DisplayName("받을 주소 필수값이 공백이면 저장할 수 없다")
fun shouldRejectInvalidGiftMailbox() {
val exception = assertThrows(SodaException::class.java) {
service.updateMailbox(
GiftMailboxCommand(
name = " ",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = null
)
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(0, giftMailboxRepository.count())
}
@Test
@DisplayName("카테고리를 등록, 수정, 논리삭제한다")
fun shouldCreateUpdateAndDeactivateCategory() {
@@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
@@ -39,7 +41,8 @@ class GiftQueryServiceTest @Autowired constructor(
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository
private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository
) {
@Test
fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() {
@@ -230,6 +233,65 @@ class GiftQueryServiceTest @Autowired constructor(
assertNull(result.statusTimeline[1].occurredAt)
}
@Test
@DisplayName("보낸 선물의 운송장 등록 전 상세만 받을 주소를 반환한다")
fun shouldReturnMailboxOnlyForSentReceivedGiftDetail() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val receivedGift = saveGift(
applicationNo = "G-MAILBOX-RECEIVED",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
saveDelivery(receivedGift)
val trackingGift = saveGift(
applicationNo = "G-MAILBOX-TRACKING",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(trackingGift).trackingRegisteredAt = NOW
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val service = queryService()
val receivedResult = service.getGiftDetail(sender.id!!, receivedGift.applicationNo)
val trackingSenderResult = service.getGiftDetail(sender.id!!, trackingGift.applicationNo)
val trackingRecipientResult = service.getGiftDetail(recipient.id!!, trackingGift.applicationNo)
assertEquals("소다라이브 선물 담당자", receivedResult.mailbox?.name)
assertEquals("01012345678", receivedResult.mailbox?.phoneNumber)
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", receivedResult.mailbox?.address)
assertNull(trackingSenderResult.mailbox)
assertNull(trackingRecipientResult.mailbox)
}
@Test
@DisplayName("받을 주소가 없으면 운송장 등록 전 상세도 mailbox가 null이다")
fun shouldReturnNullMailboxWhenMailboxNotConfigured() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val gift = saveGift(
applicationNo = "G-MAILBOX-EMPTY",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
saveDelivery(gift)
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
assertNull(result.mailbox)
}
@Test
@DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다")
fun shouldReturnReceivedGiftDetailWithoutSenderInformation() {
@@ -450,6 +512,7 @@ class GiftQueryServiceTest @Autowired constructor(
giftRepository,
deliveryRepository,
memberRepository,
giftMailboxRepository,
"https://cdn.test"
)