Merge pull request 'Test' (#459) from test into main
Reviewed-on: #459
This commit was merged in pull request #459.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# 기존 관리자 선물함에 신청 취소 기능 추가 프롬프트
|
||||
|
||||
## 목적과 사용 범위
|
||||
|
||||
- 이 문서는 이미 존재하는 관리자 선물함에 기능 하나를 추가하기 위한 전달용 프롬프트다.
|
||||
- 아래 확정된 API Request/Response 계약을 기준으로 타입, API client, 화면 액션과 테스트를 구현한다.
|
||||
- [기존 전체 페이지 프롬프트](admin-page-prompts.md)는 수정하거나 다시 실행하지 않는다.
|
||||
- 계약 기준: [PRD 8.14.1 관리자 선물 신청 취소](prd.md#8141-관리자-선물-신청-취소).
|
||||
|
||||
## 복사해서 전달할 증분 구현 프롬프트
|
||||
|
||||
아래 블록 전체를 기존 프론트엔드 저장소의 구현 담당자에게 전달한다.
|
||||
|
||||
````markdown
|
||||
# 기존 관리자 선물함에 신청 취소 액션만 추가
|
||||
|
||||
## 1. 기존 코드 확인
|
||||
|
||||
이미 구현된 관리자 선물함 목록/상세에 `신청 취소` 기능만 증분으로 추가해줘.
|
||||
페이지를 새로 만들거나 기존 전체 페이지 구현 프롬프트를 재실행하지 마.
|
||||
|
||||
- 먼저 기존 프론트엔드 코드를 읽고 목록, 상세, 액션 타입, 액션 dispatch 위치를 확인한다.
|
||||
- API 인증/권한, 공통 API client, 응답 envelope 해제 방식, 확인창, 토스트, 오류 처리를 확인한다.
|
||||
- 기존 상태 변경 액션의 pending 처리와 목록/상세 재조회 방식을 그대로 사용한다.
|
||||
- 변경할 실제 파일과 재사용할 기존 패턴을 먼저 짧게 정리한다.
|
||||
- 아래 API 계약을 기준으로 구현한다. 기존 타입이나 호출부가 계약과 다르면 차이를 보고하고 이 기능의 변경 범위 안에서 맞춘다.
|
||||
|
||||
## 2. 유지할 기존 화면과 액션
|
||||
|
||||
- 기존 `/gift/list` 라우트, 메뉴, 레이아웃, 테이블 컬럼, 필터, 페이지네이션을 보존한다.
|
||||
- 기존 상세 모달/패널과 액션 표시·실행 프레임워크를 그대로 확장한다.
|
||||
- `ARRIVE_MAILBOX`, `COMPLETE_INSPECTION`, `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`을 보존한다.
|
||||
- 기존 전달 불가 사유 입력/검증 및 각 액션의 확인창·성공·실패 동작을 변경하지 않는다.
|
||||
- `COMPLETE_DELIVERY`의 수취인 필수 정보 조건부 허용을 유지한다.
|
||||
- 모든 기존 액션도 서버 `availableActions`를 따르며 로컬 상태 매핑으로 대체하지 않는다.
|
||||
- 받을 주소, 카테고리, 사이즈 설정 화면은 수정하지 않는다.
|
||||
|
||||
## 3. 최소 타입/dispatch 확장과 버튼 노출
|
||||
|
||||
- 기존 `AdminGiftAction` 타입에 `CANCEL`을 추가하되 기존 값을 삭제하거나 재정의하지 않는다.
|
||||
- 기존 액션 라벨/핸들러 dispatch에 `CANCEL`과 `신청 취소`만 추가한다.
|
||||
- 목록 row와 열린 상세 모두 기존 액션 렌더링/실행 경로에 연결한다.
|
||||
- 각 화면의 서버 응답 `availableActions.includes("CANCEL")`일 때만 취소 버튼을 표시한다.
|
||||
- API 계약상 `RECEIVED`에서만 `CANCEL`을 제공한다. 다른 상태에서는 제공하지 않는다.
|
||||
- `status === "RECEIVED"`만으로 취소 권한을 만들거나 버튼을 강제로 추가하지 않는다.
|
||||
- 목록/상세 중 한쪽의 로컬 값을 다른 쪽의 권한 판단에 대신 사용하지 않는다.
|
||||
- 권한의 최종 판단은 서버가 하며, 관리자가 발신자 본인일 필요는 없다.
|
||||
|
||||
## 4. 확정 API 계약
|
||||
|
||||
액션 조회:
|
||||
- 목록: `GET /api/v2/admin/gifts`, `data.items[].availableActions`를 사용한다.
|
||||
- 상세: `GET /api/v2/admin/gifts/{applicationNo}`, `data.availableActions`를 사용한다.
|
||||
- 접수 완료 응답: `status="RECEIVED"`, `statusName="접수 완료"`, `availableActions=["CANCEL"]`.
|
||||
- 취소 후 응답: `status="CANCELED"`, `statusName="신청 취소"`, `availableActions=[]`.
|
||||
|
||||
취소 요청:
|
||||
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/cancel`
|
||||
- 권한: `ADMIN` 전용. 기존 관리자 인증/권한 경로를 재사용한다.
|
||||
- Request body: 없음. `{}`, 취소 사유, 환불액 등을 보내지 않는다.
|
||||
- 서버 전이: `RECEIVED`에서만 `CANCELED`로 변경한다.
|
||||
- 환불: 원 발신자에게 사용 캔 전액을 리워드캔으로 환불한다.
|
||||
- 관리자의 잔액으로 환불하는 기능이 아니다. 프론트에서 환불/잔액 변경을 직접 처리하지 않는다.
|
||||
- 성공 응답: 공통 envelope의 `success=true`와 아래 `data`이며 기존 client의 envelope 처리 방식에 맞춘다. HTTP 성공 코드만으로 업무 성공을 판단하지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"applicationNo": "A-1002609300001",
|
||||
"status": "CANCELED",
|
||||
"statusName": "신청 취소",
|
||||
"occurredAt": "2026-09-30T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`occurredAt`은 서버의 `canceledAt`과 같은 UTC ISO `Z` 문자열이다.
|
||||
기존 `AdminGiftOperationStatusResponse`를 재사용하고 로컬 현재 시각으로 값을 만들지 않는다.
|
||||
|
||||
## 5. 확인과 요청 중 처리
|
||||
|
||||
- 기존 확인창 컴포넌트를 사용하며 대상 `applicationNo`를 명확히 표시한다.
|
||||
- 확인 문구 예: `신청번호 {applicationNo}의 선물 신청을 취소하시겠습니까?`
|
||||
- 환불 안내: `사용한 캔 전액이 발신자에게 리워드캔으로 환불됩니다.`
|
||||
- 확인창을 닫거나 취소하면 API를 호출하지 않는다. 명시적으로 확정한 경우에만 호출한다.
|
||||
- 확정한 신청번호로 body 없는 요청을 한 번만 보낸다.
|
||||
- pending 동안 확인 버튼과 같은 신청의 목록/상세 취소 버튼을 비활성화해 중복 제출을 막는다.
|
||||
- 기존 client의 mutation 재시도 정책도 확인하고 취소 요청을 무조건 자동 재시도하지 않는다.
|
||||
- 키보드 포커스/확인창 접근성과 pending 표시는 기존 관리자 패턴을 유지한다.
|
||||
|
||||
## 6. 성공과 실패
|
||||
|
||||
- 서버 성공 확인 후 기존 성공 토스트를 표시한다. 응답 전에 성공으로 처리하지 않는다.
|
||||
- 현재 필터와 현재 페이지/페이지 크기를 유지한 채 목록을 다시 조회한다.
|
||||
- 상세가 열려 있으면 해당 상세도 다시 조회해 서버의 현재 상태와 액션을 반영한다.
|
||||
- 취소된 항목의 서버 상태는 `CANCELED`, `statusName`은 `신청 취소`, `availableActions`는 `[]`다.
|
||||
- 현재 필터에 맞지 않아 목록에서 사라져도 필터를 초기화하거나 첫 페이지로 임의 이동하지 않는다.
|
||||
- 실패는 기존 공통 오류 토스트/알림을 사용하며 별도 오류 스키마나 HTTP 정책을 만들지 않는다.
|
||||
- 서버가 상태 변경/잘못된 요청을 알리거나 통신 실패로 처리 결과가 불명확하면
|
||||
기존 패턴에 따라 목록과 열린 상세의 서버 현재 상태를 재조회한다. mutation은 재시도하지 않는다.
|
||||
- 실패 시 로컬 상태를 가짜 `CANCELED`로 바꾸거나 성공 토스트/환불 완료를 표시하지 않는다.
|
||||
|
||||
## 7. 구현 후 집중 검증
|
||||
|
||||
확정 계약의 성공·실패 응답 fixture와 기존 API client mock을 테스트 범위에서 사용해 화면 및 요청 동작을 검증한다. 실행 코드에 자동 fallback이나 가짜 성공 처리를 추가하지 않는다. 실제 API 연동 검증 결과는 단위/컴포넌트 테스트 결과와 구분해 보고한다.
|
||||
|
||||
- 목록과 상세 각각 `RECEIVED` + 서버 `CANCEL`이면 버튼이 나오고 기존 dispatch로 실행되는지 확인한다.
|
||||
- `RECEIVED`여도 `CANCEL`이 없으면 버튼이 없고, 다른 모든 상태에는 `CANCEL`이 없는지 확인한다.
|
||||
- 확인창에 신청번호/발신자 전액 리워드캔 환불이 표시되고 확인을 닫으면 API 호출이 0회인지 확인한다.
|
||||
- 확정 시 정확한 URL로 body 없이 1회만 요청하며 연속 클릭과 목록/상세 중복 실행이 막히는지 확인한다.
|
||||
- 성공 후 필터/페이지가 유지되고 목록과 열린 상세가 갱신되며 취소 건의 액션이 없어지는지 확인한다.
|
||||
- 실패 시 공통 오류를 표시하고 가짜 취소/성공이 없으며 필요한 현재 상태 재조회가 수행되는지 확인한다.
|
||||
- 기존 운영 액션과 `COMPLETE_DELIVERY`의 수취인 정보 조건부 노출이 그대로인지 회귀 확인한다.
|
||||
- 기존 프론트엔드 테스트 도구로 위 변경 범위만 검증하고 실행 결과/미검증 항목을 보고한다.
|
||||
|
||||
## 8. 범위 밖
|
||||
|
||||
취소 사유 필드, 취소 전용 푸시 흐름, 새 페이지/라우트, 새 디자인 시스템은 추가하지 않는다.
|
||||
백엔드 구현, 사용자 직접 취소/자동취소 변경, 기존 페이지 전면 재작성도 이 작업에 포함하지 않는다.
|
||||
````
|
||||
@@ -44,6 +44,7 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한
|
||||
| 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` |
|
||||
| 관리자 | GET | `/api/v2/admin/gifts` | 전체 선물함 목록을 조회하고 상태/신청번호/닉네임으로 검색한다. | query: `status`, `applicationNo`, `nickname`, `page`, `size` | `totalCount`, `items[]`, `page`, `size`, `hasNext` |
|
||||
| 관리자 | GET | `/api/v2/admin/gifts/{applicationNo}` | 선물 상세와 발송인/수취인 개인정보, 상품/배송/상태 정보를 조회한다. | path: `applicationNo` | `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions` |
|
||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/cancel` | 운송장 등록 전 `RECEIVED` 선물을 취소하고 발신자에게 전액 리워드캔을 환불한다. | path: `applicationNo`, body 없음 | `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` (`canceledAt` UTC ISO `Z`) |
|
||||
| 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
|
||||
| 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
|
||||
| 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive=false` |
|
||||
@@ -63,6 +64,12 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한
|
||||
- `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive`
|
||||
- `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt`
|
||||
|
||||
관리자 취소는 기존 `hasRole('ADMIN')` 권한을 따르며 관리자가 발신자가 아니어도 `RECEIVED`에서 취소할 수 있다. 같은 트랜잭션에서 `GiftDelivery.canceledAt`을 기록하고 기존 `refundGiftCan`으로 발신자에게 원 결제액 전액을 리워드캔으로 환불한다. 존재하지 않는 신청번호나 다른 상태는 기존 공통 invalid request이며 기존 `ApiResponse` envelope를 사용한다. 기존 사용자 취소 응답은 별도 계약이며 바뀌지 않는다. HTTP 성공 코드만으로 업무 성공을 판단하지 않고 envelope의 `success`를 확인한다.
|
||||
|
||||
관리자 선물함의 action: `RECEIVED`는 목록과 상세 모두 `availableActions=["CANCEL"]`; 그 외 상태는 기존 매핑을 유지한다. 특히 `INSPECTION_COMPLETED`의 `COMPLETE_DELIVERY`는 수취인 정보 완비 조건부 노출이다.
|
||||
|
||||
기존 관리자 화면에 취소 기능만 추가하는 작업은 [관리자 취소 기능 추가 프롬프트](admin-gift-cancel-addition-prompt.md)를 사용한다. 전체 화면 구현용 `admin-page-prompts.md`는 변경하지 않는다.
|
||||
|
||||
## Kotlin DTO 형태
|
||||
|
||||
아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. 응답 시각 필드는 UTC ISO 문자열이다. 예: `2026-09-30T12:00:00Z`.
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 완료 |
|
||||
| 상태 | Phase 14 완료, 전체 테스트 사용자 요청으로 제외 |
|
||||
| 작성일 | 2026-09-29 |
|
||||
| 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` |
|
||||
| API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` |
|
||||
| 현재 Phase | Phase 12 구현 완료 |
|
||||
| 현재 활성 Goal | 완료 |
|
||||
| 현재 Phase | Phase 14 관리자 선물 취소 |
|
||||
| 현재 활성 Goal | 없음, `P14-T4` 시각 정밀도 회귀 수정 완료 |
|
||||
|
||||
## 목표
|
||||
|
||||
팬이 캔으로 크리에이터에게 실물 선물을 신청하고, 운송장 등록부터 크리에이터 배송지 입력, 운영 검수, 전달 완료/불가, 리뷰와 푸시까지 서버에서 추적할 수 있게 한다.
|
||||
팬이 캔으로 크리에이터에게 실물 선물을 신청하고, 운송장 등록부터 크리에이터 배송지 입력, 운영 검수, 전달 완료/불가, 리뷰와 푸시까지 서버에서 추적한다. Phase 14는 `RECEIVED` 선물의 ADMIN 취소·환불과 목록/상세 액션을 제공한다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
| 8 | 완료 | `1/1` | 완료 | 없음 |
|
||||
| 9 | 완료 | `3/3` | 완료 | 없음 |
|
||||
| 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 |
|
||||
| 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다.
|
||||
@@ -52,6 +53,7 @@
|
||||
|
||||
- 선물 신청, 목록, 상세, 운송장 등록, 취소, 크리에이터 배송지 입력, 크리에이터 수령확인, 리뷰 작성 사용자 API
|
||||
- 관리자 카테고리 API, 관리자 사이즈 가격 API, 운영 상태변경 API
|
||||
- Phase 14 관리자 신청 취소·환불과 목록/상세 `CANCEL` 액션, 별도 관리자 기능 추가 프롬프트.
|
||||
- 운송장 미등록 자동취소, 배송지 미입력 전달불가, 기한 24시간 전 안내 스케줄러
|
||||
- 캔 사용내역 `선물하기` 추가와 선물 신청 내부 연결
|
||||
- 팬/크리에이터 대상 푸시 이벤트와 단일 딥링크 `GIFT_DETAIL` + `applicationNo`
|
||||
@@ -1430,6 +1432,199 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
|
||||
- [x] **GREEN:** 기존 `GiftDelivery` 정보를 재사용해 `recipientAddressRegistered`, `recipientAddressDeadlineAt`, `canceledAt`을 매핑했다.
|
||||
- [x] **검증:** focused 테스트와 `ktlintCheck`, `git diff --check`를 실행했다.
|
||||
|
||||
### Phase 14 문서 초안 — 2026-10-22
|
||||
|
||||
- 상태: 문서 초안 작성 완료, 최종 검증 대기
|
||||
- 무엇을: PRD, 구현 계획, 클라이언트 요약에 ADMIN 선물 취소 계약을 추가하고 기존 화면에 취소 기능만 추가하는 프롬프트를 별도 문서로 분리한다. 전체 화면 구현용 `admin-page-prompts.md`는 보존한다.
|
||||
- 왜: 승인된 `RECEIVED` 전용 취소 범위를 구현과 분리해 먼저 문서화하기 위해서다.
|
||||
- 어떻게:
|
||||
- 문서 변경만 수행했다. 코드/테스트/설정/DDL은 수정하지 않았다.
|
||||
- 초안 작성 시점에는 문서 검증을 완료하지 않았다. 최종 검증 결과는 후속 기록에 누적한다.
|
||||
- 남은 항목: 최종 문서 검증, 사용자 구현 승인, 이후 구현 및 검증
|
||||
- 다음 행동: 문서 검증 후 P14-T1 체크를 갱신하고, 구현 승인은 별도로 받는다.
|
||||
|
||||
## Phase 14: 관리자 선물 취소
|
||||
|
||||
**Phase 결과:** 관리자가 `RECEIVED` 선물만 취소하고 발신자에게 전액 리워드캔을 환불하며, 목록/상세에서 `CANCEL`을 선택할 수 있다. 서버 구현과 관련 검증을 완료했다.
|
||||
|
||||
**선행조건:** Phase 13 완료, PRD `DEC-015` 및 `GIFT-016`~`GIFT-017` 확정.
|
||||
|
||||
**2026-10-22 실행 승인:** 사용자의 "문서대로 진행" 요청으로 `P14-T2`부터 재개한다. 현재 서버 저장소에서 취소 API·환불·액션 응답을 구현하며 별도 프론트엔드 화면 구현은 포함하지 않는다.
|
||||
|
||||
**Phase 완료 조건:** `P14-T1`~`P14-T3` 및 `P14-GATE` 완료. 전체 테스트는 후속 사용자 요청으로 제외하고 관련 회귀·실제 HTTP·리뷰·테스트 제외 빌드로 검증했다.
|
||||
|
||||
### 문서 작업
|
||||
|
||||
#### Task 14.1 관리자 취소 계약 문서화
|
||||
|
||||
**Goal 실행 `P14-T1`:** 승인된 관리자 취소 범위와 계획 상태를 PRD, 계획, 클라이언트 요약, 별도 기능 추가 프롬프트에 일치시킨다.
|
||||
|
||||
- **시작 조건:** 관리자 취소가 `RECEIVED` 전용이며 목록/상세 액션으로 제공된다는 사용자 승인.
|
||||
- **완료 증거:** 네 문서의 endpoint, 액션, 상태/환불/응답 계약 일치, 기존 `admin-page-prompts.md` 무변경 및 문서 검증 기록.
|
||||
- **범위 밖:** 서버/프론트엔드 구현, 테스트 실행, mock/fallback, 새 오류/푸시/스키마.
|
||||
- **TDD 예외 사유:** 문서만 수정하며 production 동작을 변경하지 않는다.
|
||||
- **대체 검증 방법:** 네 문서 간 계약 대조, `./gradlew tasks --all`, `git diff --check`, 변경 파일 범위 확인을 수행하고 결과를 누적한다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260929_크리에이터_선물하기/prd.md`, `docs/20260929_크리에이터_선물하기/plan-task.md`, `docs/20260929_크리에이터_선물하기/client-api-summary.md`.
|
||||
- Create: `docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md` — 새 화면을 만들지 않고 기존 화면에 취소 기능만 추가하는 프롬프트.
|
||||
|
||||
- [x] 네 문서의 계약과 기존 전체 화면 프롬프트 무변경을 대조하고 검증 명령의 실제 결과를 기록한다.
|
||||
|
||||
### P14-T1 최종 문서 검증 — 2026-10-22
|
||||
|
||||
- 상태: 문서 반영 및 검증 완료. `P14-T2`, `P14-T3`, `P14-GATE`는 미착수다.
|
||||
- 무엇을: PRD/계획/API 요약에 관리자 취소와 목록·상세 `CANCEL` 계약을 반영하고, 기존 화면의 기능 추가만 지시하는 `admin-gift-cancel-addition-prompt.md`를 신규 작성했다.
|
||||
- 왜: 운송장 등록 전 취소 정책을 명시하고, 기존 전체 페이지 구현 프롬프트를 변경하지 말라는 사용자 요청을 반영하기 위해서다.
|
||||
- 어떻게: 네 문서의 `RECEIVED` 전용 취소, 발신자 리워드캔 전액 환불, body 없는 POST, 기존 관리자 응답 네 필드와 UTC 시각, 목록/상세 액션 및 미구현 표시를 대조했다. 신규 프롬프트 전체와 문서 링크도 확인했다.
|
||||
- 검증: `./gradlew tasks --all` exit 0, `git diff --check` exit 0, 신규 프롬프트의 `git diff --no-index --check /dev/null docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md` exit 0, 기존 `admin-page-prompts.md`의 `git diff --exit-code HEAD -- docs/20260929_크리에이터_선물하기/admin-page-prompts.md` exit 0.
|
||||
- 변경 범위: 기존 문서 3개 수정과 기능 추가 프롬프트 1개 생성만 확인했다. 소스/테스트/설정/DDL과 기존 완료 Task·검증 이력은 변경하지 않았다.
|
||||
- 미실행: 문서만 변경했으므로 focused/전체 테스트, lint, 애플리케이션 빌드와 브라우저 QA는 실행하지 않았다. 명령 확인은 기능 동작 검증을 의미하지 않는다.
|
||||
- 다음 행동: 사용자 구현 승인 후 `P14-T2`의 첫 미완료 RED 단계부터 시작한다.
|
||||
|
||||
### 구현 작업 (사용자 승인 후 완료)
|
||||
|
||||
#### Task 14.2 관리자 취소 상태 전이와 환불 구현
|
||||
|
||||
**Goal 실행 `P14-T2`:** 기존 관리자 권한으로 `RECEIVED` 선물만 취소하고 발신자에게 전액 리워드캔 환불을 원자적으로 처리한다.
|
||||
|
||||
- **시작 조건:** `P14-T1` 검증과 사용자 구현 승인.
|
||||
- **완료 증거:** service/controller focused test에서 상태, 환불, 권한, 공통 오류와 응답 계약 통과.
|
||||
- **범위 밖:** 새로운 환불 체계/스키마/의존성, 취소 사유/감사 테이블, 취소 전용 푸시, 사용자 취소/자동취소 정책 변경, 전면 리팩터링.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt` — `fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult` 계약을 기존 관리자 operation method에 맞춰 추가한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt` — body 없는 `POST /api/v2/admin/gifts/{applicationNo}/cancel`, 기존 `AdminGiftOperationStatusResponse`와 `ApiResponse.ok(...)` 사용.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`.
|
||||
- 조건부 확인/수정: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt` — 경합 테스트가 입증한 상태 전이/환불 중복 원인에 한해 공통 경계를 최소 수정한다. 기존 사용자/자동취소 정책은 변경하지 않는다.
|
||||
|
||||
- [x] **RED:** service 테스트에서 `RECEIVED` 관리자 취소의 `CANCELED`, `GiftDelivery.canceledAt`, 원 결제액 전액 `refundGiftCan` 1회 호출을 검증한다. 발신자가 아닌 관리자도 가능하고, 존재하지 않거나 다른 상태면 기존 `common.error.invalid_request`이며 상태/환불은 변경되지 않는지 검증한다. 환불 실패 rollback 및 중복 환불 방지도 검증한다.
|
||||
- [x] **RED:** `AdminGiftControllerTest`에 실제 Spring 트랜잭션을 통과하는 관리자 취소 통합 테스트를 추가한다. ADMIN 성공과 비관리자/미인증 거부, 모든 비허용 상태 거부, 응답 네 필드와 UTC 시각, 발신자의 실제 리워드캔/환불 내역 복원 및 관리자 잔액 불변을 검증한다. 사용자 취소·자동취소·운송장 등록·다른 관리자 취소와의 동시 요청, 환불 예외 시 DB 상태/취소 시각/환불 내역 롤백도 검증한다. mock 호출 횟수만으로 실제 환불/롤백/경합 검증을 대체하지 않는다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 새 assertion 또는 취소 메서드 미구현으로 실패해야 하며 설정/fixture 오류는 수정한 뒤 다시 확인한다.
|
||||
- [x] **GREEN:** 기존 transaction, 예외, 캔 환불 경로와 `GiftOperationStatusResult`를 재사용해 최소 구현한다. 신규 push는 발행하지 않는다.
|
||||
- [x] **GREEN 확인:** 같은 service focused test와 `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 정상 envelope, 응답 필드 및 UTC `occurredAt=canceledAt`, 기존 ADMIN 권한 거부를 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경으로 생긴 중복만 정리하고 아래 focused test와 `./gradlew ktlintCheck`를 실행해 실제 결과를 기록한다.
|
||||
|
||||
#### P14-T2 구현·검증 기록 — 2026-10-22
|
||||
|
||||
- 무엇을: ADMIN 취소 endpoint와 `GiftAdminService.cancelGift`를 추가하고 사용자 취소·운송장 등록·자동취소의 최초 선물 조회에 DB 쓰기 잠금을 적용했다. 기한 안내가 취소 시각을 덮어쓰는 경합도 RED로 확인해 동일 잠금을 적용했다. 기존 분산 job lock과 상태/환불 정책은 유지했다.
|
||||
- RED: 기존 focused 92개 통과 후 신규 취소 HTTP 404 실패, service 메서드 미구현 실패, 잠금 없는 핵심 경합 7개 실패, 안내 경합 2개 실패를 각각 확인했다.
|
||||
- GREEN: `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' ktlintCheck` exit 0, 119개 테스트(25/30/50/14) 실패·오류·skip 0. 담당자 외 독립 재실행도 `test --rerun`으로 exit 0(48초)을 확인했다.
|
||||
- 실제 DB 확인: 80캔 사용(충전 60 + 리워드 20)이 발신자 리워드 80으로 환불되고 관리자 잔액은 불변이다. 실제 환불/flush 후 예외 발생 시 상태·취소 시각·잔액·환불 내역이 모두 롤백된다. 9개 동시 요청 순서에서 최종 상태와 환불 1회 불변조건을 확인했다.
|
||||
- 리뷰: 독립 T2 스펙/코드 리뷰 APPROVE, 차단 사항 없음. 커밋 `44bec81a`.
|
||||
- 후속 사용자 요청: `44bec81a` 커밋은 취소하고 변경 파일을 작업 트리에 보존했다. 이후 커밋은 사용자가 확인 후 직접 수행하며 에이전트는 커밋하지 않는다.
|
||||
- 검증 경계: H2/Spring 트랜잭션과 MockMvc 검증이다. 실제 socket HTTP는 후속 T3/Gate에서 수행하며 MySQL 실서버는 사용하지 않았다. LSP 도구가 없어 Kotlin 컴파일과 ktlint로 대체했다. 기존 타 스케줄러의 H2 `SKIP LOCKED` 오류 로그와 Gradle deprecation 경고는 별도 관찰로 남긴다.
|
||||
|
||||
#### Task 14.3 관리자 목록/상세 취소 액션 구현
|
||||
|
||||
**Goal 실행 `P14-T3`:** 관리자 목록과 상세에서 `RECEIVED`만 `CANCEL` 액션을 노출한다.
|
||||
|
||||
- **시작 조건:** `P14-T2` 완료.
|
||||
- **완료 증거:** query service 테스트에서 모든 상태 매핑과 기존 `COMPLETE_DELIVERY` 수취인 정보 조건부 노출 통과.
|
||||
- **범위 밖:** 다른 상태 액션 재설계, 관리자 UI 구현.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt`.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt`.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` — 목록/상세 JSON 액션 및 취소 후 `CANCELED`/빈 액션 재조회 검증.
|
||||
|
||||
- [x] **RED:** 목록과 상세 각각 `RECEIVED -> ["CANCEL"]`이며 다른 상태의 기존 액션이 그대로이고 `INSPECTION_COMPLETED`의 `COMPLETE_DELIVERY` 조건이 유지되는 테스트를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest"` 실행. 신규 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 두 응답의 공통 상태별 action 계산만 필요한 범위에서 수정한다.
|
||||
- [x] **GREEN 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 목록/상세 JSON 매핑, 취소 후 빈 액션과 기존 조건부 액션 통과를 확인한다.
|
||||
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 focused test와 `./gradlew ktlintCheck` 결과를 기록한다.
|
||||
|
||||
#### P14-T3 구현·검증 기록 — 2026-10-22
|
||||
|
||||
- 무엇을: 공통 `GiftStatus.availableActions`의 `RECEIVED` 분기에 `CANCEL`만 추가했다. 기존 모든 상태 매핑과 수취인 정보 조건을 보존했다.
|
||||
- RED: 기존 34개 통과 후 신규 상세/실제 HTTP 목록 액션 assertion 2개만 실패하는 것을 확인했다. RANDOM_PORT fixture의 request context 오류는 수정 후 유효 RED를 재확인했으며 기능 실패로 계산하지 않았다.
|
||||
- GREEN: `./gradlew --no-daemon test --tests '*GiftAdminQueryServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` exit 0, 35개(query 4 + controller 31) 실패·오류·skip 0. 기존 9개 경합·롤백 테스트도 포함됐다.
|
||||
- 실제 HTTP: RANDOM_PORT Tomcat과 실제 TokenProvider JWT를 사용해 익명 401, 모든 비ADMIN 역할의 공통 실패 응답, ADMIN 목록/상세의 `RECEIVED/[CANCEL]`, body 없는 취소 성공, 반복 거부, 재조회 `CANCELED/[]`를 확인했다. 원 발신자 충전 40/리워드 0에서 리워드 80으로 환불되며 관리자 잔액과 환불 1회 조건을 실제 DB로 검증했다.
|
||||
- 증거: `AdminGiftControllerTest.shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions`의 `GIFT_HTTP` 출력에서 취소 성공 `success=true`, `status=CANCELED`, `statusName=신청 취소`, UTC `occurredAt` 네 필드를 확인했다. GET 증거는 개인정보를 제거하고 JWT는 출력하지 않았다.
|
||||
- 리뷰: T2/T3 전체 코드·테스트 최종 리뷰 APPROVE, 차단 사항 없음.
|
||||
|
||||
### Phase 14 Gate
|
||||
|
||||
**Goal 실행 `P14-GATE`:** 승인된 구현의 취소, query action 및 경합 회귀를 판정한다.
|
||||
|
||||
- **시작 조건:** `P14-T2`, `P14-T3` 완료.
|
||||
- **완료 증거:** focused test, 직접 영향 회귀, lint 통과 및 사용자 흐름 기록.
|
||||
- **범위 밖:** 실패와 무관한 구현, 전체 선물 도메인 리팩터링.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"
|
||||
./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest"
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** focused 명령과 lint가 exit 0. 취소와 운송장 등록, 사용자 취소, 자동취소, 다른 관리자 취소가 경합해도 허용 상태 전이와 환불 1회 불변조건이 유지되고, 환불 실패 시 취소 상태가 커밋되지 않는다. 기존 테스트 구조 안에서 검증하며 새 의존성이나 스키마는 추가하지 않는다.
|
||||
|
||||
- [x] 위 focused test와 직접 영향 회귀, lint 결과를 기록한다.
|
||||
- [x] 관리자 권한, 발신자 전액 환불, 목록/상세의 `CANCEL` 노출과 취소 후 빈 액션, 자동취소 전용 푸시 미발행을 검증한다.
|
||||
- [x] 문서의 구현 예정 표시를 실제 검증된 상태로 갱신한다. 전체 회귀는 공통 경계 변경 또는 focused test로 영향 범위를 판단할 수 없는 경우에만 실행하고, 생략 시 근거와 대체 명령을 기록한다.
|
||||
|
||||
#### P14-GATE 최종 판정 — 2026-10-22
|
||||
|
||||
- 판정: 사용자 요청에 따른 전체 테스트 제외 범위에서 완료. 기능·실제 HTTP·권한·환불·롤백·경합 검증과 독립 최종 리뷰를 통과했다.
|
||||
- 최종 빌드: `./gradlew --no-daemon build -x test ktlintCheck` exit 0, `BUILD SUCCESSFUL in 12s`. 최신 소스/테스트 컴파일 및 lint 산출물이 up-to-date이며 전체 build 작업이 성공했다.
|
||||
- 전체 테스트: `./gradlew --no-daemon test --rerun ktlintCheck build` 1차는 도구의 600초 제한으로 중단됐다. 제한을 늘린 2차는 사용자가 중단했다. 이후 사용자가 전체 테스트를 제외하고 마무리하도록 지시해 재실행하지 않았다. 전체 테스트 성공/실패를 확정하지 않는다.
|
||||
- 대체 증거: T2 focused 119개(독립 재실행 포함), T3 focused 35개(일부 중복), 실제 JWT socket HTTP 시나리오 및 최종 코드 리뷰 APPROVE. 테스트 삭제·skip·완화는 추가하지 않았다.
|
||||
- 한계: LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다. H2 검증이며 실제 MySQL REPEATABLE_READ/다중 인스턴스/운영 배포와 별도 프론트엔드 화면 QA는 수행하지 않았다. 기존 타 스케줄러 H2 문법 경고와 Gradle deprecation 경고는 수정 범위 밖이다.
|
||||
- 프롬프트: 사용자 요청대로 서버 구현/준비 여부를 선행조건으로 두지 않고 확정 Request/Response와 테스트 fixture를 기준으로 프론트엔드 병행 구현을 안내한다. 기존 `admin-page-prompts.md`는 그대로 보존한다.
|
||||
- 커밋: 생성했던 커밋은 사용자 요청으로 취소했다. 변경은 모두 unstaged/untracked 작업 트리에 유지하며 추가 커밋/푸시는 하지 않는다.
|
||||
- 문서 검증: `./gradlew --no-daemon tasks --all` exit 0, `git diff --check` 통과. 기존 전체 프롬프트 비교와 index 비교는 각각 exit 0, HEAD는 작업 전 `45b97088`이다. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 산출물을 확인했다.
|
||||
- 검증 기록 정정: P14-T1의 신규 파일 `git diff --no-index --check /dev/null ...` exit 0 표기는 부정확했다. 최종 확인에서는 공백 오류 출력 없이 신규 파일 차이로 exit 1이다. 기존 추적 파일 `git diff --check`는 exit 0이며, 이를 테스트/빌드 실패와 혼동하지 않는다.
|
||||
- 자원 정리: 테스트 worker/Redis 프로세스 잔류 없음과 작업 전용 디렉터리에 열린 파일 없음 확인 후 `gift-p14.xbpOuF`, `gift-p14-impl.d9YMmw`, `gift-p14-t3.Hx06BJ` 및 임시 진행 노트를 삭제하고 네 경로의 부재를 확인했다. 검증 요약은 이 문서에 보존했으며 공유 Gradle 캐시와 build 산출물은 삭제하지 않았다.
|
||||
|
||||
### Phase 14 커밋 전 검증 — 2026-10-22
|
||||
|
||||
- 승인: 사용자가 `/commit-with-validation`으로 변경을 적절히 나누어 커밋하도록 후속 요청했다. 이전 커밋 보류 기록 이후의 새 승인으로 적용한다.
|
||||
- 검증: `./gradlew --no-daemon test --rerun --tests '*GiftAdminServiceTest' --tests '*GiftAdminQueryServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' ktlintCheck` exit 0, `BUILD SUCCESSFUL in 58s`.
|
||||
- 결과: 관련 5개 클래스 124개 테스트에서 실패·오류·skip 0. lint는 UP-TO-DATE이며 `git diff --check`도 통과했다.
|
||||
- 범위: 관리자 취소와 직접 영향받는 사용자 명령·스케줄러 회귀를 재실행했다. 기존 사용자 요청에 따라 전체 테스트는 제외했고 운영 MySQL 검증은 수행하지 않았다.
|
||||
|
||||
### Phase 14 후속 회귀 수정
|
||||
|
||||
#### Task 14.4 관리자 취소 시각 정밀도 회귀 수정
|
||||
|
||||
**Goal 실행 `P14-T4`:** 관리자 취소의 DB 재조회 `canceledAt`과 응답 `occurredAt`을 OS/JDK 시계 정밀도와 무관하게 일치시킨다.
|
||||
|
||||
- **시작 조건:** Jenkins의 `AdminGiftControllerTest` 458/526행 실패 보고 및 정밀도 정규화 수정 승인. H2 2.1.214 `timestamp`에 나노초 입력 시 마이크로초 반올림을 별도 JDBC로 재현했다.
|
||||
- **정책:** 운영 DDL `canceled_at TIMESTAMP`에 맞춰 초 단위 절삭 후 저장·응답에 같은 값을 사용한다. 테스트 비교를 완화하거나 전역 `toUtcIso`, DDL, 사용자/자동취소 동작을 변경하지 않는다.
|
||||
- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`; Verify `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`; Update 기존 `prd.md`, `plan-task.md`.
|
||||
- **완료 증거:** 고정 나노초·초 경계 입력의 DB flush/clear 재조회 테스트, 기존 실패 두 HTTP 테스트를 포함한 focused test, lint/테스트 제외 빌드, 변경 리뷰 통과.
|
||||
- **범위 밖:** 전체 테스트(기존 사용자 제외 요청 유지), 커밋/푸시, 운영 DB/CI 설정 변경.
|
||||
- [x] **RED:** `0`, `123456789`, `999999999` 나노초 입력을 사용해 초 단위 기대값과 DB 재조회/응답의 완전 일치를 검증하고 기존 코드의 실패를 확인한다.
|
||||
- [x] **GREEN:** 관리자 취소 메서드에서 취소 시각을 한 번 초 단위로 정규화하고 저장·응답에 사용한다.
|
||||
- [x] **REFACTOR/검증:** `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` 및 `./gradlew --no-daemon build -x test`를 실행한다. 기존 458/526행 assertion과 실제 JWT HTTP 검증은 유지한다.
|
||||
|
||||
#### P14-T4 진행 기록 — 2026-10-22
|
||||
|
||||
- RED: 신규 parameterized DB roundtrip 테스트 3개 중 2개 실패(exit 1, 42초). `23:59:59.123456789`는 DB에서 `.123457`로, `23:59:59.999999999`는 다음 날 `00:00`으로 반올림되어 응답과 달랐다. 나노초 0 입력은 통과했다.
|
||||
- GREEN: 관리자 취소의 `now`를 `ChronoUnit.SECONDS`로 한 번 절삭해 저장과 결과에 같은 값을 사용했다. `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` exit 0(1분 26초), service 28 + controller 31 = 59개 실패·오류·skip 0.
|
||||
- 실제 HTTP: `shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions`에서 body 없는 POST가 `success=true`, `status=CANCELED`, `occurredAt=2026-10-22T19:05:41Z`를 반환하고 DB 재조회값과 정확히 일치했다. Jenkins에서 실패한 기존 두 assertion과 환불·경합 검증은 변경하지 않았다.
|
||||
- 빌드/문서: `./gradlew --no-daemon build -x test tasks --all` exit 0(14초). 전체 테스트는 기존 사용자 제외 요청을 유지해 실행하지 않았다. 커밋/푸시 없음.
|
||||
- 한계: Jenkins 재실행과 실제 운영 DB 검증은 하지 않았다. CI 원본 expected/actual은 제공되지 않았으며 로컬 고정 나노초 입력으로 해당 실패 메커니즘을 재현·검증했다. LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다.
|
||||
- 최종 판정: 독립 변경 리뷰 APPROVE, 차단 사항 없음. 기존 HTTP 458/526행의 완전 일치 검증과 전역 날짜 변환/DDL/환불 로직이 변경되지 않았음을 확인했다. `git diff --check` 통과, 후속 커밋 없음.
|
||||
|
||||
### Phase 14 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 31 | `P14-T1` | 관리자 취소 승인 범위 문서화 | 아니요 | 문서 검증 |
|
||||
| 32 | `P14-T2` | `P14-T1` 검증 및 사용자 구현 승인 | 아니요 | 승인 대기, 코드 변경 금지 |
|
||||
| 33 | `P14-T3` | `P14-T2` 계약/승인 | 아니요 | 선행 구현 승인 대기 |
|
||||
| 34 | `P14-GATE` | `P14-T2`, `P14-T3` | 아니요 | 실패 원인 Task에 반영 |
|
||||
| 35 | `P14-T4` | Jenkins 정밀도 불일치 후속 수정 승인 | 아니요 | DB roundtrip 실패 원인 확인 |
|
||||
|
||||
```text
|
||||
P14-T1 문서 검증 → 사용자 구현 승인 → P14-T2 → P14-T3 → P14-GATE
|
||||
```
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
@@ -1442,6 +1637,10 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
|
||||
| 2026-09-30 | `PLAN-DEC-006` | 확정 | 선물 API response datetime은 전역 Jackson 설정이 아니라 DTO response boundary에서 UTC ISO 문자열로 변환한다 | 선물 API만의 공개 계약 보강이며 전역 변경은 기존 API 영향이 큼 | `P8-T1`, `client-api-summary.md` |
|
||||
| 2026-09-30 | `PLAN-DEC-007` | 확정 | 기존 `categoryCode`는 내부 범용코드로 유지하고 분류번호/접수코드/대표품목을 추가하며, 신청번호는 카테고리+날짜별 sequence로 발급한다 | 사용자 확정: 카테고리 필드 의미와 사용자 폼 옵션 표시 규칙 분리 | `P9-T1`~`P9-T3` |
|
||||
| 2026-10-02 | `PLAN-DEC-008` | 확정 | `COMPLETE_DELIVERY`는 수취인 필수 정보가 모두 입력된 경우에만 허용하고, 배송지 미입력 기한 초과는 주소 입력 가능 상태 3개 전체에서 `UNDELIVERABLE`로 종료한다 | 배송지 없는 선물의 전달 완료와 관리자 상태 진행 후 만료 누락 방지 | `P12-T1`, `P12-T2` |
|
||||
| 2026-10-22 | `PLAN-DEC-009` | 확정 | Phase 14는 문서화만 먼저 진행하며 관리자 취소 구현은 사용자 별도 승인 후 시작한다. `RECEIVED` 전액 환불은 기존 경로와 관리자 응답/action 계약을 재사용한다 | 승인된 범위와 구현 전 사람 승인 경계 | `P14-T1`~`P14-GATE`, PRD `DEC-015` |
|
||||
| 2026-10-22 | `PLAN-DEC-010` | 확정 | 기존 `admin-page-prompts.md`는 보존하고 기존 화면에 취소 기능만 추가하는 프롬프트를 `admin-gift-cancel-addition-prompt.md`로 분리한다 | 사용자 수정 요청 | `P14-T1`, PRD `DEC-016` |
|
||||
| 2026-10-22 | `PLAN-DEC-011` | 확정 | 프론트엔드 추가 프롬프트에서 서버 준비 대기 조건을 제거하고 확정 API 계약으로 병행 구현한다 | 사용자 후속 요청 | `admin-gift-cancel-addition-prompt.md` |
|
||||
| 2026-10-22 | `PLAN-DEC-012` | 확정 | 전체 테스트를 제외하고 관련 검증·테스트 제외 빌드·문서·임시 자원 정리로 마무리한다. 커밋은 사용자가 직접 한다 | 사용자 마무리 및 커밋 금지 요청 | `P14-GATE` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 중 |
|
||||
| 문서 상태 | Phase 14 서버 구현 완료, 전체 테스트 제외 검증 완료 |
|
||||
| 작성일 | 2026-09-29 |
|
||||
| 최종 수정일 | 2026-09-30 |
|
||||
| 최종 수정일 | 2026-10-22 |
|
||||
| 대상 제품 | 크리에이터 선물하기 |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
|
||||
| 관련 구현 계획 | `docs/20260929_크리에이터_선물하기/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
> Phase 14 관리자 취소와 목록·상세 `CANCEL` 액션은 서버 코드에 구현됐다. 관련 테스트·실제 JWT HTTP·리뷰·테스트 제외 빌드를 검증했으며, 전체 테스트는 사용자 요청으로 완료 기준에서 제외했다. 운영 배포 여부와 별도 프론트엔드 구현 여부는 이 문서의 완료 상태에 포함하지 않는다.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
팬이 내부 재화인 캔을 사용해 크리에이터에게 실물 선물을 보낼 수 있는 API를 제공한다.
|
||||
@@ -34,6 +36,7 @@
|
||||
- 팬이 선물 사이즈, 카테고리, 발신자 정보, 약관 동의를 입력해 선물 보내기를 신청할 수 있다.
|
||||
- 선물 신청 시 캔을 즉시 차감하고 내부적으로 선물 신청과 캔 사용내역을 연결한다.
|
||||
- 팬은 운송장 등록 전 선물 신청을 취소할 수 있고, 3일 내 운송장을 등록하지 않으면 자동취소 및 전액 환불된다.
|
||||
- 관리자는 운송장 등록 전 `RECEIVED` 선물을 취소하고 발신자에게 사용 캔 전액을 리워드캔으로 환불할 수 있다.
|
||||
- 크리에이터는 팬이 운송장을 등록한 선물부터 받은 선물 목록과 상세를 조회하고 7일 이내 배송지와 수령 약관 동의를 등록할 수 있다.
|
||||
- 팬과 크리에이터는 같은 선물함 목록 API에서 보낸/받은 내역을 함께 또는 개별 조회할 수 있다.
|
||||
- 운영자는 상태별 전용 관리자 API로 사서함 도착, 검수완료, 전달불가, 전달완료를 처리할 수 있다.
|
||||
@@ -55,7 +58,7 @@
|
||||
|---|---|---|---|
|
||||
| 팬 | 크리에이터에게 선물을 보낸다 | 사이즈/카테고리 조회, 신청, 운송장 등록, 취소, 보낸 내역 조회, 리뷰 작성 | 인증 회원 |
|
||||
| 크리에이터 | 받은 선물을 확인하고 배송지를 입력한다 | 받은 내역 조회, 배송지 입력, 수령 확인 | 인증 회원 중 크리에이터 |
|
||||
| 관리자 | 선물 운영 설정을 관리한다 | 카테고리, 사이즈 가격 관리 | 관리자 |
|
||||
| 관리자 | 선물 운영 설정과 선물 상태를 관리한다 | 카테고리, 사이즈 가격 관리, `RECEIVED` 선물 취소 | 관리자 |
|
||||
| 운영자 | 선물 검수/전달 상태를 처리한다 | 사서함 도착, 검수완료, 전달불가, 전달완료 처리 | 관리자 또는 운영 권한 |
|
||||
| 스케줄러 | 기한 초과 건을 자동 처리한다 | 운송장 미등록 자동취소, 배송지 미입력 전달불가 | 시스템 |
|
||||
|
||||
@@ -74,7 +77,7 @@
|
||||
| `INSPECTION_COMPLETED` | 검수완료 | 운영자가 선물 검수를 통과 처리한 상태 | 아니오 |
|
||||
| `DELIVERED` | 전달완료 | 크리에이터 수령확인 또는 운영자 전달완료 처리 상태 | 예 |
|
||||
| `UNDELIVERABLE` | 전달불가 | 검수 실패 또는 배송지 미입력 기한 초과로 전달할 수 없는 상태 | 예 |
|
||||
| `CANCELED` | 신청 취소 | 운송장 등록 전 사용자 취소 또는 자동취소 상태 | 예 |
|
||||
| `CANCELED` | 신청 취소 | 운송장 등록 전 사용자 취소, ADMIN 취소 또는 자동취소 상태 | 예 |
|
||||
|
||||
상태 전이는 다음을 기본으로 한다.
|
||||
|
||||
@@ -171,6 +174,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
|
||||
- 선물 신청과 캔 사용내역은 내부적으로 연결되어야 한다.
|
||||
- 사용자 캔 사용내역 화면에서 선물 신청으로 이동할 수 없어도 된다.
|
||||
- 운송장 등록 전 사용자 취소 또는 3일 자동취소 시 사용 캔을 전액 환불한다.
|
||||
- ADMIN은 운송장 등록 전 `RECEIVED`에서 취소할 수 있으며 기존 `refundGiftCan` 경로로 발신자에게 전액 리워드캔 환불한다. 상태 변경, 취소 시각 저장, 환불은 같은 트랜잭션에서 처리한다.
|
||||
- 운송장 등록 후에는 팬이 취소할 수 없다.
|
||||
|
||||
### 6.8 목록 노출
|
||||
@@ -248,6 +252,8 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
|
||||
| `GIFT-012` | 확정 | 관리자는 카테고리를 등록/수정/논리삭제할 수 있다 | 삭제는 `isActive=false`만 수행한다 | `P1` |
|
||||
| `GIFT-013` | 확정 | 관리자는 사이즈별 가격을 수정할 수 있다 | 새 신청에는 변경 가격이 적용되고 기존 신청 스냅샷은 바뀌지 않는다 | `P1` |
|
||||
| `GIFT-015` | 확정 | 관리자는 전체 선물함 목록과 상세를 조회하고 현재 상태에서 가능한 운영 액션을 확인할 수 있다 | 목록은 상태/신청번호/닉네임 검색과 페이징을 지원하고, 상세는 발송인/수취인 개인정보와 상품/배송/상태 정보를 내려준다 | `P10` |
|
||||
| `GIFT-016` | 확정 | 관리자는 운송장 등록 전 접수된 선물을 취소할 수 있다 | 관리자만 호출할 수 있고 `RECEIVED`에서만 `CANCELED`로 전이하며 `GiftDelivery.canceledAt`을 저장하고 발신자에게 사용 캔 전액을 리워드캔으로 환불한다 | `P14-T2` |
|
||||
| `GIFT-017` | 확정 | 관리자 선물함 목록과 상세는 현재 가능한 액션으로 취소 가능 여부를 함께 제공한다 | `RECEIVED`에서만 `availableActions=["CANCEL"]`; 다른 상태의 기존 액션은 유지한다 | `P14-T3` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
@@ -843,11 +849,11 @@ Request:
|
||||
|
||||
Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내려준다. `address`는 기존 주소 응답과 동일하게 `(우편번호) 주소, 상세주소` 형식이다. `name`, `phoneNumber`, `zipCode`, `address`는 필수이며 공백만 입력할 수 없다. `addressDetail`은 선택이다.
|
||||
|
||||
`availableActions`는 현재 상태별로 다음 값만 내려준다.
|
||||
관리자 목록과 상세의 `availableActions`는 상태별로 다음 값을 내려준다. `RECEIVED`에서는 `CANCEL`을 제공하고, 다른 상태의 기존 매핑은 유지한다.
|
||||
|
||||
| 현재 상태 | availableActions |
|
||||
|---|---|
|
||||
| `RECEIVED` | `[]` |
|
||||
| `RECEIVED` | `["CANCEL"]` |
|
||||
| `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` |
|
||||
| `ARRIVED_AT_MAILBOX` | `["COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"]` |
|
||||
| `INSPECTION_COMPLETED`, 수취인 정보 입력 완료 | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` |
|
||||
@@ -864,6 +870,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | `ARRIVED_AT_MAILBOX` | `INSPECTION_COMPLETED` | 없음 |
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` | `UNDELIVERABLE` | `reason` |
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | `INSPECTION_COMPLETED` | `DELIVERED` | 저장된 수취인 이름, 휴대폰 번호, 우편번호, 주소 |
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/cancel` | `RECEIVED` | `CANCELED` | 없음 |
|
||||
|
||||
`complete-delivery`는 `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`가 모두 null 또는 공백이 아닐 때만 성공한다. `recipientAddressDetail`은 선택 입력이다. 조건을 만족하지 않으면 상태, 전달 완료 시각, 푸시를 변경하지 않고 요청을 거부한다.
|
||||
|
||||
@@ -875,6 +882,31 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.14.1 관리자 선물 신청 취소
|
||||
|
||||
`POST /api/v2/admin/gifts/{applicationNo}/cancel`
|
||||
|
||||
Request body: 없음
|
||||
|
||||
기존 관리자 API의 `hasRole('ADMIN')` 권한을 따르며 관리자 계정은 선물 발신자가 아니어도 취소할 수 있다. `RECEIVED`에서만 `CANCELED`로 전이하며 운송장 등록 이후에는 취소할 수 없다. 같은 트랜잭션에서 `GiftDelivery.canceledAt`을 기록하고 기존 `refundGiftCan(gift.senderMemberId, gift.canUsageId)` 경로로 발신자에게 원 결제액 전액을 리워드캔으로 환불한다. 관리자의 잔액은 변경하지 않는다.
|
||||
|
||||
Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"applicationNo": "A-1002609300001",
|
||||
"status": "CANCELED",
|
||||
"statusName": "신청 취소",
|
||||
"occurredAt": "2026-09-30T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`occurredAt`은 `canceledAt`과 같은 UTC ISO `Z` 문자열이다. 누락되거나 취소할 수 없는 상태의 신청번호는 기존 `SodaException(messageKey = "common.error.invalid_request")`로 거부한다. 정상 `ApiResponse` envelope를 사용하며 신규 오류 스키마나 HTTP status 정책은 추가하지 않는다.
|
||||
|
||||
관리자 취소 시각은 `gift-schema.sql`의 `canceled_at TIMESTAMP` 정밀도에 맞춰 초 단위로 절삭한 단일 값을 저장과 응답에 사용한다. OS/JDK 시계가 나노초를 반환해도 DB 재조회 시각과 응답 시각은 일치해야 한다. 전역 날짜 변환 및 다른 상태 변경의 시각 정책은 변경하지 않는다.
|
||||
|
||||
이 관리자 취소는 기존 사용자 직접 취소와 운송장 미등록 자동취소를 변경하지 않는다. 취소 전용 푸시는 추가하지 않으며, 자동취소 푸시 문구나 자동취소 사유를 관리자 취소에 재사용하지 않는다.
|
||||
|
||||
### 8.15 자동 스케줄링 작업
|
||||
|
||||
| 작업 | 대상 | 처리 | 푸시 |
|
||||
@@ -1014,6 +1046,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
- 푸시는 팬/크리에이터 수신자 분리와 크리에이터 수령확인 시 크리에이터 푸시 억제를 테스트한다.
|
||||
- 스케줄러는 중복 실행 시 중복 환불/중복 푸시가 없는지 테스트한다.
|
||||
- 선물 사용자/관리자 API response DTO의 날짜/시간 필드는 UTC ISO 문자열(`Z` 포함)로 직렬화되는지 controller test로 고정한다.
|
||||
- 관리자 취소는 `RECEIVED`만 허용하고, 발신자에게 전액 리워드캔 환불, `canceledAt` 저장, 환불 실패 시 트랜잭션 롤백 및 중복 환불 방지를 검증한다. 운송장 등록, 사용자 취소, 자동취소, 다른 관리자 취소와의 동시 경합에서도 잘못된 이중 처리/환불이 없어야 한다.
|
||||
- 관리자 취소 controller는 기존 관리자 인증/권한, 공통 invalid request, 정상 `ApiResponse` envelope와 `AdminGiftOperationStatusResponse`의 UTC `occurredAt`을 검증한다.
|
||||
- 관리자 목록과 상세 모두 `RECEIVED`에서만 `CANCEL` 액션을 제공하며 기존 `COMPLETE_DELIVERY` 조건부 액션을 포함한 나머지 상태 매핑은 유지한다.
|
||||
- 카테고리 `classificationNumber`, `receiptCode`, `representativeItem`의 관리자 등록/수정/조회와 사용자 폼 옵션의 `분류명/대표품목` 조합을 테스트한다.
|
||||
- 신청번호는 카테고리+날짜별 sequence, 4자리 padding, `9999` 초과 자리수 증가, 병렬 unique를 테스트한다.
|
||||
|
||||
@@ -1029,6 +1064,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
- [ ] 팬/크리에이터 푸시가 지정된 문구, 수신자, 딥링크로 발송된다.
|
||||
- [ ] 선물 사용자/관리자 API의 날짜/시간 응답을 클라이언트가 UTC ISO 문자열로 해석할 수 있다.
|
||||
- [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다.
|
||||
- [x] 관리자 취소는 `RECEIVED`에서만 전액 환불과 함께 성공하고, 목록/상세의 `CANCEL` 액션은 상태에 맞게 제공된다.
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
@@ -1045,6 +1081,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
| `GIFT-010` | 5 | 리뷰 API 테스트 | 전달완료 후 리뷰 작성 확인 |
|
||||
| `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 |
|
||||
| `GIFT-014` | 8 | 선물 사용자/관리자 controller 날짜 응답 테스트 | UTC ISO 문자열 확인 |
|
||||
| `GIFT-016`, `GIFT-017` | 14 | 관리자 취소 service/controller 및 query service 테스트 | `RECEIVED` 액션과 취소/환불 결과 확인 |
|
||||
|
||||
## 16. Decision Log
|
||||
|
||||
@@ -1064,3 +1101,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
| 2026-09-29 | `DEC-012` | 확정 | 파손면책 동의 필요 카테고리는 팬 신청 시 `damageWaiverAgreed=true`를 요구한다 | 사용자 선택 A | `GIFT-003` |
|
||||
| 2026-09-30 | `DEC-013` | 확정 | 선물 사용자/관리자 API의 response datetime 필드는 `LocalDateTime` 기본 JSON 대신 UTC ISO 문자열로 내려준다 | 클라이언트가 offset 없는 `LocalDateTime` 문자열을 안전하게 해석하지 못함 | `GIFT-014` |
|
||||
| 2026-09-30 | `DEC-014` | 확정 | 카테고리는 분류번호, 내부 범용 카테고리 코드, 분류명, 접수코드, 대표품목, 파손면책 조건을 관리하고 신청번호는 `receiptCode-classificationNumberYYMMDDSequenceNo`로 발급한다 | 관리자 운영 필드와 사용자 표시 요구가 분리됨 | `GIFT-001`, `GIFT-002`, `GIFT-012` |
|
||||
| 2026-10-22 | `DEC-015` | 확정 | 관리자 취소는 `RECEIVED`에서만 허용하고 기존 `refundGiftCan`으로 발신자에게 전액 리워드캔을 환불한다. 기존 operation status 응답을 재사용하며 관리자 취소 전용 푸시는 이번 범위에서 추가하지 않는다 | 사용자 확정: 취소 허용 상태와 문서 우선 반영. 응답/환불은 기존 계약 재사용, 신규 푸시는 요청 범위 밖 | `GIFT-016`, `GIFT-017`, `P14` |
|
||||
| 2026-10-22 | `DEC-016` | 확정 | 기존 관리자 화면 전체 구현 프롬프트는 보존하고 취소 기능 추가 프롬프트를 `admin-gift-cancel-addition-prompt.md`로 분리한다 | 사용자 요청: 기존 페이지에 해당 기능만 추가하는 별도 프롬프트 | `GIFT-016`, `GIFT-017`, `P14-T1` |
|
||||
|
||||
## 문서 상태 안내
|
||||
|
||||
Phase 14 서버 구현과 관련 검증은 완료했다. 전체 테스트는 1차 실행이 도구의 10분 제한으로 중단되고 2차는 사용자 중단 후 제외됐다. 전체 테스트 통과를 주장하지 않으며 검증 명령과 한계는 `plan-task.md`의 P14-GATE 기록을 따른다. 관리자 프론트엔드는 별도 기능 추가 프롬프트의 확정 계약을 사용한다.
|
||||
|
||||
+5
@@ -83,6 +83,11 @@ class AdminGiftController(
|
||||
@RequestBody request: AdminGiftSizePriceRequest
|
||||
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
|
||||
|
||||
@PostMapping("/gifts/{applicationNo}/cancel")
|
||||
fun cancelGift(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||
AdminGiftOperationStatusResponse.from(service.cancelGift(applicationNo))
|
||||
)
|
||||
|
||||
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
|
||||
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
|
||||
|
||||
+8
@@ -4,13 +4,19 @@ import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface GiftRepository : JpaRepository<Gift, Long> {
|
||||
fun findByApplicationNo(applicationNo: String): Gift?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select g from Gift g where g.applicationNo = :applicationNo")
|
||||
fun findByApplicationNoForUpdate(@Param("applicationNo") applicationNo: String): Gift?
|
||||
|
||||
fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page<Gift>
|
||||
|
||||
fun findByRecipientMemberIdAndStatusIn(
|
||||
@@ -74,6 +80,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
|
||||
)
|
||||
"""
|
||||
)
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List<Gift>
|
||||
|
||||
@Query(
|
||||
@@ -113,6 +120,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
|
||||
)
|
||||
"""
|
||||
)
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
fun findReceivedGiftsWithTrackingDeadlineReminderDue(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("reminderUntil") reminderUntil: LocalDateTime
|
||||
|
||||
+2
-1
@@ -157,6 +157,7 @@ class GiftAdminQueryService(
|
||||
)
|
||||
|
||||
private fun GiftStatus.availableActions(delivery: GiftDelivery) = when (this) {
|
||||
GiftStatus.RECEIVED -> listOf("CANCEL")
|
||||
GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.INSPECTION_COMPLETED -> if (delivery.hasCompleteRecipientInformation()) {
|
||||
@@ -164,7 +165,7 @@ class GiftAdminQueryService(
|
||||
} else {
|
||||
listOf("MARK_UNDELIVERABLE")
|
||||
}
|
||||
GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
|
||||
GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
|
||||
}
|
||||
|
||||
private fun GiftDelivery.recipientAddressResult(): String {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
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
|
||||
@@ -15,6 +16,7 @@ import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
data class GiftCategoryCommand(
|
||||
val classificationNumber: String,
|
||||
@@ -73,7 +75,8 @@ class GiftAdminService(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val giftMailboxRepository: GiftMailboxRepository,
|
||||
private val giftPushService: GiftPushService
|
||||
private val giftPushService: GiftPushService,
|
||||
private val canPaymentService: CanPaymentService
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult()
|
||||
@@ -150,6 +153,20 @@ class GiftAdminService(
|
||||
return sizePriceRepository.save(sizePrice).toResult()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
|
||||
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.status != GiftStatus.RECEIVED) throw invalidRequest()
|
||||
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
|
||||
val canceledAt = now.truncatedTo(ChronoUnit.SECONDS)
|
||||
|
||||
gift.status = GiftStatus.CANCELED
|
||||
delivery.canceledAt = canceledAt
|
||||
canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId)
|
||||
|
||||
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, canceledAt)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
|
||||
@@ -207,7 +207,7 @@ class GiftCommandService(
|
||||
@Transactional
|
||||
fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult {
|
||||
val senderMemberId = member.id ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
|
||||
|
||||
val canceledAt = now()
|
||||
@@ -231,7 +231,7 @@ class GiftCommandService(
|
||||
command: GiftTrackingRegistrationCommand
|
||||
): GiftTrackingRegistrationResult {
|
||||
val senderMemberId = member.id ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
|
||||
validate(command)
|
||||
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
|
||||
|
||||
+540
-1
@@ -1,7 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
@@ -11,16 +30,42 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepo
|
||||
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
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftScheduler
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.hamcrest.Matchers.endsWith
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.EnumSource
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockHttpServletRequest
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.annotation.DirtiesContext
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
@@ -29,11 +74,24 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delet
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import org.springframework.web.context.request.RequestContextHolder
|
||||
import org.springframework.web.context.request.ServletRequestAttributes
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = [
|
||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||
"spring.datasource.url=jdbc:h2:mem:gift-admin-controller;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" +
|
||||
@@ -50,15 +108,41 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
private val memberRepository: MemberRepository,
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val useCanRepository: UseCanRepository,
|
||||
private val calculateRepository: UseCanCalculateRepository,
|
||||
private val commandService: GiftCommandService,
|
||||
private val scheduler: GiftScheduler,
|
||||
private val transactionManager: PlatformTransactionManager,
|
||||
private val entityManager: EntityManager,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val restTemplate: TestRestTemplate,
|
||||
private val tokenProvider: TokenProvider
|
||||
) {
|
||||
@LocalServerPort
|
||||
private var port: Int = 0
|
||||
|
||||
@SpyBean
|
||||
private lateinit var canPaymentService: CanPaymentService
|
||||
|
||||
@MockBean
|
||||
private lateinit var giftPushService: GiftPushService
|
||||
|
||||
@BeforeEach
|
||||
fun deleteGiftFixtures() {
|
||||
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
|
||||
giftDeliveryRepository.deleteAll()
|
||||
giftRepository.deleteAll()
|
||||
categoryRepository.deleteAll()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun clearRequestContext() {
|
||||
(RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.requestCompleted()
|
||||
RequestContextHolder.resetRequestAttributes()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
|
||||
fun shouldManageGiftMailbox() {
|
||||
@@ -352,6 +436,448 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("발신자가 아닌 관리자는 접수 선물을 취소하고 원 결제액을 발신자 리워드캔으로 한 번만 환불한다")
|
||||
fun shouldCancelReceivedGiftAndRefundOriginalSenderOnlyOnce() {
|
||||
val fixture = saveFundedGift()
|
||||
assertNotEquals(fixture.sender.id, fixture.admin.id)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
|
||||
val response = mockMvc.perform(cancelRequest(fixture))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("CANCELED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("신청 취소"))
|
||||
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
|
||||
.andReturn().response
|
||||
val data = objectMapper.readTree(response.contentAsByteArray).path("data")
|
||||
assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet())
|
||||
val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText())
|
||||
assertEquals(GiftStatus.CANCELED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertRefundState(fixture, refunded = true)
|
||||
|
||||
mockMvc.perform(cancelRequest(fixture))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
|
||||
assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = true)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 JWT HTTP에서 관리자만 접수 선물의 취소 액션을 조회하고 한 번 취소·환불한다")
|
||||
fun shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions() {
|
||||
val fixture = saveFundedGift()
|
||||
val listPath = "/api/v2/admin/gifts?applicationNo=$APPLICATION_NO"
|
||||
val detailPath = "/api/v2/admin/gifts/$APPLICATION_NO"
|
||||
val cancelPath = "$detailPath/cancel"
|
||||
val endpoints = listOf(HttpMethod.GET to listPath, HttpMethod.GET to detailPath, HttpMethod.POST to cancelPath)
|
||||
assertNotEquals(fixture.sender.id, fixture.admin.id)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
|
||||
endpoints.forEach { (method, path) ->
|
||||
giftHttp(method, path, expectedStatus = HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
MemberRole.values().filter { it != MemberRole.ADMIN }.forEach { role ->
|
||||
val member = if (role == MemberRole.USER) {
|
||||
fixture.sender
|
||||
} else {
|
||||
memberRepository.saveAndFlush(
|
||||
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "nonadmin", role = role)
|
||||
)
|
||||
}
|
||||
val token = tokenFor(member)
|
||||
endpoints.forEach { (method, path) ->
|
||||
val denied = giftHttp(method, path, token)
|
||||
assertFalse(denied.path("success").asBoolean())
|
||||
assertEquals("You do not have permission.", denied.path("message").asText())
|
||||
assertTrue(denied.path("data").isNull)
|
||||
}
|
||||
}
|
||||
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
|
||||
val adminToken = tokenFor(fixture.admin)
|
||||
val beforeList = giftHttp(HttpMethod.GET, listPath, adminToken)
|
||||
val beforeDetail = giftHttp(HttpMethod.GET, detailPath, adminToken)
|
||||
listOf(beforeList, beforeDetail).forEach { assertTrue(it.path("success").asBoolean()) }
|
||||
assertEquals(1, beforeList.path("data").path("totalCount").asInt())
|
||||
listOf(beforeList.path("data").path("items").single(), beforeDetail.path("data")).forEach { data ->
|
||||
assertEquals(APPLICATION_NO, data.path("applicationNo").asText())
|
||||
assertEquals("RECEIVED", data.path("status").asText())
|
||||
assertEquals(listOf("CANCEL"), data.path("availableActions").map { it.asText() })
|
||||
}
|
||||
|
||||
val canceled = giftHttp(HttpMethod.POST, cancelPath, adminToken)
|
||||
assertTrue(canceled.path("success").asBoolean())
|
||||
val data = canceled.path("data")
|
||||
assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet())
|
||||
assertEquals(APPLICATION_NO, data.path("applicationNo").asText())
|
||||
assertEquals("CANCELED", data.path("status").asText())
|
||||
assertEquals("신청 취소", data.path("statusName").asText())
|
||||
assertTrue(data.path("occurredAt").asText().endsWith("Z"))
|
||||
val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText())
|
||||
assertRefundState(fixture, refunded = true)
|
||||
|
||||
val repeated = giftHttp(HttpMethod.POST, cancelPath, adminToken)
|
||||
assertFalse(repeated.path("success").asBoolean())
|
||||
assertEquals("Invalid request.", repeated.path("message").asText())
|
||||
assertTrue(repeated.path("data").isNull)
|
||||
assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = true)
|
||||
|
||||
val afterList = giftHttp(HttpMethod.GET, listPath, adminToken)
|
||||
val afterDetail = giftHttp(HttpMethod.GET, detailPath, adminToken)
|
||||
listOf(afterList, afterDetail).forEach { assertTrue(it.path("success").asBoolean()) }
|
||||
assertEquals(1, afterList.path("data").path("totalCount").asInt())
|
||||
listOf(afterList.path("data").path("items").single(), afterDetail.path("data")).forEach { item ->
|
||||
assertEquals(APPLICATION_NO, item.path("applicationNo").asText())
|
||||
assertEquals("CANCELED", item.path("status").asText())
|
||||
assertEquals(emptyList<String>(), item.path("availableActions").map { it.asText() })
|
||||
}
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE)
|
||||
@DisplayName("관리자 취소는 모든 비접수 상태를 공통 오류로 거부하고 DB를 변경하지 않는다")
|
||||
fun shouldRejectCancellationForEveryNonReceivedStatus(giftStatus: GiftStatus) {
|
||||
val fixture = saveFundedGift()
|
||||
TransactionTemplate(transactionManager).executeWithoutResult {
|
||||
requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO)).status = giftStatus
|
||||
}
|
||||
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
|
||||
|
||||
val gift = requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO))
|
||||
assertEquals(giftStatus, gift.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 취소는 존재하지 않는 신청번호를 공통 오류로 거부한다")
|
||||
fun shouldRejectCancellationForMissingGift() {
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/missing/cancel").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("미인증 사용자와 일반 사용자 및 크리에이터는 관리자 취소를 실행할 수 없다")
|
||||
fun shouldDenyCancellationWithoutAdminRole() {
|
||||
val fixture = saveFundedGift()
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel"))
|
||||
.andDo(print())
|
||||
.andExpect(status().isUnauthorized)
|
||||
listOf("USER", "CREATOR").forEach { role ->
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("other").roles(role)))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertTrue(it.resolvedException is org.springframework.security.access.AccessDeniedException) }
|
||||
}
|
||||
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 환불을 DB에 flush한 뒤 예외가 발생하면 상태와 시각 및 전체 환불 DB 변경이 롤백된다")
|
||||
fun shouldRollbackCancellationAndRealRefundWhenRefundFails() {
|
||||
val fixture = saveFundedGift()
|
||||
Mockito.doAnswer { invocation ->
|
||||
invocation.callRealMethod()
|
||||
entityManager.flush()
|
||||
assertEquals(80, memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow().pgRewardCan)
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
|
||||
|
||||
mockMvc.perform(cancelRequest(fixture))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("cancellationRaces")
|
||||
@DisplayName("관리자 취소와 동일 선물 변경은 별도 트랜잭션에서 선행 작업 커밋까지 직렬화된다")
|
||||
fun shouldSerializeCancellationAgainstReceivedMutations(operations: Pair<RaceOperation, RaceOperation>) {
|
||||
val fixture = saveFundedGift()
|
||||
val winnerPaused = CountDownLatch(1)
|
||||
val releaseWinner = CountDownLatch(1)
|
||||
val contenderStarted = CountDownLatch(1)
|
||||
val refundCalls = AtomicInteger()
|
||||
Mockito.doAnswer { invocation ->
|
||||
if (refundCalls.incrementAndGet() == 1 && operations.first != RaceOperation.TRACKING &&
|
||||
operations.first != RaceOperation.REMINDER
|
||||
) {
|
||||
winnerPaused.countDown()
|
||||
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
|
||||
}
|
||||
invocation.callRealMethod()
|
||||
}.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
|
||||
if (operations.first == RaceOperation.TRACKING) {
|
||||
Mockito.doAnswer {
|
||||
winnerPaused.countDown()
|
||||
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
|
||||
null
|
||||
}.`when`(giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, fixture.gift.recipientMemberId)
|
||||
}
|
||||
if (operations.first == RaceOperation.REMINDER) {
|
||||
Mockito.doAnswer {
|
||||
winnerPaused.countDown()
|
||||
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
|
||||
null
|
||||
}.`when`(giftPushService).sendTrackingDeadlineReminder(APPLICATION_NO, requireNotNull(fixture.sender.id))
|
||||
}
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val winner = executor.submit<String> { executeRaceOperation(operations.first, fixture) }
|
||||
var contender: java.util.concurrent.Future<String>? = null
|
||||
try {
|
||||
assertTrue(winnerPaused.await(10, TimeUnit.SECONDS))
|
||||
contender = executor.submit<String> {
|
||||
contenderStarted.countDown()
|
||||
executeRaceOperation(operations.second, fixture)
|
||||
}
|
||||
assertTrue(contenderStarted.await(10, TimeUnit.SECONDS))
|
||||
val waitingContender = contender
|
||||
assertThrows(TimeoutException::class.java) { waitingContender.get(300, TimeUnit.MILLISECONDS) }
|
||||
releaseWinner.countDown()
|
||||
val expectedWinner = when (operations.first) {
|
||||
RaceOperation.TRACKING -> "TRACKING_REGISTERED"
|
||||
RaceOperation.REMINDER -> "REMINDED"
|
||||
else -> "CANCELED"
|
||||
}
|
||||
val expectedContender = when {
|
||||
operations.second in listOf(RaceOperation.AUTO, RaceOperation.REMINDER) -> "SKIPPED"
|
||||
operations.first == RaceOperation.REMINDER -> "CANCELED"
|
||||
else -> "REJECTED"
|
||||
}
|
||||
assertEquals(expectedWinner, winner.get(10, TimeUnit.SECONDS))
|
||||
assertEquals(expectedContender, contender.get(10, TimeUnit.SECONDS))
|
||||
val trackingWon = operations.first == RaceOperation.TRACKING
|
||||
assertEquals(
|
||||
if (trackingWon) GiftStatus.TRACKING_REGISTERED else GiftStatus.CANCELED,
|
||||
giftRepository.findByApplicationNo(APPLICATION_NO)?.status
|
||||
)
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id)))
|
||||
assertEquals(!trackingWon, delivery.canceledAt != null)
|
||||
assertEquals(trackingWon, delivery.trackingRegisteredAt != null)
|
||||
assertEquals(operations.first == RaceOperation.REMINDER, delivery.trackingDeadlineReminderSentAt != null)
|
||||
assertRefundState(fixture, refunded = !trackingWon)
|
||||
Mockito.verify(giftPushService, Mockito.times(if (operations.first == RaceOperation.AUTO) 1 else 0))
|
||||
.sendAutoCanceledToSender(APPLICATION_NO, requireNotNull(fixture.sender.id))
|
||||
} finally {
|
||||
releaseWinner.countDown()
|
||||
executor.shutdown()
|
||||
if (!executor.awaitTermination(15, TimeUnit.SECONDS)) {
|
||||
executor.shutdownNow()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS)
|
||||
contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeRaceOperation(operation: RaceOperation, fixture: FundedGift): String {
|
||||
val requestAttributes = ServletRequestAttributes(MockHttpServletRequest())
|
||||
RequestContextHolder.setRequestAttributes(requestAttributes)
|
||||
return try {
|
||||
when (operation) {
|
||||
RaceOperation.ADMIN -> {
|
||||
val result = mockMvc.perform(cancelRequest(fixture)).andExpect(status().isOk).andReturn()
|
||||
if (objectMapper.readTree(result.response.contentAsByteArray).path("success").asBoolean()) {
|
||||
"CANCELED"
|
||||
} else {
|
||||
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
|
||||
"REJECTED"
|
||||
}
|
||||
}
|
||||
RaceOperation.USER -> commandService.cancelGift(fixture.sender, APPLICATION_NO).status.name
|
||||
RaceOperation.AUTO -> if (scheduler.cancelExpiredTrackingRegistrationGifts(NOW) == 1) "CANCELED" else "SKIPPED"
|
||||
RaceOperation.REMINDER -> {
|
||||
if (scheduler.sendDeadlineReminderGifts(NOW.minusHours(1)) == 1) "REMINDED" else "SKIPPED"
|
||||
}
|
||||
RaceOperation.TRACKING -> commandService.registerTracking(
|
||||
fixture.sender,
|
||||
APPLICATION_NO,
|
||||
GiftTrackingRegistrationCommand("CJ대한통운", "1234567890")
|
||||
).status.name
|
||||
}
|
||||
} catch (exception: SodaException) {
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
"REJECTED"
|
||||
} finally {
|
||||
requestAttributes.requestCompleted()
|
||||
RequestContextHolder.resetRequestAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelRequest(fixture: FundedGift) = post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(
|
||||
authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(fixture.admin),
|
||||
"test",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private fun tokenFor(member: Member): String = tokenProvider.createToken(
|
||||
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))),
|
||||
requireNotNull(member.id)
|
||||
)
|
||||
|
||||
private fun giftHttp(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
token: String? = null,
|
||||
expectedStatus: HttpStatus = HttpStatus.OK
|
||||
): JsonNode {
|
||||
val headers = HttpHeaders().apply {
|
||||
token?.let { setBearerAuth(it) }
|
||||
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
}
|
||||
val response = restTemplate.exchange(
|
||||
"http://127.0.0.1:$port$path",
|
||||
method,
|
||||
HttpEntity<Void>(headers),
|
||||
String::class.java
|
||||
)
|
||||
val body = objectMapper.readTree(response.body.orEmpty().ifBlank { "{}" })
|
||||
val evidence = body.deepCopy<ObjectNode>()
|
||||
val data = evidence.path("data")
|
||||
if (method == HttpMethod.GET && data.isObject) {
|
||||
(data as ObjectNode).retain("applicationNo", "status", "availableActions", "totalCount", "items")
|
||||
data.path("items").forEach { (it as ObjectNode).retain("applicationNo", "status", "availableActions") }
|
||||
}
|
||||
println("GIFT_HTTP $method $path status=${response.statusCodeValue} body=$evidence")
|
||||
assertEquals(expectedStatus, response.statusCode)
|
||||
return body
|
||||
}
|
||||
|
||||
private fun saveFundedGift(): FundedGift = requireNotNull(
|
||||
TransactionTemplate(transactionManager).execute {
|
||||
val sender = memberRepository.save(
|
||||
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "funded-sender").apply {
|
||||
pgChargeCan = 100
|
||||
pgRewardCan = 20
|
||||
}
|
||||
)
|
||||
val admin = memberRepository.save(
|
||||
Member(
|
||||
email = "${UUID.randomUUID()}@test.com",
|
||||
password = "password",
|
||||
nickname = "cancel-admin",
|
||||
role = MemberRole.ADMIN
|
||||
).apply {
|
||||
pgChargeCan = 50
|
||||
pgRewardCan = 7
|
||||
}
|
||||
)
|
||||
val funding = chargeRepository.save(
|
||||
Charge(100, 20).apply {
|
||||
member = sender
|
||||
payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
}
|
||||
)
|
||||
val usageId = canPaymentService.spendGiftCan(requireNotNull(sender.id), 80, "web")
|
||||
val category = categoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "FUNDED",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어"
|
||||
)
|
||||
)
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = requireNotNull(sender.id),
|
||||
recipientMemberId = requireNotNull(admin.id),
|
||||
status = GiftStatus.RECEIVED,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = requireNotNull(category.id),
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = usageId,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW.minusDays(4),
|
||||
senderPrivacyAgreedAt = NOW.minusDays(4)
|
||||
)
|
||||
)
|
||||
giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.minusSeconds(1)
|
||||
)
|
||||
)
|
||||
FundedGift(gift, sender, admin, requireNotNull(funding.id))
|
||||
}
|
||||
)
|
||||
|
||||
private fun assertRefundState(fixture: FundedGift, refunded: Boolean) {
|
||||
TransactionTemplate(transactionManager).executeWithoutResult {
|
||||
val sender = memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow()
|
||||
val admin = memberRepository.findById(requireNotNull(fixture.admin.id)).orElseThrow()
|
||||
assertEquals(40, sender.pgChargeCan)
|
||||
assertEquals(if (refunded) 80 else 0, sender.pgRewardCan)
|
||||
assertEquals(50, admin.pgChargeCan)
|
||||
assertEquals(7, admin.pgRewardCan)
|
||||
val usage = useCanRepository.findById(fixture.gift.canUsageId).orElseThrow()
|
||||
assertEquals(CanUsage.GIFT, usage.canUsage)
|
||||
assertEquals(60, usage.can)
|
||||
assertEquals(20, usage.rewardCan)
|
||||
assertEquals(fixture.sender.id, usage.member?.id)
|
||||
assertEquals(refunded, usage.isRefund)
|
||||
val calculations = calculateRepository.findByUseCanIdAndStatus(
|
||||
fixture.gift.canUsageId,
|
||||
if (refunded) UseCanCalculateStatus.REFUND else UseCanCalculateStatus.RECEIVED
|
||||
)
|
||||
assertEquals(1, calculations.size)
|
||||
assertEquals(80, calculations.sumOf { it.can })
|
||||
val refunds = chargeRepository.findAll().filter { it.useCan?.id == fixture.gift.canUsageId }
|
||||
assertEquals(if (refunded) 1 else 0, refunds.size)
|
||||
assertEquals(if (refunded) 80 else 0, refunds.sumOf { it.rewardCan })
|
||||
refunds.forEach {
|
||||
assertEquals(ChargeStatus.REFUND_CHARGE, it.status)
|
||||
assertEquals(0, it.chargeCan)
|
||||
assertEquals(fixture.sender.id, it.member?.id)
|
||||
assertEquals(PaymentGateway.PG, it.payment?.paymentGateway)
|
||||
assertEquals(PaymentStatus.COMPLETE, it.payment?.status)
|
||||
}
|
||||
val funding = chargeRepository.findById(fixture.fundingId).orElseThrow()
|
||||
assertEquals(40, funding.chargeCan)
|
||||
assertEquals(0, funding.rewardCan)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FundedGift(val gift: Gift, val sender: Member, val admin: Member, val fundingId: Long)
|
||||
|
||||
enum class RaceOperation { ADMIN, USER, AUTO, TRACKING, REMINDER }
|
||||
|
||||
private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
@@ -445,6 +971,19 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@JvmStatic
|
||||
fun cancellationRaces() = listOf(
|
||||
RaceOperation.ADMIN to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.USER,
|
||||
RaceOperation.USER to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.AUTO,
|
||||
RaceOperation.AUTO to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.TRACKING,
|
||||
RaceOperation.TRACKING to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.REMINDER,
|
||||
RaceOperation.REMINDER to RaceOperation.ADMIN
|
||||
)
|
||||
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
|
||||
@@ -546,7 +546,7 @@ class GiftCommandServiceTest {
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift)
|
||||
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(cancellationGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery)
|
||||
}
|
||||
|
||||
@@ -579,7 +579,7 @@ class GiftCommandServiceTest {
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift)
|
||||
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(trackingGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
trackingDelivery.takeIf { deliveryExists }
|
||||
)
|
||||
|
||||
+14
-20
@@ -125,7 +125,7 @@ class GiftAdminQueryServiceTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 선물 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다")
|
||||
@DisplayName("관리자 선물 목록과 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다")
|
||||
fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() {
|
||||
val sender = saveMember("fan-alpha")
|
||||
val recipient = saveMember("creator-alpha")
|
||||
@@ -136,26 +136,20 @@ class GiftAdminQueryServiceTest @Autowired constructor(
|
||||
|
||||
val service = service()
|
||||
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930100").availableActions)
|
||||
assertEquals(
|
||||
listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"),
|
||||
service.getGiftDetail("A-100260930101").availableActions
|
||||
val expectedActions = mapOf(
|
||||
GiftStatus.RECEIVED to listOf("CANCEL"),
|
||||
GiftStatus.TRACKING_REGISTERED to listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"),
|
||||
GiftStatus.ARRIVED_AT_MAILBOX to listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"),
|
||||
GiftStatus.INSPECTION_COMPLETED to listOf("MARK_UNDELIVERABLE"),
|
||||
GiftStatus.DELIVERED to emptyList(),
|
||||
GiftStatus.UNDELIVERABLE to emptyList(),
|
||||
GiftStatus.CANCELED to emptyList()
|
||||
)
|
||||
assertEquals(
|
||||
listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"),
|
||||
service.getGiftDetail("A-100260930102").availableActions
|
||||
)
|
||||
assertEquals(
|
||||
listOf("MARK_UNDELIVERABLE"),
|
||||
service.getGiftDetail("A-100260930103").availableActions
|
||||
)
|
||||
assertEquals(
|
||||
listOf("MARK_UNDELIVERABLE"),
|
||||
service.getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single().availableActions
|
||||
)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930104").availableActions)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930105").availableActions)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930106").availableActions)
|
||||
expectedActions.forEach { (status, actions) ->
|
||||
val item = service.getGifts(status, null, null, 0, 20).items.single()
|
||||
assertEquals(actions, service.getGiftDetail(item.applicationNo).availableActions, status.name)
|
||||
assertEquals(actions, item.availableActions, status.name)
|
||||
}
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.getGiftDetail("UNKNOWN")
|
||||
}
|
||||
|
||||
+65
-2
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
@@ -17,12 +18,14 @@ import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.EnumSource
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
@@ -37,16 +40,19 @@ class GiftAdminServiceTest @Autowired constructor(
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val giftMailboxRepository: GiftMailboxRepository
|
||||
private val giftMailboxRepository: GiftMailboxRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
private val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
private val canPaymentService = Mockito.mock(CanPaymentService::class.java)
|
||||
private val service = GiftAdminService(
|
||||
categoryRepository,
|
||||
sizePriceRepository,
|
||||
giftRepository,
|
||||
giftDeliveryRepository,
|
||||
giftMailboxRepository,
|
||||
giftPushService
|
||||
giftPushService,
|
||||
canPaymentService
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -312,6 +318,63 @@ class GiftAdminServiceTest @Autowired constructor(
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 접수 선물을 취소하고 원 발신자의 결제 내역을 한 번 환불한다")
|
||||
fun shouldCancelReceivedGiftWithOriginalSenderRefund() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.RECEIVED)
|
||||
|
||||
val result = service.cancelGift(APPLICATION_NO, NOW)
|
||||
|
||||
assertEquals(GiftOperationStatusResult(APPLICATION_NO, GiftStatus.CANCELED, "신청 취소", NOW), result)
|
||||
assertEquals(GiftStatus.CANCELED, gift.status)
|
||||
assertEquals(NOW, delivery.canceledAt)
|
||||
assertThrows(SodaException::class.java) { service.cancelGift(APPLICATION_NO, NOW.plusSeconds(1)) }
|
||||
assertEquals(NOW, delivery.canceledAt)
|
||||
Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, 10L)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = [0, 123456789, 999999999])
|
||||
@DisplayName("관리자 취소 시각은 나노초 입력에도 초 단위 저장값과 응답이 일치한다")
|
||||
fun shouldPersistAndReturnSameSecondPrecisionCancellationTime(nanos: Int) {
|
||||
val (gift, _) = saveGift(GiftStatus.RECEIVED)
|
||||
val giftId = requireNotNull(gift.id)
|
||||
val now = LocalDateTime.of(2026, 10, 22, 23, 59, 59, nanos)
|
||||
|
||||
val result = service.cancelGift(APPLICATION_NO, now)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val persisted = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)?.canceledAt)
|
||||
assertEquals(persisted, result.occurredAt)
|
||||
assertEquals(LocalDateTime.of(2026, 10, 22, 23, 59, 59), persisted)
|
||||
assertEquals(GiftStatus.CANCELED, giftRepository.findById(giftId).orElseThrow().status)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE)
|
||||
@DisplayName("운영자는 접수가 아닌 모든 상태의 선물을 취소할 수 없다")
|
||||
fun shouldRejectCancellationForEveryNonReceivedStatus(status: GiftStatus) {
|
||||
val (gift, delivery) = saveGift(status)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) { service.cancelGift(APPLICATION_NO, NOW) }
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(status, gift.status)
|
||||
assertEquals(null, delivery.canceledAt)
|
||||
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 없는 선물을 취소할 수 없다")
|
||||
fun shouldRejectCancellationForMissingGift() {
|
||||
val exception = assertThrows(SodaException::class.java) { service.cancelGift("missing", NOW) }
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
|
||||
}
|
||||
|
||||
private fun populateRecipientInformation(delivery: GiftDelivery) {
|
||||
delivery.recipientName = "김소다"
|
||||
delivery.recipientPhoneNumber = "01098765432"
|
||||
|
||||
Reference in New Issue
Block a user