diff --git a/.gitignore b/.gitignore index c2065bc2..440adc2e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +src/main/resources/static/docs/ diff --git a/src/docs/asciidoc/auth.adoc b/src/docs/asciidoc/auth.adoc index d2297554..af3052f5 100644 --- a/src/docs/asciidoc/auth.adoc +++ b/src/docs/asciidoc/auth.adoc @@ -6,7 +6,7 @@ 비회원의 토큰을 발급받을 수 있습니다. -- 비회원의 토큰 만료일: 토큰 생성일로부터 1달 +- 비회원 토큰의 만료일은 발급일로부터 1개월입니다. === Example @@ -30,7 +30,7 @@ include::{snippets}/auth-controller-test/get-guest-token/response-body.adoc[] == 액세스 토큰 재발급 -refreshToken을 사용해 accessToken을 재발급 받을 수 있습니다. +`refreshToken`을 사용해 `accessToken`을 재발급받을 수 있습니다. === Example @@ -54,8 +54,8 @@ include::{snippets}/auth-controller-test/reissue-access-token/response-body.adoc == 카카오톡 소셜 로그인 -사용자가 카카오 소셜 로그인을 완료하면, 인가 코드를 통해 카카오 Access Token을 발급받고, 이를 이용해 카카오 사용자 정보를 조회합니다. -조회된 사용자 정보로 서비스의 Access Token을 생성한 후, 해당 토큰은 쿠키를 통해 클라이언트에 전달됩니다. +사용자가 카카오 소셜 로그인을 완료하면 인가 코드를 통해 카카오 `Access Token`을 발급받습니다. +발급된 토큰으로 카카오 사용자 정보를 조회한 뒤, 서비스의 `Access Token`을 생성해 쿠키로 전달합니다. === Example @@ -79,7 +79,7 @@ include::{snippets}/auth-controller-test/kakao-login-callback/response-body.adoc == 로그아웃 -서비스와 카카오 로그아웃을 처리합니다. +서비스 로그아웃과 카카오 로그아웃을 처리할 수 있습니다. === Example @@ -103,7 +103,7 @@ include::{snippets}/auth-controller-test/kakao-logout/response-body.adoc[] == 카카오 탈퇴 -카카오 소셜로그인 사용자의 서비스 탈퇴 처리합니다. +카카오 소셜 로그인 사용자의 서비스 탈퇴를 처리할 수 있습니다. === Example @@ -154,4 +154,3 @@ include::{snippets}/auth-controller-test/check-auth-success/response-body.adoc[] include::{snippets}/auth-controller-test/check-auth-expired/response-body.adoc[] include::{snippets}/auth-controller-test/check-auth-invalid-token/response-body.adoc[] - diff --git a/src/docs/asciidoc/character.adoc b/src/docs/asciidoc/character.adoc index 47d88201..b84c1dcc 100644 --- a/src/docs/asciidoc/character.adoc +++ b/src/docs/asciidoc/character.adoc @@ -6,14 +6,9 @@ 모임의 캐릭터를 조회할 수 있습니다. -- ★: -러키 모또 - -- ★★: -천사 모또 / 딸기 또또 - -- ★★★: -마법사 또또 / 잠꾸러기 또또 +- ★: 러키 모또 +- ★★: 천사 모또 / 딸기 또또 +- ★★★: 마법사 또또 / 잠꾸러기 또또 === Example @@ -36,35 +31,10 @@ include::{snippets}/character-controller-test/get-character-success/http-respons include::{snippets}/character-controller-test/get-character-success/response-body.adoc[] -==== 응답 - 유효하지 않은 GroupToken +==== 응답 - 유효하지 않은 그룹 토큰 include::{snippets}/character-controller-test/get-character-invalid-token/response-body.adoc[] -==== 응답 - 누락된 GroupToken +==== 응답 - 누락된 그룹 토큰 include::{snippets}/character-controller-test/get-character-missing-token/response-body.adoc[] - -== 도감 조회 - -사용자의 캐릭터 수집 현황을 조회할 수 있습니다. - -=== Example - -include::{snippets}/collection-controller-test/get-my-collections/curl-request.adoc[] - -=== HTTP - -==== 요청 - -include::{snippets}/collection-controller-test/get-my-collections/http-request.adoc[] - -==== 응답 - -include::{snippets}/collection-controller-test/get-my-collections/http-response.adoc[] - -=== Body - -==== 응답 - -include::{snippets}/collection-controller-test/get-my-collections/response-body.adoc[] - diff --git a/src/docs/asciidoc/collection.adoc b/src/docs/asciidoc/collection.adoc new file mode 100644 index 00000000..e155b6ca --- /dev/null +++ b/src/docs/asciidoc/collection.adoc @@ -0,0 +1,27 @@ += 도감 (Collection) +:toc: left +:toclevels: 2 + +== 도감 조회 + +로그인한 사용자의 캐릭터 수집 현황을 조회할 수 있습니다. + +=== Example + +include::{snippets}/collection-controller-test/get-my-collections/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/collection-controller-test/get-my-collections/http-request.adoc[] + +==== 응답 + +include::{snippets}/collection-controller-test/get-my-collections/http-response.adoc[] + +=== Body + +==== 응답 + +include::{snippets}/collection-controller-test/get-my-collections/response-body.adoc[] diff --git a/src/docs/asciidoc/expense.adoc b/src/docs/asciidoc/expense.adoc index eb04520a..06fc61c6 100644 --- a/src/docs/asciidoc/expense.adoc +++ b/src/docs/asciidoc/expense.adoc @@ -16,6 +16,8 @@ include::{snippets}/expense-controller-test/save-expenses-success/curl-request.a include::{snippets}/expense-controller-test/save-expenses-success/http-request.adoc[] +include::{snippets}/expense-controller-test/create-expense/path-parameters.adoc[] + ==== 응답 include::{snippets}/expense-controller-test/save-expenses-success/http-response.adoc[] @@ -38,9 +40,8 @@ include::{snippets}/expense-controller-test/save-expenses-fail_when-member-not-f 모임의 전체 지출 내역을 조회할 수 있습니다. -- 조회시 날짜를 기준으로 오름차순으로 조회됩니다. - -- 참여자의 지출 내역까지 함께 조회됩니다. +- 날짜 기준 오름차순으로 조회됩니다. +- 참여자별 지출 내역도 함께 조회됩니다. === Example @@ -62,8 +63,6 @@ include::{snippets}/expense-controller-test/get-all-by-settlement-id-success/htt include::{snippets}/expense-controller-test/get-all-by-settlement-id-success/response-body.adoc[] -==== 응답 - - == 단일 지출 내역 조회 지출 내역 하나를 조회할 수 있습니다. @@ -78,6 +77,8 @@ include::{snippets}/expense-controller-test/get-by-expense-id-success/curl-reque include::{snippets}/expense-controller-test/get-by-expense-id-success/http-request.adoc[] +include::{snippets}/expense-controller-test/get-by-expense-id-success/path-parameters.adoc[] + ==== 응답 include::{snippets}/expense-controller-test/get-by-expense-id-success/http-response.adoc[] @@ -88,17 +89,16 @@ include::{snippets}/expense-controller-test/get-by-expense-id-success/http-respo include::{snippets}/expense-controller-test/get-by-expense-id-success/response-body.adoc[] -==== 응답 - 찾을 수 없는 지출내역 +==== 응답 - 찾을 수 없는 지출 내역 include::{snippets}/expense-controller-test/get-by-expense-id-fail_when-expense-not-found/response-body.adoc[] == 지출 상세 내역 조회 -정산 내역의 전체 정산 내역을 조회합니다. - -- 조회시 날짜를 기준으로 오름차순으로 정렬됩니다. +정산의 전체 지출 상세 내역을 조회할 수 있습니다. -- 참여자의 이름을 List값으로 가져옵니다. +- 날짜 기준 오름차순으로 정렬됩니다. +- 참여자 이름은 목록 형태로 제공됩니다. === Example @@ -110,6 +110,8 @@ include::{snippets}/expense-controller-test/get-expense-details-success/curl-req include::{snippets}/expense-controller-test/get-expense-details-success/http-request.adoc[] +include::{snippets}/expense-controller-test/get-expense-details-success/path-parameters.adoc[] + ==== 응답 include::{snippets}/expense-controller-test/get-expense-details-success/http-response.adoc[] @@ -124,7 +126,7 @@ include::{snippets}/expense-controller-test/get-expense-details-success/response 지출 내역을 수정할 수 있습니다. -- id = expenseId +- 경로의 `expenseId`를 기준으로 수정합니다. === Example @@ -137,6 +139,8 @@ include::{snippets}/expense-controller-test/update-expense-success/httpie-reques include::{snippets}/expense-controller-test/update-expense-success/http-request.adoc[] +include::{snippets}/expense-controller-test/update-expense-success/path-parameters.adoc[] + ==== 응답 include::{snippets}/expense-controller-test/update-expense-success/http-response.adoc[] @@ -151,13 +155,13 @@ include::{snippets}/expense-controller-test/update-expense-success/request-body. include::{snippets}/expense-controller-test/update-expense-success/response-body.adoc[] -==== 응답 - 찾을 수 없는 지출내역 +==== 응답 - 찾을 수 없는 지출 내역 include::{snippets}/expense-controller-test/get-by-expense-id-fail_when-expense-not-found/response-body.adoc[] == 지출 내역 삭제 -지출 내역을 삭제합니다. +지출 내역을 삭제할 수 있습니다. === Example @@ -169,13 +173,15 @@ include::{snippets}/expense-controller-test/delete-expense-success/curl-request. include::{snippets}/expense-controller-test/delete-expense-success/http-request.adoc[] +include::{snippets}/expense-controller-test/delete-expense-success/path-parameters.adoc[] + ==== 응답 include::{snippets}/expense-controller-test/delete-expense-success/http-response.adoc[] == 지출 이미지 URL 수정 -지출 내역별 이미지 URL을 업데이트할 수 있습니다. +지출 내역별 이미지 URL을 수정할 수 있습니다. === Example @@ -187,6 +193,8 @@ include::{snippets}/expense-controller-test/update-img-url-success/curl-request. include::{snippets}/expense-controller-test/update-img-url-success/http-request.adoc[] +include::{snippets}/expense-controller-test/update-img-url-success/path-parameters.adoc[] + ==== 응답 include::{snippets}/expense-controller-test/update-img-url-success/http-response.adoc[] @@ -195,4 +203,4 @@ include::{snippets}/expense-controller-test/update-img-url-success/http-response ==== 요청 -include::{snippets}/expense-controller-test/update-img-url-success/request-body.adoc[] \ No newline at end of file +include::{snippets}/expense-controller-test/update-img-url-success/request-body.adoc[] diff --git a/src/docs/asciidoc/image.adoc b/src/docs/asciidoc/image.adoc index 8a7a5ba9..2d045c1c 100644 --- a/src/docs/asciidoc/image.adoc +++ b/src/docs/asciidoc/image.adoc @@ -4,7 +4,7 @@ == 이미지 임시 저장 -지출내역 작성 완료 전, 임시로 이미지를 저장할 수 있습니다. +지출 내역 작성 완료 전에 이미지를 임시 저장할 수 있습니다. === Example @@ -28,7 +28,7 @@ include::{snippets}/image-controller-test/save-temp-image-success/response-body. == 이미지 실제 저장 -지출내역 작성 후, 임시로 저장된 이미지를 실제 폴더에 업로드할 수 있습니다. +지출 내역 작성 후 임시 저장된 이미지를 실제 폴더에 업로드할 수 있습니다. === Example @@ -48,4 +48,4 @@ include::{snippets}/image-controller-test/update-image-success/http-response.ado ==== 응답 -include::{snippets}/image-controller-test/update-image-success/response-body.adoc[] \ No newline at end of file +include::{snippets}/image-controller-test/update-image-success/response-body.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 069fe01a..80b8468c 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -14,6 +14,8 @@ include::user.adoc[] include::character.adoc[] +include::collection.adoc[] + include::expense.adoc[] include::settlement.adoc[] diff --git a/src/docs/asciidoc/member.adoc b/src/docs/asciidoc/member.adoc index 22e9ae6e..53886e3a 100644 --- a/src/docs/asciidoc/member.adoc +++ b/src/docs/asciidoc/member.adoc @@ -1,16 +1,47 @@ -= 모임원 (AppointmentMember) += 모임원 (Member) :toc: left :toclevels: 2 -== 모임원 추가 +== 모임원 조회 + +정산에 속한 전체 모임원을 조회할 수 있습니다. + +- `userId`는 해당 모임원에 연결된 사용자 ID입니다. +- 아직 로그인 사용자가 선택하지 않은 참여자는 `userId`가 `null`로 내려갑니다. +- `sortType`으로 정렬 기준을 지정할 수 있으며 기본값은 `CREATED`입니다. + +=== Example + +include::{snippets}/member-controller-test/get-appointment-members/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/member-controller-test/get-appointment-members/http-request.adoc[] + +include::{snippets}/member-controller-test/get-appointment-members/path-parameters.adoc[] + +include::{snippets}/member-controller-test/get-appointment-members/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/member-controller-test/get-appointment-members/http-response.adoc[] + +=== Body -기존 모임 참여자에 새로운 모임 참여자를 추가할 수 있습니다. +==== 응답 + +include::{snippets}/member-controller-test/get-appointment-members/response-body.adoc[] -Enum +== 모임원 추가 -- MANAGER: 총무 +기존 정산에 새로운 모임원을 추가할 수 있습니다. -- PARTICIPANT: 참여자 +역할(Enum) + +- `MANAGER`: 총무 +- `PARTICIPANT`: 참여자 === Example @@ -22,6 +53,8 @@ include::{snippets}/member-controller-test/save-appointment-member/curl-request. include::{snippets}/member-controller-test/save-appointment-member/http-request.adoc[] +include::{snippets}/member-controller-test/save-appointment-member/path-parameters.adoc[] + ==== 응답 include::{snippets}/member-controller-test/save-appointment-member/http-response.adoc[] @@ -36,6 +69,71 @@ include::{snippets}/member-controller-test/save-appointment-member/request-body. include::{snippets}/member-controller-test/save-appointment-member/response-body.adoc[] +== 참여자 선택 + +로그인 사용자가 아직 선택되지 않은 참여자를 선택할 수 있습니다. + +- 요청 body에 선택할 `memberId`를 전달합니다. +- 한 사용자는 같은 정산에서 하나의 참여자만 선택할 수 있습니다. + +=== Example + +include::{snippets}/member-controller-test/assign-member/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/member-controller-test/assign-member/http-request.adoc[] + +include::{snippets}/member-controller-test/assign-member/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/member-controller-test/assign-member/http-response.adoc[] + +=== Body + +==== 요청 + +include::{snippets}/member-controller-test/assign-member/request-body.adoc[] + +==== 응답 + +include::{snippets}/member-controller-test/assign-member/response-body.adoc[] + +== 참여자 선택 해제 + +로그인 사용자가 본인이 선택한 참여자를 해제할 수 있습니다. + +- 요청 body에 해제할 `memberId`를 전달합니다. + +=== Example + +include::{snippets}/member-controller-test/unassign-member/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/member-controller-test/unassign-member/http-request.adoc[] + +include::{snippets}/member-controller-test/unassign-member/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/member-controller-test/unassign-member/http-response.adoc[] + +=== Body + +==== 요청 + +include::{snippets}/member-controller-test/unassign-member/request-body.adoc[] + +==== 응답 + +include::{snippets}/member-controller-test/unassign-member/response-body.adoc[] + == 결제 상태 변경 모임원의 결제 상태를 변경할 수 있습니다. @@ -50,6 +148,8 @@ include::{snippets}/member-controller-test/update-payment-status/curl-request.ad include::{snippets}/member-controller-test/update-payment-status/http-request.adoc[] +include::{snippets}/member-controller-test/update-payment-status/path-parameters.adoc[] + ==== 응답 include::{snippets}/member-controller-test/update-payment-status/http-response.adoc[] @@ -66,9 +166,9 @@ include::{snippets}/member-controller-test/update-payment-status/response-body.a == 모임원 삭제 -참여자Id를 통해 참여자를 삭제할 수 있습니다. +참여자 ID로 모임원을 삭제할 수 있습니다. -- 단, 총무 참여자의 경우 삭제할 수 없습니다. +- 총무(`MANAGER`)는 삭제할 수 없습니다. === Example @@ -80,6 +180,8 @@ include::{snippets}/member-controller-test/delete-appointment-member/curl-reques include::{snippets}/member-controller-test/delete-appointment-member/http-request.adoc[] +include::{snippets}/member-controller-test/delete-appointment-member/path-parameters.adoc[] + ==== 응답 include::{snippets}/member-controller-test/delete-appointment-member/http-response.adoc[] diff --git a/src/docs/asciidoc/memberExpenses.adoc b/src/docs/asciidoc/memberExpenses.adoc index 64072d34..c53d43cd 100644 --- a/src/docs/asciidoc/memberExpenses.adoc +++ b/src/docs/asciidoc/memberExpenses.adoc @@ -4,9 +4,9 @@ == 모임원별 상세 지출 내역 조회 -받을 정산 내역의 참여자별 정산 내역 조회 기능입니다. +받을 정산 금액 기준으로 참여자별 상세 지출 내역을 조회할 수 있습니다. -- 입금순, 이름순으로 정렬되어 조회됩니다. +- 입금 순, 이름 순으로 정렬되어 조회됩니다. === Example @@ -28,4 +28,3 @@ include::{snippets}/member-expense-controller-test/get-member-expenses-details-s include::{snippets}/member-expense-controller-test/get-member-expenses-details-success/response-body.adoc[] - diff --git a/src/docs/asciidoc/settlement.adoc b/src/docs/asciidoc/settlement.adoc index bb553f75..88e7983f 100644 --- a/src/docs/asciidoc/settlement.adoc +++ b/src/docs/asciidoc/settlement.adoc @@ -6,13 +6,10 @@ 모임을 생성할 수 있습니다. -- 모임을 생성하는 사용자의 accessToken을 넣어준다. - -- 만들고자하는 모임의 이름과 비밀번호를 넣어준다. - -- 생성된 모임의 Id, 생성자(정산 담당자)의 Id, 생성 시간, 만료 시간, 계좌 여부를 알 수 있다. - -- 비회원이 생성한 모임은 1달 후 자동 삭제된다. +- 모임을 생성하는 사용자의 `accessToken`이 필요합니다. +- 생성할 모임의 이름을 요청 본문에 포함합니다. +- 생성된 모임의 ID, 생성자(정산 담당자) ID, 생성 시간, 만료 시간, 계좌 정보를 확인할 수 있습니다. +- 비회원이 생성한 모임은 1개월 후 자동 삭제됩니다. === Example @@ -40,7 +37,7 @@ include::{snippets}/settlement-controller-test/save-settlement/response-body.ado == 계좌 추가 -은행과 계좌 정보를 추가할 수 있습니다. +은행과 계좌 정보를 추가하거나 수정할 수 있습니다. === Example @@ -52,6 +49,8 @@ include::{snippets}/settlement-controller-test/update-account/curl-request.adoc[ include::{snippets}/settlement-controller-test/update-account/http-request.adoc[] +include::{snippets}/settlement-controller-test/update-account/path-parameters.adoc[] + ==== 응답 include::{snippets}/settlement-controller-test/update-account/http-response.adoc[] @@ -68,7 +67,7 @@ include::{snippets}/settlement-controller-test/update-account/response-body.adoc == 모임 조회 -모임과 참가자를 조회할 수 있습니다. +모임 정보와 참여자 목록을 조회할 수 있습니다. === Example @@ -80,6 +79,8 @@ include::{snippets}/settlement-controller-test/get-settlement/curl-request.adoc[ include::{snippets}/settlement-controller-test/get-settlement/http-request.adoc[] +include::{snippets}/settlement-controller-test/get-settlement/path-parameters.adoc[] + ==== 응답 include::{snippets}/settlement-controller-test/get-settlement/http-response.adoc[] @@ -92,7 +93,7 @@ include::{snippets}/settlement-controller-test/get-settlement/response-body.adoc == 모임 상단 조회 -지출 내역의 상단 부분을 조회할 수 있습니다. +지출 내역 화면의 상단 정보를 조회할 수 있습니다. === Example @@ -104,6 +105,8 @@ include::{snippets}/settlement-controller-test/get-header/curl-request.adoc[] include::{snippets}/settlement-controller-test/get-header/http-request.adoc[] +include::{snippets}/settlement-controller-test/get-header/path-parameters.adoc[] + ==== 응답 include::{snippets}/settlement-controller-test/get-header/http-response.adoc[] @@ -116,7 +119,7 @@ include::{snippets}/settlement-controller-test/get-header/response-body.adoc[] == 모임(정산) 리스트 조회 -user가 속한 정산의 리스트를 상태별로 조회할 수 있습니다. +사용자가 속한 정산 목록을 상태별로 조회할 수 있습니다. === Example @@ -142,7 +145,7 @@ include::{snippets}/settlement-controller-test/search-settlement-list/response-b == 공유 링크 리스트 조회 -user가 속한 정산의 공유 링크 리스트를 조회할 수 있다. +사용자가 속한 정산의 공유 링크 목록을 조회할 수 있습니다. === Example @@ -162,4 +165,4 @@ include::{snippets}/settlement-controller-test/get-share-link-list-success/http- ==== 응답 -include::{snippets}/settlement-controller-test/get-share-link-list-success/response-body.adoc[] \ No newline at end of file +include::{snippets}/settlement-controller-test/get-share-link-list-success/response-body.adoc[] diff --git a/src/docs/asciidoc/user.adoc b/src/docs/asciidoc/user.adoc index 80b9e2f1..c95a27e5 100644 --- a/src/docs/asciidoc/user.adoc +++ b/src/docs/asciidoc/user.adoc @@ -4,6 +4,8 @@ == 사용자 정보 조회 +로그인한 사용자의 정보를 조회할 수 있습니다. + === Example include::{snippets}/user-controller-test/get-user/curl-request.adoc[] @@ -26,4 +28,4 @@ include::{snippets}/user-controller-test/get-user/request-body.adoc[] ==== 응답 -include::{snippets}/user-controller-test/get-user/response-body.adoc[] \ No newline at end of file +include::{snippets}/user-controller-test/get-user/response-body.adoc[] diff --git a/src/main/java/com/dnd/moddo/common/support/aop/GroupPermissionAspect.java b/src/main/java/com/dnd/moddo/common/support/aop/GroupPermissionAspect.java index 323675f6..17ad01d6 100644 --- a/src/main/java/com/dnd/moddo/common/support/aop/GroupPermissionAspect.java +++ b/src/main/java/com/dnd/moddo/common/support/aop/GroupPermissionAspect.java @@ -29,9 +29,9 @@ public class GroupPermissionAspect { public void checkPermission(JoinPoint joinPoint, VerifyManagerPermission verifyManagerPermission) { Long userId = extractUserId(); - String groupToken = extractGroupToken(joinPoint.getArgs()); + String code = extractCode(joinPoint.getArgs()); - Long settlementId = querySettlementService.findIdByCode(groupToken); + Long settlementId = querySettlementService.findIdByCode(code); if (!isAuthorized(userId, settlementId)) { throw new UserPermissionException(); @@ -50,13 +50,13 @@ private Long extractUserId() { return authDetails.getUserId(); } - private String extractGroupToken(Object[] args) { + private String extractCode(Object[] args) { for (Object arg : args) { if (arg instanceof String token) { return token; } } - throw new IllegalArgumentException("groupToken parameter not found"); + throw new IllegalArgumentException("code parameter not found"); } private boolean isAuthorized(Long userId, Long settlementId) { diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java b/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java index 4219c383..2a4f715d 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java @@ -51,6 +51,16 @@ public MemberResponse updatePaymentStatus(Long appointmentMemberId, PaymentStatu return MemberResponse.of(member); } + public MemberResponse assignMember(Long settlementId, Long memberId, Long userId) { + Member member = memberUpdater.assignMember(settlementId, memberId, userId); + return MemberResponse.of(member); + } + + public MemberResponse unassignMember(Long settlementId, Long memberId, Long userId) { + Member member = memberUpdater.unassignMember(settlementId, memberId, userId); + return MemberResponse.of(member); + } + public void delete(Long appointmentMemberId) { memberDeleter.delete(appointmentMemberId); } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java index bd7362f4..a1abe694 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java @@ -6,6 +6,8 @@ import org.springframework.transaction.annotation.Transactional; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.type.MemberSortType; +import com.dnd.moddo.event.infrastructure.MemberQueryRepository; import com.dnd.moddo.event.infrastructure.MemberRepository; import lombok.RequiredArgsConstructor; @@ -15,9 +17,14 @@ @Transactional(readOnly = true) public class MemberReader { private final MemberRepository memberRepository; + private final MemberQueryRepository memberQueryRepository; public List findAllBySettlementId(Long settlementId) { - return memberRepository.findBySettlementId(settlementId); + return findAllBySettlementId(settlementId, MemberSortType.CREATED); + } + + public List findAllBySettlementId(Long settlementId, MemberSortType sortType) { + return memberQueryRepository.findAllBySettlementId(settlementId, sortType); } public Member findByAppointmentMemberId(Long appointmentMemberId) { @@ -25,7 +32,7 @@ public Member findByAppointmentMemberId(Long appointmentMemberId) { } public List findIdsBySettlementId(Long settlementId) { - return memberRepository.findAppointmentMemberIdsBySettlementId(settlementId); + return memberRepository.findMemberIdsBySettlementId(settlementId); } } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java index ee2fb11e..40e21910 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java @@ -10,11 +10,19 @@ import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.exception.InvalidMemberException; +import com.dnd.moddo.event.domain.member.exception.MemberAlreadyAssignedException; +import com.dnd.moddo.event.domain.member.exception.MemberNotAssignedException; +import com.dnd.moddo.event.domain.member.exception.MemberSelectionNotAllowedException; +import com.dnd.moddo.event.domain.member.exception.MemberSelectionUnauthorizedException; import com.dnd.moddo.event.domain.member.exception.PaymentConcurrencyException; +import com.dnd.moddo.event.domain.member.exception.UserAlreadyAssignedException; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; import com.dnd.moddo.event.presentation.request.PaymentStatusUpdateRequest; +import com.dnd.moddo.user.domain.User; +import com.dnd.moddo.user.infrastructure.UserRepository; import lombok.RequiredArgsConstructor; @@ -25,6 +33,7 @@ public class MemberUpdater { private final MemberReader memberReader; private final MemberValidator memberValidator; private final SettlementReader settlementReader; + private final UserRepository userRepository; @Transactional public Member addToSettlement(Long settlementId, MemberSaveRequest request) { @@ -64,6 +73,40 @@ public Member updatePaymentStatus(Long appointmentMemberId, PaymentStatusUpdateR } } + @Transactional + public Member assignMember(Long settlementId, Long memberId, Long userId) { + Member member = memberRepository.getById(memberId); + validateMemberBelongsToSettlement(member, settlementId); + validateSelectable(member); + + if (memberRepository.existsBySettlementIdAndUserId(settlementId, userId)) { + throw new UserAlreadyAssignedException(userId); + } + if (member.isAssigned()) { + throw new MemberAlreadyAssignedException(memberId); + } + + User user = userRepository.getById(userId); + member.assignUser(user); + return memberRepository.save(member); + } + + @Transactional + public Member unassignMember(Long settlementId, Long memberId, Long userId) { + Member member = memberRepository.getById(memberId); + validateMemberBelongsToSettlement(member, settlementId); + validateSelectable(member); + if (!member.isAssigned()) { + throw new MemberNotAssignedException(memberId); + } + if (!member.isAssignedTo(userId)) { + throw new MemberSelectionUnauthorizedException(memberId); + } + + member.unassignUser(userId); + return memberRepository.save(member); + } + private Integer findAvailableProfileId(List usedProfiles) { for (int i = 1; i <= 8; i++) { if (!usedProfiles.contains(i)) { @@ -73,7 +116,16 @@ private Integer findAvailableProfileId(List usedProfiles) { return (usedProfiles.size() % 8) + 1; } -} - + private void validateMemberBelongsToSettlement(Member member, Long settlementId) { + if (!member.isInSettlement(settlementId)) { + throw new InvalidMemberException(member.getId()); + } + } + private void validateSelectable(Member member) { + if (member.isManager()) { + throw new MemberSelectionNotAllowedException(member.getId()); + } + } +} diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementReader.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementReader.java index 49bae257..2b133253 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/SettlementReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementReader.java @@ -7,12 +7,12 @@ import org.springframework.transaction.annotation.Transactional; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.domain.settlement.type.SettlementSortType; import com.dnd.moddo.event.domain.settlement.type.SettlementStatus; import com.dnd.moddo.event.infrastructure.ExpenseRepository; import com.dnd.moddo.event.infrastructure.MemberQueryRepository; -import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.event.infrastructure.SettlementQueryRepository; import com.dnd.moddo.event.infrastructure.SettlementRepository; import com.dnd.moddo.event.presentation.response.MemberResponse; @@ -26,7 +26,6 @@ @RequiredArgsConstructor public class SettlementReader { private final SettlementRepository settlementRepository; - private final MemberRepository memberRepository; private final ExpenseRepository expenseRepository; private final SettlementQueryRepository settlementQueryRepository; private final MemberQueryRepository memberQueryRepository; @@ -36,7 +35,7 @@ public Settlement read(Long settlementId) { } public List findBySettlement(Long settlementId) { - return memberRepository.findBySettlementId(settlementId); + return memberQueryRepository.findAllBySettlementId(settlementId, MemberSortType.CREATED); } public SettlementHeaderResponse findByHeader(Long settlementId) { diff --git a/src/main/java/com/dnd/moddo/event/application/query/QueryMemberService.java b/src/main/java/com/dnd/moddo/event/application/query/QueryMemberService.java index ec967096..216a384d 100644 --- a/src/main/java/com/dnd/moddo/event/application/query/QueryMemberService.java +++ b/src/main/java/com/dnd/moddo/event/application/query/QueryMemberService.java @@ -6,6 +6,7 @@ import com.dnd.moddo.event.application.impl.MemberReader; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.presentation.response.MembersResponse; import lombok.RequiredArgsConstructor; @@ -15,8 +16,8 @@ public class QueryMemberService { private final MemberReader memberReader; - public MembersResponse findAll(Long settlementId) { - List members = memberReader.findAllBySettlementId(settlementId); + public MembersResponse findAll(Long settlementId, MemberSortType sortType) { + List members = memberReader.findAllBySettlementId(settlementId, sortType); return MembersResponse.of(members); } diff --git a/src/main/java/com/dnd/moddo/event/domain/member/Member.java b/src/main/java/com/dnd/moddo/event/domain/member/Member.java index a787bd81..aedcf418 100644 --- a/src/main/java/com/dnd/moddo/event/domain/member/Member.java +++ b/src/main/java/com/dnd/moddo/event/domain/member/Member.java @@ -1,6 +1,7 @@ package com.dnd.moddo.event.domain.member; import java.time.LocalDateTime; +import java.util.Objects; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.user.domain.User; @@ -70,20 +71,49 @@ public Member(String name, Integer profileId, Settlement settlement, boolean isP } public void assignUser(User user) { + Objects.requireNonNull(user, "연결할 사용자는 필수입니다."); if (this.user != null) { throw new IllegalStateException("이미 사용자와 연결된 멤버입니다."); } this.user = user; - this.name = user.getName(); // 동기화 + } + + public void unassignUser(Long userId) { + if (this.user == null) { + throw new IllegalStateException("연결된 사용자가 없는 멤버입니다."); + } + if (!isAssignedTo(userId)) { + throw new IllegalStateException("본인이 선택한 참여자만 해제할 수 있습니다."); + } + this.user = null; } public boolean isManager() { return ExpenseRole.MANAGER.equals(role); } + public boolean isAssigned() { + return user != null; + } + + public boolean isAssignedTo(Long userId) { + return getUserId() != null && getUserId().equals(userId); + } + + public boolean isInSettlement(Long settlementId) { + return settlement.getId().equals(settlementId); + } + + public Long getUserId() { + if (user == null) { + return null; + } + return user.getId(); + } + public void updatePaymentStatus(Boolean isPaid) { this.isPaid = isPaid; - this.paidAt = Boolean.TRUE.equals(isPaid) ? LocalDateTime.now() : null; + this.paidAt = isPaid ? LocalDateTime.now() : null; } public Long getSettlementId() { @@ -96,4 +126,4 @@ public String getProfileUrl() { } return "https://moddo-s3.s3.amazonaws.com/profile/" + profileId + ".png"; } -} \ No newline at end of file +} diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberAlreadyAssignedException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberAlreadyAssignedException.java new file mode 100644 index 00000000..f190934f --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberAlreadyAssignedException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.member.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class MemberAlreadyAssignedException extends ModdoException { + public MemberAlreadyAssignedException(Long memberId) { + super(HttpStatus.BAD_REQUEST, "이미 다른 사용자가 선택한 참여자입니다. (Member ID: " + memberId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotAssignedException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotAssignedException.java new file mode 100644 index 00000000..3e20b3fa --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotAssignedException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.member.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class MemberNotAssignedException extends ModdoException { + public MemberNotAssignedException(Long memberId) { + super(HttpStatus.BAD_REQUEST, "아직 사용자가 선택하지 않은 참여자입니다. (Member ID: " + memberId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSelectionNotAllowedException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSelectionNotAllowedException.java new file mode 100644 index 00000000..a6be50ee --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSelectionNotAllowedException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.member.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class MemberSelectionNotAllowedException extends ModdoException { + public MemberSelectionNotAllowedException(Long memberId) { + super(HttpStatus.BAD_REQUEST, "선택하거나 해제할 수 없는 참여자입니다. (Member ID: " + memberId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSelectionUnauthorizedException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSelectionUnauthorizedException.java new file mode 100644 index 00000000..2b14ef20 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSelectionUnauthorizedException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.member.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class MemberSelectionUnauthorizedException extends ModdoException { + public MemberSelectionUnauthorizedException(Long memberId) { + super(HttpStatus.FORBIDDEN, "본인이 선택한 참여자만 해제할 수 있습니다. (Member ID: " + memberId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSortTypeNotFoundException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSortTypeNotFoundException.java new file mode 100644 index 00000000..eb2e1baa --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberSortTypeNotFoundException.java @@ -0,0 +1,12 @@ +package com.dnd.moddo.event.domain.member.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class MemberSortTypeNotFoundException extends ModdoException { + public MemberSortTypeNotFoundException(String sortType) { + super(HttpStatus.BAD_REQUEST, + "유효하지 않은 sortType입니다. 허용 값: CREATED, NAME, PAID_AT (입력값: " + sortType + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/UserAlreadyAssignedException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/UserAlreadyAssignedException.java new file mode 100644 index 00000000..27401eae --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/UserAlreadyAssignedException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.member.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class UserAlreadyAssignedException extends ModdoException { + public UserAlreadyAssignedException(Long userId) { + super(HttpStatus.BAD_REQUEST, "이미 이 정산의 다른 참여자를 선택한 사용자입니다. (User ID: " + userId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/member/type/MemberSortType.java b/src/main/java/com/dnd/moddo/event/domain/member/type/MemberSortType.java new file mode 100644 index 00000000..5a12147d --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/member/type/MemberSortType.java @@ -0,0 +1,18 @@ +package com.dnd.moddo.event.domain.member.type; + +import java.util.Arrays; + +import com.dnd.moddo.event.domain.member.exception.MemberSortTypeNotFoundException; + +public enum MemberSortType { + CREATED, + NAME, + PAID_AT; + + public static MemberSortType from(String value) { + return Arrays.stream(values()) + .filter(sortType -> sortType.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new MemberSortTypeNotFoundException(value)); + } +} diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepository.java index 750b4bc0..d905c0fa 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepository.java @@ -3,8 +3,12 @@ import java.util.List; import java.util.Map; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.presentation.response.MemberResponse; public interface MemberQueryRepository { + List findAllBySettlementId(Long settlementId, MemberSortType sortType); + Map> findMembersByIds(List settlementIds); } diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepositoryImpl.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepositoryImpl.java index 612db5a1..ae038a8c 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepositoryImpl.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberQueryRepositoryImpl.java @@ -7,9 +7,13 @@ import org.springframework.stereotype.Repository; import com.dnd.moddo.event.domain.member.ExpenseRole; +import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.member.QMember; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.presentation.response.MemberResponse; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.Getter; @@ -21,6 +25,17 @@ public class MemberQueryRepositoryImpl implements MemberQueryRepository { private final JPAQueryFactory queryFactory; + @Override + public List findAllBySettlementId(Long settlementId, MemberSortType sortType) { + QMember member = QMember.member; + + return queryFactory + .selectFrom(member) + .where(member.settlement.id.eq(settlementId)) + .orderBy(getOrderSpecifiers(member, sortType)) + .fetch(); + } + @Override public Map> findMembersByIds(List settlementIds) { @@ -60,6 +75,30 @@ public Map> findMembersByIds(List settlementIds )); } + private OrderSpecifier[] getOrderSpecifiers(QMember member, MemberSortType sortType) { + OrderSpecifier managerFirst = new CaseBuilder() + .when(member.role.eq(ExpenseRole.MANAGER)).then(0) + .otherwise(1) + .asc(); + + return switch (sortType) { + case NAME -> new OrderSpecifier[] { + managerFirst, + member.name.asc(), + member.id.asc() + }; + case PAID_AT -> new OrderSpecifier[] { + managerFirst, + member.paidAt.asc().nullsLast(), + member.id.asc() + }; + case CREATED -> new OrderSpecifier[] { + managerFirst, + member.id.asc() + }; + }; + } + /** * Repository 내부 전용 Projection */ @@ -79,4 +118,4 @@ public MemberFlatProjection(Long settlementId, Long memberId, ExpenseRole role, this.userId = userId; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java index c0b4be97..c60696a6 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java @@ -11,15 +11,16 @@ public interface MemberRepository extends JpaRepository { - @Query("select gm from Member gm where gm.settlement.id = :settlementId order by " - + "case when gm.role = 'MANAGER' then 1 else 2 end, " - + "case when gm.paidAt is null then 1 else 0 end, " - + "gm.paidAt asc, " - + "gm.name asc") - List findBySettlementId(@Param("settlementId") Long settlementId); - @Query("select gm.id from Member gm where gm.settlement.id = :settlementId") - List findAppointmentMemberIdsBySettlementId(@Param("settlementId") Long settlementId); + List findMemberIdsBySettlementId(@Param("settlementId") Long settlementId); + + @Query(""" + select count(gm) > 0 + from Member gm + where gm.settlement.id = :settlementId + and gm.user.id = :userId + """) + boolean existsBySettlementIdAndUserId(@Param("settlementId") Long settlementId, @Param("userId") Long userId); default Member getById(Long id) { return findById(id) diff --git a/src/main/java/com/dnd/moddo/event/presentation/MemberController.java b/src/main/java/com/dnd/moddo/event/presentation/MemberController.java index 8a69ddfd..7cd16af8 100644 --- a/src/main/java/com/dnd/moddo/event/presentation/MemberController.java +++ b/src/main/java/com/dnd/moddo/event/presentation/MemberController.java @@ -8,13 +8,18 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.dnd.moddo.auth.infrastructure.security.LoginUser; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; import com.dnd.moddo.common.support.VerifyManagerPermission; import com.dnd.moddo.event.application.command.CommandMemberService; import com.dnd.moddo.event.application.query.QueryMemberService; import com.dnd.moddo.event.application.query.QuerySettlementService; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; +import com.dnd.moddo.event.presentation.request.MemberSelectionRequest; import com.dnd.moddo.event.presentation.request.PaymentStatusUpdateRequest; import com.dnd.moddo.event.presentation.response.MemberResponse; import com.dnd.moddo.event.presentation.response.MembersResponse; @@ -33,10 +38,11 @@ public class MemberController { @GetMapping public ResponseEntity getMembers( - @PathVariable String code + @PathVariable String code, + @RequestParam(defaultValue = "CREATED") String sortType ) { Long settlementId = querySettlementService.findIdByCode(code); - MembersResponse response = queryMemberService.findAll(settlementId); + MembersResponse response = queryMemberService.findAll(settlementId, MemberSortType.from(sortType)); return ResponseEntity.ok(response); } @@ -63,6 +69,28 @@ public ResponseEntity updatePaymentStatus( return ResponseEntity.ok(response); } + @PostMapping("/assign") + public ResponseEntity assignMember( + @PathVariable String code, + @Valid @RequestBody MemberSelectionRequest request, + @LoginUser LoginUserInfo loginUser + ) { + Long settlementId = querySettlementService.findIdByCode(code); + MemberResponse response = commandMemberService.assignMember(settlementId, request.memberId(), loginUser.userId()); + return ResponseEntity.ok(response); + } + + @PostMapping("/unassign") + public ResponseEntity unassignMember( + @PathVariable String code, + @Valid @RequestBody MemberSelectionRequest request, + @LoginUser LoginUserInfo loginUser + ) { + Long settlementId = querySettlementService.findIdByCode(code); + MemberResponse response = commandMemberService.unassignMember(settlementId, request.memberId(), loginUser.userId()); + return ResponseEntity.ok(response); + } + @VerifyManagerPermission @DeleteMapping("/{memberId}") public ResponseEntity deleteMember( @@ -72,4 +100,4 @@ public ResponseEntity deleteMember( commandMemberService.delete(memberId); return ResponseEntity.noContent().build(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dnd/moddo/event/presentation/SettlementController.java b/src/main/java/com/dnd/moddo/event/presentation/SettlementController.java index 76565ce9..bc55866a 100644 --- a/src/main/java/com/dnd/moddo/event/presentation/SettlementController.java +++ b/src/main/java/com/dnd/moddo/event/presentation/SettlementController.java @@ -72,7 +72,7 @@ public ResponseEntity getSettlement( return ResponseEntity.ok(response); } - @GetMapping("{code}/header") + @GetMapping("/{code}/header") public ResponseEntity getHeader( @PathVariable("code") String code) { Long settlementId = querySettlementService.findIdByCode(code); diff --git a/src/main/java/com/dnd/moddo/event/presentation/request/MemberSelectionRequest.java b/src/main/java/com/dnd/moddo/event/presentation/request/MemberSelectionRequest.java new file mode 100644 index 00000000..a9536277 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/presentation/request/MemberSelectionRequest.java @@ -0,0 +1,9 @@ +package com.dnd.moddo.event.presentation.request; + +import jakarta.validation.constraints.NotNull; + +public record MemberSelectionRequest( + @NotNull(message = "memberId는 필수입니다.") + Long memberId +) { +} diff --git a/src/main/java/com/dnd/moddo/event/presentation/response/MemberResponse.java b/src/main/java/com/dnd/moddo/event/presentation/response/MemberResponse.java index 7f31cc8e..ddd73315 100644 --- a/src/main/java/com/dnd/moddo/event/presentation/response/MemberResponse.java +++ b/src/main/java/com/dnd/moddo/event/presentation/response/MemberResponse.java @@ -4,12 +4,10 @@ import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; -import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; @Builder -@JsonInclude(JsonInclude.Include.NON_NULL) public record MemberResponse( Long id, ExpenseRole role, @@ -25,7 +23,7 @@ public static MemberResponse of(Member member) { .id(member.getId()) .name(member.getName()) .role(member.getRole()) - .userId(member.getId()) + .userId(member.getUserId()) .isPaid(member.isPaid()) .paidAt(member.getPaidAt()) .profile(member.getProfileUrl()) diff --git a/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java b/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java index 815d44e5..23e83162 100644 --- a/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java +++ b/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java @@ -12,6 +12,8 @@ import com.dnd.moddo.reward.presentation.response.CollectionListResponse; import com.dnd.moddo.reward.presentation.response.CollectionResponse; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.AllArgsConstructor; @@ -36,8 +38,14 @@ public CollectionListResponse getCollectionListByUserId(Long userId) { character.name, character.rarity, collection.acquiredAt, - character.imageUrl, - character.imageBigUrl + new CaseBuilder() + .when(collection.acquiredAt.isNull()) + .then(Expressions.nullExpression(String.class)) + .otherwise(character.imageUrl), + new CaseBuilder() + .when(collection.acquiredAt.isNull()) + .then(Expressions.nullExpression(String.class)) + .otherwise(character.imageBigUrl) ) ) .from(character) diff --git a/src/test/java/com/dnd/moddo/domain/Member/controller/MemberControllerTest.java b/src/test/java/com/dnd/moddo/domain/Member/controller/MemberControllerTest.java index 76cc2d1f..e8491b9a 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/controller/MemberControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/controller/MemberControllerTest.java @@ -3,17 +3,22 @@ import static com.dnd.moddo.event.domain.member.ExpenseRole.*; import static org.mockito.Mockito.*; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.LocalDateTime; -import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; +import com.dnd.moddo.event.presentation.request.MemberSelectionRequest; import com.dnd.moddo.event.presentation.request.PaymentStatusUpdateRequest; import com.dnd.moddo.event.presentation.response.MemberResponse; import com.dnd.moddo.event.presentation.response.MembersResponse; @@ -28,18 +33,73 @@ void getAppointmentMembers() throws Exception { String code = "code"; Long groupId = 1L; - MembersResponse mockResponse = MembersResponse.of(Collections.emptyList()); + MembersResponse mockResponse = new MembersResponse(List.of( + new MemberResponse( + 1L, + MANAGER, + "김모또", + "https://moddo-s3.s3.amazonaws.com/profile/MODDO.png", + 10L, + true, + LocalDateTime.of(2026, 3, 13, 21, 30) + ), + new MemberResponse( + 2L, + PARTICIPANT, + "김반숙", + "https://moddo-s3.s3.amazonaws.com/profile/1.png", + null, + false, + null + ) + )); when(querySettlementService.findIdByCode(code)).thenReturn(groupId); - when(queryMemberService.findAll(groupId)).thenReturn(mockResponse); + when(queryMemberService.findAll(groupId, MemberSortType.CREATED)).thenReturn(mockResponse); // when & then - mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/groups/{code}/members", code)) + mockMvc.perform(get("/api/v1/groups/{code}/members", code) + .param("sortType", "CREATED")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.members").isArray()); + .andExpect(jsonPath("$.members").isArray()) + .andExpect(jsonPath("$.members[0].id").value(1L)) + .andExpect(jsonPath("$.members[0].userId").value(10L)) + .andExpect(jsonPath("$.members[1].id").value(2L)) + .andExpect(jsonPath("$.members[1].userId").doesNotExist()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ), + queryParameters( + parameterWithName("sortType").description("정렬 기준 (CREATED | NAME | PAID_AT)").optional() + ), + responseFields( + fieldWithPath("members").type(JsonFieldType.ARRAY).description("모임원 목록"), + fieldWithPath("members[].id").type(JsonFieldType.NUMBER).description("모임원 ID"), + fieldWithPath("members[].role").type(JsonFieldType.STRING).description("모임원 역할"), + fieldWithPath("members[].name").type(JsonFieldType.STRING).description("모임원 이름"), + fieldWithPath("members[].profile").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("members[].userId").type(JsonFieldType.NUMBER) + .description("연결된 사용자 ID, 아직 선택되지 않은 참여자는 null") + .optional(), + fieldWithPath("members[].isPaid").type(JsonFieldType.BOOLEAN).description("정산 완료 여부"), + fieldWithPath("members[].paidAt").type(JsonFieldType.STRING).description("정산 완료 시각").optional() + ) + )); verify(querySettlementService).findIdByCode(code); - verify(queryMemberService).findAll(groupId); + verify(queryMemberService).findAll(groupId, MemberSortType.CREATED); + } + + @Test + @DisplayName("유효하지 않은 sortType으로 모임원을 조회하면 400을 반환한다.") + void getAppointmentMembers_whenInvalidSortType() throws Exception { + mockMvc.perform(get("/api/v1/groups/{code}/members", "code") + .param("sortType", "INVALID")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value( + "유효하지 않은 sortType입니다. 허용 값: CREATED, NAME, PAID_AT (입력값: INVALID)" + )); } @Test @@ -67,7 +127,24 @@ void saveAppointmentMember() throws Exception { .andExpect(jsonPath("$.profile").value(response.profile())) .andExpect(jsonPath("$.userId").value(response.userId())) .andExpect(jsonPath("$.isPaid").value(response.isPaid())) - .andExpect(jsonPath("$.paidAt").doesNotExist()); + .andExpect(jsonPath("$.paidAt").doesNotExist()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("모임원 이름") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("모임원 ID"), + fieldWithPath("role").type(JsonFieldType.STRING).description("모임원 역할"), + fieldWithPath("name").type(JsonFieldType.STRING).description("모임원 이름"), + fieldWithPath("profile").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("userId").type(JsonFieldType.NUMBER).description("연결된 사용자 ID"), + fieldWithPath("isPaid").type(JsonFieldType.BOOLEAN).description("정산 완료 여부"), + fieldWithPath("paidAt").type(JsonFieldType.NULL).description("정산 완료 시각").optional() + ) + )); } @Test @@ -85,11 +162,29 @@ void updatePaymentStatus() throws Exception { // when & then mockMvc.perform( - MockMvcRequestBuilders.put("/api/v1/groups/{code}/members/{memberId}", code, groupMemberId) + put("/api/v1/groups/{code}/members/{memberId}", code, groupMemberId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.isPaid").value(true)); + .andExpect(jsonPath("$.isPaid").value(true)) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드"), + parameterWithName("memberId").description("모임원 ID") + ), + requestFields( + fieldWithPath("isPaid").type(JsonFieldType.BOOLEAN).description("변경할 결제 상태") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("모임원 ID"), + fieldWithPath("role").type(JsonFieldType.STRING).description("모임원 역할"), + fieldWithPath("name").type(JsonFieldType.STRING).description("모임원 이름"), + fieldWithPath("profile").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("userId").type(JsonFieldType.NUMBER).description("연결된 사용자 ID"), + fieldWithPath("isPaid").type(JsonFieldType.BOOLEAN).description("정산 완료 여부"), + fieldWithPath("paidAt").type(JsonFieldType.STRING).description("정산 완료 시각").optional() + ) + )); } @Test @@ -99,11 +194,107 @@ void deleteAppointmentMember() throws Exception { Long groupMemberId = 1L; mockMvc.perform( - MockMvcRequestBuilders.delete("/api/v1/groups/{code}/members/{memberId}", "code", groupMemberId)) - .andExpect(status().isNoContent()); + delete("/api/v1/groups/{code}/members/{memberId}", "code", groupMemberId)) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드"), + parameterWithName("memberId").description("삭제할 모임원 ID") + ) + )); // when & then verify(commandMemberService).delete(groupMemberId); } + @Test + @DisplayName("로그인 사용자가 참여자를 성공적으로 선택한다.") + void assignMember() throws Exception { + String code = "code"; + Long groupId = 1L; + Long memberId = 2L; + Long userId = 3L; + + MemberSelectionRequest request = new MemberSelectionRequest(memberId); + MemberResponse response = new MemberResponse( + memberId, PARTICIPANT, "김반숙", + "https://moddo-s3.s3.amazonaws.com/profile/1.png", userId, false, null + ); + + when(loginUserArgumentResolver.supportsParameter(any())).thenReturn(true); + when(loginUserArgumentResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(new LoginUserInfo(userId, "USER")); + when(querySettlementService.findIdByCode(code)).thenReturn(groupId); + when(commandMemberService.assignMember(groupId, memberId, userId)).thenReturn(response); + + mockMvc.perform(post("/api/v1/groups/{code}/members/assign", code) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(memberId)) + .andExpect(jsonPath("$.userId").value(userId)) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ), + requestFields( + fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("선택할 모임원 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("모임원 ID"), + fieldWithPath("role").type(JsonFieldType.STRING).description("모임원 역할"), + fieldWithPath("name").type(JsonFieldType.STRING).description("모임원 이름"), + fieldWithPath("profile").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("userId").type(JsonFieldType.NUMBER).description("연결된 사용자 ID"), + fieldWithPath("isPaid").type(JsonFieldType.BOOLEAN).description("정산 완료 여부"), + fieldWithPath("paidAt").type(JsonFieldType.NULL).description("정산 완료 시각").optional() + ) + )); + } + + @Test + @DisplayName("로그인 사용자가 참여자 선택을 성공적으로 해제한다.") + void unassignMember() throws Exception { + String code = "code"; + Long groupId = 1L; + Long memberId = 2L; + Long userId = 3L; + + MemberSelectionRequest request = new MemberSelectionRequest(memberId); + MemberResponse response = new MemberResponse( + memberId, PARTICIPANT, "김반숙", + "https://moddo-s3.s3.amazonaws.com/profile/1.png", null, false, null + ); + + when(loginUserArgumentResolver.supportsParameter(any())).thenReturn(true); + when(loginUserArgumentResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(new LoginUserInfo(userId, "USER")); + when(querySettlementService.findIdByCode(code)).thenReturn(groupId); + when(commandMemberService.unassignMember(groupId, memberId, userId)).thenReturn(response); + + mockMvc.perform(post("/api/v1/groups/{code}/members/unassign", code) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(memberId)) + .andExpect(jsonPath("$.userId").doesNotExist()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ), + requestFields( + fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("선택 해제할 모임원 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("모임원 ID"), + fieldWithPath("role").type(JsonFieldType.STRING).description("모임원 역할"), + fieldWithPath("name").type(JsonFieldType.STRING).description("모임원 이름"), + fieldWithPath("profile").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("userId").type(JsonFieldType.NULL).description("연결된 사용자 ID").optional(), + fieldWithPath("isPaid").type(JsonFieldType.BOOLEAN).description("정산 완료 여부"), + fieldWithPath("paidAt").type(JsonFieldType.NULL).description("정산 완료 시각").optional() + ) + )); + } + } diff --git a/src/test/java/com/dnd/moddo/domain/Member/entity/MemberTest.java b/src/test/java/com/dnd/moddo/domain/Member/entity/MemberTest.java index 438cfe03..b79e57d6 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/entity/MemberTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/entity/MemberTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import java.lang.reflect.Field; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,6 +13,7 @@ import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.global.support.GroupTestFactory; +import com.dnd.moddo.global.support.UserTestFactory; import com.dnd.moddo.user.domain.User; class MemberTest { @@ -89,14 +92,103 @@ void assignUser_success() { .build(); User user = mock(User.class); - when(user.getName()).thenReturn("새이름"); // when member.assignUser(user); // then assertThat(member.getUser()).isEqualTo(user); - assertThat(member.getName()).isEqualTo("새이름"); // 동기화 확인 + assertThat(member.getName()).isEqualTo("기존이름"); + } + + @DisplayName("사용자가 연결된 참여자는 선택된 상태이다.") + @Test + void isAssigned_whenUserAssigned_returnsTrue() { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + + member.assignUser(mock(User.class)); + + assertThat(member.isAssigned()).isTrue(); + } + + @DisplayName("사용자가 연결되지 않은 참여자는 선택되지 않은 상태이다.") + @Test + void isAssigned_whenUserNotAssigned_returnsFalse() { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + + assertThat(member.isAssigned()).isFalse(); + } + + @DisplayName("본인이 선택한 참여자인지 확인할 수 있다.") + @Test + void isAssignedTo_whenSameUser_returnsTrue() throws Exception { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + + User user = UserTestFactory.createWithEmail("assigned@test.com"); + setId(user, 1L); + member.assignUser(user); + + assertThat(member.isAssignedTo(1L)).isTrue(); + } + + @DisplayName("다른 사용자가 선택한 참여자인지 확인하면 false를 반환한다.") + @Test + void isAssignedTo_whenDifferentUser_returnsFalse() throws Exception { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + + User user = UserTestFactory.createWithEmail("assigned2@test.com"); + setId(user, 1L); + member.assignUser(user); + + assertThat(member.isAssignedTo(2L)).isFalse(); + } + + @DisplayName("정산에 속한 참여자인지 확인할 수 있다.") + @Test + void isInSettlement_whenSameSettlement_returnsTrue() throws Exception { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + setSettlementId(mockSettlement, 1L); + + assertThat(member.isInSettlement(1L)).isTrue(); + } + + @DisplayName("다른 정산의 참여자인지 확인하면 false를 반환한다.") + @Test + void isInSettlement_whenDifferentSettlement_returnsFalse() throws Exception { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + setSettlementId(mockSettlement, 1L); + + assertThat(member.isInSettlement(2L)).isFalse(); } @DisplayName("이미 사용자가 연결되어 있으면 예외가 발생한다.") @@ -113,9 +205,6 @@ void assignUser_throwException_whenUserAlreadyAssigned() { User firstUser = mock(User.class); User secondUser = mock(User.class); - when(firstUser.getName()).thenReturn("첫번째"); - when(secondUser.getName()).thenReturn("두번째"); - member.assignUser(firstUser); // when & then @@ -137,4 +226,54 @@ void assignUser_throwException_whenUserIsNull() { assertThatThrownBy(() -> member.assignUser(null)) .isInstanceOf(NullPointerException.class); } + + @DisplayName("본인이 연결한 사용자를 정상적으로 해제할 수 있다.") + @Test + void unassignUser_success() throws Exception { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + + User user = UserTestFactory.createWithEmail("test1@test.com"); + setId(user, 1L); + member.assignUser(user); + + member.unassignUser(1L); + + assertThat(member.getUser()).isNull(); + } + + @DisplayName("다른 사용자가 선택한 참여자는 해제할 수 없다.") + @Test + void unassignUser_throwException_whenDifferentUser() throws Exception { + Member member = Member.builder() + .name("기존이름") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + + User user = UserTestFactory.createWithEmail("test2@test.com"); + setId(user, 1L); + member.assignUser(user); + + assertThatThrownBy(() -> member.unassignUser(2L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("본인이 선택한 참여자만 해제할 수 있습니다."); + } + + private void setId(User user, Long id) throws Exception { + Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, id); + } + + private void setSettlementId(Settlement settlement, Long id) throws Exception { + Field idField = Settlement.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(settlement, id); + } } diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java index ed1bf6bf..98ef257e 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.BDDAssertions.*; import static org.mockito.Mockito.*; +import java.lang.reflect.Field; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -15,6 +16,7 @@ import com.dnd.moddo.event.application.command.CommandMemberService; import com.dnd.moddo.event.application.impl.MemberCreator; +import com.dnd.moddo.event.application.impl.MemberDeleter; import com.dnd.moddo.event.application.impl.MemberUpdater; import com.dnd.moddo.event.application.query.QueryMemberService; import com.dnd.moddo.event.domain.member.ExpenseRole; @@ -24,6 +26,8 @@ import com.dnd.moddo.event.presentation.request.PaymentStatusUpdateRequest; import com.dnd.moddo.event.presentation.response.MemberResponse; import com.dnd.moddo.global.support.GroupTestFactory; +import com.dnd.moddo.global.support.UserTestFactory; +import com.dnd.moddo.user.domain.User; @ExtendWith(MockitoExtension.class) public class CommandMemberServiceTest { @@ -32,6 +36,8 @@ public class CommandMemberServiceTest { @Mock private MemberUpdater memberUpdater; @Mock + private MemberDeleter memberDeleter; + @Mock private QueryMemberService queryMemberService; @InjectMocks private CommandMemberService commandMemberService; @@ -133,4 +139,63 @@ void whenAllMembersPaid_thenSettlementCompleted() { verify(mockSettlement).complete(); } + @DisplayName("로그인 사용자가 참여자를 선택할 수 있다.") + @Test + void assignMemberSuccess() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + + User user = UserTestFactory.createWithEmail("assign@test.com"); + setId(user, userId); + Member member = Member.builder() + .name("김반숙") + .settlement(mockSettlement) + .profileId(1) + .role(ExpenseRole.PARTICIPANT) + .user(user) + .build(); + + when(memberUpdater.assignMember(settlementId, memberId, userId)).thenReturn(member); + + MemberResponse response = commandMemberService.assignMember(settlementId, memberId, userId); + + assertThat(response.name()).isEqualTo("김반숙"); + assertThat(response.userId()).isEqualTo(member.getUserId()); + verify(memberUpdater).assignMember(settlementId, memberId, userId); + } + + @DisplayName("로그인 사용자가 자신이 선택한 참여자를 해제할 수 있다.") + @Test + void unassignMemberSuccess() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + + Member member = Member.builder() + .name("김반숙") + .settlement(mockSettlement) + .profileId(1) + .role(ExpenseRole.PARTICIPANT) + .build(); + + when(memberUpdater.unassignMember(settlementId, memberId, userId)).thenReturn(member); + + MemberResponse response = commandMemberService.unassignMember(settlementId, memberId, userId); + + assertThat(response.name()).isEqualTo("김반숙"); + assertThat(response.userId()).isNull(); + verify(memberUpdater).unassignMember(settlementId, memberId, userId); + } + + private void setId(User user, Long id) { + try { + Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } + } diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/QueryMemberServiceTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/QueryMemberServiceTest.java index 65f1fc5f..5310b89b 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/QueryMemberServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/QueryMemberServiceTest.java @@ -17,6 +17,7 @@ import com.dnd.moddo.event.application.query.QueryMemberService; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.presentation.response.MembersResponse; import com.dnd.moddo.global.support.GroupTestFactory; @@ -58,15 +59,41 @@ void findAll() { //given Long groupId = mockSettlement.getId(); - when(memberReader.findAllBySettlementId(eq(groupId))).thenReturn(mockMembers); + when(memberReader.findAllBySettlementId(eq(groupId), eq(MemberSortType.CREATED))).thenReturn(mockMembers); //when - MembersResponse response = queryMemberService.findAll(groupId); + MembersResponse response = queryMemberService.findAll(groupId, MemberSortType.CREATED); //then assertThat(response).isNotNull(); assertThat(response.members().size()).isEqualTo(2); assertThat(response.members().get(0).name()).isEqualTo("김모또"); - verify(memberReader, times(1)).findAllBySettlementId(eq(groupId)); + verify(memberReader, times(1)).findAllBySettlementId(eq(groupId), eq(MemberSortType.CREATED)); + } + + @DisplayName("정렬 기준을 받아 모임원을 조회할 수 있다.") + @Test + void findAllWithSortType() { + Long groupId = mockSettlement.getId(); + + when(memberReader.findAllBySettlementId(eq(groupId), eq(MemberSortType.NAME))).thenReturn(mockMembers); + + MembersResponse response = queryMemberService.findAll(groupId, MemberSortType.NAME); + + assertThat(response.members()).hasSize(2); + verify(memberReader).findAllBySettlementId(groupId, MemberSortType.NAME); + } + + @DisplayName("모임 ID로 원본 Member 목록을 조회할 수 있다.") + @Test + void findAllBySettlementId() { + Long groupId = mockSettlement.getId(); + + when(memberReader.findAllBySettlementId(eq(groupId))).thenReturn(mockMembers); + + List result = queryMemberService.findAllBySettlementId(groupId); + + assertThat(result).isEqualTo(mockMembers); + verify(memberReader).findAllBySettlementId(groupId); } } diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java index 488aa62e..ecb6d121 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java @@ -17,7 +17,9 @@ import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.member.exception.MemberNotFoundException; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.domain.settlement.Settlement; +import com.dnd.moddo.event.infrastructure.MemberQueryRepository; import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.global.support.GroupTestFactory; @@ -25,6 +27,8 @@ public class MemberReaderTest { @Mock private MemberRepository memberRepository; + @Mock + private MemberQueryRepository memberQueryRepository; @InjectMocks private MemberReader memberReader; @@ -55,7 +59,8 @@ void findAllBySettlementIdSuccess() { .build() ); - when(memberRepository.findBySettlementId(eq(groupId))).thenReturn(expectedMembers); + when(memberQueryRepository.findAllBySettlementId(eq(groupId), eq(MemberSortType.CREATED))).thenReturn( + expectedMembers); //when List result = memberReader.findAllBySettlementId(groupId); @@ -65,7 +70,29 @@ void findAllBySettlementIdSuccess() { assertThat(result.size()).isEqualTo(2); assertThat(result.get(0).getName()).isEqualTo("김모또"); assertThat(result.get(0).getRole()).isEqualTo(ExpenseRole.MANAGER); - verify(memberRepository, times(1)).findBySettlementId(groupId); + verify(memberQueryRepository, times(1)).findAllBySettlementId(groupId, MemberSortType.CREATED); + } + + @DisplayName("정렬 기준을 받아 모임의 참여자를 조회할 수 있다.") + @Test + void findAllBySettlementIdWithSortTypeSuccess() { + Long groupId = mockSettlement.getId(); + List expectedMembers = List.of( + Member.builder() + .name("김반숙") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build() + ); + + when(memberQueryRepository.findAllBySettlementId(eq(groupId), eq(MemberSortType.NAME))).thenReturn( + expectedMembers); + + List result = memberReader.findAllBySettlementId(groupId, MemberSortType.NAME); + + assertThat(result).isEqualTo(expectedMembers); + verify(memberQueryRepository).findAllBySettlementId(groupId, MemberSortType.NAME); } @DisplayName("참여자가 존재할때 참여자 id를 사용해 참여자 정보 조회에 성공한다.") @@ -107,4 +134,18 @@ void findByGroupMemberIdFail() { }).hasMessage("해당 참여자를 찾을 수 없습니다. (AppointmentMember ID: " + appointmentMember + ")"); } + @DisplayName("정산 ID로 참여자 ID 목록을 조회할 수 있다.") + @Test + void findIdsBySettlementIdSuccess() { + Long groupId = 1L; + List memberIds = List.of(1L, 2L, 3L); + + when(memberRepository.findMemberIdsBySettlementId(groupId)).thenReturn(memberIds); + + List result = memberReader.findIdsBySettlementId(groupId); + + assertThat(result).isEqualTo(memberIds); + verify(memberRepository).findMemberIdsBySettlementId(groupId); + } + } diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java index d7567392..9b68694b 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java @@ -13,6 +13,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.OptimisticLockingFailureException; import com.dnd.moddo.common.config.S3Bucket; import com.dnd.moddo.event.application.impl.MemberReader; @@ -21,11 +22,21 @@ import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.exception.InvalidMemberException; +import com.dnd.moddo.event.domain.member.exception.MemberAlreadyAssignedException; import com.dnd.moddo.event.domain.member.exception.MemberDuplicateNameException; +import com.dnd.moddo.event.domain.member.exception.MemberNotAssignedException; +import com.dnd.moddo.event.domain.member.exception.MemberSelectionNotAllowedException; +import com.dnd.moddo.event.domain.member.exception.MemberSelectionUnauthorizedException; +import com.dnd.moddo.event.domain.member.exception.PaymentConcurrencyException; +import com.dnd.moddo.event.domain.member.exception.UserAlreadyAssignedException; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; import com.dnd.moddo.event.presentation.request.PaymentStatusUpdateRequest; +import com.dnd.moddo.global.support.UserTestFactory; +import com.dnd.moddo.user.domain.User; +import com.dnd.moddo.user.infrastructure.UserRepository; @ExtendWith(MockitoExtension.class) class MemberUpdaterTest { @@ -39,6 +50,8 @@ class MemberUpdaterTest { private SettlementReader settlementReader; @Mock private S3Bucket s3Bucket; + @Mock + private UserRepository userRepository; @InjectMocks private MemberUpdater memberUpdater; @@ -131,6 +144,17 @@ void updatePaymentStatus_Success() { assertThat(result.isPaid()).isTrue(); } + @DisplayName("입금 상태 변경 중 낙관적 락 충돌이 발생하면 예외가 발생한다.") + @Test + void updatePaymentStatusFailWhenConcurrencyIssue() { + PaymentStatusUpdateRequest request = new PaymentStatusUpdateRequest(true); + + when(memberRepository.getById(any())).thenThrow(new OptimisticLockingFailureException("conflict")); + + assertThatThrownBy(() -> memberUpdater.updatePaymentStatus(1L, request)) + .isInstanceOf(PaymentConcurrencyException.class); + } + @DisplayName("9번째 이상의 참여자가 추가될 때 프로필 ID가 올바르게 순환된다.") @Test void addToSettlementProfileRotationSuccess() { @@ -177,4 +201,188 @@ void addToSettlementProfileRotationSuccess() { verify(memberRepository, times(1)).save(any()); } + + @DisplayName("로그인 사용자가 선택되지 않은 참여자를 선택할 수 있다.") + @Test + void assignMemberSuccess() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + + User user = UserTestFactory.createWithEmail("assign@test.com"); + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(member.isAssigned()).thenReturn(false); + when(memberRepository.existsBySettlementIdAndUserId(settlementId, userId)).thenReturn(false); + when(userRepository.getById(userId)).thenReturn(user); + when(memberRepository.save(member)).thenReturn(member); + + Member result = memberUpdater.assignMember(settlementId, memberId, userId); + + assertThat(result).isEqualTo(member); + verify(member).assignUser(user); + verify(memberRepository).save(member); + } + + @DisplayName("다른 정산의 참여자를 선택하면 예외가 발생한다.") + @Test + void assignMemberFailWhenInvalidSettlement() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(false); + when(member.getId()).thenReturn(memberId); + + assertThatThrownBy(() -> memberUpdater.assignMember(settlementId, memberId, userId)) + .isInstanceOf(InvalidMemberException.class); + } + + @DisplayName("정산 담당는 선택할 수 없다.") + @Test + void assignMemberFailWhenManager() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(true); + when(member.getId()).thenReturn(memberId); + + assertThatThrownBy(() -> memberUpdater.assignMember(settlementId, memberId, userId)) + .isInstanceOf(MemberSelectionNotAllowedException.class); + } + + @DisplayName("이미 같은 정산의 다른 참여자를 선택한 사용자는 예외가 발생한다.") + @Test + void assignMemberFailWhenUserAlreadyAssigned() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(memberRepository.existsBySettlementIdAndUserId(settlementId, userId)).thenReturn(true); + + assertThatThrownBy(() -> memberUpdater.assignMember(settlementId, memberId, userId)) + .isInstanceOf(UserAlreadyAssignedException.class); + } + + @DisplayName("이미 선택된 참여자를 다시 선택하면 예외가 발생한다.") + @Test + void assignMemberFailWhenMemberAlreadyAssigned() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(member.isAssigned()).thenReturn(true); + when(memberRepository.existsBySettlementIdAndUserId(settlementId, userId)).thenReturn(false); + + assertThatThrownBy(() -> memberUpdater.assignMember(settlementId, memberId, userId)) + .isInstanceOf(MemberAlreadyAssignedException.class); + } + + @DisplayName("로그인 사용자가 본인이 선택한 참여자를 해제할 수 있다.") + @Test + void unassignMemberSuccess() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(member.isAssigned()).thenReturn(true); + when(member.isAssignedTo(userId)).thenReturn(true); + when(memberRepository.save(member)).thenReturn(member); + + Member result = memberUpdater.unassignMember(settlementId, memberId, userId); + + assertThat(result).isEqualTo(member); + verify(member).unassignUser(userId); + verify(memberRepository).save(member); + } + + @DisplayName("다른 정산의 참여자를 해제하면 예외가 발생한다.") + @Test + void unassignMemberFailWhenInvalidSettlement() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(false); + when(member.getId()).thenReturn(memberId); + + assertThatThrownBy(() -> memberUpdater.unassignMember(settlementId, memberId, userId)) + .isInstanceOf(InvalidMemberException.class); + } + + @DisplayName("총무는 선택 해제할 수 없다.") + @Test + void unassignMemberFailWhenManager() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(true); + when(member.getId()).thenReturn(memberId); + + assertThatThrownBy(() -> memberUpdater.unassignMember(settlementId, memberId, userId)) + .isInstanceOf(MemberSelectionNotAllowedException.class); + } + + @DisplayName("선택되지 않은 참여자를 해제하면 예외가 발생한다.") + @Test + void unassignMemberFailWhenNotAssigned() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(member.isAssigned()).thenReturn(false); + + assertThatThrownBy(() -> memberUpdater.unassignMember(settlementId, memberId, userId)) + .isInstanceOf(MemberNotAssignedException.class); + } + + @DisplayName("본인이 선택하지 않은 참여자를 해제하면 예외가 발생한다.") + @Test + void unassignMemberFailWhenUnauthorized() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(member.isAssigned()).thenReturn(true); + when(member.isAssignedTo(userId)).thenReturn(false); + + assertThatThrownBy(() -> memberUpdater.unassignMember(settlementId, memberId, userId)) + .isInstanceOf(MemberSelectionUnauthorizedException.class); + } } diff --git a/src/test/java/com/dnd/moddo/domain/character/controller/CharacterControllerTest.java b/src/test/java/com/dnd/moddo/domain/character/controller/CharacterControllerTest.java index 364d8139..075da297 100644 --- a/src/test/java/com/dnd/moddo/domain/character/controller/CharacterControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/character/controller/CharacterControllerTest.java @@ -21,7 +21,7 @@ public class CharacterControllerTest extends RestDocsTestSupport { @DisplayName("캐릭터 정보를 정상적으로 조회한다.") void getCharacterSuccess() throws Exception { // given - String groupToken = "groupToken"; + String code = "groupCode"; Long groupId = 1L; CharacterResponse mockResponse = new CharacterResponse( @@ -29,12 +29,12 @@ void getCharacterSuccess() throws Exception { "https://moddo-s3.s3.amazonaws.com/character/천사 모또-2-big.png" ); - Mockito.when(querySettlementService.findIdByCode(groupToken)).thenReturn(groupId); + Mockito.when(querySettlementService.findIdByCode(code)).thenReturn(groupId); Mockito.when(queryCharacterService.findCharacterByGroupId(eq(groupId))).thenReturn(mockResponse); // when & then mockMvc.perform(get("/api/v1/character") - .param("code", groupToken) + .param("code", code) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("천사 모또")) @@ -43,41 +43,41 @@ void getCharacterSuccess() throws Exception { .andExpect(jsonPath("$.imageBigUrl").value("https://moddo-s3.s3.amazonaws.com/character/천사 모또-2-big.png")) .andDo(print()); - verify(querySettlementService).findIdByCode(groupToken); + verify(querySettlementService).findIdByCode(code); verify(queryCharacterService).findCharacterByGroupId(groupId); } @Test - @DisplayName("유효하지 않은 groupToken일 경우 에러가 발생한다.") + @DisplayName("유효하지 않은 code일 경우 에러가 발생한다.") void getCharacterInvalidToken() throws Exception { // given - String groupToken = "invalid.groupToken"; - when(querySettlementService.findIdByCode(groupToken)).thenThrow(new TokenInvalidException()); + String code = "invalid.code"; + when(querySettlementService.findIdByCode(code)).thenThrow(new TokenInvalidException()); // when & then mockMvc.perform(get("/api/v1/character") - .param("code", groupToken) + .param("code", code) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isUnauthorized()); - verify(querySettlementService).findIdByCode(groupToken); + verify(querySettlementService).findIdByCode(code); verify(queryCharacterService, never()).findCharacterByGroupId(any()); } @Test - @DisplayName("groupToken이 비어있는 경우 에러가 발생한다.") + @DisplayName("code가 비어 있는 경우 에러가 발생한다.") void getCharacterMissingToken() throws Exception { // when - String groupToken = ""; - when(querySettlementService.findIdByCode(groupToken)).thenThrow(new MissingTokenException()); + String code = ""; + when(querySettlementService.findIdByCode(code)).thenThrow(new MissingTokenException()); // then mockMvc.perform(get("/api/v1/character") - .param("code", groupToken) + .param("code", code) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isUnauthorized()); - verify(querySettlementService).findIdByCode(groupToken); + verify(querySettlementService).findIdByCode(code); verify(queryCharacterService, never()).findCharacterByGroupId(any()); } } diff --git a/src/test/java/com/dnd/moddo/domain/expense/controller/ExpenseControllerTest.java b/src/test/java/com/dnd/moddo/domain/expense/controller/ExpenseControllerTest.java index e90ab877..3a014d72 100644 --- a/src/test/java/com/dnd/moddo/domain/expense/controller/ExpenseControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/expense/controller/ExpenseControllerTest.java @@ -79,6 +79,9 @@ void saveExpensesSuccess() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andDo(document("create-expense", + pathParameters( + parameterWithName("code").description("정산 코드") + ), requestFields( fieldWithPath("expenses").type(JsonFieldType.ARRAY).description("지출 항목 목록"), fieldWithPath("expenses[].amount").type(JsonFieldType.NUMBER).description("지출 금액"), @@ -178,7 +181,13 @@ void getByExpenseIdSuccess() throws Exception { // when & then mockMvc.perform(get("/api/v1/groups/{code}/expenses/{expenseId}", code, expenseId)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andDo(document("get-by-expense-id-success", + pathParameters( + parameterWithName("code").description("정산 코드"), + parameterWithName("expenseId").description("지출 ID") + ) + )); } @Test @@ -203,7 +212,12 @@ void getExpenseDetailsSuccess() throws Exception { .andExpect(jsonPath("$.expenses[0].id").value(1)) .andExpect(jsonPath("$.expenses[0].content").value("하이디라오")) .andExpect(jsonPath("$.expenses[0].totalAmount").value(100000)) - .andExpect(jsonPath("$.expenses[0].groupMembers[0]").value("김모또(총무)")); + .andExpect(jsonPath("$.expenses[0].groupMembers[0]").value("김모또(총무)")) + .andDo(document("get-expense-details-success", + pathParameters( + parameterWithName("code").description("정산 코드") + ) + )); } @Test @@ -240,7 +254,13 @@ void updateExpenseSuccess() throws Exception { mockMvc.perform(put("/api/v1/groups/{code}/expenses/{expenseId}", code, expenseId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andDo(document("update-expense-success", + pathParameters( + parameterWithName("code").description("정산 코드"), + parameterWithName("expenseId").description("수정할 지출 ID") + ) + )); } @Test @@ -250,7 +270,13 @@ void deleteExpenseSuccess() throws Exception { doNothing().when(commandExpenseService).delete(anyLong(), eq(expenseId), eq(groupId)); mockMvc.perform(delete("/api/v1/groups/{code}/expenses/{expenseId}", code, expenseId)) - .andExpect(status().isNoContent()); + .andExpect(status().isNoContent()) + .andDo(document("delete-expense-success", + pathParameters( + parameterWithName("code").description("정산 코드"), + parameterWithName("expenseId").description("삭제할 지출 ID") + ) + )); } @Test @@ -264,7 +290,13 @@ void updateImgUrlSuccess() throws Exception { mockMvc.perform(put("/api/v1/groups/{code}/expenses/{expenseId}/img", code, expenseId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andDo(document("update-img-url-success", + pathParameters( + parameterWithName("code").description("정산 코드"), + parameterWithName("expenseId").description("이미지를 수정할 지출 ID") + ) + )); } @Test @@ -293,4 +325,4 @@ void getByExpenseIdFail_whenExpenseNotFound() throws Exception { mockMvc.perform(get("/api/v1/groups/{code}/expenses/{expenseId}", code, expenseId)) .andExpect(status().isNotFound()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/dnd/moddo/domain/memberExpense/controller/MemberExpenseControllerTest.java b/src/test/java/com/dnd/moddo/domain/memberExpense/controller/MemberExpenseControllerTest.java index 086abdb3..36a75b5e 100644 --- a/src/test/java/com/dnd/moddo/domain/memberExpense/controller/MemberExpenseControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/memberExpense/controller/MemberExpenseControllerTest.java @@ -23,7 +23,7 @@ public class MemberExpenseControllerTest extends RestDocsTestSupport { @DisplayName("모임원별 상세 지출 내역을 성공적으로 조회한다.") void getMemberExpensesDetailsSuccess() throws Exception { // given - String code = "mockedGroupToken"; + String code = "mockedCode"; Long groupId = 1L; MembersExpenseResponse membersExpenseResponse = new MembersExpenseResponse( diff --git a/src/test/java/com/dnd/moddo/domain/reward/controller/CollectionControllerTest.java b/src/test/java/com/dnd/moddo/domain/reward/controller/CollectionControllerTest.java index 0b7ad515..41574755 100644 --- a/src/test/java/com/dnd/moddo/domain/reward/controller/CollectionControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/reward/controller/CollectionControllerTest.java @@ -1,6 +1,7 @@ package com.dnd.moddo.domain.reward.controller; import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -10,6 +11,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; import com.dnd.moddo.global.util.RestDocsTestSupport; @@ -41,6 +43,19 @@ void getMyCollections() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.collections").isArray()) .andExpect(jsonPath("$.collections[0].id").value(1L)) - .andExpect(jsonPath("$.collections[0].name").value("모또")); + .andExpect(jsonPath("$.collections[0].name").value("모또")) + .andDo(restDocs.document( + responseFields( + fieldWithPath("collections").type(JsonFieldType.ARRAY).description("보유한 캐릭터 도감 목록"), + fieldWithPath("collections[].id").type(JsonFieldType.NUMBER).description("캐릭터 ID"), + fieldWithPath("collections[].name").type(JsonFieldType.STRING).description("캐릭터 이름"), + fieldWithPath("collections[].rarity").type(JsonFieldType.NUMBER).description("캐릭터 희귀도"), + fieldWithPath("collections[].acquiredAt").type(JsonFieldType.STRING).description("획득 일시").optional(), + fieldWithPath("collections[].imageUrl").type(JsonFieldType.STRING).description("캐릭터 이미지 URL").optional(), + fieldWithPath("collections[].imageBigUrl").type(JsonFieldType.STRING) + .description("캐릭터 상세 이미지 URL") + .optional() + ) + )); } } diff --git a/src/test/java/com/dnd/moddo/domain/settlement/controller/SettlementControllerTest.java b/src/test/java/com/dnd/moddo/domain/settlement/controller/SettlementControllerTest.java index ddb8f149..21ea542b 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/controller/SettlementControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/controller/SettlementControllerTest.java @@ -36,7 +36,7 @@ public class SettlementControllerTest extends RestDocsTestSupport { void saveSettlement() throws Exception { // given SettlementRequest request = new SettlementRequest("모또 모임"); - SettlementSaveResponse response = new SettlementSaveResponse("groupToken", new MemberResponse( + SettlementSaveResponse response = new SettlementSaveResponse("code", new MemberResponse( 1L, MANAGER, "김모또", "https://moddo-s3.s3.amazonaws.com/profile/MODDO.png", 1L, true, LocalDateTime.now() )); @@ -52,7 +52,7 @@ void saveSettlement() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.groupToken").value("groupToken")); + .andExpect(jsonPath("$.groupToken").value("code")); } @Test @@ -77,7 +77,12 @@ void updateAccount() throws Exception { mockMvc.perform(put("/api/v1/groups/{code}/account", "code") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(accountRequest))) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ) + )); } @Test @@ -102,7 +107,12 @@ void getSettlement() throws Exception { // when & then mockMvc.perform(get("/api/v1/groups/{code}", "code")) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ) + )); } @Test @@ -113,12 +123,17 @@ void getHeader() throws Exception { LocalDateTime.now().plusDays(1), "우리은행", "1111-1111"); - given(querySettlementService.findIdByCode("groupToken")).willReturn(100L); + given(querySettlementService.findIdByCode("code")).willReturn(100L); given(querySettlementService.findBySettlementHeader(100L)).willReturn(response); // when & then mockMvc.perform(get("/api/v1/groups/{code}/header", "code")) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ) + )); } @Test @@ -261,6 +276,7 @@ void getShareLinkListSuccess() throws Exception { fieldWithPath("[].members[].name").description("모임원 이름"), fieldWithPath("[].members[].profile").description("프로필 이미지 URL"), fieldWithPath("[].members[].userId").description("사용자 ID"), + fieldWithPath("[].members[].paidAt").description("정산 완료 시각").optional(), fieldWithPath("[].members[].isPaid").description("정산 완료 여부") .optional() ) diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementReaderTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementReaderTest.java index 0b877e3d..d66893b5 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementReaderTest.java @@ -19,13 +19,13 @@ import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.domain.settlement.exception.GroupNotFoundException; import com.dnd.moddo.event.domain.settlement.type.SettlementSortType; import com.dnd.moddo.event.domain.settlement.type.SettlementStatus; import com.dnd.moddo.event.infrastructure.ExpenseRepository; import com.dnd.moddo.event.infrastructure.MemberQueryRepository; -import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.event.infrastructure.SettlementQueryRepository; import com.dnd.moddo.event.infrastructure.SettlementRepository; import com.dnd.moddo.event.presentation.response.MemberResponse; @@ -42,9 +42,6 @@ class SettlementReaderTest { @Mock private ExpenseRepository expenseRepository; - @Mock - private MemberRepository memberRepository; - @Mock private MemberQueryRepository memberQueryRepository; @@ -79,14 +76,14 @@ void findBySettlement_Success() { when(mockSettlement.getId()).thenReturn(1L); List mockMembers = List.of(mock(Member.class), mock(Member.class)); - when(memberRepository.findBySettlementId(anyLong())).thenReturn(mockMembers); + when(memberQueryRepository.findAllBySettlementId(anyLong(), eq(MemberSortType.CREATED))).thenReturn(mockMembers); // When List result = settlementReader.findBySettlement(mockSettlement.getId()); // Then assertThat(result).hasSize(2); - verify(memberRepository, times(1)).findBySettlementId(mockSettlement.getId()); + verify(memberQueryRepository, times(1)).findAllBySettlementId(mockSettlement.getId(), MemberSortType.CREATED); } @Test