From 02fdeecf051cbc5f4c49b5ca5d8c87ccaec3878d Mon Sep 17 00:00:00 2001 From: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Date: Fri, 18 Aug 2023 10:41:08 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=9E=91=EC=97=85=20(#416)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 깃허브 액션 CI 워크플로 작성 (#6) feat: 깃허브 액션 CI 워크플로 작성 * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * docs: CI 스크립트 수정 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * docs: CI 스크립트 내의 프로젝트 빌드 파일 경로 수정 * docs: CI 오류 확인 * docs: CI gradlew 경로로 이동 * docs: CI 경로 한 run에 설정 * docs: CI 성공 * 스프링 기본 구조 설계 (#18) * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * feat: request dto 생성 * feat: service 레이어 구현 * style: 주석 추가 * feat: Controller 레이어 구현 * refactor: update 한 후에 id 받아오도록 수정 * test: update 성공 테스트 작성 * refactor: Tag 에서 BaseEntity 제거 * feat: Tag 개수 증가 및 감소, 비교 로직 추가 * test: 테스트 displayName 변경 * feat: 초기 tag 생성 기능 구현 * feat: 필드 update 기능 구현 * feat: request dto 에 LocalDateTime getter 생성 * feat: tag 이름으로 tag 조회 기능 구현 * feat: id 로 RunnerPostTag 목록 조회 기능 구현 * feat: 러너 게시글 수정 service 구현 * refactor: dto 이름 변경 * refactor: equalsAndHashCode 적용 * test: service 테스트 작성 * Revert "러너 게시글 수정 API 구현" (#36) * 러너 게시글 등록 API 구현 (#30) * feat: RunnerPost VO에 Equals&Hashcode 추가 * feat: TagRepository 에 Tag 이름으로 조회하는 로직 추가 * feat: TagName name 컬럼 unique 제약 조건 추가 * feat: Tag 기본 생성 기능 및 count 증가 기능 추가 * feat: RunnerPostTag 추가 기능 구현 * refactor: RunnerPostTag create 메서드 삭제 * feat: WatchedCount 기본 생성 기능 구현 * feat: ChattingRoomCount 기본 생성 기능 구현 * feat: RunnerPost 기본 생성 기능 구현 * feat: RunnerRepository에서 Runner와 Member를 조인해서 가져오는 기능 구현 * feat: RunnerService에서 Runner와 Member를 조인해서 가져오는 기능 구현 * refactor: TagCount method 이름 변경 * feat: RunnerPost 생성 API 구현 * feat: RunnerPost 에 Tag 를 전체 추가하는 기능 구현 * style: RunnerPostService 줄바꿈 제거 * fix: Join fetch 조건 오류 해결 * feat: RunnerPostController createRunnerPost 에서 create 반환 변수 명 변경 * refactor: create 메서드 명을 createRunnerPost 로 변경 * style: 공백 정리 * refactor: findByIdJoinMember 를 joinMemberByRunnerId 로 변경 * refactor: TagCount 의 기본값을 항상 새로운 객체로 반환 하도록 수정 * refactor: WatchedCount 의 기본값을 항상 새로운 값으로 반환하도록 변경 * refactor: ChattingRoomCount 의 기본값을 항상 새로운 값으로 변경하도록 변경 * 러너 게시글 상세 조회 및 삭제 API 구현 (#28) * feat: 러너 게시글에 러너 게시글 태그 추가 기능 구현 * feat: RunnerPost 상세 조회 및 삭제 기능 추가 * feat: Tag 레포지터리에 TagCount 감소 기능 구현 * feat: RunnerPost 식별자값으로 RunnerPostTag 목록 조회 기능 구현 * feat: RunnerPost 서비스 상세 조회 및 삭제 기능 구현 * feat: Member 에 ImageUrl 필드 추가 * feat: RunnerPost 컨트롤러 상세 조회 및 삭제 API 구현 * test: ImageUrl null 예외 검증 테스트 구현 * test: DisplayName 내용 수정 * fix: Tag 의 TagCount 수가 줄어든 후 영속성 컨텍스트가 비어지지 않도록 수정 * refactor: Tag 의 TagCount 감소 기능을 변경 감지로 리팩터링 * test: RunnerPost 레포지터리 상세 조회 및 삭제 테스트 분리 * refactor: JPQL 에 사용할 변수명을 @Param 으로 표기하도록 리팩터링 * test: RestAssured 에 @Transactional 제거 * test: RestAssured 응답 객체 변환 기능 수정 * test: 인수테스트 @Disable * fix: RunnerPost 의 deadline 응답 반환 타입 수정 * test: Non Ascii 어노테이션 추가 * refactor: RunnerPostResponse Single 레코드명 리팩터링 * refactor: 레포지터리 Tag 조인 메서드명 리팩터링 * test: 사용하지 않는 인자 삭제 * 러너 게시글 수정 API 구현 (#41) * refactor: id -> runnerPostId 변경 * refactor: UriComponentsBuilder 적용 * refactor: String -> LocalDateTime 으로 변경 * test: 변수에 final 키워드 적용 * refactor: service 에 final 키워드 적용 * refactor: Getter 와 EqualsAndHashCode 어노테이션 위치 변경 * refactor: tag 관련 네이밍 변경 * style: 개행 닫기 * refactor: tagCount 수정 * refactor: import 적용 * refactor: 변수명 변경 * refactor: 어노테이션 위치 조정 * refactor: Tag 는 TimeStamp 저장 안하도록 설정 * refactor: id 로 runnerPost 조회 시 검색되지 않으면 예외 던지도록 설정 * refactor: 메소드명 변경 * test: display name 변경 * refactor: TagCount init 시에 parseInt 하는 방식으로 변경 * 러너 게시글 조회 API 구현 (#32) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 --------- Co-authored-by: jeonghoon * 리뷰 요청 글 전체 조회 API 수정, Tag이름 조회 수정 (#47) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 * fix: 리뷰 요청 글 전체 조회 API path 수정 * fix: Tag 이름 조회 수정 * fix: API 수정에 따른 테스트 코드 수정 * sytle: 사용하지 않는 () 삭제 --------- Co-authored-by: jeonghoon * 러너 포스트 중복 저장 시 발생하는 오류 해결 (#48) * chore: docker 외부 포트 고정 * fix: 중복되어서 저장이 안되는 문제 수정 * 러너 게시글 태그 중복 조회 오류 해결 (#51) fix: 영속성 컨텍스트에 RunnerPostTag가 기존에 존재하고 있으므로 중복되지 않도록 RunnerPost에 RunnerPostTag를 add하는 기능 삭제 * 예외 메시지 반환을 위한 ErrorResponse 객체 생성 (#62) * feat: 새로운 커스텀 Exception 추가 및 이전 버전의 Exception 클래스 수정 * feat: 새로운 커스텀 Exception 도메인별 구현체 및 도메인별 에러 코드 enum 구현 * feat: 베이스 커스텀 Exception 에 HttpStatus 가져오기 기능 구현 * feat: 베이스 에러 응답 객체 구현 * fix: 아직 구현되지 않은 러너 게시글 조회수 증가 기능 삭제 * test: 러너 게시글 마감일 검증 테스트 수정 * feat: 클라이언트 요청 커스텀 Exception 추가 및 병합된 에러 코드 구현 * 러너 게시글 조회 객체 이름 수정 (#63) * feat: 리뷰 요청 글 도메인 구현 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * refactor: 명세에 맞게 변수명 수정 * 테스트 코드를 위한 Fixture 생성 및 RunnerPost 테이블에 status 칼럼 추가 (#74) * refactor: Runner 와 Supporter 에 Grade 를 nullable=false로 설정 * feat: RunnerPost 에 ReviewStatus 추가 * test: MemberFixture 구현 * test: RunnerFixture 구현 * test: SupporterFixture 구현 * test: TagFixture 구현 * test: Fixture 추상클래스로 변경 * test: Fixture 정적 팩토리 메서드 파라미터 이름 변경 * test: Fixture 검증 * RunnerPostTag에 FK 제약 조건 이름 수정 (#66) * feat: RunnerPostTag에 fk 제약조건 이름 변경 * refactor: RunnerPost fk 제약 조건 이름 변경 * refactor: Runner fk 제약 조건 이름 변경 * refactor: Supporter fk 제약 조건 이름 변경 * refactor: RunnerPostTag fk 제약 조건 이름 변경 * 나누어져있는 RunnerPostResponse 통합 (#78) * refactor: dto 통합 * refactor: Profile dto 통합 * 서포터 목록 조회 API 구현 version-test (#93) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * 서포터 목록 조회 API 에서 별점 제거 (#96) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * fix: supporter response 에 별점 제거 * fix: supporter response 에 별점 제거 * 러너 리뷰 요청 게시글 생성 version-test (#94) * feat: RunnerPostService 에서 testversion 저장하는 기능 구현 * feat: RunnerPostController 서포터 식별자를 받아 글을 생성하는 메서드 구현 * refactor: RunnerPost 와 Runner 의 관계를 ManyToOne 으로 변경 * test: 테스트에 SupporterRepository 의존성 추가 * refactor: RunnerPost 변수에 final 키워드 추가 * refactor: CreateReqeustDto 에 timezone 추가 * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * Profile에 isOwner 삭제하고 RunnerPostResponse에 isOwner 추가 (#104) * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * refactor: ProfileResponse에 isOwner 삭제 * refactor: RunnerPostResponse에 isOwner 추가 * test: Response 변경에 따른 테스트 수정 * refactor:isOwner VO 추가 * refactor: 유지보수를 위해 true를 기본값으로 설정 * refactor: ProfileResponse 수정에 의한 변경 * Docker Hub와 GitHub self hosted runner를 이용한 CD 설정 (#106) * docs: Docker 를 이용한 CD 설정 * docs: CD 타겟 브랜치 수정 * 러너 게시글 상세 조회 API 구현 version-test (#109) * Test Fixture 파라미터 타입 수정 (#110) * test: Domain Fixture 메서드 파라미터 타입 수정 * test: 테스트 Fixture 사용 수정 * test: Runner Test Fixture 수정 * 클라이언트 예외를 위한 Validator 추가 (#117) * refactor: ClientRequestException 일반 클래스로 변경 * refactor: ErrorResponse 에서 httpStatus 제거 * refactor: RunnerPostUpdateRequest record로 변경 * refactor: ClientErrorCode 내용 추가 * refactor: record로 생긴 변화 적용 * feat: NotNullValid 어노테이션 추가 * refactor: ErrorResponse 에 getter 추가 * feat: Controller valid 추가 * feat: ValidFuture 어노테이션 추가 * feat: 어노테이션 이름 변경 * feat: Max validator 추가 * feat: request dto에 validation 적용 * refactor: 어노테이션 target 조정 * refactor: tag 값이 없을 때 Bad Request 보내도록 수정 * CORS 설정 및 Profile을 RunnerProfile로 변경 (#120) * feat: cors 설정 * refactor: MemberProfile 을 RunnerProfile 로 수정 * feat: cors HttpMethod 에 PATCH 추가 * 환경 변수 보호를 위한 서브 모듈 연결 (#125) * feat: 서브 모듈 추가 * feat: 서브 모듈 환경 설정 * fix: 실험을 위해 현재 브랜치로 CD 실행하도록 변경 * refactor: Dockerfile 경로 변경 * refactor: 실험을 위해 공백 푸시 * refactor: 실험을 성공 후 공백 다시 추가 * refactor: CD 브랜치 dev/BE로 변경 * refactor: CI/CD 파일명 변경 * String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 (#127) * feat: String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 * refactor: RunnerPostCreateTestRequest 에서 컨버터를 사용하도록 변경 * feat: Json으로 직렬화될 때 custom 날짜 형식으로 변경되도록 수정 * refactor: RunnerPostresponse 에서 JsonFormat을 사용하지 않도록 변경 * test: StringDateToLocalDateTime 테스트 방식 변경 * test: ConverterConfigTest를 SpringBootTest 에서 WebMvcTest 로 변경 * test: StringDateToLocalDateTimeConverter 실패 테스트 추가 * feat: DateTimeConverter desiralizer 구현 * test: ConverterConfigTest 실패 테스트 수정 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * 백엔드 프로덕션 컨테이너 세팅 (#132) * chore: Dockerfile 실행 환경 dev, prod 분리 및 사용하지 않는 Docker Image 삭제 명령어 추가 * chore: ci/cd deploy 환경 분리 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * 프론트엔드 배포 작업 (#135) * webpack 초기 환경 설정 (#8) chore: 프로젝트 초기 환경 설정 * Layout 컴포넌트 구현 (#10) * assets: Pretendard 폰트 추가 * design: GlobalStyle 및 ResetStyle 적용 * feat: Header, Layout 컴포넌트 구현 * MSW 설정 (#17) * chore: 환경 변수 NODE_ENV 설정 * feat: msw 초기 설정 * Avartar, Tag, Button, Modal 컴포넌트 구현 (#20) * feat: Avatar 컴포넌트 구현 * feat: Button 컴포넌트 구현 * assets: close-icon-red svg 추가 * feat: Tag 컴포넌트 구현 * design: Button 디폴트 스타일 설정 * feat: Modal 컴포넌트 구현 * React Router 설정 (#23) * design: Layout background 제거 * feat: 페이지 라우팅 설정 * msw, tsconfig, Avatar 공통 변경사항 반영 (#27) * chore: json 파일을 import하기 위한 tsconfig 수정 * fix: msw dev에서만 작동하도록 수정 * design: Avatar 기본 사이즈 설정 * 러너 게시글 조회 페이지 구현 (#31) * feat: RunnerPostPage 구현 * assets: 채팅수, 조회수 아이콘 svg 추가 * feat: 러너 게시글 mock data 추가 * feat: RunnerPostPage api 요청 기능 추가 * refactor: PostTag 컴포넌트 분리 * 러너 게시물 생성 페이지 구현 (#33) * feat: InputBox 컴포넌트 구현 * feat: TagInput 컴포넌트 구현 * feat: Textarea 컴포넌트 구현 * feat: RunnerPostCreatePage 페이지 구현 * refactor: InputBox 컴포넌트 InputText Props 삭제 * refactor: props로state를 받도록 변경 * refactor: 입력 state관련 set 함수명 수정 --------- Co-authored-by: 상규 * 메인 페이지 구현 (#34) * chore: json 파일을 import하기 위한 tsconfig 수정 * fix: msw dev에서만 작동하도록 수정 * design: Avatar 기본 사이즈 설정 * feat: MainPage 구현 * fix: map key 오류 수정 * feat: RunnerPost 타입 추가 * feat: 러너가 올린 게시물 조회 msw 구현 * refactor: RunnerPost 타입 지정 * feat: Tag 컴포넌트 렌더링 추가 * refactor: mock data 네이밍 통일 * 구현한 페이지 라우터 연결 (#42) * feat: 구현한 페이지 라우터 연결 및 hook 분리 Co-authored-by: 에이든 Co-authored-by: 남상규 * feat: 로고 클릭시 홈 이동 기능 추가 --------- Co-authored-by: 에이든 Co-authored-by: 남상규 * 러너 글 목록 조회 API 변경에 따른 MainPage 수정 (#55) * fix: api 명세 변경에 따른 러너 글 타입 수정 * feat: 러너 글 목록 조회 api 변경에 따른 MainPage 수정 * API 요청 Endpoint 변경 (#69) * fix: dev server 새로고침 오류 수정 * refactor: index.html 경로 src 외부 public 폴더로 이동 * feat: api 엔드포인트 변경 dev server에서만 msw 적용하도록 변경 * 절대 경로 재설정 (#71) chore: import 절대경로 path 재설정 * 불필요한 환경 변수 플러그인 제거 (#73) fix: build 오류 수정 웹팩 dev 환경변수 플러그인 제거 * Label 컴포넌트 구현 (#88) feat: Label 컴포넌트 구현 * 서포터 선택 결과 페이지 구현 (#90) feat: 서포터 선택 결과 페이지 구현 * 메인 페이지 및 상세 게시글 리뷰 상태 라벨 추가 (#91) * feat: mockData에 reviewStatus 추가 * feat: reviewStatus 타입 및 Label text 추가 * feat: 메인 페이지에 리뷰 상태 Label 추가 * feat: 러너 게시글 상세 페이지에 리뷰 상태 Label 추가 * 서포터 선택 시 확인 모달창 구현 (#92) feat: 서포터 선택 시 확인 모달창 구현 * 서포터 선택 페이지 구현 (#103) * feat: 서포터 선택페이지 라우팅 추가 * asset: github-icon svg파일 추가 * feat: 서포터 선택 페이지 msw 핸들러 추가 * feat: SupporterSelectPage 페이지 컴포넌트 추가 * feat: 서포터 선택 페이지 관련 타입 추가 * feat: SupporterSelectItem, List 컴포넌트 추가 * feat: ConfirmModal 컴포넌트 Props 추가 * feat: RunnerPostCreatePage 페이지 컴포넌트 api 기능 제거 * fix: supportSelect 타입 오류 수정 * fix: 저장 누락 반영 --------- Co-authored-by: 상규 * CI를 위한 테스트 코드 작성 (#111) * chore: testing library react 및 jest 패키지 설정 * test: CI를 위한 컴포넌트 테스트코드 작성 * CI설정 with GitHub Actions (#113) * style: 팀 컨벤션에 맞게 프론트엔드 최상위 폴더명 변경 * chore: 프론트엔드 CI 설정 * ConfirmModal 컴포넌트 테스트코드 작성 (#116) * test: ConfirmModal 컴포넌트 테스트코드 작성 * fix: workflow -> workflows 수정 * fix: github action working directory 수정 * API 명세 변경에 따른 기능 추가 (#123) * fix: types 파일명 변경을 위한 파일 삭제 * feat: API 명세에 맞춰 타입 추가 및 mock data 변경 * feat: 러너 게시글 상세 페이지 삭제, 코드 보러가기 기능 추가 * style: 러너 상세 게시글 타입 이름 수정 * refactor: API 명세에 맞춘 타입을 import 하도록 변경 * 로그인 페이지 구현 (#124) * feat: 헤더에 로그인 버튼 클릭시 로그인 페이지로 이동하는 기능 추가 * feat: 로그인 페이지 컴포넌트 구현 * fix: asset경로 절대경로로 수정 --------- Co-authored-by: 상규 * 서포터 선택 목록 -> 글 생성할 때 띄우는 기능 구현 (#128) * feat: InputBox font-size, font-weight props 추가 * feat: 서포터 리스트 mockData 추가 * feat: 서포터 선택 목록 페이지를 모달 컴포넌트로 변경 * design: Textarea border 수정 * fix: TagInput을 form태그 안에 넣을 경우 submit이 되는 오류 수정 * feat: 서포터 선택 목록 모달 기능 구현 * fix: cherry-pick conflict 해결 * fix: type 오류 수정 * feat: 프론트엔드 로컬에서 백엔드 서버 api 요청을 위한 proxy 포트 설정 * feat: api 명세 변경에 따른 컴포넌트 props 수정 * 서버 api 엔드포인트 변경 (#133) * feat: 변경된 api 명세에 따른 msw uri 수정 * feat: 배포 서버 base url 수정 * feat: 삭제 확인 모달창 구현 * test: 확인 모달창 변경에 따른 테스트코드 작성 * 사용하지 않는 기능 숨기기 (#137) * design: Layout margin-bottom 추가 * design: 리뷰 진행중 라벨 디자인 추가 * feat: 준비중인 기능 알림 메시지 추가 * feat: 사용하지 않는 기능 주석 처리 --------- Co-authored-by: KangSan Lee Co-authored-by: 남상규 <103256030+tkdrb12@users.noreply.github.com> Co-authored-by: 상규 Co-authored-by: 에이든 Co-authored-by: 남상규 * secret 파일 pull (#139) fix: secret 파일 pull * secret 파일 pull (#141) fix: secret 파일 pull * secret 파일 pull (#142) fix: secret 파일 pull * dev 환경에 방언 추가 (#143) * fix: secret 파일 pull * refactor: secret 변경 * dev에 prod db 적용해보기 (#144) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * cicd 파일 및 환경 변수 원상복구 (#145) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * secret 변경 (#146) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * 도커 secret 수정 (#147) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * refactor: secret 수정 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * 프론트엔드 배포 작업 (#155) * webpack 초기 환경 설정 (#8) chore: 프로젝트 초기 환경 설정 * Layout 컴포넌트 구현 (#10) * assets: Pretendard 폰트 추가 * design: GlobalStyle 및 ResetStyle 적용 * feat: Header, Layout 컴포넌트 구현 * MSW 설정 (#17) * chore: 환경 변수 NODE_ENV 설정 * feat: msw 초기 설정 * Avartar, Tag, Button, Modal 컴포넌트 구현 (#20) * feat: Avatar 컴포넌트 구현 * feat: Button 컴포넌트 구현 * assets: close-icon-red svg 추가 * feat: Tag 컴포넌트 구현 * design: Button 디폴트 스타일 설정 * feat: Modal 컴포넌트 구현 * React Router 설정 (#23) * design: Layout background 제거 * feat: 페이지 라우팅 설정 * msw, tsconfig, Avatar 공통 변경사항 반영 (#27) * chore: json 파일을 import하기 위한 tsconfig 수정 * fix: msw dev에서만 작동하도록 수정 * design: Avatar 기본 사이즈 설정 * 러너 게시글 조회 페이지 구현 (#31) * feat: RunnerPostPage 구현 * assets: 채팅수, 조회수 아이콘 svg 추가 * feat: 러너 게시글 mock data 추가 * feat: RunnerPostPage api 요청 기능 추가 * refactor: PostTag 컴포넌트 분리 * 러너 게시물 생성 페이지 구현 (#33) * feat: InputBox 컴포넌트 구현 * feat: TagInput 컴포넌트 구현 * feat: Textarea 컴포넌트 구현 * feat: RunnerPostCreatePage 페이지 구현 * refactor: InputBox 컴포넌트 InputText Props 삭제 * refactor: props로state를 받도록 변경 * refactor: 입력 state관련 set 함수명 수정 --------- Co-authored-by: 상규 * 메인 페이지 구현 (#34) * chore: json 파일을 import하기 위한 tsconfig 수정 * fix: msw dev에서만 작동하도록 수정 * design: Avatar 기본 사이즈 설정 * feat: MainPage 구현 * fix: map key 오류 수정 * feat: RunnerPost 타입 추가 * feat: 러너가 올린 게시물 조회 msw 구현 * refactor: RunnerPost 타입 지정 * feat: Tag 컴포넌트 렌더링 추가 * refactor: mock data 네이밍 통일 * 구현한 페이지 라우터 연결 (#42) * feat: 구현한 페이지 라우터 연결 및 hook 분리 Co-authored-by: 에이든 Co-authored-by: 남상규 * feat: 로고 클릭시 홈 이동 기능 추가 --------- Co-authored-by: 에이든 Co-authored-by: 남상규 * 러너 글 목록 조회 API 변경에 따른 MainPage 수정 (#55) * fix: api 명세 변경에 따른 러너 글 타입 수정 * feat: 러너 글 목록 조회 api 변경에 따른 MainPage 수정 * API 요청 Endpoint 변경 (#69) * fix: dev server 새로고침 오류 수정 * refactor: index.html 경로 src 외부 public 폴더로 이동 * feat: api 엔드포인트 변경 dev server에서만 msw 적용하도록 변경 * 절대 경로 재설정 (#71) chore: import 절대경로 path 재설정 * 불필요한 환경 변수 플러그인 제거 (#73) fix: build 오류 수정 웹팩 dev 환경변수 플러그인 제거 * Label 컴포넌트 구현 (#88) feat: Label 컴포넌트 구현 * 서포터 선택 결과 페이지 구현 (#90) feat: 서포터 선택 결과 페이지 구현 * 메인 페이지 및 상세 게시글 리뷰 상태 라벨 추가 (#91) * feat: mockData에 reviewStatus 추가 * feat: reviewStatus 타입 및 Label text 추가 * feat: 메인 페이지에 리뷰 상태 Label 추가 * feat: 러너 게시글 상세 페이지에 리뷰 상태 Label 추가 * 서포터 선택 시 확인 모달창 구현 (#92) feat: 서포터 선택 시 확인 모달창 구현 * 서포터 선택 페이지 구현 (#103) * feat: 서포터 선택페이지 라우팅 추가 * asset: github-icon svg파일 추가 * feat: 서포터 선택 페이지 msw 핸들러 추가 * feat: SupporterSelectPage 페이지 컴포넌트 추가 * feat: 서포터 선택 페이지 관련 타입 추가 * feat: SupporterSelectItem, List 컴포넌트 추가 * feat: ConfirmModal 컴포넌트 Props 추가 * feat: RunnerPostCreatePage 페이지 컴포넌트 api 기능 제거 * fix: supportSelect 타입 오류 수정 * fix: 저장 누락 반영 --------- Co-authored-by: 상규 * CI를 위한 테스트 코드 작성 (#111) * chore: testing library react 및 jest 패키지 설정 * test: CI를 위한 컴포넌트 테스트코드 작성 * CI설정 with GitHub Actions (#113) * style: 팀 컨벤션에 맞게 프론트엔드 최상위 폴더명 변경 * chore: 프론트엔드 CI 설정 * ConfirmModal 컴포넌트 테스트코드 작성 (#116) * test: ConfirmModal 컴포넌트 테스트코드 작성 * fix: workflow -> workflows 수정 * fix: github action working directory 수정 * API 명세 변경에 따른 기능 추가 (#123) * fix: types 파일명 변경을 위한 파일 삭제 * feat: API 명세에 맞춰 타입 추가 및 mock data 변경 * feat: 러너 게시글 상세 페이지 삭제, 코드 보러가기 기능 추가 * style: 러너 상세 게시글 타입 이름 수정 * refactor: API 명세에 맞춘 타입을 import 하도록 변경 * 로그인 페이지 구현 (#124) * feat: 헤더에 로그인 버튼 클릭시 로그인 페이지로 이동하는 기능 추가 * feat: 로그인 페이지 컴포넌트 구현 * fix: asset경로 절대경로로 수정 --------- Co-authored-by: 상규 * 서포터 선택 목록 -> 글 생성할 때 띄우는 기능 구현 (#128) * feat: InputBox font-size, font-weight props 추가 * feat: 서포터 리스트 mockData 추가 * feat: 서포터 선택 목록 페이지를 모달 컴포넌트로 변경 * design: Textarea border 수정 * fix: TagInput을 form태그 안에 넣을 경우 submit이 되는 오류 수정 * feat: 서포터 선택 목록 모달 기능 구현 * fix: cherry-pick conflict 해결 * fix: type 오류 수정 * feat: 프론트엔드 로컬에서 백엔드 서버 api 요청을 위한 proxy 포트 설정 * feat: api 명세 변경에 따른 컴포넌트 props 수정 * 서버 api 엔드포인트 변경 (#133) * feat: 변경된 api 명세에 따른 msw uri 수정 * feat: 배포 서버 base url 수정 * feat: 삭제 확인 모달창 구현 * test: 확인 모달창 변경에 따른 테스트코드 작성 * 사용하지 않는 기능 숨기기 (#137) * design: Layout margin-bottom 추가 * design: 리뷰 진행중 라벨 디자인 추가 * feat: 준비중인 기능 알림 메시지 추가 * feat: 사용하지 않는 기능 주석 처리 * webpack prod publicPath 재설정 (#153) * fix: 이미지 경로 및 새로고침 오류 수정 index.html base 태그 설정 * fix: base 태그 사용 대신 webpack publicPath 변경 상대경로를 사용하지 않도록 변경 --------- Co-authored-by: KangSan Lee Co-authored-by: 남상규 <103256030+tkdrb12@users.noreply.github.com> Co-authored-by: 상규 Co-authored-by: 에이든 Co-authored-by: 남상규 * 러너 게시글 조회할 때 조회수 증가 기능 구현 (#148) * feat: 러너 게시글 조회수 증가 기능 구현 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * feat: 러너 게시글 조회수 증가 기능 구현 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * docker network 설정 (#156) fix: docker network 설정 * fix: docker network 설정 (#158) * 백엔드 배포 작업 (#159) * 깃허브 액션 CI 워크플로 작성 (#6) feat: 깃허브 액션 CI 워크플로 작성 * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * docs: CI 스크립트 수정 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * docs: CI 스크립트 내의 프로젝트 빌드 파일 경로 수정 * docs: CI 오류 확인 * docs: CI gradlew 경로로 이동 * docs: CI 경로 한 run에 설정 * docs: CI 성공 * 스프링 기본 구조 설계 (#18) * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * feat: request dto 생성 * feat: service 레이어 구현 * style: 주석 추가 * feat: Controller 레이어 구현 * refactor: update 한 후에 id 받아오도록 수정 * test: update 성공 테스트 작성 * refactor: Tag 에서 BaseEntity 제거 * feat: Tag 개수 증가 및 감소, 비교 로직 추가 * test: 테스트 displayName 변경 * feat: 초기 tag 생성 기능 구현 * feat: 필드 update 기능 구현 * feat: request dto 에 LocalDateTime getter 생성 * feat: tag 이름으로 tag 조회 기능 구현 * feat: id 로 RunnerPostTag 목록 조회 기능 구현 * feat: 러너 게시글 수정 service 구현 * refactor: dto 이름 변경 * refactor: equalsAndHashCode 적용 * test: service 테스트 작성 * Revert "러너 게시글 수정 API 구현" (#36) * 러너 게시글 등록 API 구현 (#30) * feat: RunnerPost VO에 Equals&Hashcode 추가 * feat: TagRepository 에 Tag 이름으로 조회하는 로직 추가 * feat: TagName name 컬럼 unique 제약 조건 추가 * feat: Tag 기본 생성 기능 및 count 증가 기능 추가 * feat: RunnerPostTag 추가 기능 구현 * refactor: RunnerPostTag create 메서드 삭제 * feat: WatchedCount 기본 생성 기능 구현 * feat: ChattingRoomCount 기본 생성 기능 구현 * feat: RunnerPost 기본 생성 기능 구현 * feat: RunnerRepository에서 Runner와 Member를 조인해서 가져오는 기능 구현 * feat: RunnerService에서 Runner와 Member를 조인해서 가져오는 기능 구현 * refactor: TagCount method 이름 변경 * feat: RunnerPost 생성 API 구현 * feat: RunnerPost 에 Tag 를 전체 추가하는 기능 구현 * style: RunnerPostService 줄바꿈 제거 * fix: Join fetch 조건 오류 해결 * feat: RunnerPostController createRunnerPost 에서 create 반환 변수 명 변경 * refactor: create 메서드 명을 createRunnerPost 로 변경 * style: 공백 정리 * refactor: findByIdJoinMember 를 joinMemberByRunnerId 로 변경 * refactor: TagCount 의 기본값을 항상 새로운 객체로 반환 하도록 수정 * refactor: WatchedCount 의 기본값을 항상 새로운 값으로 반환하도록 변경 * refactor: ChattingRoomCount 의 기본값을 항상 새로운 값으로 변경하도록 변경 * 러너 게시글 상세 조회 및 삭제 API 구현 (#28) * feat: 러너 게시글에 러너 게시글 태그 추가 기능 구현 * feat: RunnerPost 상세 조회 및 삭제 기능 추가 * feat: Tag 레포지터리에 TagCount 감소 기능 구현 * feat: RunnerPost 식별자값으로 RunnerPostTag 목록 조회 기능 구현 * feat: RunnerPost 서비스 상세 조회 및 삭제 기능 구현 * feat: Member 에 ImageUrl 필드 추가 * feat: RunnerPost 컨트롤러 상세 조회 및 삭제 API 구현 * test: ImageUrl null 예외 검증 테스트 구현 * test: DisplayName 내용 수정 * fix: Tag 의 TagCount 수가 줄어든 후 영속성 컨텍스트가 비어지지 않도록 수정 * refactor: Tag 의 TagCount 감소 기능을 변경 감지로 리팩터링 * test: RunnerPost 레포지터리 상세 조회 및 삭제 테스트 분리 * refactor: JPQL 에 사용할 변수명을 @Param 으로 표기하도록 리팩터링 * test: RestAssured 에 @Transactional 제거 * test: RestAssured 응답 객체 변환 기능 수정 * test: 인수테스트 @Disable * fix: RunnerPost 의 deadline 응답 반환 타입 수정 * test: Non Ascii 어노테이션 추가 * refactor: RunnerPostResponse Single 레코드명 리팩터링 * refactor: 레포지터리 Tag 조인 메서드명 리팩터링 * test: 사용하지 않는 인자 삭제 * 러너 게시글 수정 API 구현 (#41) * refactor: id -> runnerPostId 변경 * refactor: UriComponentsBuilder 적용 * refactor: String -> LocalDateTime 으로 변경 * test: 변수에 final 키워드 적용 * refactor: service 에 final 키워드 적용 * refactor: Getter 와 EqualsAndHashCode 어노테이션 위치 변경 * refactor: tag 관련 네이밍 변경 * style: 개행 닫기 * refactor: tagCount 수정 * refactor: import 적용 * refactor: 변수명 변경 * refactor: 어노테이션 위치 조정 * refactor: Tag 는 TimeStamp 저장 안하도록 설정 * refactor: id 로 runnerPost 조회 시 검색되지 않으면 예외 던지도록 설정 * refactor: 메소드명 변경 * test: display name 변경 * refactor: TagCount init 시에 parseInt 하는 방식으로 변경 * 러너 게시글 조회 API 구현 (#32) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 --------- Co-authored-by: jeonghoon * 리뷰 요청 글 전체 조회 API 수정, Tag이름 조회 수정 (#47) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 * fix: 리뷰 요청 글 전체 조회 API path 수정 * fix: Tag 이름 조회 수정 * fix: API 수정에 따른 테스트 코드 수정 * sytle: 사용하지 않는 () 삭제 --------- Co-authored-by: jeonghoon * 러너 포스트 중복 저장 시 발생하는 오류 해결 (#48) * chore: docker 외부 포트 고정 * fix: 중복되어서 저장이 안되는 문제 수정 * 러너 게시글 태그 중복 조회 오류 해결 (#51) fix: 영속성 컨텍스트에 RunnerPostTag가 기존에 존재하고 있으므로 중복되지 않도록 RunnerPost에 RunnerPostTag를 add하는 기능 삭제 * 예외 메시지 반환을 위한 ErrorResponse 객체 생성 (#62) * feat: 새로운 커스텀 Exception 추가 및 이전 버전의 Exception 클래스 수정 * feat: 새로운 커스텀 Exception 도메인별 구현체 및 도메인별 에러 코드 enum 구현 * feat: 베이스 커스텀 Exception 에 HttpStatus 가져오기 기능 구현 * feat: 베이스 에러 응답 객체 구현 * fix: 아직 구현되지 않은 러너 게시글 조회수 증가 기능 삭제 * test: 러너 게시글 마감일 검증 테스트 수정 * feat: 클라이언트 요청 커스텀 Exception 추가 및 병합된 에러 코드 구현 * 러너 게시글 조회 객체 이름 수정 (#63) * feat: 리뷰 요청 글 도메인 구현 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * refactor: 명세에 맞게 변수명 수정 * 테스트 코드를 위한 Fixture 생성 및 RunnerPost 테이블에 status 칼럼 추가 (#74) * refactor: Runner 와 Supporter 에 Grade 를 nullable=false로 설정 * feat: RunnerPost 에 ReviewStatus 추가 * test: MemberFixture 구현 * test: RunnerFixture 구현 * test: SupporterFixture 구현 * test: TagFixture 구현 * test: Fixture 추상클래스로 변경 * test: Fixture 정적 팩토리 메서드 파라미터 이름 변경 * test: Fixture 검증 * RunnerPostTag에 FK 제약 조건 이름 수정 (#66) * feat: RunnerPostTag에 fk 제약조건 이름 변경 * refactor: RunnerPost fk 제약 조건 이름 변경 * refactor: Runner fk 제약 조건 이름 변경 * refactor: Supporter fk 제약 조건 이름 변경 * refactor: RunnerPostTag fk 제약 조건 이름 변경 * 나누어져있는 RunnerPostResponse 통합 (#78) * refactor: dto 통합 * refactor: Profile dto 통합 * 서포터 목록 조회 API 구현 version-test (#93) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * 서포터 목록 조회 API 에서 별점 제거 (#96) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * fix: supporter response 에 별점 제거 * fix: supporter response 에 별점 제거 * 러너 리뷰 요청 게시글 생성 version-test (#94) * feat: RunnerPostService 에서 testversion 저장하는 기능 구현 * feat: RunnerPostController 서포터 식별자를 받아 글을 생성하는 메서드 구현 * refactor: RunnerPost 와 Runner 의 관계를 ManyToOne 으로 변경 * test: 테스트에 SupporterRepository 의존성 추가 * refactor: RunnerPost 변수에 final 키워드 추가 * refactor: CreateReqeustDto 에 timezone 추가 * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * Profile에 isOwner 삭제하고 RunnerPostResponse에 isOwner 추가 (#104) * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * refactor: ProfileResponse에 isOwner 삭제 * refactor: RunnerPostResponse에 isOwner 추가 * test: Response 변경에 따른 테스트 수정 * refactor:isOwner VO 추가 * refactor: 유지보수를 위해 true를 기본값으로 설정 * refactor: ProfileResponse 수정에 의한 변경 * Docker Hub와 GitHub self hosted runner를 이용한 CD 설정 (#106) * docs: Docker 를 이용한 CD 설정 * docs: CD 타겟 브랜치 수정 * 러너 게시글 상세 조회 API 구현 version-test (#109) * Test Fixture 파라미터 타입 수정 (#110) * test: Domain Fixture 메서드 파라미터 타입 수정 * test: 테스트 Fixture 사용 수정 * test: Runner Test Fixture 수정 * 클라이언트 예외를 위한 Validator 추가 (#117) * refactor: ClientRequestException 일반 클래스로 변경 * refactor: ErrorResponse 에서 httpStatus 제거 * refactor: RunnerPostUpdateRequest record로 변경 * refactor: ClientErrorCode 내용 추가 * refactor: record로 생긴 변화 적용 * feat: NotNullValid 어노테이션 추가 * refactor: ErrorResponse 에 getter 추가 * feat: Controller valid 추가 * feat: ValidFuture 어노테이션 추가 * feat: 어노테이션 이름 변경 * feat: Max validator 추가 * feat: request dto에 validation 적용 * refactor: 어노테이션 target 조정 * refactor: tag 값이 없을 때 Bad Request 보내도록 수정 * CORS 설정 및 Profile을 RunnerProfile로 변경 (#120) * feat: cors 설정 * refactor: MemberProfile 을 RunnerProfile 로 수정 * feat: cors HttpMethod 에 PATCH 추가 * 환경 변수 보호를 위한 서브 모듈 연결 (#125) * feat: 서브 모듈 추가 * feat: 서브 모듈 환경 설정 * fix: 실험을 위해 현재 브랜치로 CD 실행하도록 변경 * refactor: Dockerfile 경로 변경 * refactor: 실험을 위해 공백 푸시 * refactor: 실험을 성공 후 공백 다시 추가 * refactor: CD 브랜치 dev/BE로 변경 * refactor: CI/CD 파일명 변경 * String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 (#127) * feat: String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 * refactor: RunnerPostCreateTestRequest 에서 컨버터를 사용하도록 변경 * feat: Json으로 직렬화될 때 custom 날짜 형식으로 변경되도록 수정 * refactor: RunnerPostresponse 에서 JsonFormat을 사용하지 않도록 변경 * test: StringDateToLocalDateTime 테스트 방식 변경 * test: ConverterConfigTest를 SpringBootTest 에서 WebMvcTest 로 변경 * test: StringDateToLocalDateTimeConverter 실패 테스트 추가 * feat: DateTimeConverter desiralizer 구현 * test: ConverterConfigTest 실패 테스트 수정 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * 백엔드 프로덕션 컨테이너 세팅 (#132) * chore: Dockerfile 실행 환경 dev, prod 분리 및 사용하지 않는 Docker Image 삭제 명령어 추가 * chore: ci/cd deploy 환경 분리 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * secret 파일 pull (#139) fix: secret 파일 pull * secret 파일 pull (#141) fix: secret 파일 pull * secret 파일 pull (#142) fix: secret 파일 pull * dev 환경에 방언 추가 (#143) * fix: secret 파일 pull * refactor: secret 변경 * dev에 prod db 적용해보기 (#144) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * cicd 파일 및 환경 변수 원상복구 (#145) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * secret 변경 (#146) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * 도커 secret 수정 (#147) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * refactor: secret 수정 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * 러너 게시글 조회할 때 조회수 증가 기능 구현 (#148) * feat: 러너 게시글 조회수 증가 기능 구현 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * feat: 러너 게시글 조회수 증가 기능 구현 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * docker network 설정 (#156) fix: docker network 설정 * fix: docker network 설정 (#158) --------- Co-authored-by: HyunSeo Park (Hyena) Co-authored-by: Ethan Co-authored-by: 은비 <67318165+eunbii0213@users.noreply.github.com> Co-authored-by: eunbii0213 * cd 파일 재설정 (#160) fix: cd 파일 재설정 * 백엔드 배포 작업 (#161) * 깃허브 액션 CI 워크플로 작성 (#6) feat: 깃허브 액션 CI 워크플로 작성 * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * docs: CI 스크립트 수정 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * docs: CI 스크립트 내의 프로젝트 빌드 파일 경로 수정 * docs: CI 오류 확인 * docs: CI gradlew 경로로 이동 * docs: CI 경로 한 run에 설정 * docs: CI 성공 * 스프링 기본 구조 설계 (#18) * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * feat: request dto 생성 * feat: service 레이어 구현 * style: 주석 추가 * feat: Controller 레이어 구현 * refactor: update 한 후에 id 받아오도록 수정 * test: update 성공 테스트 작성 * refactor: Tag 에서 BaseEntity 제거 * feat: Tag 개수 증가 및 감소, 비교 로직 추가 * test: 테스트 displayName 변경 * feat: 초기 tag 생성 기능 구현 * feat: 필드 update 기능 구현 * feat: request dto 에 LocalDateTime getter 생성 * feat: tag 이름으로 tag 조회 기능 구현 * feat: id 로 RunnerPostTag 목록 조회 기능 구현 * feat: 러너 게시글 수정 service 구현 * refactor: dto 이름 변경 * refactor: equalsAndHashCode 적용 * test: service 테스트 작성 * Revert "러너 게시글 수정 API 구현" (#36) * 러너 게시글 등록 API 구현 (#30) * feat: RunnerPost VO에 Equals&Hashcode 추가 * feat: TagRepository 에 Tag 이름으로 조회하는 로직 추가 * feat: TagName name 컬럼 unique 제약 조건 추가 * feat: Tag 기본 생성 기능 및 count 증가 기능 추가 * feat: RunnerPostTag 추가 기능 구현 * refactor: RunnerPostTag create 메서드 삭제 * feat: WatchedCount 기본 생성 기능 구현 * feat: ChattingRoomCount 기본 생성 기능 구현 * feat: RunnerPost 기본 생성 기능 구현 * feat: RunnerRepository에서 Runner와 Member를 조인해서 가져오는 기능 구현 * feat: RunnerService에서 Runner와 Member를 조인해서 가져오는 기능 구현 * refactor: TagCount method 이름 변경 * feat: RunnerPost 생성 API 구현 * feat: RunnerPost 에 Tag 를 전체 추가하는 기능 구현 * style: RunnerPostService 줄바꿈 제거 * fix: Join fetch 조건 오류 해결 * feat: RunnerPostController createRunnerPost 에서 create 반환 변수 명 변경 * refactor: create 메서드 명을 createRunnerPost 로 변경 * style: 공백 정리 * refactor: findByIdJoinMember 를 joinMemberByRunnerId 로 변경 * refactor: TagCount 의 기본값을 항상 새로운 객체로 반환 하도록 수정 * refactor: WatchedCount 의 기본값을 항상 새로운 값으로 반환하도록 변경 * refactor: ChattingRoomCount 의 기본값을 항상 새로운 값으로 변경하도록 변경 * 러너 게시글 상세 조회 및 삭제 API 구현 (#28) * feat: 러너 게시글에 러너 게시글 태그 추가 기능 구현 * feat: RunnerPost 상세 조회 및 삭제 기능 추가 * feat: Tag 레포지터리에 TagCount 감소 기능 구현 * feat: RunnerPost 식별자값으로 RunnerPostTag 목록 조회 기능 구현 * feat: RunnerPost 서비스 상세 조회 및 삭제 기능 구현 * feat: Member 에 ImageUrl 필드 추가 * feat: RunnerPost 컨트롤러 상세 조회 및 삭제 API 구현 * test: ImageUrl null 예외 검증 테스트 구현 * test: DisplayName 내용 수정 * fix: Tag 의 TagCount 수가 줄어든 후 영속성 컨텍스트가 비어지지 않도록 수정 * refactor: Tag 의 TagCount 감소 기능을 변경 감지로 리팩터링 * test: RunnerPost 레포지터리 상세 조회 및 삭제 테스트 분리 * refactor: JPQL 에 사용할 변수명을 @Param 으로 표기하도록 리팩터링 * test: RestAssured 에 @Transactional 제거 * test: RestAssured 응답 객체 변환 기능 수정 * test: 인수테스트 @Disable * fix: RunnerPost 의 deadline 응답 반환 타입 수정 * test: Non Ascii 어노테이션 추가 * refactor: RunnerPostResponse Single 레코드명 리팩터링 * refactor: 레포지터리 Tag 조인 메서드명 리팩터링 * test: 사용하지 않는 인자 삭제 * 러너 게시글 수정 API 구현 (#41) * refactor: id -> runnerPostId 변경 * refactor: UriComponentsBuilder 적용 * refactor: String -> LocalDateTime 으로 변경 * test: 변수에 final 키워드 적용 * refactor: service 에 final 키워드 적용 * refactor: Getter 와 EqualsAndHashCode 어노테이션 위치 변경 * refactor: tag 관련 네이밍 변경 * style: 개행 닫기 * refactor: tagCount 수정 * refactor: import 적용 * refactor: 변수명 변경 * refactor: 어노테이션 위치 조정 * refactor: Tag 는 TimeStamp 저장 안하도록 설정 * refactor: id 로 runnerPost 조회 시 검색되지 않으면 예외 던지도록 설정 * refactor: 메소드명 변경 * test: display name 변경 * refactor: TagCount init 시에 parseInt 하는 방식으로 변경 * 러너 게시글 조회 API 구현 (#32) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 --------- Co-authored-by: jeonghoon * 리뷰 요청 글 전체 조회 API 수정, Tag이름 조회 수정 (#47) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 * fix: 리뷰 요청 글 전체 조회 API path 수정 * fix: Tag 이름 조회 수정 * fix: API 수정에 따른 테스트 코드 수정 * sytle: 사용하지 않는 () 삭제 --------- Co-authored-by: jeonghoon * 러너 포스트 중복 저장 시 발생하는 오류 해결 (#48) * chore: docker 외부 포트 고정 * fix: 중복되어서 저장이 안되는 문제 수정 * 러너 게시글 태그 중복 조회 오류 해결 (#51) fix: 영속성 컨텍스트에 RunnerPostTag가 기존에 존재하고 있으므로 중복되지 않도록 RunnerPost에 RunnerPostTag를 add하는 기능 삭제 * 예외 메시지 반환을 위한 ErrorResponse 객체 생성 (#62) * feat: 새로운 커스텀 Exception 추가 및 이전 버전의 Exception 클래스 수정 * feat: 새로운 커스텀 Exception 도메인별 구현체 및 도메인별 에러 코드 enum 구현 * feat: 베이스 커스텀 Exception 에 HttpStatus 가져오기 기능 구현 * feat: 베이스 에러 응답 객체 구현 * fix: 아직 구현되지 않은 러너 게시글 조회수 증가 기능 삭제 * test: 러너 게시글 마감일 검증 테스트 수정 * feat: 클라이언트 요청 커스텀 Exception 추가 및 병합된 에러 코드 구현 * 러너 게시글 조회 객체 이름 수정 (#63) * feat: 리뷰 요청 글 도메인 구현 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * refactor: 명세에 맞게 변수명 수정 * 테스트 코드를 위한 Fixture 생성 및 RunnerPost 테이블에 status 칼럼 추가 (#74) * refactor: Runner 와 Supporter 에 Grade 를 nullable=false로 설정 * feat: RunnerPost 에 ReviewStatus 추가 * test: MemberFixture 구현 * test: RunnerFixture 구현 * test: SupporterFixture 구현 * test: TagFixture 구현 * test: Fixture 추상클래스로 변경 * test: Fixture 정적 팩토리 메서드 파라미터 이름 변경 * test: Fixture 검증 * RunnerPostTag에 FK 제약 조건 이름 수정 (#66) * feat: RunnerPostTag에 fk 제약조건 이름 변경 * refactor: RunnerPost fk 제약 조건 이름 변경 * refactor: Runner fk 제약 조건 이름 변경 * refactor: Supporter fk 제약 조건 이름 변경 * refactor: RunnerPostTag fk 제약 조건 이름 변경 * 나누어져있는 RunnerPostResponse 통합 (#78) * refactor: dto 통합 * refactor: Profile dto 통합 * 서포터 목록 조회 API 구현 version-test (#93) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * 서포터 목록 조회 API 에서 별점 제거 (#96) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * fix: supporter response 에 별점 제거 * fix: supporter response 에 별점 제거 * 러너 리뷰 요청 게시글 생성 version-test (#94) * feat: RunnerPostService 에서 testversion 저장하는 기능 구현 * feat: RunnerPostController 서포터 식별자를 받아 글을 생성하는 메서드 구현 * refactor: RunnerPost 와 Runner 의 관계를 ManyToOne 으로 변경 * test: 테스트에 SupporterRepository 의존성 추가 * refactor: RunnerPost 변수에 final 키워드 추가 * refactor: CreateReqeustDto 에 timezone 추가 * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * Profile에 isOwner 삭제하고 RunnerPostResponse에 isOwner 추가 (#104) * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * refactor: ProfileResponse에 isOwner 삭제 * refactor: RunnerPostResponse에 isOwner 추가 * test: Response 변경에 따른 테스트 수정 * refactor:isOwner VO 추가 * refactor: 유지보수를 위해 true를 기본값으로 설정 * refactor: ProfileResponse 수정에 의한 변경 * Docker Hub와 GitHub self hosted runner를 이용한 CD 설정 (#106) * docs: Docker 를 이용한 CD 설정 * docs: CD 타겟 브랜치 수정 * 러너 게시글 상세 조회 API 구현 version-test (#109) * Test Fixture 파라미터 타입 수정 (#110) * test: Domain Fixture 메서드 파라미터 타입 수정 * test: 테스트 Fixture 사용 수정 * test: Runner Test Fixture 수정 * 클라이언트 예외를 위한 Validator 추가 (#117) * refactor: ClientRequestException 일반 클래스로 변경 * refactor: ErrorResponse 에서 httpStatus 제거 * refactor: RunnerPostUpdateRequest record로 변경 * refactor: ClientErrorCode 내용 추가 * refactor: record로 생긴 변화 적용 * feat: NotNullValid 어노테이션 추가 * refactor: ErrorResponse 에 getter 추가 * feat: Controller valid 추가 * feat: ValidFuture 어노테이션 추가 * feat: 어노테이션 이름 변경 * feat: Max validator 추가 * feat: request dto에 validation 적용 * refactor: 어노테이션 target 조정 * refactor: tag 값이 없을 때 Bad Request 보내도록 수정 * CORS 설정 및 Profile을 RunnerProfile로 변경 (#120) * feat: cors 설정 * refactor: MemberProfile 을 RunnerProfile 로 수정 * feat: cors HttpMethod 에 PATCH 추가 * 환경 변수 보호를 위한 서브 모듈 연결 (#125) * feat: 서브 모듈 추가 * feat: 서브 모듈 환경 설정 * fix: 실험을 위해 현재 브랜치로 CD 실행하도록 변경 * refactor: Dockerfile 경로 변경 * refactor: 실험을 위해 공백 푸시 * refactor: 실험을 성공 후 공백 다시 추가 * refactor: CD 브랜치 dev/BE로 변경 * refactor: CI/CD 파일명 변경 * String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 (#127) * feat: String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 * refactor: RunnerPostCreateTestRequest 에서 컨버터를 사용하도록 변경 * feat: Json으로 직렬화될 때 custom 날짜 형식으로 변경되도록 수정 * refactor: RunnerPostresponse 에서 JsonFormat을 사용하지 않도록 변경 * test: StringDateToLocalDateTime 테스트 방식 변경 * test: ConverterConfigTest를 SpringBootTest 에서 WebMvcTest 로 변경 * test: StringDateToLocalDateTimeConverter 실패 테스트 추가 * feat: DateTimeConverter desiralizer 구현 * test: ConverterConfigTest 실패 테스트 수정 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * 백엔드 프로덕션 컨테이너 세팅 (#132) * chore: Dockerfile 실행 환경 dev, prod 분리 및 사용하지 않는 Docker Image 삭제 명령어 추가 * chore: ci/cd deploy 환경 분리 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * secret 파일 pull (#139) fix: secret 파일 pull * secret 파일 pull (#141) fix: secret 파일 pull * secret 파일 pull (#142) fix: secret 파일 pull * dev 환경에 방언 추가 (#143) * fix: secret 파일 pull * refactor: secret 변경 * dev에 prod db 적용해보기 (#144) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * cicd 파일 및 환경 변수 원상복구 (#145) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * secret 변경 (#146) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * 도커 secret 수정 (#147) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * refactor: secret 수정 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * 러너 게시글 조회할 때 조회수 증가 기능 구현 (#148) * feat: 러너 게시글 조회수 증가 기능 구현 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * feat: 러너 게시글 조회수 증가 기능 구현 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * docker network 설정 (#156) fix: docker network 설정 * fix: docker network 설정 (#158) * cd 파일 재설정 (#160) fix: cd 파일 재설정 --------- Co-authored-by: HyunSeo Park (Hyena) Co-authored-by: Ethan Co-authored-by: 은비 <67318165+eunbii0213@users.noreply.github.com> Co-authored-by: eunbii0213 * 프론트엔드 코드 삭제 (#167) fix: 프론트엔드 코드 삭제 * 서포터 전체 조회 기술 태그 목록 추가 version-test (#162) * feat: 서포터 전체 조회 기술 태그 목록 추가 version-test * fix: 서포터 태그 목록 List 로 변환 * test: supporterTechnicalTags 조회 테스트 수정 * test: supporterTechnicalTags 조회 테스트 assertAll 사용 * CORS 환경별로 allowed origin 변경 (#169) * feat: submodule 업데이트 * feat: gitignore 에 application.yml 추가 * feat: application.yml 중요 환경 변수 숨기기 * 테스트 코드 Fixture 통일 (#172) * test: assure 패키지의 MemberFixture 삭제 * test: assure 패키지의 RunnerFixture 삭제 * test: assure 패키지의 RunnerPostFixture 삭제 * test: domain 에 있는 RunnerFixture 삭제 * test: domain 에 있는 RunnerPostData 삭제 * test: RunnerRepositoryTest 가 RepositoryTestConfig 를 상속 받도록 변경 * test: TagRepositoryTest 가 RepositoryTestConfig 를 상속 받도록 변경 * domain exception이 server exception을 상속받도록 수정 (#177) * refactor: domain exception을 server exception을 상속받도록 수정 * refactor: 사용하지 않는 MemberException 제거 * dev 환경 변수 수정 (#188) fix: 환경 변수 수정 * Github Social Login 4차 구현 (#164) * chore: HttpInterface 사용을 위한 의존성 추가 * feat: 소셜 타입 구분을 위한 OauthType 추가 * feat: AuthCode 를 받아오기 위한 인터페이스와 확장성을 고려한 컴포시트 패턴 추가 * feat: Oauth 정보를 받아올 인터페이스와 확장성을 위한 컴포시트 패턴 구현 * feat: 외부 변수를 받아올 GithubOauthConfig 구현 및 최상단 애플리케이션에 읽어올 수 있도록 스캔 어노테이션 적용 * feat: Github Oauth 요청을 위한 구현체 및 스프링 컨테이너에 빈 등록을 위한 HttpInterfaceConfig 설정 구현 * feat: Oauth 로그인을 받을 컨트롤러와 저장 및 조회를 위한 Oauth 레포지터리, Oauth 서비스 구현 * chore: gradle 에 auth0 jwt 의존성 추가 * feat: AccessToken dto 구현 추가 및 OauthInformation 내부 회사 정보 삭제 * feat: OauthType enum 컨버터 구현 * feat: Jwt 인코더, Jwt 디코더 구현 * feat: Oauth 전용 Runner, Supporter 레포지터리 구현 * feat: Runner, Supporter Principal 어노테이션 및 Runner, Supporter ArgumentResolver 구현 * feat: Oauth 서비스 Jwt 인코드 기능 및 Member, Runner, Supporter 신규 사용자 자동 회원가입 추가, Oauth 컨트롤러 Jwt 헤더 반환 구현 * test: WebMvcTest 시 bean 스캔 문제 해결을 위한 커스텀 MockMvcTest 어노테이션 구현 및 RestdocsConfig 수정 * test: Oauth Api 테스트 및 기존 Api Test 를 MockMvcTest 로 수정 * refactor: auth0 의존성을 삭제하고 jjwt 추가 후 HS256 를 이용하도록 JWT 설정 변경 * refactor: AccessToken JWT 내부 Claim 에서 삭제 * refactor: oauth 패키지 이동 * refactor: JWT 디코더 내부 검증 변경 * refactor: Oauth 사용자 정의 예외로 변경 * chore: 서브 모듈 최신화 * fix: 외부 환경 변수 자바 객체 주입을 문자열로 수정 * refactor: cors origin 외부 환경 변수 주입하도록 변경 * refactor: JWT Signature 클라이언트 요청 예외 에러 코드 변경 * refactor: Oauth Email 에러 코드 JWT 로 변경 * feat: Jwt 디코더 에러 코드 추가 * style: 문자열 상수화 및 메서드 파라미터 컨벤션 적용 * dev 환경 변수 수정 (#188) fix: 환경 변수 수정 * refactor: AccessToken 명 SocialToken 으로 변경 및 Auth 관련 패키지 분리 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * Oauth API prefix 수정 및 깃허브 소셜 로그인 Http Method 수정 (#193) * fix: Oauth API prefix 수정 및 깃허브 소셜 로그인 Http Method 수정 * test: Oauth Api Test prefix 수정 * 서포터 피드백 등록 API 구현 (#186) * feat: Description VO 구현 * feat: SupporterFeedback 엔티티 구현 * chore: Feedback 관련 패키지 변경 * refactor: SupporterFeedback Entity 에 nullable 추가 * feat: Runner 에 equals&hashcode 추가 * feat: SupporterFeedback 등록 API 구현 * refactor: FeedbackBusinessException 내부 클래스 삭제 * test: 안 쓰는 인스턴스 변수 로컬 변수로 변경 * test: SupporterFeedback 관련 Fixture 구현 * test: RestAssuredTest 컨벤션에 맞게 변경 * refactor: FeedbackService 에 Transaction 추가 * refactor: FeedbackService Runner 찾는 과정 삭제 * refactor: 로그인 기능 추가에 따른 @AuthRunner 사용하도록 변경 * refactor: RunnerPost 와 SupporterFeedback 과의 관계를 OneToOne 으로 변경 * 모든 도메인 객체 exception Notnull method 제거 (#184) * refactor: domain exception을 server exception을 상속받도록 수정 * refactor: 사용하지 않는 MemberException 제거 * refactor: 모든 도메인 객체 exception Notnull method 제거 * refactor: stream 컨벤션에 맞도록 수정 * refactor: conflict 해결 * test: 에러메세지 수정 * refactor: TagException 상세히 분할(RunnerPostTag, SupporterTechnicalTag) * refactor: TagException 상세히 분할(RunnerPostTag, SupporterTechnicalTag)에 따른 테스트 수정 * refactor: stream 점 위치 수정 * 러너 본인 프로필 조회 기능 구현 (#183) * refactor: runner response 는 runner 패키지로 이동 * feat: runner 프로필 조회 response 생성 * refactor: 바뀐 response 패키지 적용 * test: IntroductionFixture 추가 * test: 인수테스트 추가 * test: 중복된 테스트 제거 * feat: runner 식별자로 runnerPost 조회 쿼리 생성 * feat: runner 식별자로 runnerPost 조회 서비스 로직 구현 * feat: runner 본인 프로필 조회 기능 구현 * refactor: auth 적용 * refactor: restdocs 관련 테스트 주석 처리 * test: runner fixture 에 introduction 추가 * refactor: controller 메소드 이름 변경 * Member Email 값 객체를 SocialId 값 객체로 변경 (#196) * refactor: Member Email 값 객체를 SocialId 로 변경 * refactor: Member 의 SocialId 컬럼명 수정 * 모든 API 인증 기능 추가 (#202) * refactor: FeedbackController 에 사용하지 않는 변수 삭제 * refactor: runnerPost auth 어노테이션 붙이기 * refactor: 게시물 상세 조회 시 auth 선택적으로 적용하도록 변경 * refactor: 게시물 수정, 삭제 시에 Auth 어노테이션 추가 * refactor: 서포터 전체 조회 반환 필드 변경 * test: 변경 사항에 따른 테스트 코드 변경 * 클라이언트 에러 HttpStatus 수정 (#206) refactor: 클라이언트 에러 HttpStatus 수정 * 게시글 최신순으로 정렬 (#207) * feat: 생성 순으로 게시글 불러오는 쿼리 생성 * feat: 게시글 생성 순으로 정렬 * refactor: yml 업데이트 * 백엔드 배포 작업 (#209) * 깃허브 액션 CI 워크플로 작성 (#6) feat: 깃허브 액션 CI 워크플로 작성 * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * docs: CI 스크립트 수정 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * docs: CI 스크립트 내의 프로젝트 빌드 파일 경로 수정 * docs: CI 오류 확인 * docs: CI gradlew 경로로 이동 * docs: CI 경로 한 run에 설정 * docs: CI 성공 * 스프링 기본 구조 설계 (#18) * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * feat: request dto 생성 * feat: service 레이어 구현 * style: 주석 추가 * feat: Controller 레이어 구현 * refactor: update 한 후에 id 받아오도록 수정 * test: update 성공 테스트 작성 * refactor: Tag 에서 BaseEntity 제거 * feat: Tag 개수 증가 및 감소, 비교 로직 추가 * test: 테스트 displayName 변경 * feat: 초기 tag 생성 기능 구현 * feat: 필드 update 기능 구현 * feat: request dto 에 LocalDateTime getter 생성 * feat: tag 이름으로 tag 조회 기능 구현 * feat: id 로 RunnerPostTag 목록 조회 기능 구현 * feat: 러너 게시글 수정 service 구현 * refactor: dto 이름 변경 * refactor: equalsAndHashCode 적용 * test: service 테스트 작성 * Revert "러너 게시글 수정 API 구현" (#36) * 러너 게시글 등록 API 구현 (#30) * feat: RunnerPost VO에 Equals&Hashcode 추가 * feat: TagRepository 에 Tag 이름으로 조회하는 로직 추가 * feat: TagName name 컬럼 unique 제약 조건 추가 * feat: Tag 기본 생성 기능 및 count 증가 기능 추가 * feat: RunnerPostTag 추가 기능 구현 * refactor: RunnerPostTag create 메서드 삭제 * feat: WatchedCount 기본 생성 기능 구현 * feat: ChattingRoomCount 기본 생성 기능 구현 * feat: RunnerPost 기본 생성 기능 구현 * feat: RunnerRepository에서 Runner와 Member를 조인해서 가져오는 기능 구현 * feat: RunnerService에서 Runner와 Member를 조인해서 가져오는 기능 구현 * refactor: TagCount method 이름 변경 * feat: RunnerPost 생성 API 구현 * feat: RunnerPost 에 Tag 를 전체 추가하는 기능 구현 * style: RunnerPostService 줄바꿈 제거 * fix: Join fetch 조건 오류 해결 * feat: RunnerPostController createRunnerPost 에서 create 반환 변수 명 변경 * refactor: create 메서드 명을 createRunnerPost 로 변경 * style: 공백 정리 * refactor: findByIdJoinMember 를 joinMemberByRunnerId 로 변경 * refactor: TagCount 의 기본값을 항상 새로운 객체로 반환 하도록 수정 * refactor: WatchedCount 의 기본값을 항상 새로운 값으로 반환하도록 변경 * refactor: ChattingRoomCount 의 기본값을 항상 새로운 값으로 변경하도록 변경 * 러너 게시글 상세 조회 및 삭제 API 구현 (#28) * feat: 러너 게시글에 러너 게시글 태그 추가 기능 구현 * feat: RunnerPost 상세 조회 및 삭제 기능 추가 * feat: Tag 레포지터리에 TagCount 감소 기능 구현 * feat: RunnerPost 식별자값으로 RunnerPostTag 목록 조회 기능 구현 * feat: RunnerPost 서비스 상세 조회 및 삭제 기능 구현 * feat: Member 에 ImageUrl 필드 추가 * feat: RunnerPost 컨트롤러 상세 조회 및 삭제 API 구현 * test: ImageUrl null 예외 검증 테스트 구현 * test: DisplayName 내용 수정 * fix: Tag 의 TagCount 수가 줄어든 후 영속성 컨텍스트가 비어지지 않도록 수정 * refactor: Tag 의 TagCount 감소 기능을 변경 감지로 리팩터링 * test: RunnerPost 레포지터리 상세 조회 및 삭제 테스트 분리 * refactor: JPQL 에 사용할 변수명을 @Param 으로 표기하도록 리팩터링 * test: RestAssured 에 @Transactional 제거 * test: RestAssured 응답 객체 변환 기능 수정 * test: 인수테스트 @Disable * fix: RunnerPost 의 deadline 응답 반환 타입 수정 * test: Non Ascii 어노테이션 추가 * refactor: RunnerPostResponse Single 레코드명 리팩터링 * refactor: 레포지터리 Tag 조인 메서드명 리팩터링 * test: 사용하지 않는 인자 삭제 * 러너 게시글 수정 API 구현 (#41) * refactor: id -> runnerPostId 변경 * refactor: UriComponentsBuilder 적용 * refactor: String -> LocalDateTime 으로 변경 * test: 변수에 final 키워드 적용 * refactor: service 에 final 키워드 적용 * refactor: Getter 와 EqualsAndHashCode 어노테이션 위치 변경 * refactor: tag 관련 네이밍 변경 * style: 개행 닫기 * refactor: tagCount 수정 * refactor: import 적용 * refactor: 변수명 변경 * refactor: 어노테이션 위치 조정 * refactor: Tag 는 TimeStamp 저장 안하도록 설정 * refactor: id 로 runnerPost 조회 시 검색되지 않으면 예외 던지도록 설정 * refactor: 메소드명 변경 * test: display name 변경 * refactor: TagCount init 시에 parseInt 하는 방식으로 변경 * 러너 게시글 조회 API 구현 (#32) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 --------- Co-authored-by: jeonghoon * 리뷰 요청 글 전체 조회 API 수정, Tag이름 조회 수정 (#47) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 * fix: 리뷰 요청 글 전체 조회 API path 수정 * fix: Tag 이름 조회 수정 * fix: API 수정에 따른 테스트 코드 수정 * sytle: 사용하지 않는 () 삭제 --------- Co-authored-by: jeonghoon * 러너 포스트 중복 저장 시 발생하는 오류 해결 (#48) * chore: docker 외부 포트 고정 * fix: 중복되어서 저장이 안되는 문제 수정 * 러너 게시글 태그 중복 조회 오류 해결 (#51) fix: 영속성 컨텍스트에 RunnerPostTag가 기존에 존재하고 있으므로 중복되지 않도록 RunnerPost에 RunnerPostTag를 add하는 기능 삭제 * 예외 메시지 반환을 위한 ErrorResponse 객체 생성 (#62) * feat: 새로운 커스텀 Exception 추가 및 이전 버전의 Exception 클래스 수정 * feat: 새로운 커스텀 Exception 도메인별 구현체 및 도메인별 에러 코드 enum 구현 * feat: 베이스 커스텀 Exception 에 HttpStatus 가져오기 기능 구현 * feat: 베이스 에러 응답 객체 구현 * fix: 아직 구현되지 않은 러너 게시글 조회수 증가 기능 삭제 * test: 러너 게시글 마감일 검증 테스트 수정 * feat: 클라이언트 요청 커스텀 Exception 추가 및 병합된 에러 코드 구현 * 러너 게시글 조회 객체 이름 수정 (#63) * feat: 리뷰 요청 글 도메인 구현 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * refactor: 명세에 맞게 변수명 수정 * 테스트 코드를 위한 Fixture 생성 및 RunnerPost 테이블에 status 칼럼 추가 (#74) * refactor: Runner 와 Supporter 에 Grade 를 nullable=false로 설정 * feat: RunnerPost 에 ReviewStatus 추가 * test: MemberFixture 구현 * test: RunnerFixture 구현 * test: SupporterFixture 구현 * test: TagFixture 구현 * test: Fixture 추상클래스로 변경 * test: Fixture 정적 팩토리 메서드 파라미터 이름 변경 * test: Fixture 검증 * RunnerPostTag에 FK 제약 조건 이름 수정 (#66) * feat: RunnerPostTag에 fk 제약조건 이름 변경 * refactor: RunnerPost fk 제약 조건 이름 변경 * refactor: Runner fk 제약 조건 이름 변경 * refactor: Supporter fk 제약 조건 이름 변경 * refactor: RunnerPostTag fk 제약 조건 이름 변경 * 나누어져있는 RunnerPostResponse 통합 (#78) * refactor: dto 통합 * refactor: Profile dto 통합 * 서포터 목록 조회 API 구현 version-test (#93) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * 서포터 목록 조회 API 에서 별점 제거 (#96) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * fix: supporter response 에 별점 제거 * fix: supporter response 에 별점 제거 * 러너 리뷰 요청 게시글 생성 version-test (#94) * feat: RunnerPostService 에서 testversion 저장하는 기능 구현 * feat: RunnerPostController 서포터 식별자를 받아 글을 생성하는 메서드 구현 * refactor: RunnerPost 와 Runner 의 관계를 ManyToOne 으로 변경 * test: 테스트에 SupporterRepository 의존성 추가 * refactor: RunnerPost 변수에 final 키워드 추가 * refactor: CreateReqeustDto 에 timezone 추가 * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * Profile에 isOwner 삭제하고 RunnerPostResponse에 isOwner 추가 (#104) * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * refactor: ProfileResponse에 isOwner 삭제 * refactor: RunnerPostResponse에 isOwner 추가 * test: Response 변경에 따른 테스트 수정 * refactor:isOwner VO 추가 * refactor: 유지보수를 위해 true를 기본값으로 설정 * refactor: ProfileResponse 수정에 의한 변경 * Docker Hub와 GitHub self hosted runner를 이용한 CD 설정 (#106) * docs: Docker 를 이용한 CD 설정 * docs: CD 타겟 브랜치 수정 * 러너 게시글 상세 조회 API 구현 version-test (#109) * Test Fixture 파라미터 타입 수정 (#110) * test: Domain Fixture 메서드 파라미터 타입 수정 * test: 테스트 Fixture 사용 수정 * test: Runner Test Fixture 수정 * 클라이언트 예외를 위한 Validator 추가 (#117) * refactor: ClientRequestException 일반 클래스로 변경 * refactor: ErrorResponse 에서 httpStatus 제거 * refactor: RunnerPostUpdateRequest record로 변경 * refactor: ClientErrorCode 내용 추가 * refactor: record로 생긴 변화 적용 * feat: NotNullValid 어노테이션 추가 * refactor: ErrorResponse 에 getter 추가 * feat: Controller valid 추가 * feat: ValidFuture 어노테이션 추가 * feat: 어노테이션 이름 변경 * feat: Max validator 추가 * feat: request dto에 validation 적용 * refactor: 어노테이션 target 조정 * refactor: tag 값이 없을 때 Bad Request 보내도록 수정 * CORS 설정 및 Profile을 RunnerProfile로 변경 (#120) * feat: cors 설정 * refactor: MemberProfile 을 RunnerProfile 로 수정 * feat: cors HttpMethod 에 PATCH 추가 * 환경 변수 보호를 위한 서브 모듈 연결 (#125) * feat: 서브 모듈 추가 * feat: 서브 모듈 환경 설정 * fix: 실험을 위해 현재 브랜치로 CD 실행하도록 변경 * refactor: Dockerfile 경로 변경 * refactor: 실험을 위해 공백 푸시 * refactor: 실험을 성공 후 공백 다시 추가 * refactor: CD 브랜치 dev/BE로 변경 * refactor: CI/CD 파일명 변경 * String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 (#127) * feat: String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 * refactor: RunnerPostCreateTestRequest 에서 컨버터를 사용하도록 변경 * feat: Json으로 직렬화될 때 custom 날짜 형식으로 변경되도록 수정 * refactor: RunnerPostresponse 에서 JsonFormat을 사용하지 않도록 변경 * test: StringDateToLocalDateTime 테스트 방식 변경 * test: ConverterConfigTest를 SpringBootTest 에서 WebMvcTest 로 변경 * test: StringDateToLocalDateTimeConverter 실패 테스트 추가 * feat: DateTimeConverter desiralizer 구현 * test: ConverterConfigTest 실패 테스트 수정 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * 백엔드 프로덕션 컨테이너 세팅 (#132) * chore: Dockerfile 실행 환경 dev, prod 분리 및 사용하지 않는 Docker Image 삭제 명령어 추가 * chore: ci/cd deploy 환경 분리 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * secret 파일 pull (#139) fix: secret 파일 pull * secret 파일 pull (#141) fix: secret 파일 pull * secret 파일 pull (#142) fix: secret 파일 pull * dev 환경에 방언 추가 (#143) * fix: secret 파일 pull * refactor: secret 변경 * dev에 prod db 적용해보기 (#144) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * cicd 파일 및 환경 변수 원상복구 (#145) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * secret 변경 (#146) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * 도커 secret 수정 (#147) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * refactor: secret 수정 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * 러너 게시글 조회할 때 조회수 증가 기능 구현 (#148) * feat: 러너 게시글 조회수 증가 기능 구현 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * feat: 러너 게시글 조회수 증가 기능 구현 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * docker network 설정 (#156) fix: docker network 설정 * fix: docker network 설정 (#158) * cd 파일 재설정 (#160) fix: cd 파일 재설정 * 프론트엔드 코드 삭제 (#167) fix: 프론트엔드 코드 삭제 * 서포터 전체 조회 기술 태그 목록 추가 version-test (#162) * feat: 서포터 전체 조회 기술 태그 목록 추가 version-test * fix: 서포터 태그 목록 List 로 변환 * test: supporterTechnicalTags 조회 테스트 수정 * test: supporterTechnicalTags 조회 테스트 assertAll 사용 * CORS 환경별로 allowed origin 변경 (#169) * feat: submodule 업데이트 * feat: gitignore 에 application.yml 추가 * feat: application.yml 중요 환경 변수 숨기기 * 테스트 코드 Fixture 통일 (#172) * test: assure 패키지의 MemberFixture 삭제 * test: assure 패키지의 RunnerFixture 삭제 * test: assure 패키지의 RunnerPostFixture 삭제 * test: domain 에 있는 RunnerFixture 삭제 * test: domain 에 있는 RunnerPostData 삭제 * test: RunnerRepositoryTest 가 RepositoryTestConfig 를 상속 받도록 변경 * test: TagRepositoryTest 가 RepositoryTestConfig 를 상속 받도록 변경 * domain exception이 server exception을 상속받도록 수정 (#177) * refactor: domain exception을 server exception을 상속받도록 수정 * refactor: 사용하지 않는 MemberException 제거 * dev 환경 변수 수정 (#188) fix: 환경 변수 수정 * Github Social Login 4차 구현 (#164) * chore: HttpInterface 사용을 위한 의존성 추가 * feat: 소셜 타입 구분을 위한 OauthType 추가 * feat: AuthCode 를 받아오기 위한 인터페이스와 확장성을 고려한 컴포시트 패턴 추가 * feat: Oauth 정보를 받아올 인터페이스와 확장성을 위한 컴포시트 패턴 구현 * feat: 외부 변수를 받아올 GithubOauthConfig 구현 및 최상단 애플리케이션에 읽어올 수 있도록 스캔 어노테이션 적용 * feat: Github Oauth 요청을 위한 구현체 및 스프링 컨테이너에 빈 등록을 위한 HttpInterfaceConfig 설정 구현 * feat: Oauth 로그인을 받을 컨트롤러와 저장 및 조회를 위한 Oauth 레포지터리, Oauth 서비스 구현 * chore: gradle 에 auth0 jwt 의존성 추가 * feat: AccessToken dto 구현 추가 및 OauthInformation 내부 회사 정보 삭제 * feat: OauthType enum 컨버터 구현 * feat: Jwt 인코더, Jwt 디코더 구현 * feat: Oauth 전용 Runner, Supporter 레포지터리 구현 * feat: Runner, Supporter Principal 어노테이션 및 Runner, Supporter ArgumentResolver 구현 * feat: Oauth 서비스 Jwt 인코드 기능 및 Member, Runner, Supporter 신규 사용자 자동 회원가입 추가, Oauth 컨트롤러 Jwt 헤더 반환 구현 * test: WebMvcTest 시 bean 스캔 문제 해결을 위한 커스텀 MockMvcTest 어노테이션 구현 및 RestdocsConfig 수정 * test: Oauth Api 테스트 및 기존 Api Test 를 MockMvcTest 로 수정 * refactor: auth0 의존성을 삭제하고 jjwt 추가 후 HS256 를 이용하도록 JWT 설정 변경 * refactor: AccessToken JWT 내부 Claim 에서 삭제 * refactor: oauth 패키지 이동 * refactor: JWT 디코더 내부 검증 변경 * refactor: Oauth 사용자 정의 예외로 변경 * chore: 서브 모듈 최신화 * fix: 외부 환경 변수 자바 객체 주입을 문자열로 수정 * refactor: cors origin 외부 환경 변수 주입하도록 변경 * refactor: JWT Signature 클라이언트 요청 예외 에러 코드 변경 * refactor: Oauth Email 에러 코드 JWT 로 변경 * feat: Jwt 디코더 에러 코드 추가 * style: 문자열 상수화 및 메서드 파라미터 컨벤션 적용 * dev 환경 변수 수정 (#188) fix: 환경 변수 수정 * refactor: AccessToken 명 SocialToken 으로 변경 및 Auth 관련 패키지 분리 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * Oauth API prefix 수정 및 깃허브 소셜 로그인 Http Method 수정 (#193) * fix: Oauth API prefix 수정 및 깃허브 소셜 로그인 Http Method 수정 * test: Oauth Api Test prefix 수정 * 서포터 피드백 등록 API 구현 (#186) * feat: Description VO 구현 * feat: SupporterFeedback 엔티티 구현 * chore: Feedback 관련 패키지 변경 * refactor: SupporterFeedback Entity 에 nullable 추가 * feat: Runner 에 equals&hashcode 추가 * feat: SupporterFeedback 등록 API 구현 * refactor: FeedbackBusinessException 내부 클래스 삭제 * test: 안 쓰는 인스턴스 변수 로컬 변수로 변경 * test: SupporterFeedback 관련 Fixture 구현 * test: RestAssuredTest 컨벤션에 맞게 변경 * refactor: FeedbackService 에 Transaction 추가 * refactor: FeedbackService Runner 찾는 과정 삭제 * refactor: 로그인 기능 추가에 따른 @AuthRunner 사용하도록 변경 * refactor: RunnerPost 와 SupporterFeedback 과의 관계를 OneToOne 으로 변경 * 모든 도메인 객체 exception Notnull method 제거 (#184) * refactor: domain exception을 server exception을 상속받도록 수정 * refactor: 사용하지 않는 MemberException 제거 * refactor: 모든 도메인 객체 exception Notnull method 제거 * refactor: stream 컨벤션에 맞도록 수정 * refactor: conflict 해결 * test: 에러메세지 수정 * refactor: TagException 상세히 분할(RunnerPostTag, SupporterTechnicalTag) * refactor: TagException 상세히 분할(RunnerPostTag, SupporterTechnicalTag)에 따른 테스트 수정 * refactor: stream 점 위치 수정 * 러너 본인 프로필 조회 기능 구현 (#183) * refactor: runner response 는 runner 패키지로 이동 * feat: runner 프로필 조회 response 생성 * refactor: 바뀐 response 패키지 적용 * test: IntroductionFixture 추가 * test: 인수테스트 추가 * test: 중복된 테스트 제거 * feat: runner 식별자로 runnerPost 조회 쿼리 생성 * feat: runner 식별자로 runnerPost 조회 서비스 로직 구현 * feat: runner 본인 프로필 조회 기능 구현 * refactor: auth 적용 * refactor: restdocs 관련 테스트 주석 처리 * test: runner fixture 에 introduction 추가 * refactor: controller 메소드 이름 변경 * Member Email 값 객체를 SocialId 값 객체로 변경 (#196) * refactor: Member Email 값 객체를 SocialId 로 변경 * refactor: Member 의 SocialId 컬럼명 수정 * 모든 API 인증 기능 추가 (#202) * refactor: FeedbackController 에 사용하지 않는 변수 삭제 * refactor: runnerPost auth 어노테이션 붙이기 * refactor: 게시물 상세 조회 시 auth 선택적으로 적용하도록 변경 * refactor: 게시물 수정, 삭제 시에 Auth 어노테이션 추가 * refactor: 서포터 전체 조회 반환 필드 변경 * test: 변경 사항에 따른 테스트 코드 변경 * 클라이언트 에러 HttpStatus 수정 (#206) refactor: 클라이언트 에러 HttpStatus 수정 * 게시글 최신순으로 정렬 (#207) * feat: 생성 순으로 게시글 불러오는 쿼리 생성 * feat: 게시글 생성 순으로 정렬 * refactor: yml 업데이트 --------- Co-authored-by: HyunSeo Park (Hyena) Co-authored-by: Ethan Co-authored-by: 은비 <67318165+eunbii0213@users.noreply.github.com> Co-authored-by: eunbii0213 * submodule 업데이트 (#212) refactor: submodule 업데이트 * 백엔드 배포 작업 (#213) * 깃허브 액션 CI 워크플로 작성 (#6) feat: 깃허브 액션 CI 워크플로 작성 * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * docs: CI 스크립트 수정 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * docs: CI 스크립트 내의 프로젝트 빌드 파일 경로 수정 * docs: CI 오류 확인 * docs: CI gradlew 경로로 이동 * docs: CI 경로 한 run에 설정 * docs: CI 성공 * 스프링 기본 구조 설계 (#18) * feat: 프로젝트 생성 및 의존성 추가 * chore: logging 설정 * feat: Member Entity 생성 * refactor: Member 패키지 변경 * feat: Runner Entity 생성 * refactor: 모든 entity 에서 embeddedId 제거 * feat: Company null 예외 검증 구현 * feat: Email null 예외 검증 구현 * feat: GithubUrl null 예외 검증 구현 * feat: Name null 예외 검증 구현 * feat: OauthId null 예외 검증 구현 * feat: Member null 예외 검증 구현 * refactor: Grade 패키지 위치 변경 * refactor: TotalRating 매직 넘버 설정 * feat: Supporter 의 VO 객체 생성 * feat: Supporter 생성 기능 구현 * feat: Runner null 예외 검증 구현 * refactor: Runner, Supporter 외래키 nullable = false 로 설정 * feat: Title VO 구현 * feat: Contents VO 구현 * feat: PullRequestUrl VO 구현 * refactor: Contents 타입 varchar 에서 text로 변경 * feat: Deadline VO 구현 * feat: WatchedCount VO 구현 * feat: ChattingRoomCount VO 구현 * fix: 예외 메시지 분리 * style: 클래스 명과 변수 사이 개행 * feat: RunnerPost entity 생성 * refactor: Member 이름 변수명 변경 * feat: TagName VO 구현 * feat: TagCount VO 구현 * feat: Tag entity 생성 * feat: RunnerPostTag entity 생성 * feat: BaseEntity 적용 * feat: Entity JPA repository 구현 * feat: Cascade persist 옵션과 orphanRemoval 옵션 추가 * feat: RunnerPost Controller 및 Service 뼈대 코드 작성 * test: RunnerPostTag 테스트 수정 * refactor: gradle 빌드 파일 삭제 * refactor: gradle 빌드 파일 재생성 * refactor: gradle 빌드 파일 재생성 * feat: request dto 생성 * feat: service 레이어 구현 * style: 주석 추가 * feat: Controller 레이어 구현 * refactor: update 한 후에 id 받아오도록 수정 * test: update 성공 테스트 작성 * refactor: Tag 에서 BaseEntity 제거 * feat: Tag 개수 증가 및 감소, 비교 로직 추가 * test: 테스트 displayName 변경 * feat: 초기 tag 생성 기능 구현 * feat: 필드 update 기능 구현 * feat: request dto 에 LocalDateTime getter 생성 * feat: tag 이름으로 tag 조회 기능 구현 * feat: id 로 RunnerPostTag 목록 조회 기능 구현 * feat: 러너 게시글 수정 service 구현 * refactor: dto 이름 변경 * refactor: equalsAndHashCode 적용 * test: service 테스트 작성 * Revert "러너 게시글 수정 API 구현" (#36) * 러너 게시글 등록 API 구현 (#30) * feat: RunnerPost VO에 Equals&Hashcode 추가 * feat: TagRepository 에 Tag 이름으로 조회하는 로직 추가 * feat: TagName name 컬럼 unique 제약 조건 추가 * feat: Tag 기본 생성 기능 및 count 증가 기능 추가 * feat: RunnerPostTag 추가 기능 구현 * refactor: RunnerPostTag create 메서드 삭제 * feat: WatchedCount 기본 생성 기능 구현 * feat: ChattingRoomCount 기본 생성 기능 구현 * feat: RunnerPost 기본 생성 기능 구현 * feat: RunnerRepository에서 Runner와 Member를 조인해서 가져오는 기능 구현 * feat: RunnerService에서 Runner와 Member를 조인해서 가져오는 기능 구현 * refactor: TagCount method 이름 변경 * feat: RunnerPost 생성 API 구현 * feat: RunnerPost 에 Tag 를 전체 추가하는 기능 구현 * style: RunnerPostService 줄바꿈 제거 * fix: Join fetch 조건 오류 해결 * feat: RunnerPostController createRunnerPost 에서 create 반환 변수 명 변경 * refactor: create 메서드 명을 createRunnerPost 로 변경 * style: 공백 정리 * refactor: findByIdJoinMember 를 joinMemberByRunnerId 로 변경 * refactor: TagCount 의 기본값을 항상 새로운 객체로 반환 하도록 수정 * refactor: WatchedCount 의 기본값을 항상 새로운 값으로 반환하도록 변경 * refactor: ChattingRoomCount 의 기본값을 항상 새로운 값으로 변경하도록 변경 * 러너 게시글 상세 조회 및 삭제 API 구현 (#28) * feat: 러너 게시글에 러너 게시글 태그 추가 기능 구현 * feat: RunnerPost 상세 조회 및 삭제 기능 추가 * feat: Tag 레포지터리에 TagCount 감소 기능 구현 * feat: RunnerPost 식별자값으로 RunnerPostTag 목록 조회 기능 구현 * feat: RunnerPost 서비스 상세 조회 및 삭제 기능 구현 * feat: Member 에 ImageUrl 필드 추가 * feat: RunnerPost 컨트롤러 상세 조회 및 삭제 API 구현 * test: ImageUrl null 예외 검증 테스트 구현 * test: DisplayName 내용 수정 * fix: Tag 의 TagCount 수가 줄어든 후 영속성 컨텍스트가 비어지지 않도록 수정 * refactor: Tag 의 TagCount 감소 기능을 변경 감지로 리팩터링 * test: RunnerPost 레포지터리 상세 조회 및 삭제 테스트 분리 * refactor: JPQL 에 사용할 변수명을 @Param 으로 표기하도록 리팩터링 * test: RestAssured 에 @Transactional 제거 * test: RestAssured 응답 객체 변환 기능 수정 * test: 인수테스트 @Disable * fix: RunnerPost 의 deadline 응답 반환 타입 수정 * test: Non Ascii 어노테이션 추가 * refactor: RunnerPostResponse Single 레코드명 리팩터링 * refactor: 레포지터리 Tag 조인 메서드명 리팩터링 * test: 사용하지 않는 인자 삭제 * 러너 게시글 수정 API 구현 (#41) * refactor: id -> runnerPostId 변경 * refactor: UriComponentsBuilder 적용 * refactor: String -> LocalDateTime 으로 변경 * test: 변수에 final 키워드 적용 * refactor: service 에 final 키워드 적용 * refactor: Getter 와 EqualsAndHashCode 어노테이션 위치 변경 * refactor: tag 관련 네이밍 변경 * style: 개행 닫기 * refactor: tagCount 수정 * refactor: import 적용 * refactor: 변수명 변경 * refactor: 어노테이션 위치 조정 * refactor: Tag 는 TimeStamp 저장 안하도록 설정 * refactor: id 로 runnerPost 조회 시 검색되지 않으면 예외 던지도록 설정 * refactor: 메소드명 변경 * test: display name 변경 * refactor: TagCount init 시에 parseInt 하는 방식으로 변경 * 러너 게시글 조회 API 구현 (#32) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 --------- Co-authored-by: jeonghoon * 리뷰 요청 글 전체 조회 API 수정, Tag이름 조회 수정 (#47) * feat: 리뷰 요청 글 도메인 구현 * feat: 리뷰 요청 글 컨트롤러 구현 * feat: 리뷰 요청 글 레포지토리 구현 * feat: 리뷰 요청 글 서비스 구현 * feat: VO equals and hashcode 재정의 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * fix: 충돌 해결 * fix: 리뷰 요청 글 전체 조회 API path 수정 * fix: Tag 이름 조회 수정 * fix: API 수정에 따른 테스트 코드 수정 * sytle: 사용하지 않는 () 삭제 --------- Co-authored-by: jeonghoon * 러너 포스트 중복 저장 시 발생하는 오류 해결 (#48) * chore: docker 외부 포트 고정 * fix: 중복되어서 저장이 안되는 문제 수정 * 러너 게시글 태그 중복 조회 오류 해결 (#51) fix: 영속성 컨텍스트에 RunnerPostTag가 기존에 존재하고 있으므로 중복되지 않도록 RunnerPost에 RunnerPostTag를 add하는 기능 삭제 * 예외 메시지 반환을 위한 ErrorResponse 객체 생성 (#62) * feat: 새로운 커스텀 Exception 추가 및 이전 버전의 Exception 클래스 수정 * feat: 새로운 커스텀 Exception 도메인별 구현체 및 도메인별 에러 코드 enum 구현 * feat: 베이스 커스텀 Exception 에 HttpStatus 가져오기 기능 구현 * feat: 베이스 에러 응답 객체 구현 * fix: 아직 구현되지 않은 러너 게시글 조회수 증가 기능 삭제 * test: 러너 게시글 마감일 검증 테스트 수정 * feat: 클라이언트 요청 커스텀 Exception 추가 및 병합된 에러 코드 구현 * 러너 게시글 조회 객체 이름 수정 (#63) * feat: 리뷰 요청 글 도메인 구현 * test: 리뷰 요청글 작성 테스트 작성 * feat: imageUrl 생성 * refactor: 리뷰 반영 * refactor: 모든 리뷰 요청 글 조회외에 모두 삭제 * refactor: 명세에 맞게 변수명 수정 * 테스트 코드를 위한 Fixture 생성 및 RunnerPost 테이블에 status 칼럼 추가 (#74) * refactor: Runner 와 Supporter 에 Grade 를 nullable=false로 설정 * feat: RunnerPost 에 ReviewStatus 추가 * test: MemberFixture 구현 * test: RunnerFixture 구현 * test: SupporterFixture 구현 * test: TagFixture 구현 * test: Fixture 추상클래스로 변경 * test: Fixture 정적 팩토리 메서드 파라미터 이름 변경 * test: Fixture 검증 * RunnerPostTag에 FK 제약 조건 이름 수정 (#66) * feat: RunnerPostTag에 fk 제약조건 이름 변경 * refactor: RunnerPost fk 제약 조건 이름 변경 * refactor: Runner fk 제약 조건 이름 변경 * refactor: Supporter fk 제약 조건 이름 변경 * refactor: RunnerPostTag fk 제약 조건 이름 변경 * 나누어져있는 RunnerPostResponse 통합 (#78) * refactor: dto 통합 * refactor: Profile dto 통합 * 서포터 목록 조회 API 구현 version-test (#93) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * 서포터 목록 조회 API 에서 별점 제거 (#96) * feat: Runner 와 Supporter 에 introduction 칼럼 추가 * feat: SupporterService 구현 * feat: Supporter Response 구현 * feat: 서포터 전체 조회 controller 구현 * feat: 서포터 전체 조회 Response 에 회사 추가 구현 * fix: supporter response 에 별점 제거 * fix: supporter response 에 별점 제거 * 러너 리뷰 요청 게시글 생성 version-test (#94) * feat: RunnerPostService 에서 testversion 저장하는 기능 구현 * feat: RunnerPostController 서포터 식별자를 받아 글을 생성하는 메서드 구현 * refactor: RunnerPost 와 Runner 의 관계를 ManyToOne 으로 변경 * test: 테스트에 SupporterRepository 의존성 추가 * refactor: RunnerPost 변수에 final 키워드 추가 * refactor: CreateReqeustDto 에 timezone 추가 * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * Profile에 isOwner 삭제하고 RunnerPostResponse에 isOwner 추가 (#104) * feat: 러너 게시글 조회 API 구현 version-test * style: 주석 제거 * refactor: ProfileResponse에 isOwner 삭제 * refactor: RunnerPostResponse에 isOwner 추가 * test: Response 변경에 따른 테스트 수정 * refactor:isOwner VO 추가 * refactor: 유지보수를 위해 true를 기본값으로 설정 * refactor: ProfileResponse 수정에 의한 변경 * Docker Hub와 GitHub self hosted runner를 이용한 CD 설정 (#106) * docs: Docker 를 이용한 CD 설정 * docs: CD 타겟 브랜치 수정 * 러너 게시글 상세 조회 API 구현 version-test (#109) * Test Fixture 파라미터 타입 수정 (#110) * test: Domain Fixture 메서드 파라미터 타입 수정 * test: 테스트 Fixture 사용 수정 * test: Runner Test Fixture 수정 * 클라이언트 예외를 위한 Validator 추가 (#117) * refactor: ClientRequestException 일반 클래스로 변경 * refactor: ErrorResponse 에서 httpStatus 제거 * refactor: RunnerPostUpdateRequest record로 변경 * refactor: ClientErrorCode 내용 추가 * refactor: record로 생긴 변화 적용 * feat: NotNullValid 어노테이션 추가 * refactor: ErrorResponse 에 getter 추가 * feat: Controller valid 추가 * feat: ValidFuture 어노테이션 추가 * feat: 어노테이션 이름 변경 * feat: Max validator 추가 * feat: request dto에 validation 적용 * refactor: 어노테이션 target 조정 * refactor: tag 값이 없을 때 Bad Request 보내도록 수정 * CORS 설정 및 Profile을 RunnerProfile로 변경 (#120) * feat: cors 설정 * refactor: MemberProfile 을 RunnerProfile 로 수정 * feat: cors HttpMethod 에 PATCH 추가 * 환경 변수 보호를 위한 서브 모듈 연결 (#125) * feat: 서브 모듈 추가 * feat: 서브 모듈 환경 설정 * fix: 실험을 위해 현재 브랜치로 CD 실행하도록 변경 * refactor: Dockerfile 경로 변경 * refactor: 실험을 위해 공백 푸시 * refactor: 실험을 성공 후 공백 다시 추가 * refactor: CD 브랜치 dev/BE로 변경 * refactor: CI/CD 파일명 변경 * String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 (#127) * feat: String 으로 된 Date 값을 LocalDateTime 으로 변경하는 컨버터 구현 * refactor: RunnerPostCreateTestRequest 에서 컨버터를 사용하도록 변경 * feat: Json으로 직렬화될 때 custom 날짜 형식으로 변경되도록 수정 * refactor: RunnerPostresponse 에서 JsonFormat을 사용하지 않도록 변경 * test: StringDateToLocalDateTime 테스트 방식 변경 * test: ConverterConfigTest를 SpringBootTest 에서 WebMvcTest 로 변경 * test: StringDateToLocalDateTimeConverter 실패 테스트 추가 * feat: DateTimeConverter desiralizer 구현 * test: ConverterConfigTest 실패 테스트 수정 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * 백엔드 프로덕션 컨테이너 세팅 (#132) * chore: Dockerfile 실행 환경 dev, prod 분리 및 사용하지 않는 Docker Image 삭제 명령어 추가 * chore: ci/cd deploy 환경 분리 * Merge pull request #130 * test: Restdocs Test Config 설정 및 러너 게시글 전체 조회 api test 추가 * secret 파일 pull (#139) fix: secret 파일 pull * secret 파일 pull (#141) fix: secret 파일 pull * secret 파일 pull (#142) fix: secret 파일 pull * dev 환경에 방언 추가 (#143) * fix: secret 파일 pull * refactor: secret 변경 * dev에 prod db 적용해보기 (#144) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * cicd 파일 및 환경 변수 원상복구 (#145) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * secret 변경 (#146) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * 도커 secret 수정 (#147) * fix: secret 파일 pull * refactor: secret 변경 * fix: cicd 스크립트에 prod db 적용 * refactor: secret, cicd 원상복귀 * refactor: secret 수정 * refactor: secret 수정 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * 러너 게시글 조회할 때 조회수 증가 기능 구현 (#148) * feat: 러너 게시글 조회수 증가 기능 구현 * 서포터 기술 태그 table 추가 (#151) * feat: TechnicalTag entity 생성 * feat: SupporterTechnicalTag entity 생성 * fix: 터지는 테스트 수정 * feat: 러너 게시글 조회수 증가 기능 구현 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * docker network 설정 (#156) fix: docker network 설정 * fix: docker network 설정 (#158) * cd 파일 재설정 (#160) fix: cd 파일 재설정 * 프론트엔드 코드 삭제 (#167) fix: 프론트엔드 코드 삭제 * 서포터 전체 조회 기술 태그 목록 추가 version-test (#162) * feat: 서포터 전체 조회 기술 태그 목록 추가 version-test * fix: 서포터 태그 목록 List 로 변환 * test: supporterTechnicalTags 조회 테스트 수정 * test: supporterTechnicalTags 조회 테스트 assertAll 사용 * CORS 환경별로 allowed origin 변경 (#169) * feat: submodule 업데이트 * feat: gitignore 에 application.yml 추가 * feat: application.yml 중요 환경 변수 숨기기 * 테스트 코드 Fixture 통일 (#172) * test: assure 패키지의 MemberFixture 삭제 * test: assure 패키지의 RunnerFixture 삭제 * test: assure 패키지의 RunnerPostFixture 삭제 * test: domain 에 있는 RunnerFixture 삭제 * test: domain 에 있는 RunnerPostData 삭제 * test: RunnerRepositoryTest 가 RepositoryTestConfig 를 상속 받도록 변경 * test: TagRepositoryTest 가 RepositoryTestConfig 를 상속 받도록 변경 * domain exception이 server exception을 상속받도록 수정 (#177) * refactor: domain exception을 server exception을 상속받도록 수정 * refactor: 사용하지 않는 MemberException 제거 * dev 환경 변수 수정 (#188) fix: 환경 변수 수정 * Github Social Login 4차 구현 (#164) * chore: HttpInterface 사용을 위한 의존성 추가 * feat: 소셜 타입 구분을 위한 OauthType 추가 * feat: AuthCode 를 받아오기 위한 인터페이스와 확장성을 고려한 컴포시트 패턴 추가 * feat: Oauth 정보를 받아올 인터페이스와 확장성을 위한 컴포시트 패턴 구현 * feat: 외부 변수를 받아올 GithubOauthConfig 구현 및 최상단 애플리케이션에 읽어올 수 있도록 스캔 어노테이션 적용 * feat: Github Oauth 요청을 위한 구현체 및 스프링 컨테이너에 빈 등록을 위한 HttpInterfaceConfig 설정 구현 * feat: Oauth 로그인을 받을 컨트롤러와 저장 및 조회를 위한 Oauth 레포지터리, Oauth 서비스 구현 * chore: gradle 에 auth0 jwt 의존성 추가 * feat: AccessToken dto 구현 추가 및 OauthInformation 내부 회사 정보 삭제 * feat: OauthType enum 컨버터 구현 * feat: Jwt 인코더, Jwt 디코더 구현 * feat: Oauth 전용 Runner, Supporter 레포지터리 구현 * feat: Runner, Supporter Principal 어노테이션 및 Runner, Supporter ArgumentResolver 구현 * feat: Oauth 서비스 Jwt 인코드 기능 및 Member, Runner, Supporter 신규 사용자 자동 회원가입 추가, Oauth 컨트롤러 Jwt 헤더 반환 구현 * test: WebMvcTest 시 bean 스캔 문제 해결을 위한 커스텀 MockMvcTest 어노테이션 구현 및 RestdocsConfig 수정 * test: Oauth Api 테스트 및 기존 Api Test 를 MockMvcTest 로 수정 * refactor: auth0 의존성을 삭제하고 jjwt 추가 후 HS256 를 이용하도록 JWT 설정 변경 * refactor: AccessToken JWT 내부 Claim 에서 삭제 * refactor: oauth 패키지 이동 * refactor: JWT 디코더 내부 검증 변경 * refactor: Oauth 사용자 정의 예외로 변경 * chore: 서브 모듈 최신화 * fix: 외부 환경 변수 자바 객체 주입을 문자열로 수정 * refactor: cors origin 외부 환경 변수 주입하도록 변경 * refactor: JWT Signature 클라이언트 요청 예외 에러 코드 변경 * refactor: Oauth Email 에러 코드 JWT 로 변경 * feat: Jwt 디코더 에러 코드 추가 * style: 문자열 상수화 및 메서드 파라미터 컨벤션 적용 * dev 환경 변수 수정 (#188) fix: 환경 변수 수정 * refactor: AccessToken 명 SocialToken 으로 변경 및 Auth 관련 패키지 분리 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * Oauth API prefix 수정 및 깃허브 소셜 로그인 Http Method 수정 (#193) * fix: Oauth API prefix 수정 및 깃허브 소셜 로그인 Http Method 수정 * test: Oauth Api Test prefix 수정 * 서포터 피드백 등록 API 구현 (#186) * feat: Description VO 구현 * feat: SupporterFeedback 엔티티 구현 * chore: Feedback 관련 패키지 변경 * refactor: SupporterFeedback Entity 에 nullable 추가 * feat: Runner 에 equals&hashcode 추가 * feat: SupporterFeedback 등록 API 구현 * refactor: FeedbackBusinessException 내부 클래스 삭제 * test: 안 쓰는 인스턴스 변수 로컬 변수로 변경 * test: SupporterFeedback 관련 Fixture 구현 * test: RestAssuredTest 컨벤션에 맞게 변경 * refactor: FeedbackService 에 Transaction 추가 * refactor: FeedbackService Runner 찾는 과정 삭제 * refactor: 로그인 기능 추가에 따른 @AuthRunner 사용하도록 변경 * refactor: RunnerPost 와 SupporterFeedback 과의 관계를 OneToOne 으로 변경 * 모든 도메인 객체 exception Notnull method 제거 (#184) * refactor: domain exception을 server exception을 상속받도록 수정 * refactor: 사용하지 않는 MemberException 제거 * refactor: 모든 도메인 객체 exception Notnull method 제거 * refactor: stream 컨벤션에 맞도록 수정 * refactor: conflict 해결 * test: 에러메세지 수정 * refactor: TagException 상세히 분할(RunnerPostTag, SupporterTechnicalTag) * refactor: TagException 상세히 분할(RunnerPostTag, SupporterTechnicalTag)에 따른 테스트 수정 * refactor: stream 점 위치 수정 * 러너 본인 프로필 조회 기능 구현 (#183) * refactor: runner response 는 runner 패키지로 이동 * feat: runner 프로필 조회 response 생성 * refactor: 바뀐 response 패키지 적용 * test: IntroductionFixture 추가 * test: 인수테스트 추가 * test: 중복된 테스트 제거 * feat: runner 식별자로 runnerPost 조회 쿼리 생성 * feat: runner 식별자로 runnerPost 조회 서비스 로직 구현 * feat: runner 본인 프로필 조회 기능 구현 * refactor: auth 적용 * refactor: restdocs 관련 테스트 주석 처리 * test: runner fixture 에 introduction 추가 * refactor: controller 메소드 이름 변경 * Member Email 값 객체를 SocialId 값 객체로 변경 (#196) * refactor: Member Email 값 객체를 SocialId 로 변경 * refactor: Member 의 SocialId 컬럼명 수정 * 모든 API 인증 기능 추가 (#202) * refactor: FeedbackController 에 사용하지 않는 변수 삭제 * refactor: runnerPost auth 어노테이션 붙이기 * refactor: 게시물 상세 조회 시 auth 선택적으로 적용하도록 변경 * refactor: 게시물 수정, 삭제 시에 Auth 어노테이션 추가 * refactor: 서포터 전체 조회 반환 필드 변경 * test: 변경 사항에 따른 테스트 코드 변경 * 클라이언트 에러 HttpStatus 수정 (#206) refactor: 클라이언트 에러 HttpStatus 수정 * 게시글 최신순으로 정렬 (#207) * feat: 생성 순으로 게시글 불러오는 쿼리 생성 * feat: 게시글 생성 순으로 정렬 * refactor: yml 업데이트 * submodule 업데이트 (#212) refactor: submodule 업데이트 --------- Co-authored-by: HyunSeo Park (Hyena) Co-authored-by: Ethan Co-authored-by: 은비 <67318165+eunbii0213@users.noreply.github.com> Co-authored-by: eunbii0213 * Oauth RedirectUrl, Claim, 서브 모듈 변경 (#219) refactor: claim 수정 및 서브 모듈 변경 * 전체 조회 쿼리 날짜 역순으로 정렬 (#227) fix: 전체 조회 쿼리 날짜 역순으로 정렬 * 러너 게시글 상세 조회 시 조회수가 증가하도록 변경 (#230) refactor: 글 조회 시 조회수가 증가하지 않았던 문제 수정 * Deploy에서 쿼리 log을 삭제 (#249) chore; deploy 에서 format_sql 를 false 로 변경 * 엔티티 리팩터링 (#265) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * 백엔드 CI/CD 스크립트 수정 (#282) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * CI/CD container 타임존 설정 (#285) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 --------- Co-authored-by: HyunSeo Park (Hyena) * TagCount 삭제 (#287) refactor: TagCount 클래스 삭제 및 관련 파일 제거 * Member, Runner, Supporter 별 AuthPrincipal 수정 및 추가 (#291) refactor: Member, Runner, Supporter 별 AuthPrincipal 변경 * dev 서버 CORS 및 Redirect URL 변경 (#293) refactor: 프론트 배포에 따른 cors 설정 변경 * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * Dockerfile 오류 해결 (#297) fix: Dockerfile 오류 해결 * 서포터 프로필 조회 API 구현 (#298) * test: 인수 테스트 Jwt Mocking 추가 * feat: 서포터 프로필 응답 객체 추가 * test: 서포터 프로필 조회 인수 테스트 추가 * feat: 서포터와 사용자 패치 조인 기능 추가 * feat: 서포터 프로필 조회 서비스 기능 추가 * feat: 서포터 프로필 조회 API 추가 * 로그인한 맴버 조회 API 구현 (#308) * feat: 로그인 한 사용자 조회 API 구현 * test: 인수 테스트 성공 케이스 작성 * refactor: restdocsConfig 변경 * test: 로그인된 사용자 조회 API mock mvc 테스트 진행 * refactor: 코드 리뷰 반영 * 러너 본인 프로필 조회 API 구현 (#310) * feat: Runner 기술 태그 추가 * feat: Runner 본인 프로필 응답 객체 추가 * test: Runner 본인 프로필 조회 인수 테스트 추가 * test: Runner 생성시 RunnerTechnicalTags 생성시 추가 * test: 인수 테스트 truncate Listener 추가 * feat: Runner 본인 프로필 조회 컨트롤러 추가 * docs: Runner 본인 프로필 조회 API 문서 * docs: Runner 본인 프로필 조회 API 문서 내부 수정 * test: 인숱 테스트 데이터 삭제 메서드 수정 * 러너 프로필 조회 API 구현 (#311) * feat: Runner 에 RunnerTechnicalTag 추가 * feat: RunnerProfile 상세 조회 API 추가 * style: 공백 추가 및 제거 * feat: RunnerProfileReadAPI Restdocs 테스트 구현 * docs: Asciidoc 파일 소제목 수정 * refactor: RunnerProfileAssuredSupport 러너 피드백 상세 조회한다 메서드 파라미터 명 수정 * refactor: RestAssured 테스트 변수 한글로 변경 * test: 테스트 메서드 명 변경 * test: RunnerFixture 메서드 순서 변경 * docs: 러너 본인 프로필 조회 -> 러너 마이페이지 프로필 조회로 변경 * docs: adoc 파일 순서 변경 * refactor: ReadApi 테스트 병합 * 서포터 프로필 변경 API (#315) * feat: 로그인 한 사용자 조회 API 구현 * test: 인수 테스트 성공 케이스 작성 * refactor: restdocsConfig 변경 * test: 로그인된 사용자 조회 API mock mvc 테스트 진행 * refactor: 코드 리뷰 반영 * feat: request dto 생성 * test: 인수 테스트 작성 * test: 인수 테스트 수정 * feat: technical tag 이름으로 technical tag 찾는 기능 추가 * feat: supporter 와 technicalTag 로 supporterTechnicalTag 찾는 기능 구현 * refactor: supporter 와 technicalTag 로 supporterTechnicalTag 찾는 기능 제거 * feat: API 구현 * test: restdocs 테스트 구현 * refactor: submodule 변경 * refactor: submodule 수정 * refactor: Location 반환하도록 변경 * refactor: static 으로 된 메서드들 non-static 으로 변경 * feat: 서포터로 SupporterTechnicalTag 제거 기능 구현 * refactor: RunnerTechnicalTag nullable 하게 변경 * refactor: Batch delete 방식으로 데이터 삭제하도록 변경 * refactor: uri 매핑 형식 변경 * test: 공백 제거 * refactor: batch delete 시에 flush & clear * test: 오타 수정 * Dev에 allowed-origins에 localhost:3000을 추가 (#318) refactor: Allowed-Orgins 수정 * 만들어진 restdocs 파일을 src/resources/static/docs/ 하위로 이동 (#329) * docs: 잘못된 asciiDoc 위치 변경 * chore: ignore에 docs 파일 추가 * docs: adoc을 한 파일로 합치는 index.adoc 및 build.gradle 추가 * docs: index.adoc에 맞게 제목 수준 변경 * docs: index.adoc 목차 수준 변경 * docs: index.adoc 목차 이름 변경 * Runner profile 수정 API 구현 (#325) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: Runner 의 프로필 수정 기능 구현 * fix: 충돌 해결 * refactor: ClientErrorCode 수정 * refactor: RunnerProfileRequest 위치 변경 및 이름 수정 * refactor: setter 삭제 * refactor: RunnerProfileController 수정 및 redirectUri 추가 * refactor: RunnerProfileService 의 코드 RunnerService 로 이동 * fix: TechnicalTag, RunnerTechnicalTag 중복 저장 해결 * test: RunnerProfileServiceTest 수정 * docs: RunnerProfileUpdateApi RestDocs 작성 * refactor: ClientErrorCode static import 추가 * test: RunnerProfileAssuredUpdateTest 작성 * refactor: RunnerUpdateRequest 컨벤션에 맞도록 수정 * refactor: ServiceTestConfig 컨벤션에 맞도록 수정 * refactor: request 전체를 보내지 않도록 수정 * feat: runner의 introduction 기본값 설정 * style: 사용하지 않는 주석 제거 * test: RunnerServiceTest로 이동 및 테스트 코드 작성 * refactor: Runner 프로필 수정 기능 컨벤션에 맞도록 수정 및 리팩터링 * fix: DDL 오류 수정 * refactor: runner, support에서 Introduction null 체크 * test: service 테스트에서 Response 를 의존하지 않도록 작성 * test: service 테스트에서 Response 를 의존하지 않도록 작성 * refactor: 컨벤션에 맞도록 수정 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * 서포터의 리뷰 완료한 게시글 조회 API 구현 (#331) * fix: Runner 로그인이 익명일 경우 게스트 계정 반환하도록 수정 * feat: ReviewStatus 컨버터 구현 및 구성 정보 추가 * feat: Deadline 생성자 내부 초 단위 제거 기능 구현 * feat: 페이징 응답 객체 구현 * feat: 서포터가 연관된 러너 게시글 응답 객체 구현 * test: 서포터가 연관된 러너 게시글 페이징 조회 인수 테스트 추가 * feat: 서포터가 연관된 러너 게시글 페이징 조회 레포지터리 구현 * feat: 러너 게시글 객체 내부 서포터 할당 메서드 예외 추가 * feat: 서포터가 연관된 러너 게시글 페이징 조회 및 지원자 참여수 조회 서비스, 레포지터리 기능 구현 * feat: 서포터가 연관된 러너 게시글 페이징 조회 컨트롤러 구현 * refactor: 서포터 러너 게시글 레포지터리 count 기능 변경 * refactor: 서포터가 연관된 러너 게시글 조회 컨트롤러 기본 페이지 시작 번호 수정 * test: 인수 테스트 클래스명 변경 * docs: 서포터가 연관된 러너 게시글 조회 문서 추가 및 분리 * chore: 서브모듈 업데이트 * 만들어진 restdocs 파일을 src/resources/static/docs/ 하위로 이동 (#329) * docs: 잘못된 asciiDoc 위치 변경 * chore: ignore에 docs 파일 추가 * docs: adoc을 한 파일로 합치는 index.adoc 및 build.gradle 추가 * docs: index.adoc에 맞게 제목 수준 변경 * docs: index.adoc 목차 수준 변경 * docs: index.adoc 목차 이름 변경 * Runner profile 수정 API 구현 (#325) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: Runner 의 프로필 수정 기능 구현 * fix: 충돌 해결 * refactor: ClientErrorCode 수정 * refactor: RunnerProfileRequest 위치 변경 및 이름 수정 * refactor: setter 삭제 * refactor: RunnerProfileController 수정 및 redirectUri 추가 * refactor: RunnerProfileService 의 코드 RunnerService 로 이동 * fix: TechnicalTag, RunnerTechnicalTag 중복 저장 해결 * test: RunnerProfileServiceTest 수정 * docs: RunnerProfileUpdateApi RestDocs 작성 * refactor: ClientErrorCode static import 추가 * test: RunnerProfileAssuredUpdateTest 작성 * refactor: RunnerUpdateRequest 컨벤션에 맞도록 수정 * refactor: ServiceTestConfig 컨벤션에 맞도록 수정 * refactor: request 전체를 보내지 않도록 수정 * feat: runner의 introduction 기본값 설정 * style: 사용하지 않는 주석 제거 * test: RunnerServiceTest로 이동 및 테스트 코드 작성 * refactor: Runner 프로필 수정 기능 컨벤션에 맞도록 수정 및 리팩터링 * fix: DDL 오류 수정 * refactor: runner, support에서 Introduction null 체크 * test: service 테스트에서 Response 를 의존하지 않도록 작성 * test: service 테스트에서 Response 를 의존하지 않도록 작성 * refactor: 컨벤션에 맞도록 수정 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * fix: Runner 로그인이 익명일 경우 게스트 계정 반환하도록 수정 * feat: ReviewStatus 컨버터 구현 및 구성 정보 추가 * feat: Deadline 생성자 내부 초 단위 제거 기능 구현 * feat: 페이징 응답 객체 구현 * feat: 서포터가 연관된 러너 게시글 응답 객체 구현 * test: 서포터가 연관된 러너 게시글 페이징 조회 인수 테스트 추가 * feat: 서포터가 연관된 러너 게시글 페이징 조회 레포지터리 구현 * feat: 러너 게시글 객체 내부 서포터 할당 메서드 예외 추가 * feat: 서포터가 연관된 러너 게시글 페이징 조회 및 지원자 참여수 조회 서비스, 레포지터리 기능 구현 * feat: 서포터가 연관된 러너 게시글 페이징 조회 컨트롤러 구현 * refactor: 서포터 러너 게시글 레포지터리 count 기능 변경 * refactor: 서포터가 연관된 러너 게시글 조회 컨트롤러 기본 페이지 시작 번호 수정 * test: 인수 테스트 클래스명 변경 * docs: 서포터가 연관된 러너 게시글 조회 문서 추가 및 분리 * chore: 서브모듈 업데이트 * feat: ReviewStatus OVERDUE 추가 및 ReviewStatus 업데이트 예외 기능 구현 * test: Supporter 리뷰 완료한 RunnerPost 조회 인수테스트 수정 * refactor: RunnerPostResponse.ReferencedBySupporter 정적 팩토리 메서드명 수정 * refactor: RunnerPostResponse.ReferencedBySupporter 지원자수, 조회수 타입 변경 * refactor: RunnerPost 의 ReviewStatus 업데이트, Supporter 할당 예외 검증 메시지 수정 * test: Supporter 와 연관된 RunnerPost 페이징 조회 테스트명 수정 * test: RunnerPost 에 연관된 Supporter 를 repository 를 통해 저장하도록 테스트 수정 * feat: RunnerPostTags equals & hashcode 재정의 * docs: RunnerProfileRead adoc 문서 수정 * test: RunnerPostRead 서비스 테스트 생성자 추가 * test: Pageable 인수 테스트 구성 정보 추가 --------- Co-authored-by: Ethan Co-authored-by: 은비 <67318165+eunbii0213@users.noreply.github.com> Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> * 서포터의 리뷰 제안 철회 API 구현 (#337) * feat: 인수 테스트 작성 * feat: repository 구현 * feat: service 구현 * feat: controller 구현 * refactor: 예외 케이스 추가 * test: 인수 테스트 수정 * test: service 테스트 분리 * refactor: delete 시에 반환은 void로 변경 * refactor: service * test: restdocs 테스트 클래스 명 변경 * test: 필요없는 테스트 삭제 * test: 필요없는 테스트 삭제 * test: api docs 클래스명 변경 * refactor: 실수로 삭제한 메서드 복구 * refactor: 필요없는 ClientErrorCode 삭제 * refactor: 서브모듈 업데이트 * feat: 충돌 해결 * refactor: 정렬 * test: RestSupport 메소드 순서 획일화 * 러너가 서포터 목록에서 서포터를 선택하는 API 구현 (#346) * feat: 지원한 서포터를 선택하는 API 구현 * test: RunnerPostService 테스트 추가 * refactor: 파라미터 명 수정 * test: SupporterRunnerPostRepositoryReadTest 추가 * refactor: 잘못된 redirect uri 변경 * test: Restdocs 테스트 추가 * test: isNotOwner 테스트 추가 * style: startReview 메서드 줄 변경 * refactor: Objects.equals 대신 도메인 로직을 사용하도록 변경 * refactor: 메서드 이름 변경 * refactor: 병합 출돌 해결 * refactor: 필요없는 메서드 삭제 * docs: index.adoc에 서포터 선택 API 추가 및 depth 조절 * refactor: 사용하지 않는 메서드 제거 * test: 컨벤션 맞게 반영 * test: DeadlineFixture 로 변경 * refactor: 예외 메세지 이름 변경 * refactor: private 메서드 병합 * test: notSavedId given 절로 변경 * refactor: hasMessage 제거 및 DTO 이름 변경 * 서포터가 러너 게시글 리뷰 신청 API 구현 (#340) * test: Supporter 가 RunnerPost 에 리뷰 신청하는 인수 테스트 추가 * feat: Supporter, RunnerPost 에 리뷰 요청 서비스 기능 구현 * feat: Supporter, RunnerPost 에 리뷰 요청 컨트롤러 구현 * refactor: RunnerPost 식별자값 조회 메서드 예외 타입 수정 * refactor: Supporter 리뷰 지원 이력 조회 기능 수정 * test: 인수 테스트용 AssuredSupport 메서드 호출 순서 수정 * test: 식별자값 검증시 Positive 검증은 하지 않도록 수정 * test: Api(Controller) Test, restdocs setup 컨벤션 수정 * refactor: 저장하는 서비스 로직에서는 식별자값을 반환하도록 수정 * test: Supporter RunnerPost 지원 서비스 실패 테스트 추가 * fix: RunnerPost 서비스 계층 예외 타입 수정 * feat: Supporter 의 RunnerPost 리뷰 지원 요청 검증 어노테이션 추가 * refactor: 개행 * refactor: 예외 문자 * refactor: SupporterRunnerPost @Param 수정 * test: AssuredSupporter 메서드 순서 수정 * refactor: applicantCount long 타입으로 수정 * chore: 머지 후 중복 메서드 삭제 * test: RunnerPost 인수테스트 개행 수정 * docs: adoc 정렬 * 러너 게시글 삭제 API (#350) * test: 인수 테스트 작성 * feat: 레포지토리 구현 * feat: 서비스 구현 * test: API 문서 작성 * test: 인수 테스트 수정 * refactor: 코드 리뷰 반영 * refactor: 충돌 해결 * refactor: 포매팅 * refactor: 코드 리뷰 반영 * 서포터 본인 프로필 조회 API를 구현 (#345) * feat: 서포터 마이페이지 조회 기능 추가 * test: 서포터 마이페이지 Restdocs 테스트 추가 * refactor: 병합 충돌 해결 * docs: index.adoc에 서포터 프로필관련 adoc 추가 * docs: depths 수정 * refactor: 리뷰 반영 - SupporterProfileController 변수 명 변경 - SupporterProfile 메서드 명 수정 및 메서드 분리 * docs: adoc 띄어쓰기 추가 * test: 응답 객체 분리 * 러너 게시글 상세 조회 API 리팩터링 (#343) * test: Supporter 가 RunnerPost 에 리뷰 신청하는 인수 테스트 추가 * feat: Supporter, RunnerPost 에 리뷰 요청 서비스 기능 구현 * feat: Supporter, RunnerPost 에 리뷰 요청 컨트롤러 구현 * refactor: RunnerPost Detail 응답 객체 수정 * test: RunnerPost 상세 조회 컨트롤러 테스트 수정 * test: RunnerPost 상세 조회 인수 테스트 수정 * feat: RunnerPost 단건 지원자수 count 레포지터리, 서비스 기능 구현 * refactor: RunnerPost 단건 상세 조회 컨트롤러 수정 * chore: 머지 후 변경 작업 * docs: adoc 정렬 및 index.adoc 추가 * 서포터가 본인이 리뷰한 게시글의 상태를 리뷰 완료로 변경하는 API 구현 (#347) * feat: 리뷰 완료 api controller 기능 및 인수테스트 추가 * test: Restdocs 테스트 추가 * feat: 리뷰 완료 서비스 코드 구현 * test: 깨지는 테스트 수정 * test: AssuredSupport 파마리터 개행 추가 * test: 접근제한자 제거 * test: hasMessage() 검증부 제거 * refactor: 불필요한 private 메서드 제거 * test: 충돌 메서드 명 수정 * style: 개행 두 칸 제거 * test: Restdocs 테스트에서 누락된 path parameters 추가 * 로그인한 서포터의 러너 게시글 조회 API 구현 (#342) * test: RestAssured 및 Restdocs 테스트 컨벤션 적용 * feat: review status 상태 하나 추가 * feat: response dto 구현 * test: 인수 테스트 작성 * feat: 서포터와 리뷰 상태로 러너 게시글 최신순으로 조회하는 기능 구현 * feat: 서포터와 리뷰 상태로 러너 게시글 최신순으로 조회하는 서비스 구현 * feat: controller 작성 및 주석 처리 * refactor: 충돌 해결 * refactor: 페이지네이션 적용 * test: NOT_STARTED 인 테스트 진행 * test: restdocs 작성 * refactor: 서브 모듈 업데이트 * refactor: 개행 제거 * refactor: 코드 리뷰 반영 * refactor: 코드 리뷰 반영 * test: restdocs 테스트에 path parameter 추가 * test: 코드리뷰 반영 * test: 코드리뷰 반영 * refactor: 충돌 해결 * 러너 게시글 생성 API 수정 (#348) * refactor: RunnerPostService create 로직 변경 * test: 게시글 생성 인수테스트 작성 * test: restdos 테스트 추가 * docs: restdocs 파일 포맷팅 * test: HttpHeaders 를 spring 패키지를 import 를 받도록 변경 * refactor: FutureValidator 에서 null 인 localdatetime을 체크 안하도록 변경 * refactor: 없던 중괄호 추가 * Restdocs가 build 파일에 생성이 되지 않던 문제 수정 (#359) * chore: gradle task 순서 변경 및 공통 경로 상수화 * docs: adoc 파일 snippet 경로 수정 * docs: adoc 파일 depth 변경 * build.gradle에서 RESTDocs 파일 변수명 수정 (#361) chore: 변수명 수정 * build.gradle에서 RESTDocs 파일 변수명 수정 (#362) chore: index.html 복사 경로 수정 * build.gradle에서 RESTDocs 파일 변수명 재수정 (#363) * chore: RestDocs 복사경로 재수정 * chore: resolveMainClassName 의존성 추가 * 러너 게시글 상세 조회 서포터 리뷰 지원 유무 응답 추가 및 uri 수정 (#365) * feat: Supporter 의 RunnerPost 지원 여부 확인 조회 메서드 레포지터리, 서비스 구현 * feat: RunnerPost 상세 조회시 Supporter 리뷰 지원 여부 * test: RunnerPost 상세 조회시 Supporter 지원 여부 테스트 구현 * fix: RunnerPost 에 Supporter 리뷰 지원 api 의 uri 수정 * docs: 러너 게시글 상세 조회 테스트 클래스명 수정으로 인한 adoc 파일 변경 * 러너 게시글 전체 조회 API 페이징 기능 추가 (#344) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: 러너 게시글 전체 조회 API 구현 * fix: conflict 해결 * test: 테스트 수정 * fix: conflict 해결 * refactor: 피드백 반영 * refactor: collectApplicantCounts 메서드 로직 수정 * refactor: collectApplicantCounts 메서드 로직 수정 * refactor: 피드백 반영 * fix: 지원한 서포터가 아무도 없을 때 applicants를 계산하는 로직 수정 * refactor: queryParameters 추가 * refactor: queryParameters 추가 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * 전역 예외 처리 및 로깅 (#378) * chore: log, aop 의존성 추가 * feat: log4j2.xml 추가 * feat: submodule 업데이트 * feat: aop 로깅 적용 * feat: filter 설정 * 지원한 서포터 목록 조회 API 구현 (#335) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: 지원한 서포터 목록 조회 API 구현 * fix: conflict 해결 * refactor: for 문 삭제 * test: path Parameter 추가 * fix: 충돌 해결 * refactor: introduction 객체 수정 * refactor: 코드 포맷팅 * style: 중복되는 테스트 삭제 * fix: 충돌 해결 * refactor: restdocs request Headers 추가 * refactor: 변수명 변경 * refactor: 변수명 변경 * refactor: 피드백 반영 * fix: introduction nullable=true 로 변경 * fix: introduction nullable=false로 변경 * refactor: 피드백 반영 * fix: 충돌 해결 * refactor: 피드백 반영 * refactor: 피드백 반영 * refactor: 피드백 반영 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * 러너 마이페이지 게시글 조회 API 구현 (#349) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * 서포터 프로필 변경 API (#315) * feat: 로그인 한 사용자 조회 API 구현 * test: 인수 테스트 성공 케이스 작성 * refactor: restdocsConfig 변경 * test: 로그인된 사용자 조회 API mock mvc 테스트 진행 * refactor: 코드 리뷰 반영 * feat: request dto 생성 * test: 인수 테스트 작성 * test: 인수 테스트 수정 * feat: technical tag 이름으로 technical tag 찾는 기능 추가 * feat: supporter 와 technicalTag 로 supporterTechnicalTag 찾는 기능 구현 * refactor: supporter 와 technicalTag 로 supporterTechnicalTag 찾는 기능 제거 * feat: API 구현 * test: restdocs 테스트 구현 * refactor: submodule 변경 * refactor: submodule 수정 * refactor: Location 반환하도록 변경 * refactor: static 으로 된 메서드들 non-static 으로 변경 * feat: 서포터로 SupporterTechnicalTag 제거 기능 구현 * refactor: RunnerTechnicalTag nullable 하게 변경 * refactor: Batch delete 방식으로 데이터 삭제하도록 변경 * refactor: uri 매핑 형식 변경 * test: 공백 제거 * refactor: batch delete 시에 flush & clear * test: 오타 수정 * 지원한 서포터 목록 조회 API 구현 (#335) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: 지원한 서포터 목록 조회 API 구현 * fix: conflict 해결 * refactor: for 문 삭제 * test: path Parameter 추가 * fix: 충돌 해결 * refactor: introduction 객체 수정 * refactor: 코드 포맷팅 * style: 중복되는 테스트 삭제 * fix: 충돌 해결 * refactor: restdocs request Headers 추가 * refactor: 변수명 변경 * refactor: 변수명 변경 * refactor: 피드백 반영 * fix: introduction nullable=true 로 변경 * fix: introduction nullable=false로 변경 * refactor: 피드백 반영 * fix: 충돌 해결 * refactor: 피드백 반영 * refactor: 피드백 반영 * refactor: 피드백 반영 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * 지원한 서포터 목록 조회 API 구현 (#335) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: 지원한 서포터 목록 조회 API 구현 * fix: conflict 해결 * refactor: for 문 삭제 * test: path Parameter 추가 * fix: 충돌 해결 * refactor: introduction 객체 수정 * refactor: 코드 포맷팅 * style: 중복되는 테스트 삭제 * fix: 충돌 해결 * refactor: restdocs request Headers 추가 * refactor: 변수명 변경 * refactor: 변수명 변경 * refactor: 피드백 반영 * fix: introduction nullable=true 로 변경 * fix: introduction nullable=false로 변경 * refactor: 피드백 반영 * fix: 충돌 해결 * refactor: 피드백 반영 * refactor: 피드백 반영 * refactor: 피드백 반영 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * feat: response 에 reviewStatus 추가 * 지원한 서포터 목록 조회 API 구현 (#335) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: 지원한 서포터 목록 조회 API 구현 * fix: conflict 해결 * refactor: for 문 삭제 * test: path Parameter 추가 * fix: 충돌 해결 * refactor: introduction 객체 수정 * refactor: 코드 포맷팅 * style: 중복되는 테스트 삭제 * fix: 충돌 해결 * refactor: restdocs request Headers 추가 * refactor: 변수명 변경 * refactor: 변수명 변경 * refactor: 피드백 반영 * fix: introduction nullable=true 로 변경 * fix: introduction nullable=false로 변경 * refactor: 피드백 반영 * fix: 충돌 해결 * refactor: 피드백 반영 * refactor: 피드백 반영 * refactor: 피드백 반영 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * 지원한 서포터 목록 조회 API 구현 (#335) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: 지원한 서포터 목록 조회 API 구현 * fix: conflict 해결 * refactor: for 문 삭제 * test: path Parameter 추가 * fix: 충돌 해결 * refactor: introduction 객체 수정 * refactor: 코드 포맷팅 * style: 중복되는 테스트 삭제 * fix: 충돌 해결 * refactor: restdocs request Headers 추가 * refactor: 변수명 변경 * refactor: 변수명 변경 * refactor: 피드백 반영 * fix: introduction nullable=true 로 변경 * fix: introduction nullable=false로 변경 * refactor: 피드백 반영 * fix: 충돌 해결 * refactor: 피드백 반영 * refactor: 피드백 반영 * refactor: 피드백 반영 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * 지원한 서포터 목록 조회 API 구현 (#335) * 오류나는 Dockerfile 수정 (#295) * refactor: 필요없는 러너 서포터 필드 삭제 * refactor: 필요없는 ChattingCount 필드 삭제 * feat: RunnerTechnicalTag 관련 클래스 추가 * refactor: SupporterTechnicalTag 빌더 생성자 접근제한자 private 으로 변경 * feat: SupporterRunnerPost 엔티티 생성 * refactor: dev CI 스크립트 변경 * refactor: dev CD 스크립트 변경 * refactor: deploy CI 스크립트 변경 * refactor: deploy CD 스크립트 변경 * Auth ArgumentResolver 익명 null 반환 변경 (#271) * refactor: Auth ArgumentResolver 익명 null 반환 변경 * refactor: AuthSupporterPrincipal 내부 속성 변경 * refactor: docker container timezone 변경 * feat: Dockerfile 타임존 설정 * feat: jar 타임존 설정 * fix: Dockerfile 오류 해결 --------- Co-authored-by: HyunSeo Park (Hyena) * feat: 지원한 서포터 목록 조회 API 구현 * fix: conflict 해결 * refactor: for 문 삭제 * test: path Parameter 추가 * fix: 충돌 해결 * refactor: introduction 객체 수정 * refactor: 코드 포맷팅 * style: 중복되는 테스트 삭제 * fix: 충돌 해결 * refactor: restdocs request Headers 추가 * refactor: 변수명 변경 * refactor: 변수명 변경 * refactor: 피드백 반영 * fix: introduction nullable=true 로 변경 * fix: introduction nullable=false로 변경 * refactor: 피드백 반영 * fix: 충돌 해결 * refactor: 피드백 반영 * refactor: 피드백 반영 * refactor: 피드백 반영 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * fix: applicantCount 계산 로직 수정 * feat: 피드백 반영 및 RestDocs 테스트 보충 * refactor: 피드백 반영 * refactor: restDocs 파일명에 Profile 제거 * style: 필요없는 개행 제거 * fix: countByRunnerPostIds 쿼리 수정 * fix: countByRunnerPostIds 쿼리 수정 * refactor: 피드백 반영 * style: 사용하지 않는 개행 제거 * test: countByRunnerPostIds 테스트 작성 * refactor: 피드백 반영 * refactor: 피드백 반영 * refactor: 피드백 반영 --------- Co-authored-by: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Co-authored-by: HyunSeo Park (Hyena) * PR url에 검증 기능 추가 (#398) * feat: UrlValidator 구현 * refactor: url protocol 을 반드시 붙이도록 정책 변경 * refactor: MockConstraintValidatorContext 로 이름 변경 * style: 예외 메서드 변경 * style: 예외 메서드 변경 * refactor: ClientErrorCode static import하도록 변경 * deploy cd script에 docker mount를 추가 (#392) chore: deploy cd script 에 mount 추가 * 스케줄러 구현 (#400) * feat: submodule 추가 * feat: repository 로직 구현 * feat: scheduling 구현 * feat: scheduling 구현 * feat: query param 으로 수정 * test: 테스트 코드를 예쁘게 하기 * PageableDefault 의 page default 값을 0으로 시작하도록 수정 (#408) fix: PageableDefault 의 page default 값을 0으로 시작하도록 수정 * 필요 없는 메소드 삭제 (#410) * refactor: 스케줄링 사이클 1분으로 수정 * refactor: RunnerPost controller, service 필요없는 로직 제거 * test: 서포터 피드백 등록 assured test 수정 * test: 필요 없는 테스트 삭제 * test: 컨벤션 적용 * test: 충돌 해결 * refactor: 코드 리뷰 반영 * 러너 게시글 상세 페이지 isOwner 응답 수정 (#414) fix: 러너 게시글 상세 조회 isOwner 응답 수정 * docs: 기존 Restdocs 정리 (#415) * docs: 기존 Restdocs 정리 * docs: docs 이름 변경 * test: 중복된 테스트 삭제 * docs: 누락된 restdocs 추가 * docs: 노션에 맞게 adoc 정리 * docs: 병합 후 깨지는 파일 수정 * docs: 파일 포맷팅 정리 --------- Co-authored-by: HyunSeo Park (Hyena) Co-authored-by: Ethan Co-authored-by: 은비 <67318165+eunbii0213@users.noreply.github.com> Co-authored-by: eunbii0213 Co-authored-by: 에이든 <62369936+gyeongza@users.noreply.github.com> Co-authored-by: KangSan Lee Co-authored-by: 남상규 <103256030+tkdrb12@users.noreply.github.com> Co-authored-by: 상규 Co-authored-by: 에이든 Co-authored-by: 남상규 --- .github/workflows/deploy-be-ci-cd-push.yml | 65 +++ .github/workflows/deploy-be-ci-pr.yml | 23 + .github/workflows/deploy-fe-ci-pr.yml | 21 + .github/workflows/dev-be-ci-cd-push.yml | 65 +++ .github/workflows/dev-be-ci-pr.yml | 23 + .gitmodules | 3 + backend/baton/.gitignore | 183 +++++++ backend/baton/Dockerfile-deploy | 7 + backend/baton/Dockerfile-dev | 7 + backend/baton/build.gradle | 120 +++++ backend/baton/docker-compose.yaml | 10 + .../baton/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61608 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + backend/baton/gradlew | 244 +++++++++ backend/baton/gradlew.bat | 92 ++++ backend/baton/secret | 1 + backend/baton/settings.gradle | 1 + .../src/docs/asciidoc/MemberLoginReadApi.adoc | 29 + .../baton/src/docs/asciidoc/RunneReadApi.adoc | 26 + .../src/docs/asciidoc/RunnerLoginReadApi.adoc | 29 + .../docs/asciidoc/RunnerPostCreateApi.adoc | 25 + .../asciidoc/RunnerPostCreateReadApi.adoc | 37 ++ .../docs/asciidoc/RunnerPostDeleteApi.adoc | 25 + .../src/docs/asciidoc/RunnerPostReadApi.adoc | 131 +++++ .../docs/asciidoc/RunnerPostUpdateApi.adoc | 47 ++ ...nnerPostUpdateApplicantCancelationApi.adoc | 33 ++ .../src/docs/asciidoc/RunnerUpdateApi.adoc | 33 ++ .../src/docs/asciidoc/SupporterReadApi.adoc | 48 ++ .../src/docs/asciidoc/SupporterUpdateApi.adoc | 33 ++ backend/baton/src/docs/asciidoc/index.adoc | 54 ++ .../java/touch/baton/BatonApplication.java | 15 + .../java/touch/baton/common/LoggerAspect.java | 60 +++ .../java/touch/baton/common/LoggerUtils.java | 106 ++++ .../RunnerPostDeadlineCheckScheduler.java | 17 + .../ScheduleRunnerPostRepository.java | 18 + .../common/schedule/SchedulerService.java | 19 + .../baton/config/ArgumentResolverConfig.java | 26 + .../java/touch/baton/config/JpaConfig.java | 9 + .../config/OauthHttpInterfaceConfig.java | 25 + .../touch/baton/config/SchedulingConfig.java | 9 + .../java/touch/baton/config/WebMvcConfig.java | 36 ++ .../config/converter/ConverterConfig.java | 50 ++ .../config/converter/OauthTypeConverter.java | 12 + .../converter/ReviewStatusConverter.java | 12 + .../StringDateToLocalDateTimeConverter.java | 26 + .../baton/config/filter/FilterConfig.java | 21 + .../baton/config/filter/MDCLoggingFilter.java | 25 + .../touch/baton/domain/common/BaseEntity.java | 28 + .../domain/common/GlobalControllerAdvice.java | 75 +++ .../common/exception/BaseException.java | 9 + .../common/exception/BusinessException.java | 8 + .../common/exception/ClientErrorCode.java | 61 +++ .../exception/ClientRequestException.java | 15 + .../common/exception/DomainException.java | 8 + .../exception/validator/NotNullValidator.java | 24 + .../exception/validator/ValidNotNull.java | 38 ++ .../domain/common/response/ErrorResponse.java | 10 + .../domain/common/response/PageResponse.java | 38 ++ .../common/response/ServerErrorResponse.java | 16 + .../baton/domain/common/vo/ChattingCount.java | 31 ++ .../baton/domain/common/vo/Contents.java | 32 ++ .../baton/domain/common/vo/Introduction.java | 39 ++ .../touch/baton/domain/common/vo/TagName.java | 32 ++ .../touch/baton/domain/common/vo/Title.java | 32 ++ .../baton/domain/common/vo/TotalRating.java | 27 + .../baton/domain/common/vo/WatchedCount.java | 35 ++ .../domain/feedback/SupporterFeedback.java | 100 ++++ .../controller/FeedbackController.java | 38 ++ .../exception/FeedbackBusinessException.java | 10 + .../exception/SupporterFeedbackException.java | 10 + .../SupporterFeedbackRepository.java | 7 + .../feedback/service/FeedbackService.java | 54 ++ .../SupporterFeedBackCreateRequest.java | 16 + .../baton/domain/feedback/vo/Description.java | 32 ++ .../baton/domain/feedback/vo/ReviewType.java | 6 + .../touch/baton/domain/member/Member.java | 140 +++++ .../controller/MemberProfileController.java | 21 + .../response/LoginMemberInfoResponse.java | 10 + .../exception/MemberBusinessException.java | 10 + .../exception/MemberDomainException.java | 10 + .../exception/MemberRequestException.java | 11 + .../member/repository/MemberRepository.java | 7 + .../touch/baton/domain/member/vo/Company.java | 32 ++ .../baton/domain/member/vo/GithubUrl.java | 32 ++ .../baton/domain/member/vo/ImageUrl.java | 32 ++ .../baton/domain/member/vo/MemberName.java | 32 ++ .../touch/baton/domain/member/vo/OauthId.java | 32 ++ .../baton/domain/member/vo/SocialId.java | 32 ++ .../baton/domain/oauth/OauthInformation.java | 48 ++ .../touch/baton/domain/oauth/OauthType.java | 12 + .../touch/baton/domain/oauth/SocialToken.java | 15 + .../authcode/AuthCodeRequestUrlProvider.java | 10 + .../AuthCodeRequestUrlProviderComposite.java | 35 ++ .../oauth/client/OauthInformationClient.java | 11 + .../OauthInformationClientComposite.java | 34 ++ .../oauth/controller/OauthController.java | 45 ++ .../resolver/AuthMemberPrincipal.java | 13 + .../AuthMemberPrincipalArgumentResolver.java | 54 ++ .../resolver/AuthRunnerPrincipal.java | 13 + .../AuthRunnerPrincipalArgumentResolver.java | 58 ++ .../resolver/AuthSupporterPrincipal.java | 13 + ...uthSupporterPrincipalArgumentResolver.java | 51 ++ .../UserPrincipalArgumentResolver.java | 61 +++ .../exception/OauthBusinessException.java | 10 + .../exception/OauthRequestException.java | 11 + .../repository/OauthMemberRepository.java | 15 + .../repository/OauthRunnerRepository.java | 20 + .../repository/OauthSupporterRepository.java | 20 + .../domain/oauth/service/OauthService.java | 85 +++ .../touch/baton/domain/runner/Runner.java | 109 ++++ .../controller/RunnerProfileController.java | 63 +++ .../response/RunnerMyProfileResponse.java | 10 + .../response/RunnerProfileResponse.java | 36 ++ .../controller/response/RunnerResponse.java | 76 +++ .../exception/RunnerBusinessException.java | 10 + .../exception/RunnerDomainException.java | 10 + .../exception/RunnerRequestException.java | 11 + .../runner/repository/RunnerRepository.java | 19 + .../domain/runner/service/RunnerService.java | 75 +++ .../service/dto/RunnerUpdateRequest.java | 21 + .../baton/domain/runnerpost/RunnerPost.java | 288 ++++++++++ .../controller/RunnerPostController.java | 254 +++++++++ .../response/RunnerPostReadResponses.java | 27 + .../response/RunnerPostResponse.java | 206 +++++++ .../SupporterResponseTestVersion.java | 16 + .../response/SupporterRunnerPostResponse.java | 37 ++ .../SupporterRunnerPostResponses.java | 13 + .../RunnerPostBusinessException.java | 10 + .../exception/RunnerPostDomainException.java | 10 + .../exception/RunnerPostRequestException.java | 11 + .../exception/validator/FutureValidator.java | 27 + .../validator/MaxLengthValidator.java | 28 + .../exception/validator/UrlValidator.java | 34 ++ .../exception/validator/ValidFuture.java | 28 + .../exception/validator/ValidMaxLength.java | 30 ++ .../exception/validator/ValidNotUrl.java | 28 + .../repository/RunnerPostRepository.java | 60 +++ .../runnerpost/service/RunnerPostService.java | 233 ++++++++ .../dto/RunnerPostApplicantCreateRequest.java | 9 + .../service/dto/RunnerPostCreateRequest.java | 34 ++ .../dto/RunnerPostCreateTestRequest.java | 13 + .../service/dto/RunnerPostUpdateRequest.java | 32 ++ .../baton/domain/runnerpost/vo/Deadline.java | 38 ++ .../domain/runnerpost/vo/PullRequestUrl.java | 34 ++ .../domain/runnerpost/vo/ReviewStatus.java | 35 ++ .../baton/domain/supporter/Supporter.java | 136 +++++ .../domain/supporter/SupporterRunnerPost.java | 92 ++++ .../controller/SupporterController.java | 29 + .../SupporterProfileController.java | 50 ++ .../response/SupporterReadResponses.java | 13 + .../response/SupporterResponse.java | 79 +++ .../exception/SupporterBusinessException.java | 10 + .../exception/SupporterDomainException.java | 10 + .../exception/SupporterRequestException.java | 11 + .../repository/SupporterRepository.java | 19 + .../SupporterRunnerPostRepository.java | 59 ++ .../supporter/service/SupporterService.java | 67 +++ .../service/dto/SupporterUpdateRequest.java | 21 + .../baton/domain/supporter/vo/Message.java | 32 ++ .../domain/supporter/vo/ReviewCount.java | 27 + .../touch/baton/domain/tag/RunnerPostTag.java | 65 +++ .../baton/domain/tag/RunnerPostTags.java | 47 ++ .../main/java/touch/baton/domain/tag/Tag.java | 56 ++ .../RunnerPostTagDomainException.java | 10 + .../SupporterTechnicalTagDomainException.java | 10 + .../tag/exception/TagBusinessException.java | 10 + .../tag/exception/TagDomainException.java | 10 + .../tag/exception/TagRequestException.java | 11 + .../TechnicalTagDomainException.java | 10 + .../repository/RunnerPostTagRepository.java | 19 + .../domain/tag/repository/TagRepository.java | 12 + .../technicaltag/RunnerTechnicalTag.java | 63 +++ .../technicaltag/RunnerTechnicalTags.java | 29 + .../technicaltag/SupporterTechnicalTag.java | 63 +++ .../technicaltag/SupporterTechnicalTags.java | 29 + .../domain/technicaltag/TechnicalTag.java | 46 ++ .../RunnerTechnicalTagRepository.java | 15 + .../SupporterTechnicalTagRepository.java | 15 + .../repository/TechnicalTagRepository.java | 12 + .../touch/baton/infra/auth/jwt/JwtConfig.java | 25 + .../baton/infra/auth/jwt/JwtDecoder.java | 37 ++ .../baton/infra/auth/jwt/JwtEncoder.java | 36 ++ .../auth/oauth/github/GithubOauthConfig.java | 11 + .../GithubAuthCodeRequestUrlProvider.java | 31 ++ .../client/GithubInformationClient.java | 41 ++ .../github/http/GithubHttpInterface.java | 21 + .../github/request/GithubTokenRequest.java | 13 + .../github/response/GithubMemberResponse.java | 32 ++ .../auth/oauth/github/token/GithubToken.java | 11 + .../baton/src/main/resources/application.yml | 36 ++ .../baton/src/main/resources/logs/log4j2.xml | 85 +++ .../touch/baton/BatonApplicationTests.java | 13 + .../touch/baton/TestBatonApplication.java | 21 + .../baton/assure/common/AssuredSupport.java | 198 +++++++ .../common/HttpStatusAndLocationHeader.java | 22 + .../SupporterFeedbackAssuredSupport.java | 58 ++ .../SupporterFeedbackCreateAssuredTest.java | 55 ++ .../assure/member/MemberAssuredSupport.java | 63 +++ ...emberReadWithLoginedMemberAssuredTest.java | 27 + .../assure/runner/RunnerAssuredSupport.java | 116 ++++ .../RunnerReadByRunnerIdAssuredTest.java | 42 ++ ...unnerReadWithLoginedRunnerAssuredTest.java | 30 ++ .../runner/RunnerUpdateAssuredTest.java | 99 ++++ .../RunnerPostAssuredCreateSupport.java | 98 ++++ .../RunnerPostAssuredCreateTest.java | 113 ++++ .../runnerpost/RunnerPostAssuredSupport.java | 318 +++++++++++ .../RunnerPostCreateAssuredTest.java | 190 +++++++ .../RunnerPostDeleteAssuredTest.java | 115 ++++ .../runnerpost/RunnerPostReadAssuredTest.java | 82 +++ ...nnerPostReadByRunnerPostIdAssuredTest.java | 53 ++ .../RunnerPostReadWithLoginedAssuredTest.java | 97 ++++ ...stReadWithLoginedSupporterAssuredTest.java | 119 ++++ ...SupporterIdAndReviewStatusAssuredTest.java | 97 ++++ .../RunnerPostUpdateAssuredTest.java | 79 +++ .../supporter/SupporterAssuredSupport.java | 113 ++++ ...SupporterReadBySupporterIdAssuredTest.java | 72 +++ .../SupporterRunnerPostAssuredSupport.java | 59 ++ .../SupporterRunnerPostDeleteAssuredTest.java | 66 +++ .../supporter/SupporterUpdateAssuredTest.java | 97 ++++ .../RunnerPostDeadlineCheckSchedulerTest.java | 58 ++ .../ScheduleRunnerPostRepositoryTest.java | 107 ++++ .../touch/baton/config/AssuredTestConfig.java | 69 +++ .../config/AssuredTestExecutionListener.java | 39 ++ .../baton/config/PageableTestConfig.java | 14 + .../baton/config/RepositoryTestConfig.java | 9 + .../touch/baton/config/RestdocsConfig.java | 127 +++++ .../touch/baton/config/ServiceTestConfig.java | 50 ++ .../config/converter/ConverterConfigTest.java | 75 +++ ...tringDateToLocalDateTimeConverterTest.java | 44 ++ .../oauth/github/GithubOauthApiTest.java | 93 ++++ .../MemberReadWithLoginedMemberApiTest.java | 70 +++ .../runner/read/RunnerReadByGuestApiTest.java | 116 ++++ .../read/RunnerReadByRunnerIdApiTest.java | 84 +++ .../RunnerReadWithLoginedRunnerApiTest.java | 78 +++ .../runner/update/RunnerUpdateApiTest.java | 90 ++++ .../read/SupporterReadByGuestApiTest.java | 116 ++++ .../update/SupporterUpdateApiTest.java | 99 ++++ .../create/RunnerPostApplicantApiTest.java | 103 ++++ .../create/RunnerPostCreateApiTest.java | 75 +++ .../delete/RunnerPostDeleteApiTest.java | 71 +++ .../read/RunnerPostReadAllApiTest.java | 178 ++++++ ...nnerPostReadOfSupporterByGuestApiTest.java | 120 +++++ .../read/RunnerPostReadOneApiTest.java | 119 ++++ ...erPostReadWithLoginedSupporterApiTest.java | 140 +++++ ...PostUpdateApplicantCancelationApiTest.java | 99 ++++ .../read/SupporterRunnerPostReadApiTest.java | 105 ++++ .../update/RunnerPostUpdateApiTest.java | 104 ++++ .../baton/domain/common/vo/ContentsTest.java | 16 + .../domain/common/vo/DescriptionTest.java | 18 + .../baton/domain/common/vo/TitleTest.java | 16 + .../domain/common/vo/WatchedCountTest.java | 32 ++ .../feedback/service/FeedbackServiceTest.java | 78 +++ .../touch/baton/domain/member/MemberTest.java | 182 +++++++ .../baton/domain/member/vo/CompanyTest.java | 16 + .../baton/domain/member/vo/GithubUrlTest.java | 16 + .../baton/domain/member/vo/ImageUrlTest.java | 16 + .../baton/domain/member/vo/NameTest.java | 16 + .../baton/domain/member/vo/OauthIdTest.java | 16 + .../baton/domain/member/vo/SocialIdTest.java | 16 + .../controller/OauthTypeConverterTest.java | 26 + .../touch/baton/domain/runner/RunnerTest.java | 67 +++ .../repository/RunnerRepositoryTest.java | 91 ++++ .../runner/service/RunnerServiceReadTest.java | 57 ++ .../service/RunnerServiceUpdateTest.java | 68 +++ .../domain/runnerpost/RunnerPostTest.java | 508 ++++++++++++++++++ .../exception/validator/UrlValidatorTest.java | 121 +++++ .../RunnerPostRepositoryDeleteTest.java | 85 +++ .../RunnerPostRepositoryReadTest.java | 86 +++ .../repository/RunnerPostRepositoryTest.java | 162 ++++++ ...SupporterRunnerPostRepositoryReadTest.java | 105 ++++ .../read/RunnerPostRepositoryReadTest.java | 119 ++++ .../service/RunnerPostServiceCreateTest.java | 160 ++++++ .../service/RunnerPostServiceDeleteTest.java | 119 ++++ .../service/RunnerPostServiceReadTest.java | 327 +++++++++++ .../service/RunnerPostServiceUpdateTest.java | 201 +++++++ ...UpdateApplicantCancelationServiceTest.java | 102 ++++ .../domain/runnerpost/vo/DeadlineTest.java | 16 + .../runnerpost/vo/PullRequestUrlTest.java | 16 + .../supporter/SupporterFeedbackTest.java | 150 ++++++ .../baton/domain/supporter/SupporterTest.java | 157 ++++++ .../repository/SupporterRepositoryTest.java | 47 ++ .../SupporterRunnerPostRepositoryTest.java | 209 +++++++ .../service/SupporterServiceTest.java | 61 +++ .../baton/domain/tag/RunnerPostTagTest.java | 117 ++++ .../baton/domain/tag/RunnerPostTagsTest.java | 54 ++ .../java/touch/baton/domain/tag/TagTest.java | 37 ++ .../RunnerPostTagRepositoryTest.java | 149 +++++ .../tag/repository/TagRepositoryTest.java | 35 ++ .../baton/domain/tag/vo/TagNameTest.java | 17 + .../SupporterTechnicalTagTest.java | 66 +++ .../SupporterTechnicalTagsTest.java | 42 ++ .../domain/technicaltag/TechnicalTagTest.java | 31 ++ .../SupporterTechnicalTagRepositoryTest.java | 57 ++ .../TechnicalTagRepositoryTest.java | 52 ++ .../baton/fixture/domain/MemberFixture.java | 89 +++ .../baton/fixture/domain/RunnerFixture.java | 54 ++ .../fixture/domain/RunnerPostFixture.java | 185 +++++++ .../fixture/domain/RunnerPostTagFixture.java | 18 + .../fixture/domain/RunnerPostTagsFixture.java | 16 + .../domain/RunnerTechnicalTagsFixture.java | 29 + .../domain/SupporterFeedbackFixture.java | 44 ++ .../fixture/domain/SupporterFixture.java | 64 +++ .../domain/SupporterRunnerPostFixture.java | 20 + .../domain/SupporterTechnicalTagFixture.java | 18 + .../domain/SupporterTechnicalTagsFixture.java | 16 + .../baton/fixture/domain/TagFixture.java | 29 + .../fixture/domain/TechnicalTagFixture.java | 30 ++ .../baton/fixture/vo/CompanyFixture.java | 13 + .../baton/fixture/vo/ContentsFixture.java | 13 + .../baton/fixture/vo/DeadlineFixture.java | 15 + .../baton/fixture/vo/DescriptionFixture.java | 13 + .../baton/fixture/vo/GithubUrlFixture.java | 13 + .../baton/fixture/vo/ImageUrlFixture.java | 13 + .../baton/fixture/vo/IntroductionFixture.java | 13 + .../baton/fixture/vo/MemberNameFixture.java | 13 + .../baton/fixture/vo/MessageFixture.java | 13 + .../baton/fixture/vo/OauthIdFixture.java | 13 + .../fixture/vo/PullRequestUrlFixture.java | 13 + .../baton/fixture/vo/ReviewCountFixture.java | 13 + .../baton/fixture/vo/SocialIdFixture.java | 13 + .../baton/fixture/vo/TagNameFixture.java | 13 + .../touch/baton/fixture/vo/TitleFixture.java | 13 + .../baton/fixture/vo/WatchedCountFixture.java | 13 + .../auth/jwt/JwtEncoderAndDecoderTest.java | 61 +++ 324 files changed, 17316 insertions(+) create mode 100644 .github/workflows/deploy-be-ci-cd-push.yml create mode 100644 .github/workflows/deploy-be-ci-pr.yml create mode 100644 .github/workflows/deploy-fe-ci-pr.yml create mode 100644 .github/workflows/dev-be-ci-cd-push.yml create mode 100644 .github/workflows/dev-be-ci-pr.yml create mode 100644 .gitmodules create mode 100644 backend/baton/.gitignore create mode 100644 backend/baton/Dockerfile-deploy create mode 100644 backend/baton/Dockerfile-dev create mode 100644 backend/baton/build.gradle create mode 100644 backend/baton/docker-compose.yaml create mode 100644 backend/baton/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/baton/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/baton/gradlew create mode 100644 backend/baton/gradlew.bat create mode 160000 backend/baton/secret create mode 100644 backend/baton/settings.gradle create mode 100644 backend/baton/src/docs/asciidoc/MemberLoginReadApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunneReadApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerLoginReadApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerPostCreateApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerPostCreateReadApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerPostDeleteApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerPostReadApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerPostUpdateApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerPostUpdateApplicantCancelationApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/RunnerUpdateApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/SupporterReadApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/SupporterUpdateApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/index.adoc create mode 100644 backend/baton/src/main/java/touch/baton/BatonApplication.java create mode 100644 backend/baton/src/main/java/touch/baton/common/LoggerAspect.java create mode 100644 backend/baton/src/main/java/touch/baton/common/LoggerUtils.java create mode 100644 backend/baton/src/main/java/touch/baton/common/schedule/RunnerPostDeadlineCheckScheduler.java create mode 100644 backend/baton/src/main/java/touch/baton/common/schedule/ScheduleRunnerPostRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/common/schedule/SchedulerService.java create mode 100644 backend/baton/src/main/java/touch/baton/config/ArgumentResolverConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/config/JpaConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/config/OauthHttpInterfaceConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/config/SchedulingConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/config/converter/OauthTypeConverter.java create mode 100644 backend/baton/src/main/java/touch/baton/config/converter/ReviewStatusConverter.java create mode 100644 backend/baton/src/main/java/touch/baton/config/converter/StringDateToLocalDateTimeConverter.java create mode 100644 backend/baton/src/main/java/touch/baton/config/filter/FilterConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/config/filter/MDCLoggingFilter.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/BaseEntity.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/GlobalControllerAdvice.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/exception/BaseException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/exception/BusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/exception/ClientRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/exception/DomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/exception/validator/NotNullValidator.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/exception/validator/ValidNotNull.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/response/ErrorResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/response/PageResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/response/ServerErrorResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/vo/ChattingCount.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/vo/Contents.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/vo/Introduction.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/vo/TagName.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/vo/Title.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/vo/TotalRating.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/vo/WatchedCount.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/SupporterFeedback.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/controller/FeedbackController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/exception/FeedbackBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/exception/SupporterFeedbackException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/repository/SupporterFeedbackRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/service/FeedbackService.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/service/SupporterFeedBackCreateRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/vo/Description.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/feedback/vo/ReviewType.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/Member.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/controller/MemberProfileController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/controller/response/LoginMemberInfoResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/exception/MemberBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/exception/MemberDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/exception/MemberRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/repository/MemberRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/vo/Company.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/vo/GithubUrl.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/vo/ImageUrl.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/vo/MemberName.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/vo/OauthId.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/member/vo/SocialId.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/OauthInformation.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/OauthType.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/SocialToken.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProvider.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProviderComposite.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClient.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClientComposite.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/OauthController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipal.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipalArgumentResolver.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipal.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipalArgumentResolver.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipal.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipalArgumentResolver.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/UserPrincipalArgumentResolver.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthMemberRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthRunnerRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthSupporterRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/oauth/service/OauthService.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/Runner.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/controller/RunnerProfileController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerMyProfileResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerProfileResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/repository/RunnerRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/service/RunnerService.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runner/service/dto/RunnerUpdateRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/RunnerPost.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/RunnerPostController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostReadResponses.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterResponseTestVersion.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponses.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/FutureValidator.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/MaxLengthValidator.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/UrlValidator.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidFuture.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidMaxLength.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidNotUrl.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/repository/RunnerPostRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/service/RunnerPostService.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostApplicantCreateRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateTestRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostUpdateRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/Deadline.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/PullRequestUrl.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/ReviewStatus.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/Supporter.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/SupporterRunnerPost.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterProfileController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterReadResponses.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/service/SupporterService.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/service/dto/SupporterUpdateRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/vo/Message.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/supporter/vo/ReviewCount.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTag.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTags.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/Tag.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/exception/RunnerPostTagDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/exception/SupporterTechnicalTagDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/exception/TagBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/exception/TagDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/exception/TagRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/exception/TechnicalTagDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/repository/RunnerPostTagRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/tag/repository/TagRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTag.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTags.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTag.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTags.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/TechnicalTag.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/RunnerTechnicalTagRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtDecoder.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtEncoder.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/GithubOauthConfig.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/authcode/GithubAuthCodeRequestUrlProvider.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/client/GithubInformationClient.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/http/GithubHttpInterface.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/request/GithubTokenRequest.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/response/GithubMemberResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/token/GithubToken.java create mode 100644 backend/baton/src/main/resources/application.yml create mode 100644 backend/baton/src/main/resources/logs/log4j2.xml create mode 100644 backend/baton/src/test/java/touch/baton/BatonApplicationTests.java create mode 100644 backend/baton/src/test/java/touch/baton/TestBatonApplication.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/common/HttpStatusAndLocationHeader.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackAssuredSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackCreateAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/member/MemberAssuredSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/member/MemberReadWithLoginedMemberAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runner/RunnerAssuredSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadByRunnerIdAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadWithLoginedRunnerAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runner/RunnerUpdateAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostCreateAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostDeleteAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadByRunnerPostIdAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedSupporterAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithSupporterIdAndReviewStatusAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostUpdateAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/supporter/SupporterAssuredSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/supporter/SupporterReadBySupporterIdAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostAssuredSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostDeleteAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/supporter/SupporterUpdateAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/common/schedule/RunnerPostDeadlineCheckSchedulerTest.java create mode 100644 backend/baton/src/test/java/touch/baton/common/schedule/ScheduleRunnerPostRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java create mode 100644 backend/baton/src/test/java/touch/baton/config/AssuredTestExecutionListener.java create mode 100644 backend/baton/src/test/java/touch/baton/config/PageableTestConfig.java create mode 100644 backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java create mode 100644 backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java create mode 100644 backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java create mode 100644 backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java create mode 100644 backend/baton/src/test/java/touch/baton/config/converter/StringDateToLocalDateTimeConverterTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/profile/member/read/MemberReadWithLoginedMemberApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByRunnerIdApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadWithLoginedRunnerApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/profile/runner/update/RunnerUpdateApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/profile/supporter/read/SupporterReadByGuestApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/profile/supporter/update/SupporterUpdateApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostApplicantApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostCreateApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/delete/RunnerPostDeleteApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadAllApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOfSupporterByGuestApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOneApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadWithLoginedSupporterApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostUpdateApplicantCancelationApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/read/SupporterRunnerPostReadApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/common/vo/ContentsTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/common/vo/DescriptionTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/common/vo/TitleTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/common/vo/WatchedCountTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/feedback/service/FeedbackServiceTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/member/MemberTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/member/vo/CompanyTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/member/vo/GithubUrlTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/member/vo/ImageUrlTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/member/vo/NameTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/member/vo/OauthIdTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/member/vo/SocialIdTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/oauth/controller/OauthTypeConverterTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runner/RunnerTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runner/repository/RunnerRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceReadTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceUpdateTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/exception/validator/UrlValidatorTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryDeleteTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryReadTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/SupporterRunnerPostRepositoryReadTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/read/RunnerPostRepositoryReadTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceCreateTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceDeleteTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceReadTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceUpdateTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostUpdateApplicantCancelationServiceTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/DeadlineTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/PullRequestUrlTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/supporter/SupporterFeedbackTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/supporter/SupporterTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterServiceTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagsTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/tag/TagTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/tag/repository/RunnerPostTagRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/tag/repository/TagRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/tag/vo/TagNameTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagsTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/technicaltag/TechnicalTagTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/MemberFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/RunnerFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagsFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/RunnerTechnicalTagsFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFeedbackFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/SupporterRunnerPostFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagsFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/TagFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/TechnicalTagFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/CompanyFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/ContentsFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/DeadlineFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/DescriptionFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/GithubUrlFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/ImageUrlFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/IntroductionFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/MemberNameFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/MessageFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/OauthIdFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/PullRequestUrlFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/ReviewCountFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/SocialIdFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/TagNameFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/TitleFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/WatchedCountFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/infra/auth/jwt/JwtEncoderAndDecoderTest.java diff --git a/.github/workflows/deploy-be-ci-cd-push.yml b/.github/workflows/deploy-be-ci-cd-push.yml new file mode 100644 index 000000000..c30277920 --- /dev/null +++ b/.github/workflows/deploy-be-ci-cd-push.yml @@ -0,0 +1,65 @@ +name: All deploy CI + CD on push + +on: + push: + branches: [ "deploy/BE" ] + +jobs: + deploy-ci: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + token: ${{ secrets.SUBMODULE_BE_TOKEN }} + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + + - name: Build with Gradle + uses: gradle/gradle-build-action@v2.6.0 + - name: Execute Gradle build + run: | + cd backend/baton + ./gradlew build + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v2.9.1 + + - name: Login to Docker Hub + uses: docker/login-action@v2.2.0 + with: + username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + + - name: Docker Image Build + run: | + cd backend/baton + docker build --platform linux/arm64/v8 -t 2023batondeploy/2023-baton-deploy -f Dockerfile-deploy . + + - name: Docker Hub Push + run: docker push 2023batondeploy/2023-baton-deploy + + deploy-cd: + needs: deploy-ci + runs-on: [self-hosted, Linux, ARM64, deploy] + + steps: + - name: Pull Latest Docker Image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + if sudo docker inspect spring-baton &>/dev/null; then + sudo docker stop spring-baton + sudo docker rm -f spring-baton + sudo docker image prune -af + fi + sudo docker pull 2023batondeploy/2023-baton-deploy:latest + + - name: Docker Compose + run: | + sudo docker run --name spring-baton -v /home/ubuntu/logs:/app/logs -p 8080:8080 -e TZ=Asia/Seoul 2023batondeploy/2023-baton-deploy:latest 1>> build.log 2>> error.log & diff --git a/.github/workflows/deploy-be-ci-pr.yml b/.github/workflows/deploy-be-ci-pr.yml new file mode 100644 index 000000000..2a9cd4526 --- /dev/null +++ b/.github/workflows/deploy-be-ci-pr.yml @@ -0,0 +1,23 @@ +name: BE deploy CI on Pull Request + +on: + pull_request: + branches: [ "deploy/BE" ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + - name: Build with Gradle + uses: gradle/gradle-build-action@v2.6.0 + - name: Execute Gradle build + run: | + cd backend/baton + ./gradlew build diff --git a/.github/workflows/deploy-fe-ci-pr.yml b/.github/workflows/deploy-fe-ci-pr.yml new file mode 100644 index 000000000..7bf483c46 --- /dev/null +++ b/.github/workflows/deploy-fe-ci-pr.yml @@ -0,0 +1,21 @@ +name: FE deploy CI on Pull Request + +on: + pull_request: + branches: [ "deploy" ] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: 의존성을 설치한다 + run: npm install + + - name: 테스트를 수행한다 + run: npm run test diff --git a/.github/workflows/dev-be-ci-cd-push.yml b/.github/workflows/dev-be-ci-cd-push.yml new file mode 100644 index 000000000..4c522bb27 --- /dev/null +++ b/.github/workflows/dev-be-ci-cd-push.yml @@ -0,0 +1,65 @@ +name: dev/BE CD on Push + +on: + push: + branches: [ "dev/BE" ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + token: ${{ secrets.SUBMODULE_BE_TOKEN }} + submodules: recursive + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + + - name: Build with Gradle + uses: gradle/gradle-build-action@v2.6.0 + - name: Execute Gradle build + run: | + cd backend/baton + ./gradlew build + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v2.9.1 + + - name: Login to Docker Hub + uses: docker/login-action@v2.2.0 + with: + username: ${{ secrets.DOCKERHUB_DEV_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEV_TOKEN }} + + - name: Docker Image Build + run: | + cd backend/baton + docker build --platform linux/arm64/v8 -t 2023baton/2023baton -f Dockerfile-dev . + + - name: Docker Hub Push + run: docker push 2023baton/2023baton + + deploy: + runs-on: [self-hosted, Linux, ARM64] + needs: build + + steps: + - name: Pull Latest Docker Image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEV_USERNAME }} --password ${{ secrets.DOCKERHUB_DEV_TOKEN }} + if sudo docker inspect spring-baton &>/dev/null; then + sudo docker stop spring-baton + sudo docker rm -f spring-baton + sudo docker image prune -af + fi + sudo docker pull 2023baton/2023baton:latest + + - name: Docker Compose + run: | + sudo docker run --name spring-baton --network=baton -p 8080:8080 -e TZ=Asia/Seoul 2023baton/2023baton:latest 1>> build.log 2>> error.log & diff --git a/.github/workflows/dev-be-ci-pr.yml b/.github/workflows/dev-be-ci-pr.yml new file mode 100644 index 000000000..f6f7b682b --- /dev/null +++ b/.github/workflows/dev-be-ci-pr.yml @@ -0,0 +1,23 @@ +name: dev/BE CI on Pull Request + +on: + pull_request: + branches: [ "dev/BE" ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + - name: Build with Gradle + uses: gradle/gradle-build-action@v2.6.0 + - name: Execute Gradle build + run: | + cd backend/baton + ./gradlew build diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2d48c233c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "backend/baton/secret"] + path = backend/baton/secret + url = https://github.com/2023-baton/sub-be.git diff --git a/backend/baton/.gitignore b/backend/baton/.gitignore new file mode 100644 index 000000000..121ef1a81 --- /dev/null +++ b/backend/baton/.gitignore @@ -0,0 +1,183 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,java,gradle,intellij+all +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,java,gradle,intellij+all + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +HELP.md + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/macos,java,gradle,intellij+all + +src/main/resources/application-deploy.yml +src/main/resources/application-dev.yml +src/main/resources/static/docs/** diff --git a/backend/baton/Dockerfile-deploy b/backend/baton/Dockerfile-deploy new file mode 100644 index 000000000..2ae02d8e9 --- /dev/null +++ b/backend/baton/Dockerfile-deploy @@ -0,0 +1,7 @@ +FROM arm64v8/amazoncorretto:17 + +WORKDIR /app + +COPY ./build/libs/baton-0.0.1-SNAPSHOT.jar /app/baton.jar + +CMD ["java", "-Duser.timezone=Asia/Seoul", "-jar", "-Dspring.profiles.active=deploy", "baton.jar"] diff --git a/backend/baton/Dockerfile-dev b/backend/baton/Dockerfile-dev new file mode 100644 index 000000000..c1383534a --- /dev/null +++ b/backend/baton/Dockerfile-dev @@ -0,0 +1,7 @@ +FROM arm64v8/amazoncorretto:17 + +WORKDIR /app + +COPY ./build/libs/baton-0.0.1-SNAPSHOT.jar /app/baton.jar + +CMD ["java", "-Duser.timezone=Asia/Seoul", "-jar", "-Dspring.profiles.active=dev", "baton.jar"] diff --git a/backend/baton/build.gradle b/backend/baton/build.gradle new file mode 100644 index 000000000..58d99a37d --- /dev/null +++ b/backend/baton/build.gradle @@ -0,0 +1,120 @@ +plugins { + id 'java' + id 'java-test-fixtures' + id 'org.springframework.boot' version '3.1.1' + id 'io.spring.dependency-management' version '1.1.0' + id 'org.asciidoctor.jvm.convert' version '3.3.2' +} + +group = 'touch' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + asciidoctorExt + configureEach { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +ext { + set('snippetsDir', file("build/generated-snippets")) + set('restDocsCopyFrom', file("build/docs/asciidoc/index.html")) + set('restDocsCopyIntoBuild', file("build/resources/main/static/docs")) + set('restDocsCopyIntoLocal', file("src/main/resources/static/docs")) +} + +dependencies { + // spring starter + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-aop:3.1.1' + + // flyway + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // database + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + // log + implementation 'org.springframework.boot:spring-boot-starter-log4j2' + + // docker + developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + + // test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'org.testcontainers:mysql' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' +} + +tasks.named('test') { + outputs.dir snippetsDir + useJUnitPlatform() +} + +processResources.dependsOn('copySecret') + +tasks.register('copySecret', Copy) { + from './secret' + include 'application*.yml' + into 'src/main/resources' +} + +tasks.named('asciidoctor') { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + sources { + include("**/index.adoc") + } + baseDirFollowsSourceFile() + dependsOn test +} + +asciidoctor.doFirst { + delete file(restDocsCopyFrom) +} + +task copyDocument(type: Copy) { + dependsOn asciidoctor + from file(restDocsCopyFrom) + into file(restDocsCopyIntoLocal) + into file(restDocsCopyIntoBuild) +} + +tasks.named('resolveMainClassName') { + inputs.files(tasks.named('copyDocument')) +} + +bootJar.dependsOn('copyDocument') diff --git a/backend/baton/docker-compose.yaml b/backend/baton/docker-compose.yaml new file mode 100644 index 000000000..3f0d6fd01 --- /dev/null +++ b/backend/baton/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + mysql: + image: 'mysql:latest' + environment: + - 'MYSQL_DATABASE=mydatabase' + - 'MYSQL_PASSWORD=secret' + - 'MYSQL_ROOT_PASSWORD=verysecret' + - 'MYSQL_USER=myuser' + ports: + - '3307:3306' diff --git a/backend/baton/gradle/wrapper/gradle-wrapper.jar b/backend/baton/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..ccebba7710deaf9f98673a68957ea02138b60d0a GIT binary patch literal 61608 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 literal 0 HcmV?d00001 diff --git a/backend/baton/gradle/wrapper/gradle-wrapper.properties b/backend/baton/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..42defcc94 --- /dev/null +++ b/backend/baton/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/baton/gradlew b/backend/baton/gradlew new file mode 100755 index 000000000..79a61d421 --- /dev/null +++ b/backend/baton/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/baton/gradlew.bat b/backend/baton/gradlew.bat new file mode 100644 index 000000000..93e3f59f1 --- /dev/null +++ b/backend/baton/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/baton/secret b/backend/baton/secret new file mode 160000 index 000000000..d345771f0 --- /dev/null +++ b/backend/baton/secret @@ -0,0 +1 @@ +Subproject commit d345771f04f01c72890d77ef01d385313b5e9d1e diff --git a/backend/baton/settings.gradle b/backend/baton/settings.gradle new file mode 100644 index 000000000..12f2c9107 --- /dev/null +++ b/backend/baton/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'baton' diff --git a/backend/baton/src/docs/asciidoc/MemberLoginReadApi.adoc b/backend/baton/src/docs/asciidoc/MemberLoginReadApi.adoc new file mode 100644 index 000000000..4ce44c3ed --- /dev/null +++ b/backend/baton/src/docs/asciidoc/MemberLoginReadApi.adoc @@ -0,0 +1,29 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *로그인된 사용자 프로필 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/member-read-with-logined-member-api-test/read-login-member-by-access-token/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/member-read-with-logined-member-api-test/read-login-member-by-access-token/request-headers.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/member-read-with-logined-member-api-test/read-login-member-by-access-token/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/member-read-with-logined-member-api-test/read-login-member-by-access-token/response-fields.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunneReadApi.adoc b/backend/baton/src/docs/asciidoc/RunneReadApi.adoc new file mode 100644 index 000000000..0a57d96bb --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunneReadApi.adoc @@ -0,0 +1,26 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *러너 프로필 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-read-by-runner-id-api-test/read-runner-profile/http-request.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-read-by-runner-id-api-test/read-runner-profile/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/runner-read-by-runner-id-api-test/read-runner-profile/response-fields.adoc[] + diff --git a/backend/baton/src/docs/asciidoc/RunnerLoginReadApi.adoc b/backend/baton/src/docs/asciidoc/RunnerLoginReadApi.adoc new file mode 100644 index 000000000..3a04a5295 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerLoginReadApi.adoc @@ -0,0 +1,29 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *러너 마이페이지 프로필 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-read-with-logined-runner-api-test/read-my-profile-by-token/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/runner-read-with-logined-runner-api-test/read-my-profile-by-token/request-headers.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-read-with-logined-runner-api-test/read-my-profile-by-token/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/runner-read-with-logined-runner-api-test/read-my-profile-by-token/response-fields.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunnerPostCreateApi.adoc b/backend/baton/src/docs/asciidoc/RunnerPostCreateApi.adoc new file mode 100644 index 000000000..9c84fd3e6 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerPostCreateApi.adoc @@ -0,0 +1,25 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *러너 게시글 생성 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-create-api-test/create-runner-post/http-request.adoc[] + +===== *Http Request Header* + +include::{snippets}/../../build/generated-snippets/runner-post-create-api-test/create-runner-post/request-headers.adoc[] + +===== *Http Response Header* + +include::{snippets}/../../build/generated-snippets/runner-post-create-api-test/create-runner-post/response-headers.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunnerPostCreateReadApi.adoc b/backend/baton/src/docs/asciidoc/RunnerPostCreateReadApi.adoc new file mode 100644 index 000000000..01936e816 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerPostCreateReadApi.adoc @@ -0,0 +1,37 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *서포터 러너 게시글 리뷰 신청 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-applicant-api-test/create-runner-post-applicant/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-applicant-api-test/create-runner-post-applicant/request-headers.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-applicant-api-test/create-runner-post-applicant/path-parameters.adoc[] + +===== *Http Request Fields* + +include::{snippets}/../../build/generated-snippets/runner-post-applicant-api-test/create-runner-post-applicant/request-fields.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-applicant-api-test/create-runner-post-applicant/http-response.adoc[] + +===== *Http Response Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-applicant-api-test/create-runner-post-applicant/response-headers.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunnerPostDeleteApi.adoc b/backend/baton/src/docs/asciidoc/RunnerPostDeleteApi.adoc new file mode 100644 index 000000000..f2512376f --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerPostDeleteApi.adoc @@ -0,0 +1,25 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *러너 게시글 삭제 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-delete-api-test/delete-by-runner-post-id/http-request.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-delete-api-test/delete-by-runner-post-id/path-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-delete-api-test/delete-by-runner-post-id/http-response.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunnerPostReadApi.adoc b/backend/baton/src/docs/asciidoc/RunnerPostReadApi.adoc new file mode 100644 index 000000000..21b2bf780 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerPostReadApi.adoc @@ -0,0 +1,131 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *러너 게시글 상세 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-read-one-api-test/read-by-runner-post-id/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-read-one-api-test/read-by-runner-post-id/request-headers.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-read-one-api-test/read-by-runner-post-id/path-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-read-one-api-test/read-by-runner-post-id/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/runner-post-read-one-api-test/read-by-runner-post-id/response-fields.adoc[] + +==== *러너 게시글 전체 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-all-runner-posts/http-request.adoc[] + +===== *Http Request Query Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-all-runner-posts/query-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-all-runner-posts/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-all-runner-posts/response-fields.adoc[] + +==== *러너 마이페이지 게시글 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-runner-my-page/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-runner-my-page/request-headers.adoc[] + +===== *Http Request Query Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-runner-my-page/query-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-runner-my-page/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/runner-post-read-all-api-test/read-runner-my-page/response-fields.adoc[] + +==== *리뷰 지원한 서포터 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/supporter-runner-post-read-api-test/read-supporter-runner-posts-by-runner-post-id/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/supporter-runner-post-read-api-test/read-supporter-runner-posts-by-runner-post-id/request-headers.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/supporter-runner-post-read-api-test/read-supporter-runner-posts-by-runner-post-id/path-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/supporter-runner-post-read-api-test/read-supporter-runner-posts-by-runner-post-id/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/supporter-runner-post-read-api-test/read-supporter-runner-posts-by-runner-post-id/response-fields.adoc[] + +==== *서포터 마이페이지 러너 게시글 조회 API (queryParam)* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-read-with-logined-supporter-api-test/read-runner-posts-by-logined-supporter-and-review-status/http-request.adoc[] + +===== *Http Request Query Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-read-with-logined-supporter-api-test/read-runner-posts-by-logined-supporter-and-review-status/query-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-read-with-logined-supporter-api-test/read-runner-posts-by-logined-supporter-and-review-status/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/runner-post-read-with-logined-supporter-api-test/read-runner-posts-by-logined-supporter-and-review-status/response-fields.adoc[] + +==== *서포터 리뷰 완료한 게시글 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-read-of-supporter-by-guest-api-test/read-referenced-by-supporter/http-request.adoc[] + +===== *Http Request Query Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-read-of-supporter-by-guest-api-test/read-referenced-by-supporter/query-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-read-of-supporter-by-guest-api-test/read-referenced-by-supporter/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/runner-post-read-of-supporter-by-guest-api-test/read-referenced-by-supporter/response-fields.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunnerPostUpdateApi.adoc b/backend/baton/src/docs/asciidoc/RunnerPostUpdateApi.adoc new file mode 100644 index 000000000..e6422f038 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerPostUpdateApi.adoc @@ -0,0 +1,47 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *리뷰 할 서포터 선택 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-supporter/http-request.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-supporter/path-parameters.adoc[] + +===== *Http Request Header* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-supporter/request-headers.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-supporter/http-response.adoc[] + +==== *서포터가 러너 게시글을 리뷰 완료 상태로 변경 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-review-status-done/http-request.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-review-status-done/path-parameters.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-review-status-done/request-headers.adoc[] + +===== *Http Response Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-update-api-test/update-runner-post-review-status-done/response-headers.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunnerPostUpdateApplicantCancelationApi.adoc b/backend/baton/src/docs/asciidoc/RunnerPostUpdateApplicantCancelationApi.adoc new file mode 100644 index 000000000..78ec19dcc --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerPostUpdateApplicantCancelationApi.adoc @@ -0,0 +1,33 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: investment +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *서포터 러너 게시글 리뷰 제안 취소 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-post-update-applicant-cancelation-api-test/update-supporter-cancel-runner-post/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-update-applicant-cancelation-api-test/update-supporter-cancel-runner-post/request-headers.adoc[] + +===== *Http Path Parameters* + +include::{snippets}/../../build/generated-snippets/runner-post-update-applicant-cancelation-api-test/update-supporter-cancel-runner-post/path-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-post-update-applicant-cancelation-api-test/update-supporter-cancel-runner-post/http-response.adoc[] + +===== *Http Response Headers* + +include::{snippets}/../../build/generated-snippets/runner-post-update-applicant-cancelation-api-test/update-supporter-cancel-runner-post/response-headers.adoc[] diff --git a/backend/baton/src/docs/asciidoc/RunnerUpdateApi.adoc b/backend/baton/src/docs/asciidoc/RunnerUpdateApi.adoc new file mode 100644 index 000000000..e0ca82e57 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/RunnerUpdateApi.adoc @@ -0,0 +1,33 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: investment +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *러너 프로필 수정 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/runner-update-api-test/update-runner-profile/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/runner-update-api-test/update-runner-profile/request-headers.adoc[] + +===== *Http Request Body* + +include::{snippets}/../../build/generated-snippets/runner-update-api-test/update-runner-profile/request-body.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/runner-update-api-test/update-runner-profile/http-response.adoc[] + +===== *Http Response Headers* + +include::{snippets}/../../build/generated-snippets/runner-update-api-test/update-runner-profile/response-headers.adoc[] diff --git a/backend/baton/src/docs/asciidoc/SupporterReadApi.adoc b/backend/baton/src/docs/asciidoc/SupporterReadApi.adoc new file mode 100644 index 000000000..dcc01730a --- /dev/null +++ b/backend/baton/src/docs/asciidoc/SupporterReadApi.adoc @@ -0,0 +1,48 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: investment +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *서포터 프로필 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-profile-by-supporter-id/http-request.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-profile-by-supporter-id/path-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-profile-by-supporter-id/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-profile-by-supporter-id/response-fields.adoc[] + +==== *서포터 마이페이지 프로필 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-my-profile-by-token/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-my-profile-by-token/request-headers.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-my-profile-by-token/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/supporter-read-by-guest-api-test/read-my-profile-by-token/response-fields.adoc[] + diff --git a/backend/baton/src/docs/asciidoc/SupporterUpdateApi.adoc b/backend/baton/src/docs/asciidoc/SupporterUpdateApi.adoc new file mode 100644 index 000000000..6d8c392c0 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/SupporterUpdateApi.adoc @@ -0,0 +1,33 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *서포터 프로필 수정 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/supporter-update-api-test/update-supporter-profile/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/supporter-update-api-test/update-supporter-profile/request-headers.adoc[] + +===== *Http Request Body* + +include::{snippets}/../../build/generated-snippets/supporter-update-api-test/update-supporter-profile/request-body.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/supporter-update-api-test/update-supporter-profile/http-response.adoc[] + +===== *Http Response Headers* + +include::{snippets}/../../build/generated-snippets/supporter-update-api-test/update-supporter-profile/response-headers.adoc[] diff --git a/backend/baton/src/docs/asciidoc/index.adoc b/backend/baton/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..d3ee20cfe --- /dev/null +++ b/backend/baton/src/docs/asciidoc/index.adoc @@ -0,0 +1,54 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: + += Baton-API + +== *[ 프로필 ]* + +=== *사용자 프로필 조회* + +include::MemberLoginReadApi.adoc[] + +=== *러너 프로필 조회* + +include::RunneReadApi.adoc[] + +=== *러너 프로필 수정* + +include::RunnerUpdateApi.adoc[] + +=== *서포터 프로필 조회* + +include::SupporterReadApi.adoc[] +include::RunnerLoginReadApi.adoc[] + +=== *서포터 프로필 수정* + +include::SupporterUpdateApi.adoc[] + +== *[ 러너 - 게시글 ]* + +=== *러너 게시글 생성* + +include::RunnerPostCreateApi.adoc[] + +=== *러너 게시글 조회* + +include::RunnerPostReadApi.adoc[] + +=== *러너 게시글 수정* + +include::RunnerPostUpdateApi.adoc[] +include::RunnerPostCreateReadApi.adoc[] +include::RunnerPostUpdateApplicantCancelationApi.adoc[] + +=== *러너 게시글 삭제* + +include::RunnerPostDeleteApi.adoc[] diff --git a/backend/baton/src/main/java/touch/baton/BatonApplication.java b/backend/baton/src/main/java/touch/baton/BatonApplication.java new file mode 100644 index 000000000..814bd7f3c --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/BatonApplication.java @@ -0,0 +1,15 @@ +package touch.baton; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class BatonApplication { + + public static void main(String[] args) { + SpringApplication.run(BatonApplication.class, args); + } + +} diff --git a/backend/baton/src/main/java/touch/baton/common/LoggerAspect.java b/backend/baton/src/main/java/touch/baton/common/LoggerAspect.java new file mode 100644 index 000000000..63de093e4 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/common/LoggerAspect.java @@ -0,0 +1,60 @@ +package touch.baton.common; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Objects; + +@Slf4j +@Aspect +@Component +public class LoggerAspect { + + @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping) || " + + "@annotation(org.springframework.web.bind.annotation.GetMapping) || " + + "@annotation(org.springframework.web.bind.annotation.PatchMapping) || " + + "@annotation(org.springframework.web.bind.annotation.PutMapping) || " + + "@annotation(org.springframework.web.bind.annotation.DeleteMapping)") + public void logInfo() { + } + + @Around(value = "logInfo()") + public Object printLog(final ProceedingJoinPoint joinPoint) { + final HttpServletRequest request = getRequest(); + final String signatureName = getSignatureName(joinPoint); + log.info(">>>>> API start [" + signatureName + "() from " + + request.getRemoteAddr() + "] by " + + request.getMethod() + " " + + request.getRequestURI()); + + final long startTime = System.currentTimeMillis(); + Object proceed = process(joinPoint, request, signatureName); + final long timeDiff = System.currentTimeMillis() - startTime; + log.info("시간차이(m) : {}", timeDiff); + return proceed; + } + + private HttpServletRequest getRequest() { + return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest(); + } + + private String getSignatureName(final ProceedingJoinPoint joinPoint) { + return joinPoint.getSignature().getDeclaringType().getSimpleName() + "." + joinPoint.getSignature().getName(); + } + + private Object process(final ProceedingJoinPoint joinPoint, final HttpServletRequest request, final String signatureName) { + try { + return joinPoint.proceed(); + } catch (Throwable e) { + log.error(">>>>> controller start [" + signatureName + "() from " + request.getRemoteAddr() + "] with Error[" + e.getMessage() + "]"); + throw new RuntimeException("에러 나요."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/common/LoggerUtils.java b/backend/baton/src/main/java/touch/baton/common/LoggerUtils.java new file mode 100644 index 000000000..21d6d5040 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/common/LoggerUtils.java @@ -0,0 +1,106 @@ +package touch.baton.common; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +import java.util.Enumeration; + +@Slf4j +public abstract class LoggerUtils { + + private LoggerUtils() { + } + + public static void logError(final HttpServletRequest request, final Exception e) { + final String headers = block(convertHeaders(request)); + log.error(""" + {} + ========> exception : {} + ========> requestId : {} + ========> sessionId : {} + ========> remoteUser : {} + ========> remoteAddr : {} + ========> remoteHost : {} + ========> remotePort : {} + ========> http method : {} + ========> request url : {} + ========> character encoding : {} + ========> local : {} + ========> headers : {} + """, + System.lineSeparator(), + e, + request.getRequestId(), + request.getRequestedSessionId(), + request.getRemoteUser(), + request.getRemoteAddr(), + request.getRemoteHost(), + request.getRemotePort(), + request.getMethod(), + request.getRequestURL(), + request.getCharacterEncoding(), + request.getLocale(), + headers + ); + } + + public static void logWarn(final HttpServletRequest request, final RuntimeException e) { + final String headers = block(convertHeaders(request)); + + log.warn(""" + {} + ========> exception : {} + ========> requestId : {} + ========> sessionId : {} + ========> remoteUser : {} + ========> remoteAddr : {} + ========> remoteHost : {} + ========> remotePort : {} + ========> http method : {} + ========> request url : {} + ========> character encoding : {} + ========> local : {} + ========> headers : {} + """, + System.lineSeparator(), + e, + request.getRequestId(), + request.getRequestedSessionId(), + request.getRemoteUser(), + request.getRemoteAddr(), + request.getRemoteHost(), + request.getRemotePort(), + request.getMethod(), + request.getRequestURL(), + request.getCharacterEncoding(), + request.getLocale(), + headers + ); + } + + private static StringBuilder convertHeaders(final HttpServletRequest request) { + final StringBuilder headers = new StringBuilder(); + final Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + final String headerName = headerNames.nextElement(); + final String headerValue = request.getHeader(headerName); + headers.append(" ") + .append(headerName) + .append(":") + .append(headerValue) + .append(System.lineSeparator()); + } + + return headers; + } + + private static String block(final StringBuilder sb) { + if (sb.isEmpty()) { + return "EMPTY"; + } + sb.insert(0, "{") + .insert(1, System.lineSeparator()) + .insert(sb.length(), "}"); + return sb.toString(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/common/schedule/RunnerPostDeadlineCheckScheduler.java b/backend/baton/src/main/java/touch/baton/common/schedule/RunnerPostDeadlineCheckScheduler.java new file mode 100644 index 000000000..296e569b1 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/common/schedule/RunnerPostDeadlineCheckScheduler.java @@ -0,0 +1,17 @@ +package touch.baton.common.schedule; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class RunnerPostDeadlineCheckScheduler { + + private final ScheduleRunnerPostRepository scheduleRunnerPostRepository; + + @Transactional + public void updateReviewStatus() { + scheduleRunnerPostRepository.updateAllPassedDeadline(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/common/schedule/ScheduleRunnerPostRepository.java b/backend/baton/src/main/java/touch/baton/common/schedule/ScheduleRunnerPostRepository.java new file mode 100644 index 000000000..e877b034a --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/common/schedule/ScheduleRunnerPostRepository.java @@ -0,0 +1,18 @@ +package touch.baton.common.schedule; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import touch.baton.domain.runnerpost.RunnerPost; + +public interface ScheduleRunnerPostRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update RunnerPost rp + set rp.reviewStatus = 'OVERDUE' + where rp.deadline.value <= local_datetime() + and rp.reviewStatus = 'NOT_STARTED' + """) + void updateAllPassedDeadline(); +} diff --git a/backend/baton/src/main/java/touch/baton/common/schedule/SchedulerService.java b/backend/baton/src/main/java/touch/baton/common/schedule/SchedulerService.java new file mode 100644 index 000000000..b7bdd4f87 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/common/schedule/SchedulerService.java @@ -0,0 +1,19 @@ +package touch.baton.common.schedule; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class SchedulerService { + + private static final int CYCLE = 60000; + + private final RunnerPostDeadlineCheckScheduler runnerPostDeadlineCheckScheduler; + + @Scheduled(fixedRate = CYCLE) + public void updateReviewStatus() { + runnerPostDeadlineCheckScheduler.updateReviewStatus(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/ArgumentResolverConfig.java b/backend/baton/src/main/java/touch/baton/config/ArgumentResolverConfig.java new file mode 100644 index 000000000..4173249d0 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/ArgumentResolverConfig.java @@ -0,0 +1,26 @@ +package touch.baton.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthMemberPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthSupporterPrincipalArgumentResolver; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class ArgumentResolverConfig extends WebMvcConfig { + + private final AuthRunnerPrincipalArgumentResolver authRunnerPrincipalArgumentResolver; + private final AuthSupporterPrincipalArgumentResolver authSupporterPrincipalArgumentResolver; + private final AuthMemberPrincipalArgumentResolver authMemberPrincipalArgumentResolver; + + @Override + public void addArgumentResolvers(final List resolvers) { + resolvers.add(authRunnerPrincipalArgumentResolver); + resolvers.add(authSupporterPrincipalArgumentResolver); + resolvers.add(authMemberPrincipalArgumentResolver); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/JpaConfig.java b/backend/baton/src/main/java/touch/baton/config/JpaConfig.java new file mode 100644 index 000000000..94748c9d0 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/JpaConfig.java @@ -0,0 +1,9 @@ +package touch.baton.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration(proxyBeanMethods = false) +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/backend/baton/src/main/java/touch/baton/config/OauthHttpInterfaceConfig.java b/backend/baton/src/main/java/touch/baton/config/OauthHttpInterfaceConfig.java new file mode 100644 index 000000000..724caa1fe --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/OauthHttpInterfaceConfig.java @@ -0,0 +1,25 @@ +package touch.baton.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import touch.baton.infra.auth.oauth.github.http.GithubHttpInterface; + +import static org.springframework.web.reactive.function.client.support.WebClientAdapter.forClient; + +@Configuration +public class OauthHttpInterfaceConfig { + + @Bean + public GithubHttpInterface githubHttpClient() { + return createHttpClient(GithubHttpInterface.class); + } + + private T createHttpClient(final Class clazz) { + return HttpServiceProxyFactory + .builder(forClient(WebClient.create())) + .build() + .createClient(clazz); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/SchedulingConfig.java b/backend/baton/src/main/java/touch/baton/config/SchedulingConfig.java new file mode 100644 index 000000000..2b3ad3fc7 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package touch.baton.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java b/backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java new file mode 100644 index 000000000..0b2e32481 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/WebMvcConfig.java @@ -0,0 +1,36 @@ +package touch.baton.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.HttpMethod.DELETE; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.OPTIONS; +import static org.springframework.http.HttpMethod.PATCH; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.HttpMethod.PUT; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Value("${cors.allowed-origin}") + private List origins; + + @Override + public void addCorsMappings(final CorsRegistry registry) { + final String[] allowedOrigins = origins.toArray(String[]::new); + + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowCredentials(true) + .allowedMethods(GET.name(), POST.name(), PUT.name(), PATCH.name(), DELETE.name(), OPTIONS.name()) + .exposedHeaders(LOCATION, AUTHORIZATION) + .maxAge(3600); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java b/backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java new file mode 100644 index 000000000..ae3bb0ebc --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/converter/ConverterConfig.java @@ -0,0 +1,50 @@ +package touch.baton.config.converter; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.time.format.DateTimeFormatter; +import java.util.TimeZone; + +@Configuration +public class ConverterConfig implements WebMvcConfigurer { + + private static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm"; + private static final TimeZone KOREA_TIME_ZONE = TimeZone.getTimeZone("Asia/Seoul"); + + @Override + public void addFormatters(final FormatterRegistry registry) { + registry.addConverter(new StringDateToLocalDateTimeConverter(DEFAULT_DATE_TIME_FORMAT, KOREA_TIME_ZONE)); + registry.addConverter(new OauthTypeConverter()); + registry.addConverter(new ReviewStatusConverter()); + } + + @Bean + public MappingJackson2HttpMessageConverter jackson2HttpMessageConverter() { + final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setObjectMapper(jacksonBuilder().build()); + return converter; + } + + @Bean + public Jackson2ObjectMapperBuilder jacksonBuilder() { + final Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); + + builder.timeZone(KOREA_TIME_ZONE); + builder.simpleDateFormat(DEFAULT_DATE_TIME_FORMAT); + + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT); + builder.serializers(new LocalDateTimeSerializer(formatter)); + builder.deserializers(new LocalDateTimeDeserializer(formatter)); + builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return builder; + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/converter/OauthTypeConverter.java b/backend/baton/src/main/java/touch/baton/config/converter/OauthTypeConverter.java new file mode 100644 index 000000000..be414a79f --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/converter/OauthTypeConverter.java @@ -0,0 +1,12 @@ +package touch.baton.config.converter; + +import org.springframework.core.convert.converter.Converter; +import touch.baton.domain.oauth.OauthType; + +public class OauthTypeConverter implements Converter { + + @Override + public OauthType convert(final String source) { + return OauthType.from(source); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/converter/ReviewStatusConverter.java b/backend/baton/src/main/java/touch/baton/config/converter/ReviewStatusConverter.java new file mode 100644 index 000000000..36cdfcff3 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/converter/ReviewStatusConverter.java @@ -0,0 +1,12 @@ +package touch.baton.config.converter; + +import org.springframework.core.convert.converter.Converter; +import touch.baton.domain.runnerpost.vo.ReviewStatus; + +public class ReviewStatusConverter implements Converter { + + @Override + public ReviewStatus convert(final String source) { + return ReviewStatus.from(source); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/converter/StringDateToLocalDateTimeConverter.java b/backend/baton/src/main/java/touch/baton/config/converter/StringDateToLocalDateTimeConverter.java new file mode 100644 index 000000000..bcc28f8a6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/converter/StringDateToLocalDateTimeConverter.java @@ -0,0 +1,26 @@ +package touch.baton.config.converter; + +import org.springframework.core.convert.converter.Converter; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.TimeZone; + +public class StringDateToLocalDateTimeConverter implements Converter { + + private final TimeZone timeZone; + private final String dateTimeFormat; + + public StringDateToLocalDateTimeConverter(final String dateTimeFormat, final TimeZone timeZone) { + this.timeZone = timeZone; + this.dateTimeFormat = dateTimeFormat; + } + + @Override + public LocalDateTime convert(final String source) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateTimeFormat) + .withZone(timeZone.toZoneId()); + + return LocalDateTime.parse(source, formatter); + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/filter/FilterConfig.java b/backend/baton/src/main/java/touch/baton/config/filter/FilterConfig.java new file mode 100644 index 000000000..f40bb5064 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/filter/FilterConfig.java @@ -0,0 +1,21 @@ +package touch.baton.config.filter; + +import jakarta.servlet.Filter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class FilterConfig implements WebMvcConfigurer { + + @Bean + public FilterRegistrationBean getFilterRegistrationBean() { + final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(new MDCLoggingFilter()); + registrationBean.setOrder(Integer.MIN_VALUE); + registrationBean.setUrlPatterns(List.of("/api/**")); + return registrationBean; + } +} diff --git a/backend/baton/src/main/java/touch/baton/config/filter/MDCLoggingFilter.java b/backend/baton/src/main/java/touch/baton/config/filter/MDCLoggingFilter.java new file mode 100644 index 000000000..e7d46b854 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/config/filter/MDCLoggingFilter.java @@ -0,0 +1,25 @@ +package touch.baton.config.filter; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import org.apache.logging.log4j.core.config.Order; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import java.io.IOException; +import java.util.UUID; + +@Order(Ordered.HIGHEST_PRECEDENCE) +class MDCLoggingFilter implements Filter { + + @Override + public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException { + final UUID uuid = UUID.randomUUID(); + MDC.put("request_id", uuid.toString()); + filterChain.doFilter(servletRequest, servletResponse); + MDC.clear(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/BaseEntity.java b/backend/baton/src/main/java/touch/baton/domain/common/BaseEntity.java new file mode 100644 index 000000000..b7fc3f251 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/BaseEntity.java @@ -0,0 +1,28 @@ +package touch.baton.domain.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Column(updatable = false, nullable = false) + @CreatedDate + private LocalDateTime createdAt; + + @Column(nullable = false) + @LastModifiedDate + private LocalDateTime updatedAt; + + @Column(nullable = true) + private LocalDateTime deletedAt; +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/GlobalControllerAdvice.java b/backend/baton/src/main/java/touch/baton/domain/common/GlobalControllerAdvice.java new file mode 100644 index 000000000..133e2a9bd --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/GlobalControllerAdvice.java @@ -0,0 +1,75 @@ +package touch.baton.domain.common; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.TypeMismatchException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingPathVariableException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; +import org.springframework.web.servlet.NoHandlerFoundException; +import touch.baton.common.LoggerUtils; +import touch.baton.domain.common.exception.BaseException; +import touch.baton.domain.common.exception.ClientRequestException; +import touch.baton.domain.common.response.ErrorResponse; +import touch.baton.domain.common.response.ServerErrorResponse; + +@Slf4j +@RestControllerAdvice +public class GlobalControllerAdvice { + + @ExceptionHandler(ClientRequestException.class) + public ResponseEntity handleClientRequest(final HttpServletRequest request, + final ClientRequestException e + ) { + LoggerUtils.logWarn(request, e); + return ResponseEntity + .status(e.getErrorCode().getHttpStatus()) + .body(ErrorResponse.from(e)); + } + + @ExceptionHandler({HttpRequestMethodNotSupportedException.class, + HttpMediaTypeNotSupportedException.class, + HttpMediaTypeNotAcceptableException.class, + MissingPathVariableException.class, + MissingServletRequestParameterException.class, + ServletRequestBindingException.class, + MethodArgumentNotValidException.class, + NoHandlerFoundException.class, + AsyncRequestTimeoutException.class, + ErrorResponseException.class, + TypeMismatchException.class, + HttpMessageNotReadableException.class, + HttpMessageNotWritableException.class}) + public ResponseEntity handle(final RuntimeException ex, + final HttpHeaders headers, + final HttpStatusCode statusCode, + final WebRequest request + ) { + return ResponseEntity.badRequest().body(ServerErrorResponse.from(ex)); + } + + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(final HttpServletRequest httpServletRequest, + final BaseException ex, + final HttpHeaders headers, + final HttpStatusCode statusCode, + final WebRequest request + ) { + LoggerUtils.logWarn(httpServletRequest, ex); + return ResponseEntity.internalServerError().body(ServerErrorResponse.from(ex)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/BaseException.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/BaseException.java new file mode 100644 index 000000000..b1bd1b6aa --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/BaseException.java @@ -0,0 +1,9 @@ +package touch.baton.domain.common.exception; + +public abstract class BaseException extends RuntimeException { + + + public BaseException(String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/BusinessException.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/BusinessException.java new file mode 100644 index 000000000..f485e962b --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/BusinessException.java @@ -0,0 +1,8 @@ +package touch.baton.domain.common.exception; + +public abstract class BusinessException extends BaseException { + + public BusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java new file mode 100644 index 000000000..c3e6e3ff6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientErrorCode.java @@ -0,0 +1,61 @@ +package touch.baton.domain.common.exception; + +import org.springframework.http.HttpStatus; + +public enum ClientErrorCode { + + TITLE_IS_NULL(HttpStatus.BAD_REQUEST, "RP001", "제목을 입력해주세요."), + PULL_REQUEST_URL_IS_NULL(HttpStatus.BAD_REQUEST, "RP002", "PR 주소를 입력해주세요."), + DEADLINE_IS_NULL(HttpStatus.BAD_REQUEST, "RP003", "마감일을 입력해주세요."), + CONTENTS_ARE_NULL(HttpStatus.BAD_REQUEST, "RP004", "내용을 입력해주세요."), + CONTENTS_OVERFLOW(HttpStatus.BAD_REQUEST, "RP005", "내용은 1000자 까지 입력해주세요."), + PAST_DEADLINE(HttpStatus.BAD_REQUEST, "RP006", "마감일은 오늘보다 과거일 수 없습니다."), + RUNNER_POST_NOT_FOUND(HttpStatus.NOT_FOUND, "RP007", "존재하지 않는 게시물입니다."), + TAGS_ARE_NULL(HttpStatus.BAD_REQUEST, "RP008", "태그 목록을 빈 값이라도 입력해주세요."), + ASSIGN_SUPPORTER_ID_IS_NULL(HttpStatus.BAD_REQUEST, "RP009", "선택한 서포터의 식별자를 입력해주세요."), + APPLICANT_MESSAGE_IS_OVERFLOW(HttpStatus.BAD_REQUEST, "RP010", "서포터 지원 메시지는 500자 까지 입력해주세요."), + PULL_REQUEST_URL_IS_NOT_URL(HttpStatus.BAD_REQUEST, "RP011", "올바른 PR 주소를 입력해주세요."), + + REVIEW_TYPE_IS_NULL(HttpStatus.BAD_REQUEST, "FB001", "만족도를 입력해주세요."), + SUPPORTER_ID_IS_NULL(HttpStatus.BAD_REQUEST, "FB002", "서포터 식별자를 입력해주세요."), + RUNNER_ID_IS_NULL(HttpStatus.BAD_REQUEST, "FB003", "러너 식별자를 입력해주세요."), + + NAME_IS_NULL(HttpStatus.BAD_REQUEST, "MB001", "사용자의 이름을 입력해주세요."), + COMPANY_IS_NULL(HttpStatus.BAD_REQUEST, "MB002", "사용자의 회사 정보를 입력해주세요."), + SUPPORTER_TECHNICAL_TAGS_ARE_NULL(HttpStatus.BAD_REQUEST, "MB003", "서포터 기술 태그 목록을 빈 값이라도 입력해주세요."), + RUNNER_TECHNICAL_TAGS_ARE_NULL(HttpStatus.BAD_REQUEST, "MB004", "러너 기술 태그 목록을 빈 값이라도 입력해주세요."), + RUNNER_INTRODUCTION_IS_NULL(HttpStatus.BAD_REQUEST, "MB005", "러너의 정보를 입력해주세요."), + SUPPORTER_INTRODUCTION_IS_NULL(HttpStatus.BAD_REQUEST, "MB006", "서포터의 정보를 입력해주세요."), + + OAUTH_REQUEST_URL_PROVIDER_IS_WRONG(HttpStatus.UNAUTHORIZED, "OA001", "redirect 할 url 이 조회되지 않는 잘못된 소셜 타입입니다."), + OAUTH_INFORMATION_CLIENT_IS_WRONG(HttpStatus.UNAUTHORIZED, "OA002", " 소셜 계정 정보를 조회할 수 없는 잘못된 소셜 타입입니다."), + OAUTH_AUTHORIZATION_VALUE_IS_NULL(HttpStatus.UNAUTHORIZED, "OA003", "Authorization 값을 입력해주세요."), + OAUTH_AUTHORIZATION_BEARER_TYPE_NOT_FOUND(HttpStatus.UNAUTHORIZED, "OA004", "Authorization 값을 Bearer 타입으로 입력해주세요."), + + JWT_SIGNATURE_IS_WRONG(HttpStatus.UNAUTHORIZED, "JW001", "시그니처가 다른 잘못된 JWT 입니다."), + JWT_FORM_IS_WRONG(HttpStatus.UNAUTHORIZED, "JW002", "잘못 생성된 JWT 로 디코딩 할 수 없습니다."), + JWT_CLAIM_IS_WRONG(HttpStatus.UNAUTHORIZED, "JW003", "JWT 에 기대한 정보를 모두 포함하고 있지 않습니다."), + JWT_CLAIM_SOCIAL_ID_IS_WRONG(HttpStatus.UNAUTHORIZED, "JW004", "사용자의 잘못된 소셜 아이디(SocialId) 정보를 가진 JWT 입니다."); + + private final HttpStatus httpStatus; + private final String errorCode; + private final String message; + + ClientErrorCode(final HttpStatus httpStatus, final String code, final String message) { + this.httpStatus = httpStatus; + this.errorCode = code; + this.message = message; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public String getErrorCode() { + return errorCode; + } + + public String getMessage() { + return message; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientRequestException.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientRequestException.java new file mode 100644 index 000000000..650ed5786 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/ClientRequestException.java @@ -0,0 +1,15 @@ +package touch.baton.domain.common.exception; + +public class ClientRequestException extends BaseException { + + private final ClientErrorCode errorCode; + + public ClientRequestException(final ClientErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ClientErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/DomainException.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/DomainException.java new file mode 100644 index 000000000..9496a9983 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/DomainException.java @@ -0,0 +1,8 @@ +package touch.baton.domain.common.exception; + +public abstract class DomainException extends BaseException { + + public DomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/validator/NotNullValidator.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/validator/NotNullValidator.java new file mode 100644 index 000000000..bb3ea1f71 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/validator/NotNullValidator.java @@ -0,0 +1,24 @@ +package touch.baton.domain.common.exception.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class NotNullValidator implements ConstraintValidator { + + private ClientErrorCode errorCode; + + @Override + public void initialize(final ValidNotNull constraintAnnotation) { + errorCode = constraintAnnotation.clientErrorCode(); + } + + @Override + public boolean isValid(final Object value, final ConstraintValidatorContext context) { + if (value == null) { + throw new ClientRequestException(errorCode); + } + return true; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/exception/validator/ValidNotNull.java b/backend/baton/src/main/java/touch/baton/domain/common/exception/validator/ValidNotNull.java new file mode 100644 index 000000000..9e72d7018 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/exception/validator/ValidNotNull.java @@ -0,0 +1,38 @@ +package touch.baton.domain.common.exception.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import touch.baton.domain.common.exception.ClientErrorCode; + +import java.lang.annotation.Documented; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({FIELD, PARAMETER}) +@Retention(RUNTIME) +@Repeatable(ValidNotNull.List.class) +@Documented +@Constraint(validatedBy = NotNullValidator.class) +public @interface ValidNotNull { + + String message() default "null 값이 존재합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + ClientErrorCode clientErrorCode(); + + @Target({FIELD, PARAMETER}) + @Retention(RUNTIME) + @Documented + @interface List { + + ValidNotNull[] value(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/response/ErrorResponse.java b/backend/baton/src/main/java/touch/baton/domain/common/response/ErrorResponse.java new file mode 100644 index 000000000..d2ad39ad5 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/response/ErrorResponse.java @@ -0,0 +1,10 @@ +package touch.baton.domain.common.response; + +import touch.baton.domain.common.exception.ClientRequestException; + +public record ErrorResponse(String errorCode, String message) { + + public static ErrorResponse from(final ClientRequestException e) { + return new ErrorResponse(e.getErrorCode().getErrorCode(), e.getMessage()); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/response/PageResponse.java b/backend/baton/src/main/java/touch/baton/domain/common/response/PageResponse.java new file mode 100644 index 000000000..7f0a1f5d6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/response/PageResponse.java @@ -0,0 +1,38 @@ +package touch.baton.domain.common.response; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record PageResponse(List data, + PageInfo pageInfo +) { + + public static PageResponse from(final Page page) { + return new PageResponse<>(page.getContent(), PageInfo.from(page)); + } + + public record PageInfo(boolean isFirst, + boolean isLast, + boolean hasNext, + int totalPages, + long totalElements, + int currentPage, + int currentSize + ) { + + private static final int START_PAGE_NUMBER = 1; + + public static PageInfo from(final Page page) { + return new PageInfo( + page.isFirst(), + page.isLast(), + page.hasNext(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumber() + START_PAGE_NUMBER, + page.getSize() + ); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/response/ServerErrorResponse.java b/backend/baton/src/main/java/touch/baton/domain/common/response/ServerErrorResponse.java new file mode 100644 index 000000000..cfe2dcf0d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/response/ServerErrorResponse.java @@ -0,0 +1,16 @@ +package touch.baton.domain.common.response; + +import java.time.LocalDateTime; + +import static java.time.temporal.ChronoUnit.MINUTES; + +public record ServerErrorResponse(String message, LocalDateTime createdAt) { + + public static ServerErrorResponse from(final Exception e) { + return new ServerErrorResponse(e.getMessage(), LocalDateTime.now().truncatedTo(MINUTES)); + } + + public static ServerErrorResponse unExpected() { + return new ServerErrorResponse("예상치 못한 에러입니다. 관리자에게 문의해주세요.", LocalDateTime.now().truncatedTo(MINUTES)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/vo/ChattingCount.java b/backend/baton/src/main/java/touch/baton/domain/common/vo/ChattingCount.java new file mode 100644 index 000000000..072f9d222 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/vo/ChattingCount.java @@ -0,0 +1,31 @@ +package touch.baton.domain.common.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class ChattingCount { + + private static final String DEFAULT_VALUE = "0"; + + @ColumnDefault(DEFAULT_VALUE) + @Column(name = "chatting_room_count", nullable = false) + private int value; + + public ChattingCount(final int value) { + this.value = value; + } + + public static ChattingCount zero() { + return new ChattingCount(Integer.parseInt(DEFAULT_VALUE)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/vo/Contents.java b/backend/baton/src/main/java/touch/baton/domain/common/vo/Contents.java new file mode 100644 index 000000000..6e0e63c9e --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/vo/Contents.java @@ -0,0 +1,32 @@ +package touch.baton.domain.common.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class Contents { + + @Column(name = "contents", nullable = false, columnDefinition = "TEXT") + private String value; + + public Contents(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("Contents 객체 내부에 contents 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/vo/Introduction.java b/backend/baton/src/main/java/touch/baton/domain/common/vo/Introduction.java new file mode 100644 index 000000000..dfd2363e6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/vo/Introduction.java @@ -0,0 +1,39 @@ +package touch.baton.domain.common.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.exception.DomainException; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class Introduction { + + private static final String DEFAULT_VALUE = "안녕하세요."; + + @Column(name = "introduction", nullable = false) + private String value = DEFAULT_VALUE; + + public Introduction(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("Introduction 의 value 는 null 일 수 없습니다."); + } + } + + public static Introduction getDefaultIntroduction() { + return new Introduction(DEFAULT_VALUE); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/vo/TagName.java b/backend/baton/src/main/java/touch/baton/domain/common/vo/TagName.java new file mode 100644 index 000000000..b3a61a4d8 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/vo/TagName.java @@ -0,0 +1,32 @@ +package touch.baton.domain.common.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class TagName { + + @Column(name = "name", nullable = false, unique = true) + private String value; + + public TagName(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("TagName 객체 내부에 tagName 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/vo/Title.java b/backend/baton/src/main/java/touch/baton/domain/common/vo/Title.java new file mode 100644 index 000000000..55a92b6f6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/vo/Title.java @@ -0,0 +1,32 @@ +package touch.baton.domain.common.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class Title { + + @Column(name = "title", nullable = false) + private String value; + + public Title(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("Title 객체 내부에 title 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/vo/TotalRating.java b/backend/baton/src/main/java/touch/baton/domain/common/vo/TotalRating.java new file mode 100644 index 000000000..8db33b994 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/vo/TotalRating.java @@ -0,0 +1,27 @@ +package touch.baton.domain.common.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class TotalRating { + + private static final String DEFAULT_VALUE = "0"; + + @ColumnDefault(DEFAULT_VALUE) + @Column(name = "total_rating", nullable = false) + private int value; + + public TotalRating(final int value) { + this.value = value; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/common/vo/WatchedCount.java b/backend/baton/src/main/java/touch/baton/domain/common/vo/WatchedCount.java new file mode 100644 index 000000000..39683032b --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/vo/WatchedCount.java @@ -0,0 +1,35 @@ +package touch.baton.domain.common.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class WatchedCount { + + private static final String DEFAULT_VALUE = "0"; + + @ColumnDefault(DEFAULT_VALUE) + @Column(name = "watch_count", nullable = false) + private int value; + + public WatchedCount(final int value) { + this.value = value; + } + + public static WatchedCount zero() { + return new WatchedCount(Integer.parseInt(DEFAULT_VALUE)); + } + + public WatchedCount increase() { + return new WatchedCount(value + 1); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/SupporterFeedback.java b/backend/baton/src/main/java/touch/baton/domain/feedback/SupporterFeedback.java new file mode 100644 index 000000000..09b201005 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/SupporterFeedback.java @@ -0,0 +1,100 @@ +package touch.baton.domain.feedback; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.BaseEntity; +import touch.baton.domain.feedback.exception.SupporterFeedbackException; +import touch.baton.domain.feedback.vo.Description; +import touch.baton.domain.feedback.vo.ReviewType; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.supporter.Supporter; + +import java.util.Objects; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class SupporterFeedback extends BaseEntity { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Enumerated(STRING) + @Column(nullable = false) + private ReviewType reviewType; + + @Embedded + private Description description; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "supporter_id", nullable = false, foreignKey = @ForeignKey(name = "fk_supporter_feed_back_to_supporter")) + private Supporter supporter; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "runner_id", nullable = false, foreignKey = @ForeignKey(name = "fk_supporter_feed_back_to_runner")) + private Runner runner; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "runner_post_id", nullable = false, foreignKey = @ForeignKey(name = "fk_supporter_feed_back_to_runner_post")) + private RunnerPost runnerPost; + + @Builder + private SupporterFeedback(final ReviewType reviewType, final Description description, final Supporter supporter, final Runner runner, final RunnerPost runnerPost) { + this(null, reviewType, description, supporter, runner, runnerPost); + } + + private SupporterFeedback(final Long id, final ReviewType reviewType, final Description description, final Supporter supporter, final Runner runner, final RunnerPost runnerPost) { + validateNotNull(reviewType, description, supporter, runner, runnerPost); + this.id = id; + this.reviewType = reviewType; + this.description = description; + this.supporter = supporter; + this.runner = runner; + this.runnerPost = runnerPost; + } + + private void validateNotNull(final ReviewType reviewType, + final Description description, + final Supporter supporter, + final Runner runner, + final RunnerPost runnerPost + ) { + if (Objects.isNull(reviewType)) { + throw new SupporterFeedbackException("SupporterFeedback 의 reviewType 은 null 일 수 없습니다."); + } + + if (Objects.isNull(description)) { + throw new SupporterFeedbackException("SupporterFeedback 의 description 은 null 일 수 없습니다."); + } + + if (Objects.isNull(supporter)) { + throw new SupporterFeedbackException("SupporterFeedback 의 supporter 는 null 일 수 없습니다."); + } + + if (Objects.isNull(runner)) { + throw new SupporterFeedbackException("SupporterFeedback 의 runner 는 null 일 수 없습니다."); + } + + if (Objects.isNull(runnerPost)) { + throw new SupporterFeedbackException("SupporterFeedback 의 runnerPost 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/controller/FeedbackController.java b/backend/baton/src/main/java/touch/baton/domain/feedback/controller/FeedbackController.java new file mode 100644 index 000000000..c09123426 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/controller/FeedbackController.java @@ -0,0 +1,38 @@ +package touch.baton.domain.feedback.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; +import touch.baton.domain.feedback.service.FeedbackService; +import touch.baton.domain.feedback.service.SupporterFeedBackCreateRequest; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipal; +import touch.baton.domain.runner.Runner; + +import java.net.URI; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/feedback") +@RestController +public class FeedbackController { + + private final FeedbackService feedbackService; + + @PostMapping("/supporter") + public ResponseEntity createSupporterFeedback(@AuthRunnerPrincipal final Runner runner, + @Valid @RequestBody final SupporterFeedBackCreateRequest request + ) { + final Long savedId = feedbackService.createSupporterFeedback(runner, request); + + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/feedback/supporter") + .path("/{id}") + .buildAndExpand(savedId) + .toUri(); + return ResponseEntity.created(redirectUri).build(); + } + +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/exception/FeedbackBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/feedback/exception/FeedbackBusinessException.java new file mode 100644 index 000000000..b90b145f1 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/exception/FeedbackBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.feedback.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class FeedbackBusinessException extends BusinessException { + + public FeedbackBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/exception/SupporterFeedbackException.java b/backend/baton/src/main/java/touch/baton/domain/feedback/exception/SupporterFeedbackException.java new file mode 100644 index 000000000..6dea41486 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/exception/SupporterFeedbackException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.feedback.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class SupporterFeedbackException extends DomainException { + + public SupporterFeedbackException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/repository/SupporterFeedbackRepository.java b/backend/baton/src/main/java/touch/baton/domain/feedback/repository/SupporterFeedbackRepository.java new file mode 100644 index 000000000..3fcbfa163 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/repository/SupporterFeedbackRepository.java @@ -0,0 +1,7 @@ +package touch.baton.domain.feedback.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import touch.baton.domain.feedback.SupporterFeedback; + +public interface SupporterFeedbackRepository extends JpaRepository { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/service/FeedbackService.java b/backend/baton/src/main/java/touch/baton/domain/feedback/service/FeedbackService.java new file mode 100644 index 000000000..b7a18b0d1 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/service/FeedbackService.java @@ -0,0 +1,54 @@ +package touch.baton.domain.feedback.service; + + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import touch.baton.domain.feedback.SupporterFeedback; +import touch.baton.domain.feedback.exception.FeedbackBusinessException; +import touch.baton.domain.feedback.repository.SupporterFeedbackRepository; +import touch.baton.domain.feedback.vo.Description; +import touch.baton.domain.feedback.vo.ReviewType; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.repository.RunnerPostRepository; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.repository.SupporterRepository; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class FeedbackService { + + private static final String DELIMITER = "|"; + + private final SupporterFeedbackRepository supporterFeedbackRepository; + private final RunnerPostRepository runnerPostRepository; + private final SupporterRepository supporterRepository; + + @Transactional + public Long createSupporterFeedback(final Runner runner, final SupporterFeedBackCreateRequest request) { + final Supporter foundSupporter = supporterRepository.findById(request.supporterId()) + .orElseThrow(() -> new FeedbackBusinessException("서포터를 찾을 수 없습니다.")); + final RunnerPost foundRunnerPost = runnerPostRepository.findById(request.runnerPostId()) + .orElseThrow(() -> new FeedbackBusinessException("러너 게시글을 찾을 수 없습니다.")); + + if (foundRunnerPost.isNotOwner(runner)) { + throw new FeedbackBusinessException("리뷰 글을 작성한 주인만 글을 작성할 수 있습니다."); + } + + if (foundRunnerPost.isDifferentSupporter(foundSupporter)) { + throw new FeedbackBusinessException("리뷰를 작성한 서포터에 대해서만 피드백을 작성할 수 있습니다."); + } + + final SupporterFeedback supporterFeedback = SupporterFeedback.builder() + .reviewType(ReviewType.valueOf(request.reviewType())) + .description(new Description(String.join(DELIMITER, request.descriptions()))) + .runner(runner) + .supporter(foundSupporter) + .runnerPost(foundRunnerPost) + .build(); + + return supporterFeedbackRepository.save(supporterFeedback).getId(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/service/SupporterFeedBackCreateRequest.java b/backend/baton/src/main/java/touch/baton/domain/feedback/service/SupporterFeedBackCreateRequest.java new file mode 100644 index 000000000..c1f317153 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/service/SupporterFeedBackCreateRequest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.feedback.service; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.validator.ValidNotNull; + +import java.util.List; + +public record SupporterFeedBackCreateRequest(@ValidNotNull(clientErrorCode = ClientErrorCode.REVIEW_TYPE_IS_NULL) + String reviewType, + List descriptions, + @ValidNotNull(clientErrorCode = ClientErrorCode.SUPPORTER_ID_IS_NULL) + Long supporterId, + @ValidNotNull(clientErrorCode = ClientErrorCode.RUNNER_ID_IS_NULL) + Long runnerPostId +) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/vo/Description.java b/backend/baton/src/main/java/touch/baton/domain/feedback/vo/Description.java new file mode 100644 index 000000000..5bfc58deb --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/vo/Description.java @@ -0,0 +1,32 @@ +package touch.baton.domain.feedback.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class Description { + + @Column(name = "description", nullable = true, columnDefinition = "TEXT") + private String value; + + public Description(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("Description 객체 내부에 value 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/feedback/vo/ReviewType.java b/backend/baton/src/main/java/touch/baton/domain/feedback/vo/ReviewType.java new file mode 100644 index 000000000..e8a0f1ca4 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/feedback/vo/ReviewType.java @@ -0,0 +1,6 @@ +package touch.baton.domain.feedback.vo; + +public enum ReviewType { + + GREAT, GOOD, BAD +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/Member.java b/backend/baton/src/main/java/touch/baton/domain/member/Member.java new file mode 100644 index 000000000..61925a91b --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/Member.java @@ -0,0 +1,140 @@ +package touch.baton.domain.member; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.BaseEntity; +import touch.baton.domain.member.exception.MemberDomainException; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; + +import java.util.Objects; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Member extends BaseEntity { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Embedded + private MemberName memberName; + + @Embedded + private SocialId socialId; + + @Embedded + private OauthId oauthId; + + @Embedded + private GithubUrl githubUrl; + + @Embedded + private Company company; + + @Embedded + private ImageUrl imageUrl; + + @Builder + private Member(final MemberName memberName, + final SocialId socialId, + final OauthId oauthId, + final GithubUrl githubUrl, + final Company company, + final ImageUrl imageUrl + ) { + this(null, memberName, socialId, oauthId, githubUrl, company, imageUrl); + } + + private Member(final Long id, + final MemberName memberName, + final SocialId socialId, + final OauthId oauthId, + final GithubUrl githubUrl, + final Company company, + final ImageUrl imageUrl + ) { + validateNotNull(memberName, socialId, oauthId, githubUrl, company, imageUrl); + this.id = id; + this.memberName = memberName; + this.socialId = socialId; + this.oauthId = oauthId; + this.githubUrl = githubUrl; + this.company = company; + this.imageUrl = imageUrl; + } + + private void validateNotNull(final MemberName memberName, + final SocialId socialId, + final OauthId oauthId, + final GithubUrl githubUrl, + final Company company, + final ImageUrl imageUrl + ) { + validateMemberNameNotNull(memberName); + validateSocialIdNotNull(socialId); + validateOauthIdNotNull(oauthId); + validateGithubUrlNotNull(githubUrl); + validateCompanyNotNull(company); + validateImageUrlNotNull(imageUrl); + } + + private void validateImageUrlNotNull(final ImageUrl imageUrl) { + if (Objects.isNull(imageUrl)) { + throw new MemberDomainException("Member 의 imageUrl 은 null 일 수 없습니다."); + } + } + + private void validateCompanyNotNull(final Company company) { + if (Objects.isNull(company)) { + throw new MemberDomainException("Member 의 company 는 null 일 수 없습니다."); + } + } + + private void validateGithubUrlNotNull(final GithubUrl githubUrl) { + if (Objects.isNull(githubUrl)) { + throw new MemberDomainException("Member 의 githubUrl 은 null 일 수 없습니다."); + } + } + + private void validateOauthIdNotNull(final OauthId oauthId) { + if (Objects.isNull(oauthId)) { + throw new MemberDomainException("Member 의 oauthId 는 null 일 수 없습니다."); + } + } + + private void validateSocialIdNotNull(final SocialId socialId) { + if (Objects.isNull(socialId)) { + throw new MemberDomainException("Member 의 socialId 은 null 일 수 없습니다."); + } + } + + private void validateMemberNameNotNull(final MemberName memberName) { + if (Objects.isNull(memberName)) { + throw new MemberDomainException("Member 의 name 은 null 일 수 없습니다."); + } + } + + public void updateMemberName(final MemberName memberName) { + validateMemberNameNotNull(memberName); + this.memberName = memberName; + } + + public void updateCompany(final Company company) { + validateCompanyNotNull(company); + this.company = company; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/controller/MemberProfileController.java b/backend/baton/src/main/java/touch/baton/domain/member/controller/MemberProfileController.java new file mode 100644 index 000000000..89d85e6c4 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/controller/MemberProfileController.java @@ -0,0 +1,21 @@ +package touch.baton.domain.member.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.controller.response.LoginMemberInfoResponse; +import touch.baton.domain.oauth.controller.resolver.AuthMemberPrincipal; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/profile") +@RestController +public class MemberProfileController { + + @GetMapping("/me") + ResponseEntity readLoginMemberInfo(@AuthMemberPrincipal final Member member) { + return ResponseEntity.ok(LoginMemberInfoResponse.from(member)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/controller/response/LoginMemberInfoResponse.java b/backend/baton/src/main/java/touch/baton/domain/member/controller/response/LoginMemberInfoResponse.java new file mode 100644 index 000000000..bf1d562bd --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/controller/response/LoginMemberInfoResponse.java @@ -0,0 +1,10 @@ +package touch.baton.domain.member.controller.response; + +import touch.baton.domain.member.Member; + +public record LoginMemberInfoResponse(String name, String imageUrl) { + + public static LoginMemberInfoResponse from(final Member member) { + return new LoginMemberInfoResponse(member.getMemberName().getValue(), member.getImageUrl().getValue()); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberBusinessException.java new file mode 100644 index 000000000..9e1400e3e --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.member.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class MemberBusinessException extends BusinessException { + + public MemberBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberDomainException.java b/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberDomainException.java new file mode 100644 index 000000000..1fc0e3c78 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.member.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class MemberDomainException extends DomainException { + + public MemberDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberRequestException.java b/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberRequestException.java new file mode 100644 index 000000000..01aa1d11d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/exception/MemberRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.member.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class MemberRequestException extends ClientRequestException { + + public MemberRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/repository/MemberRepository.java b/backend/baton/src/main/java/touch/baton/domain/member/repository/MemberRepository.java new file mode 100644 index 000000000..83f47b92d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/repository/MemberRepository.java @@ -0,0 +1,7 @@ +package touch.baton.domain.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import touch.baton.domain.member.Member; + +public interface MemberRepository extends JpaRepository { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/vo/Company.java b/backend/baton/src/main/java/touch/baton/domain/member/vo/Company.java new file mode 100644 index 000000000..072a9b2af --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/vo/Company.java @@ -0,0 +1,32 @@ +package touch.baton.domain.member.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class Company { + + @Column(name = "company", nullable = false) + private String value; + + public Company(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("Company 객체 내부에 company 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/vo/GithubUrl.java b/backend/baton/src/main/java/touch/baton/domain/member/vo/GithubUrl.java new file mode 100644 index 000000000..93cc335e9 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/vo/GithubUrl.java @@ -0,0 +1,32 @@ +package touch.baton.domain.member.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class GithubUrl { + + @Column(name = "github_url", nullable = false) + private String value; + + public GithubUrl(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("GithubUrl 객체 내부에 githubUrl 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/vo/ImageUrl.java b/backend/baton/src/main/java/touch/baton/domain/member/vo/ImageUrl.java new file mode 100644 index 000000000..def08fc80 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/vo/ImageUrl.java @@ -0,0 +1,32 @@ +package touch.baton.domain.member.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class ImageUrl { + + @Column(name = "image_url", nullable = false) + private String value; + + public ImageUrl(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("ImageUrl 객체 내부에 imageUrl 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/vo/MemberName.java b/backend/baton/src/main/java/touch/baton/domain/member/vo/MemberName.java new file mode 100644 index 000000000..2a4dcd958 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/vo/MemberName.java @@ -0,0 +1,32 @@ +package touch.baton.domain.member.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class MemberName { + + @Column(name = "name", nullable = false) + private String value; + + public MemberName(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("MemberName 객체 내부에 name 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/vo/OauthId.java b/backend/baton/src/main/java/touch/baton/domain/member/vo/OauthId.java new file mode 100644 index 000000000..0b944f8f0 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/vo/OauthId.java @@ -0,0 +1,32 @@ +package touch.baton.domain.member.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class OauthId { + + @Column(name = "oauth_id", nullable = false) + private String value; + + public OauthId(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("OauthId 객체 내부에 oauthId 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/member/vo/SocialId.java b/backend/baton/src/main/java/touch/baton/domain/member/vo/SocialId.java new file mode 100644 index 000000000..f4ce90f3d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/member/vo/SocialId.java @@ -0,0 +1,32 @@ +package touch.baton.domain.member.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class SocialId { + + @Column(name = "social_id", nullable = false) + private String value; + + public SocialId(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("SocialId 객체 내부에 value 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/OauthInformation.java b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthInformation.java new file mode 100644 index 000000000..d6762483b --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthInformation.java @@ -0,0 +1,48 @@ +package touch.baton.domain.oauth; + + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +public class OauthInformation { + + private SocialToken socialToken; + + private OauthId oauthId; + + private MemberName memberName; + + private SocialId socialId; + + private GithubUrl githubUrl; + + private ImageUrl imageUrl; + + @Builder + private OauthInformation(final SocialToken socialToken, + final OauthId oauthId, + final MemberName memberName, + final SocialId socialId, + final GithubUrl githubUrl, + final ImageUrl imageUrl + ) { + this.socialToken = socialToken; + this.oauthId = oauthId; + this.memberName = memberName; + this.socialId = socialId; + this.githubUrl = githubUrl; + this.imageUrl = imageUrl; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/OauthType.java b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthType.java new file mode 100644 index 000000000..fb2de4143 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/OauthType.java @@ -0,0 +1,12 @@ +package touch.baton.domain.oauth; + +import static java.util.Locale.ENGLISH; + +public enum OauthType { + + GITHUB; + + public static OauthType from(final String name) { + return OauthType.valueOf(name.toUpperCase(ENGLISH)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/SocialToken.java b/backend/baton/src/main/java/touch/baton/domain/oauth/SocialToken.java new file mode 100644 index 000000000..2abaa9794 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/SocialToken.java @@ -0,0 +1,15 @@ +package touch.baton.domain.oauth; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode +@Getter +public final class SocialToken { + + private final String value; + + public SocialToken(final String value) { + this.value = value; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProvider.java b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProvider.java new file mode 100644 index 000000000..089b64cf5 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProvider.java @@ -0,0 +1,10 @@ +package touch.baton.domain.oauth.authcode; + +import touch.baton.domain.oauth.OauthType; + +public interface AuthCodeRequestUrlProvider { + + OauthType oauthServer(); + + String getRequestUrl(); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProviderComposite.java b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProviderComposite.java new file mode 100644 index 000000000..f25ff3ade --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/authcode/AuthCodeRequestUrlProviderComposite.java @@ -0,0 +1,35 @@ +package touch.baton.domain.oauth.authcode; + +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.exception.OauthRequestException; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static java.util.function.Function.identity; + +@Component +public class AuthCodeRequestUrlProviderComposite { + + private final Map authCodeProviders; + + public AuthCodeRequestUrlProviderComposite(final Set authCodeProviders) { + this.authCodeProviders = authCodeProviders.stream() + .collect(Collectors.toMap( + AuthCodeRequestUrlProvider::oauthServer, identity() + )); + } + + public String findRequestUrl(final OauthType oauthType) { + return findAuthCodeProvider(oauthType).getRequestUrl(); + } + + private AuthCodeRequestUrlProvider findAuthCodeProvider(final OauthType oauthType) { + return Optional.ofNullable(authCodeProviders.get(oauthType)) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.OAUTH_REQUEST_URL_PROVIDER_IS_WRONG)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClient.java b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClient.java new file mode 100644 index 000000000..21c9a00bb --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClient.java @@ -0,0 +1,11 @@ +package touch.baton.domain.oauth.client; + +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; + +public interface OauthInformationClient { + + OauthType oauthType(); + + OauthInformation fetchInformation(final String authCode); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClientComposite.java b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClientComposite.java new file mode 100644 index 000000000..c93c09f07 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/client/OauthInformationClientComposite.java @@ -0,0 +1,34 @@ +package touch.baton.domain.oauth.client; + +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.exception.OauthRequestException; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static java.util.function.Function.identity; + +@Component +public class OauthInformationClientComposite { + + private final Map clients; + + public OauthInformationClientComposite(final Set clients) { + this.clients = clients.stream() + .collect(Collectors.toMap( + OauthInformationClient::oauthType, + identity() + )); + } + + public OauthInformation fetchInformation(final OauthType oauthType, final String authCode) { + return Optional.ofNullable(clients.get(oauthType)) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.OAUTH_INFORMATION_CLIENT_IS_WRONG)) + .fetchInformation(authCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/OauthController.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/OauthController.java new file mode 100644 index 000000000..cc5e35801 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/OauthController.java @@ -0,0 +1,45 @@ +package touch.baton.domain.oauth.controller; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.service.OauthService; + +import java.io.IOException; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpStatus.FOUND; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/oauth") +@RestController +public class OauthController { + + private final OauthService oauthService; + + @GetMapping("/{oauthType}") + public ResponseEntity redirectAuthCode(@PathVariable("oauthType") final OauthType oauthType, + final HttpServletResponse response + ) throws IOException { + final String redirectUrl = oauthService.readAuthCodeRedirect(oauthType); + response.sendRedirect(redirectUrl); + + return ResponseEntity.status(FOUND).build(); + } + + @GetMapping("/login/{oauthType}") + public ResponseEntity login(@PathVariable final OauthType oauthType, + @RequestParam final String code + ) { + final String jwtToken = oauthService.login(oauthType, code); + return ResponseEntity.ok() + .header(AUTHORIZATION, jwtToken) + .build(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipal.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipal.java new file mode 100644 index 000000000..775963b1c --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipal.java @@ -0,0 +1,13 @@ +package touch.baton.domain.oauth.controller.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMemberPrincipal { + + boolean required() default true; +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipalArgumentResolver.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipalArgumentResolver.java new file mode 100644 index 000000000..ec066b4ed --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthMemberPrincipalArgumentResolver.java @@ -0,0 +1,54 @@ +package touch.baton.domain.oauth.controller.resolver; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.oauth.exception.OauthRequestException; +import touch.baton.domain.oauth.repository.OauthMemberRepository; +import touch.baton.infra.auth.jwt.JwtDecoder; + +@Component +public class AuthMemberPrincipalArgumentResolver extends UserPrincipalArgumentResolver { + + private final OauthMemberRepository oauthMemberRepository; + + public AuthMemberPrincipalArgumentResolver(final JwtDecoder jwtDecoder, final OauthMemberRepository oauthMemberRepository) { + super(jwtDecoder); + this.oauthMemberRepository = oauthMemberRepository; + } + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMemberPrincipal.class); + } + + @Override + protected Object getGuest() { + return Member.builder() + .memberName(new MemberName("게스트")) + .socialId(new SocialId("게스트 SocialId")) + .oauthId(new OauthId("게스트 OauthId")) + .githubUrl(new GithubUrl("게스트 GitHubUrl")) + .company(new Company("게스트 회사")) + .imageUrl(new ImageUrl("게스트 이미지")) + .build(); + } + + @Override + protected Object getUser(final String socialId) { + return oauthMemberRepository.findBySocialId(new SocialId(socialId)) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.JWT_CLAIM_SOCIAL_ID_IS_WRONG)); + } + + @Override + protected boolean isAuthorizationRequired(final MethodParameter parameter) { + return parameter.getParameterAnnotation(AuthMemberPrincipal.class).required(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipal.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipal.java new file mode 100644 index 000000000..d44f4b40a --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipal.java @@ -0,0 +1,13 @@ +package touch.baton.domain.oauth.controller.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthRunnerPrincipal { + + boolean required() default true; +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipalArgumentResolver.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipalArgumentResolver.java new file mode 100644 index 000000000..bdffe6038 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthRunnerPrincipalArgumentResolver.java @@ -0,0 +1,58 @@ +package touch.baton.domain.oauth.controller.resolver; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.oauth.exception.OauthRequestException; +import touch.baton.domain.oauth.repository.OauthRunnerRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.infra.auth.jwt.JwtDecoder; + +@Component +public class AuthRunnerPrincipalArgumentResolver extends UserPrincipalArgumentResolver { + + private final OauthRunnerRepository oauthRunnerRepository; + + public AuthRunnerPrincipalArgumentResolver(final JwtDecoder jwtDecoder, final OauthRunnerRepository oauthRunnerRepository) { + super(jwtDecoder); + this.oauthRunnerRepository = oauthRunnerRepository; + } + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthRunnerPrincipal.class); + } + + @Override + protected Object getGuest() { + return Runner.builder() + .member(Member.builder() + .memberName(new MemberName("게스트")) + .socialId(new SocialId("guestSocialId")) + .oauthId(new OauthId("guestOauthId")) + .githubUrl(new GithubUrl("guestGithubUrl")) + .company(new Company("guestCompany")) + .imageUrl(new ImageUrl("guestImageUrl")) + .build() + ); + } + + @Override + protected Object getUser(final String socialId) { + return oauthRunnerRepository.joinByMemberSocialId(new SocialId(socialId)) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.JWT_CLAIM_SOCIAL_ID_IS_WRONG)); + } + + @Override + protected boolean isAuthorizationRequired(final MethodParameter parameter) { + return parameter.getParameterAnnotation(AuthRunnerPrincipal.class).required(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipal.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipal.java new file mode 100644 index 000000000..ae187010b --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipal.java @@ -0,0 +1,13 @@ +package touch.baton.domain.oauth.controller.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthSupporterPrincipal { + + boolean required() default true; +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipalArgumentResolver.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipalArgumentResolver.java new file mode 100644 index 000000000..b126b104d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/AuthSupporterPrincipalArgumentResolver.java @@ -0,0 +1,51 @@ +package touch.baton.domain.oauth.controller.resolver; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.oauth.exception.OauthRequestException; +import touch.baton.domain.oauth.repository.OauthSupporterRepository; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; +import touch.baton.infra.auth.jwt.JwtDecoder; + +import java.util.Collections; + +@Component +public class AuthSupporterPrincipalArgumentResolver extends UserPrincipalArgumentResolver { + + private final OauthSupporterRepository oauthSupporterRepository; + + public AuthSupporterPrincipalArgumentResolver(final JwtDecoder jwtDecoder, final OauthSupporterRepository oauthSupporterRepository) { + super(jwtDecoder); + this.oauthSupporterRepository = oauthSupporterRepository; + } + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthSupporterPrincipal.class); + } + + @Override + protected Object getGuest() { + return Supporter.builder() + .reviewCount(new ReviewCount(0)) + .member(null) + .supporterTechnicalTags(new SupporterTechnicalTags(Collections.emptyList())) + .build(); + } + + @Override + protected Object getUser(final String socialId) { + return oauthSupporterRepository.joinByMemberSocialId(new SocialId(socialId)) + .orElseThrow(() -> new OauthRequestException(ClientErrorCode.JWT_CLAIM_SOCIAL_ID_IS_WRONG)); + } + + @Override + protected boolean isAuthorizationRequired(final MethodParameter parameter) { + return parameter.getParameterAnnotation(AuthSupporterPrincipal.class).required(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/UserPrincipalArgumentResolver.java b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/UserPrincipalArgumentResolver.java new file mode 100644 index 000000000..0570d2441 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/controller/resolver/UserPrincipalArgumentResolver.java @@ -0,0 +1,61 @@ +package touch.baton.domain.oauth.controller.resolver; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.exception.OauthRequestException; +import touch.baton.infra.auth.jwt.JwtDecoder; + +import java.util.Objects; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@RequiredArgsConstructor +public abstract class UserPrincipalArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String BEARER = "Bearer "; + + private final JwtDecoder jwtDecoder; + + @Override + public Object resolveArgument(final MethodParameter parameter, + final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, + final WebDataBinderFactory binderFactory + ) { + final boolean isAuthorizationRequired = isAuthorizationRequired(parameter); + final boolean isAuthorizationHeaderNotExist = isAuthorizationHeaderNotExist(webRequest); + if (isAuthorizationRequired && isAuthorizationHeaderNotExist) { + throw new OauthRequestException(ClientErrorCode.OAUTH_AUTHORIZATION_VALUE_IS_NULL); + } + if (!isAuthorizationRequired && isAuthorizationHeaderNotExist) { + return getGuest(); + } + + final String authHeader = webRequest.getHeader(AUTHORIZATION); + if (!authHeader.startsWith(BEARER)) { + throw new OauthRequestException(ClientErrorCode.OAUTH_AUTHORIZATION_BEARER_TYPE_NOT_FOUND); + } + + final String token = authHeader.substring(BEARER.length()); + final Claims claims = jwtDecoder.parseJwtToken(token); + final String socialId = claims.get("socialId", String.class); + + return getUser(socialId); + } + + private boolean isAuthorizationHeaderNotExist(final NativeWebRequest webRequest) { + return Objects.isNull(webRequest.getHeader(AUTHORIZATION)); + } + + protected abstract boolean isAuthorizationRequired(final MethodParameter parameter); + + protected abstract Object getGuest(); + + protected abstract Object getUser(final String socialId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthBusinessException.java new file mode 100644 index 000000000..e5a379890 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.oauth.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class OauthBusinessException extends BusinessException { + + public OauthBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthRequestException.java b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthRequestException.java new file mode 100644 index 000000000..6aa131f97 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/exception/OauthRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.oauth.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class OauthRequestException extends ClientRequestException { + + public OauthRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthMemberRepository.java b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthMemberRepository.java new file mode 100644 index 000000000..a0f81ce75 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthMemberRepository.java @@ -0,0 +1,15 @@ +package touch.baton.domain.oauth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; + +import java.util.Optional; + +public interface OauthMemberRepository extends JpaRepository { + + Optional findMemberByOauthId(final OauthId oauthId); + + Optional findBySocialId(final SocialId socialId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthRunnerRepository.java b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthRunnerRepository.java new file mode 100644 index 000000000..0bc96920a --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthRunnerRepository.java @@ -0,0 +1,20 @@ +package touch.baton.domain.oauth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; + +import java.util.Optional; + +public interface OauthRunnerRepository extends JpaRepository { + + @Query(""" + select r, r.member + from Runner r + join fetch Member m on m.id = r.member.id + where m.socialId = :socialId + """) + Optional joinByMemberSocialId(@Param("socialId") final SocialId socialId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthSupporterRepository.java b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthSupporterRepository.java new file mode 100644 index 000000000..80bd43a54 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/repository/OauthSupporterRepository.java @@ -0,0 +1,20 @@ +package touch.baton.domain.oauth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.supporter.Supporter; + +import java.util.Optional; + +public interface OauthSupporterRepository extends JpaRepository { + + @Query(""" + select s, s.member + from Supporter s + join fetch Member m on m.id = s.member.id + where m.socialId = :socialId + """) + Optional joinByMemberSocialId(@Param("socialId") final SocialId socialId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/service/OauthService.java b/backend/baton/src/main/java/touch/baton/domain/oauth/service/OauthService.java new file mode 100644 index 000000000..5c31f3219 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/service/OauthService.java @@ -0,0 +1,85 @@ +package touch.baton.domain.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.authcode.AuthCodeRequestUrlProviderComposite; +import touch.baton.domain.oauth.client.OauthInformationClientComposite; +import touch.baton.domain.oauth.repository.OauthMemberRepository; +import touch.baton.domain.oauth.repository.OauthRunnerRepository; +import touch.baton.domain.oauth.repository.OauthSupporterRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; +import touch.baton.infra.auth.jwt.JwtEncoder; + +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class OauthService { + + private final AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite; + private final OauthInformationClientComposite oauthInformationClientComposite; + private final OauthMemberRepository oauthMemberRepository; + private final OauthRunnerRepository oauthRunnerRepository; + private final OauthSupporterRepository oauthSupporterRepository; + private final JwtEncoder jwtEncoder; + + public String readAuthCodeRedirect(final OauthType oauthType) { + return authCodeRequestUrlProviderComposite.findRequestUrl(oauthType); + } + + public String login(final OauthType oauthType, final String code) { + final OauthInformation oauthInformation = oauthInformationClientComposite.fetchInformation(oauthType, code); + + final Optional maybeMember = oauthMemberRepository.findMemberByOauthId(oauthInformation.getOauthId()); + if (maybeMember.isEmpty()) { + final Member savedMember = signUpMember(oauthInformation); + saveNewRunner(savedMember); + saveNewSupporter(savedMember); + } + + return jwtEncoder.jwtToken(Map.of( + "socialId", oauthInformation.getSocialId().getValue()) + ); + } + + private Member signUpMember(final OauthInformation oauthInformation) { + final Member newMember = Member.builder() + .memberName(oauthInformation.getMemberName()) + .socialId(oauthInformation.getSocialId()) + .oauthId(oauthInformation.getOauthId()) + .githubUrl(oauthInformation.getGithubUrl()) + .company(new Company("")) + .imageUrl(oauthInformation.getImageUrl()) + .build(); + + return oauthMemberRepository.save(newMember); + } + + private Runner saveNewRunner(final Member member) { + final Runner newRunner = Runner.builder() + .member(member) + .build(); + + return oauthRunnerRepository.save(newRunner); + } + + private Supporter saveNewSupporter(final Member member) { + final Supporter newSupporter = Supporter.builder() + .reviewCount(new ReviewCount(0)) + .member(member) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build(); + + return oauthSupporterRepository.save(newSupporter); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/Runner.java b/backend/baton/src/main/java/touch/baton/domain/runner/Runner.java new file mode 100644 index 000000000..8fe2489d6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/Runner.java @@ -0,0 +1,109 @@ +package touch.baton.domain.runner; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.BaseEntity; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.runner.exception.RunnerDomainException; +import touch.baton.domain.supporter.exception.SupporterDomainException; +import touch.baton.domain.technicaltag.RunnerTechnicalTag; +import touch.baton.domain.technicaltag.RunnerTechnicalTags; + +import java.util.List; +import java.util.Objects; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Runner extends BaseEntity { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Embedded + private Introduction introduction; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "fk_runner_to_member"), nullable = false) + private Member member; + + @Embedded + private RunnerTechnicalTags runnerTechnicalTags; + + @Builder + private Runner(final Member member, + final RunnerTechnicalTags runnerTechnicalTags + ) { + this(null, Introduction.getDefaultIntroduction(), member, runnerTechnicalTags); + } + + private Runner(final Long id, + final Introduction introduction, + final Member member, + final RunnerTechnicalTags runnerTechnicalTags + ) { + validateMemberNotNull(member); + this.id = id; + this.introduction = introduction; + this.member = member; + this.runnerTechnicalTags = runnerTechnicalTags; + } + + private void validateMemberNotNull(final Member member) { + if (Objects.isNull(member)) { + throw new RunnerDomainException("Runner 의 member 는 null 일 수 없습니다."); + } + } + + public void updateIntroduction(final Introduction introduction) { + validateIntroductionNotNull(introduction); + this.introduction = introduction; + } + + private void validateIntroductionNotNull(final Introduction introduction) { + if (Objects.isNull(introduction)) { + throw new SupporterDomainException("Runner 의 introduction 은 null 일 수 없습니다."); + } + } + + public void updateMemberName(final MemberName memberName) { + this.member.updateMemberName(memberName); + } + + public void updateCompany(final Company company) { + this.member.updateCompany(company); + } + + public void addAllRunnerTechnicalTags(final List runnerTechnicalTags) { + this.runnerTechnicalTags.addAll(runnerTechnicalTags); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Runner runner = (Runner) o; + return Objects.equals(id, runner.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/controller/RunnerProfileController.java b/backend/baton/src/main/java/touch/baton/domain/runner/controller/RunnerProfileController.java new file mode 100644 index 000000000..7f8ebb0bf --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/controller/RunnerProfileController.java @@ -0,0 +1,63 @@ +package touch.baton.domain.runner.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipal; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.response.RunnerMyProfileResponse; +import touch.baton.domain.runner.controller.response.RunnerProfileResponse; +import touch.baton.domain.runner.controller.response.RunnerResponse; +import touch.baton.domain.runner.service.RunnerService; +import touch.baton.domain.runner.service.dto.RunnerUpdateRequest; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.service.RunnerPostService; + +import java.net.URI; +import java.util.List; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/profile/runner") +@RestController +public class RunnerProfileController { + + private final RunnerPostService runnerPostService; + private final RunnerService runnerService; + + @GetMapping + public ResponseEntity readMyProfile(@AuthRunnerPrincipal final Runner runner) { + final RunnerResponse.Mine me = RunnerResponse.Mine.from(runner); + final List runnerPosts = runnerPostService.readRunnerPostsByRunnerId(runner.getId()).stream() + .map(RunnerPostResponse.Mine::from) + .toList(); + return ResponseEntity.ok(new RunnerMyProfileResponse(me, runnerPosts)); + } + + @GetMapping("/me") + public ResponseEntity readMyProfileByToken(@AuthRunnerPrincipal Runner runner) { + final RunnerResponse.MyProfile response = RunnerResponse.MyProfile.from(runner); + + return ResponseEntity.ok(response); + } + + @PatchMapping("/me") + public ResponseEntity updateMyProfile(@AuthRunnerPrincipal final Runner runner, + @RequestBody @Valid final RunnerUpdateRequest runnerUpdateRequest) { + runnerService.updateRunner(runner, runnerUpdateRequest); + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/profile/runner/me").build().toUri(); + return ResponseEntity.noContent().location(redirectUri).build(); + } + + @GetMapping("/{runnerId}") + public ResponseEntity readRunnerProfile(@PathVariable Long runnerId) { + final Runner runner = runnerService.readByRunnerId(runnerId); + return ResponseEntity.ok(RunnerProfileResponse.Detail.from(runner)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerMyProfileResponse.java b/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerMyProfileResponse.java new file mode 100644 index 000000000..3737ef318 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerMyProfileResponse.java @@ -0,0 +1,10 @@ +package touch.baton.domain.runner.controller.response; + +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; + +import java.util.List; + +public record RunnerMyProfileResponse(RunnerResponse.Mine profile, + List runnerPosts +) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerProfileResponse.java b/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerProfileResponse.java new file mode 100644 index 000000000..c39dc9fd0 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerProfileResponse.java @@ -0,0 +1,36 @@ +package touch.baton.domain.runner.controller.response; + +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; + +import java.util.List; +import java.util.Objects; + +public record RunnerProfileResponse() { + + public record Detail(Long runnerId, + String name, + String imageUrl, + String githubUrl, + String introduction, + String company, + List technicalTags + ) { + + public static Detail from(final Runner runner) { + final List tagNames = runner.getRunnerTechnicalTags().getRunnerTechnicalTags().stream() + .map(runnerTechnicalTag -> runnerTechnicalTag.getTechnicalTag().getTagName().getValue()) + .toList(); + + final Member member = runner.getMember(); + return new Detail(runner.getId(), + member.getMemberName().getValue(), + member.getImageUrl().getValue(), + member.getGithubUrl().getValue(), + runner.getIntroduction().getValue(), + member.getCompany().getValue(), + tagNames); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerResponse.java b/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerResponse.java new file mode 100644 index 000000000..0c8bf2696 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/controller/response/RunnerResponse.java @@ -0,0 +1,76 @@ +package touch.baton.domain.runner.controller.response; + +import touch.baton.domain.runner.Runner; + +import java.util.List; + +public record RunnerResponse() { + + public record Detail(Long runnerId, + String name, + String company, + String imageUrl + ) { + + public static Detail from(final Runner runner) { + return new Detail( + runner.getId(), + runner.getMember().getMemberName().getValue(), + runner.getMember().getCompany().getValue(), + runner.getMember().getImageUrl().getValue() + ); + } + } + + public record Simple(String name, String imageUrl) { + + public static Simple from(final Runner runner) { + return new Simple( + runner.getMember().getMemberName().getValue(), + runner.getMember().getImageUrl().getValue() + ); + } + } + + public record Mine(String name, + String imageUrl, + String githubUrl, + String introduction + ) { + + public static Mine from(final Runner runner) { + return new Mine( + runner.getMember().getMemberName().getValue(), + runner.getMember().getImageUrl().getValue(), + runner.getMember().getGithubUrl().getValue(), + runner.getIntroduction().getValue() + ); + } + } + + public record MyProfile(String name, + String company, + String imageUrl, + String githubUrl, + String introduction, + List technicalTags + ) { + + public static MyProfile from(final Runner runner) { + return new MyProfile( + runner.getMember().getMemberName().getValue(), + runner.getMember().getCompany().getValue(), + runner.getMember().getImageUrl().getValue(), + runner.getMember().getGithubUrl().getValue(), + runner.getIntroduction().getValue(), + convertRunnerTechnicalTags(runner) + ); + } + + private static List convertRunnerTechnicalTags(final Runner runner) { + return runner.getRunnerTechnicalTags().getRunnerTechnicalTags().stream() + .map(runnerTechnicalTag -> runnerTechnicalTag.getTechnicalTag().getTagName().getValue()) + .toList(); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerBusinessException.java new file mode 100644 index 000000000..0662d1c16 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.runner.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class RunnerBusinessException extends BusinessException { + + public RunnerBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerDomainException.java b/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerDomainException.java new file mode 100644 index 000000000..785f80016 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.runner.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class RunnerDomainException extends DomainException { + + public RunnerDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerRequestException.java b/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerRequestException.java new file mode 100644 index 000000000..a259334ca --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/exception/RunnerRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.runner.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class RunnerRequestException extends ClientRequestException { + + public RunnerRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/repository/RunnerRepository.java b/backend/baton/src/main/java/touch/baton/domain/runner/repository/RunnerRepository.java new file mode 100644 index 000000000..c5e8998b8 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/repository/RunnerRepository.java @@ -0,0 +1,19 @@ +package touch.baton.domain.runner.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.runner.Runner; + +import java.util.Optional; + +public interface RunnerRepository extends JpaRepository { + + @Query(""" + select r + from Runner r + join fetch Member m on m.id = r.member.id + where r.id = :runnerId + """) + Optional joinMemberByRunnerId(@Param("runnerId") Long runnerId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/service/RunnerService.java b/backend/baton/src/main/java/touch/baton/domain/runner/service/RunnerService.java new file mode 100644 index 000000000..80e006be8 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/service/RunnerService.java @@ -0,0 +1,75 @@ +package touch.baton.domain.runner.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.exception.RunnerBusinessException; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runner.service.dto.RunnerUpdateRequest; +import touch.baton.domain.technicaltag.RunnerTechnicalTag; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.domain.technicaltag.repository.RunnerTechnicalTagRepository; +import touch.baton.domain.technicaltag.repository.TechnicalTagRepository; + +import java.util.List; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class RunnerService { + + private final RunnerRepository runnerRepository; + private final RunnerTechnicalTagRepository runnerTechnicalTagRepository; + private final TechnicalTagRepository technicalTagRepository; + + public Runner readByRunnerId(final Long runnerId) { + return runnerRepository.joinMemberByRunnerId(runnerId) + .orElseThrow(() -> new RunnerBusinessException("Runner 가 존재하지 않습니다.")); + } + + @Transactional + public void updateRunner(Runner runner, RunnerUpdateRequest runnerUpdateRequest) { + runner.updateMemberName(new MemberName(runnerUpdateRequest.name())); + runner.updateCompany(new Company(runnerUpdateRequest.company())); + runner.updateIntroduction(new Introduction(runnerUpdateRequest.introduction())); + updateTechnicalTags(runner, runnerUpdateRequest.technicalTags()); + } + + private void updateTechnicalTags(final Runner runner, final List technicalTags) { + runnerTechnicalTagRepository.deleteByRunner(runner); + createRunnerTechnicalTags(runner, technicalTags); + } + + private List createRunnerTechnicalTags(final Runner runner, final List technicalTags) { + return technicalTags.stream() + .map(tagName -> createRunnerTechnicalTag(runner, new TagName(tagName))) + .toList(); + } + + private RunnerTechnicalTag createRunnerTechnicalTag(final Runner runner, final TagName tagName) { + final TechnicalTag technicalTag = findTechnicalTagIfExistElseCreate(tagName); + return createRunnerTechnicalTagAndSave(runner, technicalTag); + } + + private TechnicalTag findTechnicalTagIfExistElseCreate(final TagName tagName) { + return technicalTagRepository.findByTagName(tagName) + .orElseGet(() -> technicalTagRepository.save( + TechnicalTag.builder() + .tagName(tagName) + .build()) + ); + } + + private RunnerTechnicalTag createRunnerTechnicalTagAndSave(final Runner runner, final TechnicalTag technicalTag) { + RunnerTechnicalTag runnerTechnicalTag = RunnerTechnicalTag.builder() + .runner(runner) + .technicalTag(technicalTag) + .build(); + return runnerTechnicalTagRepository.save(runnerTechnicalTag); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runner/service/dto/RunnerUpdateRequest.java b/backend/baton/src/main/java/touch/baton/domain/runner/service/dto/RunnerUpdateRequest.java new file mode 100644 index 000000000..5291ddebe --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runner/service/dto/RunnerUpdateRequest.java @@ -0,0 +1,21 @@ +package touch.baton.domain.runner.service.dto; + +import touch.baton.domain.common.exception.validator.ValidNotNull; + +import java.util.List; + +import static touch.baton.domain.common.exception.ClientErrorCode.COMPANY_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.NAME_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.RUNNER_INTRODUCTION_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.RUNNER_TECHNICAL_TAGS_ARE_NULL; + +public record RunnerUpdateRequest(@ValidNotNull(clientErrorCode = NAME_IS_NULL) + String name, + @ValidNotNull(clientErrorCode = COMPANY_IS_NULL) + String company, + @ValidNotNull(clientErrorCode = RUNNER_INTRODUCTION_IS_NULL) + String introduction, + @ValidNotNull(clientErrorCode = RUNNER_TECHNICAL_TAGS_ARE_NULL) + List technicalTags +) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/RunnerPost.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/RunnerPost.java new file mode 100644 index 000000000..c3e4f18e8 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/RunnerPost.java @@ -0,0 +1,288 @@ +package touch.baton.domain.runnerpost; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.BaseEntity; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.exception.RunnerPostDomainException; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.RunnerPostTags; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.DONE; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.IN_PROGRESS; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.NOT_STARTED; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.OVERDUE; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class RunnerPost extends BaseEntity { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Embedded + private Title title; + + @Embedded + private Contents contents; + + @Embedded + private PullRequestUrl pullRequestUrl; + + @Embedded + private Deadline deadline; + + @Embedded + private WatchedCount watchedCount; + + @Enumerated(STRING) + @Column(nullable = false) + private ReviewStatus reviewStatus = ReviewStatus.NOT_STARTED; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "runner_id", foreignKey = @ForeignKey(name = "fk_runner_post_to_runner"), nullable = false) + private Runner runner; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "supporter_id", foreignKey = @ForeignKey(name = "fk_runner_post_to_supporter"), nullable = true) + private Supporter supporter; + + @Embedded + private RunnerPostTags runnerPostTags; + + @Builder + private RunnerPost(final Title title, + final Contents contents, + final PullRequestUrl pullRequestUrl, + final Deadline deadline, + final WatchedCount watchedCount, + final ReviewStatus reviewStatus, + final Runner runner, + final Supporter supporter, + final RunnerPostTags runnerPostTags + ) { + this(null, title, contents, pullRequestUrl, deadline, watchedCount, reviewStatus, runner, supporter, runnerPostTags); + } + + private RunnerPost(final Long id, + final Title title, + final Contents contents, + final PullRequestUrl pullRequestUrl, + final Deadline deadline, + final WatchedCount watchedCount, + final ReviewStatus reviewStatus, + final Runner runner, + final Supporter supporter, + final RunnerPostTags runnerPostTags + ) { + validateNotNull(title, contents, pullRequestUrl, deadline, watchedCount, reviewStatus, runner, runnerPostTags); + this.id = id; + this.title = title; + this.contents = contents; + this.pullRequestUrl = pullRequestUrl; + this.deadline = deadline; + this.watchedCount = watchedCount; + this.reviewStatus = reviewStatus; + this.runner = runner; + this.supporter = supporter; + this.runnerPostTags = runnerPostTags; + } + + public static RunnerPost newInstance(final String title, + final String contents, + final String pullRequestUrl, + final LocalDateTime deadline, + final Runner runner + ) { + return RunnerPost.builder() + .title(new Title(title)) + .contents(new Contents(contents)) + .pullRequestUrl(new PullRequestUrl(pullRequestUrl)) + .deadline(new Deadline(deadline)) + .runner(runner) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .watchedCount(WatchedCount.zero()) + .reviewStatus(NOT_STARTED) + .build(); + } + + private void validateNotNull(final Title title, + final Contents contents, + final PullRequestUrl pullRequestUrl, + final Deadline deadline, + final WatchedCount watchedCount, + final ReviewStatus reviewStatus, + final Runner runner, + final RunnerPostTags runnerPostTags + ) { + if (Objects.isNull(title)) { + throw new RunnerPostDomainException("RunnerPost 의 title 은 null 일 수 없습니다."); + } + + if (Objects.isNull(contents)) { + throw new RunnerPostDomainException("RunnerPost 의 contents 는 null 일 수 없습니다."); + } + + if (Objects.isNull(pullRequestUrl)) { + throw new RunnerPostDomainException("RunnerPost 의 pullRequestUrl 은 null 일 수 없습니다."); + } + + if (Objects.isNull(deadline)) { + throw new RunnerPostDomainException("RunnerPost 의 deadline 은 null 일 수 없습니다."); + } + + if (Objects.isNull(watchedCount)) { + throw new RunnerPostDomainException("RunnerPost 의 watchedCount 는 null 일 수 없습니다."); + } + + if (Objects.isNull(reviewStatus)) { + throw new RunnerPostDomainException("RunnerPost 의 reviewStatus 는 null 일 수 없습니다."); + } + + if (Objects.isNull(runner)) { + throw new RunnerPostDomainException("RunnerPost 의 runner 는 null 일 수 없습니다."); + } + + if (Objects.isNull(runnerPostTags)) { + throw new RunnerPostDomainException("RunnerPost 의 runnerPostTags 는 null 일 수 없습니다."); + } + } + + public void addAllRunnerPostTags(final List postTags) { + runnerPostTags.addAll(postTags); + } + + public void appendRunnerPostTag(RunnerPostTag postTag) { + runnerPostTags.add(postTag); + } + + public void updateTitle(final Title title) { + this.title = title; + } + + public void updateContents(final Contents contents) { + this.contents = contents; + } + + public void updatePullRequestUrl(final PullRequestUrl pullRequestUrl) { + this.pullRequestUrl = pullRequestUrl; + } + + public void updateDeadLine(final Deadline deadline) { + this.deadline = deadline; + } + + public void finishReview() { + updateReviewStatus(DONE); + } + + public void updateReviewStatus(final ReviewStatus other) { + if (this.reviewStatus.isSame(NOT_STARTED) && other.isSame(IN_PROGRESS)) { + throw new RunnerPostDomainException("ReviewStatus 를 수정하던 도중 NOT_STARTED 에서 IN_PROGRESS 로 리뷰 상태 정책을 원인으로 실패하였습니다."); + } + if (this.reviewStatus.isSame(NOT_STARTED) && other.isSame(DONE)) { + throw new RunnerPostDomainException("ReviewStatus 를 수정하던 도중 NOT_STARTED 에서 DONE 으로 리뷰 상태 정책을 원인으로 실패하였습니다."); + } + if (this.reviewStatus.isSame(DONE) && other.isSame(NOT_STARTED)) { + throw new RunnerPostDomainException("ReviewStatus 를 수정하던 도중 DONE 에서 NOT_STARTED 로 리뷰 상태 정책을 원인으로 실패하였습니다."); + } + if (this.reviewStatus.isSame(DONE) && other.isSame(IN_PROGRESS)) { + throw new RunnerPostDomainException("ReviewStatus 를 수정하던 도중 DONE 에서 IN_PROGRESS 로 리뷰 상태 정책을 원인으로 실패하였습니다."); + } + if (this.reviewStatus.isSame(DONE) && other.isSame(OVERDUE)) { + throw new RunnerPostDomainException("ReviewStatus 를 수정하던 도중 DONE 에서 OVERDUE 로 리뷰 상태 정책을 원인으로 실패하였습니다."); + } + if (this.reviewStatus.isSame(OVERDUE) && other.isSame(DONE)) { + throw new RunnerPostDomainException("ReviewStatus 를 수정하던 도중 OVERDUE 에서 DONE 로 리뷰 상태 정책을 원인으로 실패하였습니다."); + } + if (this.reviewStatus.isSame(other)) { + throw new RunnerPostDomainException("ReviewStatus 를 수정하던 도중 같은 ReviewStatus 로 리뷰 상태 정책을 원인으로 실패하였습니다."); + } + + this.reviewStatus = other; + } + + public void assignSupporter(final Supporter supporter) { + if (Objects.nonNull(this.supporter)) { + throw new RunnerPostDomainException("Supporter 를 할당하던 도중 RunnerPost 에 이미 다른 Supporter 가 할당되어 있는 것을 원인으로 실패하였습니다."); + } + if (reviewStatus.isSame(OVERDUE)) { + throw new RunnerPostDomainException("Supporter 를 할당하던 도중 ReviewStatus 가 OVERDUE 상태가 원인으로 실패하였습니다."); + } + if (reviewStatus.isNotSameAsNotStarted()) { + throw new RunnerPostDomainException("Supporter 를 할당하던 도중 ReviewStatus 가 NOT_STARTED 상태가 아닌 것을 원인으로 실패하였습니다."); + } + if (deadline.isEnd()) { + throw new RunnerPostDomainException("Supporter 를 할당하던 도중 ReviewStatus 의 Deadline 이 현재 시간보다 과거인 것을 원인으로 실패하였습니다."); + } + + this.supporter = supporter; + this.reviewStatus = IN_PROGRESS; + } + + public void increaseWatchedCount() { + this.watchedCount = watchedCount.increase(); + } + + public boolean isNotOwner(final Runner targetRunner) { + return !runner.equals(targetRunner); + } + + public boolean isOwner(final Member targetMember) { + return runner.getMember().equals(targetMember); + } + + public boolean isReviewStatusStarted() { + return !(reviewStatus.isNotStarted() || reviewStatus.isOverdue()); + } + + public boolean isDifferentSupporter(final Supporter targetSupporter) { + return !supporter.equals(targetSupporter); + } + + public boolean isReviewStatusNotStarted() { + return reviewStatus.isNotStarted(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RunnerPost that = (RunnerPost) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/RunnerPostController.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/RunnerPostController.java new file mode 100644 index 000000000..9f2f6aecb --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/RunnerPostController.java @@ -0,0 +1,254 @@ +package touch.baton.domain.runnerpost.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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 org.springframework.web.util.UriComponentsBuilder; +import touch.baton.domain.common.response.PageResponse; +import touch.baton.domain.member.Member; +import touch.baton.domain.oauth.controller.resolver.AuthMemberPrincipal; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipal; +import touch.baton.domain.oauth.controller.resolver.AuthSupporterPrincipal; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.controller.response.SupporterRunnerPostResponse; +import touch.baton.domain.runnerpost.controller.response.SupporterRunnerPostResponses; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.service.dto.RunnerPostApplicantCreateRequest; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; +import touch.baton.domain.runnerpost.service.dto.RunnerPostUpdateRequest; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; + +import java.net.URI; +import java.util.List; +import java.util.stream.IntStream; + +import static org.springframework.data.domain.Sort.Direction.DESC; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/posts/runner") +@RestController +public class RunnerPostController { + + private final RunnerPostService runnerPostService; + + @PostMapping + public ResponseEntity createRunnerPost(@AuthRunnerPrincipal final Runner runner, + @Valid @RequestBody final RunnerPostCreateRequest request + ) { + final Long savedId = runnerPostService.createRunnerPost(runner, request); + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/posts/runner") + .path("/{id}") + .buildAndExpand(savedId) + .toUri(); + return ResponseEntity.created(redirectUri).build(); + } + + @GetMapping("/{runnerPostId}") + public ResponseEntity readByRunnerPostId( + @AuthMemberPrincipal(required = false) final Member member, + @PathVariable final Long runnerPostId + ) { + final RunnerPost foundRunnerPost = runnerPostService.readByRunnerPostId(runnerPostId); + final long applicantCount = runnerPostService.readCountByRunnerPostId(foundRunnerPost.getId()); + final boolean isApplicantHistoryExist = runnerPostService.existsRunnerPostApplicantByRunnerPostIdAndMemberId(runnerPostId, member.getId()); + + runnerPostService.increaseWatchedCount(foundRunnerPost); + final RunnerPostResponse.Detail response = RunnerPostResponse.Detail.of( + foundRunnerPost, + foundRunnerPost.isOwner(member), + isApplicantHistoryExist, + applicantCount + ); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{runnerPostId}") + public ResponseEntity deleteByRunnerPostId(@AuthRunnerPrincipal final Runner runner, + @PathVariable final Long runnerPostId + ) { + runnerPostService.deleteByRunnerPostId(runnerPostId, runner); + return ResponseEntity.noContent().build(); + } + + @GetMapping + public ResponseEntity> readAllRunnerPosts( + @PageableDefault(sort = "createdAt", direction = DESC) final Pageable pageable + ) { + final Page pageRunnerPosts = runnerPostService.readAllRunnerPosts(pageable); + final List foundRunnerPosts = pageRunnerPosts.getContent(); + final List applicantCounts = collectApplicantCounts(pageRunnerPosts); + final List responses = IntStream.range(0, foundRunnerPosts.size()) + .mapToObj(index -> { + final RunnerPost runnerPost = foundRunnerPosts.get(index); + final Long applicantCount = applicantCounts.get(index); + + return RunnerPostResponse.Simple.from(runnerPost, applicantCount); + }).toList(); + + final Page pageResponse + = new PageImpl<>(responses, pageable, pageRunnerPosts.getTotalPages()); + + return ResponseEntity.ok(PageResponse.from(pageResponse)); + } + + @GetMapping("/search") + public ResponseEntity> readReferencedBySupporter( + @PageableDefault(sort = "createdAt", direction = DESC) final Pageable pageable, + @RequestParam("supporterId") final Long supporterId, + @RequestParam("reviewStatus") final ReviewStatus reviewStatus + ) { + final Page pageRunnerPosts = runnerPostService.readRunnerPostsBySupporterIdAndReviewStatus(pageable, supporterId, reviewStatus); + final List foundRunnerPosts = pageRunnerPosts.getContent(); + final List applicantCounts = collectApplicantCounts(pageRunnerPosts); + final List responses = IntStream.range(0, foundRunnerPosts.size()) + .mapToObj(index -> { + final RunnerPost foundRunnerPost = foundRunnerPosts.get(index); + final long applicantCount = applicantCounts.get(index); + + return RunnerPostResponse.ReferencedBySupporter.of(foundRunnerPost, applicantCount); + }).toList(); + + final Page pageResponse + = new PageImpl<>(responses, pageable, pageRunnerPosts.getTotalPages()); + + return ResponseEntity.ok(PageResponse.from(pageResponse)); + } + + @GetMapping("/{runnerPostId}/supporters") + public ResponseEntity readSupporterRunnerPostsByRunnerPostId( + @AuthRunnerPrincipal final Runner runner, + @PathVariable final Long runnerPostId + ) { + final List responses = runnerPostService.readSupporterRunnerPostsByRunnerPostId(runner, runnerPostId).stream() + .map(SupporterRunnerPostResponse.Detail::from) + .toList(); + + return ResponseEntity.ok(SupporterRunnerPostResponses.Detail.from(responses)); + } + + @GetMapping("/me/supporter") + public ResponseEntity> readRunnerPostsByLoginedSupporterAndReviewStatus( + @PageableDefault(sort = "createdAt", direction = DESC) final Pageable pageable, + @AuthSupporterPrincipal final Supporter supporter, + @RequestParam("reviewStatus") final ReviewStatus reviewStatus + ) { + final Page pageRunnerPosts = runnerPostService.readRunnerPostsBySupporterIdAndReviewStatus(pageable, supporter.getId(), reviewStatus); + final List foundRunnerPosts = pageRunnerPosts.getContent(); + final List applicantCounts = collectApplicantCounts(pageRunnerPosts); + final List responses = IntStream.range(0, foundRunnerPosts.size()) + .mapToObj(index -> { + final RunnerPost foundRunnerPost = foundRunnerPosts.get(index); + final Long applicantCount = applicantCounts.get(index); + + return RunnerPostResponse.ReferencedBySupporter.of(foundRunnerPost, applicantCount); + }).toList(); + + final Page pageResponse + = new PageImpl<>(responses, pageable, pageRunnerPosts.getTotalPages()); + + return ResponseEntity.ok(PageResponse.from(pageResponse)); + } + + @GetMapping("/me/runner") + public ResponseEntity> readRunnerMyPage( + @PageableDefault(sort = "createdAt", direction = DESC) final Pageable pageable, + @AuthRunnerPrincipal final Runner runner, + @RequestParam("reviewStatus") final ReviewStatus reviewStatus + ) { + final Page pageRunnerPosts = runnerPostService.readRunnerPostsByRunnerIdAndReviewStatus(pageable, runner.getId(), reviewStatus); + final List applicantCounts = collectApplicantCounts(pageRunnerPosts); + + final List responses = IntStream.range(0, pageRunnerPosts.getContent().size()) + .mapToObj(index -> { + final Long applicantCount = applicantCounts.get(index); + final RunnerPost runnerPost = pageRunnerPosts.getContent().get(index); + return RunnerPostResponse.SimpleInMyPage.from(runnerPost, applicantCount); + } + ).toList(); + + final Page pageResponse + = new PageImpl<>(responses, pageable, pageRunnerPosts.getTotalPages()); + + return ResponseEntity.ok(PageResponse.from(pageResponse)); + } + + private List collectApplicantCounts(final Page pageRunnerPosts) { + final List runnerPostIds = pageRunnerPosts.stream() + .map(RunnerPost::getId) + .toList(); + + return runnerPostService.readCountsByRunnerPostIds(runnerPostIds); + } + + @PostMapping("/{runnerPostId}/application") + public ResponseEntity createRunnerPostApplicant( + @AuthSupporterPrincipal final Supporter supporter, + @PathVariable final Long runnerPostId, + @RequestBody @Valid final RunnerPostApplicantCreateRequest request + ) { + runnerPostService.createRunnerPostApplicant(supporter, request, runnerPostId); + + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/posts/runner") + .path("/{runnerPostId}") + .buildAndExpand(runnerPostId) + .toUri(); + + return ResponseEntity.created(redirectUri).build(); + } + + @PatchMapping("/{runnerPostId}/cancelation") + public ResponseEntity updateSupporterCancelRunnerPost(@AuthSupporterPrincipal final Supporter supporter, + @PathVariable final Long runnerPostId + ) { + runnerPostService.deleteSupporterRunnerPost(supporter, runnerPostId); + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/posts/runner") + .path("/{runnerPostId}") + .buildAndExpand(runnerPostId) + .toUri(); + return ResponseEntity.noContent().location(redirectUri).build(); + } + + @PatchMapping("/{runnerPostId}/supporters") + public ResponseEntity updateRunnerPostAppliedSupporter(@AuthRunnerPrincipal final Runner runner, + @PathVariable final Long runnerPostId, + @Valid @RequestBody final RunnerPostUpdateRequest.SelectSupporter request + ) { + runnerPostService.updateRunnerPostAppliedSupporter(runner, runnerPostId, request); + + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/posts/runner") + .path("/{runnerPostId}") + .buildAndExpand(runnerPostId) + .toUri(); + return ResponseEntity.noContent().location(redirectUri).build(); + } + + @PatchMapping("/{runnerPostId}/done") + public ResponseEntity updateRunnerPostReviewStatusDone(@AuthSupporterPrincipal final Supporter supporter, + @PathVariable final Long runnerPostId + ) { + runnerPostService.updateRunnerPostReviewStatusDone(runnerPostId, supporter); + + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/posts/runner") + .path("/{runnerPostId}") + .buildAndExpand(runnerPostId) + .toUri(); + return ResponseEntity.noContent().location(redirectUri).build(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostReadResponses.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostReadResponses.java new file mode 100644 index 000000000..7270ed8c4 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostReadResponses.java @@ -0,0 +1,27 @@ +package touch.baton.domain.runnerpost.controller.response; + +import java.util.List; + +public record RunnerPostReadResponses() { + + public record NoFiltering(List data) { + + public static NoFiltering from(final List data) { + return new NoFiltering(data); + } + } + + public record SimpleInMyPage(List data) { + + public static SimpleInMyPage from(final List data) { + return new SimpleInMyPage(data); + } + } + + public record LoginedSupporter(List data) { + + public static LoginedSupporter from(final List data) { + return new LoginedSupporter(data); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostResponse.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostResponse.java new file mode 100644 index 000000000..ea159a866 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/RunnerPostResponse.java @@ -0,0 +1,206 @@ +package touch.baton.domain.runnerpost.controller.response; + +import touch.baton.domain.runner.controller.response.RunnerResponse; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.ReviewStatus; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +public record RunnerPostResponse() { + + public record Detail(Long runnerPostId, + String title, + String contents, + String pullRequestUrl, + LocalDateTime deadline, + int watchedCount, + long applicantCount, + ReviewStatus reviewStatus, + boolean isOwner, + boolean isApplied, + List tags, + RunnerResponse.Detail runnerProfile + ) { + + public static Detail of(final RunnerPost runnerPost, + final boolean isOwner, + final boolean isApplied, + final long applicantCount + ) { + return new Detail( + runnerPost.getId(), + runnerPost.getTitle().getValue(), + runnerPost.getContents().getValue(), + runnerPost.getPullRequestUrl().getValue(), + runnerPost.getDeadline().getValue(), + runnerPost.getWatchedCount().getValue(), + applicantCount, + runnerPost.getReviewStatus(), + isOwner, + isApplied, + convertToTags(runnerPost), + RunnerResponse.Detail.from(runnerPost.getRunner()) + ); + } + } + + public record DetailVersionTest(Long runnerPostId, + String title, + String contents, + String pullRequestUrl, + LocalDateTime deadline, + Integer watchedCount, + ReviewStatus reviewStatus, + RunnerResponse.Detail runnerProfile, + SupporterResponseTestVersion.Simple supporterProfile, + boolean isOwner, + List tags + ) { + + public static DetailVersionTest ofVersionTest(final RunnerPost runnerPost, final boolean isOwner) { + return new DetailVersionTest( + runnerPost.getId(), + runnerPost.getTitle().getValue(), + runnerPost.getContents().getValue(), + runnerPost.getPullRequestUrl().getValue(), + runnerPost.getDeadline().getValue(), + runnerPost.getWatchedCount().getValue(), + runnerPost.getReviewStatus(), + RunnerResponse.Detail.from(runnerPost.getRunner()), + SupporterResponseTestVersion.Simple.fromTestVersion(runnerPost.getSupporter()), + isOwner, + convertToTags(runnerPost) + ); + } + } + + public record Simple(Long runnerPostId, + String title, + LocalDateTime deadline, + int watchedCount, + long applicantCount, + String reviewStatus, + RunnerResponse.Simple runnerProfile, + List tags + ) { + + public static Simple from(final RunnerPost runnerPost, + final long applicantCount + ) { + return new Simple( + runnerPost.getId(), + runnerPost.getTitle().getValue(), + runnerPost.getDeadline().getValue(), + runnerPost.getWatchedCount().getValue(), + applicantCount, + runnerPost.getReviewStatus().name(), + RunnerResponse.Simple.from(runnerPost.getRunner()), + convertToTags(runnerPost) + ); + } + } + + public record LoginedSupporter(Long runnerPostId, + String title, + LocalDateTime deadline, + List tags, + int watchedCount, + int applicantCount + + ) { + + public static LoginedSupporter from(final RunnerPost runnerPost, final int applicantCount) { + return new LoginedSupporter( + runnerPost.getId(), + runnerPost.getTitle().getValue(), + runnerPost.getDeadline().getValue(), + convertToTags(runnerPost), + runnerPost.getWatchedCount().getValue(), + applicantCount + ); + } + } + + public record Mine(Long runnerPostId, + String title, + LocalDateTime deadline, + List tags, + String reviewStatus + ) { + + public static Mine from(final RunnerPost runnerPost) { + return new Mine( + runnerPost.getId(), + runnerPost.getTitle().getValue(), + runnerPost.getDeadline().getValue(), + convertToTags(runnerPost), + runnerPost.getReviewStatus().name() + ); + } + } + public record SimpleInMyPage(Long runnerPostId, + Long supporterId, + String title, + LocalDateTime deadline, + List tags, + int watchedCount, + long applicantCount, + String reviewStatus + + ) { + + public static SimpleInMyPage from(final RunnerPost runnerPost, + final long applicantCount + ) { + return new SimpleInMyPage( + runnerPost.getId(), + getSupporterIdByRunnerPost(runnerPost), + runnerPost.getTitle().getValue(), + runnerPost.getDeadline().getValue(), + convertToTags(runnerPost), + runnerPost.getWatchedCount().getValue(), + applicantCount, + runnerPost.getReviewStatus().name() + ); + } + + private static Long getSupporterIdByRunnerPost(final RunnerPost runnerPost) { + if (Objects.isNull(runnerPost.getSupporter())) { + return null; + } + return runnerPost.getSupporter().getId(); + } + } + + public record ReferencedBySupporter(Long runnerPostId, + String title, + LocalDateTime deadline, + List tags, + int watchedCount, + long applicantCount, + String reviewStatus + ) { + + public static ReferencedBySupporter of(final RunnerPost runnerPost, final long applicantCount) { + return new ReferencedBySupporter( + runnerPost.getId(), + runnerPost.getTitle().getValue(), + runnerPost.getDeadline().getValue(), + convertToTags(runnerPost), + runnerPost.getWatchedCount().getValue(), + applicantCount, + runnerPost.getReviewStatus().name() + ); + } + } + + private static List convertToTags(final RunnerPost runnerPost) { + return runnerPost.getRunnerPostTags() + .getRunnerPostTags() + .stream() + .map(runnerPostTag -> runnerPostTag.getTag().getTagName().getValue()) + .toList(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterResponseTestVersion.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterResponseTestVersion.java new file mode 100644 index 000000000..33ca01c86 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterResponseTestVersion.java @@ -0,0 +1,16 @@ +package touch.baton.domain.runnerpost.controller.response; + +import touch.baton.domain.supporter.Supporter; + +public record SupporterResponseTestVersion() { + + public record Simple(Long supporterId, String name) { + + public static Simple fromTestVersion(final Supporter supporter) { + return new Simple( + supporter.getId(), + supporter.getMember().getMemberName().getValue() + ); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponse.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponse.java new file mode 100644 index 000000000..0edf5f814 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponse.java @@ -0,0 +1,37 @@ +package touch.baton.domain.runnerpost.controller.response; + +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; + +import java.util.List; + +public record SupporterRunnerPostResponse() { + + public record Detail(Long supporterId, + String name, + String company, + int reviewCount, + String imageUrl, + String message, + List technicalTags + ) { + + public static Detail from(final SupporterRunnerPost supporterRunnerPost) { + return new Detail( + supporterRunnerPost.getSupporter().getId(), + supporterRunnerPost.getSupporter().getMember().getMemberName().getValue(), + supporterRunnerPost.getSupporter().getMember().getCompany().getValue(), + supporterRunnerPost.getSupporter().getReviewCount().getValue(), + supporterRunnerPost.getSupporter().getMember().getImageUrl().getValue(), + supporterRunnerPost.getMessage().getValue(), + convertToTechnicalTags(supporterRunnerPost.getSupporter()) + ); + } + + private static List convertToTechnicalTags(final Supporter supporter) { + return supporter.getSupporterTechnicalTags().getSupporterTechnicalTags().stream() + .map(supporterTechnicalTag -> supporterTechnicalTag.getTechnicalTag().getTagName().getValue()) + .toList(); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponses.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponses.java new file mode 100644 index 000000000..0365ac646 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/controller/response/SupporterRunnerPostResponses.java @@ -0,0 +1,13 @@ +package touch.baton.domain.runnerpost.controller.response; + +import java.util.List; + +public record SupporterRunnerPostResponses() { + + public record Detail(List data) { + + public static Detail from(final List data) { + return new Detail(data); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostBusinessException.java new file mode 100644 index 000000000..f2fd5fc64 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.runnerpost.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class RunnerPostBusinessException extends BusinessException { + + public RunnerPostBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostDomainException.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostDomainException.java new file mode 100644 index 000000000..b17187b29 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.runnerpost.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class RunnerPostDomainException extends DomainException { + + public RunnerPostDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostRequestException.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostRequestException.java new file mode 100644 index 000000000..8418f5f49 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/RunnerPostRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.runnerpost.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class RunnerPostRequestException extends ClientRequestException { + + public RunnerPostRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/FutureValidator.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/FutureValidator.java new file mode 100644 index 000000000..ae9ac82f3 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/FutureValidator.java @@ -0,0 +1,27 @@ +package touch.baton.domain.runnerpost.exception.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +import java.time.LocalDateTime; +import java.util.Objects; + +public class FutureValidator implements ConstraintValidator { + + private ClientErrorCode errorCode; + + @Override + public void initialize(final ValidFuture constraintAnnotation) { + errorCode = constraintAnnotation.clientErrorCode(); + } + + @Override + public boolean isValid(final LocalDateTime value, final ConstraintValidatorContext context) { + if (Objects.nonNull(value) && value.isBefore(LocalDateTime.now())) { + throw new ClientRequestException(errorCode); + } + return true; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/MaxLengthValidator.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/MaxLengthValidator.java new file mode 100644 index 000000000..c13fdade3 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/MaxLengthValidator.java @@ -0,0 +1,28 @@ +package touch.baton.domain.runnerpost.exception.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +import java.util.Objects; + +public class MaxLengthValidator implements ConstraintValidator { + + private ClientErrorCode errorCode; + private int max; + + @Override + public void initialize(final ValidMaxLength constraintAnnotation) { + errorCode = constraintAnnotation.clientErrorCode(); + max = constraintAnnotation.max(); + } + + @Override + public boolean isValid(final String value, final ConstraintValidatorContext context) { + if (Objects.nonNull(value) && value.length() > max) { + throw new ClientRequestException(errorCode); + } + return true; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/UrlValidator.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/UrlValidator.java new file mode 100644 index 000000000..6848e3dad --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/UrlValidator.java @@ -0,0 +1,34 @@ +package touch.baton.domain.runnerpost.exception.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +import java.util.Objects; +import java.util.regex.Pattern; + +public class UrlValidator implements ConstraintValidator { + + private static final Pattern urlPattern = Pattern.compile("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#()?&//=]*)"); + + private ClientErrorCode errorCode; + + @Override + public void initialize(final ValidNotUrl constraintAnnotation) { + errorCode = constraintAnnotation.clientErrorCode(); + } + + @Override + public boolean isValid(final String value, final ConstraintValidatorContext context) { + if (Objects.nonNull(value) && isNotUrl(value)) { + throw new ClientRequestException(errorCode); + } + + return true; + } + + private boolean isNotUrl(final String value) { + return !urlPattern.matcher(value).matches(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidFuture.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidFuture.java new file mode 100644 index 000000000..a20f50c71 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidFuture.java @@ -0,0 +1,28 @@ +package touch.baton.domain.runnerpost.exception.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import touch.baton.domain.common.exception.ClientErrorCode; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({FIELD, PARAMETER}) +@Retention(RUNTIME) +@Documented +@Constraint(validatedBy = FutureValidator.class) +public @interface ValidFuture { + + String message() default "마감일은 오늘보다 과거일 수 없습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + ClientErrorCode clientErrorCode(); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidMaxLength.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidMaxLength.java new file mode 100644 index 000000000..831ca6c3f --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidMaxLength.java @@ -0,0 +1,30 @@ +package touch.baton.domain.runnerpost.exception.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import touch.baton.domain.common.exception.ClientErrorCode; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({FIELD, PARAMETER}) +@Retention(RUNTIME) +@Documented +@Constraint(validatedBy = MaxLengthValidator.class) +public @interface ValidMaxLength { + + String message() default "길이가 잘못되었습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + ClientErrorCode clientErrorCode(); + + int max(); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidNotUrl.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidNotUrl.java new file mode 100644 index 000000000..7579b2331 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/exception/validator/ValidNotUrl.java @@ -0,0 +1,28 @@ +package touch.baton.domain.runnerpost.exception.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import touch.baton.domain.common.exception.ClientErrorCode; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({FIELD, PARAMETER}) +@Retention(RUNTIME) +@Documented +@Constraint(validatedBy = UrlValidator.class) +public @interface ValidNotUrl { + + String message() default "PR 주소가 URL이 아닙니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + ClientErrorCode clientErrorCode(); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/repository/RunnerPostRepository.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/repository/RunnerPostRepository.java new file mode 100644 index 000000000..3312a9e2c --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/repository/RunnerPostRepository.java @@ -0,0 +1,60 @@ +package touch.baton.domain.runnerpost.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.ReviewStatus; + +import java.util.List; +import java.util.Optional; + +public interface RunnerPostRepository extends JpaRepository { + + @Query(value = """ + select rp + from RunnerPost rp + join fetch Runner r on r.id = rp.runner.id + join fetch Member m on m.id = r.member.id + where rp.id = :runnerPostId + """) + Optional joinMemberByRunnerPostId(@Param("runnerPostId") final Long runnerPostId); + + Page findAll(final Pageable pageable); + + @Query(countQuery = """ + select count(1) + from RunnerPost rp + where rp.runner.id = :runnerId + and rp.reviewStatus = :reviewStatus + """) + Page findByRunnerIdAndReviewStatus(final Pageable pageable, + @Param("runnerId") final Long runnerId, + @Param("reviewStatus") final ReviewStatus reviewStatus); + + List findByRunnerId(final Long runnerId); + + @Query(countQuery = """ + select count(1) + from RunnerPost rp + where rp.supporter.id = :supporterId + and rp.reviewStatus = :reviewStatus + """) + Page findBySupporterIdAndReviewStatus(final Pageable pageable, + @Param("supporterId") final Long supporterId, + @Param("reviewStatus") final ReviewStatus reviewStatus); + + @Query(""" + select rp + from RunnerPost rp + join fetch SupporterRunnerPost srp on srp.runnerPost.id = rp.id + where srp.supporter.id = :supporterId + and rp.reviewStatus = :reviewStatus + """) + Page joinSupporterRunnerPostBySupporterIdAndReviewStatus( + final Pageable pageable, + @Param("supporterId") final Long supporterId, + @Param("reviewStatus") final ReviewStatus reviewStatus); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/RunnerPostService.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/RunnerPostService.java new file mode 100644 index 000000000..4390136ff --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/RunnerPostService.java @@ -0,0 +1,233 @@ +package touch.baton.domain.runnerpost.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.exception.RunnerPostBusinessException; +import touch.baton.domain.runnerpost.repository.RunnerPostRepository; +import touch.baton.domain.runnerpost.service.dto.RunnerPostApplicantCreateRequest; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; +import touch.baton.domain.runnerpost.service.dto.RunnerPostUpdateRequest; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.domain.supporter.repository.SupporterRepository; +import touch.baton.domain.supporter.repository.SupporterRunnerPostRepository; +import touch.baton.domain.supporter.vo.Message; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.Tag; +import touch.baton.domain.tag.repository.RunnerPostTagRepository; +import touch.baton.domain.tag.repository.TagRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class RunnerPostService { + + private final RunnerPostRepository runnerPostRepository; + private final RunnerPostTagRepository runnerPostTagRepository; + private final TagRepository tagRepository; + private final SupporterRepository supporterRepository; + private final SupporterRunnerPostRepository supporterRunnerPostRepository; + + @Transactional + public Long createRunnerPost(final Runner runner, final RunnerPostCreateRequest request) { + final RunnerPost runnerPost = toDomain(runner, request); + runnerPostRepository.save(runnerPost); + + final List tags = findTagsAfterSave(request.tags()); + + final List runnerPostTags = tags.stream() + .map(tag -> RunnerPostTag.builder() + .tag(tag) + .runnerPost(runnerPost).build()) + .toList(); + + runnerPost.addAllRunnerPostTags(runnerPostTags); + return runnerPost.getId(); + } + + private RunnerPost toDomain(final Runner runner, final RunnerPostCreateRequest request) { + return RunnerPost.newInstance(request.title(), + request.contents(), + request.pullRequestUrl(), + request.deadline(), + runner); + } + + private List findTagsAfterSave(final List tagNames) { + final List tags = new ArrayList<>(); + for (String tagName : tagNames) { + tagRepository.findByTagName(new TagName(tagName)) + .ifPresentOrElse(tags::add, addTagAfterSave(tags, tagName)); + } + + return tags; + } + + private Runnable addTagAfterSave(final List tags, final String tagName) { + return () -> { + final Tag savedTag = tagRepository.save(Tag.newInstance(tagName)); + tags.add(savedTag); + }; + } + + public RunnerPost readByRunnerPostId(final Long runnerPostId) { + runnerPostTagRepository.joinTagByRunnerPostId(runnerPostId); + return runnerPostRepository.joinMemberByRunnerPostId(runnerPostId) + .orElseThrow(() -> new RunnerPostBusinessException("RunnerPost 의 식별자값으로 러너 게시글을 조회할 수 없습니다.")); + } + + public List readSupporterRunnerPostsByRunnerPostId(final Runner runner, final Long runnerPostId) { + final RunnerPost foundRunnerPost = runnerPostRepository.joinMemberByRunnerPostId(runnerPostId) + .orElseThrow(() -> new RunnerPostBusinessException(("RunnerPost 의 식별자값으로 러너 게시글을 조회할 수 없습니다."))); + + if (foundRunnerPost.isNotOwner(runner)) { + throw new RunnerPostBusinessException("RunnerPost 의 작성자가 아닙니다."); + } + + return supporterRunnerPostRepository.readByRunnerPostId(runnerPostId); + } + + @Transactional + public void increaseWatchedCount(final RunnerPost runnerPost) { + runnerPost.increaseWatchedCount(); + } + + @Transactional + public void deleteByRunnerPostId(final Long runnerPostId, final Runner runner) { + final RunnerPost runnerPost = runnerPostRepository.findById(runnerPostId) + .orElseThrow(() -> new RunnerPostBusinessException("RunnerPost 의 식별자값으로 삭제할 러너 게시글이 존재하지 않습니다.")); + if (runnerPost.isNotOwner(runner)) { + throw new RunnerPostBusinessException("RunnerPost 를 게시한 유저가 아닙니다."); + } + if (runnerPost.isReviewStatusStarted()) { + throw new RunnerPostBusinessException("삭제할 수 없는 상태의 리뷰 상태입니다."); + } + if (supporterRunnerPostRepository.existsByRunnerPostId(runnerPostId)) { + throw new RunnerPostBusinessException("지원자가 존재하여 삭제할 수 없습니다."); + } + runnerPostRepository.deleteById(runnerPostId); + } + + private RunnerPost getRunnerPostOrThrowException(final Long runnerPostId) { + return runnerPostRepository.findById(runnerPostId) + .orElseThrow(() -> new RunnerPostBusinessException("해당 runnerPostId 로 러너 게시글을 찾을 수 없습니다. runnerPostId 를 다시 확인해주세요")); + } + + @Transactional + public Long createRunnerPostApplicant(final Supporter supporter, + final RunnerPostApplicantCreateRequest request, + final Long runnerPostId + ) { + final RunnerPost foundRunnerPost = getRunnerPostOrThrowException(runnerPostId); + final boolean isApplicantHistoryExist = supporterRunnerPostRepository.existsByRunnerPostIdAndSupporterId(runnerPostId, supporter.getId()); + if (isApplicantHistoryExist) { + throw new RunnerPostBusinessException("Supporter 는 이미 해당 RunnerPost 에 리뷰 신청을 한 이력이 있습니다."); + } + + final SupporterRunnerPost runnerPostApplicant = SupporterRunnerPost.builder() + .supporter(supporter) + .runnerPost(foundRunnerPost) + .message(new Message(request.message())) + .build(); + + return supporterRunnerPostRepository.save(runnerPostApplicant).getId(); + } + + public Page readAllRunnerPosts(final Pageable pageable) { + return runnerPostRepository.findAll(pageable); + } + + public List readRunnerPostsByRunnerId(final Long runnerId) { + return runnerPostRepository.findByRunnerId(runnerId); + } + + public Page readRunnerPostsByRunnerIdAndReviewStatus(final Pageable pageable, + final Long runnerId, + final ReviewStatus reviewStatus + ) { + return runnerPostRepository.findByRunnerIdAndReviewStatus(pageable, runnerId, reviewStatus); + } + + public Page readRunnerPostsBySupporterIdAndReviewStatus(final Pageable pageable, + final Long supporterId, + final ReviewStatus reviewStatus + ) { + if (reviewStatus.isSameAsNotStarted()) { + return runnerPostRepository.joinSupporterRunnerPostBySupporterIdAndReviewStatus(pageable, supporterId, reviewStatus); + } + return runnerPostRepository.findBySupporterIdAndReviewStatus(pageable, supporterId, reviewStatus); + } + + public List readCountsByRunnerPostIds(final List runnerPostIds) { + return supporterRunnerPostRepository.countByRunnerPostIds(runnerPostIds); + } + + @Transactional + public void updateRunnerPostReviewStatusDone(final Long runnerPostId, final Supporter supporter) { + final RunnerPost foundRunnerPost = runnerPostRepository.findById(runnerPostId) + .orElseThrow(() -> new RunnerPostBusinessException("해당 식별자의 러너 게시글이 존재하지 않습니다.")); + + if (Objects.isNull(foundRunnerPost.getSupporter())) { + throw new RunnerPostBusinessException("아직 서포터가 배정이 안 된 게시글 입니다."); + } + + if (foundRunnerPost.isDifferentSupporter(supporter)) { + throw new RunnerPostBusinessException("다른 사람이 리뷰 중인 게시글의 상태를 변경할 수 없습니다."); + } + + foundRunnerPost.finishReview(); + } + + public long readCountByRunnerPostId(final Long runnerPostId) { + return supporterRunnerPostRepository.countByRunnerPostId(runnerPostId).orElse(0L); + } + + @Transactional + public void deleteSupporterRunnerPost(final Supporter supporter, final Long runnerPostId) { + final RunnerPost runnerPost = runnerPostRepository.findById(runnerPostId) + .orElseThrow(() -> new RunnerPostBusinessException("존재하지 않는 RunnerPost 입니다.")); + if (!runnerPost.isReviewStatusNotStarted()) { + throw new RunnerPostBusinessException("이미 진행 중인 러너 게시글의 서포터 지원은 철회할 수 없습니다."); + } + supporterRunnerPostRepository.deleteBySupporterIdAndRunnerPostId(supporter.getId(), runnerPostId); + } + + @Transactional + public void updateRunnerPostAppliedSupporter(final Runner runner, + final Long runnerPostId, + final RunnerPostUpdateRequest.SelectSupporter request + ) { + final Supporter foundApplySupporter = supporterRepository.findById(request.supporterId()) + .orElseThrow(() -> new RunnerPostBusinessException("해당하는 식별자값의 서포터를 찾을 수 없습니다.")); + final RunnerPost foundRunnerPost = runnerPostRepository.findById(runnerPostId) + .orElseThrow(() -> new RunnerPostBusinessException("RunnerPost 의 식별자값으로 러너 게시글을 조회할 수 없습니다.")); + + if (isApplySupporter(runnerPostId, foundApplySupporter)) { + throw new RunnerPostBusinessException("게시글에 리뷰를 제안한 서포터가 아닙니다."); + } + if (foundRunnerPost.isNotOwner(runner)) { + throw new RunnerPostBusinessException("RunnerPost 의 글쓴이와 다른 사용자입니다."); + } + + foundRunnerPost.assignSupporter(foundApplySupporter); + } + + private boolean isApplySupporter(final Long runnerPostId, final Supporter foundSupporter) { + return !supporterRunnerPostRepository.existsByRunnerPostIdAndSupporterId(runnerPostId, foundSupporter.getId()); + } + + public boolean existsRunnerPostApplicantByRunnerPostIdAndMemberId(final Long runnerPostId, final Long memberId) { + return supporterRunnerPostRepository.existsByRunnerPostIdAndMemberId(runnerPostId, memberId); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostApplicantCreateRequest.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostApplicantCreateRequest.java new file mode 100644 index 000000000..9a3446635 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostApplicantCreateRequest.java @@ -0,0 +1,9 @@ +package touch.baton.domain.runnerpost.service.dto; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.runnerpost.exception.validator.ValidMaxLength; + +public record RunnerPostApplicantCreateRequest(@ValidMaxLength(clientErrorCode = ClientErrorCode.APPLICANT_MESSAGE_IS_OVERFLOW, max = 500) + String message +) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateRequest.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateRequest.java new file mode 100644 index 000000000..c2fa44f1d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateRequest.java @@ -0,0 +1,34 @@ +package touch.baton.domain.runnerpost.service.dto; + +import touch.baton.domain.common.exception.validator.ValidNotNull; +import touch.baton.domain.runnerpost.exception.validator.ValidFuture; +import touch.baton.domain.runnerpost.exception.validator.ValidMaxLength; +import touch.baton.domain.runnerpost.exception.validator.ValidNotUrl; + +import java.time.LocalDateTime; +import java.util.List; + +import static touch.baton.domain.common.exception.ClientErrorCode.CONTENTS_ARE_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.CONTENTS_OVERFLOW; +import static touch.baton.domain.common.exception.ClientErrorCode.DEADLINE_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.PAST_DEADLINE; +import static touch.baton.domain.common.exception.ClientErrorCode.PULL_REQUEST_URL_IS_NOT_URL; +import static touch.baton.domain.common.exception.ClientErrorCode.PULL_REQUEST_URL_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.TAGS_ARE_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.TITLE_IS_NULL; + +public record RunnerPostCreateRequest(@ValidNotNull(clientErrorCode = TITLE_IS_NULL) + String title, + @ValidNotNull(clientErrorCode = TAGS_ARE_NULL) + List tags, + @ValidNotNull(clientErrorCode = PULL_REQUEST_URL_IS_NULL) + @ValidNotUrl(clientErrorCode = PULL_REQUEST_URL_IS_NOT_URL) + String pullRequestUrl, + @ValidNotNull(clientErrorCode = DEADLINE_IS_NULL) + @ValidFuture(clientErrorCode = PAST_DEADLINE) + LocalDateTime deadline, + @ValidNotNull(clientErrorCode = CONTENTS_ARE_NULL) + @ValidMaxLength(clientErrorCode = CONTENTS_OVERFLOW, max = 1000) + String contents +) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateTestRequest.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateTestRequest.java new file mode 100644 index 000000000..2ccf42d03 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostCreateTestRequest.java @@ -0,0 +1,13 @@ +package touch.baton.domain.runnerpost.service.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record RunnerPostCreateTestRequest(String title, + List tags, + String pullRequestUrl, + LocalDateTime deadline, + String contents, + Long supporterId +) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostUpdateRequest.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostUpdateRequest.java new file mode 100644 index 000000000..5a17a8a99 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/service/dto/RunnerPostUpdateRequest.java @@ -0,0 +1,32 @@ +package touch.baton.domain.runnerpost.service.dto; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.validator.ValidNotNull; +import touch.baton.domain.runnerpost.exception.validator.ValidFuture; +import touch.baton.domain.runnerpost.exception.validator.ValidMaxLength; + +import java.time.LocalDateTime; +import java.util.List; + +public record RunnerPostUpdateRequest() { + + public record Default(@ValidNotNull(clientErrorCode = ClientErrorCode.TITLE_IS_NULL) + String title, + @ValidNotNull(clientErrorCode = ClientErrorCode.TAGS_ARE_NULL) + List tags, + @ValidNotNull(clientErrorCode = ClientErrorCode.PULL_REQUEST_URL_IS_NULL) + String pullRequestUrl, + @ValidNotNull(clientErrorCode = ClientErrorCode.DEADLINE_IS_NULL) + @ValidFuture(clientErrorCode = ClientErrorCode.PAST_DEADLINE) + LocalDateTime deadline, + @ValidNotNull(clientErrorCode = ClientErrorCode.CONTENTS_ARE_NULL) + @ValidMaxLength(clientErrorCode = ClientErrorCode.CONTENTS_OVERFLOW, max = 1000) + String contents + ) { + } + + public record SelectSupporter(@ValidNotNull(clientErrorCode = ClientErrorCode.ASSIGN_SUPPORTER_ID_IS_NULL) + Long supporterId + ) { + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/Deadline.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/Deadline.java new file mode 100644 index 000000000..14cb5fff4 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/Deadline.java @@ -0,0 +1,38 @@ +package touch.baton.domain.runnerpost.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +import static java.time.temporal.ChronoUnit.MINUTES; +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class Deadline { + + @Column(name = "deadline", nullable = false) + private LocalDateTime value; + + public Deadline(final LocalDateTime value) { + validateNotNull(value); + this.value = value.truncatedTo(MINUTES); + } + + private void validateNotNull(final LocalDateTime value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("Deadline 객체 내부에 deadline 은 null 일 수 없습니다."); + } + } + + public boolean isEnd() { + return value.isBefore(LocalDateTime.now()); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/PullRequestUrl.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/PullRequestUrl.java new file mode 100644 index 000000000..ece053285 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/PullRequestUrl.java @@ -0,0 +1,34 @@ +package touch.baton.domain.runnerpost.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class PullRequestUrl { + + private static final int MAXIMUM_URL_LENGTH = 2083; + + @Column(name = "pull_request_url", nullable = false, length = MAXIMUM_URL_LENGTH) + private String value; + + public PullRequestUrl(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("PullRequestUrl 객체 내부에 pull request url 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/ReviewStatus.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/ReviewStatus.java new file mode 100644 index 000000000..da8916bb6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/vo/ReviewStatus.java @@ -0,0 +1,35 @@ +package touch.baton.domain.runnerpost.vo; + +import static java.util.Locale.ENGLISH; + +public enum ReviewStatus { + + NOT_STARTED, + IN_PROGRESS, + DONE, + OVERDUE; + + public static ReviewStatus from(final String name) { + return ReviewStatus.valueOf(name.toUpperCase(ENGLISH)); + } + + public boolean isSame(final ReviewStatus reviewStatus) { + return this == reviewStatus; + } + + public boolean isSameAsNotStarted() { + return this == NOT_STARTED; + } + + public boolean isNotSameAsNotStarted() { + return this != NOT_STARTED; + } + + public boolean isOverdue() { + return this == OVERDUE; + } + + public boolean isNotStarted() { + return this == NOT_STARTED; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/Supporter.java b/backend/baton/src/main/java/touch/baton/domain/supporter/Supporter.java new file mode 100644 index 000000000..7227d30ca --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/Supporter.java @@ -0,0 +1,136 @@ +package touch.baton.domain.supporter; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.BaseEntity; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.supporter.exception.SupporterDomainException; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.technicaltag.SupporterTechnicalTag; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; + +import java.util.List; +import java.util.Objects; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Supporter extends BaseEntity { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Embedded + private ReviewCount reviewCount; + + @Embedded + private Introduction introduction; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "fk_supporter_to_member"), nullable = false) + private Member member; + + @Embedded + private SupporterTechnicalTags supporterTechnicalTags; + + @Builder + private Supporter(final ReviewCount reviewCount, + final Member member, + final SupporterTechnicalTags supporterTechnicalTags + ) { + this(null, reviewCount, Introduction.getDefaultIntroduction(), member, supporterTechnicalTags); + } + + private Supporter(final Long id, + final ReviewCount reviewCount, + final Introduction introduction, + final Member member, + final SupporterTechnicalTags supporterTechnicalTags + ) { + validateNotNull(reviewCount, member, supporterTechnicalTags); + this.id = id; + this.introduction = introduction; + this.reviewCount = reviewCount; + this.member = member; + this.supporterTechnicalTags = supporterTechnicalTags; + } + + private void validateNotNull(final ReviewCount reviewCount, + final Member member, + final SupporterTechnicalTags supporterTechnicalTags + ) { + validateReviewCountNotNull(reviewCount); + validateMemberNotNull(member); + validateSupporterTechnicalTagsNotNull(supporterTechnicalTags); + } + + private void validateReviewCountNotNull(final ReviewCount reviewCount) { + if (Objects.isNull(reviewCount)) { + throw new SupporterDomainException("Supporter 의 reviewCount 는 null 일 수 없습니다."); + } + } + + private void validateMemberNotNull(final Member member) { + if (Objects.isNull(member)) { + throw new SupporterDomainException("Supporter 의 member 는 null 일 수 없습니다."); + } + } + + private void validateSupporterTechnicalTagsNotNull(final SupporterTechnicalTags supporterTechnicalTags) { + if (Objects.isNull(supporterTechnicalTags)) { + throw new SupporterDomainException("Supporter 의 supporterTechnicalTags 는 null 일 수 없습니다."); + } + } + + public void updateIntroduction(final Introduction introduction) { + validateIntroductionNotNull(introduction); + this.introduction = introduction; + } + + private void validateIntroductionNotNull(final Introduction introduction) { + if (Objects.isNull(introduction)) { + throw new SupporterDomainException("Supporter 의 introduction 은 null 일 수 없습니다."); + } + } + + public void addAllSupporterTechnicalTags(final List supporterTechnicalTags) { + this.supporterTechnicalTags.addAll(supporterTechnicalTags); + } + + public void updateMemberName(final MemberName memberName) { + this.member.updateMemberName(memberName); + } + + public void updateCompany(final Company company) { + this.member.updateCompany(company); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Supporter supporter = (Supporter) o; + return Objects.equals(id, supporter.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/SupporterRunnerPost.java b/backend/baton/src/main/java/touch/baton/domain/supporter/SupporterRunnerPost.java new file mode 100644 index 000000000..33eda68df --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/SupporterRunnerPost.java @@ -0,0 +1,92 @@ +package touch.baton.domain.supporter; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.BaseEntity; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.exception.RunnerPostDomainException; +import touch.baton.domain.supporter.vo.Message; + +import java.util.Objects; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class SupporterRunnerPost extends BaseEntity { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Embedded + private Message message; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "supporter_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_support_runner_post_to_supporter")) + private Supporter supporter; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "runner_post_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_support_runner_post_to_runner_post")) + private RunnerPost runnerPost; + + @Builder + private SupporterRunnerPost(final Message message, + final Supporter supporter, + final RunnerPost runnerPost + ) { + this(null, message, supporter, runnerPost); + } + + private SupporterRunnerPost(final Long id, final Message message, final Supporter supporter, final RunnerPost runnerPost) { + validateNotNull(message, supporter, runnerPost); + this.id = id; + this.message = message; + this.supporter = supporter; + this.runnerPost = runnerPost; + } + + private void validateNotNull(final Message message, + final Supporter supporter, + final RunnerPost runnerPost + ) { + if (Objects.isNull(message)) { + throw new RunnerPostDomainException("SupporterRunnerPost 의 message 는 null 일 수 없습니다."); + } + + if (Objects.isNull(supporter)) { + throw new RunnerPostDomainException("SupporterRunnerPost 의 supporter 는 null 일 수 없습니다."); + } + + if (Objects.isNull(runnerPost)) { + throw new RunnerPostDomainException("SupporterRunnerPost 의 runnerPost 는 null 일 수 없습니다."); + } + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (!(o instanceof SupporterRunnerPost that)) return false; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterController.java b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterController.java new file mode 100644 index 000000000..2de7b66df --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterController.java @@ -0,0 +1,29 @@ +package touch.baton.domain.supporter.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import touch.baton.domain.supporter.controller.response.SupporterReadResponses; +import touch.baton.domain.supporter.controller.response.SupporterResponse; +import touch.baton.domain.supporter.service.SupporterService; + +import java.util.List; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/supporters") +@RestController +public class SupporterController { + + private final SupporterService supporterService; + + @GetMapping("/test") + public ResponseEntity readAll() { + final List responses = supporterService.readAllSupporters().stream() + .map(SupporterResponse.Detail::from) + .toList(); + + return ResponseEntity.ok(SupporterReadResponses.NoFiltering.from(responses)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterProfileController.java b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterProfileController.java new file mode 100644 index 000000000..0e9719334 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/SupporterProfileController.java @@ -0,0 +1,50 @@ +package touch.baton.domain.supporter.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; +import touch.baton.domain.oauth.controller.resolver.AuthSupporterPrincipal; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.controller.response.SupporterResponse; +import touch.baton.domain.supporter.service.SupporterService; +import touch.baton.domain.supporter.service.dto.SupporterUpdateRequest; + +import java.net.URI; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/profile/supporter") +@RestController +public class SupporterProfileController { + + private final SupporterService supporterService; + + @GetMapping("/{supporterId}") + public ResponseEntity readProfileBySupporterId(@PathVariable final Long supporterId) { + final Supporter foundSupporter = supporterService.readBySupporterId(supporterId); + final SupporterResponse.Profile response = SupporterResponse.Profile.from(foundSupporter); + + return ResponseEntity.ok(response); + } + + @GetMapping("/me") + public ResponseEntity readSupporterMyProfileByLoginToken(@AuthSupporterPrincipal final Supporter loginedSupporter) { + final SupporterResponse.MyProfile response = SupporterResponse.MyProfile.from(loginedSupporter); + + return ResponseEntity.ok(response); + } + + @PatchMapping("/me") + public ResponseEntity updateProfile(@AuthSupporterPrincipal final Supporter supporter, + @RequestBody @Valid final SupporterUpdateRequest request) { + supporterService.updateSupporter(supporter, request); + final URI redirectUri = UriComponentsBuilder.fromPath("/api/v1/profile/supporter/me").build().toUri(); + return ResponseEntity.noContent().location(redirectUri).build(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterReadResponses.java b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterReadResponses.java new file mode 100644 index 000000000..b27231463 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterReadResponses.java @@ -0,0 +1,13 @@ +package touch.baton.domain.supporter.controller.response; + +import java.util.List; + +public record SupporterReadResponses() { + + public record NoFiltering(List data) { + + public static NoFiltering from(final List data) { + return new SupporterReadResponses.NoFiltering(data); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterResponse.java b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterResponse.java new file mode 100644 index 000000000..69d9d891a --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/controller/response/SupporterResponse.java @@ -0,0 +1,79 @@ +package touch.baton.domain.supporter.controller.response; + +import touch.baton.domain.supporter.Supporter; + +import java.util.List; + +public record SupporterResponse() { + + public record Detail(Long supporterId, + String name, + String company, + int reviewCount, + String githubUrl, + String introduction, + List technicalTags + ) { + + public static Detail from(final Supporter supporter) { + return new Detail( + supporter.getId(), + supporter.getMember().getMemberName().getValue(), + supporter.getMember().getCompany().getValue(), + supporter.getReviewCount().getValue(), + supporter.getMember().getGithubUrl().getValue(), + supporter.getIntroduction().getValue(), + convertToTechnicalTags(supporter) + ); + } + } + + public record Profile(Long supporterId, + String name, + String company, + String imageUrl, + String githubUrl, + String introduction, + List technicalTags + ) { + public static SupporterResponse.Profile from(final Supporter supporter) { + return new SupporterResponse.Profile( + supporter.getId(), + supporter.getMember().getMemberName().getValue(), + supporter.getMember().getCompany().getValue(), + supporter.getMember().getImageUrl().getValue(), + supporter.getMember().getGithubUrl().getValue(), + supporter.getIntroduction().getValue(), + convertToTechnicalTags(supporter) + ); + } + } + + public record MyProfile( + String name, + String imageUrl, + String githubUrl, + String introduction, + String company, + List technicalTags + ) { + + public static MyProfile from(final Supporter supporter) { + return new SupporterResponse.MyProfile( + supporter.getMember().getMemberName().getValue(), + supporter.getMember().getImageUrl().getValue(), + supporter.getMember().getGithubUrl().getValue(), + supporter.getIntroduction().getValue(), + supporter.getMember().getCompany().getValue(), + convertToTechnicalTags(supporter) + ); + } + } + + private static List convertToTechnicalTags(final Supporter supporter) { + return supporter.getSupporterTechnicalTags().getSupporterTechnicalTags() + .stream() + .map(supporterTechnicalTag -> supporterTechnicalTag.getTechnicalTag().getTagName().getValue()) + .toList(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterBusinessException.java new file mode 100644 index 000000000..3eb88cabd --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.supporter.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class SupporterBusinessException extends BusinessException { + + public SupporterBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterDomainException.java b/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterDomainException.java new file mode 100644 index 000000000..0c94c5d8d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.supporter.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class SupporterDomainException extends DomainException { + + public SupporterDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterRequestException.java b/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterRequestException.java new file mode 100644 index 000000000..67b19cec9 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/exception/SupporterRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.supporter.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class SupporterRequestException extends ClientRequestException { + + public SupporterRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRepository.java b/backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRepository.java new file mode 100644 index 000000000..da9865b8e --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRepository.java @@ -0,0 +1,19 @@ +package touch.baton.domain.supporter.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.supporter.Supporter; + +import java.util.Optional; + +public interface SupporterRepository extends JpaRepository { + + @Query(""" + select s + from Supporter s + join fetch Member m on s.member.id = m.id + where s.id = :supporterId + """) + Optional joinMemberBySupporterId(@Param("supporterId") final Long supporterId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepository.java b/backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepository.java new file mode 100644 index 000000000..8d8cd1363 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepository.java @@ -0,0 +1,59 @@ +package touch.baton.domain.supporter.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.supporter.SupporterRunnerPost; + +import java.util.List; +import java.util.Optional; + +public interface SupporterRunnerPostRepository extends JpaRepository { + + @Query(""" + select count(1) + from SupporterRunnerPost srp + group by srp.runnerPost.id + having srp.runnerPost.id in (:runnerPostIds) + """) + List countByRunnerPostIdIn(@Param("runnerPostIds") final List runnerPostIds); + + @Query(""" + select count(1) + from SupporterRunnerPost srp + group by srp.runnerPost.id + having srp.runnerPost.id = :runnerPostId + """) + Optional countByRunnerPostId(@Param("runnerPostId") final Long runnerPostId); + + @Query(""" + select case when exists ( + select 1 from SupporterRunnerPost srp + where srp.runnerPost.id = rp.id) + then ( + select count(srp.id) from SupporterRunnerPost srp + where srp.runnerPost.id = rp.id + ) else 0 end + from RunnerPost rp + where rp.id in :runnerPostIds + order by rp.id desc + """) + List countByRunnerPostIds(@Param("runnerPostIds") final List runnerPostIds); + + @Query(""" + select (count(1) >= 1) + from SupporterRunnerPost srp + join fetch Member m on m.id = srp.supporter.member.id + where srp.runnerPost.id = :runnerPostId + and srp.supporter.member.id = :memberId + """) + boolean existsByRunnerPostIdAndMemberId(@Param("runnerPostId") final Long runnerPostId, @Param("memberId") final Long memberId); + + boolean existsByRunnerPostId(final Long runnerPostId); + + void deleteBySupporterIdAndRunnerPostId(final Long supporterId, final Long runnerPostId); + + boolean existsByRunnerPostIdAndSupporterId(final Long runnerPostId, final Long supporterId); + + List readByRunnerPostId(final Long runnerPostId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/service/SupporterService.java b/backend/baton/src/main/java/touch/baton/domain/supporter/service/SupporterService.java new file mode 100644 index 000000000..935e6b53b --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/service/SupporterService.java @@ -0,0 +1,67 @@ +package touch.baton.domain.supporter.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.exception.SupporterBusinessException; +import touch.baton.domain.supporter.repository.SupporterRepository; +import touch.baton.domain.supporter.service.dto.SupporterUpdateRequest; +import touch.baton.domain.technicaltag.SupporterTechnicalTag; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.domain.technicaltag.repository.SupporterTechnicalTagRepository; +import touch.baton.domain.technicaltag.repository.TechnicalTagRepository; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class SupporterService { + + private final SupporterRepository supporterRepository; + private final TechnicalTagRepository technicalTagRepository; + private final SupporterTechnicalTagRepository supporterTechnicalTagRepository; + + public List readAllSupporters() { + return supporterRepository.findAll(); + } + + public Supporter readBySupporterId(final Long supporterId) { + return supporterRepository.joinMemberBySupporterId(supporterId) + .orElseThrow(() -> new SupporterBusinessException("존재하지 않는 서포터 식별자값으로 조회할 수 없습니다.")); + } + + @Transactional + public void updateSupporter(final Supporter supporter, final SupporterUpdateRequest supporterUpdateRequest) { + supporter.updateMemberName(new MemberName(supporterUpdateRequest.name())); + supporter.updateCompany(new Company(supporterUpdateRequest.company())); + supporter.updateIntroduction(new Introduction(supporterUpdateRequest.introduction())); + supporterTechnicalTagRepository.deleteBySupporter(supporter); + supporterUpdateRequest.technicalTags() + .forEach(tagName -> createSupporterTechnicalTag(supporter, new TagName(tagName))); + } + + private SupporterTechnicalTag createSupporterTechnicalTag(final Supporter supporter, final TagName tagName) { + final TechnicalTag technicalTag = findTechnicalTagIfExistElseCreate(tagName); + return supporterTechnicalTagRepository.save(SupporterTechnicalTag.builder() + .supporter(supporter) + .technicalTag(technicalTag) + .build() + ); + } + + private TechnicalTag findTechnicalTagIfExistElseCreate(final TagName tagName) { + final Optional maybeTechnicalTag = technicalTagRepository.findByTagName(tagName); + return maybeTechnicalTag.orElseGet(() -> + technicalTagRepository.save(TechnicalTag.builder() + .tagName(tagName) + .build() + )); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/service/dto/SupporterUpdateRequest.java b/backend/baton/src/main/java/touch/baton/domain/supporter/service/dto/SupporterUpdateRequest.java new file mode 100644 index 000000000..2161339fc --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/service/dto/SupporterUpdateRequest.java @@ -0,0 +1,21 @@ +package touch.baton.domain.supporter.service.dto; + +import touch.baton.domain.common.exception.validator.ValidNotNull; + +import java.util.List; + +import static touch.baton.domain.common.exception.ClientErrorCode.COMPANY_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.NAME_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.SUPPORTER_INTRODUCTION_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.SUPPORTER_TECHNICAL_TAGS_ARE_NULL; + +public record SupporterUpdateRequest(@ValidNotNull(clientErrorCode = NAME_IS_NULL) + String name, + @ValidNotNull(clientErrorCode = COMPANY_IS_NULL) + String company, + @ValidNotNull(clientErrorCode = SUPPORTER_INTRODUCTION_IS_NULL) + String introduction, + @ValidNotNull(clientErrorCode = SUPPORTER_TECHNICAL_TAGS_ARE_NULL) + List technicalTags +) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/vo/Message.java b/backend/baton/src/main/java/touch/baton/domain/supporter/vo/Message.java new file mode 100644 index 000000000..3228062cb --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/vo/Message.java @@ -0,0 +1,32 @@ +package touch.baton.domain.supporter.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class Message { + + @Column(name = "message", nullable = false) + private String value; + + public Message(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("Message 객체 내부에 message 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/supporter/vo/ReviewCount.java b/backend/baton/src/main/java/touch/baton/domain/supporter/vo/ReviewCount.java new file mode 100644 index 000000000..fe75db747 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/supporter/vo/ReviewCount.java @@ -0,0 +1,27 @@ +package touch.baton.domain.supporter.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class ReviewCount { + + private static final String DEFAULT_VALUE = "0"; + + @ColumnDefault(DEFAULT_VALUE) + @Column(name = "review_count") + private int value; + + public ReviewCount(final int value) { + this.value = value; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTag.java b/backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTag.java new file mode 100644 index 000000000..2c0c6d8af --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTag.java @@ -0,0 +1,65 @@ +package touch.baton.domain.tag; + +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.tag.exception.RunnerPostTagDomainException; + +import java.util.Objects; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class RunnerPostTag { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "runner_post_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_runner_post_tag_to_runner_post")) + private RunnerPost runnerPost; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "tag_id", nullable = false, foreignKey = @ForeignKey(name = "fk_runner_post_tag_to_tag")) + private Tag tag; + + @Builder + private RunnerPostTag(final RunnerPost runnerPost, final Tag tag) { + this(null, runnerPost, tag); + } + + private RunnerPostTag(final Long id, final RunnerPost runnerPost, final Tag tag) { + validateNotNull(runnerPost, tag); + this.id = id; + this.runnerPost = runnerPost; + this.tag = tag; + } + + private void validateNotNull(final RunnerPost runnerPost, final Tag tag) { + if (Objects.isNull(runnerPost)) { + throw new RunnerPostTagDomainException("RunnerPostTag 의 runnerPost 는 null 일 수 없습니다."); + } + + if (Objects.isNull(tag)) { + throw new RunnerPostTagDomainException("RunnerPostTag 의 tag 는 null 일 수 없습니다."); + } + } + + public boolean isSameTagName(final String tagName) { + return tag.isSameTagName(tagName); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTags.java b/backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTags.java new file mode 100644 index 000000000..52dd9e5e7 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/RunnerPostTags.java @@ -0,0 +1,47 @@ +package touch.baton.domain.tag; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static jakarta.persistence.CascadeType.PERSIST; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class RunnerPostTags { + + @OneToMany(mappedBy = "runnerPost", cascade = PERSIST, orphanRemoval = true) + private List runnerPostTags = new ArrayList<>(); + + public RunnerPostTags(final List runnerPostTags) { + this.runnerPostTags = runnerPostTags; + } + + public void add(final RunnerPostTag runnerPostTag) { + runnerPostTags.add(runnerPostTag); + } + + public void addAll(final List runnerPostTags) { + this.runnerPostTags.addAll(runnerPostTags); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final RunnerPostTags that = (RunnerPostTags) o; + return Objects.equals(runnerPostTags, that.runnerPostTags); + } + + @Override + public int hashCode() { + return Objects.hash(runnerPostTags); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/Tag.java b/backend/baton/src/main/java/touch/baton/domain/tag/Tag.java new file mode 100644 index 000000000..bce6189f5 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/Tag.java @@ -0,0 +1,56 @@ +package touch.baton.domain.tag; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.tag.exception.TagDomainException; + +import java.util.Objects; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Tag { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Embedded + private TagName tagName; + + @Builder + private Tag(final TagName tagName) { + this(null, tagName); + } + + private Tag(final Long id, final TagName tagName) { + validateNotNull(tagName); + this.id = id; + this.tagName = tagName; + } + + private void validateNotNull(final TagName tagName) { + if (Objects.isNull(tagName)) { + throw new TagDomainException("Tag 의 tagName 은 null 일 수 없습니다."); + } + } + + public static Tag newInstance(final String tagName) { + return Tag.builder() + .tagName(new TagName(tagName)) + .build(); + } + + public boolean isSameTagName(final String tagName) { + return this.tagName.equals(new TagName(tagName)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/exception/RunnerPostTagDomainException.java b/backend/baton/src/main/java/touch/baton/domain/tag/exception/RunnerPostTagDomainException.java new file mode 100644 index 000000000..88f24ecd1 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/exception/RunnerPostTagDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.tag.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class RunnerPostTagDomainException extends DomainException { + + public RunnerPostTagDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/exception/SupporterTechnicalTagDomainException.java b/backend/baton/src/main/java/touch/baton/domain/tag/exception/SupporterTechnicalTagDomainException.java new file mode 100644 index 000000000..0acbe7fb7 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/exception/SupporterTechnicalTagDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.tag.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class SupporterTechnicalTagDomainException extends DomainException { + + public SupporterTechnicalTagDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagBusinessException.java new file mode 100644 index 000000000..9385e39b0 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.tag.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class TagBusinessException extends BusinessException { + + public TagBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagDomainException.java b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagDomainException.java new file mode 100644 index 000000000..82b156c2e --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.tag.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class TagDomainException extends DomainException { + + public TagDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagRequestException.java b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagRequestException.java new file mode 100644 index 000000000..25b28d85d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TagRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.tag.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class TagRequestException extends ClientRequestException { + + public TagRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/exception/TechnicalTagDomainException.java b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TechnicalTagDomainException.java new file mode 100644 index 000000000..2c9c0d285 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/exception/TechnicalTagDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.tag.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class TechnicalTagDomainException extends DomainException { + + public TechnicalTagDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/repository/RunnerPostTagRepository.java b/backend/baton/src/main/java/touch/baton/domain/tag/repository/RunnerPostTagRepository.java new file mode 100644 index 000000000..a72bd070a --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/repository/RunnerPostTagRepository.java @@ -0,0 +1,19 @@ +package touch.baton.domain.tag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.tag.RunnerPostTag; + +import java.util.List; + +public interface RunnerPostTagRepository extends JpaRepository { + + @Query(""" + select rpt + from RunnerPostTag rpt + join fetch Tag tag on rpt.tag.id = tag.id + where rpt.runnerPost.id = :runnerPostId + """) + List joinTagByRunnerPostId(@Param("runnerPostId") final Long runnerPostId); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/tag/repository/TagRepository.java b/backend/baton/src/main/java/touch/baton/domain/tag/repository/TagRepository.java new file mode 100644 index 000000000..958e52954 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/tag/repository/TagRepository.java @@ -0,0 +1,12 @@ +package touch.baton.domain.tag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.tag.Tag; + +import java.util.Optional; + +public interface TagRepository extends JpaRepository { + + Optional findByTagName(final TagName tagName); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTag.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTag.java new file mode 100644 index 000000000..c0800bddf --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTag.java @@ -0,0 +1,63 @@ +package touch.baton.domain.technicaltag; + +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.tag.exception.SupporterTechnicalTagDomainException; + +import java.util.Objects; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class RunnerTechnicalTag { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "runner_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_runner_technical_tag_to_runner")) + private Runner runner; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "technical_tag_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_runner_technical_tag_to_technical_tag")) + private TechnicalTag technicalTag; + + @Builder + private RunnerTechnicalTag(final Runner runner, final TechnicalTag technicalTag) { + this(null, runner, technicalTag); + } + + private RunnerTechnicalTag(final Long id, final Runner runner, final TechnicalTag technicalTag) { + validateNotNull(runner, technicalTag); + this.id = id; + this.runner = runner; + this.technicalTag = technicalTag; + } + + private void validateNotNull(final Runner runner, final TechnicalTag technicalTag) { + if (Objects.isNull(runner)) { + throw new SupporterTechnicalTagDomainException("RunnerTechnicalTag 의 runner 는 null 일 수 없습니다."); + } + + if (Objects.isNull(technicalTag)) { + throw new SupporterTechnicalTagDomainException("RunnerTechnicalTag 의 technicalTag 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTags.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTags.java new file mode 100644 index 000000000..f76ab5c65 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/RunnerTechnicalTags.java @@ -0,0 +1,29 @@ +package touch.baton.domain.technicaltag; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +import static jakarta.persistence.CascadeType.PERSIST; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class RunnerTechnicalTags { + + @OneToMany(mappedBy = "runner", cascade = PERSIST, orphanRemoval = true) + private List runnerTechnicalTags = new ArrayList<>(); + + public RunnerTechnicalTags(final List runnerTechnicalTags) { + this.runnerTechnicalTags = runnerTechnicalTags; + } + + public void addAll(final List runnerTechnicalTags) { + this.runnerTechnicalTags.addAll(runnerTechnicalTags); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTag.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTag.java new file mode 100644 index 000000000..645252353 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTag.java @@ -0,0 +1,63 @@ +package touch.baton.domain.technicaltag; + +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.tag.exception.SupporterTechnicalTagDomainException; + +import java.util.Objects; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class SupporterTechnicalTag { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "supporter_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_supporter_technical_tag_to_supporter")) + private Supporter supporter; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "technical_tag_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_supporter_technical_tag_to_technical_tag")) + private TechnicalTag technicalTag; + + @Builder + private SupporterTechnicalTag(final Supporter supporter, final TechnicalTag technicalTag) { + this(null, supporter, technicalTag); + } + + private SupporterTechnicalTag(final Long id, final Supporter supporter, final TechnicalTag technicalTag) { + validateNotNull(supporter, technicalTag); + this.id = id; + this.supporter = supporter; + this.technicalTag = technicalTag; + } + + private void validateNotNull(final Supporter supporter, final TechnicalTag technicalTag) { + if (Objects.isNull(supporter)) { + throw new SupporterTechnicalTagDomainException("SupporterTechnicalTag 의 supporter 는 null 일 수 없습니다."); + } + + if (Objects.isNull(technicalTag)) { + throw new SupporterTechnicalTagDomainException("SupporterTechnicalTag 의 technicalTag 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTags.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTags.java new file mode 100644 index 000000000..6b9e72ca2 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/SupporterTechnicalTags.java @@ -0,0 +1,29 @@ +package touch.baton.domain.technicaltag; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +import static jakarta.persistence.CascadeType.PERSIST; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class SupporterTechnicalTags { + + @OneToMany(mappedBy = "supporter", cascade = PERSIST, orphanRemoval = true) + private List supporterTechnicalTags = new ArrayList<>(); + + public SupporterTechnicalTags(final List supporterTechnicalTags) { + this.supporterTechnicalTags = supporterTechnicalTags; + } + + public void addAll(final List supporterTechnicalTags) { + this.supporterTechnicalTags.addAll(supporterTechnicalTags); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/TechnicalTag.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/TechnicalTag.java new file mode 100644 index 000000000..cffffce11 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/TechnicalTag.java @@ -0,0 +1,46 @@ +package touch.baton.domain.technicaltag; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.tag.exception.TechnicalTagDomainException; + +import java.util.Objects; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class TechnicalTag { + + @GeneratedValue(strategy = IDENTITY) + @Id + private Long id; + + @Embedded + private TagName tagName; + + @Builder + private TechnicalTag(final TagName tagName) { + this(null, tagName); + } + + private TechnicalTag(final Long id, final TagName tagName) { + validateNotNull(tagName); + this.id = id; + this.tagName = tagName; + } + + private void validateNotNull(final TagName tagName) { + if (Objects.isNull(tagName)) { + throw new TechnicalTagDomainException("TechnicalTag 의 tagName 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/RunnerTechnicalTagRepository.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/RunnerTechnicalTagRepository.java new file mode 100644 index 000000000..717f40479 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/RunnerTechnicalTagRepository.java @@ -0,0 +1,15 @@ +package touch.baton.domain.technicaltag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.technicaltag.RunnerTechnicalTag; + +public interface RunnerTechnicalTagRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM RunnerTechnicalTag rt WHERE rt.runner = :runner") + int deleteByRunner(@Param("runner") final Runner runner); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepository.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepository.java new file mode 100644 index 000000000..ace8e6c7f --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepository.java @@ -0,0 +1,15 @@ +package touch.baton.domain.technicaltag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.technicaltag.SupporterTechnicalTag; + +public interface SupporterTechnicalTagRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM SupporterTechnicalTag st WHERE st.supporter = :supporter") + int deleteBySupporter(@Param("supporter") final Supporter supporter); +} diff --git a/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepository.java b/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepository.java new file mode 100644 index 000000000..681c35827 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepository.java @@ -0,0 +1,12 @@ +package touch.baton.domain.technicaltag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.technicaltag.TechnicalTag; + +import java.util.Optional; + +public interface TechnicalTagRepository extends JpaRepository { + + Optional findByTagName(final TagName tagName); +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtConfig.java b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtConfig.java new file mode 100644 index 000000000..49bfcf2fc --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtConfig.java @@ -0,0 +1,25 @@ +package touch.baton.infra.auth.jwt; + +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.security.Key; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@RequiredArgsConstructor +@ConfigurationProperties("jwt.token") +public class JwtConfig { + + private final String secretKey; + private final String issuer; + + public Key getSecretKey() { + return Keys.hmacShaKeyFor(secretKey.getBytes(UTF_8)); + } + + public String getIssuer() { + return this.issuer; + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtDecoder.java b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtDecoder.java new file mode 100644 index 000000000..7d64c6c66 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtDecoder.java @@ -0,0 +1,37 @@ +package touch.baton.infra.auth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.IncorrectClaimException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.MissingClaimException; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.exception.OauthRequestException; + +@RequiredArgsConstructor +@Component +public class JwtDecoder { + + private final JwtConfig jwtConfig; + + public Claims parseJwtToken(final String token) { + try { + final JwtParser jwtParser = Jwts.parserBuilder() + .setSigningKey(jwtConfig.getSecretKey()) + .requireIssuer(jwtConfig.getIssuer()) + .build(); + + return jwtParser.parseClaimsJws(token).getBody(); + } catch (SignatureException e) { + throw new OauthRequestException(ClientErrorCode.JWT_SIGNATURE_IS_WRONG); + } catch (MalformedJwtException e) { + throw new OauthRequestException(ClientErrorCode.JWT_FORM_IS_WRONG); + } catch (MissingClaimException | IncorrectClaimException e) { + throw new OauthRequestException(ClientErrorCode.JWT_CLAIM_IS_WRONG); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtEncoder.java b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtEncoder.java new file mode 100644 index 000000000..a351c2e88 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/jwt/JwtEncoder.java @@ -0,0 +1,36 @@ +package touch.baton.infra.auth.jwt; + + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Date; +import java.util.Map; + +@RequiredArgsConstructor +@Component +public class JwtEncoder { + + private final JwtConfig jwtConfig; + + public String jwtToken(final Map payload) { + final Date now = new Date(); + final Date expiration = new Date(now.getTime() + Duration.ofDays(30).toMillis()); + final Claims claims = Jwts.claims(); + + final JwtBuilder jwtBuilder = Jwts.builder() + .signWith(jwtConfig.getSecretKey(), SignatureAlgorithm.HS256) + .setIssuer(jwtConfig.getIssuer()) + .setIssuedAt(now) + .setExpiration(expiration) + .addClaims(claims) + .addClaims(payload); + + return jwtBuilder.compact(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/GithubOauthConfig.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/GithubOauthConfig.java new file mode 100644 index 000000000..a72db83e8 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/GithubOauthConfig.java @@ -0,0 +1,11 @@ +package touch.baton.infra.auth.oauth.github; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.github") +public record GithubOauthConfig(String redirectUri, + String clientId, + String clientSecret, + String[] scope +) { +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/authcode/GithubAuthCodeRequestUrlProvider.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/authcode/GithubAuthCodeRequestUrlProvider.java new file mode 100644 index 000000000..1fed7b572 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/authcode/GithubAuthCodeRequestUrlProvider.java @@ -0,0 +1,31 @@ +package touch.baton.infra.auth.oauth.github.authcode; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.authcode.AuthCodeRequestUrlProvider; +import touch.baton.infra.auth.oauth.github.GithubOauthConfig; + +@RequiredArgsConstructor +@Component +public class GithubAuthCodeRequestUrlProvider implements AuthCodeRequestUrlProvider { + + private final GithubOauthConfig githubOauthConfig; + + @Override + public OauthType oauthServer() { + return OauthType.GITHUB; + } + + @Override + public String getRequestUrl() { + return UriComponentsBuilder + .fromUriString("https://github.com/login/oauth/authorize") + .queryParam("response_type", "code") + .queryParam("client_id", githubOauthConfig.clientId()) + .queryParam("redirect_uri", githubOauthConfig.redirectUri()) + .queryParam("scope", String.join(",", githubOauthConfig.scope())) + .toUriString(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/client/GithubInformationClient.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/client/GithubInformationClient.java new file mode 100644 index 000000000..664d90d21 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/client/GithubInformationClient.java @@ -0,0 +1,41 @@ +package touch.baton.infra.auth.oauth.github.client; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.OauthType; +import touch.baton.domain.oauth.client.OauthInformationClient; +import touch.baton.infra.auth.oauth.github.GithubOauthConfig; +import touch.baton.infra.auth.oauth.github.http.GithubHttpInterface; +import touch.baton.infra.auth.oauth.github.request.GithubTokenRequest; +import touch.baton.infra.auth.oauth.github.response.GithubMemberResponse; +import touch.baton.infra.auth.oauth.github.token.GithubToken; + +@RequiredArgsConstructor +@Component +public class GithubInformationClient implements OauthInformationClient { + + private final GithubHttpInterface githubHttpInterface; + private final GithubOauthConfig githubOauthConfig; + + @Override + public OauthType oauthType() { + return OauthType.GITHUB; + } + + @Override + public OauthInformation fetchInformation(final String authCode) { + final GithubToken githubToken = githubHttpInterface.fetchToken(tokenRequestBody(authCode)); + final GithubMemberResponse githubMemberResponse = githubHttpInterface.fetchMember("Bearer " + githubToken.accessToken()); + + return githubMemberResponse.toOauthInformation(githubToken.accessToken()); + } + + private GithubTokenRequest tokenRequestBody(final String authCode) { + return new GithubTokenRequest( + githubOauthConfig.clientId(), + githubOauthConfig.clientSecret(), + authCode, + githubOauthConfig.redirectUri()); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/http/GithubHttpInterface.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/http/GithubHttpInterface.java new file mode 100644 index 000000000..ffeb62205 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/http/GithubHttpInterface.java @@ -0,0 +1,21 @@ +package touch.baton.infra.auth.oauth.github.http; + +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; +import touch.baton.infra.auth.oauth.github.request.GithubTokenRequest; +import touch.baton.infra.auth.oauth.github.response.GithubMemberResponse; +import touch.baton.infra.auth.oauth.github.token.GithubToken; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +public interface GithubHttpInterface { + + @PostExchange(url = "https://github.com/login/oauth/access_token", accept = APPLICATION_JSON_VALUE) + GithubToken fetchToken(@RequestBody GithubTokenRequest request); + + @GetExchange("https://api.github.com/user") + GithubMemberResponse fetchMember(@RequestHeader(name = AUTHORIZATION) String bearerToken); +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/request/GithubTokenRequest.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/request/GithubTokenRequest.java new file mode 100644 index 000000000..d4953742d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/request/GithubTokenRequest.java @@ -0,0 +1,13 @@ +package touch.baton.infra.auth.oauth.github.request; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +@JsonNaming(SnakeCaseStrategy.class) +public record GithubTokenRequest(String clientId, + String clientSecret, + String code, + String redirectUri +) { +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/response/GithubMemberResponse.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/response/GithubMemberResponse.java new file mode 100644 index 000000000..a0012f1b9 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/response/GithubMemberResponse.java @@ -0,0 +1,32 @@ +package touch.baton.infra.auth.oauth.github.response; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.oauth.OauthInformation; +import touch.baton.domain.oauth.SocialToken; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +@JsonNaming(SnakeCaseStrategy.class) +public record GithubMemberResponse(String id, + String name, + String login, + String htmlUrl, + String avatarUrl +) { + + public OauthInformation toOauthInformation(final String accessToken) { + return OauthInformation.builder() + .socialToken(new SocialToken(accessToken)) + .oauthId(new OauthId(id)) + .memberName(new MemberName(name)) + .socialId(new SocialId(login)) + .githubUrl(new GithubUrl(htmlUrl)) + .imageUrl(new ImageUrl(avatarUrl)) + .build(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/token/GithubToken.java b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/token/GithubToken.java new file mode 100644 index 000000000..011523932 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/infra/auth/oauth/github/token/GithubToken.java @@ -0,0 +1,11 @@ +package touch.baton.infra.auth.oauth.github.token; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +@JsonNaming(SnakeCaseStrategy.class) +public record GithubToken(String tokenType, + String accessToken +) { +} diff --git a/backend/baton/src/main/resources/application.yml b/backend/baton/src/main/resources/application.yml new file mode 100644 index 000000000..8e89b7285 --- /dev/null +++ b/backend/baton/src/main/resources/application.yml @@ -0,0 +1,36 @@ +spring: + flyway: + enabled: false + jpa: + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create + + data: + web: + pageable: + default-page-size: 10 + one-indexed-parameters: true + +logging: + config: classpath:logs/log4j2.xml + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + +oauth: + github: + client_id: ${OAUTH_GITHUB_CLIENT_ID} + redirect_uri: ${OAUTH_GITHUB_REDIRECT_URI} + client_secret: ${OAUTH_GITHUB_CLIENT_SECRET} + scope: ${OAUTH_GITHUB_SCOPE} + +cors: + allowed-origin: http://localhost:3000 + +jwt: + token: + secret_key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} diff --git a/backend/baton/src/main/resources/logs/log4j2.xml b/backend/baton/src/main/resources/logs/log4j2.xml new file mode 100644 index 000000000..94e42150e --- /dev/null +++ b/backend/baton/src/main/resources/logs/log4j2.xml @@ -0,0 +1,85 @@ + + + + daily-log + warn-log + error-log + logs + %style{%d{ISO8601}}{white} %highlight{%-5level }[%style{%t}{bright,blue}] %style{%C{1.}}{bright,yellow}: %msg%n%throwable + [%equals{%X{request_id}}{}{startup}] %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n + + + + + + + + + ${BASE_DIR}/${FILE_NAME}.log + ${BASE_DIR}/${FILE_NAME}-%d{yyyy-MM-dd-hh-mm}.log + + + + + + + + + + + + + + + ${BASE_DIR}/${WARN_FILE_NAME}.log + ${BASE_DIR}/${WARN_FILE_NAME}-%d{yyyy-MM-dd-hh-mm}.log + + + + + + + + + + + + + + + + ${BASE_DIR}/${ERROR_FILE_NAME}.log + ${BASE_DIR}/${ERROR_FILE_NAME}-%d{yyyy-MM-dd-hh-mm}.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/baton/src/test/java/touch/baton/BatonApplicationTests.java b/backend/baton/src/test/java/touch/baton/BatonApplicationTests.java new file mode 100644 index 000000000..5c7455c6c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/BatonApplicationTests.java @@ -0,0 +1,13 @@ +package touch.baton; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BatonApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/baton/src/test/java/touch/baton/TestBatonApplication.java b/backend/baton/src/test/java/touch/baton/TestBatonApplication.java new file mode 100644 index 000000000..49aeee199 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/TestBatonApplication.java @@ -0,0 +1,21 @@ +package touch.baton; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.MySQLContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class TestBatonApplication { + + @Bean + @ServiceConnection + MySQLContainer mysqlContainer() { + return new MySQLContainer<>("mysql:latest"); + } + + public static void main(String[] args) { + SpringApplication.from(BatonApplication::main).with(TestBatonApplication.class).run(args); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java new file mode 100644 index 000000000..b6a60dd32 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java @@ -0,0 +1,198 @@ +package touch.baton.assure.common; + +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +import java.util.Map; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +public class AssuredSupport { + + public static ExtractableResponse post(final String uri, final Object params) { + return RestAssured + .given().log().ifValidationFails() + .contentType(APPLICATION_JSON_VALUE) + .body(params) + .when().log().ifValidationFails() + .post(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse post(final String uri, final String accessToken, final Object body) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .contentType(APPLICATION_JSON_VALUE) + .body(body) + .when().log().ifValidationFails() + .post(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse post(final String uri, + final String accessToken, + final Map pathVariables, + final Object requestBody + ) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .contentType(APPLICATION_JSON_VALUE) + .pathParams(pathVariables) + .body(requestBody) + .when().log().ifValidationFails() + .post(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse post(final String uri, final Object params, final String accessToken) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .contentType(APPLICATION_JSON_VALUE) + .body(params) + .when().log().ifValidationFails() + .post(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse get(final String uri, final String accessToken) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .when().log().ifValidationFails() + .get(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse get(final String uri, + final String pathParamName, + final Long id, + final String accessToken + ) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .pathParam(pathParamName, id) + .when().log().ifValidationFails() + .get(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse get(final String uri, final String pathParamName, final Long id) { + return RestAssured + .given().log().ifValidationFails() + .pathParam(pathParamName, id) + .when().log().ifValidationFails() + .get(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse get(final String uri, final Map queryParams) { + return RestAssured + .given().log().ifValidationFails() + .contentType(APPLICATION_JSON_VALUE) + .queryParams(queryParams) + .when().log().ifValidationFails() + .get(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse get(final String uri, final String accessToken, final Map queryParams) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .contentType(APPLICATION_JSON_VALUE) + .queryParams(queryParams) + .when().log().ifValidationFails() + .get(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse get(final String uri, final Map queryParams, final String accessToken) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .contentType(APPLICATION_JSON_VALUE) + .queryParams(queryParams) + .when().log().ifValidationFails() + .get(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse patch(final String uri, + final String pathParamName, + final Long id, + final Object requestBody, + final String accessToken + ) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .contentType(APPLICATION_JSON_VALUE) + .pathParam(pathParamName, id) + .body(requestBody) + .when().log().ifValidationFails() + .patch(uri) + .then().log().ifError() + .extract(); + } + + + public static ExtractableResponse patch(final String uri, final String accessToken, final Object requestBody) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .contentType(APPLICATION_JSON_VALUE) + .body(requestBody) + .when().log().ifValidationFails() + .patch(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse patch(final String uri, final String pathParamName, final Long id, final String accessToken) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .pathParam(pathParamName, id) + .when().log().ifValidationFails() + .patch(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse delete(final String uri, final String pathParamName, final Long id) { + return RestAssured + .given().log().ifValidationFails() + .pathParam(pathParamName, id) + .contentType(APPLICATION_JSON_VALUE) + .when().log().ifValidationFails() + .delete(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse delete(final String uri, final String accessToken, final String pathParamName, final Long id) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .pathParam(pathParamName, id) + .when().log().ifValidationFails() + .delete(uri) + .then().log().ifError() + .extract(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/common/HttpStatusAndLocationHeader.java b/backend/baton/src/test/java/touch/baton/assure/common/HttpStatusAndLocationHeader.java new file mode 100644 index 000000000..cceb29912 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/common/HttpStatusAndLocationHeader.java @@ -0,0 +1,22 @@ +package touch.baton.assure.common; + +import org.springframework.http.HttpStatus; + +public class HttpStatusAndLocationHeader { + + private final HttpStatus httpStatus; + private final String location; + + public HttpStatusAndLocationHeader(final HttpStatus httpStatus, final String location) { + this.httpStatus = httpStatus; + this.location = location; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public String getLocation() { + return location; + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackAssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackAssuredSupport.java new file mode 100644 index 000000000..e2dd8eec1 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackAssuredSupport.java @@ -0,0 +1,58 @@ +package touch.baton.assure.feedback; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpHeaders; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.assure.common.HttpStatusAndLocationHeader; +import touch.baton.domain.feedback.service.SupporterFeedBackCreateRequest; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SuppressWarnings("NonAsciiCharacters") +public class SupporterFeedbackAssuredSupport { + + private SupporterFeedbackAssuredSupport() { + } + + public static SupporterFeedbackClientRequestBuilder 클라이언트_요청() { + return new SupporterFeedbackClientRequestBuilder(); + } + + public static class SupporterFeedbackClientRequestBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public SupporterFeedbackClientRequestBuilder 토큰으로_로그인한다(final String 토큰) { + this.accessToken = 토큰; + return this; + } + + public SupporterFeedbackClientRequestBuilder 서포터_피드백을_등록한다(final SupporterFeedBackCreateRequest 서포터_피드백_정보) { + response = AssuredSupport.post("/api/v1/feedback/supporter", 서포터_피드백_정보, accessToken); + return this; + } + + public SupporterFeedbackServerResponseBuilder 서버_응답() { + return new SupporterFeedbackServerResponseBuilder(response); + } + } + + public static class SupporterFeedbackServerResponseBuilder { + + private final ExtractableResponse response; + + public SupporterFeedbackServerResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public void 서포터_피드백_등록_성공을_검증한다(final HttpStatusAndLocationHeader 응답상태_및_로케이션) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(응답상태_및_로케이션.getHttpStatus().value()); + softly.assertThat(response.header(HttpHeaders.LOCATION)).contains(응답상태_및_로케이션.getLocation()); + }); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackCreateAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackCreateAssuredTest.java new file mode 100644 index 000000000..5950ca7fa --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/feedback/SupporterFeedbackCreateAssuredTest.java @@ -0,0 +1,55 @@ +package touch.baton.assure.feedback; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import touch.baton.assure.common.HttpStatusAndLocationHeader; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.feedback.service.SupporterFeedBackCreateRequest; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.http.HttpStatus.CREATED; +import static touch.baton.fixture.domain.SupporterFixture.create; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +@SuppressWarnings("NonAsciiCharacters") +class SupporterFeedbackCreateAssuredTest extends AssuredTestConfig { + + private static String 토큰; + private Runner 피드백할_러너; + + @BeforeEach + void setUp() { + final String 소셜_아이디 = "hongSile"; + final Member 사용자 = memberRepository.save(MemberFixture.createWithSocialId(소셜_아이디)); + 피드백할_러너 = runnerRepository.save(RunnerFixture.createRunner(사용자)); + 토큰 = login(소셜_아이디); + } + + @Test + void 러너가_서포터_피드백을_등록한다() { + // given + final Member 사용자_에단 = memberRepository.save(MemberFixture.createEthan()); + final Supporter 리뷰해준_서포터 = supporterRepository.save(create(사용자_에단)); + final RunnerPost 리뷰_완료한_게시글 = runnerPostRepository.save(RunnerPostFixture.create(피드백할_러너, 리뷰해준_서포터, deadline(LocalDateTime.now().plusHours(100)))); + + final SupporterFeedBackCreateRequest 서포터_피드백_요청 = new SupporterFeedBackCreateRequest("GOOD", List.of("코드리뷰가 맛있어요.", "말투가 친절해요."), 리뷰해준_서포터.getId(), 리뷰_완료한_게시글.getId()); + + // when, then + SupporterFeedbackAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .서포터_피드백을_등록한다(서포터_피드백_요청) + + .서버_응답() + .서포터_피드백_등록_성공을_검증한다(new HttpStatusAndLocationHeader(CREATED, "/api/v1/feedback/supporter")); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/member/MemberAssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/member/MemberAssuredSupport.java new file mode 100644 index 000000000..3dd5d0062 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/member/MemberAssuredSupport.java @@ -0,0 +1,63 @@ +package touch.baton.assure.member; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.controller.response.LoginMemberInfoResponse; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SuppressWarnings("NonAsciiCharacters") +public class MemberAssuredSupport { + + private MemberAssuredSupport() { + } + + public static MemberClientRequestBuilder 클라이언트_요청() { + return new MemberClientRequestBuilder(); + } + + public static LoginMemberInfoResponse 로그인한_사용자_프로필_응답(final Member 맴버) { + return LoginMemberInfoResponse.from(맴버); + } + + public static class MemberClientRequestBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public MemberClientRequestBuilder 로그인_한다(final String 토큰) { + accessToken = 토큰; + return this; + } + + public MemberClientRequestBuilder 사용자_본인_프로필을_가지고_있는_토큰으로_조회한다() { + response = AssuredSupport.get("api/v1/profile/me", accessToken); + return this; + } + + public MemberServerResponseBuilder 서버_응답() { + return new MemberServerResponseBuilder(response); + } + } + + public static class MemberServerResponseBuilder { + + private final ExtractableResponse response; + + public MemberServerResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public void 로그인한_사용자_프로필_조회_성공을_검증한다(final LoginMemberInfoResponse 맴버_로그인_프로필_응답) { + final LoginMemberInfoResponse actual = this.response.as(LoginMemberInfoResponse.class); + + assertSoftly(softly -> { + softly.assertThat(actual.name()).isEqualTo(맴버_로그인_프로필_응답.name()); + softly.assertThat(actual.imageUrl()).isEqualTo(맴버_로그인_프로필_응답.imageUrl()); + }); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/member/MemberReadWithLoginedMemberAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/member/MemberReadWithLoginedMemberAssuredTest.java new file mode 100644 index 000000000..21be39d16 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/member/MemberReadWithLoginedMemberAssuredTest.java @@ -0,0 +1,27 @@ +package touch.baton.assure.member; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.fixture.domain.MemberFixture; + +import static touch.baton.assure.member.MemberAssuredSupport.로그인한_사용자_프로필_응답; + +@SuppressWarnings("NonAsciiCharacters") +public class MemberReadWithLoginedMemberAssuredTest extends AssuredTestConfig { + + @Test + void 로그인_한_맴버_프로필을_조회한다() { + final String 디투_소셜_id = "ditooSocialId"; + final Member 사용자_디투 = memberRepository.save(MemberFixture.createWithSocialId(디투_소셜_id)); + final String 디투_액세스_토큰 = login(디투_소셜_id); + + MemberAssuredSupport + .클라이언트_요청() + .로그인_한다(디투_액세스_토큰) + .사용자_본인_프로필을_가지고_있는_토큰으로_조회한다() + + .서버_응답() + .로그인한_사용자_프로필_조회_성공을_검증한다(로그인한_사용자_프로필_응답(사용자_디투)); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runner/RunnerAssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerAssuredSupport.java new file mode 100644 index 000000000..d2708e749 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerAssuredSupport.java @@ -0,0 +1,116 @@ +package touch.baton.assure.runner; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.response.ErrorResponse; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.response.RunnerProfileResponse; +import touch.baton.domain.runner.controller.response.RunnerResponse; +import touch.baton.domain.runner.service.dto.RunnerUpdateRequest; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SuppressWarnings("NonAsciiCharacters") +public class RunnerAssuredSupport { + + private RunnerAssuredSupport() { + } + + public static RunnerClientRequestBuilder 클라이언트_요청() { + return new RunnerClientRequestBuilder(); + } + + public static RunnerResponse.MyProfile 러너_본인_프로필_응답(final Runner 러너) { + return RunnerResponse.MyProfile.from(러너); + } + + public static class RunnerClientRequestBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public RunnerClientRequestBuilder 토큰으로_로그인한다(final String 토큰) { + this.accessToken = 토큰; + return this; + } + + public RunnerClientRequestBuilder 러너_본인_프로필을_가지고_있는_토큰으로_조회한다() { + response = AssuredSupport.get("/api/v1/profile/runner/me", accessToken); + return this; + } + + public RunnerClientRequestBuilder 러너_프로필을_상세_조회한다(final Long 러너_식별자) { + response = AssuredSupport.get("/api/v1/profile/runner/{runnerId}", "runnerId", 러너_식별자); + return this; + } + + public RunnerServerResponseBuilder 서버_응답() { + return new RunnerServerResponseBuilder(response); + } + + public RunnerClientRequestBuilder 러너_본인_프로필을_수정한다(final RunnerUpdateRequest 러너_업데이트_요청) { + response = AssuredSupport.patch("/api/v1/profile/runner/me", accessToken, 러너_업데이트_요청); + return this; + } + } + + public static class RunnerServerResponseBuilder { + + private final ExtractableResponse response; + + public RunnerServerResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public static RunnerClientRequestBuilder 클라이언트_요청() { + return new RunnerClientRequestBuilder(); + } + + public void 러너_본인_프로필_조회_성공을_검증한다(final RunnerResponse.MyProfile 러너_본인_프로필_응답) { + final RunnerResponse.MyProfile actual = this.response.as(RunnerResponse.MyProfile.class); + + assertSoftly(softly -> { + softly.assertThat(actual.name()).isEqualTo(러너_본인_프로필_응답.name()); + softly.assertThat(actual.company()).isEqualTo(러너_본인_프로필_응답.company()); + softly.assertThat(actual.imageUrl()).isEqualTo(러너_본인_프로필_응답.imageUrl()); + softly.assertThat(actual.githubUrl()).isEqualTo(러너_본인_프로필_응답.githubUrl()); + softly.assertThat(actual.introduction()).isEqualTo(러너_본인_프로필_응답.introduction()); + softly.assertThat(actual.technicalTags()).isEqualTo(러너_본인_프로필_응답.technicalTags()); + }); + } + + public void 러너_프로필_상세_조회를_검증한다(final RunnerProfileResponse.Detail 러너_프로필_상세_응답) { + final RunnerProfileResponse.Detail actual = this.response.as(RunnerProfileResponse.Detail.class); + assertSoftly(softly -> { + softly.assertThat(actual.runnerId()).isNotNull(); + softly.assertThat(actual.name()).isEqualTo(러너_프로필_상세_응답.name()); + softly.assertThat(actual.imageUrl()).isEqualTo(러너_프로필_상세_응답.imageUrl()); + softly.assertThat(actual.githubUrl()).isEqualTo(러너_프로필_상세_응답.githubUrl()); + softly.assertThat(actual.introduction()).isEqualTo(러너_프로필_상세_응답.introduction()); + softly.assertThat(actual.company()).isEqualTo(러너_프로필_상세_응답.company()); + softly.assertThat(actual.technicalTags()).containsExactlyElementsOf(러너_프로필_상세_응답.technicalTags()); + } + ); + } + + public void 러너_본인_프로필_수정_성공을_검증한다(final HttpStatus HTTP_STATUS, final Long 러너_아이디) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HTTP_STATUS.value()); + softly.assertThat(response.header("Location")).isNotNull(); + }); + } + + public void 러너_본인_프로필_수정_실패를_검증한다(final ClientErrorCode 클라이언트_에러_코드) { + final ErrorResponse actual = this.response.as(ErrorResponse.class); + + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(클라이언트_에러_코드.getHttpStatus().value()); + softly.assertThat(actual.errorCode()).isEqualTo(클라이언트_에러_코드.getErrorCode()); + }); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadByRunnerIdAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadByRunnerIdAssuredTest.java new file mode 100644 index 000000000..e8c0e16ac --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadByRunnerIdAssuredTest.java @@ -0,0 +1,42 @@ +package touch.baton.assure.runner; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.response.RunnerProfileResponse; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerReadByRunnerIdAssuredTest extends AssuredTestConfig { + + @Test + void 러너_프로필_조회에_성공한다() { + // given + final Member 사용자_에단 = memberRepository.save(MemberFixture.createEthan()); + final TechnicalTag 자바_태그 = technicalTagRepository.save(TechnicalTagFixture.createJava()); + final TechnicalTag 리액트_태그 = technicalTagRepository.save(TechnicalTagFixture.createReact()); + final Runner 러너_에단 = runnerRepository.save(RunnerFixture.createRunner(사용자_에단, List.of(자바_태그, 리액트_태그))); + final RunnerProfileResponse.Detail 러너_프로필_조회_응답 = new RunnerProfileResponse.Detail( + 1L, + "에단", + "https://", + "https://github.com/", + "안녕하세요.", + "우아한테크코스 5기 백엔드", + List.of("Java", "React")); + + // when, then + RunnerAssuredSupport + .클라이언트_요청() + .러너_프로필을_상세_조회한다(러너_에단.getId()) + + .서버_응답() + .러너_프로필_상세_조회를_검증한다(러너_프로필_조회_응답); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadWithLoginedRunnerAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadWithLoginedRunnerAssuredTest.java new file mode 100644 index 000000000..1735d21a4 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerReadWithLoginedRunnerAssuredTest.java @@ -0,0 +1,30 @@ +package touch.baton.assure.runner; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; + +import static touch.baton.assure.runner.RunnerAssuredSupport.러너_본인_프로필_응답; +import static touch.baton.fixture.vo.IntroductionFixture.introduction; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerReadWithLoginedRunnerAssuredTest extends AssuredTestConfig { + + @Test + void 러너_본인_프로필을_가지고_있는_토큰으로_조회에_성공한다() { + final Member 사용자_헤나 = memberRepository.save(MemberFixture.createHyena()); + final Runner 러너_헤나 = runnerRepository.save(RunnerFixture.createRunner(introduction("안녕하세요"), 사용자_헤나)); + final String 로그인용_토큰 = login(사용자_헤나.getSocialId().getValue()); + + RunnerAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_본인_프로필을_가지고_있는_토큰으로_조회한다() + + .서버_응답() + .러너_본인_프로필_조회_성공을_검증한다(러너_본인_프로필_응답(러너_헤나)); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runner/RunnerUpdateAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerUpdateAssuredTest.java new file mode 100644 index 000000000..ab2922b41 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runner/RunnerUpdateAssuredTest.java @@ -0,0 +1,99 @@ +package touch.baton.assure.runner; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.service.dto.RunnerUpdateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; + +import java.util.List; + +import static org.springframework.http.HttpStatus.NO_CONTENT; +import static touch.baton.domain.common.exception.ClientErrorCode.COMPANY_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.NAME_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.RUNNER_INTRODUCTION_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.RUNNER_TECHNICAL_TAGS_ARE_NULL; + +@SuppressWarnings("NonAsciiCharacters") +public class RunnerUpdateAssuredTest extends AssuredTestConfig { + + private String 디투_액세스_토큰; + + private Runner 러너_디투; + + @BeforeEach + void setUp() { + final String 소셜_id = "judySocialId"; + final Member 사용자_주디 = memberRepository.save(MemberFixture.createWithSocialId(소셜_id)); + 러너_디투 = runnerRepository.save(RunnerFixture.createRunner(사용자_주디)); + 디투_액세스_토큰 = login(소셜_id); + } + + @Test + void 러너_정보를_수정한다() { + final RunnerUpdateRequest 러너_업데이트_요청 = new RunnerUpdateRequest("업데이트된 이름", "업데이트된 소속", "업데이트된 자기소개", List.of("Java", "React")); + + RunnerAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(디투_액세스_토큰) + .러너_본인_프로필을_수정한다(러너_업데이트_요청) + + .서버_응답() + .러너_본인_프로필_수정_성공을_검증한다(NO_CONTENT, 러너_디투.getId()); + } + + @Test + void 러너_정보_수정_시에_이름이_없으면_예외가_발생한다() { + final RunnerUpdateRequest 러너_업데이트_요청 = new RunnerUpdateRequest(null, "업데이트된 소속", "업데이트된 자기소개", List.of("Java", "React")); + + RunnerAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(디투_액세스_토큰) + .러너_본인_프로필을_수정한다(러너_업데이트_요청) + + .서버_응답() + .러너_본인_프로필_수정_실패를_검증한다(NAME_IS_NULL); + } + + @Test + void 서포터_정보_수정_시에_소속이_없으면_예외가_발생한다() { + final RunnerUpdateRequest 러너_업데이트_요청 = new RunnerUpdateRequest("업데이트된 이름", null, "업데이트된 자기소개", List.of("Java", "React")); + + RunnerAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(디투_액세스_토큰) + .러너_본인_프로필을_수정한다(러너_업데이트_요청) + + .서버_응답() + .러너_본인_프로필_수정_실패를_검증한다(COMPANY_IS_NULL); + } + + @Test + void 러너_정보_수정_시에_소개글이_없으면_예외가_발생한다() { + final RunnerUpdateRequest 러너_업데이트_요청 = new RunnerUpdateRequest("업데이트된 이름", "업데이트된 소속", null, List.of("Java", "React")); + + RunnerAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(디투_액세스_토큰) + .러너_본인_프로필을_수정한다(러너_업데이트_요청) + + .서버_응답() + .러너_본인_프로필_수정_실패를_검증한다(RUNNER_INTRODUCTION_IS_NULL); + } + + @Test + void 러너_정보_수정_시에_기술_태그가_없으면_예외가_발생한다() { + final RunnerUpdateRequest 러너_업데이트_요청 = new RunnerUpdateRequest("업데이트된 이름", "업데이트된 소속", "업데이트된 자기소개", null); + + RunnerAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(디투_액세스_토큰) + .러너_본인_프로필을_수정한다(러너_업데이트_요청) + + .서버_응답() + .러너_본인_프로필_수정_실패를_검증한다(RUNNER_TECHNICAL_TAGS_ARE_NULL); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateSupport.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateSupport.java new file mode 100644 index 000000000..5d31cd378 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateSupport.java @@ -0,0 +1,98 @@ +package touch.baton.assure.runnerpost; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.domain.runnerpost.service.dto.RunnerPostApplicantCreateRequest; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.springframework.http.HttpHeaders.LOCATION; + +@SuppressWarnings("NonAsciiCharacters") +public class RunnerPostAssuredCreateSupport { + + private RunnerPostAssuredCreateSupport() { + } + + public static RunnerPostClientRequestBuilder 클라이언트_요청() { + return new RunnerPostClientRequestBuilder(); + } + + public static RunnerPostCreateRequest 러너_게시글_생성_요청(final String 러너_게시글_제목, + final List 태그_목록, + final String 풀_리퀘스트, + final LocalDateTime 마감기한, + final String 러너_게시글_내용 + ) { + return new RunnerPostCreateRequest(러너_게시글_제목, 태그_목록, 풀_리퀘스트, 마감기한, 러너_게시글_내용); + } + + public static class RunnerPostClientRequestBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public RunnerPostClientRequestBuilder 토큰으로_로그인한다(final String accessToken) { + this.accessToken = accessToken; + return this; + } + + public RunnerPostClientRequestBuilder 러너가_러너_게시글을_작성한다(final RunnerPostCreateRequest request) { + response = AssuredSupport.post("/api/v1/posts/runner", accessToken, request); + return this; + } + + public RunnerPostClientRequestBuilder 서포터가_러너_게시글에_리뷰를_신청한다(final Long 러너_게시글_식별자값, final String 리뷰_지원_메시지) { + response = AssuredSupport.post("/api/v1/posts/runner/{runnerPostId}/application", + accessToken, + Map.of("runnerPostId", 러너_게시글_식별자값), + new RunnerPostApplicantCreateRequest(리뷰_지원_메시지) + ); + return this; + } + + public RunnerPostServerResponseBuilder 서버_응답() { + return new RunnerPostServerResponseBuilder(response); + } + } + + public static class RunnerPostServerResponseBuilder { + + private final ExtractableResponse response; + + public RunnerPostServerResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public RunnerPostServerResponseBuilder 러너_게시글_생성_성공을_검증한다() { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + softly.assertThat(response.header(LOCATION)).startsWith("/api/v1/posts/runner/"); + }); + + return this; + } + + public RunnerPostServerResponseBuilder 서포터가_러너_게시글에_리뷰_신청_성공을_검증한다(final Long 러너_게시글_식별자값) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + softly.assertThat(response.header(LOCATION)).startsWith("/api/v1/posts/runner/" + 러너_게시글_식별자값); + }); + + return this; + } + + public Long 생성한_러너_게시글의_식별자값을_반환한다() { + final String savedRunnerPostId = this.response.header(LOCATION).replaceFirst("/api/v1/posts/runner/", ""); + + return Long.parseLong(savedRunnerPostId); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateTest.java new file mode 100644 index 000000000..2102c179c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredCreateTest.java @@ -0,0 +1,113 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.List; + +import static java.time.LocalDateTime.now; +import static touch.baton.assure.runnerpost.RunnerPostAssuredCreateSupport.러너_게시글_생성_요청; +import static touch.baton.assure.runnerpost.RunnerPostAssuredSupport.러너_게시글_Detail_응답; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.NOT_STARTED; +import static touch.baton.fixture.domain.TechnicalTagFixture.createJava; +import static touch.baton.fixture.domain.TechnicalTagFixture.createSpring; +import static touch.baton.fixture.vo.IntroductionFixture.introduction; +import static touch.baton.fixture.vo.ReviewCountFixture.reviewCount; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerPostAssuredCreateTest extends AssuredTestConfig { + + @Test + void 러너가_러너_게시글을_생성하고_서포터가_러너_게시글에_리뷰를_신청한다() { + final Runner 러너_에단 = 러너를_저장한다(MemberFixture.createEthan()); + final Supporter 서포터_헤나 = 서포터_헤나를_저장한다(); + + final String 에단_로그인_토큰 = login(러너_에단.getMember().getSocialId().getValue()); + final String 헤나_로그인_토큰 = login(서포터_헤나.getMember().getSocialId().getValue()); + + final RunnerPostCreateRequest 러너_게시글_생성_요청 = 러너_게시글_생성_요청을_생성한다(); + final Long 에단의_러너_게시글_식별자값 = RunnerPostAssuredCreateSupport + .클라이언트_요청() + .토큰으로_로그인한다(에단_로그인_토큰) + .러너가_러너_게시글을_작성한다(러너_게시글_생성_요청) + + .서버_응답() + .러너_게시글_생성_성공을_검증한다() + .생성한_러너_게시글의_식별자값을_반환한다(); + + final RunnerPostResponse.Detail 리뷰가_시작되지_않은_에단의_러너_게시글_Detail_응답 = 러너_게시글_Detail_응답을_생성한다(러너_에단, 러너_게시글_생성_요청, NOT_STARTED, 에단의_러너_게시글_식별자값, 1, 0L, false); + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(에단_로그인_토큰) + .러너_게시글_식별자값으로_러너_게시글을_조회한다(에단의_러너_게시글_식별자값) + + .서버_응답() + .러너_게시글_단건_조회_성공을_검증한다(리뷰가_시작되지_않은_에단의_러너_게시글_Detail_응답); + + RunnerPostAssuredCreateSupport + .클라이언트_요청() + .토큰으로_로그인한다(헤나_로그인_토큰) + .서포터가_러너_게시글에_리뷰를_신청한다(에단의_러너_게시글_식별자값, "안녕하세요. 서포터 헤나입니다.") + + .서버_응답() + .서포터가_러너_게시글에_리뷰_신청_성공을_검증한다(에단의_러너_게시글_식별자값); + } + + private Runner 러너를_저장한다(final Member 사용자) { + final Member 저장된_사용자 = memberRepository.save(사용자); + + return runnerRepository.save(RunnerFixture.createRunner(introduction("안녕하세요"), 저장된_사용자)); + } + + private Supporter 서포터_헤나를_저장한다() { + final TechnicalTag 자바_기술_태그 = technicalTagRepository.save(createJava()); + final TechnicalTag 스프링_기술_태그 = technicalTagRepository.save(createSpring()); + final List 기술_태그_목록 = List.of(자바_기술_태그, 스프링_기술_태그); + final Member 저장된_사용자_헤나 = memberRepository.save(MemberFixture.createHyena()); + + return supporterRepository.save(SupporterFixture.create(reviewCount(0), 저장된_사용자_헤나, 기술_태그_목록)); + } + + private RunnerPostCreateRequest 러너_게시글_생성_요청을_생성한다() { + return 러너_게시글_생성_요청( + "러너 게시글 테스트 제목", + List.of("java", "spring"), + "https://github.com", + now().plusHours(10), + "러너 게시글 내용"); + } + + private RunnerPostResponse.Detail 러너_게시글_Detail_응답을_생성한다(final Runner 러너, + final RunnerPostCreateRequest 러너_게시글_생성_요청, + final ReviewStatus 리뷰_상태, + final Long 러너_게시글_식별자값, + final int 조회수, + final long 서포터_지원자수, + final boolean 서포터_지원_여부 + ) { + return 러너_게시글_Detail_응답( + 러너_게시글_식별자값, + 러너_게시글_생성_요청.title(), + 러너_게시글_생성_요청.contents(), + 러너_게시글_생성_요청.pullRequestUrl(), + 러너_게시글_생성_요청.deadline(), + 조회수, + 서포터_지원자수, + 리뷰_상태, + true, + 서포터_지원_여부, + 러너, + 러너_게시글_생성_요청.tags() + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredSupport.java new file mode 100644 index 000000000..5fd977dfc --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostAssuredSupport.java @@ -0,0 +1,318 @@ +package touch.baton.assure.runnerpost; + +import io.restassured.common.mapper.TypeRef; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.assure.common.HttpStatusAndLocationHeader; +import touch.baton.domain.common.response.ErrorResponse; +import touch.baton.domain.common.response.PageResponse; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.response.RunnerResponse; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.controller.response.SupporterRunnerPostResponses; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; +import touch.baton.domain.runnerpost.service.dto.RunnerPostUpdateRequest; +import touch.baton.domain.runnerpost.vo.ReviewStatus; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.springframework.http.HttpHeaders.LOCATION; + +@SuppressWarnings("NonAsciiCharacters") +public class RunnerPostAssuredSupport { + + private RunnerPostAssuredSupport() { + } + + public static RunnerPostClientRequestBuilder 클라이언트_요청() { + return new RunnerPostClientRequestBuilder(); + } + + public static RunnerPostResponse.Detail 러너_게시글_Detail_응답(final Long 러너_게시글_식별자값, + final String 제목, + final String 내용, + final String 풀_리퀘스트, + final LocalDateTime 마감기한, + final int 조회수, + final long 서포터_지원자수, + final ReviewStatus 리뷰_상태, + final boolean 주인_여부, + final boolean 서포터_지원_여부, + final Runner 러너, + final List 태그_목록 + ) { + return new RunnerPostResponse.Detail( + 러너_게시글_식별자값, + 제목, + 내용, + 풀_리퀘스트, + 마감기한, + 조회수, + 서포터_지원자수, + 리뷰_상태, + 주인_여부, + 서포터_지원_여부, + 태그_목록, + RunnerResponse.Detail.from(러너) + ); + } + + public static PageResponse 서포터가_리뷰_완료한_러너_게시글_응답(final Pageable 페이징_정보, + final List 서포터가_연관된_러너_게시글_목록 + ) { + final Page 페이징된_서포터가_연관된_러너_게시글 = new PageImpl<>(서포터가_연관된_러너_게시글_목록, 페이징_정보, 서포터가_연관된_러너_게시글_목록.size()); + final PageResponse 페이징된_서포터가_연관된_러너_게시글_응답 = PageResponse.from(페이징된_서포터가_연관된_러너_게시글); + + return 페이징된_서포터가_연관된_러너_게시글_응답; + } + + public static PageResponse 러너_게시글_전체_조회_응답(final Pageable 페이징_정보, + final List 러너_게시글_목록 + ) { + final Page 페이징된_러너_게시글 = new PageImpl<>(러너_게시글_목록, 페이징_정보, 러너_게시글_목록.size()); + final PageResponse 페이징된_러너_게시글_응답 = PageResponse.from(페이징된_러너_게시글); + + return 페이징된_러너_게시글_응답; + } + + public static PageResponse 마이페이지_러너_게시글_응답(final Pageable 페이징_정보, + final List 마이페이지_러너_게시글_목록 + ) { + final Page 페이징된_마이페이지_러너_게시글 = new PageImpl<>(마이페이지_러너_게시글_목록, 페이징_정보, 마이페이지_러너_게시글_목록.size()); + final PageResponse 페이징된_마이페이지_러너_게시글_응답 = PageResponse.from(페이징된_마이페이지_러너_게시글); + + return 페이징된_마이페이지_러너_게시글_응답; + } + + public static class RunnerPostClientRequestBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public RunnerPostClientRequestBuilder 토큰으로_로그인한다(final String 토큰) { + this.accessToken = 토큰; + return this; + } + + public RunnerPostClientRequestBuilder 러너_게시글_등록_요청한다(final RunnerPostCreateRequest 게시글_생성_요청) { + response = AssuredSupport.post("/api/v1/posts/runner", 게시글_생성_요청, accessToken); + return this; + } + + public RunnerPostClientRequestBuilder 러너_게시글_식별자값으로_러너_게시글을_조회한다(final Long 러너_게시글_식별자값) { + response = AssuredSupport.get("/api/v1/posts/runner/{runnerPostId}", "runnerPostId", 러너_게시글_식별자값, accessToken); + return this; + } + + public RunnerPostClientRequestBuilder 러너_게시글_식별자값으로_서포터_러너_게시글을_조회한다(final Long 러너_게시글_식별자값) { + response = AssuredSupport.get("/api/v1/posts/runner/{runnerPostId}/supporters", "runnerPostId", 러너_게시글_식별자값, accessToken); + return this; + } + + public RunnerPostClientRequestBuilder 서포터와_연관된_러너_게시글_페이징을_조회한다(final Long 서포터_식별자값, + final ReviewStatus 리뷰_진행_상태, + final Pageable 페이징_정보 + ) { + final Map queryParams = Map.of( + "supporterId", 서포터_식별자값, + "reviewStatus", 리뷰_진행_상태, + "size", 페이징_정보.getPageSize(), + "page", 페이징_정보.getPageNumber() + ); + + response = AssuredSupport.get("/api/v1/posts/runner/search", queryParams); + return this; + } + + public RunnerPostClientRequestBuilder 마이페이지_러너_게시글_페이징을_조회한다(final ReviewStatus 리뷰_상태, + final Pageable 페이징_정보 + ) { + final Map queryParams = Map.of( + "reviewStatus", 리뷰_상태, + "size", 페이징_정보.getPageSize(), + "page", 페이징_정보.getPageNumber() + ); + + response = AssuredSupport.get("/api/v1/posts/runner/me/runner", queryParams, accessToken); + return this; + } + + public RunnerPostClientRequestBuilder 전체_러너_게시글_페이징을_조회한다(final Pageable 페이징_정보) { + final Map queryParams = Map.of( + "size", 페이징_정보.getPageSize(), + "page", 페이징_정보.getPageNumber() + ); + + response = AssuredSupport.get("/api/v1/posts/runner", queryParams); + return this; + } + + public RunnerPostClientRequestBuilder 서포터가_리뷰를_완료하고_리뷰완료_버튼을_누른다(final Long 게시글_식별자) { + response = AssuredSupport.patch("/api/v1/posts/runner/{runnerPostId}/done", "runnerPostId", 게시글_식별자, accessToken); + return this; + } + + public RunnerPostClientRequestBuilder 러너_게시글_식별자값으로_러너_게시글을_삭제한다(final Long 러너_게시글_식별자값) { + response = AssuredSupport.delete("/api/v1/posts/runner/{runnerPostId}", accessToken, "runnerPostId", 러너_게시글_식별자값); + return this; + } + + public RunnerPostClientRequestBuilder 러너가_서포터를_선택한다(final Long 게시글_식별자값, + final RunnerPostUpdateRequest.SelectSupporter 서포터_선택_요청_정보 + ) { + response = AssuredSupport.patch("/api/v1/posts/runner/{runnerPostId}/supporters", + "runnerPostId", 게시글_식별자값, + 서포터_선택_요청_정보, + accessToken + ); + return this; + } + + public RunnerPostClientRequestBuilder 로그인한_서포터의_러너_게시글_페이징을_조회한다(final ReviewStatus 리뷰_진행_상태, + final Pageable 페이징_정보 + ) { + final Map queryParams = Map.of( + "reviewStatus", 리뷰_진행_상태, + "size", 페이징_정보.getPageSize(), + "page", 페이징_정보.getPageNumber() + ); + + response = AssuredSupport.get("/api/v1/posts/runner/me/supporter", accessToken, queryParams); + return this; + } + + public RunnerPostServerResponseBuilder 서버_응답() { + return new RunnerPostServerResponseBuilder(response); + } + } + + public static class RunnerPostServerResponseBuilder { + + private final ExtractableResponse response; + + public RunnerPostServerResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public void 러너_게시글_단건_조회_성공을_검증한다(final RunnerPostResponse.Detail 러너_게시글_응답) { + final RunnerPostResponse.Detail actual = this.response.as(RunnerPostResponse.Detail.class); + + assertSoftly(softly -> { + softly.assertThat(actual.runnerPostId()).isEqualTo(러너_게시글_응답.runnerPostId()); + softly.assertThat(actual.title()).isEqualTo(러너_게시글_응답.title()); + softly.assertThat(actual.contents()).isEqualTo(러너_게시글_응답.contents()); + softly.assertThat(actual.deadline()).isEqualToIgnoringSeconds(러너_게시글_응답.deadline()); + softly.assertThat(actual.watchedCount()).isEqualTo(러너_게시글_응답.watchedCount()); + softly.assertThat(actual.applicantCount()).isEqualTo(러너_게시글_응답.applicantCount()); + softly.assertThat(actual.reviewStatus()).isEqualTo(러너_게시글_응답.reviewStatus()); + softly.assertThat(actual.tags()).isEqualTo(러너_게시글_응답.tags()); + softly.assertThat(actual.deadline()).isEqualToIgnoringSeconds(러너_게시글_응답.deadline()); + softly.assertThat(actual.runnerProfile().name()).isEqualTo(러너_게시글_응답.runnerProfile().name()); + softly.assertThat(actual.runnerProfile().company()).isEqualTo(러너_게시글_응답.runnerProfile().company()); + softly.assertThat(actual.runnerProfile().imageUrl()).isEqualTo(러너_게시글_응답.runnerProfile().imageUrl()); + softly.assertThat(actual.runnerProfile().runnerId()).isEqualTo(러너_게시글_응답.runnerProfile().runnerId()); + softly.assertThat(actual.watchedCount()).isEqualTo(러너_게시글_응답.watchedCount()); + softly.assertThat(actual.runnerPostId()).isEqualTo(러너_게시글_응답.runnerPostId()); + } + ); + } + + public void 마이페이지_러너_게시글_페이징_조회_성공을_검증한다(final PageResponse 마이페이지_러너_게시글_페이징_응답) { + final PageResponse actual = this.response.as(new TypeRef>() { + + }); + + assertSoftly(softly -> { + softly.assertThat(this.response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(actual.data()).isEqualTo(마이페이지_러너_게시글_페이징_응답.data()); + } + ); + } + + public void 서포터와_연관된_러너_게시글_페이징_조회_성공을_검증한다(final PageResponse 서포터와_연관된_러너_게시글_페이징_응답) { + final PageResponse actual = this.response.as(new TypeRef>() { + + }); + + assertSoftly(softly -> { + softly.assertThat(this.response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(actual.data()).isEqualTo(서포터와_연관된_러너_게시글_페이징_응답.data()); + } + ); + } + + public void 전체_러너_게시글_페이징_조회_성공을_검증한다(final PageResponse 전체_러너_게시글_페이징_응답) { + final PageResponse actual = this.response.as(new TypeRef>() { + + }); + + assertSoftly(softly -> { + softly.assertThat(this.response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(actual.data()).isEqualTo(전체_러너_게시글_페이징_응답.data()); + } + ); + } + + public void 서포터_러너_게시글_조회_성공을_검증한다(final SupporterRunnerPostResponses.Detail 전체_러너_게시글_페이징_응답) { + final SupporterRunnerPostResponses.Detail actual = this.response.as(new TypeRef() { + + }); + + assertSoftly(softly -> { + softly.assertThat(this.response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(actual.data()).isEqualTo(전체_러너_게시글_페이징_응답.data()); + } + ); + } + + public void 러너_게시글_삭제_성공을_검증한다(final HttpStatus HTTP_STATUS) { + assertThat(response.statusCode()).isEqualTo(HTTP_STATUS.value()); + } + + public void 러너_게시글_삭제_실패를_검증한다(final HttpStatus HTTP_STATUS) { + assertThat(response.statusCode()).isEqualTo(HTTP_STATUS.value()); + } + + public void 러너_게시글에_서포터가_성공적으로_선택되었는지_확인한다(final HttpStatusAndLocationHeader httpStatusAndLocationHeader) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(httpStatusAndLocationHeader.getHttpStatus().value()); + softly.assertThat(response.header(LOCATION)).contains(httpStatusAndLocationHeader.getLocation()); + }); + } + + public void 러너_게시글이_성공적으로_리뷰_완료_상태인지_확인한다(final HttpStatusAndLocationHeader httpStatusAndLocationHeader) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(httpStatusAndLocationHeader.getHttpStatus().value()); + softly.assertThat(response.header(LOCATION)).contains(httpStatusAndLocationHeader.getLocation()); + } + ); + } + + public void 러너_게시글_등록_성공을_검증한다(final HttpStatusAndLocationHeader httpStatusAndLocationHeader) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(httpStatusAndLocationHeader.getHttpStatus().value()); + softly.assertThat(response.header(LOCATION)).contains(httpStatusAndLocationHeader.getLocation()); + } + ); + } + + public void 러너_게시글_등록_실패를_검증한다(final ErrorResponse 예상_에러_응답) { + final ErrorResponse 실제_에러_응답 = response.as(ErrorResponse.class); + + assertSoftly(softly -> { + softly.assertThat(실제_에러_응답.errorCode()).isEqualTo(예상_에러_응답.errorCode()); + softly.assertThat(실제_에러_응답.message()).isEqualTo(예상_에러_응답.message()); + }); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostCreateAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostCreateAssuredTest.java new file mode 100644 index 000000000..9e8104782 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostCreateAssuredTest.java @@ -0,0 +1,190 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import touch.baton.assure.common.HttpStatusAndLocationHeader; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.common.response.ErrorResponse; +import touch.baton.domain.member.Member; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.http.HttpStatus.CREATED; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerPostCreateAssuredTest extends AssuredTestConfig { + + private static String 토큰; + + @BeforeEach + void setUp() { + final String 소셜_아이디 = "hongSile"; + final Member 사용자 = memberRepository.save(MemberFixture.createWithSocialId(소셜_아이디)); + runnerRepository.save(RunnerFixture.createRunner(사용자)); + 토큰 = login(소셜_아이디); + } + + @Test + void 러너_게시글_등록이_성공한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + List.of("Java", "Spring"), + "https://github.com/cookienc", + LocalDateTime.now().plusDays(10), + "싸게 부탁드려요." + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_성공을_검증한다(new HttpStatusAndLocationHeader(CREATED, "/api/v1/posts/runner")); + } + + @Test + void 게시글_제목이_null이면_러너_게시글_등록_실패한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest(null, + List.of("Java", "Spring"), + "https://github.com/cookienc", + LocalDateTime.now().plusDays(10), + "싸게 부탁드려요." + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_실패를_검증한다(new ErrorResponse("RP001", "제목을 입력해주세요.")); + } + + @Test + void 게시글_태그가_null이면_러너_게시글_등록_실패한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + null, + "https://github.com/cookienc", + LocalDateTime.now().plusDays(10), + "싸게 부탁드려요." + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_실패를_검증한다(new ErrorResponse("RP008", "태그 목록을 빈 값이라도 입력해주세요.")); + } + + @Test + void 게시글_PR_URL이_null이면_러너_게시글_등록_실패한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + List.of("Java", "Spring"), + null, + LocalDateTime.now().plusDays(10), + "싸게 부탁드려요." + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_실패를_검증한다(new ErrorResponse("RP002", "PR 주소를 입력해주세요.")); + } + + @Test + void 게시글_마감기한이_null이면_러너_게시글_등록_실패한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + List.of("Java", "Spring"), + "https://github.com/cookienc", + null, + "싸게 부탁드려요." + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_실패를_검증한다(new ErrorResponse("RP003", "마감일을 입력해주세요.")); + } + + @Test + void 게시글_마감기한이_현재보다_과거면_러너_게시글_등록_실패한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + List.of("Java", "Spring"), + "https://github.com/cookienc", + LocalDateTime.now().minusDays(1), + "싸게 부탁드려요." + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_실패를_검증한다(new ErrorResponse("RP006", "마감일은 오늘보다 과거일 수 없습니다.")); + } + + @Test + void 게시글_내용이_null이면_러너_게시글_등록_실패한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + List.of("Java", "Spring"), + "https://github.com/cookienc", + LocalDateTime.now().plusDays(10), + null + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_실패를_검증한다(new ErrorResponse("RP004", "내용을 입력해주세요.")); + } + + @Test + void 게시글_내용이_1000자_보다_길면_러너_게시글_등록_실패한다() { + // given + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + List.of("Java", "Spring"), + "https://github.com/cookienc", + LocalDateTime.now().plusDays(10), + "12345".repeat(200) + "1" + ); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_등록_실패를_검증한다(new ErrorResponse("RP005", "내용은 1000자 까지 입력해주세요.")); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostDeleteAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostDeleteAssuredTest.java new file mode 100644 index 000000000..0ab00926f --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostDeleteAssuredTest.java @@ -0,0 +1,115 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NO_CONTENT; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerPostDeleteAssuredTest extends AssuredTestConfig { + + private Runner 러너_디투; + private String 로그인용_토큰; + + @BeforeEach + void setUp() { + final Member 사용자_디투 = memberRepository.save(MemberFixture.createDitoo()); + 러너_디투 = runnerRepository.save(RunnerFixture.createRunner(사용자_디투)); + 로그인용_토큰 = login(사용자_디투.getSocialId().getValue()); + } + + @Test + void 리뷰가_대기중이고_리뷰_지원자가_없다면_러너의_게시글_식별자값으로_러너_게시글_삭제에_성공한다() { + final RunnerPost 러너_게시글 = runnerPostRepository.save(RunnerPostFixture.create(러너_디투, deadline(LocalDateTime.now().plusHours(100)))); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_러너_게시글을_삭제한다(러너_게시글.getId()) + + .서버_응답() + .러너_게시글_삭제_성공을_검증한다(NO_CONTENT); + } + + @Test + void 러너_게시글이_존재하지_않으면_러너의_게시글_식별자값으로_러너_게시글_삭제에_실패한다() { + final Long 존재하지_않는_러너_게시글의_식별자값 = -1L; + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_러너_게시글을_삭제한다(존재하지_않는_러너_게시글의_식별자값) + + .서버_응답() + .러너_게시글_삭제_실패를_검증한다(INTERNAL_SERVER_ERROR); + } + + @Test + void 리뷰가_진행중인_상태라면_러너의_게시글_식별자값으로_러너_게시글_삭제에_실패한다() { + final RunnerPost 러너_게시글 = runnerPostRepository.save(RunnerPostFixture.create( + 러너_디투, + deadline(LocalDateTime.now().plusHours(100)), + ReviewStatus.IN_PROGRESS + )); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_러너_게시글을_삭제한다(러너_게시글.getId()) + + .서버_응답() + .러너_게시글_삭제_실패를_검증한다(INTERNAL_SERVER_ERROR); + } + + @Test + void 리뷰가_완료된_상태라면_러너의_게시글_식별자값으로_러너_게시글_삭제에_실패한다() { + final RunnerPost 러너_게시글 = runnerPostRepository.save(RunnerPostFixture.create( + 러너_디투, + deadline(LocalDateTime.now().plusHours(100)), + ReviewStatus.DONE + )); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_러너_게시글을_삭제한다(러너_게시글.getId()) + + .서버_응답() + .러너_게시글_삭제_실패를_검증한다(INTERNAL_SERVER_ERROR); + } + + @Test + void 리뷰_요청_대기중인_상태이고_리뷰_지원자가_있는_상태라면_러너의_게시글_식별자값으로_러너_게시글_삭제에_실패한다() { + final RunnerPost 러너_게시글 = runnerPostRepository.save(RunnerPostFixture.create( + 러너_디투, + deadline(LocalDateTime.now().plusHours(100)), + ReviewStatus.NOT_STARTED + )); + final Member 지원자_맴버 = memberRepository.save(MemberFixture.createHyena()); + final Supporter 지원자_서포터 = supporterRepository.save(SupporterFixture.create(지원자_맴버)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(러너_게시글, 지원자_서포터)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_러너_게시글을_삭제한다(러너_게시글.getId()) + + .서버_응답() + .러너_게시글_삭제_실패를_검증한다(INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadAssuredTest.java new file mode 100644 index 000000000..88cbe06b6 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadAssuredTest.java @@ -0,0 +1,82 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.common.response.PageResponse; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; + +import java.util.List; + +import static java.time.LocalDateTime.now; +import static touch.baton.assure.runnerpost.RunnerPostAssuredSupport.러너_게시글_전체_조회_응답; +import static touch.baton.assure.runnerpost.RunnerPostAssuredSupport.마이페이지_러너_게시글_응답; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.IntroductionFixture.introduction; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerPostReadAssuredTest extends AssuredTestConfig { + + @Test + void 러너_게시글_전체_조회에_성공한다() { + final Runner 러너_에단 = 러너를_저장한다(MemberFixture.createEthan()); + final RunnerPost 러너_에단의_게시글 = 러너_게시글을_등록한다(러너_에단); + runnerPostRepository.save(러너_에단의_게시글); + + final String 에단_액세스_토큰 = login(러너_에단.getMember().getSocialId().getValue()); + + final PageRequest 페이징_정보 = PageRequest.of(1, 10); + final RunnerPostResponse.Simple 게시글_응답 + = RunnerPostResponse.Simple.from(러너_에단의_게시글, 0); + final PageResponse 페이징된_게시글_응답 + = 러너_게시글_전체_조회_응답(페이징_정보, List.of(게시글_응답)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(에단_액세스_토큰) + .전체_러너_게시글_페이징을_조회한다(페이징_정보) + + .서버_응답() + .전체_러너_게시글_페이징_조회_성공을_검증한다(페이징된_게시글_응답); + } + + @Test + void 마이페이지_러너_게시글_페이징_조회에_성공한다() { + final Member 멤버_에단 = MemberFixture.createEthan(); + final Runner 러너_에단 = 러너를_저장한다(멤버_에단); + final RunnerPost 러너_에단의_게시글 = 러너_게시글을_등록한다(러너_에단); + + final String 에단_액세스_토큰 = login(러너_에단.getMember().getSocialId().getValue()); + + final PageRequest 페이징_정보 = PageRequest.of(1, 10); + final RunnerPostResponse.SimpleInMyPage 마이페이지_러너_게시글_응답 + = RunnerPostResponse.SimpleInMyPage.from(러너_에단의_게시글, 0); + final PageResponse 페이징된_마이페이지_러너_게시글_응답 + = 마이페이지_러너_게시글_응답(페이징_정보, List.of(마이페이지_러너_게시글_응답)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(에단_액세스_토큰) + .마이페이지_러너_게시글_페이징을_조회한다(ReviewStatus.NOT_STARTED, 페이징_정보) + + .서버_응답() + .마이페이지_러너_게시글_페이징_조회_성공을_검증한다(페이징된_마이페이지_러너_게시글_응답); + } + + private RunnerPost 러너_게시글을_등록한다(final Runner 러너) { + return runnerPostRepository.save(RunnerPostFixture.create(러너, deadline(now().plusHours(100)))); + } + + private Runner 러너를_저장한다(final Member 사용자) { + final Member 저장된_사용자 = memberRepository.save(사용자); + + return runnerRepository.save(RunnerFixture.createRunner(introduction("안녕하세요"), 저장된_사용자)); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadByRunnerPostIdAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadByRunnerPostIdAssuredTest.java new file mode 100644 index 000000000..cd8d070f9 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadByRunnerPostIdAssuredTest.java @@ -0,0 +1,53 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.response.RunnerResponse; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; + +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.IntroductionFixture.introduction; +import static touch.baton.fixture.vo.WatchedCountFixture.watchedCount; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerPostReadByRunnerPostIdAssuredTest extends AssuredTestConfig { + + @Test + void 러너의_게시글_식별자값으로_러너_게시글_상세_정보_조회에_성공한다() { + final Member 사용자_헤나 = memberRepository.save(MemberFixture.createHyena()); + final Runner 러너_헤나 = runnerRepository.save(RunnerFixture.createRunner(introduction("안녕하세요"), 사용자_헤나)); + final RunnerPost 러너_게시글 = runnerPostRepository.save(RunnerPostFixture.create(러너_헤나, deadline(LocalDateTime.now().plusHours(100)))); + final String 로그인용_토큰 = login(사용자_헤나.getSocialId().getValue()); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_러너_게시글을_조회한다(러너_게시글.getId()) + + .서버_응답() + .러너_게시글_단건_조회_성공을_검증한다(new RunnerPostResponse.Detail( + 러너_게시글.getId(), + 러너_게시글.getTitle().getValue(), + 러너_게시글.getContents().getValue(), + 러너_게시글.getPullRequestUrl().getValue(), + 러너_게시글.getDeadline().getValue(), + watchedCount(1).getValue(), + 0L, + ReviewStatus.NOT_STARTED, + true, + false, + new ArrayList<>(), + RunnerResponse.Detail.from(러너_헤나) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedAssuredTest.java new file mode 100644 index 000000000..d55c42cef --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedAssuredTest.java @@ -0,0 +1,97 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.controller.response.SupporterRunnerPostResponse; +import touch.baton.domain.runnerpost.controller.response.SupporterRunnerPostResponses; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static java.time.LocalDateTime.now; +import static touch.baton.assure.runnerpost.RunnerPostAssuredSupport.러너_게시글_Detail_응답; +import static touch.baton.assure.runnerpost.RunnerPostAssuredSupport.클라이언트_요청; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.IntroductionFixture.introduction; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerPostReadWithLoginedAssuredTest extends AssuredTestConfig { + + @Test + void 러너의_게시글_식별자값으로_러너_게시글_상세_정보_조회에_성공한다() { + final Member 사용자_헤나 = memberRepository.save(MemberFixture.createHyena()); + final Runner 러너_헤나 = runnerRepository.save(RunnerFixture.createRunner(introduction("안녕하세요"), 사용자_헤나)); + final RunnerPost 러너_게시글 = runnerPostRepository.save(RunnerPostFixture.create(러너_헤나, deadline(now().plusHours(100)))); + final String 로그인용_토큰 = login(사용자_헤나.getSocialId().getValue()); + + final RunnerPostResponse.Detail 러너_게시글_detail_응답 = 러너_게시글_Detail_응답을_생성한다(러너_헤나, 러너_게시글, ReviewStatus.NOT_STARTED, 러너_게시글.getId(), 1, 0, false); + + 클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_러너_게시글을_조회한다(러너_게시글.getId()) + + .서버_응답() + .러너_게시글_단건_조회_성공을_검증한다(러너_게시글_detail_응답); + } + + private RunnerPostResponse.Detail 러너_게시글_Detail_응답을_생성한다(final Runner 로그인한_러너, + final RunnerPost 러너_게시글, + final ReviewStatus 리뷰_상태, + final Long 러너_게시글_식별자값, + final int 조회수, + final long 서포터_지원자수, + final boolean 서포터_지원_여부 + ) { + return 러너_게시글_Detail_응답( + 러너_게시글_식별자값, + 러너_게시글.getTitle().getValue(), + 러너_게시글.getContents().getValue(), + 러너_게시글.getPullRequestUrl().getValue(), + 러너_게시글.getDeadline().getValue(), + 조회수, + 서포터_지원자수, + 리뷰_상태, + !러너_게시글.isNotOwner(로그인한_러너), + 서포터_지원_여부, + 러너_게시글.getRunner(), + 러너_게시글.getRunnerPostTags().getRunnerPostTags().stream() + .map(runnerPostTag -> runnerPostTag.getTag().getTagName().getValue()) + .toList() + ); + } + + @Test + void 러너의_게시글_식별자값으로_서포터_러너_게시글_조회에_성공한다() { + final Member 사용자_헤나 = memberRepository.save(MemberFixture.createHyena()); + final Runner 러너_헤나 = runnerRepository.save(RunnerFixture.createRunner(introduction("안녕하세요"), 사용자_헤나)); + final RunnerPost 러너_게시글 = runnerPostRepository.save(RunnerPostFixture.create(러너_헤나, deadline(LocalDateTime.now().plusHours(100)))); + final String 로그인용_토큰 = login(사용자_헤나.getSocialId().getValue()); + + final Member 사용자_주디 = memberRepository.save(MemberFixture.createJudy()); + final Supporter 서포터_주디 = supporterRepository.save(SupporterFixture.create(사용자_주디)); + final SupporterRunnerPost 서포터_러너_게시글 = supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(러너_게시글, 서포터_주디)); + + final SupporterRunnerPostResponse.Detail 서포터_러너_게시글_응답 = SupporterRunnerPostResponse.Detail.from(서포터_러너_게시글); + final SupporterRunnerPostResponses.Detail 서포터_러너_게시글_응답들 = SupporterRunnerPostResponses.Detail.from(List.of(서포터_러너_게시글_응답)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .러너_게시글_식별자값으로_서포터_러너_게시글을_조회한다(러너_게시글.getId()) + + .서버_응답() + .서포터_러너_게시글_조회_성공을_검증한다(서포터_러너_게시글_응답들); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedSupporterAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedSupporterAssuredTest.java new file mode 100644 index 000000000..984595359 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithLoginedSupporterAssuredTest.java @@ -0,0 +1,119 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.common.response.PageResponse; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static touch.baton.assure.runnerpost.RunnerPostAssuredSupport.서포터가_리뷰_완료한_러너_게시글_응답; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +@SuppressWarnings("NonAsciiCharacters") +public class RunnerPostReadWithLoginedSupporterAssuredTest extends AssuredTestConfig { + + private final PageRequest 페이징_정보 = PageRequest.of(1, 10); + private Supporter 로그인된_서포터; + private String 로그인용_토큰; + private RunnerPost 대기중인_게시글; + private RunnerPost 리뷰중인_게시글; + private RunnerPost 완료된_게시글; + + @BeforeEach + void setUp() { + final Member 로그인된_사용자 = memberRepository.save(MemberFixture.createDitoo()); + 로그인된_서포터 = supporterRepository.save(SupporterFixture.create(로그인된_사용자)); + 로그인용_토큰 = login(로그인된_사용자.getSocialId().getValue()); + 로그인된_서포터의_러너_게시글을_모든_리뷰_상태로_저장한다(로그인된_서포터); + } + + private void 로그인된_서포터의_러너_게시글을_모든_리뷰_상태로_저장한다(final Supporter 로그인된_서포터) { + final Member 사용자_누군가 = memberRepository.save(MemberFixture.createEthan()); + final Runner 러너_누군가 = runnerRepository.save(RunnerFixture.createRunner(사용자_누군가)); + 대기중인_게시글 = runnerPostRepository.save(RunnerPostFixture.create( + 러너_누군가, + null, + deadline(LocalDateTime.now().plusHours(100)), + ReviewStatus.NOT_STARTED + )); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(대기중인_게시글, 로그인된_서포터)); + + 리뷰중인_게시글 = runnerPostRepository.save(RunnerPostFixture.create( + 러너_누군가, + 로그인된_서포터, + deadline(LocalDateTime.now().plusHours(100)), + ReviewStatus.IN_PROGRESS + )); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(리뷰중인_게시글, 로그인된_서포터)); + + 완료된_게시글 = runnerPostRepository.save(RunnerPostFixture.create( + 러너_누군가, + 로그인된_서포터, + deadline(LocalDateTime.now().plusHours(100)), + ReviewStatus.DONE + )); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(완료된_게시글, 로그인된_서포터)); + } + + @Test + void 로그인된_서포터의_대기중인_러너_게시글_목록_조회에_성공한다() { + final RunnerPostResponse.ReferencedBySupporter 서포터가_지원한_러너_게시글_응답 + = RunnerPostResponse.ReferencedBySupporter.of(대기중인_게시글, 1); + final PageResponse 페이징된_서포터가_지원한_러너_게시글_응답 + = 서포터가_리뷰_완료한_러너_게시글_응답(페이징_정보, List.of(서포터가_지원한_러너_게시글_응답)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .로그인한_서포터의_러너_게시글_페이징을_조회한다(ReviewStatus.NOT_STARTED, 페이징_정보) + + .서버_응답() + .서포터와_연관된_러너_게시글_페이징_조회_성공을_검증한다(페이징된_서포터가_지원한_러너_게시글_응답); + } + + @Test + void 로그인된_서포터의_진행중인_러너_게시글_목록_조회에_성공한다() { + final RunnerPostResponse.ReferencedBySupporter 서포터가_리뷰중인_러너_게시글_응답 + = RunnerPostResponse.ReferencedBySupporter.of(리뷰중인_게시글, 1); + final PageResponse 페이징된_서포터가_리뷰중인_러너_게시글_응답 + = 서포터가_리뷰_완료한_러너_게시글_응답(페이징_정보, List.of(서포터가_리뷰중인_러너_게시글_응답)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .로그인한_서포터의_러너_게시글_페이징을_조회한다(ReviewStatus.IN_PROGRESS, 페이징_정보) + + .서버_응답() + .서포터와_연관된_러너_게시글_페이징_조회_성공을_검증한다(페이징된_서포터가_리뷰중인_러너_게시글_응답); + } + + @Test + void 로그인된_서포터의_완료된_러너_게시글_목록_조회에_성공한다() { + final RunnerPostResponse.ReferencedBySupporter 서포터가_리뷰_완료한_러너_게시글_응답 + = RunnerPostResponse.ReferencedBySupporter.of(완료된_게시글, 1); + final PageResponse 페이징된_서포터가_리뷰_완료한_러너_게시글_응답 + = 서포터가_리뷰_완료한_러너_게시글_응답(페이징_정보, List.of(서포터가_리뷰_완료한_러너_게시글_응답)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(로그인용_토큰) + .로그인한_서포터의_러너_게시글_페이징을_조회한다(ReviewStatus.DONE, 페이징_정보) + + .서버_응답() + .서포터와_연관된_러너_게시글_페이징_조회_성공을_검증한다(페이징된_서포터가_리뷰_완료한_러너_게시글_응답); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithSupporterIdAndReviewStatusAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithSupporterIdAndReviewStatusAssuredTest.java new file mode 100644 index 000000000..8381cd24d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostReadWithSupporterIdAndReviewStatusAssuredTest.java @@ -0,0 +1,97 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.common.response.PageResponse; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.response.RunnerPostResponse; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.List; + +import static java.time.LocalDateTime.now; +import static touch.baton.assure.runnerpost.RunnerPostAssuredSupport.서포터가_리뷰_완료한_러너_게시글_응답; +import static touch.baton.fixture.domain.TechnicalTagFixture.createJava; +import static touch.baton.fixture.domain.TechnicalTagFixture.createSpring; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.IntroductionFixture.introduction; +import static touch.baton.fixture.vo.MessageFixture.message; +import static touch.baton.fixture.vo.ReviewCountFixture.reviewCount; + +@SuppressWarnings("NonAsciiCharacters") +class RunnerPostReadWithSupporterIdAndReviewStatusAssuredTest extends AssuredTestConfig { + + @Test + void 서포터가_리뷰_완료한_러너_게시글_페이징_조회에_성공한다() { + final Runner 러너_에단 = 러너를_저장한다(MemberFixture.createEthan()); + final Supporter 서포터_헤나 = 서포터_헤나를_저장한다(); + final RunnerPost 러너_에단의_게시글 = 러너_게시글을_등록한다(러너_에단); + 러너_게시글에_서포터를_할당한다(서포터_헤나, 러너_에단의_게시글); + 서포터를_러너_게시글에_저장한다(서포터_헤나, 러너_에단의_게시글); + + // FIXME: 2023/08/13 러너 게시글 리뷰 완료 요청 기능 구현시 아래 ReviewStatus.DONE 테스트를 수정해야한다. + 러너_에단의_게시글.updateReviewStatus(ReviewStatus.DONE); + runnerPostRepository.save(러너_에단의_게시글); + + final String 헤나_액세스_토큰 = login(서포터_헤나.getMember().getSocialId().getValue()); + + final PageRequest 페이징_정보 = PageRequest.of(1, 10); + final RunnerPostResponse.ReferencedBySupporter 서포터가_리뷰_완료한_러너_게시글_응답 + = RunnerPostResponse.ReferencedBySupporter.of(러너_에단의_게시글, 1); + final PageResponse 페이징된_서포터가_리뷰_완료한_러너_게시글_응답 + = 서포터가_리뷰_완료한_러너_게시글_응답(페이징_정보, List.of(서포터가_리뷰_완료한_러너_게시글_응답)); + + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(헤나_액세스_토큰) + .서포터와_연관된_러너_게시글_페이징을_조회한다(서포터_헤나.getId(), ReviewStatus.DONE, 페이징_정보) + + .서버_응답() + .서포터와_연관된_러너_게시글_페이징_조회_성공을_검증한다(페이징된_서포터가_리뷰_완료한_러너_게시글_응답); + } + + private RunnerPost 러너_게시글에_서포터를_할당한다(final Supporter 서포터_헤나, final RunnerPost 러너_에단의_게시글) { + 러너_에단의_게시글.assignSupporter(서포터_헤나); + return runnerPostRepository.save(러너_에단의_게시글); + } + + private RunnerPost 러너_게시글을_등록한다(final Runner 러너) { + + return runnerPostRepository.save(RunnerPostFixture.create(러너, deadline(now().plusHours(100)))); + } + + private Runner 러너를_저장한다(final Member member) { + final Member 저장된_사용자 = memberRepository.save(member); + + return runnerRepository.save(RunnerFixture.createRunner(introduction("안녕하세요"), 저장된_사용자)); + } + + private Supporter 서포터_헤나를_저장한다() { + final TechnicalTag 자바_기술_태그 = technicalTagRepository.save(createJava()); + final TechnicalTag 스프링_기술_태그 = technicalTagRepository.save(createSpring()); + final List 기술_태그_목록 = List.of(자바_기술_태그, 스프링_기술_태그); + final Member 저장된_사용자_헤나 = memberRepository.save(MemberFixture.createHyena()); + + return supporterRepository.save(SupporterFixture.create(reviewCount(0), 저장된_사용자_헤나, 기술_태그_목록)); + } + + private SupporterRunnerPost 서포터를_러너_게시글에_저장한다(final Supporter 서포터, final RunnerPost 러너_게시글) { + final SupporterRunnerPost 서포터_러너_게시글 = SupporterRunnerPost.builder() + .runnerPost(러너_게시글) + .supporter(서포터) + .message(message("안녕하세요. 서포터 헤나입니다.")) + .build(); + + return supporterRunnerPostRepository.save(서포터_러너_게시글); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostUpdateAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostUpdateAssuredTest.java new file mode 100644 index 000000000..bf475bbb4 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/RunnerPostUpdateAssuredTest.java @@ -0,0 +1,79 @@ +package touch.baton.assure.runnerpost; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.HttpStatusAndLocationHeader; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.service.dto.RunnerPostUpdateRequest; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; + +import static touch.baton.domain.runnerpost.vo.ReviewStatus.IN_PROGRESS; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +@SuppressWarnings("NonAsciiCharacters") +public class RunnerPostUpdateAssuredTest extends AssuredTestConfig { + + @Test + void 러너가_서포터_목록에서_서포터를_선택할_수_있다() { + // given + final String 디투_소셜_아이디 = "hongSile"; + final Member 사용자_디투 = memberRepository.save(MemberFixture.createWithSocialId(디투_소셜_아이디)); + final Runner 러너_디투 = runnerRepository.save(RunnerFixture.createRunner(사용자_디투)); + final String 디투_토큰 = login(디투_소셜_아이디); + + final RunnerPost 디투_게시글 = runnerPostRepository.save(RunnerPostFixture.create(러너_디투, deadline(LocalDateTime.now().plusDays(10)))); + + final Member 사용자_에단 = memberRepository.save(MemberFixture.createEthan()); + final Supporter 서포터_에단 = supporterRepository.save(SupporterFixture.create(사용자_에단)); + + 서포터가_리뷰_게시글에_리뷰_제안을_한다(디투_게시글, 서포터_에단); + + final RunnerPostUpdateRequest.SelectSupporter 서포터_선택_요청_정보 = new RunnerPostUpdateRequest.SelectSupporter(서포터_에단.getId()); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(디투_토큰) + .러너가_서포터를_선택한다(디투_게시글.getId(), 서포터_선택_요청_정보) + + .서버_응답() + .러너_게시글에_서포터가_성공적으로_선택되었는지_확인한다(new HttpStatusAndLocationHeader(HttpStatus.NO_CONTENT, "/api/v1/posts/runner")); + } + + private void 서포터가_리뷰_게시글에_리뷰_제안을_한다(final RunnerPost 지원할_게시글, final Supporter 지원한_서포터) { + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(지원할_게시글, 지원한_서포터)); + } + + @Test + void 서포터_리뷰완료_후_리뷰상태를_완료로_변경할_수_있다() { + // given + final Member 사용자_에단 = memberRepository.save(MemberFixture.createEthan()); + final Runner 글_쓴_러너 = runnerRepository.save(RunnerFixture.createRunner(사용자_에단)); + + final String 디투_소셜_아이디 = "hongSile"; + final Member 사용자_디투 = memberRepository.save(MemberFixture.createWithSocialId(디투_소셜_아이디)); + final Supporter 선택된_서포터 = supporterRepository.save(SupporterFixture.create(사용자_디투)); + final String 서포터_디투_토큰 = login(디투_소셜_아이디); + + final RunnerPost 서포터가_배정된_게시글 = runnerPostRepository.save(RunnerPostFixture.createWithReviewStatus(글_쓴_러너, 선택된_서포터, IN_PROGRESS)); + + // when, then + RunnerPostAssuredSupport + .클라이언트_요청() + .토큰으로_로그인한다(서포터_디투_토큰) + .서포터가_리뷰를_완료하고_리뷰완료_버튼을_누른다(서포터가_배정된_게시글.getId()) + + .서버_응답() + .러너_게시글이_성공적으로_리뷰_완료_상태인지_확인한다(new HttpStatusAndLocationHeader(HttpStatus.NO_CONTENT, "/api/v1/posts/runner")); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterAssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterAssuredSupport.java new file mode 100644 index 000000000..1b1721f54 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterAssuredSupport.java @@ -0,0 +1,113 @@ +package touch.baton.assure.supporter; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.response.ErrorResponse; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.controller.response.SupporterResponse; +import touch.baton.domain.supporter.service.dto.SupporterUpdateRequest; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SuppressWarnings("NonAsciiCharacters") +public class SupporterAssuredSupport { + + private SupporterAssuredSupport() { + } + + public static SupporterClientRequestBuilder 클라이언트_요청() { + return new SupporterClientRequestBuilder(); + } + + public static SupporterResponse.Profile 서포터_프로필_응답(final Supporter 서포터) { + return SupporterResponse.Profile.from(서포터); + } + + public static class SupporterClientRequestBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public SupporterClientRequestBuilder 로그인_한다(final String 토큰) { + accessToken = 토큰; + return this; + } + + public SupporterClientRequestBuilder 서포터_프로필을_서포터_식별자값으로_조회한다(final Long 서포터_식별자값) { + response = AssuredSupport.get("/api/v1/profile/supporter/{supporterId}", "supporterId", 서포터_식별자값); + return this; + } + + public SupporterClientRequestBuilder 서포터_본인_프로필을_수정한다(final SupporterUpdateRequest 서포터_업데이트_요청) { + response = AssuredSupport.patch("/api/v1/profile/supporter/me", accessToken, 서포터_업데이트_요청); + return this; + } + + public SupporterClientRequestBuilder 서포터_마이페이지를_토큰으로_조회한다() { + response = AssuredSupport.get("/api/v1/profile/supporter/me", accessToken); + return this; + } + + public SupporterServerResponseBuilder 서버_응답() { + return new SupporterServerResponseBuilder(response); + } + } + + public static class SupporterServerResponseBuilder { + + private final ExtractableResponse response; + + public SupporterServerResponseBuilder(final ExtractableResponse 응답) { + this.response = 응답; + } + + public void 서포터_프로필_조회_성공을_검증한다(final SupporterResponse.Profile 서포터_프로필_응답) { + final SupporterResponse.Profile actual = this.response.as(SupporterResponse.Profile.class); + + assertSoftly(softly -> { + softly.assertThat(actual.supporterId()).isEqualTo(서포터_프로필_응답.supporterId()); + softly.assertThat(actual.name()).isEqualTo(서포터_프로필_응답.name()); + softly.assertThat(actual.company()).isEqualTo(서포터_프로필_응답.company()); + softly.assertThat(actual.imageUrl()).isEqualTo(서포터_프로필_응답.imageUrl()); + softly.assertThat(actual.githubUrl()).isEqualTo(서포터_프로필_응답.githubUrl()); + softly.assertThat(actual.introduction()).isEqualTo(서포터_프로필_응답.introduction()); + softly.assertThat(actual.technicalTags()).isEqualTo(서포터_프로필_응답.technicalTags()); + } + ); + } + + public void 서포터_본인_프로필_수정_성공을_검증한다(final HttpStatus HTTP_STATUS) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HTTP_STATUS.value()); + softly.assertThat(response.header("Location")).isNotNull(); + }); + } + + public void 서포터_본인_프로필_수정_실패를_검증한다(final ClientErrorCode 클라이언트_에러_코드) { + final ErrorResponse actual = this.response.as(ErrorResponse.class); + + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(클라이언트_에러_코드.getHttpStatus().value()); + softly.assertThat(actual.errorCode()).isEqualTo(클라이언트_에러_코드.getErrorCode()); + }); + } + + public void 서포터_마이페이지_프로필_조회_성공을_검증한다(final SupporterResponse.MyProfile 서포터_마이페이지_프로필_응답) { + final SupporterResponse.MyProfile actual = this.response.as(SupporterResponse.MyProfile.class); + + assertSoftly(softly -> { + softly.assertThat(actual.name()).isEqualTo(서포터_마이페이지_프로필_응답.name()); + softly.assertThat(actual.company()).isEqualTo(서포터_마이페이지_프로필_응답.company()); + softly.assertThat(actual.imageUrl()).isEqualTo(서포터_마이페이지_프로필_응답.imageUrl()); + softly.assertThat(actual.githubUrl()).isEqualTo(서포터_마이페이지_프로필_응답.githubUrl()); + softly.assertThat(actual.introduction()).isEqualTo(서포터_마이페이지_프로필_응답.introduction()); + softly.assertThat(actual.technicalTags()).isEqualTo(서포터_마이페이지_프로필_응답.technicalTags()); + } + ); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterReadBySupporterIdAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterReadBySupporterIdAssuredTest.java new file mode 100644 index 000000000..7b33fda8c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterReadBySupporterIdAssuredTest.java @@ -0,0 +1,72 @@ +package touch.baton.assure.supporter; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.controller.response.SupporterResponse; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; + +import static touch.baton.assure.supporter.SupporterAssuredSupport.서포터_프로필_응답; + +@SuppressWarnings("NonAsciiCharacters") +class SupporterReadBySupporterIdAssuredTest extends AssuredTestConfig { + + @Test + void 서포터_프로필을_조회한다() { + final Member 사용자_헤나 = memberRepository.save(MemberFixture.createHyena()); + final Supporter 서포터_헤나 = supporterRepository.save(SupporterFixture.create(사용자_헤나)); + + SupporterAssuredSupport + .클라이언트_요청() + .서포터_프로필을_서포터_식별자값으로_조회한다(서포터_헤나.getId()) + + .서버_응답() + .서포터_프로필_조회_성공을_검증한다(서포터_프로필_응답(서포터_헤나)); + } + + @Test + void 서포터_마이페이지_프로필을_조회한다() { + // given + final String 디투_소셜_아이디 = "hongsile"; + final Member 사용자_디투 = memberRepository.save(MemberFixture.createWithSocialId(디투_소셜_아이디)); + + final TechnicalTag 자바_태그 = technicalTagRepository.save(TechnicalTagFixture.createJava()); + final TechnicalTag 리액트_태그 = technicalTagRepository.save(TechnicalTagFixture.createReact()); + + final Supporter 서포터_디투 = supporterRepository.save(SupporterFixture.create(사용자_디투, List.of(자바_태그, 리액트_태그))); + final String 서포터_디투_토큰 = login(디투_소셜_아이디); + + // when, then + SupporterAssuredSupport + .클라이언트_요청() + .로그인_한다(서포터_디투_토큰) + .서포터_마이페이지를_토큰으로_조회한다() + + .서버_응답() + .서포터_마이페이지_프로필_조회_성공을_검증한다(응답(서포터_디투)); + } + + private SupporterResponse.MyProfile 응답(final Supporter 서포터) { + final Member 사용자_디투 = 서포터.getMember(); + return new SupporterResponse.MyProfile( + 사용자_디투.getMemberName().getValue(), + 사용자_디투.getImageUrl().getValue(), + 사용자_디투.getGithubUrl().getValue(), + 서포터.getIntroduction().getValue(), + 사용자_디투.getCompany().getValue(), + 서포터_기술_스택(서포터) + ); + } + + private List 서포터_기술_스택(final Supporter 서포터) { + return 서포터.getSupporterTechnicalTags().getSupporterTechnicalTags().stream() + .map(supporterTechnicalTag -> supporterTechnicalTag.getTechnicalTag().getTagName().getValue()) + .toList(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostAssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostAssuredSupport.java new file mode 100644 index 000000000..bf607037e --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostAssuredSupport.java @@ -0,0 +1,59 @@ +package touch.baton.assure.supporter; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.AssuredSupport; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SuppressWarnings("NonAsciiCharacters") +public class SupporterRunnerPostAssuredSupport { + + private SupporterRunnerPostAssuredSupport() { + } + + public static SupporterRunnerPostClientRequestBuilder 클라이언트_요청() { + return new SupporterRunnerPostClientRequestBuilder(); + } + + public static class SupporterRunnerPostClientRequestBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public SupporterRunnerPostClientRequestBuilder 로그인_한다(final String 토큰) { + accessToken = 토큰; + return this; + } + + public SupporterRunnerPostClientRequestBuilder 서포터가_리뷰_제안을_취소한다(final Long 러너_게시글_식별자값) { + response = AssuredSupport.patch("/api/v1/posts/runner/{runnerPostId}/cancelation", "runnerPostId", 러너_게시글_식별자값, accessToken); + return this; + } + + public SupporterRunnerPostServerResponseBuilder 서버_응답() { + return new SupporterRunnerPostServerResponseBuilder(response); + } + } + + public static class SupporterRunnerPostServerResponseBuilder { + + private final ExtractableResponse response; + + public SupporterRunnerPostServerResponseBuilder(final ExtractableResponse 응답) { + this.response = 응답; + } + + public void 서포터의_리뷰_제안_철회를_검증한다(final HttpStatus HTTP_STATUS, + final String 응답_헤더_이름, + final String 응답_헤더_값 + ) { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HTTP_STATUS.value()); + softly.assertThat(response.header(응답_헤더_이름)).isEqualTo(응답_헤더_값); + }); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostDeleteAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostDeleteAssuredTest.java new file mode 100644 index 000000000..c300d5ae0 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterRunnerPostDeleteAssuredTest.java @@ -0,0 +1,66 @@ +package touch.baton.assure.supporter; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; + +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.HttpStatus.NO_CONTENT; + +@SuppressWarnings("NonAsciiCharacters") +public class SupporterRunnerPostDeleteAssuredTest extends AssuredTestConfig { + + @Test + void 러너_게시글에_보낸_리뷰_제안을_취소한다() { + final String 로그인한_서포터의_소셜_id = "ditooSocialId"; + final Supporter 로그인한_서포터 = 로그인한_서포터를_저장한다(로그인한_서포터의_소셜_id); + final String 로그인한_서포터의_액세스_토큰 = login(로그인한_서포터의_소셜_id); + + final Runner 리뷰_받고_싶은_러너 = 리뷰_받고_싶은_러너를_저장한다(); + final RunnerPost 리뷰_받을_게시글 = 리뷰_받을_게시글을_생성한다(리뷰_받고_싶은_러너); + 서포터가_러너_게시글에_리뷰_제안한다(로그인한_서포터, 리뷰_받을_게시글); + + final String 응답_헤더_이름 = LOCATION; + final String 응답_헤더_값 = "/api/v1/posts/runner/" + 리뷰_받을_게시글.getId(); + + SupporterRunnerPostAssuredSupport + .클라이언트_요청() + .로그인_한다(로그인한_서포터의_액세스_토큰) + .서포터가_리뷰_제안을_취소한다(리뷰_받을_게시글.getId()) + + .서버_응답() + .서포터의_리뷰_제안_철회를_검증한다(NO_CONTENT, 응답_헤더_이름, 응답_헤더_값); + } + + private Supporter 로그인한_서포터를_저장한다(final String 소셜_id) { + final Member 사용자 = memberRepository.save(MemberFixture.createWithSocialId(소셜_id)); + return supporterRepository.save(SupporterFixture.create(사용자)); + } + + private Runner 리뷰_받고_싶은_러너를_저장한다() { + final Member 사용자 = memberRepository.save(MemberFixture.createEthan()); + return runnerRepository.save(RunnerFixture.createRunner(사용자)); + } + + private RunnerPost 리뷰_받을_게시글을_생성한다(final Runner 리뷰_받을_러너) { + final RunnerPost 러너_게시글 = RunnerPostFixture.create(리뷰_받을_러너, new Deadline(LocalDateTime.now().plusHours(100))); + return runnerPostRepository.save(러너_게시글); + } + + private void 서포터가_러너_게시글에_리뷰_제안한다(final Supporter 서포터, final RunnerPost 러너_게시글) { + final SupporterRunnerPost 리뷰_제안 = SupporterRunnerPostFixture.create(러너_게시글, 서포터); + supporterRunnerPostRepository.save(리뷰_제안); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterUpdateAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterUpdateAssuredTest.java new file mode 100644 index 000000000..87f0448b7 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/supporter/SupporterUpdateAssuredTest.java @@ -0,0 +1,97 @@ +package touch.baton.assure.supporter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.service.dto.SupporterUpdateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.List; + +import static org.springframework.http.HttpStatus.NO_CONTENT; +import static touch.baton.domain.common.exception.ClientErrorCode.COMPANY_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.NAME_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.SUPPORTER_INTRODUCTION_IS_NULL; +import static touch.baton.domain.common.exception.ClientErrorCode.SUPPORTER_TECHNICAL_TAGS_ARE_NULL; + +@SuppressWarnings("NonAsciiCharacters") +public class SupporterUpdateAssuredTest extends AssuredTestConfig { + + private String 디투_액세스_토큰; + + @BeforeEach + void setUp() { + final String 디투_소셜_id = "ditooSocialId"; + final Member 사용자_디투 = memberRepository.save(MemberFixture.createWithSocialId(디투_소셜_id)); + final Supporter 서포터_디투 = supporterRepository.save(SupporterFixture.create(사용자_디투)); + 디투_액세스_토큰 = login(디투_소셜_id); + } + + @Test + void 서포터_정보를_수정한다() { + final SupporterUpdateRequest 서포터_수정_요청_값 = new SupporterUpdateRequest("디투랜드", "우아한테크코스", "안녕하세요.", List.of("java", "spring")); + + SupporterAssuredSupport + .클라이언트_요청() + .로그인_한다(디투_액세스_토큰) + .서포터_본인_프로필을_수정한다(서포터_수정_요청_값) + + .서버_응답() + .서포터_본인_프로필_수정_성공을_검증한다(NO_CONTENT); + } + + @Test + void 서포터_정보_수정_시에_이름이_없으면_예외가_발생한다() { + final SupporterUpdateRequest 서포터_수정_요청_값 = new SupporterUpdateRequest(null, "우아한테크코스", "안녕하세요.", List.of("java", "spring")); + + SupporterAssuredSupport + .클라이언트_요청() + .로그인_한다(디투_액세스_토큰) + .서포터_본인_프로필을_수정한다(서포터_수정_요청_값) + + .서버_응답() + .서포터_본인_프로필_수정_실패를_검증한다(NAME_IS_NULL); + } + + @Test + void 서포터_정보_수정_시에_소속이_없으면_예외가_발생한다() { + final SupporterUpdateRequest 서포터_수정_요청_값 = new SupporterUpdateRequest("디투랜드", null, "안녕하세요.", List.of("java", "spring")); + + SupporterAssuredSupport + .클라이언트_요청() + .로그인_한다(디투_액세스_토큰) + .서포터_본인_프로필을_수정한다(서포터_수정_요청_값) + + .서버_응답() + .서포터_본인_프로필_수정_실패를_검증한다(COMPANY_IS_NULL); + } + + @Test + void 서포터_정보_수정_시에_소개글이_없으면_예외가_발생한다() { + final SupporterUpdateRequest 서포터_수정_요청_값 = new SupporterUpdateRequest("디투랜드", "배달의민족", null, List.of("java", "spring")); + + SupporterAssuredSupport + .클라이언트_요청() + .로그인_한다(디투_액세스_토큰) + .서포터_본인_프로필을_수정한다(서포터_수정_요청_값) + + .서버_응답() + .서포터_본인_프로필_수정_실패를_검증한다(SUPPORTER_INTRODUCTION_IS_NULL); + } + + @Test + void 서포터_정보_수정_시에_기술_태그가_없으면_예외가_발생한다() { + final SupporterUpdateRequest 서포터_수정_요청_값 = new SupporterUpdateRequest("디투랜드", "배달의민족", "배달왕이 될거에요.", null); + + SupporterAssuredSupport + .클라이언트_요청() + .로그인_한다(디투_액세스_토큰) + .서포터_본인_프로필을_수정한다(서포터_수정_요청_값) + + .서버_응답() + .서포터_본인_프로필_수정_실패를_검증한다(SUPPORTER_TECHNICAL_TAGS_ARE_NULL); + } +} diff --git a/backend/baton/src/test/java/touch/baton/common/schedule/RunnerPostDeadlineCheckSchedulerTest.java b/backend/baton/src/test/java/touch/baton/common/schedule/RunnerPostDeadlineCheckSchedulerTest.java new file mode 100644 index 000000000..1cb5b3622 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/common/schedule/RunnerPostDeadlineCheckSchedulerTest.java @@ -0,0 +1,58 @@ +package touch.baton.common.schedule; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.OVERDUE; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class RunnerPostDeadlineCheckSchedulerTest extends ServiceTestConfig { + + @Autowired + private ScheduleRunnerPostRepository scheduleRunnerPostRepository; + + private RunnerPostDeadlineCheckScheduler runnerPostDeadlineCheckScheduler; + + @BeforeEach + void setUp() { + runnerPostDeadlineCheckScheduler = new RunnerPostDeadlineCheckScheduler(scheduleRunnerPostRepository); + } + + @Autowired + private EntityManager em; + + @DisplayName("1분 전에 deadline 이 지난 runnerPost 는 OVERDUE 된다.") + @Test + void runnerPost_which_passed_deadline_might_be_overdue() { + // given + final Member member = MemberFixture.createEthan(); + em.persist(member); + final Runner runner = RunnerFixture.createRunner(member); + em.persist(runner); + final Deadline passedDeadline = deadline(LocalDateTime.now().minusMinutes(1)); + final RunnerPost runnerPost = RunnerPostFixture.create(runner, passedDeadline); + em.persist(runnerPost); + + // when + runnerPostDeadlineCheckScheduler.updateReviewStatus(); + final RunnerPost actual = em.createQuery("select rp from RunnerPost rp", RunnerPost.class) + .getSingleResult(); + + // then + assertThat(actual.getReviewStatus()).isEqualTo(OVERDUE); + } +} diff --git a/backend/baton/src/test/java/touch/baton/common/schedule/ScheduleRunnerPostRepositoryTest.java b/backend/baton/src/test/java/touch/baton/common/schedule/ScheduleRunnerPostRepositoryTest.java new file mode 100644 index 000000000..130730e44 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/common/schedule/ScheduleRunnerPostRepositoryTest.java @@ -0,0 +1,107 @@ +package touch.baton.common.schedule; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.*; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class ScheduleRunnerPostRepositoryTest extends RepositoryTestConfig { + + @Autowired + private ScheduleRunnerPostRepository scheduleRunnerPostRepository; + + @Autowired + private EntityManager em; + + private Runner runner; + + @BeforeEach + void setUp() { + final Member member = MemberFixture.createDitoo(); + em.persist(member); + runner = RunnerFixture.createRunner(member); + em.persist(runner); + } + + @DisplayName("deadline 이 지난 NOT_STARTED 상태의 runnerPost 는 OVERDUE 상태로 된다.") + @Test + void updateAllPassedDeadline_success() { + // given + final Deadline passedDeadlineOne = deadline(LocalDateTime.now().minusMinutes(1)); + final Deadline passedDeadlineTwo = deadline(LocalDateTime.now().minusMinutes(1)); + final RunnerPost runnerPostOne = RunnerPostFixture.create(runner, passedDeadlineOne); + final RunnerPost runnerPostTwo = RunnerPostFixture.create(runner, passedDeadlineTwo); + em.persist(runnerPostOne); + em.persist(runnerPostTwo); + + // when + scheduleRunnerPostRepository.updateAllPassedDeadline(); + final List actual = scheduleRunnerPostRepository.findAll().stream() + .map(RunnerPost::getReviewStatus) + .toList(); + + // then + assertThat(actual).containsExactly(OVERDUE, OVERDUE); + } + + @DisplayName("deadline 이 지난 DONE 상태의 runnerPost 는 리뷰 상태가 업데이트 되지 않는다.") + @Test + void updateAllPassedDeadline_fail_when_reviewStatus_is_DONE() { + // given + final Deadline passedDeadlineOne = deadline(LocalDateTime.now().minusHours(10)); + final Deadline passedDeadlineTwo = deadline(LocalDateTime.now().minusHours(5)); + final ReviewStatus expectedReviewStatus = DONE; + final RunnerPost runnerPostOne = RunnerPostFixture.create(runner, passedDeadlineOne, expectedReviewStatus); + final RunnerPost runnerPostTwo = RunnerPostFixture.create(runner, passedDeadlineTwo, expectedReviewStatus); + em.persist(runnerPostOne); + em.persist(runnerPostTwo); + + // when + scheduleRunnerPostRepository.updateAllPassedDeadline(); + final List actual = scheduleRunnerPostRepository.findAll().stream() + .map(RunnerPost::getReviewStatus) + .toList(); + + // then + assertThat(actual).containsExactly(expectedReviewStatus, expectedReviewStatus); + } + + @DisplayName("deadline 이 지나지 않은 NOT_STARTED 상태의 runnerPost 는 리뷰 상태가 업데이트 되지 않는다.") + @Test + void updateAllPassedDeadline_fail_when_deadline_is_not_passed() { + // given + final Deadline passedDeadlineOne = deadline(LocalDateTime.now().plusHours(10)); + final Deadline passedDeadlineTwo = deadline(LocalDateTime.now().plusHours(5)); + final ReviewStatus expectedReviewStatus = NOT_STARTED; + final RunnerPost runnerPostOne = RunnerPostFixture.create(runner, passedDeadlineOne, expectedReviewStatus); + final RunnerPost runnerPostTwo = RunnerPostFixture.create(runner, passedDeadlineTwo, expectedReviewStatus); + em.persist(runnerPostOne); + em.persist(runnerPostTwo); + + // when + scheduleRunnerPostRepository.updateAllPassedDeadline(); + final List actual = scheduleRunnerPostRepository.findAll().stream() + .map(RunnerPost::getReviewStatus) + .toList(); + + // then + assertThat(actual).containsExactly(expectedReviewStatus, expectedReviewStatus); + } +} diff --git a/backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java b/backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java new file mode 100644 index 000000000..3bd2e8b24 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java @@ -0,0 +1,69 @@ +package touch.baton.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestExecutionListeners; +import touch.baton.config.converter.ConverterConfig; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.repository.RunnerPostRepository; +import touch.baton.domain.supporter.repository.SupporterRepository; +import touch.baton.domain.supporter.repository.SupporterRunnerPostRepository; +import touch.baton.domain.technicaltag.repository.TechnicalTagRepository; +import touch.baton.infra.auth.jwt.JwtDecoder; + +import java.util.UUID; + +import static org.mockito.BDDMockito.when; + +@Import({JpaConfig.class, ConverterConfig.class, PageableTestConfig.class}) +@TestExecutionListeners(value = AssuredTestExecutionListener.class, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class AssuredTestConfig { + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected RunnerRepository runnerRepository; + + @Autowired + protected SupporterRepository supporterRepository; + + @Autowired + protected RunnerPostRepository runnerPostRepository; + + @Autowired + protected SupporterRunnerPostRepository supporterRunnerPostRepository; + + @Autowired + protected TechnicalTagRepository technicalTagRepository; + + @MockBean + private JwtDecoder jwtDecoder; + + @BeforeEach + void assuredTestSetUp(@LocalServerPort int port) { + RestAssured.port = port; + } + + public String login(final String socialId) { + final String token = UUID.randomUUID().toString(); + final Claims claims = Jwts.claims(); + claims.put("socialId", socialId); + + when(jwtDecoder.parseJwtToken(token)).thenReturn(claims); + + return token; + } +} diff --git a/backend/baton/src/test/java/touch/baton/config/AssuredTestExecutionListener.java b/backend/baton/src/test/java/touch/baton/config/AssuredTestExecutionListener.java new file mode 100644 index 000000000..834ea8bc2 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/AssuredTestExecutionListener.java @@ -0,0 +1,39 @@ +package touch.baton.config; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; + +import java.util.List; + +public class AssuredTestExecutionListener extends AbstractTestExecutionListener { + + @Override + public void afterTestMethod(final TestContext testContext) { + final JdbcTemplate jdbcTemplate = getJdbcTemplate(testContext); + final List truncateQueries = getTruncateQueries(jdbcTemplate); + truncateTables(jdbcTemplate, truncateQueries); + } + + private List getTruncateQueries(final JdbcTemplate jdbcTemplate) { + return jdbcTemplate.queryForList(""" + SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = 'PUBLIC' + """, String.class); + } + + private JdbcTemplate getJdbcTemplate(final TestContext testContext) { + return testContext.getApplicationContext().getBean(JdbcTemplate.class); + } + + private void truncateTables(final JdbcTemplate jdbcTemplate, final List truncateQueries) { + execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY FALSE"); + truncateQueries.forEach(v -> execute(jdbcTemplate, v)); + execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY TRUE"); + } + + private void execute(final JdbcTemplate jdbcTemplate, final String query) { + jdbcTemplate.execute(query); + } +} diff --git a/backend/baton/src/test/java/touch/baton/config/PageableTestConfig.java b/backend/baton/src/test/java/touch/baton/config/PageableTestConfig.java new file mode 100644 index 000000000..178bed313 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/PageableTestConfig.java @@ -0,0 +1,14 @@ +package touch.baton.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.web.config.PageableHandlerMethodArgumentResolverCustomizer; + +@TestConfiguration +public abstract class PageableTestConfig { + + @Bean + public PageableHandlerMethodArgumentResolverCustomizer pageableResolverCustomizer() { + return pageableResolver -> pageableResolver.setOneIndexedParameters(true); + } +} diff --git a/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java b/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java new file mode 100644 index 000000000..a366cfec7 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java @@ -0,0 +1,9 @@ +package touch.baton.config; + +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@Import(JpaConfig.class) +@DataJpaTest +public abstract class RepositoryTestConfig { +} diff --git a/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java b/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java new file mode 100644 index 000000000..251c8d122 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java @@ -0,0 +1,127 @@ +package touch.baton.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.restdocs.ManualRestDocumentation; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import touch.baton.config.converter.ConverterConfig; +import touch.baton.config.converter.OauthTypeConverter; +import touch.baton.config.converter.ReviewStatusConverter; +import touch.baton.domain.oauth.controller.resolver.AuthMemberPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthSupporterPrincipalArgumentResolver; +import touch.baton.domain.oauth.repository.OauthMemberRepository; +import touch.baton.domain.oauth.repository.OauthRunnerRepository; +import touch.baton.domain.oauth.repository.OauthSupporterRepository; +import touch.baton.infra.auth.jwt.JwtDecoder; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +@ExtendWith(RestDocumentationExtension.class) +@Import({RestDocsResultConfig.class, ConverterConfig.class}) +public abstract class RestdocsConfig { + + protected MockMvc mockMvc; + protected AuthMemberPrincipalArgumentResolver authMemberPrincipalArgumentResolver; + protected AuthRunnerPrincipalArgumentResolver authRunnerPrincipalArgumentResolver; + protected AuthSupporterPrincipalArgumentResolver authSupporterPrincipalArgumentResolver; + + @Autowired + protected RestDocumentationResultHandler restDocs; + + @Autowired + protected RestDocumentationContextProvider restDocumentation; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected JwtDecoder jwtDecoder; + + @MockBean + protected OauthMemberRepository oauthMemberRepository; + + @MockBean + protected OauthRunnerRepository oauthRunnerRepository; + + @MockBean + protected OauthSupporterRepository oauthSupporterRepository; + + @Autowired + protected MappingJackson2HttpMessageConverter jackson2HttpMessageConverter; + + @Autowired + protected Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder; + + protected void restdocsSetUp(final Object controller) { + authMemberPrincipalArgumentResolver = new AuthMemberPrincipalArgumentResolver(jwtDecoder, oauthMemberRepository); + authRunnerPrincipalArgumentResolver = new AuthRunnerPrincipalArgumentResolver(jwtDecoder, oauthRunnerRepository); + authSupporterPrincipalArgumentResolver = new AuthSupporterPrincipalArgumentResolver(jwtDecoder, oauthSupporterRepository); + + final FormattingConversionService formattingConversionService = new FormattingConversionService(); + formattingConversionService.addConverter(new OauthTypeConverter()); + formattingConversionService.addConverter(new ReviewStatusConverter()); + + this.mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers( + authMemberPrincipalArgumentResolver, + authRunnerPrincipalArgumentResolver, + authSupporterPrincipalArgumentResolver, + new PageableHandlerMethodArgumentResolver()) + .apply(documentationConfiguration(restDocumentation)) + .setConversionService(formattingConversionService) + .setMessageConverters(jackson2HttpMessageConverter) + .alwaysDo(restDocs) + .build(); + } + + protected String getAccessTokenBySocialId(final String socialId) { + final String token = UUID.randomUUID().toString(); + final Claims claims = Jwts.claims(); + claims.put("socialId", socialId); + + when(jwtDecoder.parseJwtToken(any())).thenReturn(claims); + + return token; + } +} + +@TestConfiguration +class RestDocsResultConfig { + + @Bean + RestDocumentationResultHandler restDocumentationResultHandler() { + return MockMvcRestDocumentation.document("{class-name}/{method-name}", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ); + } + + @Bean + RestDocumentationContextProvider restDocumentationContextProvider() { + return new ManualRestDocumentation(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java b/backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java new file mode 100644 index 000000000..6d123dd5e --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java @@ -0,0 +1,50 @@ +package touch.baton.config; + +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.domain.feedback.repository.SupporterFeedbackRepository; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.repository.RunnerPostRepository; +import touch.baton.domain.supporter.repository.SupporterRepository; +import touch.baton.domain.supporter.repository.SupporterRunnerPostRepository; +import touch.baton.domain.tag.repository.RunnerPostTagRepository; +import touch.baton.domain.tag.repository.TagRepository; +import touch.baton.domain.technicaltag.repository.RunnerTechnicalTagRepository; +import touch.baton.domain.technicaltag.repository.SupporterTechnicalTagRepository; +import touch.baton.domain.technicaltag.repository.TechnicalTagRepository; + +public abstract class ServiceTestConfig extends RepositoryTestConfig { + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected RunnerRepository runnerRepository; + + @Autowired + protected SupporterRepository supporterRepository; + + @Autowired + protected RunnerPostRepository runnerPostRepository; + + @Autowired + protected SupporterRunnerPostRepository supporterRunnerPostRepository; + + @Autowired + protected RunnerPostTagRepository runnerPostTagRepository; + + @Autowired + protected TagRepository tagRepository; + + @Autowired + protected SupporterFeedbackRepository supporterFeedbackRepository; + + @Autowired + protected TechnicalTagRepository technicalTagRepository; + + @Autowired + protected RunnerTechnicalTagRepository runnerTechnicalTagRepository; + + @Autowired + protected SupporterTechnicalTagRepository supporterTechnicalTagRepository; +} diff --git a/backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java b/backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java new file mode 100644 index 000000000..5ec46c862 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/converter/ConverterConfigTest.java @@ -0,0 +1,75 @@ +package touch.baton.config.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import touch.baton.config.ArgumentResolverConfig; +import touch.baton.domain.oauth.controller.resolver.AuthMemberPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthRunnerPrincipalArgumentResolver; +import touch.baton.domain.oauth.controller.resolver.AuthSupporterPrincipalArgumentResolver; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@WebMvcTest(controllers = ConverterConfig.class, excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + ArgumentResolverConfig.class, + AuthRunnerPrincipalArgumentResolver.class, + AuthSupporterPrincipalArgumentResolver.class, + AuthMemberPrincipalArgumentResolver.class +})) +class ConverterConfigTest { + + @Autowired + private ObjectMapper objectMapper; + + @DisplayName("직렬화 할 때 LocalDateTime yyyy-MM-dd'T'HH:mm 형식으로 변경된다.") + @Test + void serializeStringDateToLocalDateTime() throws JsonProcessingException { + // given + final LocalDateTime expected = LocalDateTime.of(2023, 8, 15, 12, 13); + final LocalDateTime expectedWithSecond = LocalDateTime.of(2023, 8, 15, 12, 13, 12); + + // when + final String actual = objectMapper.writeValueAsString(expected); + final String actualWithSecond = objectMapper.writeValueAsString(expectedWithSecond); + + // then + assertAll( + () -> assertThat(actual).isEqualTo("\"2023-08-15T12:13\""), + () -> assertThat(actualWithSecond).isEqualTo("\"2023-08-15T12:13\"") + ); + } + + @DisplayName("역직렬화 할 때, yyyy-MM-dd'T'HH:mm 형식이 LocalDateTime 으로 변경된다.") + @Test + void success_deserializeLocalDateTimeToStringDate() throws JsonProcessingException { + // given + final String expected = "\"2023-08-15T12:13\""; + + // when + final LocalDateTime actual = objectMapper.readValue(expected, LocalDateTime.class); + + // then + assertThat(actual).isEqualTo(LocalDateTime.of(2023, 8, 15, 12, 13)); + } + + @DisplayName("역직렬화 할 때, 맞지 않는 형식의 날짜 String 이 들어오면 LocalDateTime 으로 변환이 실패한다.") + @ValueSource(strings = {"\"2023-08-15T12:13:45\"", "\"2023-08-15 12:13\"", "\"2023/08/15T12:13\"", "\"2023/08/15 12:13\""}) + @ParameterizedTest + void fail_deserializeLocalDateTimeToStringDate(final String expected) { + // when, then + assertThatThrownBy(() -> objectMapper.readValue(expected, LocalDateTime.class)) + .isInstanceOf(InvalidFormatException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/config/converter/StringDateToLocalDateTimeConverterTest.java b/backend/baton/src/test/java/touch/baton/config/converter/StringDateToLocalDateTimeConverterTest.java new file mode 100644 index 000000000..52309dafa --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/config/converter/StringDateToLocalDateTimeConverterTest.java @@ -0,0 +1,44 @@ +package touch.baton.config.converter; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.TimeZone; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StringDateToLocalDateTimeConverterTest { + + private static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm"; + private static final TimeZone KOREA_TIME_ZONE = TimeZone.getTimeZone("Asia/Seoul"); + + @DisplayName("String 값 Date 가 LocalDateTime 으로 변경되는지 확인한다.") + @Test + void success_convertStringDateToLocalDateTime() { + // given + final String expect = "2023-08-18T09:45"; + final StringDateToLocalDateTimeConverter converter = new StringDateToLocalDateTimeConverter(DEFAULT_DATE_TIME_FORMAT, KOREA_TIME_ZONE); + + // when + final LocalDateTime actual = converter.convert(expect); + + // then + assertThat(actual).isEqualTo(LocalDateTime.of(2023, 8, 18, 9, 45)); + } + + @DisplayName("형식에 맞지 않는 String 값 Date 는 convert 할 때 예외를 던진다.") + @ValueSource(strings = {"2023-08-18T09:45:12", "2023-08-18 09:45", "2023/08/18 09:45", "2023.08.18 09:45"}) + @ParameterizedTest + void fail_convertInvalidStringDateToLocalDateTime(final String expect) { + // given + final StringDateToLocalDateTimeConverter converter = new StringDateToLocalDateTimeConverter(DEFAULT_DATE_TIME_FORMAT, KOREA_TIME_ZONE); + + // when, then + assertThatThrownBy(() -> converter.convert(expect)).isInstanceOf(DateTimeParseException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java b/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java new file mode 100644 index 000000000..3681e2af1 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java @@ -0,0 +1,93 @@ +package touch.baton.document.oauth.github; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.oauth.controller.OauthController; +import touch.baton.domain.oauth.service.OauthService; +import touch.baton.infra.auth.oauth.github.GithubOauthConfig; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.BDDMockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.domain.oauth.OauthType.GITHUB; + +@EnableConfigurationProperties(GithubOauthConfig.class) +@TestPropertySource("classpath:application.yml") +@WebMvcTest(OauthController.class) +class GithubOauthApiTest extends RestdocsConfig { + + @MockBean + private OauthService oauthService; + + @Autowired + private GithubOauthConfig githubOauthConfig; + + @BeforeEach + void setUp() { + final OauthController oauthController = new OauthController(oauthService); + restdocsSetUp(oauthController); + } + + @DisplayName("Github 소셜 로그인을 위한 AuthCode 를 받을 수 있도록 사용자를 redirect 한다.") + @Test + void github_redirect_auth_code() throws Exception { + // given & when + when(oauthService.readAuthCodeRedirect(GITHUB)) + .thenReturn(githubOauthConfig.redirectUri()); + + // then + mockMvc.perform(get("/api/v1/oauth/{oauthType}", "github")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(githubOauthConfig.redirectUri())) + .andDo(restDocs.document( + pathParameters( + parameterWithName("oauthType").description("소셜 로그인 타입") + ) + )) + .andDo(print()); + } + + @DisplayName("Github 소셜 로그인을 위해 AuthCode 를 받아 SocialToken 으로 교환하여 Github 프로필 정보를 찾아오고 미가입 사용자일 경우 자동으로 회원가입을 진행하고 JWT 로 변환하여 클라이언트에게 넘겨준다.") + @Test + void github_login() throws Exception { + // given & when + when(oauthService.login(GITHUB, "authcode")) + .thenReturn("Bearer Jwt"); + + // then + mockMvc.perform(get("/api/v1/oauth/login/{oauthType}", "github") + .queryParam("code", "authcode") + .contentType(APPLICATION_JSON) + .characterEncoding(UTF_8) + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("oauthType").description("소셜 로그인 타입") + ), + queryParameters( + parameterWithName("code").description("소셜로부터 redirect 하여 받은 AuthCode") + ), + responseHeaders( + headerWithName("Authorization").description("Json Web Token") + ) + )) + .andDo(print()); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/profile/member/read/MemberReadWithLoginedMemberApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/member/read/MemberReadWithLoginedMemberApiTest.java new file mode 100644 index 000000000..849b6b086 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/profile/member/read/MemberReadWithLoginedMemberApiTest.java @@ -0,0 +1,70 @@ +package touch.baton.document.profile.member.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.controller.MemberProfileController; +import touch.baton.fixture.domain.MemberFixture; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.CompanyFixture.company; +import static touch.baton.fixture.vo.GithubUrlFixture.githubUrl; +import static touch.baton.fixture.vo.ImageUrlFixture.imageUrl; +import static touch.baton.fixture.vo.MemberNameFixture.memberName; +import static touch.baton.fixture.vo.OauthIdFixture.oauthId; +import static touch.baton.fixture.vo.SocialIdFixture.socialId; + +@WebMvcTest(MemberProfileController.class) +public class MemberReadWithLoginedMemberApiTest extends RestdocsConfig { + + @BeforeEach + void setUp() { + restdocsSetUp(new MemberProfileController()); + } + + @DisplayName("로그인 한 맴버 정보 조회 API") + @Test + void readLoginMemberByAccessToken() throws Exception { + // given + final String socialId = "ditooSocialId"; + final Member member = MemberFixture.create( + memberName("디투"), + socialId(socialId), + oauthId("abcd"), + githubUrl("naver.com"), + company("우아한테크코스"), + imageUrl("profile.jpg") + ); + final String token = getAccessTokenBySocialId(socialId); + + // when + when(oauthMemberRepository.findBySocialId(any())).thenReturn(Optional.ofNullable(member)); + + // then + mockMvc.perform(get("/api/v1/profile/me").header(AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(headerWithName(AUTHORIZATION).description("Bearer JWT")), + responseFields( + fieldWithPath("name").type(STRING).description("사용자 이름"), + fieldWithPath("imageUrl").type(STRING).description("사용자 프로필 이미지 url") + ) + )) + .andDo(print()); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java new file mode 100644 index 000000000..7ed538a3a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java @@ -0,0 +1,116 @@ +package touch.baton.document.profile.runner.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.RunnerProfileController; +import touch.baton.domain.runner.service.RunnerService; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; +import java.util.Optional; + +import static javax.swing.text.html.parser.DTDConstants.NUMBER; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.BDDMockito.when; +import static org.mockito.Mockito.spy; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerProfileController.class) +class RunnerReadByGuestApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @MockBean + private RunnerService runnerService; + + @BeforeEach + void setUp() { + restdocsSetUp(new RunnerProfileController(runnerPostService, runnerService)); + } + + @DisplayName("러너 본인 프로필 조회 API") + @Test + void readMyProfileByToken() throws Exception { + // given + final TechnicalTag java = TechnicalTagFixture.create(tagName("java")); + final TechnicalTag spring = TechnicalTagFixture.create(tagName("spring")); + final Runner runner = RunnerFixture.createRunner(MemberFixture.createHyena(), List.of(java, spring)); + final String token = getAccessTokenBySocialId(runner.getMember().getSocialId().getValue()); + + when(oauthRunnerRepository.joinByMemberSocialId(notNull())).thenReturn(Optional.ofNullable(runner)); + + // then + mockMvc.perform(get("/api/v1/profile/runner/me") + .header(AUTHORIZATION, "Bearer " + token)) + .andDo(print()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ), + responseFields( + fieldWithPath("name").type(STRING).description("러너 이름"), + fieldWithPath("company").type(STRING).description("러너 소속 회사"), + fieldWithPath("imageUrl").type(STRING).description("러너 프로필 이미지 url"), + fieldWithPath("githubUrl").type(STRING).description("러너 깃허브 url"), + fieldWithPath("introduction").type(STRING).description("러너 자기소개"), + fieldWithPath("technicalTags").type(ARRAY).description("러너 기술 태그 목록") + ) + )) + .andDo(print()); + } + + @DisplayName("러너 프로필 상세 조회 API") + @Test + void readRunnerProfile() throws Exception { + // given + final Member ethan = MemberFixture.createEthan(); + final TechnicalTag javaTag = TechnicalTagFixture.createJava(); + final TechnicalTag reactTag = TechnicalTagFixture.createReact(); + final Runner runner = RunnerFixture.createRunner(ethan, List.of(javaTag, reactTag)); + final Runner spyRunner = spy(runner); + + // when + when(spyRunner.getId()).thenReturn(1L); + when(runnerService.readByRunnerId(anyLong())).thenReturn(spyRunner); + + // then + mockMvc.perform(get("/api/v1/profile/runner/{runnerId}", 1L)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + responseFields( + fieldWithPath("runnerId").type(NUMBER).description("러너 식별자값"), + fieldWithPath("name").type(STRING).description("러너 이름"), + fieldWithPath("imageUrl").type(STRING).description("사용자 이미지"), + fieldWithPath("githubUrl").type(STRING).description("깃허브 프로필 url"), + fieldWithPath("introduction").type(STRING).description("소개"), + fieldWithPath("company").type(STRING).description("소속"), + fieldWithPath("technicalTags").type(ARRAY).description("기술 스택") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByRunnerIdApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByRunnerIdApiTest.java new file mode 100644 index 000000000..875d291fc --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByRunnerIdApiTest.java @@ -0,0 +1,84 @@ +package touch.baton.document.profile.runner.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.RunnerProfileController; +import touch.baton.domain.runner.service.RunnerService; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; + +import static javax.swing.text.html.parser.DTDConstants.NUMBER; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(RunnerProfileController.class) +public class RunnerReadByRunnerIdApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @MockBean + private RunnerService runnerService; + + @BeforeEach + void setUp() { + final RunnerProfileController runnerProfileController = new RunnerProfileController(runnerPostService, runnerService); + restdocsSetUp(runnerProfileController); + } + + @DisplayName("러너 프로필 상세 조회 API") + @Test + void readRunnerProfile() throws Exception { + // given + final Member ethan = MemberFixture.createEthan(); + final TechnicalTag javaTag = TechnicalTagFixture.createJava(); + final TechnicalTag reactTag = TechnicalTagFixture.createReact(); + final Runner runner = RunnerFixture.createRunner(ethan, List.of(javaTag, reactTag)); + final Runner spyRunner = spy(runner); + + // when + when(spyRunner.getId()).thenReturn(1L); + when(runnerService.readByRunnerId(anyLong())).thenReturn(spyRunner); + + // then + mockMvc.perform(get("/api/v1/profile/runner/{runnerId}", 1L)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + pathParameters( + parameterWithName("runnerId").description("러너 식별자값") + ), + responseFields( + fieldWithPath("runnerId").type(NUMBER).description("러너 식별자값"), + fieldWithPath("name").type(STRING).description("러너 이름"), + fieldWithPath("imageUrl").type(STRING).description("사용자 이미지"), + fieldWithPath("githubUrl").type(STRING).description("깃허브 프로필 url"), + fieldWithPath("introduction").type(STRING).description("소개"), + fieldWithPath("company").type(STRING).description("소속"), + fieldWithPath("technicalTags").type(ARRAY).description("기술 스택") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadWithLoginedRunnerApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadWithLoginedRunnerApiTest.java new file mode 100644 index 000000000..71e4997e7 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadWithLoginedRunnerApiTest.java @@ -0,0 +1,78 @@ +package touch.baton.document.profile.runner.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.RunnerProfileController; +import touch.baton.domain.runner.service.RunnerService; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.BDDMockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerProfileController.class) +class RunnerReadWithLoginedRunnerApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @MockBean + private RunnerService runnerService; + + @BeforeEach + void setUp() { + final RunnerProfileController runnerProfileController = new RunnerProfileController(runnerPostService, runnerService); + restdocsSetUp(runnerProfileController); + } + + @DisplayName("러너 본인 프로필 조회 API") + @Test + void readMyProfileByToken() throws Exception { + // given + final TechnicalTag java = TechnicalTagFixture.create(tagName("java")); + final TechnicalTag spring = TechnicalTagFixture.create(tagName("spring")); + final Runner runner = RunnerFixture.createRunner(MemberFixture.createHyena(), List.of(java, spring)); + final String token = getAccessTokenBySocialId(runner.getMember().getSocialId().getValue()); + + when(oauthRunnerRepository.joinByMemberSocialId(notNull())).thenReturn(Optional.ofNullable(runner)); + + // then + mockMvc.perform(get("/api/v1/profile/runner/me") + .header(AUTHORIZATION, "Bearer " + token)) + .andDo(print()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ), + responseFields( + fieldWithPath("name").type(STRING).description("러너 이름"), + fieldWithPath("company").type(STRING).description("러너 소속 회사"), + fieldWithPath("imageUrl").type(STRING).description("러너 프로필 이미지 url"), + fieldWithPath("githubUrl").type(STRING).description("러너 깃허브 url"), + fieldWithPath("introduction").type(STRING).description("러너 자기소개"), + fieldWithPath("technicalTags").type(ARRAY).description("러너 기술 태그 목록") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/profile/runner/update/RunnerUpdateApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/runner/update/RunnerUpdateApiTest.java new file mode 100644 index 000000000..bea871752 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/profile/runner/update/RunnerUpdateApiTest.java @@ -0,0 +1,90 @@ +package touch.baton.document.profile.runner.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.controller.RunnerProfileController; +import touch.baton.domain.runner.service.RunnerService; +import touch.baton.domain.runner.service.dto.RunnerUpdateRequest; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(RunnerProfileController.class) +public class RunnerUpdateApiTest extends RestdocsConfig { + + @MockBean + private RunnerService runnerService; + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerProfileController runnerProfileController = new RunnerProfileController(runnerPostService, runnerService); + restdocsSetUp(runnerProfileController); + } + + @DisplayName("러너 프로필 수정 API") + @Test + void updateRunnerProfile() throws Exception { + // given + final RunnerUpdateRequest request = new RunnerUpdateRequest("주디", "우아한테크코스", "주디입니다.", List.of("spring", "java")); + final String requestBody = objectMapper.writeValueAsString(request); + final String socialId = "judySocicalId"; + final Member judyMember = MemberFixture.createJudy(); + final Runner judyRunner = RunnerFixture.createRunner(judyMember); + final String token = getAccessTokenBySocialId(socialId); + + // when + when(oauthRunnerRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(judyRunner)); + + // then + mockMvc.perform(patch("/api/v1/profile/runner/me") + .header(AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)) + .andExpect(status().isNoContent()) + .andExpect(header().string("Location", "/api/v1/profile/runner/me")) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT"), + headerWithName(CONTENT_TYPE).description("application/json") + ), + requestFields( + fieldWithPath("name").type(STRING).description("변경할 이름"), + fieldWithPath("company").type(STRING).description("변경할 소속"), + fieldWithPath("introduction").type(STRING).description("변경할 소개글"), + fieldWithPath("technicalTags.[]").type(ARRAY).description("변경할 기술 태그 목록") + ), + responseHeaders(headerWithName(LOCATION).description("redirect uri")) + )) + .andDo(print()); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/profile/supporter/read/SupporterReadByGuestApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/supporter/read/SupporterReadByGuestApiTest.java new file mode 100644 index 000000000..2a8b5ff88 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/profile/supporter/read/SupporterReadByGuestApiTest.java @@ -0,0 +1,116 @@ +package touch.baton.document.profile.supporter.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.controller.SupporterProfileController; +import touch.baton.domain.supporter.service.SupporterService; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; +import java.util.Optional; + +import static org.apache.http.HttpHeaders.AUTHORIZATION; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.when; +import static org.mockito.Mockito.spy; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.ReviewCountFixture.reviewCount; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(SupporterProfileController.class) +class SupporterReadByGuestApiTest extends RestdocsConfig { + + @MockBean + private SupporterService supporterService; + + @BeforeEach + void setUp() { + final SupporterProfileController supporterProfileController = new SupporterProfileController(supporterService); + restdocsSetUp(supporterProfileController); + } + + @DisplayName("서포터 프로필 조회 API") + @Test + void readProfileBySupporterId() throws Exception { + // given + final TechnicalTag java = TechnicalTagFixture.create(tagName("java")); + final TechnicalTag spring = TechnicalTagFixture.create(tagName("spring")); + final Supporter supporter = SupporterFixture.create(reviewCount(0), MemberFixture.createHyena(), List.of(java, spring)); + final Supporter spySupporter = spy(supporter); + + when(spySupporter.getId()).thenReturn(1L); + when(supporterService.readBySupporterId(spySupporter.getId())).thenReturn(spySupporter); + + // then + mockMvc.perform(get("/api/v1/profile/supporter/{supporterId}", 1L)) + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("supporterId").description("서포터 식별자값") + ), + responseFields( + fieldWithPath("supporterId").type(NUMBER).description("서포터 식별자값"), + fieldWithPath("name").type(STRING).description("서포터 이름"), + fieldWithPath("company").type(STRING).description("서포터 소속 회사"), + fieldWithPath("imageUrl").type(STRING).description("서포터 프로필 이미지 url"), + fieldWithPath("githubUrl").type(STRING).description("서포터 깃허브 url"), + fieldWithPath("introduction").type(STRING).description("서포터 자기소개"), + fieldWithPath("technicalTags").type(ARRAY).description("서포터 기술 태그 목록") + ) + )) + .andDo(print()); + } + + @DisplayName("서포터 마이페이지 프로필 조회 API") + @Test + void readMyProfileByToken() throws Exception { + // given + final String socialId = "hello ditoo"; + final Member ditooMember = MemberFixture.createWithSocialId(socialId); + final TechnicalTag javaTag = TechnicalTagFixture.createJava(); + final TechnicalTag reactTag = TechnicalTagFixture.createReact(); + final Supporter supporter = SupporterFixture.create(ditooMember, List.of(javaTag, reactTag)); + final Supporter spySupporter = spy(supporter); + final String accessToken = getAccessTokenBySocialId(socialId); + + // when + when(spySupporter.getId()).thenReturn(1L); + when(oauthSupporterRepository.joinByMemberSocialId(any(SocialId.class))).thenReturn(Optional.ofNullable(spySupporter)); + + // then + mockMvc.perform(get("/api/v1/profile/supporter/me").header(AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(headerWithName(AUTHORIZATION).description("Bearer JWT")), + responseFields( + fieldWithPath("name").type(STRING).description("서포터 이름"), + fieldWithPath("imageUrl").type(STRING).description("서포터 프로필 이미지 url"), + fieldWithPath("githubUrl").type(STRING).description("서포터 깃허브 url"), + fieldWithPath("introduction").type(STRING).description("서포터 자기소개"), + fieldWithPath("company").type(STRING).description("서포터 소속 회사"), + fieldWithPath("technicalTags").type(ARRAY).description("서포터 기술 태그 목록") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/profile/supporter/update/SupporterUpdateApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/supporter/update/SupporterUpdateApiTest.java new file mode 100644 index 000000000..09517a8d9 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/profile/supporter/update/SupporterUpdateApiTest.java @@ -0,0 +1,99 @@ +package touch.baton.document.profile.supporter.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.controller.SupporterProfileController; +import touch.baton.domain.supporter.service.SupporterService; +import touch.baton.domain.supporter.service.dto.SupporterUpdateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.CompanyFixture.company; +import static touch.baton.fixture.vo.GithubUrlFixture.githubUrl; +import static touch.baton.fixture.vo.ImageUrlFixture.imageUrl; +import static touch.baton.fixture.vo.MemberNameFixture.memberName; +import static touch.baton.fixture.vo.OauthIdFixture.oauthId; +import static touch.baton.fixture.vo.SocialIdFixture.socialId; + +@WebMvcTest(SupporterProfileController.class) +public class SupporterUpdateApiTest extends RestdocsConfig { + + @MockBean + private SupporterService supporterService; + + @BeforeEach + void setUp() { + final SupporterProfileController supporterProfileController = new SupporterProfileController(supporterService); + restdocsSetUp(supporterProfileController); + } + + @DisplayName("서포터 프로필 수정 API") + @Test + void updateSupporterProfile() throws Exception { + // given + final SupporterUpdateRequest request = new SupporterUpdateRequest("디투랜드", "우아한테크코스", "안녕하세요. 디투입니다.", List.of("java", "python")); + final String requestBody = objectMapper.writeValueAsString(request); + final String socialId = "ditooSocialId"; + final Member member = MemberFixture.create( + memberName("디투"), + socialId(socialId), + oauthId("abcd"), + githubUrl("naver.com"), + company("우아한테크코스"), + imageUrl("profile.jpg") + ); + final Supporter supporter = SupporterFixture.create(member); + final String token = getAccessTokenBySocialId(socialId); + + // when + when(oauthSupporterRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(supporter)); + + // then + mockMvc.perform(patch("/api/v1/profile/supporter/me") + .header(AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)) + .andExpect(status().isNoContent()) + .andExpect(header().string("Location", "/api/v1/profile/supporter/me")) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT"), + headerWithName(CONTENT_TYPE).description("application/json") + ), + requestFields( + fieldWithPath("name").type(STRING).description("변경할 이름"), + fieldWithPath("company").type(STRING).description("변경할 소속"), + fieldWithPath("introduction").type(STRING).description("변경할 소개글"), + fieldWithPath("technicalTags.[]").type(ARRAY).description("변경할 기술 태그 목록") + ), + responseHeaders(headerWithName(LOCATION).description("redirect uri")) + )) + .andDo(print()); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostApplicantApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostApplicantApiTest.java new file mode 100644 index 000000000..9d00fb42a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostApplicantApiTest.java @@ -0,0 +1,103 @@ +package touch.baton.document.runnerpost.create; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.service.dto.RunnerPostApplicantCreateRequest; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.TagFixture; + +import java.util.List; +import java.util.Optional; + +import static java.time.LocalDateTime.now; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.*; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerPostController.class) +class RunnerPostApplicantApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("Supporter 가 RunnerPost 에 리뷰를 제안한다.") + @Test + void createRunnerPostApplicant() throws Exception { + // given + final Runner runnerEthan = RunnerFixture.createRunner(MemberFixture.createEthan()); + final Supporter supporterHyena = SupporterFixture.create(MemberFixture.createHyena()); + + final Deadline deadline = deadline(now().plusHours(100)); + final Tag javaTag = TagFixture.create(tagName("자바")); + final RunnerPost runnerPost = RunnerPostFixture.create(runnerEthan, deadline, List.of(javaTag)); + + // when + final RunnerPost spyRunnerPost = spy(runnerPost); + when(spyRunnerPost.getId()).thenReturn(1L); + when(runnerPostService.createRunnerPostApplicant(any(), any(), any())).thenReturn(1L); + + when(oauthSupporterRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(supporterHyena)); + + final String token = getAccessTokenBySocialId(supporterHyena.getMember().getSocialId().getValue()); + + // then + final RunnerPostApplicantCreateRequest request = new RunnerPostApplicantCreateRequest("안녕하세요, 서포터 헤나입니다."); + + mockMvc.perform(post("/api/v1/posts/runner/{runnerPostId}/application", spyRunnerPost.getId()) + .header(AUTHORIZATION, "Bearer " + token) + .contentType(APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(header().string(LOCATION, "/api/v1/posts/runner/" + spyRunnerPost.getId())) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT"), + headerWithName(CONTENT_TYPE).description("application/json") + ), + pathParameters( + parameterWithName("runnerPostId").description("러너 게시글 식별자값") + ), + requestFields( + fieldWithPath("message").type(STRING).description("서포터의 러너 게시글 리뷰 지원 메시지") + ), + responseHeaders( + headerWithName(LOCATION).description("redirect uri") + ) + )) + .andDo(print()); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostCreateApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostCreateApiTest.java new file mode 100644 index 000000000..248011e8d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/create/RunnerPostCreateApiTest.java @@ -0,0 +1,75 @@ +package touch.baton.document.runnerpost.create; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.apache.http.HttpHeaders.*; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(RunnerPostController.class) +class RunnerPostCreateApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + restdocsSetUp(new RunnerPostController(runnerPostService)); + } + + @DisplayName("러너 게시글 등록 API") + @Test + void createRunnerPost() throws Exception { + // given + final String socialId = "hongSile"; + final Member member = MemberFixture.createWithSocialId(socialId); + final Runner runner = RunnerFixture.createRunner(member); + final String accessToken = getAccessTokenBySocialId(socialId); + + final RunnerPostCreateRequest request = new RunnerPostCreateRequest("코드 리뷰 해주세요.", + List.of("Java", "Spring"), + "https://github.com/cookienc", + LocalDateTime.now().plusDays(10), + "12345".repeat(200) + ); + + // when + when(runnerPostService.createRunnerPost(any(Runner.class), any(RunnerPostCreateRequest.class))).thenReturn(1L); + when(oauthRunnerRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(runner)); + + // then + mockMvc.perform(post("/api/v1/posts/runner") + .header(AUTHORIZATION, "Bearer " + accessToken) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(redirectedUrl("/api/v1/posts/runner/1")) + .andDo(restDocs.document( + requestHeaders(headerWithName(AUTHORIZATION).description("Bearer Token"), + headerWithName(CONTENT_TYPE).description(APPLICATION_JSON_VALUE)), + responseHeaders(headerWithName(LOCATION).description("Redirect URI")) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/delete/RunnerPostDeleteApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/delete/RunnerPostDeleteApiTest.java new file mode 100644 index 000000000..c2d34ed11 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/delete/RunnerPostDeleteApiTest.java @@ -0,0 +1,71 @@ +package touch.baton.document.runnerpost.delete; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +@WebMvcTest(RunnerPostController.class) +public class RunnerPostDeleteApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("러너 게시글 삭제 API") + @Test + void deleteByRunnerPostId() throws Exception { + // given + final String socialId = "ditooSocialId"; + final Runner runner = RunnerFixture.createRunner(MemberFixture.createWithSocialId(socialId)); + final String token = getAccessTokenBySocialId(socialId); + final RunnerPost runnerPost = RunnerPostFixture.create(runner, deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost spyRunnerPost = spy(runnerPost); + + // when + when(oauthRunnerRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(runner)); + when(spyRunnerPost.getId()).thenReturn(1L); + + // then + mockMvc.perform(delete("/api/v1/posts/runner/{runnerPostId}", 1L) + .header(AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ), + pathParameters( + parameterWithName("runnerPostId").description("러너 게시글 식별자값") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadAllApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadAllApiTest.java new file mode 100644 index 000000000..10d01b05b --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadAllApiTest.java @@ -0,0 +1,178 @@ +package touch.baton.document.runnerpost.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.TagFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.when; +import static org.mockito.Mockito.spy; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerPostController.class) +class RunnerPostReadAllApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("러너 게시글 전체 조회 API") + @Test + void readAllRunnerPosts() throws Exception { + // given + final Runner runner = RunnerFixture.createRunner(MemberFixture.createHyena()); + final Deadline deadline = deadline(LocalDateTime.now().plusHours(100)); + final Tag javaTag = TagFixture.create(tagName("자바")); + final RunnerPost runnerPost = RunnerPostFixture.create(runner, deadline, List.of(javaTag)); + final RunnerPost spyRunnerPost = spy(runnerPost); + given(spyRunnerPost.getId()).willReturn(1L); + + // when + final List runnerPosts = List.of(spyRunnerPost); + final PageRequest pageOne = PageRequest.of(1, 10); + final PageImpl pageRunnerPosts = new PageImpl<>(runnerPosts, pageOne, runnerPosts.size()); + when(runnerPostService.readAllRunnerPosts(any())).thenReturn(pageRunnerPosts); + when(runnerPostService.readCountsByRunnerPostIds(anyList())).thenReturn(List.of(1L)); + + // then + mockMvc.perform(get("/api/v1/posts/runner") + .queryParam("size", String.valueOf(pageOne.getPageSize())) + .queryParam("page", String.valueOf(pageOne.getPageNumber()))) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + queryParameters( + parameterWithName("size").description("페이지 사이즈"), + parameterWithName("page").description("페이지 번호") + ), + responseFields( + fieldWithPath("data.[].runnerPostId").type(NUMBER).description("러너 게시글 식별자값(id)"), + fieldWithPath("data.[].title").type(STRING).description("러너 게시글의 제목"), + fieldWithPath("data.[].deadline").type(STRING).description("러너 게시글의 마감 기한"), + fieldWithPath("data.[].watchedCount").type(NUMBER).description("러너 게시글의 조회수"), + fieldWithPath("data.[].reviewStatus").type(STRING).description("러너 게시글의 리뷰 상태"), + fieldWithPath("data.[].runnerProfile.name").type(STRING).description("러너 게시글의 러너 프로필 이름"), + fieldWithPath("data.[].runnerProfile.imageUrl").type(STRING).description("러너 게시글의 러너 프로필 이미지"), + fieldWithPath("data.[].tags.[]").type(ARRAY).description("러너 게시글의 태그 목록"), + fieldWithPath("data.[].applicantCount").type(NUMBER).description("러너 게시글에 신청한 서포터 수"), + fieldWithPath("pageInfo.isFirst").type(BOOLEAN).description("첫 번째 페이지인지"), + fieldWithPath("pageInfo.isLast").type(BOOLEAN).description("마지막 페이지인지"), + fieldWithPath("pageInfo.hasNext").type(BOOLEAN).description("다음 페이지가 있는지"), + fieldWithPath("pageInfo.totalPages").type(NUMBER).description("총 페이지 수"), + fieldWithPath("pageInfo.totalElements").type(NUMBER).description("총 데이터 수"), + fieldWithPath("pageInfo.currentPage").type(NUMBER).description("현재 페이지"), + fieldWithPath("pageInfo.currentSize").type(NUMBER).description("현재 페이지 데이터 수") + )) + ); + } + + @DisplayName("러너와 연관된 러너 게시글 페이징 조회 API") + @Test + void readRunnerMyPage() throws Exception { + // given + final Runner runnerJudy = RunnerFixture.createRunner(MemberFixture.createJudy()); + final String token = getAccessTokenBySocialId(runnerJudy.getMember().getSocialId().getValue()); + + when(oauthRunnerRepository.joinByMemberSocialId(notNull())) + .thenReturn(Optional.ofNullable(runnerJudy)); + + final Tag javaTag = TagFixture.create(tagName("자바")); + final Deadline deadline = deadline(LocalDateTime.now().plusHours(100)); + final RunnerPost runnerPost = RunnerPostFixture.create(runnerJudy, deadline, List.of(javaTag)); + + // when + final RunnerPost spyRunnerPost = spy(runnerPost); + when(spyRunnerPost.getId()).thenReturn(1L); + + final List runnerPosts = List.of(spyRunnerPost); + final PageRequest pageOne = PageRequest.of(1, 10); + final PageImpl pageRunnerPosts = new PageImpl<>(runnerPosts, pageOne, runnerPosts.size()); + when(runnerPostService.readRunnerPostsByRunnerIdAndReviewStatus(any(), any(), any())) + .thenReturn(pageRunnerPosts); + when(runnerPostService.readCountsByRunnerPostIds(anyList())) + .thenReturn(List.of(0L)); + + // then + mockMvc.perform(get("/api/v1/posts/runner/me/runner") + .header(AUTHORIZATION, "Bearer " + token) + .characterEncoding(UTF_8) + .accept(APPLICATION_JSON) + .queryParam("size", String.valueOf(pageOne.getPageSize())) + .queryParam("page", String.valueOf(pageOne.getPageNumber())) + .queryParam("reviewStatus", ReviewStatus.IN_PROGRESS.name())) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ), + queryParameters( + parameterWithName("size").description("페이지 사이즈"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("reviewStatus").description("리뷰 상태") + ), + responseFields( + fieldWithPath("data.[].runnerPostId").type(NUMBER).description("러너 게시글 식별자값(id)"), + fieldWithPath("data.[].supporterId").type(NUMBER).optional().description("서포터 식별자값(id)은 null 일 수 있다"), + fieldWithPath("data.[].title").type(STRING).description("러너 게시글 제목"), + fieldWithPath("data.[].deadline").type(STRING).description("러너 게시글의 마감 기한"), + fieldWithPath("data.[].tags").type(ARRAY).description("러너 게시글 태그 목록"), + fieldWithPath("data.[].watchedCount").type(NUMBER).description("러너 게시글의 조회수"), + fieldWithPath("data.[].applicantCount").type(NUMBER).description("러너 게시글에 신청한 서포터 수"), + fieldWithPath("data.[].reviewStatus").type(STRING).description("러너 게시글 리뷰 상태"), + fieldWithPath("pageInfo.isFirst").type(BOOLEAN).description("첫 번째 페이지인지"), + fieldWithPath("pageInfo.isLast").type(BOOLEAN).description("마지막 페이지 인지"), + fieldWithPath("pageInfo.hasNext").type(BOOLEAN).description("다음 페이지가 있는지"), + fieldWithPath("pageInfo.totalPages").type(NUMBER).description("총 페이지 수"), + fieldWithPath("pageInfo.totalElements").type(NUMBER).description("총 데이터 수"), + fieldWithPath("pageInfo.currentPage").type(NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageInfo.currentSize").type(NUMBER).description("현재 페이지 데이터 수") + )) + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOfSupporterByGuestApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOfSupporterByGuestApiTest.java new file mode 100644 index 000000000..6067176c0 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOfSupporterByGuestApiTest.java @@ -0,0 +1,120 @@ +package touch.baton.document.runnerpost.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.TagFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.domain.TechnicalTagFixture.createJava; +import static touch.baton.fixture.domain.TechnicalTagFixture.createSpring; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.ReviewCountFixture.reviewCount; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerPostController.class) +class RunnerPostReadOfSupporterByGuestApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("서포터와 연관된 러너 게시글 페이징 조회 API") + @Test + void readReferencedBySupporter() throws Exception { + // given + final Runner runnerJudy = RunnerFixture.createRunner(MemberFixture.createJudy()); + final Supporter supporterHyena = SupporterFixture.create(reviewCount(10), MemberFixture.createHyena(), List.of(createJava(), createSpring())); + + final Tag javaTag = TagFixture.create(tagName("자바")); + final Deadline deadline = deadline(LocalDateTime.now().plusHours(100)); + final RunnerPost runnerPost = RunnerPostFixture.create(runnerJudy, deadline, List.of(javaTag)); + runnerPost.assignSupporter(supporterHyena); + + // when + final RunnerPost spyRunnerPost = spy(runnerPost); + final Supporter spySupporterHyena = spy(supporterHyena); + when(spySupporterHyena.getId()).thenReturn(1L); + when(spyRunnerPost.getId()).thenReturn(1L); + + final List runnerPosts = List.of(spyRunnerPost); + final PageRequest pageOne = PageRequest.of(1, 10); + final PageImpl pageRunnerPosts = new PageImpl<>(runnerPosts, pageOne, runnerPosts.size()); + when(runnerPostService.readRunnerPostsBySupporterIdAndReviewStatus(any(), any(), any())) + .thenReturn(pageRunnerPosts); + when(runnerPostService.readCountsByRunnerPostIds(anyList())).thenReturn(List.of(1L)); + + // then + mockMvc.perform(get("/api/v1/posts/runner/search") + .characterEncoding(UTF_8) + .accept(APPLICATION_JSON) + .queryParam("size", String.valueOf(pageOne.getPageSize())) + .queryParam("page", String.valueOf(pageOne.getPageNumber())) + .queryParam("supporterId", String.valueOf(spySupporterHyena.getId())) + .queryParam("reviewStatus", ReviewStatus.IN_PROGRESS.name())) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + queryParameters( + parameterWithName("size").description("페이지 사이즈"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("supporterId").description("서포터 식별자값"), + parameterWithName("reviewStatus").description("리뷰 상태") + ), + responseFields( + fieldWithPath("pageInfo.isFirst").type(BOOLEAN).description("첫 번째 페이지인지"), + fieldWithPath("pageInfo.isLast").type(BOOLEAN).description("마지막 페이지 인지"), + fieldWithPath("pageInfo.hasNext").type(BOOLEAN).description("다음 페이지가 있는지"), + fieldWithPath("pageInfo.totalPages").type(NUMBER).description("총 페이지 수"), + fieldWithPath("pageInfo.totalElements").type(NUMBER).description("총 데이터 수"), + fieldWithPath("pageInfo.currentPage").type(NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageInfo.currentSize").type(NUMBER).description("현재 페이지 데이터 수"), + fieldWithPath("data.[].runnerPostId").type(NUMBER).description("러너 게시글 식별자값(id)"), + fieldWithPath("data.[].title").type(STRING).description("러너 게시글 제목"), + fieldWithPath("data.[].deadline").type(STRING).description("러너 게시글의 마감 기한"), + fieldWithPath("data.[].tags").type(ARRAY).description("러너 게시글 태그 목록"), + fieldWithPath("data.[].watchedCount").type(NUMBER).description("러너 게시글의 조회수"), + fieldWithPath("data.[].applicantCount").type(NUMBER).description("러너 게시글에 신청한 서포터 수"), + fieldWithPath("data.[].reviewStatus").type(STRING).description("러너 게시글 리뷰 상태") + )) + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOneApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOneApiTest.java new file mode 100644 index 000000000..d95fda106 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadOneApiTest.java @@ -0,0 +1,119 @@ +package touch.baton.document.runnerpost.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.TagFixture; + +import java.util.List; +import java.util.Optional; + +import static java.time.LocalDateTime.now; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.when; +import static org.mockito.Mockito.spy; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerPostController.class) +class RunnerPostReadOneApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("러너 게시글 상세 조회 API") + @Test + void readByRunnerPostId() throws Exception { + // given + final Member memberHyena = MemberFixture.createHyena(); + final Runner runnerHyena = RunnerFixture.createRunner(memberHyena); + + final Deadline deadline = deadline(now().plusHours(100)); + final Tag javaTag = TagFixture.create(tagName("자바")); + + // when + final Member spyMemberHyena = spy(memberHyena); + final Runner spyRunnerHyena = spy(runnerHyena); + when(spyRunnerHyena.getId()).thenReturn(1L); + when(spyMemberHyena.getId()).thenReturn(1L); + + final RunnerPost runnerPost = RunnerPostFixture.create(spyRunnerHyena, deadline, List.of(javaTag)); + final RunnerPost spyRunnerPost = spy(runnerPost); + when(spyRunnerPost.getId()).thenReturn(1L); + + when(runnerPostService.readByRunnerPostId(any())) + .thenReturn(spyRunnerPost); + when(runnerPostService.readCountByRunnerPostId(any())) + .thenReturn(3L); + + final String token = getAccessTokenBySocialId(memberHyena.getSocialId().getValue()); + + when(oauthMemberRepository.findBySocialId(any())) + .thenReturn(Optional.ofNullable(memberHyena)); + + // then + mockMvc.perform(get("/api/v1/posts/runner/{runnerPostId}", spyRunnerPost.getId()) + .header(AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT (필수값 x)") + ), + pathParameters( + parameterWithName("runnerPostId").description("러너 게시글 식별자값") + ), + responseFields( + fieldWithPath("runnerPostId").type(NUMBER).description("러너 게시글 식별자값(id)"), + fieldWithPath("title").type(STRING).description("러너 게시글 제목"), + fieldWithPath("contents").type(STRING).description("러너 게시글 내용"), + fieldWithPath("deadline").type(STRING).description("러너 게시글 마감기한"), + fieldWithPath("isOwner").type(BOOLEAN).description("러너 게시글 주인 여부"), + fieldWithPath("isApplied").type(BOOLEAN).description("로그인한 서포터 리뷰 지원 여부"), + fieldWithPath("applicantCount").type(NUMBER).description("러너 게시글 서포터 지원자수"), + fieldWithPath("watchedCount").type(NUMBER).description("러너 게시글 조회수"), + fieldWithPath("reviewStatus").type(STRING).description("러너 게시글 리뷰 상태"), + fieldWithPath("pullRequestUrl").type(STRING).description("러너 게시글 PR URL"), + fieldWithPath("tags").type(ARRAY).description("러너 게시글 태그 목록"), + fieldWithPath("runnerProfile.runnerId").type(NUMBER).description("러너 게시글 식별자값(id)"), + fieldWithPath("runnerProfile.name").type(STRING).description("러너 게시글 식별자값(id)"), + fieldWithPath("runnerProfile.company").type(STRING).description("러너 게시글 식별자값(id)"), + fieldWithPath("runnerProfile.imageUrl").type(STRING).description("러너 게시글 식별자값(id)") + )) + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadWithLoginedSupporterApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadWithLoginedSupporterApiTest.java new file mode 100644 index 000000000..9df156ea0 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostReadWithLoginedSupporterApiTest.java @@ -0,0 +1,140 @@ +package touch.baton.document.runnerpost.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.TagFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.CompanyFixture.company; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.GithubUrlFixture.githubUrl; +import static touch.baton.fixture.vo.ImageUrlFixture.imageUrl; +import static touch.baton.fixture.vo.MemberNameFixture.memberName; +import static touch.baton.fixture.vo.OauthIdFixture.oauthId; +import static touch.baton.fixture.vo.SocialIdFixture.socialId; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerPostController.class) +public class RunnerPostReadWithLoginedSupporterApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("로그인한 서포터가 참여한 러너 게시글 페이징 조회 API") + @Test + void readRunnerPostsByLoginedSupporterAndReviewStatus() throws Exception { + // given + final Runner runnerJudy = RunnerFixture.createRunner(MemberFixture.createJudy()); + final String socialId = "ditooSocialId"; + final Member loginedMember = MemberFixture.create( + memberName("디투"), + socialId(socialId), + oauthId("abcd"), + githubUrl("naver.com"), + company("우아한테크코스"), + imageUrl("profile.jpg") + ); + final Supporter loginedSupporter = SupporterFixture.create(loginedMember); + final String token = getAccessTokenBySocialId(socialId); + + final Tag javaTag = TagFixture.create(tagName("자바")); + final Deadline deadline = deadline(LocalDateTime.now().plusHours(100)); + final RunnerPost runnerPost = RunnerPostFixture.create(runnerJudy, deadline, List.of(javaTag)); + runnerPost.assignSupporter(loginedSupporter); + + // when + final RunnerPost spyRunnerPost = spy(runnerPost); + final Supporter spyLoginedSupporter = spy(loginedSupporter); + when(oauthSupporterRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(spyLoginedSupporter)); + when(spyRunnerPost.getId()).thenReturn(1L); + + final List runnerPosts = List.of(spyRunnerPost); + final PageRequest pageOne = PageRequest.of(1, 10); + final PageImpl pageRunnerPosts = new PageImpl<>(runnerPosts, pageOne, runnerPosts.size()); + when(runnerPostService.readRunnerPostsBySupporterIdAndReviewStatus(any(), any(), any())) + .thenReturn(pageRunnerPosts); + when(runnerPostService.readCountsByRunnerPostIds(anyList())).thenReturn(List.of(1L)); + + // then + mockMvc.perform(get("/api/v1/posts/runner/me/supporter") + .header(AUTHORIZATION, "Bearer " + token) + .queryParam("size", String.valueOf(pageOne.getPageSize())) + .queryParam("page", String.valueOf(pageOne.getPageNumber())) + .queryParam("reviewStatus", ReviewStatus.IN_PROGRESS.name())) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ), + queryParameters( + parameterWithName("size").description("페이지 사이즈"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("reviewStatus").description("리뷰 상태") + ), + responseFields( + fieldWithPath("pageInfo.isFirst").type(BOOLEAN).description("첫 번째 페이지인지"), + fieldWithPath("pageInfo.isLast").type(BOOLEAN).description("마지막 페이지 인지"), + fieldWithPath("pageInfo.hasNext").type(BOOLEAN).description("다음 페이지가 있는지"), + fieldWithPath("pageInfo.totalPages").type(NUMBER).description("총 페이지 수"), + fieldWithPath("pageInfo.totalElements").type(NUMBER).description("총 데이터 수"), + fieldWithPath("pageInfo.currentPage").type(NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageInfo.currentSize").type(NUMBER).description("현재 페이지 데이터 수"), + fieldWithPath("data.[].runnerPostId").type(NUMBER).description("러너 게시글 식별자값(id)"), + fieldWithPath("data.[].title").type(STRING).description("러너 게시글 제목"), + fieldWithPath("data.[].deadline").type(STRING).description("러너 게시글의 마감 기한"), + fieldWithPath("data.[].tags").type(ARRAY).description("러너 게시글 태그 목록"), + fieldWithPath("data.[].watchedCount").type(NUMBER).description("러너 게시글의 조회수"), + fieldWithPath("data.[].applicantCount").type(NUMBER).description("러너 게시글에 신청한 서포터 수"), + fieldWithPath("data.[].reviewStatus").type(STRING).description("러너 게시글 리뷰 상태") + )) + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostUpdateApplicantCancelationApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostUpdateApplicantCancelationApiTest.java new file mode 100644 index 000000000..876023abc --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/RunnerPostUpdateApplicantCancelationApiTest.java @@ -0,0 +1,99 @@ +package touch.baton.document.runnerpost.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.CompanyFixture.company; +import static touch.baton.fixture.vo.GithubUrlFixture.githubUrl; +import static touch.baton.fixture.vo.ImageUrlFixture.imageUrl; +import static touch.baton.fixture.vo.MemberNameFixture.memberName; +import static touch.baton.fixture.vo.OauthIdFixture.oauthId; +import static touch.baton.fixture.vo.SocialIdFixture.socialId; + +@WebMvcTest(RunnerPostController.class) +public class RunnerPostUpdateApplicantCancelationApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("러너 게시글에 리뷰 제안한 서포터가 리뷰 제안 철회 API") + @Test + void updateSupporterCancelRunnerPost() throws Exception { + // given + final String socialId = "ditooSocialId"; + final Member member = MemberFixture.create( + memberName("디투"), + socialId(socialId), + oauthId("abcd"), + githubUrl("naver.com"), + company("우아한테크코스"), + imageUrl("profile.jpg") + ); + final Supporter supporter = SupporterFixture.create(member); + final String token = getAccessTokenBySocialId(socialId); + + final Runner runner = RunnerFixture.createRunner(MemberFixture.createHyena()); + final RunnerPost runnerPost = RunnerPostFixture.create(runner, supporter, new Deadline(LocalDateTime.now().plusHours(10))); + final RunnerPost spyRunnerPost = spy(runnerPost); + + // when + when(spyRunnerPost.getId()).thenReturn(1L); + when(oauthSupporterRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(supporter)); + runnerPostService.deleteSupporterRunnerPost(any(), eq(1L)); + + // then + mockMvc.perform(patch("/api/v1/posts/runner/{runnerPostId}/cancelation", 1L) + .header(AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isNoContent()) + .andExpect(header().string("Location", "/api/v1/posts/runner/1")) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ), + pathParameters( + parameterWithName("runnerPostId").description("러너 게시글 식별자값") + ), + responseHeaders( + headerWithName(LOCATION).description("redirect uri") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/read/SupporterRunnerPostReadApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/SupporterRunnerPostReadApiTest.java new file mode 100644 index 000000000..c1ba8347d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/read/SupporterRunnerPostReadApiTest.java @@ -0,0 +1,105 @@ +package touch.baton.document.runnerpost.read; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; +import touch.baton.fixture.domain.TagFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +@WebMvcTest(RunnerPostController.class) +class SupporterRunnerPostReadApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + final RunnerPostController runnerPostController = new RunnerPostController(runnerPostService); + restdocsSetUp(runnerPostController); + } + + @DisplayName("러너 게시글의 지원한 서포터 목록 조회 API") + @Test + void readSupporterRunnerPostsByRunnerPostId() throws Exception { + // given + final Member member = MemberFixture.createHyena(); + final Runner runner = RunnerFixture.createRunner(member); + final Deadline deadline = deadline(LocalDateTime.now().plusHours(100)); + final Tag javaTag = TagFixture.create(tagName("자바")); + final RunnerPost runnerPost = RunnerPostFixture.create(runner, deadline, List.of(javaTag)); + final RunnerPost spyRunnerPost = spy(runnerPost); + + final Supporter supporter = SupporterFixture.create(MemberFixture.createJudy()); + final Supporter spySupporter = spy(supporter); + final SupporterRunnerPost supporterRunnerPost = SupporterRunnerPostFixture.create(spyRunnerPost, spySupporter); + final String token = getAccessTokenBySocialId(runner.getMember().getSocialId().getValue()); + + // when + given(spySupporter.getId()).willReturn(1L); + given(spyRunnerPost.getId()).willReturn(1L); + given(runnerPostService.readSupporterRunnerPostsByRunnerPostId(any(), any())).willReturn(List.of(supporterRunnerPost)); + when(oauthRunnerRepository.joinByMemberSocialId(notNull())) + .thenReturn(Optional.ofNullable(runner)); + + // then + mockMvc.perform(get("/api/v1/posts/runner/{runnerPostId}/supporters", 1L) + .header(AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ), + pathParameters(parameterWithName("runnerPostId").description("러너 게시글 식별자(id)")), + responseFields( + fieldWithPath("data.[].supporterId").type(NUMBER).description("서포터 러너 게시글 서포터의 식별자값(id)"), + fieldWithPath("data.[].name").type(STRING).description("서포터 러너 게시글의 서포터의 이름"), + fieldWithPath("data.[].company").type(STRING).description("서포터 러너 게시글의 서포터의 소속"), + fieldWithPath("data.[].reviewCount").type(NUMBER).description("서포터 러너 게시글의 서포터의 리뷰수"), + fieldWithPath("data.[].imageUrl").type(STRING).description("서포터 러너 게시글의 서포터의 이미지 주소"), + fieldWithPath("data.[].message").type(STRING).description("서포터 러너 게시글의 메세지"), + fieldWithPath("data.[].technicalTags.[]").type(ARRAY).description("서포터 러너 게시글의 서포터의 태그 목록") + )) + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java new file mode 100644 index 000000000..7889c82d2 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java @@ -0,0 +1,104 @@ +package touch.baton.document.runnerpost.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.controller.RunnerPostController; +import touch.baton.domain.runnerpost.service.RunnerPostService; +import touch.baton.domain.runnerpost.service.dto.RunnerPostUpdateRequest; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.Optional; + +import static org.apache.http.HttpHeaders.CONTENT_TYPE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(RunnerPostController.class) +public class RunnerPostUpdateApiTest extends RestdocsConfig { + + @MockBean + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + restdocsSetUp(new RunnerPostController(runnerPostService)); + } + + @DisplayName("제안한 서포터 목록 중에서 서포터로 선택하는 API") + @Test + void updateRunnerPostSupporter() throws Exception { + // given + final String ditooSocialId = "helloToken"; + final String token = getAccessTokenBySocialId(ditooSocialId); + final Member ditooMember = MemberFixture.createWithSocialId(ditooSocialId); + final Runner ditooRunner = RunnerFixture.createRunner(ditooMember); + + final RunnerPostUpdateRequest.SelectSupporter request = new RunnerPostUpdateRequest.SelectSupporter(1L); + + // when + willDoNothing().given(runnerPostService).updateRunnerPostAppliedSupporter(any(Runner.class), anyLong(), any(RunnerPostUpdateRequest.SelectSupporter.class)); + when(oauthRunnerRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(ditooRunner)); + + // then + mockMvc.perform(patch("/api/v1/posts/runner/{runnerPostId}/supporters", 1L) + .header(AUTHORIZATION, "Bearer " + token) + .contentType(APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()) + .andExpect(redirectedUrl("/api/v1/posts/runner/1")) + .andDo(restDocs.document( + pathParameters(parameterWithName("runnerPostId").description("러너 게시글 식별자값")), + requestHeaders(headerWithName(AUTHORIZATION).description("Bearer JWT"), + headerWithName(CONTENT_TYPE).description(APPLICATION_JSON_VALUE)), + responseHeaders(headerWithName(LOCATION).description("Redirect URI")) + )).andDo(print()); + } + + @DisplayName("서포터 리뷰 완료 API") + @Test + void updateRunnerPostReviewStatusDone() throws Exception { + // given + final String ditooSocialId = "hongSile"; + final Member memberDitoo = MemberFixture.createWithSocialId(ditooSocialId); + final Supporter supporter = SupporterFixture.create(memberDitoo); + final String accessToken = getAccessTokenBySocialId(ditooSocialId); + + // when + willDoNothing().given(runnerPostService).updateRunnerPostReviewStatusDone(anyLong(), any(Supporter.class)); + when(oauthSupporterRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(supporter)); + + // then + mockMvc.perform(patch("/api/v1/posts/runner/{runnerPostId}/done", 1L) + .header(AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isNoContent()) + .andExpect(redirectedUrl("/api/v1/posts/runner/1")) + .andDo(restDocs.document( + pathParameters(parameterWithName("runnerPostId").description("러너 게시글 식별자값")), + requestHeaders(headerWithName(AUTHORIZATION).description("Bearer TOKEN")), + responseHeaders(headerWithName(LOCATION).description("Redirect Uri")) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/common/vo/ContentsTest.java b/backend/baton/src/test/java/touch/baton/domain/common/vo/ContentsTest.java new file mode 100644 index 000000000..312f7a5ce --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/common/vo/ContentsTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.common.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContentsTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new Contents(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/common/vo/DescriptionTest.java b/backend/baton/src/test/java/touch/baton/domain/common/vo/DescriptionTest.java new file mode 100644 index 000000000..88ec103e2 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/common/vo/DescriptionTest.java @@ -0,0 +1,18 @@ +package touch.baton.domain.common.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.domain.feedback.vo.Description; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DescriptionTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new Description(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Description 객체 내부에 value 는 null 일 수 없습니다."); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/common/vo/TitleTest.java b/backend/baton/src/test/java/touch/baton/domain/common/vo/TitleTest.java new file mode 100644 index 000000000..a27af7c9f --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/common/vo/TitleTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.common.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TitleTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new Title(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/common/vo/WatchedCountTest.java b/backend/baton/src/test/java/touch/baton/domain/common/vo/WatchedCountTest.java new file mode 100644 index 000000000..85542d55d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/common/vo/WatchedCountTest.java @@ -0,0 +1,32 @@ +package touch.baton.domain.common.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class WatchedCountTest { + + @DisplayName("기본 조회수를 가진 WatchedCount 를 생성할 수 있다.") + @Test + void createDefaultWatchedCount() { + // given + final WatchedCount expected = WatchedCount.zero(); + + // when, then + assertThat(expected.getValue()).isEqualTo(0); + } + + @DisplayName("조회수를 증가시킨다.") + @Test + void increase() { + // given + final WatchedCount watchedCount = WatchedCount.zero(); + + // when + final WatchedCount increasedWatchedCount = watchedCount.increase(); + + // then + assertThat(increasedWatchedCount).isEqualTo(new WatchedCount(1)); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/feedback/service/FeedbackServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/feedback/service/FeedbackServiceTest.java new file mode 100644 index 000000000..06fd43b44 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/feedback/service/FeedbackServiceTest.java @@ -0,0 +1,78 @@ +package touch.baton.domain.feedback.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.feedback.exception.FeedbackBusinessException; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FeedbackServiceTest extends ServiceTestConfig { + + private FeedbackService feedbackService; + private Runner exactRunner; + private RunnerPost runnerPost; + private SupporterFeedBackCreateRequest request; + + @BeforeEach + void setUp() { + feedbackService = new FeedbackService(supporterFeedbackRepository, runnerPostRepository, supporterRepository); + Member ethan = memberRepository.save(MemberFixture.createEthan()); + exactRunner = runnerRepository.save(RunnerFixture.createRunner(ethan)); + Member ditoo = memberRepository.save(MemberFixture.createDitoo()); + Supporter supporterDitoo = supporterRepository.save(SupporterFixture.create(ditoo)); + runnerPost = runnerPostRepository.save(RunnerPostFixture.create(exactRunner, supporterDitoo)); + + request = new SupporterFeedBackCreateRequest("GOOD", List.of("코드리뷰가 맛있어요.", "말투가 친절해요."), supporterDitoo.getId(), runnerPost.getId()); + } + + @DisplayName("러너가 서포터 피드백을 할 수 있다.") + @Test + void createSupporterFeedback() { + // when + final Long expected = feedbackService.createSupporterFeedback(exactRunner, request); + + // then + assertThat(expected).isNotNull(); + } + + @DisplayName("소유자가 아닌 러너는 피드백을 할 수 없다.") + @Test + void fail_createSupporterFeedback_if_not_owner_runner() { + // given + final Member differentMember = memberRepository.save(MemberFixture.createHyena()); + final Runner notOwner = runnerRepository.save(RunnerFixture.createRunner(differentMember)); + + // when, then + assertThatThrownBy(() -> feedbackService.createSupporterFeedback(notOwner, request)) + .isInstanceOf(FeedbackBusinessException.class) + .hasMessage("리뷰 글을 작성한 주인만 글을 작성할 수 있습니다."); + } + + @DisplayName("리뷰를 하지 않은 서포터를 피드백을 할 수 없다.") + @Test + void fail_createSupporterFeedback_if_not_review_supporter_runner() { + // given + final Member differentMember = memberRepository.save(MemberFixture.createHyena()); + final Supporter notReviewSupporter = supporterRepository.save(SupporterFixture.create(differentMember)); + final SupporterFeedBackCreateRequest notReviewSupporterRequest = new SupporterFeedBackCreateRequest("GOOD", new ArrayList<>(), notReviewSupporter.getId(), runnerPost.getId()); + + // when, then + assertThatThrownBy(() -> feedbackService.createSupporterFeedback(exactRunner, notReviewSupporterRequest)) + .isInstanceOf(FeedbackBusinessException.class) + .hasMessage("리뷰를 작성한 서포터에 대해서만 피드백을 작성할 수 있습니다."); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/member/MemberTest.java b/backend/baton/src/test/java/touch/baton/domain/member/MemberTest.java new file mode 100644 index 000000000..a3ff8dbc1 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/member/MemberTest.java @@ -0,0 +1,182 @@ +package touch.baton.domain.member; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.member.exception.MemberDomainException; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.fixture.domain.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + @DisplayName("생성 테스트") + @Nested + class Create { + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("https://")) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("이름에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_name_is_null() { + assertThatThrownBy(() -> Member.builder() + .memberName(null) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("imageUrl")) + .build() + ).isInstanceOf(MemberDomainException.class) + .hasMessage("Member 의 name 은 null 일 수 없습니다."); + } + + @DisplayName("socialId에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_socialId_is_null() { + assertThatThrownBy(() -> Member.builder() + .memberName(new MemberName("에단")) + .socialId(null) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("imageUrl")) + .build() + ).isInstanceOf(MemberDomainException.class) + .hasMessage("Member 의 socialId 은 null 일 수 없습니다."); + } + + @DisplayName("oauth id 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_oauth_id_is_null() { + assertThatThrownBy(() -> Member.builder() + .memberName(new MemberName("에단")) + .socialId(new SocialId("testSocialId")) + .oauthId(null) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("imageUrl")) + .build() + ).isInstanceOf(MemberDomainException.class) + .hasMessage("Member 의 oauthId 는 null 일 수 없습니다."); + } + + @DisplayName("github url 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_github_url_is_null() { + assertThatThrownBy(() -> Member.builder() + .memberName(new MemberName("에단")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(null) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("imageUrl")) + .build() + ).isInstanceOf(MemberDomainException.class) + .hasMessage("Member 의 githubUrl 은 null 일 수 없습니다."); + } + + @DisplayName("company 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_company_is_null() { + assertThatThrownBy(() -> Member.builder() + .memberName(new MemberName("에단")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(null) + .imageUrl(new ImageUrl("imageUrl")) + .build() + ).isInstanceOf(MemberDomainException.class) + .hasMessage("Member 의 company 는 null 일 수 없습니다."); + } + + @DisplayName("imageUrl 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_imageUrl_is_null() { + assertThatThrownBy(() -> Member.builder() + .memberName(new MemberName("에단")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(null) + .build() + ).isInstanceOf(MemberDomainException.class) + .hasMessage("Member 의 imageUrl 은 null 일 수 없습니다."); + } + } + + @DisplayName("수정 테스트") + @Nested + class Update { + + private Member member; + + @BeforeEach + void setUp() { + member = MemberFixture.createDitoo(); + } + + @DisplayName("이름 수정에 성공한다.") + @Test + void name_success() { + // given + final MemberName updatedName = new MemberName("디투랜드"); + + // when + member.updateMemberName(updatedName); + + // then + assertThat(member.getMemberName()).isEqualTo(updatedName); + } + + @DisplayName("이름이 null 이면 이름 수정에 실패한다.") + @Test + void name_fail_if_null() { + assertThatThrownBy(() -> member.updateMemberName(null)) + .isInstanceOf(MemberDomainException.class); + } + + @DisplayName("소속 수정에 성공한다.") + @Test + void company_success() { + // given + final Company updatedCompany = new Company("넥슨"); + + // when + member.updateCompany(updatedCompany); + + // then + assertThat(member.getCompany()).isEqualTo(updatedCompany); + } + + @DisplayName("소속이 null 이면 소속 수정에 실패한다.") + @Test + void company_fail_if_null() { + assertThatThrownBy(() -> member.updateCompany(null)) + .isInstanceOf(MemberDomainException.class); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/member/vo/CompanyTest.java b/backend/baton/src/test/java/touch/baton/domain/member/vo/CompanyTest.java new file mode 100644 index 000000000..e9312518e --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/member/vo/CompanyTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.member.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CompanyTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new Company(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/member/vo/GithubUrlTest.java b/backend/baton/src/test/java/touch/baton/domain/member/vo/GithubUrlTest.java new file mode 100644 index 000000000..28c73ca0f --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/member/vo/GithubUrlTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.member.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GithubUrlTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new GithubUrl(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/member/vo/ImageUrlTest.java b/backend/baton/src/test/java/touch/baton/domain/member/vo/ImageUrlTest.java new file mode 100644 index 000000000..511c551c4 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/member/vo/ImageUrlTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.member.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ImageUrlTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new ImageUrl(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/member/vo/NameTest.java b/backend/baton/src/test/java/touch/baton/domain/member/vo/NameTest.java new file mode 100644 index 000000000..062e90c46 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/member/vo/NameTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.member.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NameTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new MemberName(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/member/vo/OauthIdTest.java b/backend/baton/src/test/java/touch/baton/domain/member/vo/OauthIdTest.java new file mode 100644 index 000000000..20582fcc6 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/member/vo/OauthIdTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.member.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OauthIdTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new OauthId(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/member/vo/SocialIdTest.java b/backend/baton/src/test/java/touch/baton/domain/member/vo/SocialIdTest.java new file mode 100644 index 000000000..818617c06 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/member/vo/SocialIdTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.member.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SocialIdTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new SocialId(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/oauth/controller/OauthTypeConverterTest.java b/backend/baton/src/test/java/touch/baton/domain/oauth/controller/OauthTypeConverterTest.java new file mode 100644 index 000000000..96a52b625 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/oauth/controller/OauthTypeConverterTest.java @@ -0,0 +1,26 @@ +package touch.baton.domain.oauth.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import touch.baton.config.converter.OauthTypeConverter; +import touch.baton.domain.oauth.OauthType; + +import static org.assertj.core.api.Assertions.assertThat; + +class OauthTypeConverterTest { + + @DisplayName("OauthType 이 github 으로 입력될 때 변환에 성공한다.") + @ParameterizedTest + @ValueSource(strings = {"github", "Github", "GitHub", "GITHUB"}) + void github(final String oauthTypeValue) { + // given + final OauthTypeConverter oauthTypeConverter = new OauthTypeConverter(); + + // when + final OauthType convertedOauthType = oauthTypeConverter.convert(oauthTypeValue); + + // then + assertThat(convertedOauthType).isEqualTo(OauthType.GITHUB); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runner/RunnerTest.java b/backend/baton/src/test/java/touch/baton/domain/runner/RunnerTest.java new file mode 100644 index 000000000..8143ab4e5 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runner/RunnerTest.java @@ -0,0 +1,67 @@ +package touch.baton.domain.runner; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.exception.RunnerDomainException; +import touch.baton.domain.technicaltag.RunnerTechnicalTags; + +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RunnerTest { + + @DisplayName("생성 테스트") + @Nested + class Create { + + private final Member member = Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("imageUrl")) + .build(); + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> Runner.builder() + .member(member) + .runnerTechnicalTags(new RunnerTechnicalTags(new ArrayList<>())) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("member 가 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_member_is_null() { + assertThatThrownBy(() -> Runner.builder() + .member(null) + .runnerTechnicalTags(new RunnerTechnicalTags(new ArrayList<>())) + .build() + ).isInstanceOf(RunnerDomainException.class) + .hasMessage("Runner 의 member 는 null 일 수 없습니다."); + } + + @DisplayName("runnerTechnicalTags 가 null 이 들어갈 경우 예외가 발생하지 않는다.") + @Test + void fail_if_runnerTechnicalTags_is_null() { + assertThatCode(() -> Runner.builder() + .member(member) + .runnerTechnicalTags(null) + .build() + ).doesNotThrowAnyException(); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runner/repository/RunnerRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/runner/repository/RunnerRepositoryTest.java new file mode 100644 index 000000000..0d67369b5 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runner/repository/RunnerRepositoryTest.java @@ -0,0 +1,91 @@ +package touch.baton.domain.runner.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; + +import java.util.ArrayList; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class RunnerRepositoryTest extends RepositoryTestConfig { + + private static final MemberName memberName = new MemberName("헤에디주"); + private static final SocialId socialId = new SocialId("testSocialId"); + private static final OauthId oauthId = new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j"); + private static final GithubUrl githubUrl = new GithubUrl("github.com/hyena0608"); + private static final Company company = new Company("우아한형제들"); + private static final ImageUrl imageUrl = new ImageUrl("김석호"); + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private MemberRepository memberRepository; + + private Runner runner; + + @BeforeEach + void setUp() { + final Member member = Member.builder() + .memberName(memberName) + .socialId(socialId) + .oauthId(oauthId) + .githubUrl(githubUrl) + .company(company) + .imageUrl(imageUrl) + .build(); + memberRepository.save(member); + + runner = Runner.builder() + .member(member) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + } + + @DisplayName("Runner 를 Member 와 조인해서 조회할 수 있다.") + @Test + void findByIdJoinMember() { + // given + final Runner expected = runnerRepository.save(runner); + + // when + final Optional actual = runnerRepository.joinMemberByRunnerId(expected.getId()); + + // then + assertThat(actual).isPresent(); + final Member actualMember = actual.get().getMember(); + assertAll( + () -> assertThat(actualMember.getId()).isNotNull(), + () -> assertThat(actualMember.getMemberName()).isEqualTo(memberName), + () -> assertThat(actualMember.getCompany()).isEqualTo(company), + () -> assertThat(actualMember.getSocialId()).isEqualTo(socialId), + () -> assertThat(actualMember.getOauthId()).isEqualTo(oauthId), + () -> assertThat(actualMember.getGithubUrl()).isEqualTo(githubUrl) + ); + } + + @DisplayName("식별자가 없으면 Optional.empty()가 반환된다.") + @Test + void findByIdJoinMember_if_id_is_not_exists() { + // when + final Optional actual = runnerRepository.joinMemberByRunnerId(999L); + + // then + assertThat(actual).isEmpty(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceReadTest.java b/backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceReadTest.java new file mode 100644 index 000000000..8feb962a6 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceReadTest.java @@ -0,0 +1,57 @@ +package touch.baton.domain.runner.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class RunnerServiceReadTest extends ServiceTestConfig { + + private RunnerService runnerService; + + @BeforeEach + void setUp() { + runnerService = new RunnerService(runnerRepository, runnerTechnicalTagRepository, technicalTagRepository); + } + + @DisplayName("러너를 사용자와 함께 조회한다.") + @Test + void readRunnerWithMember() { + // given + final Member expectedMember = memberRepository.save(MemberFixture.createEthan()); + + final TechnicalTag javaTag = technicalTagRepository.save(TechnicalTagFixture.createJava()); + final TechnicalTag reactTag = technicalTagRepository.save(TechnicalTagFixture.createReact()); + final List technicalTags = List.of(javaTag, reactTag); + final Runner expectedRunner = runnerRepository.save(RunnerFixture.createRunner(expectedMember, technicalTags)); + + // when + final Runner actualRunner = runnerService.readByRunnerId(expectedRunner.getId()); + + // then + final Member actualMember = actualRunner.getMember(); + assertAll( + () -> assertThat(actualRunner.getId()).isEqualTo(expectedRunner.getId()), + () -> assertThat(actualRunner.getIntroduction()).isEqualTo(expectedRunner.getIntroduction()), + () -> assertThat(actualRunner.getRunnerTechnicalTags().getRunnerTechnicalTags()).containsExactlyElementsOf(expectedRunner.getRunnerTechnicalTags().getRunnerTechnicalTags()), + () -> assertThat(actualMember.getId()).isEqualTo(expectedMember.getId()), + () -> assertThat(actualMember.getMemberName()).isEqualTo(expectedMember.getMemberName()), + () -> assertThat(actualMember.getSocialId()).isEqualTo(expectedMember.getSocialId()), + () -> assertThat(actualMember.getOauthId()).isEqualTo(expectedMember.getOauthId()), + () -> assertThat(actualMember.getGithubUrl()).isEqualTo(expectedMember.getGithubUrl()), + () -> assertThat(actualMember.getCompany()).isEqualTo(expectedMember.getCompany()), + () -> assertThat(actualMember.getImageUrl()).isEqualTo(expectedMember.getImageUrl()) + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceUpdateTest.java b/backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceUpdateTest.java new file mode 100644 index 000000000..b9e0f4b26 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runner/service/RunnerServiceUpdateTest.java @@ -0,0 +1,68 @@ +package touch.baton.domain.runner.service; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.service.dto.RunnerUpdateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +class RunnerServiceUpdateTest extends ServiceTestConfig { + + private RunnerService runnerService; + + @BeforeEach + void setUp() { + runnerService = new RunnerService(runnerRepository, runnerTechnicalTagRepository, technicalTagRepository); + } + + @DisplayName("Runner 의 프로필을 수정한다.") + @Test + void updateRunnerProfile() { + // given + final Member memberJudy = memberRepository.save(MemberFixture.createJudy()); + final Runner runnerJudy = runnerRepository.save(RunnerFixture.createRunner(memberJudy)); + final RunnerUpdateRequest runnerUpdateRequest = new RunnerUpdateRequest("변경된 이름", "변경된 회사", "변경된 자기소개", List.of("changedTag1", "changedTag2")); + + // when, then + assertThatCode(() -> runnerService.updateRunner(runnerJudy, runnerUpdateRequest)) + .doesNotThrowAnyException(); + } + + @DisplayName("수정된 Runner 의 프로필을 조회하고 검증한다.") + @Test + void readUpdatedRunnerProfile() { + // given + final Member memberJudy = memberRepository.save(MemberFixture.createJudy()); + final Runner runnerJudy = runnerRepository.save(RunnerFixture.createRunner(memberJudy)); + final RunnerUpdateRequest runnerUpdateRequest = new RunnerUpdateRequest("변경된 이름", "변경된 회사", "변경된 자기소개", List.of("changedTag1", "changedTag2")); + + runnerService.updateRunner(runnerJudy, runnerUpdateRequest); + final Runner foundRunnerJudy = runnerRepository.findById(runnerJudy.getId()).get(); + + // when, then + assertAll( + () -> assertThat(foundRunnerJudy.getMember().getMemberName().getValue()).isEqualTo(runnerUpdateRequest.name()), + () -> assertThat(foundRunnerJudy.getMember().getCompany().getValue()).isEqualTo(runnerUpdateRequest.company()), + () -> assertThat(foundRunnerJudy.getIntroduction().getValue()).isEqualTo(runnerUpdateRequest.introduction()), + () -> assertThat(getRunnerTechnicalTags(foundRunnerJudy)).isEqualTo(runnerUpdateRequest.technicalTags()) + ); + } + + @NotNull + private List getRunnerTechnicalTags(final Runner foundRunnerJudy) { + return foundRunnerJudy.getRunnerTechnicalTags().getRunnerTechnicalTags().stream() + .map(runnerTechnicalTag -> runnerTechnicalTag.getTechnicalTag().getTagName().getValue()) + .toList(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java new file mode 100644 index 000000000..71d2f8bd3 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java @@ -0,0 +1,508 @@ +package touch.baton.domain.runnerpost; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.exception.RunnerPostDomainException; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.RunnerPostTags; +import touch.baton.domain.tag.Tag; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; +import touch.baton.fixture.vo.DeadlineFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.junit.jupiter.api.Assertions.assertAll; + +class RunnerPostTest { + + private final Member runnerMember = Member.builder() + .memberName(new MemberName("러너 사용자")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("ads7821iuqjkrhadsioh1f1r4efsoi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한테크코스")) + .imageUrl(new ImageUrl("김석호")) + .build(); + + private final Member supporterMember = Member.builder() + .memberName(new MemberName("서포터 사용자")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/pobi")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("김석호")) + .build(); + + private final Runner runner = Runner.builder() + .member(runnerMember) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + + private final Supporter supporter = Supporter.builder() + .reviewCount(new ReviewCount(10)) + .member(supporterMember) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build(); + + @DisplayName("runnerPostTags 전체를 추가할 수 있다.") + @Test + void addAllRunnerPostTags() { + // given + final String title = "JPA 리뷰 부탁 드려요."; + final String contents = "넘나 어려워요."; + final String pullRequestUrl = "https://github.com/cookienc"; + final LocalDateTime deadline = LocalDateTime.of(2099, 12, 12, 0, 0); + final RunnerPost runnerPost = RunnerPost.newInstance(title, contents, pullRequestUrl, deadline, runner); + final RunnerPostTag java = RunnerPostTag.builder() + .tag(Tag.newInstance("Java")) + .runnerPost(runnerPost) + .build(); + final RunnerPostTag spring = RunnerPostTag.builder() + .tag(Tag.newInstance("Spring")) + .runnerPost(runnerPost) + .build(); + + final List expectedTagNames = Arrays.asList("Java", "Spring"); + + // when + runnerPost.addAllRunnerPostTags(List.of(java, spring)); + List runnerPostTags = runnerPost.getRunnerPostTags().getRunnerPostTags(); + final List actualTagNames = runnerPostTags.stream() + .map(runnerPostTag -> runnerPostTag.getTag().getTagName().getValue()) + .collect(Collectors.toList()); + + // then + assertSoftly(softAssertions -> { + assertThat(runnerPost.getRunnerPostTags().getRunnerPostTags()).hasSize(2); + assertThat(actualTagNames).containsExactlyElementsOf(expectedTagNames); + }); + } + + @DisplayName("생성 테스트") + @Nested + class Create { + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> RunnerPost.builder() + .title(new Title("JPA 정복")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("supporterProfile 에 null 이 들어간 경우 아직 리뷰어 할당이 되지 않은 것이다.") + @Test + void success_if_supporter_is_null() { + assertThatCode(() -> RunnerPost.builder() + .title(new Title("아이")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(null) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("title 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_title_is_null() { + assertThatThrownBy(() -> RunnerPost.builder() + .title(null) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).isInstanceOf(RunnerPostDomainException.class) + .hasMessage("RunnerPost 의 title 은 null 일 수 없습니다."); + } + + @DisplayName("contents 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_contents_is_null() { + assertThatThrownBy(() -> RunnerPost.builder() + .title(new Title("헤나")) + .contents(null) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).isInstanceOf(RunnerPostDomainException.class) + .hasMessage("RunnerPost 의 contents 는 null 일 수 없습니다."); + } + + @DisplayName("pull request url 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_pullRequestUrl_is_null() { + assertThatThrownBy(() -> RunnerPost.builder() + .title(new Title("하이하이")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(null) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).isInstanceOf(RunnerPostDomainException.class) + .hasMessage("RunnerPost 의 pullRequestUrl 은 null 일 수 없습니다."); + } + + @DisplayName("deadline 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_deadline_is_null() { + assertThatThrownBy(() -> RunnerPost.builder() + .title(new Title("아이")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(null) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).isInstanceOf(RunnerPostDomainException.class) + .hasMessage("RunnerPost 의 deadline 은 null 일 수 없습니다."); + } + + @DisplayName("watched count 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_watchedCount_is_null() { + assertThatThrownBy(() -> RunnerPost.builder() + .title(new Title("아이")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(null) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).isInstanceOf(RunnerPostDomainException.class) + .hasMessage("RunnerPost 의 watchedCount 는 null 일 수 없습니다."); + } + + @DisplayName("runner 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_runner_is_null() { + assertThatThrownBy(() -> RunnerPost.builder() + .title(new Title("아이")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(null) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build() + ).isInstanceOf(RunnerPostDomainException.class) + .hasMessage("RunnerPost 의 runner 는 null 일 수 없습니다."); + } + + @DisplayName("runnerPostTags 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_runnerPostTags_is_null() { + assertThatThrownBy(() -> RunnerPost.builder() + .title(new Title("아이")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(null) + .build() + ).isInstanceOf(RunnerPostDomainException.class) + .hasMessage("RunnerPost 의 runnerPostTags 는 null 일 수 없습니다."); + } + + @DisplayName("태그, 조회수, 채팅수가 초기화된 RunnerPost 를 생성할 수 있다.") + @Test + void createDefaultRunnerPost() { + // given + final String title = "JPA 리뷰 부탁 드려요."; + final String contents = "넘나 어려워요."; + final String pullRequestUrl = "https://github.com/cookienc"; + final LocalDateTime deadline = LocalDateTime.of(2099, 12, 12, 0, 0); + final RunnerPost runnerPost = RunnerPost.newInstance(title, contents, pullRequestUrl, deadline, runner); + + // when, then + assertAll( + () -> assertThat(runnerPost.getTitle()).isEqualTo(new Title(title)), + () -> assertThat(runnerPost.getContents()).isEqualTo(new Contents(contents)), + () -> assertThat(runnerPost.getPullRequestUrl()).isEqualTo(new PullRequestUrl(pullRequestUrl)), + () -> assertThat(runnerPost.getDeadline()).isEqualTo(new Deadline(deadline)), + () -> assertThat(runnerPost.getRunnerPostTags()).isNotNull(), + () -> assertThat(runnerPost.getWatchedCount()).isEqualTo(new WatchedCount(0)) + ); + } + } + + @DisplayName("Supporter 할당") + @Nested + class AssignSupporter { + + @DisplayName("RunnerPost 내부의 Supporter 가 null 이며 ReviewStatus 가 NOT_STARTED 이어야 하며 Deadline 이 끝나지 않은 경우 성공한다.") + @Test + void success_supporter_is_null_and_deadline_is_not_end() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("JPA 정복")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(null) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // then + assertThatCode(() -> runnerPost.assignSupporter(supporter)) + .doesNotThrowAnyException(); + } + + @DisplayName("RunnerPost 내부의 Supporter 가 null 이 아닐 때 예외가 발생한다.") + @Test + void fail_supporter_is_not_null() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("JPA 정복")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // then + assertThatThrownBy(() -> runnerPost.assignSupporter(supporter)) + .isInstanceOf(RunnerPostDomainException.class); + } + + @DisplayName("RunnerPost 의 마감 기한이 이미 끝났을 때 예외가 발생한다.") + @Test + void fail_deadline_is_already_end() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("JPA 정복")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now().minusDays(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // then + assertThatThrownBy(() -> runnerPost.assignSupporter(supporter)) + .isInstanceOf(RunnerPostDomainException.class); + } + } + + @DisplayName("RunnerPost ReviewStatus 수정") + @Nested + class UpdateReviewStatus { + + private static Stream reviewStatusDummy() { + return Arrays.stream(ReviewStatus.values()) + .map(Arguments::arguments); + } + + @DisplayName("IN_PROGRESS 에서 DONE 으로 수정 성공한다.") + @Test + void success_IN_PROGRESS__to_DONE() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("러너가 작성하는 리뷰 요청 게시글의 테스트 제목입니다.")) + .contents(new Contents("안녕하세요. 테스트 내용입니다.")) + .pullRequestUrl(new PullRequestUrl("https://github.com")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.IN_PROGRESS) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // when + runnerPost.updateReviewStatus(ReviewStatus.DONE); + + // then + assertThat(runnerPost.getReviewStatus()).isEqualTo(ReviewStatus.DONE); + } + + @DisplayName("NOT_STARTED 에서 IN_PROGRESS 으로 수정 실패한다.") + @Test + void fail_NOT_STARTED__to_IN_PROGRESS() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("러너가 작성하는 리뷰 요청 게시글의 테스트 제목입니다.")) + .contents(new Contents("안녕하세요. 테스트 내용입니다.")) + .pullRequestUrl(new PullRequestUrl("https://github.com")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // when & then + assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.IN_PROGRESS)) + .isInstanceOf(RunnerPostDomainException.class); + } + + @DisplayName("NOT_STARTED 에서 DONE 으로 수정 실패한다.") + @Test + void fail_NOT_STARTED__to_DONE() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("러너가 작성하는 리뷰 요청 게시글의 테스트 제목입니다.")) + .contents(new Contents("안녕하세요. 테스트 내용입니다.")) + .pullRequestUrl(new PullRequestUrl("https://github.com")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.DONE) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // when & then + assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.DONE)) + .isInstanceOf(RunnerPostDomainException.class); + } + + @DisplayName("DONE 에서 NOT_STARTED 으로 수정 실패한다.") + @Test + void fail_DONE_to_NOT_STARTED() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("러너가 작성하는 리뷰 요청 게시글의 테스트 제목입니다.")) + .contents(new Contents("안녕하세요. 테스트 내용입니다.")) + .pullRequestUrl(new PullRequestUrl("https://github.com")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.DONE) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // when & then + assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.NOT_STARTED)) + .isInstanceOf(RunnerPostDomainException.class); + } + + @DisplayName("DONE 에서 IN_PROGRESS 으로 수정 실패한다.") + @Test + void fail_DONE_to_IN_PROGRESS() { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("러너가 작성하는 리뷰 요청 게시글의 테스트 제목입니다.")) + .contents(new Contents("안녕하세요. 테스트 내용입니다.")) + .pullRequestUrl(new PullRequestUrl("https://github.com")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.DONE) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // when & then + assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.IN_PROGRESS)) + .isInstanceOf(RunnerPostDomainException.class); + } + + @DisplayName("같은 ReviewStatus 로 수정할 경우 실패한다.") + @ParameterizedTest + @MethodSource("reviewStatusDummy") + void fail_same_to_same(final ReviewStatus reviewStatus) { + // given + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("러너가 작성하는 리뷰 요청 게시글의 테스트 제목입니다.")) + .contents(new Contents("안녕하세요. 테스트 내용입니다.")) + .pullRequestUrl(new PullRequestUrl("https://github.com")) + .deadline(new Deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .reviewStatus(reviewStatus) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + // when & then + assertThatThrownBy(() -> runnerPost.updateReviewStatus(reviewStatus)) + .isInstanceOf(RunnerPostDomainException.class); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/exception/validator/UrlValidatorTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/exception/validator/UrlValidatorTest.java new file mode 100644 index 000000000..4fc38051c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/exception/validator/UrlValidatorTest.java @@ -0,0 +1,121 @@ +package touch.baton.domain.runnerpost.exception.validator; + +import jakarta.validation.ClockProvider; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +import java.lang.annotation.Annotation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UrlValidatorTest { + + @DisplayName("URL을 검증한다.") + @Nested + class isValid { + + @DisplayName("옳은 URL이면 통과한다.") + @ValueSource(strings = {"https://www.naver.com", + "https://www.naver.com", + "https://github.com/cookienc", + "http://dev-king-ethan.n-e.kr", + "https://www.naver.com/hello", + "https://www.naver.com/hello?world=123", + "https://github.com/twitter/the-algorithm/pull/1740" + }) + @ParameterizedTest + void success(final String target) { + // given + final UrlValidator urlValidator = new UrlValidator(); + urlValidator.initialize(new MockValidNotUrl()); + final MockConstraintValidatorContext mockconstraintValidatorContext = new MockConstraintValidatorContext(); + + // when, then + assertThat(urlValidator.isValid(target, mockconstraintValidatorContext)).isTrue(); + } + + @DisplayName("옳지 않은 URL이면 통과하지 않는다.") + @ValueSource(strings = {"https;//github.com/hello", + "https://", + "http://", + "URL 아님", + "github.com/twitter/the-algorithm/pull/1740", + "htts:github.com/twitter/the-algorithm/pull/1740" + }) + @ParameterizedTest + void fail_if_not_url(final String target) { + // given + final UrlValidator urlValidator = new UrlValidator(); + urlValidator.initialize(new MockValidNotUrl()); + final MockConstraintValidatorContext mockConstraintValidatorContext = new MockConstraintValidatorContext(); + + // when, then + assertThatThrownBy(() -> urlValidator.isValid(target, mockConstraintValidatorContext)) + .isInstanceOf(ClientRequestException.class); + } + } + + private static class MockConstraintValidatorContext implements ConstraintValidatorContext { + + @Override + public void disableDefaultConstraintViolation() { + + } + + @Override + public String getDefaultConstraintMessageTemplate() { + return null; + } + + @Override + public ClockProvider getClockProvider() { + return null; + } + + @Override + public ConstraintViolationBuilder buildConstraintViolationWithTemplate(final String messageTemplate) { + return null; + } + + @Override + public T unwrap(final Class type) { + return null; + } + } + + private static class MockValidNotUrl implements ValidNotUrl { + + @Override + public String message() { + return null; + } + + @Override + public Class[] groups() { + return null; + } + + @Override + public Class[] payload() { + return null; + } + + @Override + public ClientErrorCode clientErrorCode() { + return ClientErrorCode.PULL_REQUEST_URL_IS_NOT_URL; + } + + @Override + public Class annotationType() { + return null; + } + } + +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryDeleteTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryDeleteTest.java new file mode 100644 index 000000000..3454d722b --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryDeleteTest.java @@ -0,0 +1,85 @@ +package touch.baton.domain.runnerpost.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.tag.RunnerPostTags; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class RunnerPostRepositoryDeleteTest extends RepositoryTestConfig { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private RunnerPostRepository runnerPostRepository; + + @DisplayName("RunnerPost 식별자값으로 RunnerPost 을 삭제한다.") + @Test + void success_deleteByRunnerPostId() { + // given + final Member member = Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("홍혁준")) + .build(); + final Member saveMember = memberRepository.saveAndFlush(member); + + final Runner runner = Runner.builder() + .member(saveMember) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + final Runner saveRunner = runnerRepository.saveAndFlush(runner); + + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("제 코드 리뷰 좀 해주세요!!")) + .contents(new Contents("제 코드는 클린코드가 맞을까요?")) + .deadline(new Deadline(LocalDateTime.now())) + .pullRequestUrl(new PullRequestUrl("https://")) + .watchedCount(new WatchedCount(1)) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(saveRunner) + .supporter(null) + .build(); + final Long saveRunnerPostId = runnerPostRepository.saveAndFlush(runnerPost).getId(); + + // when + runnerPostRepository.deleteById(saveRunnerPostId); + + final Optional maybeRunnerPost = runnerPostRepository.findById(saveRunnerPostId); + + // then + assertThat(maybeRunnerPost).isNotPresent(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryReadTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryReadTest.java new file mode 100644 index 000000000..a418ff785 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryReadTest.java @@ -0,0 +1,86 @@ +package touch.baton.domain.runnerpost.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.tag.RunnerPostTags; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class RunnerPostRepositoryReadTest extends RepositoryTestConfig { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private RunnerPostRepository runnerPostRepository; + + @DisplayName("RunnerPost 식별자값으로 RunnerPost 을 삭제한다.") + @Test + void success_deleteByRunnerPostId() { + // given + final Member member = Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("홍혁준")) + .build(); + final Member saveMember = memberRepository.saveAndFlush(member); + + final Runner runner = Runner.builder() + .member(saveMember) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + + final Runner saveRunner = runnerRepository.saveAndFlush(runner); + + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("제 코드 리뷰 좀 해주세요!!")) + .contents(new Contents("제 코드는 클린코드가 맞을까요?")) + .deadline(new Deadline(LocalDateTime.now())) + .pullRequestUrl(new PullRequestUrl("https://")) + .watchedCount(new WatchedCount(1)) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(saveRunner) + .supporter(null) + .build(); + final Long saveRunnerPostId = runnerPostRepository.saveAndFlush(runnerPost).getId(); + + // when + runnerPostRepository.deleteById(saveRunnerPostId); + + final Optional maybeRunnerPost = runnerPostRepository.findById(saveRunnerPostId); + + // then + assertThat(maybeRunnerPost).isNotPresent(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryTest.java new file mode 100644 index 000000000..f4dba6fef --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/RunnerPostRepositoryTest.java @@ -0,0 +1,162 @@ +package touch.baton.domain.runnerpost.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.repository.SupporterRepository; +import touch.baton.domain.supporter.repository.SupporterRunnerPostRepository; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class RunnerPostRepositoryTest extends RepositoryTestConfig { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private SupporterRepository supporterRepository; + + @Autowired + private RunnerPostRepository runnerPostRepository; + + @Autowired + private SupporterRunnerPostRepository supporterRunnerPostRepository; + + @DisplayName("Supporter 식별자값과 ReviewStatus 로 연관된 RunnerPost 를 페이징하여 조회한다.") + @Test + void findBySupporterIdAndReviewStatus() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + final Member savedMemberEthan = memberRepository.save(MemberFixture.createEthan()); + final Runner savedRunnerEthan = runnerRepository.save(RunnerFixture.createRunner(savedMemberEthan)); + final Member savedMemberJudy = memberRepository.save(MemberFixture.createJudy()); + final Runner savedRunnerJudy = runnerRepository.save(RunnerFixture.createRunner(savedMemberJudy)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost runnerPostOne = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostOne = runnerPostRepository.save(runnerPostOne); + final RunnerPost runnerPostTwo = RunnerPostFixture.create(savedRunnerEthan, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostTwo = runnerPostRepository.save(runnerPostTwo); + final RunnerPost runnerPostThree = RunnerPostFixture.create(savedRunnerJudy, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostThree = runnerPostRepository.save(runnerPostThree); + + savedRunnerPostOne.assignSupporter(savedSupporterHyena); + savedRunnerPostOne.updateReviewStatus(ReviewStatus.DONE); + savedRunnerPostTwo.assignSupporter(savedSupporterHyena); + savedRunnerPostTwo.updateReviewStatus(ReviewStatus.DONE); + savedRunnerPostThree.assignSupporter(savedSupporterHyena); + savedRunnerPostThree.updateReviewStatus(ReviewStatus.DONE); + + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostOne, savedSupporterHyena)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostTwo, savedSupporterHyena)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostThree, savedSupporterHyena)); + + // when + final PageRequest pageOne = PageRequest.of(0, 2); + final PageRequest pageTwo = PageRequest.of(1, 2); + + final Page pageOneRunnerPosts + = runnerPostRepository.findBySupporterIdAndReviewStatus(pageOne, savedSupporterHyena.getId(), ReviewStatus.DONE); + final Page pageTwoRunnerPosts + = runnerPostRepository.findBySupporterIdAndReviewStatus(pageTwo, savedSupporterHyena.getId(), ReviewStatus.DONE); + + // then + assertSoftly(softly -> { + softly.assertThat(pageOneRunnerPosts.getContent()).containsExactly(savedRunnerPostOne, savedRunnerPostTwo); + softly.assertThat(pageTwoRunnerPosts.getContent()).containsExactly(savedRunnerPostThree); + }); + } + + @DisplayName("join 한 SupporterRunnerPost의 Supporter 외래키가 Supporter 식별자값과 같고, ReviewStatus 로 연관된 RunnerPost 를 페이징하여 조회한다.") + @Test + void joinSupporterRunnerPostBySupporterIdAndReviewStatus() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + final Member savedMemberEthan = memberRepository.save(MemberFixture.createEthan()); + final Runner savedRunnerEthan = runnerRepository.save(RunnerFixture.createRunner(savedMemberEthan)); + final Member savedMemberJudy = memberRepository.save(MemberFixture.createJudy()); + final Runner savedRunnerJudy = runnerRepository.save(RunnerFixture.createRunner(savedMemberJudy)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedApplicantHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost runnerPostOne = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostOne = runnerPostRepository.save(runnerPostOne); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostOne, savedApplicantHyena)); + final RunnerPost runnerPostTwo = RunnerPostFixture.create(savedRunnerEthan, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostTwo = runnerPostRepository.save(runnerPostTwo); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostTwo, savedApplicantHyena)); + final RunnerPost runnerPostThree = RunnerPostFixture.create(savedRunnerJudy, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostThree = runnerPostRepository.save(runnerPostThree); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostThree, savedApplicantHyena)); + + // when + final PageRequest pageOne = PageRequest.of(0, 2); + final PageRequest pageTwo = PageRequest.of(1, 2); + + final Page pageOneRunnerPosts + = runnerPostRepository.joinSupporterRunnerPostBySupporterIdAndReviewStatus(pageOne, savedApplicantHyena.getId(), ReviewStatus.NOT_STARTED); + final Page pageTwoRunnerPosts + = runnerPostRepository.joinSupporterRunnerPostBySupporterIdAndReviewStatus(pageTwo, savedApplicantHyena.getId(), ReviewStatus.NOT_STARTED); + + // then + assertSoftly(softly -> { + softly.assertThat(pageOneRunnerPosts.getContent()).containsExactly(savedRunnerPostOne, savedRunnerPostTwo); + softly.assertThat(pageTwoRunnerPosts.getContent()).containsExactly(savedRunnerPostThree); + }); + } + + @DisplayName("Runner 식별자값과 ReviewStatus 로 연관된 RunnerPost 를 페이징하여 조회한다.") + @Test + void findByRunnerIdAndReviewStatus() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final RunnerPost runnerPostOne = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostOne = runnerPostRepository.save(runnerPostOne); + final RunnerPost runnerPostTwo = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostTwo = runnerPostRepository.save(runnerPostTwo); + final RunnerPost runnerPostThree = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100))); + final RunnerPost savedRunnerPostThree = runnerPostRepository.save(runnerPostThree); + + // when + final PageRequest pageOne = PageRequest.of(0, 2); + final PageRequest pageTwo = PageRequest.of(1, 2); + + final Page pageOneRunnerPosts + = runnerPostRepository.findByRunnerIdAndReviewStatus(pageOne, savedRunnerDitoo.getId(), ReviewStatus.NOT_STARTED); + final Page pageTwoRunnerPosts + = runnerPostRepository.findByRunnerIdAndReviewStatus(pageTwo, savedRunnerDitoo.getId(), ReviewStatus.NOT_STARTED); + + assertSoftly(softly -> { + softly.assertThat(pageOneRunnerPosts.getContent()).containsExactly(savedRunnerPostOne, savedRunnerPostTwo); + softly.assertThat(pageTwoRunnerPosts.getContent()).containsExactly(savedRunnerPostThree); + }); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/SupporterRunnerPostRepositoryReadTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/SupporterRunnerPostRepositoryReadTest.java new file mode 100644 index 000000000..08fc35c3e --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/SupporterRunnerPostRepositoryReadTest.java @@ -0,0 +1,105 @@ +package touch.baton.domain.runnerpost.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.repository.SupporterRepository; +import touch.baton.domain.supporter.repository.SupporterRunnerPostRepository; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class SupporterRunnerPostRepositoryReadTest extends RepositoryTestConfig { + + @Autowired + private SupporterRunnerPostRepository supporterRunnerPostRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private SupporterRepository supporterRepository; + + @Autowired + private RunnerPostRepository runnerPostRepository; + + @DisplayName("RunnerPostId 와 SupporterId 로 존재 유무를 확인할 수 있다.") + @Test + void existsByRunnerPostIdAndSupporterId() { + // given + final Member ehtanMember = memberRepository.save(MemberFixture.createEthan()); + final Runner runnerPostOwner = runnerRepository.save(RunnerFixture.createRunner(ehtanMember)); + final RunnerPost runner = runnerPostRepository.save(RunnerPostFixture.create(runnerPostOwner, + deadline(LocalDateTime.now().plusDays(10)))); + + final Member hyenaMember = memberRepository.save(MemberFixture.createHyena()); + final Supporter supporter = supporterRepository.save(SupporterFixture.create(hyenaMember)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(runner, supporter)); + + final Long notSavedRunnerPostId = -1L; + final Long notSavedSupporter = -1L; + + // when, then + assertSoftly(softly -> { + softly.assertThat(supporterRunnerPostRepository.existsByRunnerPostIdAndSupporterId(runner.getId(), supporter.getId())).isTrue(); + softly.assertThat(supporterRunnerPostRepository.existsByRunnerPostIdAndSupporterId(notSavedRunnerPostId, supporter.getId())).isFalse(); + softly.assertThat(supporterRunnerPostRepository.existsByRunnerPostIdAndSupporterId(runner.getId(), notSavedSupporter)).isFalse(); + } + ); + } + + @DisplayName("RunnerPostId 로 지원한 서포터의 수를 확인할 수 있다.") + @Test + void countByRunnerPostIds() { + // given + final Member ehtanMember = memberRepository.save(MemberFixture.createEthan()); + final Runner runnerPostOwner = runnerRepository.save(RunnerFixture.createRunner(ehtanMember)); + final RunnerPost firstRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(runnerPostOwner, + deadline(LocalDateTime.now().plusDays(10)))); + final RunnerPost twoRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(runnerPostOwner, + deadline(LocalDateTime.now().plusDays(10)))); + + final Member hyenaMember = memberRepository.save(MemberFixture.createHyena()); + final Supporter hyenaSupporter = supporterRepository.save(SupporterFixture.create(hyenaMember)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(firstRunnerPost, hyenaSupporter)); + + final Member judyMember = memberRepository.save(MemberFixture.createJudy()); + final Supporter judySupporter = supporterRepository.save(SupporterFixture.create(judyMember)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(firstRunnerPost, judySupporter)); + + // when + final List applicantCounts = supporterRunnerPostRepository.countByRunnerPostIds(List.of(firstRunnerPost.getId(), twoRunnerPost.getId())); + + final Long actualSecondRunnerPostApplicantsCount = applicantCounts.get(0); + final Long actualFirstRunnerPostApplicantsCount = applicantCounts.get(1); + final Long expectedSecondRunnerPostApplicantsCount = 0L; + final Long expectedFirstRunnerPostApplicantsCount = 2L; + final int expectedSize = 2; + + // when, then + assertSoftly(softly -> { + softly.assertThat(applicantCounts.size()).isEqualTo(expectedSize); + softly.assertThat(actualSecondRunnerPostApplicantsCount).isEqualTo(expectedSecondRunnerPostApplicantsCount); + softly.assertThat(actualFirstRunnerPostApplicantsCount).isEqualTo(expectedFirstRunnerPostApplicantsCount); + } + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/read/RunnerPostRepositoryReadTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/read/RunnerPostRepositoryReadTest.java new file mode 100644 index 000000000..69d29eec0 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/repository/read/RunnerPostRepositoryReadTest.java @@ -0,0 +1,119 @@ +package touch.baton.domain.runnerpost.repository.read; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.repository.RunnerPostRepository; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.Tag; +import touch.baton.domain.tag.repository.RunnerPostTagRepository; +import touch.baton.domain.tag.repository.TagRepository; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.RunnerPostTagFixture; +import touch.baton.fixture.domain.RunnerPostTagsFixture; +import touch.baton.fixture.domain.TagFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.NOT_STARTED; +import static touch.baton.fixture.vo.ContentsFixture.contents; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.PullRequestUrlFixture.pullRequestUrl; +import static touch.baton.fixture.vo.TitleFixture.title; +import static touch.baton.fixture.vo.WatchedCountFixture.watchedCount; + +class RunnerPostRepositoryReadTest extends RepositoryTestConfig { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private RunnerPostRepository runnerPostRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private RunnerPostTagRepository runnerPostTagRepository; + + @Autowired + private EntityManager entityManager; + + @DisplayName("RunnerPost 식별자로 RunnerPostTag 목록을 조회할 때 Tag 가 있으면 조회된다.") + @Test + void findRunnerPostTagsById_exist() { + // given + final Member ditoo = MemberFixture.createDitoo(); + entityManager.persist(ditoo); + final Runner runner = RunnerFixture.createRunner(ditoo); + entityManager.persist(runner); + + final RunnerPost runnerPost = RunnerPostFixture.create(title("제 코드를 리뷰해주세요"), + contents("제 코드의 내용은 이렇습니다."), + pullRequestUrl("https://"), + deadline(LocalDateTime.now().plusHours(10)), + watchedCount(0), + NOT_STARTED, + runner, + null, + RunnerPostTagsFixture.runnerPostTags(new ArrayList<>())); + runnerPostRepository.save(runnerPost); + + final Tag java = TagFixture.createJava(); + entityManager.persist(java); + final Tag spring = TagFixture.createSpring(); + entityManager.persist(spring); + final RunnerPostTag javaRunnerPostTag = RunnerPostTagFixture.create(runnerPost, java); + final RunnerPostTag springRunnerPostTag = RunnerPostTagFixture.create(runnerPost, spring); + + runnerPost.addAllRunnerPostTags(List.of(javaRunnerPostTag, springRunnerPostTag)); + + // when + final List expected = runnerPostTagRepository.joinTagByRunnerPostId(runnerPost.getId()); + + // then + assertThat(expected).containsExactly(javaRunnerPostTag, springRunnerPostTag); + } + + @DisplayName("Runner 식별자로 RunnerPost 목록을 조회한다.") + @Test + void findByRunnerId() { + // given + final Member ditoo = MemberFixture.createDitoo(); + entityManager.persist(ditoo); + final Runner runner = RunnerFixture.createRunner(ditoo); + entityManager.persist(runner); + + final RunnerPost runnerPost = RunnerPostFixture.create(title("제 코드를 리뷰해주세요"), + contents("제 코드의 내용은 이렇습니다."), + pullRequestUrl("https://"), + deadline(LocalDateTime.now().plusHours(10)), + watchedCount(0), + NOT_STARTED, + runner, + null, + RunnerPostTagsFixture.runnerPostTags(new ArrayList<>())); + runnerPostRepository.save(runnerPost); + + // when + final List actual = runnerPostRepository.findByRunnerId(runner.getId()); + + // then + assertThat(actual).containsExactly(runnerPost); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceCreateTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceCreateTest.java new file mode 100644 index 000000000..fe9b00599 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceCreateTest.java @@ -0,0 +1,160 @@ +package touch.baton.domain.runnerpost.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.exception.RunnerPostBusinessException; +import touch.baton.domain.runnerpost.service.dto.RunnerPostApplicantCreateRequest; +import touch.baton.domain.runnerpost.service.dto.RunnerPostCreateRequest; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static java.time.LocalDateTime.now; +import static java.time.LocalDateTime.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.junit.jupiter.api.Assertions.assertAll; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class RunnerPostServiceCreateTest extends ServiceTestConfig { + + private static final String TITLE = "코드 리뷰 해주세요."; + private static final String TAG = "Java"; + private static final String OTHER_TAG = "Spring"; + private static final String PULL_REQUEST_URL = "https://github.com/cookienc"; + private static final LocalDateTime DEADLINE = LocalDateTime.now().plusDays(10); + private static final String CONTENTS = "싸게 부탁드려요."; + + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + runnerPostService = new RunnerPostService( + runnerPostRepository, + runnerPostTagRepository, + tagRepository, + supporterRepository, + supporterRunnerPostRepository + ); + } + + @DisplayName("Runner post 저장에 성공한다.") + @Test + void success() { + // given + final RunnerPostCreateRequest request = new RunnerPostCreateRequest(TITLE, + List.of(TAG, OTHER_TAG), + PULL_REQUEST_URL, + DEADLINE, + CONTENTS); + final Member ethanMember = memberRepository.save(MemberFixture.createEthan()); + final Runner runner = runnerRepository.save(RunnerFixture.createRunner(ethanMember)); + + // when + final Long savedId = runnerPostService.createRunnerPost(runner, request); + + // then + assertThat(savedId).isNotNull(); + final Optional maybeActual = runnerPostRepository.findById(savedId); + assertThat(maybeActual).isPresent(); + final RunnerPost actual = maybeActual.get(); + assertAll( + () -> assertThat(actual.getTitle()).isEqualTo(new Title(TITLE)), + () -> assertThat(actual.getContents()).isEqualTo(new Contents(CONTENTS)), + () -> assertThat(actual.getPullRequestUrl()).isEqualTo(new PullRequestUrl(PULL_REQUEST_URL)), + () -> assertThat(actual.getDeadline()).isEqualTo(new Deadline(DEADLINE)), + () -> assertThat(actual.getWatchedCount()).isEqualTo(new WatchedCount(0)), + () -> assertThat(actual.getRunner()).isEqualTo(runner) + ); + } + + @DisplayName("Supporter 가 RunnerPost 에 리뷰를 지원한다.") + @Test + void success_createRunnerPostApplicant() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitto = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitto, deadline(now().plusHours(100)))); + + // when + final RunnerPostApplicantCreateRequest request = new RunnerPostApplicantCreateRequest("안녕하세요. 서포터 헤나입니다."); + final Long savedRunnerPostApplicantId = runnerPostService.createRunnerPostApplicant(savedSupporterHyena, request, savedRunnerPost.getId()); + + final Optional maybeRunnerPostApplicant = supporterRunnerPostRepository.findById(savedRunnerPostApplicantId); + + // then + assertSoftly(softly -> { + softly.assertThat(maybeRunnerPostApplicant).isPresent(); + softly.assertThat(maybeRunnerPostApplicant.get().getId()) + .isNotNull() + .isEqualTo(maybeRunnerPostApplicant.get().getId()); + softly.assertThat(maybeRunnerPostApplicant.get().getSupporter()).isEqualTo(savedSupporterHyena); + softly.assertThat(maybeRunnerPostApplicant.get().getRunnerPost()).isEqualTo(savedRunnerPost); + softly.assertThat(maybeRunnerPostApplicant.get().getMessage().getValue()).isEqualTo(request.message()); + }); + } + + @DisplayName("Supporter 가 RunnerPost 에 리뷰를 지원할 때 RunnerPost 가 존재하지 않을 경우 예외가 발생한다.") + @Test + void fail_createRunnerPostApplicant_if_runnerPost_is_null() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitto = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + // when + final RunnerPostApplicantCreateRequest request = new RunnerPostApplicantCreateRequest("안녕하세요. 서포터 헤나입니다."); + + // then + assertThatThrownBy(() -> runnerPostService.createRunnerPostApplicant(savedSupporterHyena, request, 0L)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("Supporter 가 RunnerPost 에 리뷰를 지원할 때 이미 지원한 이력이 있는 경우 예외가 발생한다.") + @Test + void fail_createRunnerPostApplicant_if_supporter_already_applied() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitto = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitto, deadline(now().plusHours(100)))); + + // when + final RunnerPostApplicantCreateRequest request = new RunnerPostApplicantCreateRequest("안녕하세요. 서포터 헤나입니다."); + + // then + assertSoftly(softly -> { + softly.assertThatCode(() -> runnerPostService.createRunnerPostApplicant(savedSupporterHyena, request, savedRunnerPost.getId())) + .doesNotThrowAnyException(); + softly.assertThatThrownBy(() -> runnerPostService.createRunnerPostApplicant(savedSupporterHyena, request, savedRunnerPost.getId())) + .isInstanceOf(RunnerPostBusinessException.class); + }); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceDeleteTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceDeleteTest.java new file mode 100644 index 000000000..0d8a2d320 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceDeleteTest.java @@ -0,0 +1,119 @@ +package touch.baton.domain.runnerpost.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.exception.RunnerPostBusinessException; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class RunnerPostServiceDeleteTest extends ServiceTestConfig { + + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + runnerPostService = new RunnerPostService( + runnerPostRepository, + runnerPostTagRepository, + tagRepository, + supporterRepository, + supporterRunnerPostRepository + ); + } + + @DisplayName("RunnerPost 식별자값으로 RunnerPost 을 삭제한다.") + @Test + void success_deleteByRunnerPostId() { + // given + final Member member = memberRepository.save(MemberFixture.createDitoo()); + final Runner runner = runnerRepository.save(RunnerFixture.createRunner(member)); + final RunnerPost runnerPost = runnerPostRepository.save(RunnerPostFixture.create(runner, deadline(LocalDateTime.now().plusHours(10)))); + + // when + runnerPostService.deleteByRunnerPostId(runnerPost.getId(), runner); + + // then + assertThat(runnerPostRepository.existsById(runnerPost.getId())).isFalse(); + } + + @DisplayName("RunnerPost 식별자값으로 존재하지 않는 RunnerPost 를 삭제 시도할 경우 예외가 발생한다.") + @Test + void fail_deleteByRunnerPostId_if_runnerPost_is_null() { + final Member member = memberRepository.save(MemberFixture.createDitoo()); + final Runner runner = runnerRepository.save(RunnerFixture.createRunner(member)); + assertThatThrownBy(() -> runnerPostService.deleteByRunnerPostId(0L, runner)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("RunnerPost 를 작성하지 않은 Runner 가 삭제 시도할 경우 예외가 발생한다.") + @Test + void fail_deleteByRunnerPostId_if_not_owner() { + // given + final Member memberRunnerPostOwner = memberRepository.save(MemberFixture.createDitoo()); + final Runner runnerPostOwner = runnerRepository.save(RunnerFixture.createRunner(memberRunnerPostOwner)); + final RunnerPost runnerPost = runnerPostRepository.save(RunnerPostFixture.create( + runnerPostOwner, + deadline(LocalDateTime.now().plusHours(10)) + )); + final Member memberRunnerPostNotOwner = memberRepository.save(MemberFixture.createJudy()); + final Runner runnerPostNotOwner = runnerRepository.save(RunnerFixture.createRunner(memberRunnerPostNotOwner)); + + // when & then + assertThatThrownBy(() -> runnerPostService.deleteByRunnerPostId(runnerPost.getId(), runnerPostNotOwner)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("NOT_STARTED 상태가 아닌 RunnerPost 를 삭제 시도할 경우 예외가 발생한다.") + @Test + void fail_deleteByRunnerPostId_if_reviewStatus_is_not_NOT_STARTED() { + // given + final Member memberRunner = memberRepository.save(MemberFixture.createDitoo()); + final Runner runner = runnerRepository.save(RunnerFixture.createRunner(memberRunner)); + final RunnerPost runnerPost = runnerPostRepository.save(RunnerPostFixture.create( + runner, + deadline(LocalDateTime.now().plusHours(10)) + )); + final Member memberSupporter = memberRepository.save(MemberFixture.createEthan()); + final Supporter supporter = supporterRepository.save(SupporterFixture.create(memberSupporter)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(runnerPost, supporter)); + runnerPost.assignSupporter(supporter); + + // when & then + assertThatThrownBy(() -> runnerPostService.deleteByRunnerPostId(runnerPost.getId(), runner)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("지원한 서포터가 있는 경우에 RunnerPost 를 삭제 시도할 경우 예외가 발생한다.") + @Test + void fail_deleteByRunnerPostId_if_applicant_is_exist() { + // given + final Member member = memberRepository.save(MemberFixture.createDitoo()); + final Runner runner = runnerRepository.save(RunnerFixture.createRunner(member)); + final RunnerPost runnerPost = runnerPostRepository.save(RunnerPostFixture.create( + runner, + deadline(LocalDateTime.now().plusHours(10)) + )); + final Member memberSupporter = memberRepository.save(MemberFixture.createEthan()); + final Supporter supporter = supporterRepository.save(SupporterFixture.create(memberSupporter)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(runnerPost, supporter)); + + // when & then + assertThatThrownBy(() -> runnerPostService.deleteByRunnerPostId(runnerPost.getId(), runner)) + .isInstanceOf(RunnerPostBusinessException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceReadTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceReadTest.java new file mode 100644 index 000000000..ca9cbee4a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceReadTest.java @@ -0,0 +1,327 @@ +package touch.baton.domain.runnerpost.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.exception.RunnerPostBusinessException; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.RunnerPostTags; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static java.time.LocalDateTime.now; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.junit.jupiter.api.Assertions.assertAll; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.NOT_STARTED; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class RunnerPostServiceReadTest extends ServiceTestConfig { + + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + runnerPostService = new RunnerPostService( + runnerPostRepository, + runnerPostTagRepository, + tagRepository, + supporterRepository, + supporterRunnerPostRepository + ); + } + + @DisplayName("RunnerPost 식별자로 RunnerPost 를 조회한다.") + @Test + void success_findByRunnerPostId() { + // given + final Member member = Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("홍혁준")) + .build(); + memberRepository.save(member); + + final Runner runner = Runner.builder() + .member(member) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + runnerRepository.save(runner); + + final LocalDateTime deadline = now(); + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("제 코드 리뷰 좀 해주세요!!")) + .contents(new Contents("제 코드는 클린코드가 맞을까요?")) + .deadline(new Deadline(deadline)) + .pullRequestUrl(new PullRequestUrl("https://")) + .watchedCount(new WatchedCount(0)) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .reviewStatus(NOT_STARTED) + .runner(runner) + .supporter(null) + .build(); + runnerPostRepository.save(runnerPost); + + final Tag tag = Tag.builder() + .tagName(new TagName("자바")) + .build(); + tagRepository.save(tag); + + final RunnerPostTag runnerPostTag = RunnerPostTag.builder() + .runnerPost(runnerPost) + .tag(tag) + .build(); + runnerPost.addAllRunnerPostTags(List.of(runnerPostTag)); + + // when + final RunnerPost findRunnerPost = runnerPostService.readByRunnerPostId(runnerPost.getId()); + + // then + assertThat(findRunnerPost) + .usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(runnerPost); + } + + @DisplayName("RunnerPost 식별자로 존재하지 않는 RunnerPost 를 조회할 경우 예외가 발생한다.") + @Test + void fail_findByRunnerPostId_if_runner_post_is_null() { + assertThatThrownBy(() -> runnerPostService.readByRunnerPostId(0L)) + .isInstanceOf(RunnerPostBusinessException.class) + .hasMessage("RunnerPost 의 식별자값으로 러너 게시글을 조회할 수 없습니다."); + } + + @DisplayName("Runner 식별자값으로 RunnerPost 를 조회한다.") + @Test + void success_findByRunnerId() { + // given + final Member ditoo = MemberFixture.createDitoo(); + memberRepository.save(ditoo); + final Runner runner = RunnerFixture.createRunner(ditoo); + runnerRepository.save(runner); + final RunnerPost expected = RunnerPostFixture.create(runner, new Deadline(now().plusHours(100))); + runnerPostRepository.save(expected); + + // when + final List actual = runnerPostService.readRunnerPostsByRunnerId(runner.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0)).isEqualTo(expected); + }); + } + + @DisplayName("Supporter 외래키와 ReviewStatus 가 NOT_STARTED 가 아닌 것으로 러너 게시글을 조회한다.") + @Test + void readRunnerPostsBySupporterIdAndReviewStatusIsNot_NOT_STARTED() { + // given + final Member savedMemberEthan = memberRepository.save(MemberFixture.createEthan()); + final Runner savedRunnerEthan = runnerRepository.save(RunnerFixture.createRunner(savedMemberEthan)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost runnerPost = RunnerPostFixture.create(savedRunnerEthan, deadline(now().plusHours(100))); + final RunnerPost savedRunnerPost = runnerPostRepository.save(runnerPost); + savedRunnerPost.assignSupporter(savedSupporterHyena); + + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(runnerPost, savedSupporterHyena)); + + // when + final PageRequest pageable = PageRequest.of(0, 10); + final Page pageRunnerPosts + = runnerPostService.readRunnerPostsBySupporterIdAndReviewStatus(pageable, savedSupporterHyena.getId(), ReviewStatus.IN_PROGRESS); + + // then + assertAll( + () -> assertThat(pageRunnerPosts.getPageable()).isEqualTo(pageable), + () -> assertThat(pageRunnerPosts.getContent()).containsExactly(savedRunnerPost) + ); + } + + @DisplayName("Runner 외래키와 ReviewStatus 로 러너 게시글을 조회한다.") + @Test + void readRunnerPostsByRunnerIdAndReviewStatus() { + // given + final Member savedMemberEthan = memberRepository.save(MemberFixture.createEthan()); + final Runner savedRunnerEthan = runnerRepository.save(RunnerFixture.createRunner(savedMemberEthan)); + + final RunnerPost runnerPost = RunnerPostFixture.create(savedRunnerEthan, deadline(now().plusHours(100))); + final RunnerPost savedRunnerPost = runnerPostRepository.save(runnerPost); + + // when + final PageRequest pageable = PageRequest.of(0, 10); + final Page pageRunnerPosts + = runnerPostService.readRunnerPostsByRunnerIdAndReviewStatus(pageable, savedRunnerEthan.getId(), ReviewStatus.NOT_STARTED); + + // then + assertAll( + () -> assertThat(pageRunnerPosts.getPageable()).isEqualTo(pageable), + () -> assertThat(pageRunnerPosts.getContent()).containsExactly(savedRunnerPost) + ); + } + + @DisplayName("RunnerPost 식별자값으로 Supporter 지원자수를 count 한다.") + @Test + void readCountByRunnerPostId() { + // given + final Member savedMemberEthan = memberRepository.save(MemberFixture.createEthan()); + final Runner savedRunnerEthan = runnerRepository.save(RunnerFixture.createRunner(savedMemberEthan)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost runnerPost = RunnerPostFixture.create(savedRunnerEthan, deadline(now().plusHours(100))); + final RunnerPost savedRunnerPost = runnerPostRepository.save(runnerPost); + savedRunnerPost.assignSupporter(savedSupporterHyena); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(runnerPost, savedSupporterHyena)); + + // when + final long foundApplicantCount = runnerPostService.readCountByRunnerPostId(savedRunnerPost.getId()); + + // then + assertThat(foundApplicantCount).isEqualTo(1); + } + + @DisplayName("RunnerPost 식별자값으로 찾은 Supporter 지원자가 아무도 없을 경우 count 로 0을 반환한다.") + @Test + void readCountByRunnerPostId_is_null_then_return_zero() { + // given + final Member savedMemberEthan = memberRepository.save(MemberFixture.createEthan()); + final Runner savedRunnerEthan = runnerRepository.save(RunnerFixture.createRunner(savedMemberEthan)); + + final RunnerPost runnerPost = RunnerPostFixture.create(savedRunnerEthan, deadline(now().plusHours(100))); + final RunnerPost savedRunnerPost = runnerPostRepository.save(runnerPost); + + // when + final long foundApplicantCount = runnerPostService.readCountByRunnerPostId(savedRunnerPost.getId()); + + // then1 + assertThat(foundApplicantCount).isZero(); + } + + @DisplayName("Supporter 외래키와 ReviewStatus 가 NOT_STARTED 로 러너 게시글을 조회한다.") + @Test + void readRunnerPostsBySupporterIdAndReviewStatusIs_NOT_STARTED() { + // given + final Member savedMemberEthan = memberRepository.save(MemberFixture.createEthan()); + final Runner savedRunnerEthan = runnerRepository.save(RunnerFixture.createRunner(savedMemberEthan)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost runnerPost = RunnerPostFixture.create(savedRunnerEthan, deadline(now().plusHours(100))); + final RunnerPost savedRunnerPost = runnerPostRepository.save(runnerPost); + + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(runnerPost, savedSupporterHyena)); + + // when + final PageRequest pageable = PageRequest.of(0, 10); + final Page pageRunnerPosts + = runnerPostService.readRunnerPostsBySupporterIdAndReviewStatus(pageable, savedSupporterHyena.getId(), NOT_STARTED); + + // then + assertAll( + () -> assertThat(pageRunnerPosts.getPageable()).isEqualTo(pageable), + () -> assertThat(pageRunnerPosts.getContent()).containsExactly(savedRunnerPost) + ); + } + + @DisplayName("Member 가 RunnerPost 에 지원한 이력이 있을 경우 true 를 반환한다.") + @Test + void existsRunnerPostApplicantByRunnerPostIdAndMemberId() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPost, savedSupporterHyena)); + + // when + final boolean isApplicantHistoryExist = runnerPostService.existsRunnerPostApplicantByRunnerPostIdAndMemberId( + savedRunnerPost.getId(), + savedMemberHyena.getId() + ); + + // then + assertThat(isApplicantHistoryExist).isTrue(); + } + + @DisplayName("Member 가 RunnerPost 에 지원한 이력을 조회할 때 RunnerPost 자체가 없으면 false 를 반환한다.") + @Test + void existsRunnerPostApplicantByRunnerPostIdAndMemberId_if_runnerPost_is_not_exist_then_return_false() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + + // when + final Long notExistRunnerPostId = -1L; + final boolean isApplicantHistoryExist = runnerPostService.existsRunnerPostApplicantByRunnerPostIdAndMemberId( + notExistRunnerPostId, + savedMemberHyena.getId() + ); + + // then + assertThat(isApplicantHistoryExist).isFalse(); + } + + @DisplayName("Member 가 RunnerPost 에 지원한 이력이 없을 경우 false 를 반환한다.") + @Test + void existsRunnerPostApplicantByRunnerPostIdAndMemberId_if_member_is_not_exist_then_return_false() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final RunnerPost savedRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + + // when + final Long notExistMemberId = -1L; + final boolean isApplicantHistoryExist = runnerPostService.existsRunnerPostApplicantByRunnerPostIdAndMemberId( + savedRunnerPost.getId(), + notExistMemberId + ); + + // then + assertThat(isApplicantHistoryExist).isFalse(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceUpdateTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceUpdateTest.java new file mode 100644 index 000000000..736cf0f53 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostServiceUpdateTest.java @@ -0,0 +1,201 @@ +package touch.baton.domain.runnerpost.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.exception.RunnerPostBusinessException; +import touch.baton.domain.runnerpost.exception.RunnerPostDomainException; +import touch.baton.domain.runnerpost.service.dto.RunnerPostUpdateRequest; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.IN_PROGRESS; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.OVERDUE; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class RunnerPostServiceUpdateTest extends ServiceTestConfig { + + private static Runner runnerPostOwner; + private static RunnerPost targetRunnerPost; + private static Supporter applySupporter; + private static Runner runner; + private static Supporter assignedSupporter; + + private RunnerPostService runnerPostService; + + @BeforeEach + void setUp() { + runnerPostService = new RunnerPostService( + runnerPostRepository, + runnerPostTagRepository, + tagRepository, + supporterRepository, + supporterRunnerPostRepository + ); + + final Member ehtanMember = memberRepository.save(MemberFixture.createEthan()); + runnerPostOwner = runnerRepository.save(RunnerFixture.createRunner(ehtanMember)); + targetRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(runnerPostOwner, + deadline(LocalDateTime.now().plusDays(10)))); + + final Member hyenaMember = memberRepository.save(MemberFixture.createHyena()); + applySupporter = supporterRepository.save(SupporterFixture.create(hyenaMember)); + supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(targetRunnerPost, applySupporter)); + + final Member runnerMember = memberRepository.save(MemberFixture.createEthan()); + runner = runnerRepository.save(RunnerFixture.createRunner(runnerMember)); + + final Member supporterMember = memberRepository.save(MemberFixture.createDitoo()); + assignedSupporter = supporterRepository.save(SupporterFixture.create(supporterMember)); + } + + @DisplayName("러너는 자신의 글에 제안한 서포터를 서포터로 선택할 수 있다.") + @Test + void updateRunnerPostAppliedSupporter() { + // given + final RunnerPostUpdateRequest.SelectSupporter request = new RunnerPostUpdateRequest.SelectSupporter(applySupporter.getId()); + + // when + runnerPostService.updateRunnerPostAppliedSupporter(runnerPostOwner, targetRunnerPost.getId(), request); + + // then + final Optional maybeRunnerPost = runnerPostRepository.findById(targetRunnerPost.getId()); + assertThat(maybeRunnerPost).isPresent(); + + final RunnerPost actualRunnerPost = maybeRunnerPost.get(); + assertAll( + () -> assertThat(actualRunnerPost.getSupporter().getId()).isEqualTo(applySupporter.getId()), + () -> assertThat(actualRunnerPost.getReviewStatus()).isEqualTo(ReviewStatus.IN_PROGRESS) + ); + } + + @DisplayName("러너는 가입되어 있지 않는 서포터를 선택할 수 없다.") + @Test + void fail_updateRunnerPostAppliedSupporter_if_not_join_supporter() { + // given + final Long notJoinSupporterId = 1000000L; + final RunnerPostUpdateRequest.SelectSupporter request = new RunnerPostUpdateRequest.SelectSupporter(notJoinSupporterId); + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostAppliedSupporter(runnerPostOwner, targetRunnerPost.getId(), request)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("러너는 자신의 글에 제안한 서포터가 아니면 서포터로 선택할 수 없다.") + @Test + void fail_updateRunnerPostAppliedSupporter_if_not_apply_supporter() { + // given + final Member ditooMember = memberRepository.save(MemberFixture.createDitoo()); + final Supporter notApplySupporter = supporterRepository.save(SupporterFixture.create(ditooMember)); + + final RunnerPostUpdateRequest.SelectSupporter request = new RunnerPostUpdateRequest.SelectSupporter(notApplySupporter.getId()); + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostAppliedSupporter(runnerPostOwner, targetRunnerPost.getId(), request)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("러너는 작성된 글이 아니면 서포터를 선택할 수 없다.") + @Test + void fail_updateRunnerPostAppliedSupporter_if_is_not_written_runnerPost() { + // given + final RunnerPostUpdateRequest.SelectSupporter request = new RunnerPostUpdateRequest.SelectSupporter(applySupporter.getId()); + final Long notWrittenRunnerPostId = 1000000L; + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostAppliedSupporter(runnerPostOwner, notWrittenRunnerPostId, request)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("러너는 자신의 글이 아니면 서포터를 선택할 수 없다.") + @Test + void fail_updateRunnerPostAppliedSupporter_if_is_not_owner_of_runnerPost() { + // given + final Member ditooMember = memberRepository.save(MemberFixture.createDitoo()); + final Runner notOwnerRunner = runnerRepository.save(RunnerFixture.createRunner(ditooMember)); + + final RunnerPostUpdateRequest.SelectSupporter request = new RunnerPostUpdateRequest.SelectSupporter(applySupporter.getId()); + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostAppliedSupporter(notOwnerRunner, targetRunnerPost.getId(), request)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("리뷰가 완료되면 서포터는 게시글의 상태를 리뷰 완료로 변경할 수 있다.") + @Test + void updateRunnerPostReviewStatusDone() { + // given + final RunnerPost targetRunnerPost = runnerPostRepository.save(RunnerPostFixture.createWithReviewStatus(runner, assignedSupporter, IN_PROGRESS)); + + // when + runnerPostService.updateRunnerPostReviewStatusDone(targetRunnerPost.getId(), assignedSupporter); + + // then + final Optional maybeRunnerPost = runnerPostRepository.findById(targetRunnerPost.getId()); + assertThat(maybeRunnerPost).isPresent(); + final RunnerPost actualRunnerPost = maybeRunnerPost.get(); + assertThat(actualRunnerPost.getReviewStatus()).isEqualTo(ReviewStatus.DONE); + } + + @DisplayName("없는 게시글의 상태를 리뷰 완료로 변경할 수 없다.") + @Test + void fail_updateRunnerPostReviewStatusDone_if_invalid_runnerPostId() { + // given + runnerPostRepository.save(RunnerPostFixture.createWithReviewStatus(runner, assignedSupporter, IN_PROGRESS)); + final Long unsavedRunnerPostId = 100000L; + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostReviewStatusDone(unsavedRunnerPostId, assignedSupporter)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("서포터가 배정 되지 않은 게시글의 상태를 리뷰 완료로 변경할 수 없다.") + @Test + void fail_updateRunnerPostReviewStatusDone_if_supporter_is_null() { + // given + final RunnerPost targetRunnerPost = runnerPostRepository.save(RunnerPostFixture.createWithReviewStatus(runner, null, IN_PROGRESS)); + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostReviewStatusDone(targetRunnerPost.getId(), assignedSupporter)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("다른 서포터가 리뷰 중인 게시글의 상태를 리뷰 완료로 변경할 수 없다.") + @Test + void fail_updateRunnerPostReviewStatusDone_if_different_supporter_is_assigned() { + // given + final RunnerPost targetRunnerPost = runnerPostRepository.save(RunnerPostFixture.createWithReviewStatus(runner, assignedSupporter, IN_PROGRESS)); + final Member differentMember = memberRepository.save(MemberFixture.createHyena()); + final Supporter differentSupporter = supporterRepository.save(SupporterFixture.create(differentMember)); + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostReviewStatusDone(targetRunnerPost.getId(), differentSupporter)) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("만료된 리뷰 게시글의 상태를 리뷰 완료로 변경할 수 없다.") + @Test + void fail_updateRunnerPostReviewStatusDone_if_reviewStatus_is_overdue() { + // given + final RunnerPost targetRunnerPost = runnerPostRepository.save(RunnerPostFixture.createWithReviewStatus(runner, assignedSupporter, OVERDUE)); + + // when, then + assertThatThrownBy(() -> runnerPostService.updateRunnerPostReviewStatusDone(targetRunnerPost.getId(), assignedSupporter)) + .isInstanceOf(RunnerPostDomainException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostUpdateApplicantCancelationServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostUpdateApplicantCancelationServiceTest.java new file mode 100644 index 000000000..d0670f24b --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/service/RunnerPostUpdateApplicantCancelationServiceTest.java @@ -0,0 +1,102 @@ +package touch.baton.domain.runnerpost.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.exception.RunnerPostBusinessException; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class RunnerPostUpdateApplicantCancelationServiceTest extends ServiceTestConfig { + + private RunnerPostService runnerPostService; + + private Supporter applicantSupporter; + private Runner revieweeRunner; + + @BeforeEach + void setUp() { + runnerPostService = new RunnerPostService(runnerPostRepository, runnerPostTagRepository, tagRepository, supporterRepository, supporterRunnerPostRepository); + + final Member applicantMember = memberRepository.save(MemberFixture.createDitoo()); + applicantSupporter = supporterRepository.save(SupporterFixture.create(applicantMember)); + + final Member revieweeMember = memberRepository.save(MemberFixture.createJudy()); + revieweeRunner = runnerRepository.save(RunnerFixture.createRunner(revieweeMember)); + } + + @DisplayName("성공한다.") + @Test + void success() { + // given + final RunnerPost runnerPost = runnerPostRepository.save( + RunnerPostFixture.create( + revieweeRunner, + applicantSupporter, + new Deadline(LocalDateTime.now().plusHours(100)) + )); + final SupporterRunnerPost supporterRunnerPost = SupporterRunnerPostFixture.create(runnerPost, applicantSupporter); + supporterRunnerPostRepository.save(supporterRunnerPost); + + // when + runnerPostService.deleteSupporterRunnerPost(applicantSupporter, runnerPost.getId()); + + // then + assertThat(supporterRunnerPostRepository.findById(supporterRunnerPost.getId())).isNotPresent(); + } + + @DisplayName("RunnerPost 가 존재하지 않으면 실패한다.") + @Test + void fail_when_runnerPost_not_found() { + // given + final RunnerPost runnerPost = runnerPostRepository.save( + RunnerPostFixture.create( + revieweeRunner, + applicantSupporter, + new Deadline(LocalDateTime.now().plusHours(100)) + )); + final SupporterRunnerPost supporterRunnerPost = SupporterRunnerPostFixture.create(runnerPost, applicantSupporter); + supporterRunnerPostRepository.save(supporterRunnerPost); + runnerPostRepository.delete(runnerPost); + + // when & then + assertThatThrownBy(() -> runnerPostService.deleteSupporterRunnerPost(applicantSupporter, runnerPost.getId())) + .isInstanceOf(RunnerPostBusinessException.class); + } + + @DisplayName("RunnerPost 의 리뷰 상태가 대기중이 아니면 실패한다.") + @Test + void fail_when_runnerPost_reviewStatus_is_not_NOT_STARTED() { + // given + final RunnerPost runnerPost = runnerPostRepository.save( + RunnerPostFixture.create( + revieweeRunner, + applicantSupporter, + new Deadline(LocalDateTime.now().plusHours(100)), + ReviewStatus.IN_PROGRESS + )); + final SupporterRunnerPost supporterRunnerPost = SupporterRunnerPostFixture.create(runnerPost, applicantSupporter); + supporterRunnerPostRepository.save(supporterRunnerPost); + + + // when & then + assertThatThrownBy(() -> runnerPostService.deleteSupporterRunnerPost(applicantSupporter, runnerPost.getId())) + .isInstanceOf(RunnerPostBusinessException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/DeadlineTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/DeadlineTest.java new file mode 100644 index 000000000..644b60bbb --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/DeadlineTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.runnerpost.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DeadlineTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new Deadline(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/PullRequestUrlTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/PullRequestUrlTest.java new file mode 100644 index 000000000..86f583eff --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/vo/PullRequestUrlTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.runnerpost.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PullRequestUrlTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new PullRequestUrl(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/supporter/SupporterFeedbackTest.java b/backend/baton/src/test/java/touch/baton/domain/supporter/SupporterFeedbackTest.java new file mode 100644 index 000000000..e2d65347c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/supporter/SupporterFeedbackTest.java @@ -0,0 +1,150 @@ +package touch.baton.domain.supporter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.feedback.exception.SupporterFeedbackException; +import touch.baton.domain.feedback.vo.Description; +import touch.baton.domain.feedback.vo.ReviewType; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.RunnerPostTagsFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static touch.baton.domain.feedback.SupporterFeedback.builder; +import static touch.baton.domain.runnerpost.vo.ReviewStatus.NOT_STARTED; +import static touch.baton.fixture.vo.ContentsFixture.contents; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; +import static touch.baton.fixture.vo.PullRequestUrlFixture.pullRequestUrl; +import static touch.baton.fixture.vo.TitleFixture.title; +import static touch.baton.fixture.vo.WatchedCountFixture.watchedCount; + +class SupporterFeedbackTest { + + @DisplayName("생성 테스트") + @Nested + class Create { + + private Supporter supporter; + private Runner runner; + private RunnerPost runnerPost; + + @BeforeEach + void setUp() { + supporter = SupporterFixture.create(new ReviewCount(0), + MemberFixture.createEthan(), + new ArrayList<>()); + + runner = RunnerFixture.createRunner(MemberFixture.createDitoo()); + + runnerPost = RunnerPostFixture.create(title("제 코드를 리뷰해주세요"), + contents("제 코드의 내용은 이렇습니다."), + pullRequestUrl("https://"), + deadline(LocalDateTime.now().plusHours(10)), + watchedCount(0), + NOT_STARTED, + runner, + supporter, + RunnerPostTagsFixture.runnerPostTags(new ArrayList<>())); + } + + @DisplayName("성공한다.") + @Test + void success() { + // when, then + assertThatCode(() -> builder() + .reviewType(ReviewType.GOOD) + .description(new Description("무난무난")) + .supporter(supporter) + .runner(runner) + .runnerPost(runnerPost) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("reviewType 이 null 이면 실패한다.") + @Test + void fail_if_reviewType_is_null() { + // when, then + assertThatThrownBy(() -> builder() + .reviewType(null) + .description(new Description("무난무난")) + .supporter(supporter) + .runner(runner) + .runnerPost(runnerPost) + .build() + ).isInstanceOf(SupporterFeedbackException.class) + .hasMessage("SupporterFeedback 의 reviewType 은 null 일 수 없습니다."); + } + + @DisplayName("description 이 null 이면 실패한다.") + @Test + void fail_if_description_is_null() { + // when, then + assertThatThrownBy(() -> builder() + .reviewType(ReviewType.GOOD) + .description(null) + .supporter(supporter) + .runner(runner) + .runnerPost(runnerPost) + .build() + ).isInstanceOf(SupporterFeedbackException.class) + .hasMessage("SupporterFeedback 의 description 은 null 일 수 없습니다."); + } + + @DisplayName("supporter 가 null 이면 실패한다.") + @Test + void fail_if_supporter_is_null() { + // when, then + assertThatThrownBy(() -> builder() + .reviewType(ReviewType.GOOD) + .description(new Description("무난무난")) + .supporter(null) + .runner(runner) + .runnerPost(runnerPost) + .build() + ).isInstanceOf(SupporterFeedbackException.class) + .hasMessage("SupporterFeedback 의 supporter 는 null 일 수 없습니다."); + } + + @DisplayName("runner 가 null 이면 실패한다.") + @Test + void fail_if_runner_is_null() { + // when, then + assertThatThrownBy(() -> builder() + .reviewType(ReviewType.GOOD) + .description(new Description("무난무난")) + .supporter(supporter) + .runner(null) + .runnerPost(runnerPost) + .build() + ).isInstanceOf(SupporterFeedbackException.class) + .hasMessage("SupporterFeedback 의 runner 는 null 일 수 없습니다."); + } + + @DisplayName("runnerPost 가 null 이면 실패한다.") + @Test + void fail_if_runnerPost_is_null() { + // when, then + assertThatThrownBy(() -> builder() + .reviewType(ReviewType.GOOD) + .description(new Description("무난무난")) + .supporter(supporter) + .runner(runner) + .runnerPost(null) + .build() + ).isInstanceOf(SupporterFeedbackException.class) + .hasMessage("SupporterFeedback 의 runnerPost 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/supporter/SupporterTest.java b/backend/baton/src/test/java/touch/baton/domain/supporter/SupporterTest.java new file mode 100644 index 000000000..47590375c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/supporter/SupporterTest.java @@ -0,0 +1,157 @@ +package touch.baton.domain.supporter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.supporter.exception.SupporterDomainException; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.technicaltag.SupporterTechnicalTag; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterTechnicalTagFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class SupporterTest { + + private final Member member = Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("imageUrl")) + .build(); + + @DisplayName("생성 테스트") + @Nested + class Create { + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> Supporter.builder() + .reviewCount(new ReviewCount(10)) + .member(member) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("member 가 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_member_is_null() { + assertThatThrownBy(() -> Supporter.builder() + .reviewCount(new ReviewCount(10)) + .member(null) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build() + ).isInstanceOf(SupporterDomainException.class) + .hasMessage("Supporter 의 member 는 null 일 수 없습니다."); + } + + @DisplayName("supporterTechnicalTags 가 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_supporterTechnicalTags_is_null() { + assertThatThrownBy(() -> Supporter.builder() + .reviewCount(new ReviewCount(10)) + .member(member) + .supporterTechnicalTags(null) + .build() + ).isInstanceOf(SupporterDomainException.class) + .hasMessage("Supporter 의 supporterTechnicalTags 는 null 일 수 없습니다."); + } + } + + @DisplayName("supporter 의 technicalTags 를 조회한다.") + @Test + void read_supporterTechnicalTags() { + // given + final Supporter supporter = Supporter.builder() + .reviewCount(new ReviewCount(10)) + .member(member) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build(); + + final TechnicalTag technicalTag = TechnicalTagFixture.createJava(); + final SupporterTechnicalTag supporterTechnicalTag = SupporterTechnicalTagFixture.create(supporter, technicalTag); + supporter.addAllSupporterTechnicalTags(List.of(supporterTechnicalTag)); + + // when + final List actual = supporter.getSupporterTechnicalTags().getSupporterTechnicalTags(); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0)).isEqualTo(supporterTechnicalTag); + }); + } + + @DisplayName("수정 테스트") + @Nested + class Update { + + private Supporter supporter; + + @BeforeEach + void setUp() { + supporter = SupporterFixture.create(member); + } + + @DisplayName("이름 수정에 성공한다.") + @Test + void name_success() { + // given + final MemberName UpdatedName = new MemberName("디투"); + + // when + supporter.updateMemberName(UpdatedName); + + // then + assertThat(supporter.getMember().getMemberName()).isEqualTo(UpdatedName); + } + + @DisplayName("소속 수정에 성공한다.") + @Test + void company_success() { + // given + final Company updatedCompany = new Company("넥슨"); + + // when + supporter.updateCompany(updatedCompany); + + // then + assertThat(supporter.getMember().getCompany()).isEqualTo(updatedCompany); + } + + @DisplayName("소개글 수정에 성공한다.") + @Test + void introduction_success() { + // given + final Introduction updatedIntroduction = new Introduction("디투"); + + // when + supporter.updateIntroduction(updatedIntroduction); + + // then + assertThat(supporter.getIntroduction()).isEqualTo(updatedIntroduction); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRepositoryTest.java new file mode 100644 index 000000000..7f1d1c9ce --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRepositoryTest.java @@ -0,0 +1,47 @@ +package touch.baton.domain.supporter.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class SupporterRepositoryTest extends RepositoryTestConfig { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private SupporterRepository supporterRepository; + + @DisplayName("Supporter 식별자값으로 Member 를 패치 조인하여 조회한다.") + @Test + void joinMemberBySupporterId() { + // given + final Member savedMember = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporter = supporterRepository.save(SupporterFixture.create(savedMember)); + + // when + final Optional maybeSupporter = supporterRepository.joinMemberBySupporterId(savedSupporter.getId()); + + // then + assertAll( + () -> assertThat(maybeSupporter).isPresent(), + () -> assertThat(maybeSupporter.get().getId()).isEqualTo(savedSupporter.getId()), + () -> assertThat(maybeSupporter.get().getIntroduction()).isEqualTo(savedSupporter.getIntroduction()), + () -> assertThat(maybeSupporter.get().getReviewCount()).isEqualTo(savedSupporter.getReviewCount()), + () -> assertThat(maybeSupporter.get().getSupporterTechnicalTags()).isEqualTo(savedSupporter.getSupporterTechnicalTags()), + () -> assertThat(maybeSupporter.get().getMember()).isEqualTo(savedMember) + ); + + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepositoryTest.java new file mode 100644 index 000000000..7dca5324d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/supporter/repository/SupporterRunnerPostRepositoryTest.java @@ -0,0 +1,209 @@ +package touch.baton.domain.supporter.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.repository.RunnerPostRepository; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.domain.supporter.vo.Message; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterRunnerPostFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static java.time.LocalDateTime.now; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +class SupporterRunnerPostRepositoryTest extends RepositoryTestConfig { + + @Autowired + private SupporterRunnerPostRepository supporterRunnerPostRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private SupporterRepository supporterRepository; + + @Autowired + private RunnerPostRepository runnerPostRepository; + + @DisplayName("러너 게시글 식별자값으로 서포터가 지원한 수를 count 한다.") + @Test + void countByRunnerPostIdIn() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createDitoo()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPostOne = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + final RunnerPost savedRunnerPostTwo = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + final RunnerPost savedRunnerPostThree = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + final RunnerPost savedRunnerPostFour = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + + savedRunnerPostOne.assignSupporter(savedSupporterHyena); + savedRunnerPostTwo.assignSupporter(savedSupporterHyena); + savedRunnerPostThree.assignSupporter(savedSupporterHyena); + savedRunnerPostFour.assignSupporter(savedSupporterHyena); + + supporterRunnerPostRepository.save(createSupporterRunnerPost(savedSupporterHyena, savedRunnerPostOne)); + supporterRunnerPostRepository.save(createSupporterRunnerPost(savedSupporterHyena, savedRunnerPostTwo)); + supporterRunnerPostRepository.save(createSupporterRunnerPost(savedSupporterHyena, savedRunnerPostThree)); + supporterRunnerPostRepository.save(createSupporterRunnerPost(savedSupporterHyena, savedRunnerPostFour)); + + // when + final List runnerPostIds = List.of( + savedRunnerPostOne.getId(), + savedRunnerPostTwo.getId(), + savedRunnerPostThree.getId(), + savedRunnerPostFour.getId() + ); + final List foundRunnerPostsApplicantCounts = supporterRunnerPostRepository.countByRunnerPostIdIn(runnerPostIds); + + // then + assertThat(foundRunnerPostsApplicantCounts).containsExactly(1L, 1L, 1L, 1L); + } + + private SupporterRunnerPost createSupporterRunnerPost(final Supporter supporter, final RunnerPost runnerPost) { + return SupporterRunnerPost.builder() + .runnerPost(runnerPost) + .supporter(supporter) + .message(new Message("안녕하세요. 서포터 헤나입니다.")) + .build(); + } + + @DisplayName("Member 가 SupporterRunnerPost 에 지원한 이력이 있을 경우 true 를 반환한다.") + @Test + void existsByRunnerPostIdAndMemberId_return_true() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + + final SupporterRunnerPost runnerPostApplicant = createSupporterRunnerPost(savedSupporterHyena, savedRunnerPost); + supporterRunnerPostRepository.save(runnerPostApplicant); + + // when + final Long notExistMemberId = -1L; + final boolean isApplicantHistoryExist = supporterRunnerPostRepository.existsByRunnerPostIdAndMemberId( + savedRunnerPost.getId(), + savedMemberHyena.getId() + ); + + // then + assertThat(isApplicantHistoryExist).isTrue(); + } + + @DisplayName("Member 가 SupporterRunnerPost 에 지원한 이력이 없을 경우 false 를 반환한다.") + @Test + void existsByRunnerPostIdAndMemberId_if_supporterRunnerPost_is_not_exist_then_return_false() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createHyena()); + + final RunnerPost savedRunnerPost = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + + // when + final boolean isApplicantHistoryNotExist = supporterRunnerPostRepository.existsByRunnerPostIdAndMemberId( + savedRunnerPost.getId(), + savedMemberHyena.getId() + ); + + // then + assertThat(isApplicantHistoryNotExist).isFalse(); + } + + @DisplayName("Member 가 SupporterRunnerPost 에 지원한 이력을 조회할 때 RunnerPost 자체가 없으면 false 를 반환한다.") + @Test + void existsByRunnerPostIdAndMemberId_if_runnerPost_is_not_exist_then_return_false() { + // given + final Member savedMemberHyena = memberRepository.save(MemberFixture.createDitoo()); + + // when + final Long notExistRunnerPostId = -1L; + final boolean isApplicantHistoryNotExist = supporterRunnerPostRepository.existsByRunnerPostIdAndMemberId( + notExistRunnerPostId, + savedMemberHyena.getId() + ); + + // then + assertThat(isApplicantHistoryNotExist).isFalse(); + } + + @DisplayName("RunnerPost 외래키로 된 SupporterRunnerPost 가 존재하는지 확인한다.") + @Test + void existsByRunnerPostId() { + // given + final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberRepository.save(MemberFixture.createDitoo()); + final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost runnerPostOfApplicantExist = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + final RunnerPost runnerPostOfApplicantNotExist = runnerPostRepository.save(RunnerPostFixture.create(savedRunnerDitoo, new Deadline(now().plusHours(100)))); + runnerPostOfApplicantExist.assignSupporter(savedSupporterHyena); + supporterRunnerPostRepository.save(createSupporterRunnerPost(savedSupporterHyena, runnerPostOfApplicantExist)); + + // when + final boolean actualOfExist = supporterRunnerPostRepository.existsByRunnerPostId(runnerPostOfApplicantExist.getId()); + final boolean actualOfNotExist = supporterRunnerPostRepository.existsByRunnerPostId(runnerPostOfApplicantNotExist.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actualOfExist).isTrue(); + softly.assertThat(actualOfNotExist).isFalse(); + }); + } + + @DisplayName("서포터의 러너 게시글 리뷰 제안을 철회하는데 성공한다") + @Test + void deleteBySupporterAndRunnerPostId() { + // given + final Member reviewerMember = memberRepository.save(MemberFixture.createDitoo()); + final Supporter reviewerSupporter = supporterRepository.save(SupporterFixture.create(reviewerMember)); + + final Member revieweeMember = memberRepository.save(MemberFixture.createJudy()); + final Runner revieweeRunner = runnerRepository.save(RunnerFixture.createRunner(revieweeMember)); + + final RunnerPost runnerPost = runnerPostRepository.save(RunnerPostFixture.create( + revieweeRunner, + reviewerSupporter, + deadline(LocalDateTime.now().plusHours(100)) + )); + + final SupporterRunnerPost deletedSupporterRunnerPost = supporterRunnerPostRepository.save( + SupporterRunnerPostFixture.create(runnerPost, reviewerSupporter)); + + // when + supporterRunnerPostRepository.deleteBySupporterIdAndRunnerPostId(reviewerSupporter.getId(), runnerPost.getId()); + + // then + assertThat(supporterRunnerPostRepository.findById(deletedSupporterRunnerPost.getId())).isNotPresent(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterServiceTest.java new file mode 100644 index 000000000..0c22dbed7 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterServiceTest.java @@ -0,0 +1,61 @@ +package touch.baton.domain.supporter.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.service.dto.SupporterUpdateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + + +class SupporterServiceTest extends ServiceTestConfig { + + private SupporterService supporterService; + + @BeforeEach + void setUp() { + supporterService = new SupporterService(supporterRepository, technicalTagRepository, supporterTechnicalTagRepository); + } + + @DisplayName("Supporter 식별자 값으로 Member 패치 조인하여 Supporter 를 조회한다.") + @Test + void readBySupporterId() { + // given + final Member savedMember = memberRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporter = supporterRepository.save(SupporterFixture.create(savedMember)); + + // when + final Supporter foundSupporter = supporterService.readBySupporterId(savedSupporter.getId()); + + // then + assertAll( + () -> assertThat(foundSupporter.getId()).isEqualTo(savedSupporter.getId()), + () -> assertThat(foundSupporter.getIntroduction()).isEqualTo(savedSupporter.getIntroduction()), + () -> assertThat(foundSupporter.getReviewCount()).isEqualTo(savedSupporter.getReviewCount()), + () -> assertThat(foundSupporter.getSupporterTechnicalTags()).isEqualTo(savedSupporter.getSupporterTechnicalTags()), + () -> assertThat(foundSupporter.getMember()).isEqualTo(savedMember) + ); + } + + @DisplayName("Supporter 정보를 수정한다.") + @Test + void updateSupporter() { + // given + final Member savedMember = memberRepository.save(MemberFixture.createDitoo()); + final Supporter savedSupporter = supporterRepository.save(SupporterFixture.create(savedMember)); + final SupporterUpdateRequest request = new SupporterUpdateRequest("디투랜드", "두나무", "소개글입니다.", List.of("golang", "rust")); + + // when & then + assertThatCode(() -> supporterService.updateSupporter(savedSupporter, request)) + .doesNotThrowAnyException(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagTest.java b/backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagTest.java new file mode 100644 index 000000000..7e61f7351 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagTest.java @@ -0,0 +1,117 @@ +package touch.baton.domain.tag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.tag.exception.RunnerPostTagDomainException; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RunnerPostTagTest { + + @DisplayName("생성 테스트") + @Nested + class Create { + + private final Member runnerMember = Member.builder() + .memberName(new MemberName("러너 사용자")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("ads7821iuqjkrhadsioh1f1r4efsoi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한테크코스")) + .imageUrl(new ImageUrl("imageUrl")) + .build(); + + private final Member supporterMember = Member.builder() + .memberName(new MemberName("서포터 사용자")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/pobi")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("imageUrl")) + .build(); + + private final Runner runner = Runner.builder() + .member(runnerMember) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + + private final Supporter supporter = Supporter.builder() + .reviewCount(new ReviewCount(10)) + .member(supporterMember) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build(); + + private final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("JPA 정복")) + .contents(new Contents("김영한 짱짱맨")) + .pullRequestUrl(new PullRequestUrl("https://github.com/woowacourse-teams/2023-baton/pull/17")) + .deadline(new Deadline(LocalDateTime.now())) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(supporter) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + private final Tag tag = Tag.builder() + .tagName(new TagName("자바")) + .build(); + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> RunnerPostTag.builder() + .runnerPost(runnerPost) + .tag(tag) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("runner post 가 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_runnerPost_is_null() { + assertThatThrownBy(() -> RunnerPostTag.builder() + .runnerPost(null) + .tag(tag) + .build() + ).isInstanceOf(RunnerPostTagDomainException.class) + .hasMessage("RunnerPostTag 의 runnerPost 는 null 일 수 없습니다."); + } + + @DisplayName("tag 가 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_tag_is_null() { + assertThatThrownBy(() -> RunnerPostTag.builder() + .runnerPost(runnerPost) + .tag(null) + .build() + ).isInstanceOf(RunnerPostTagDomainException.class) + .hasMessage("RunnerPostTag 의 tag 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagsTest.java b/backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagsTest.java new file mode 100644 index 000000000..33a9bc852 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/tag/RunnerPostTagsTest.java @@ -0,0 +1,54 @@ +package touch.baton.domain.tag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RunnerPostTagsTest { + + @DisplayName("RunnerPostTags 에 runnerPostTag 를 추가할 수 있다.") + @Test + void addAllRunnerPostTags() { + // given + RunnerPostTags postTags = new RunnerPostTags(); + Member member = Member.builder() + .memberName(new MemberName("러너 사용자")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("ads7821iuqjkrhadsioh1f1r4efsoi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한테크코스")) + .imageUrl(new ImageUrl("김석호")) + .build(); + Runner runner = Runner.builder() + .member(member) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + final RunnerPost runnerpost = RunnerPost.newInstance("리뷰해주세요.", "제발요.", "https://github.com/cookienc", LocalDateTime.of(2099, 12, 12, 0, 0), runner); + + final RunnerPostTag runnerPostTag = RunnerPostTag.builder() + .runnerPost(runnerpost) + .tag(Tag.newInstance("Java")) + .build(); + + // when + postTags.addAll(List.of(runnerPostTag)); + + // then + assertThat(postTags.getRunnerPostTags()).hasSize(1); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/tag/TagTest.java b/backend/baton/src/test/java/touch/baton/domain/tag/TagTest.java new file mode 100644 index 000000000..609b43c71 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/tag/TagTest.java @@ -0,0 +1,37 @@ +package touch.baton.domain.tag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.tag.exception.TagDomainException; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TagTest { + + @DisplayName("생성 테스트") + @Nested + class Create { + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> Tag.builder() + .tagName(new TagName("자바")) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("tag name 이 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_tagName_is_null() { + assertThatThrownBy(() -> Tag.builder() + .tagName(null) + .build() + ).isInstanceOf(TagDomainException.class) + .hasMessage("Tag 의 tagName 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/tag/repository/RunnerPostTagRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/tag/repository/RunnerPostTagRepositoryTest.java new file mode 100644 index 000000000..496eb7bd9 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/tag/repository/RunnerPostTagRepositoryTest.java @@ -0,0 +1,149 @@ +package touch.baton.domain.tag.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.member.Member; +import touch.baton.domain.member.repository.MemberRepository; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runner.repository.RunnerRepository; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.repository.RunnerPostRepository; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.RunnerPostTags; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.domain.RunnerTechnicalTagsFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RunnerPostTagRepositoryTest extends RepositoryTestConfig { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RunnerRepository runnerRepository; + + @Autowired + private RunnerPostRepository runnerPostRepository; + + @Autowired + private RunnerPostTagRepository runnerPostTagRepository; + + @Autowired + private TagRepository tagRepository; + + @DisplayName("RunnerPostTag 의 식별자값 목록으로 Tag 목록을 조회한다.") + @Test + void success_joinTagByRunnerPostIds() { + // given + final Member member = Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("김석호")) + .build(); + final Member saveMember = memberRepository.saveAndFlush(member); + + final Runner runner = Runner.builder() + .member(saveMember) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + final Runner saveRunner = runnerRepository.saveAndFlush(runner); + + final LocalDateTime deadline = LocalDateTime.now(); + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("제 코드 리뷰 좀 해주세요!!")) + .contents(new Contents("제 코드는 클린코드가 맞을까요?")) + .deadline(new Deadline(deadline)) + .pullRequestUrl(new PullRequestUrl("https://")) + .watchedCount(new WatchedCount(1)) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(saveRunner) + .supporter(null) + .build(); + runnerPostRepository.saveAndFlush(runnerPost); + + final Tag tag = Tag.builder() + .tagName(new TagName("자바")) + .build(); + tagRepository.save(tag); + + final RunnerPostTag runnerPostTag = RunnerPostTag.builder() + .runnerPost(runnerPost) + .tag(tag) + .build(); + runnerPostTagRepository.save(runnerPostTag); + + // when + final List joinRunnerPostTags + = runnerPostTagRepository.joinTagByRunnerPostId(runnerPost.getId()); + + // then + assertThat(joinRunnerPostTags).containsExactly(runnerPostTag); + } + + @DisplayName("RunnerPostTag 의 식별자값 목록이 비어있을 때 빈 컬렉션을 반환한다.") + @Test + void success_joinTagByRunnerPostIds_if_tag_is_empty() { + // given + final Member member = Member.builder() + .memberName(new MemberName("헤에디주")) + .socialId(new SocialId("testSocialId")) + .oauthId(new OauthId("dsigjh98gh230gn2oinv913bcuo23nqovbvu93b12voi3bc31j")) + .githubUrl(new GithubUrl("github.com/hyena0608")) + .company(new Company("우아한형제들")) + .imageUrl(new ImageUrl("김석호")) + .build(); + final Member saveMember = memberRepository.saveAndFlush(member); + + final Runner runner = Runner.builder() + .member(saveMember) + .runnerTechnicalTags(RunnerTechnicalTagsFixture.create(new ArrayList<>())) + .build(); + + final Runner saveRunner = runnerRepository.saveAndFlush(runner); + + final LocalDateTime deadline = LocalDateTime.now(); + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("제 코드 리뷰 좀 해주세요!!")) + .contents(new Contents("제 코드는 클린코드가 맞을까요?")) + .deadline(new Deadline(deadline)) + .pullRequestUrl(new PullRequestUrl("https://")) + .watchedCount(new WatchedCount(1)) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(saveRunner) + .supporter(null) + .build(); + runnerPostRepository.saveAndFlush(runnerPost); + + // when + final List joinRunnerPostTags + = runnerPostTagRepository.joinTagByRunnerPostId(runnerPost.getId()); + + // then + assertThat(joinRunnerPostTags).isEmpty(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/tag/repository/TagRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/tag/repository/TagRepositoryTest.java new file mode 100644 index 000000000..fd19a9db1 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/tag/repository/TagRepositoryTest.java @@ -0,0 +1,35 @@ +package touch.baton.domain.tag.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.tag.Tag; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class TagRepositoryTest extends RepositoryTestConfig { + + @Autowired + private TagRepository tagRepository; + + @DisplayName("이름으로 단건 검색한다.") + @Test + void findByName() { + // given + final String newTagName = "Java"; + final Tag newTag = Tag.builder() + .tagName(new TagName(newTagName)) + .build(); + final Tag expected = tagRepository.save(newTag); + + // when + final Optional actual = tagRepository.findByTagName(new TagName(newTagName)); + + // then + assertThat(actual).contains(expected); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/tag/vo/TagNameTest.java b/backend/baton/src/test/java/touch/baton/domain/tag/vo/TagNameTest.java new file mode 100644 index 000000000..59f13042e --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/tag/vo/TagNameTest.java @@ -0,0 +1,17 @@ +package touch.baton.domain.tag.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.domain.common.vo.TagName; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TagNameTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new TagName(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagTest.java b/backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagTest.java new file mode 100644 index 000000000..933e8b5f1 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagTest.java @@ -0,0 +1,66 @@ +package touch.baton.domain.technicaltag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.tag.exception.SupporterTechnicalTagDomainException; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SupporterTechnicalTagTest { + + @DisplayName("생성 테스트") + @Nested + class Create { + + private final Member member = MemberFixture.createHyena(); + + private final TechnicalTag technicalTag = TechnicalTagFixture.createJava(); + + private final Supporter supporter = SupporterFixture.create( + new ReviewCount(0), + member, + new ArrayList<>()); + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> SupporterTechnicalTag.builder() + .supporter(supporter) + .technicalTag(technicalTag) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("supporter 가 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_supporter_is_null() { + assertThatThrownBy(() -> SupporterTechnicalTag.builder() + .supporter(null) + .technicalTag(technicalTag) + .build() + ).isInstanceOf(SupporterTechnicalTagDomainException.class) + .hasMessage("SupporterTechnicalTag 의 supporter 는 null 일 수 없습니다."); + } + + @DisplayName("technical tag 가 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_technical_tag_is_null() { + assertThatThrownBy(() -> SupporterTechnicalTag.builder() + .supporter(supporter) + .technicalTag(null) + .build() + ).isInstanceOf(SupporterTechnicalTagDomainException.class) + .hasMessage("SupporterTechnicalTag 의 technicalTag 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagsTest.java b/backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagsTest.java new file mode 100644 index 000000000..cedbf2dc4 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/technicaltag/SupporterTechnicalTagsTest.java @@ -0,0 +1,42 @@ +package touch.baton.domain.technicaltag; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterTechnicalTagFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SupporterTechnicalTagsTest { + + private Supporter supporter; + private SupporterTechnicalTag supporterTechnicalTag; + + @BeforeEach + void setUp() { + final Member member = MemberFixture.createDitoo(); + supporter = SupporterFixture.create(member); + final TechnicalTag technicalTag = TechnicalTagFixture.createJava(); + supporterTechnicalTag = SupporterTechnicalTagFixture.create(supporter, technicalTag); + } + + @DisplayName("다중 덧셈 테스트") + @Test + void addAll() { + // given + final SupporterTechnicalTags supporterTechnicalTags = new SupporterTechnicalTags(); + + // when + supporterTechnicalTags.addAll(List.of(supporterTechnicalTag)); + + // then + assertThat(supporterTechnicalTags.getSupporterTechnicalTags()).containsExactly(supporterTechnicalTag); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/technicaltag/TechnicalTagTest.java b/backend/baton/src/test/java/touch/baton/domain/technicaltag/TechnicalTagTest.java new file mode 100644 index 000000000..e7f77a11f --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/technicaltag/TechnicalTagTest.java @@ -0,0 +1,31 @@ +package touch.baton.domain.technicaltag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.tag.exception.TechnicalTagDomainException; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TechnicalTagTest { + + @DisplayName("성공한다.") + @Test + void success() { + assertThatCode(() -> TechnicalTag.builder() + .tagName(new TagName("자바")) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("tag name 이 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_tagName_is_null() { + assertThatThrownBy(() -> TechnicalTag.builder() + .tagName(null) + .build() + ).isInstanceOf(TechnicalTagDomainException.class) + .hasMessage("TechnicalTag 의 tagName 은 null 일 수 없습니다."); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepositoryTest.java new file mode 100644 index 000000000..d76612dbb --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/SupporterTechnicalTagRepositoryTest.java @@ -0,0 +1,57 @@ +package touch.baton.domain.technicaltag.repository; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.technicaltag.SupporterTechnicalTag; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.SupporterFixture; +import touch.baton.fixture.domain.SupporterTechnicalTagFixture; +import touch.baton.fixture.domain.TechnicalTagFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SupporterTechnicalTagRepositoryTest extends RepositoryTestConfig { + + @Autowired + private SupporterTechnicalTagRepository supporterTechnicalTagRepository; + + @Autowired + private EntityManager entityManager; + + @DisplayName("batch 로 supporter 의 모든 SupporterTechnicalTag 를 삭제한다.") + @Test + void deleteBySupporter() { + // given + final Member member = MemberFixture.createDitoo(); + entityManager.persist(member); + final Supporter supporter = SupporterFixture.create(member); + entityManager.persist(supporter); + final TechnicalTag technicalTag1 = TechnicalTagFixture.createReact(); + final TechnicalTag technicalTag2 = TechnicalTagFixture.createSpring(); + final TechnicalTag technicalTag3 = TechnicalTagFixture.createJava(); + entityManager.persist(technicalTag1); + entityManager.persist(technicalTag2); + entityManager.persist(technicalTag3); + final SupporterTechnicalTag supporterTechnicalTag1 = SupporterTechnicalTagFixture.create(supporter, technicalTag1); + final SupporterTechnicalTag supporterTechnicalTag2 = SupporterTechnicalTagFixture.create(supporter, technicalTag2); + final SupporterTechnicalTag supporterTechnicalTag3 = SupporterTechnicalTagFixture.create(supporter, technicalTag3); + final List savedSupporterTechnicalTags = List.of(supporterTechnicalTag1, supporterTechnicalTag2, supporterTechnicalTag3); + supporterTechnicalTagRepository.saveAll(savedSupporterTechnicalTags); + entityManager.flush(); + + // when + final int expected = savedSupporterTechnicalTags.size(); + final int actual = supporterTechnicalTagRepository.deleteBySupporter(supporter); + + // then + assertThat(expected).isEqualTo(actual); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepositoryTest.java new file mode 100644 index 000000000..605c2f274 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/technicaltag/repository/TechnicalTagRepositoryTest.java @@ -0,0 +1,52 @@ +package touch.baton.domain.technicaltag.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.domain.TechnicalTagFixture; +import touch.baton.fixture.vo.TagNameFixture; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class TechnicalTagRepositoryTest extends RepositoryTestConfig { + + @Autowired + private TechnicalTagRepository technicalTagRepository; + + @DisplayName("TagName 으로 TechnicalTag 를 검색할 때 TechnicalTag 가 존재하면 검색된다.") + @Test + void findByName_ifPresent() { + // given + final TagName tagName = TagNameFixture.tagName("java"); + final TechnicalTag expected = technicalTagRepository.save(TechnicalTagFixture.create(tagName)); + technicalTagRepository.flush(); + + // when + final Optional actual = technicalTagRepository.findByTagName(tagName); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).isPresent(); + softly.assertThat(expected).isEqualTo(actual.get()); + }); + } + + @DisplayName("TagName 으로 TechnicalTag 를 검색할 때 TechnicalTag 가 존재하지 않으면 검색되지 않는다.") + @Test + void findByName_ifNotPresent() { + // given + final TagName tagName = new TagName("java"); + + // when + final Optional actual = technicalTagRepository.findByTagName(tagName); + + // then + assertThat(actual).isNotPresent(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/MemberFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/MemberFixture.java new file mode 100644 index 000000000..c5c8bb076 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/MemberFixture.java @@ -0,0 +1,89 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.member.Member; +import touch.baton.domain.member.vo.Company; +import touch.baton.domain.member.vo.GithubUrl; +import touch.baton.domain.member.vo.ImageUrl; +import touch.baton.domain.member.vo.MemberName; +import touch.baton.domain.member.vo.OauthId; +import touch.baton.domain.member.vo.SocialId; + +import static touch.baton.fixture.vo.CompanyFixture.company; +import static touch.baton.fixture.vo.GithubUrlFixture.githubUrl; +import static touch.baton.fixture.vo.ImageUrlFixture.imageUrl; +import static touch.baton.fixture.vo.MemberNameFixture.memberName; +import static touch.baton.fixture.vo.OauthIdFixture.oauthId; +import static touch.baton.fixture.vo.SocialIdFixture.socialId; + +public abstract class MemberFixture { + + private MemberFixture() { + } + + public static Member create(final MemberName memberName, + final SocialId socialId, + final OauthId oauthId, + final GithubUrl githubUrl, + final Company company, + final ImageUrl imageUrl + ) { + return Member.builder() + .memberName(memberName) + .socialId(socialId) + .oauthId(oauthId) + .githubUrl(githubUrl) + .company(company) + .imageUrl(imageUrl) + .build(); + } + + public static Member createHyena() { + return create( + memberName("헤나"), + socialId("hyenaSocialId"), + oauthId("oauth_hyena"), + githubUrl("https://github.com/"), + company("우아한테크코스 5기 백엔드"), + imageUrl("https://")); + } + + public static Member createEthan() { + return create( + memberName("에단"), + socialId("ethanSocialId"), + oauthId("oauth_ethan"), + githubUrl("https://github.com/"), + company("우아한테크코스 5기 백엔드"), + imageUrl("https://")); + } + + public static Member createDitoo() { + return create( + memberName("디투"), + socialId("ditooSocialId"), + oauthId("oauth_ditoo"), + githubUrl("https://github.com/"), + company("우아한테크코스 5기 백엔드"), + imageUrl("https://")); + } + + public static Member createJudy() { + return create( + memberName("주디"), + socialId("judySocialId"), + oauthId("oauth_judy"), + githubUrl("https://github.com/"), + company("우아한테크코스 5기 백엔드"), + imageUrl("https://")); + } + + public static Member createWithSocialId(final String socialId) { + return create( + memberName("디투"), + socialId(socialId), + oauthId("oauth_ditoo"), + githubUrl("https://github.com/"), + company("우아한테크코스 5기 백엔드"), + imageUrl("https://")); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerFixture.java new file mode 100644 index 000000000..c4ddf38a9 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerFixture.java @@ -0,0 +1,54 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.common.vo.Introduction; +import touch.baton.domain.member.Member; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.technicaltag.RunnerTechnicalTag; +import touch.baton.domain.technicaltag.RunnerTechnicalTags; +import touch.baton.domain.technicaltag.TechnicalTag; + +import java.util.ArrayList; +import java.util.List; + +import static touch.baton.fixture.vo.IntroductionFixture.introduction; + +public abstract class RunnerFixture { + + private RunnerFixture() { + } + + public static Runner createRunner(final Member member) { + return createRunner(introduction("안녕하세요."), member, new RunnerTechnicalTags(new ArrayList<>())); + } + + public static Runner createRunner(final Member member, final List technicalTags) { + final Runner runner = createRunner(introduction("안녕하세요."), member); + + final List runnerTechnicalTags = technicalTags.stream() + .map(technicalTag -> RunnerTechnicalTag.builder() + .technicalTag(technicalTag) + .runner(runner) + .build()) + .toList(); + + runner.addAllRunnerTechnicalTags(runnerTechnicalTags); + return runner; + } + + public static Runner createRunner(final Introduction introduction, final Member member) { + return Runner.builder() + .member(member) + .runnerTechnicalTags(new RunnerTechnicalTags(new ArrayList<>())) + .build(); + } + + public static Runner createRunner(final Introduction introduction, + final Member member, + final RunnerTechnicalTags runnerTechnicalTags + ) { + return Runner.builder() + .member(member) + .runnerTechnicalTags(runnerTechnicalTags) + .build(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostFixture.java new file mode 100644 index 000000000..6b17699e5 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostFixture.java @@ -0,0 +1,185 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.common.vo.Contents; +import touch.baton.domain.common.vo.Title; +import touch.baton.domain.common.vo.WatchedCount; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.runnerpost.vo.Deadline; +import touch.baton.domain.runnerpost.vo.PullRequestUrl; +import touch.baton.domain.runnerpost.vo.ReviewStatus; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.RunnerPostTags; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.vo.DeadlineFixture; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public abstract class RunnerPostFixture { + + private RunnerPostFixture() { + } + + public static RunnerPost create(final Title title, + final Contents contents, + final PullRequestUrl pullRequestUrl, + final Deadline deadline, + final WatchedCount watchedCount, + final ReviewStatus reviewStatus, + final Runner runner, + final Supporter supporter, + final RunnerPostTags runnerPostTags + ) { + return RunnerPost.builder() + .title(title) + .contents(contents) + .pullRequestUrl(pullRequestUrl) + .deadline(deadline) + .watchedCount(watchedCount) + .reviewStatus(reviewStatus) + .runner(runner) + .supporter(supporter) + .runnerPostTags(runnerPostTags) + .build(); + } + + public static RunnerPost create(final Runner runner, final Deadline deadline) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(deadline) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(null) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + } + + public static RunnerPost create(final Runner runner, final Deadline deadline, final ReviewStatus reviewStatus) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(deadline) + .watchedCount(new WatchedCount(0)) + .reviewStatus(reviewStatus) + .runner(runner) + .supporter(null) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + } + + public static RunnerPost create(final Runner runner, final Deadline deadline, List tags) { + final RunnerPost runnerPost = RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(deadline) + .watchedCount(new WatchedCount(0)) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runner(runner) + .supporter(null) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + + final List runnerPostTags = tags.stream() + .map(tag -> RunnerPostTagFixture.create(runnerPost, tag)) + .toList(); + + runnerPost.addAllRunnerPostTags(runnerPostTags); + + return runnerPost; + } + + + public static RunnerPost create(final Runner runner, final RunnerPostTags runnerPostTags, final Deadline deadline) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(deadline) + .watchedCount(new WatchedCount(0)) + .runner(runner) + .supporter(null) + .runnerPostTags(runnerPostTags) + .build(); + } + + public static RunnerPost create(final Runner runner, final Supporter supporter) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(DeadlineFixture.deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .runner(runner) + .supporter(supporter) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + } + + public static RunnerPost createWithReviewStatus(final Runner runner, final Supporter supporter, final ReviewStatus reviewStatus) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(DeadlineFixture.deadline(LocalDateTime.now().plusHours(100))) + .watchedCount(new WatchedCount(0)) + .runner(runner) + .supporter(supporter) + .reviewStatus(reviewStatus) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + } + + public static RunnerPost create(final Runner runner, final Supporter supporter, final Deadline deadline) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(deadline) + .watchedCount(new WatchedCount(0)) + .runner(runner) + .supporter(supporter) + .reviewStatus(ReviewStatus.NOT_STARTED) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + } + + public static RunnerPost create(final Runner runner, + final Supporter supporter, + final Deadline deadline, + final ReviewStatus reviewStatus + ) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(deadline) + .watchedCount(new WatchedCount(0)) + .runner(runner) + .supporter(supporter) + .reviewStatus(reviewStatus) + .runnerPostTags(new RunnerPostTags(new ArrayList<>())) + .build(); + } + + public static RunnerPost create(final Runner runner, final Supporter supporter, final RunnerPostTags runnerPostTags, final Deadline deadline) { + return RunnerPost.builder() + .title(new Title("테스트 제목")) + .contents(new Contents("테스트 내용")) + .pullRequestUrl(new PullRequestUrl("https://테스트")) + .deadline(deadline) + .watchedCount(new WatchedCount(0)) + .runner(runner) + .supporter(supporter) + .runnerPostTags(runnerPostTags) + .build(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagFixture.java new file mode 100644 index 000000000..b244ce1bb --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagFixture.java @@ -0,0 +1,18 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.Tag; + +public abstract class RunnerPostTagFixture { + + private RunnerPostTagFixture() { + } + + public static RunnerPostTag create(final RunnerPost runnerPost, final Tag tag) { + return RunnerPostTag.builder() + .runnerPost(runnerPost) + .tag(tag) + .build(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagsFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagsFixture.java new file mode 100644 index 000000000..3a62d7096 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerPostTagsFixture.java @@ -0,0 +1,16 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.tag.RunnerPostTag; +import touch.baton.domain.tag.RunnerPostTags; + +import java.util.List; + +public abstract class RunnerPostTagsFixture { + + private RunnerPostTagsFixture() { + } + + public static RunnerPostTags runnerPostTags(final List runnerPostTags) { + return new RunnerPostTags(runnerPostTags); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerTechnicalTagsFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerTechnicalTagsFixture.java new file mode 100644 index 000000000..0561a4afc --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/RunnerTechnicalTagsFixture.java @@ -0,0 +1,29 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.runner.Runner; +import touch.baton.domain.technicaltag.RunnerTechnicalTag; +import touch.baton.domain.technicaltag.RunnerTechnicalTags; +import touch.baton.domain.technicaltag.TechnicalTag; + +import java.util.List; + +public abstract class RunnerTechnicalTagsFixture { + + private RunnerTechnicalTagsFixture() { + } + + public static RunnerTechnicalTags create(final List runnerTechnicalTags) { + return new RunnerTechnicalTags(runnerTechnicalTags); + } + + public static RunnerTechnicalTags create(final Runner runner, final List technicalTags) { + final List runnerTechnicalTags = technicalTags.stream() + .map(technicalTag -> RunnerTechnicalTag.builder() + .technicalTag(technicalTag) + .runner(runner) + .build()) + .toList(); + + return new RunnerTechnicalTags(runnerTechnicalTags); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFeedbackFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFeedbackFixture.java new file mode 100644 index 000000000..9a4bf0192 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFeedbackFixture.java @@ -0,0 +1,44 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.feedback.SupporterFeedback; +import touch.baton.domain.feedback.vo.Description; +import touch.baton.domain.feedback.vo.ReviewType; +import touch.baton.domain.runner.Runner; +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.supporter.Supporter; + +import static touch.baton.fixture.vo.DescriptionFixture.description; + +public abstract class SupporterFeedbackFixture { + + private SupporterFeedbackFixture() { + } + + public static SupporterFeedback create(final Supporter supporter, + final Runner runner, + final RunnerPost runnerPost + ) { + return SupporterFeedback.builder() + .reviewType(ReviewType.GOOD) + .description(description("리뷰가 친절합니다.")) + .supporter(supporter) + .runner(runner) + .runnerPost(runnerPost) + .build(); + } + + public static SupporterFeedback create(final ReviewType reviewType, + final Description description, + final Supporter supporter, + final Runner runner, + final RunnerPost runnerPost + ) { + return SupporterFeedback.builder() + .reviewType(reviewType) + .description(description) + .supporter(supporter) + .runner(runner) + .runnerPost(runnerPost) + .build(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFixture.java new file mode 100644 index 000000000..e5b93fc55 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterFixture.java @@ -0,0 +1,64 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.member.Member; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.vo.ReviewCount; +import touch.baton.domain.technicaltag.SupporterTechnicalTag; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; +import touch.baton.domain.technicaltag.TechnicalTag; +import touch.baton.fixture.vo.ReviewCountFixture; + +import java.util.ArrayList; +import java.util.List; + +public abstract class SupporterFixture { + + private SupporterFixture() { + } + + public static Supporter create(final Member member) { + return Supporter.builder() + .reviewCount(ReviewCountFixture.reviewCount(0)) + .member(member) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build(); + } + + public static Supporter create(final ReviewCount reviewCount, + final Member member, + final SupporterTechnicalTags supporterTechnicalTags + ) { + return Supporter.builder() + .reviewCount(reviewCount) + .member(member) + .supporterTechnicalTags(supporterTechnicalTags) + .build(); + } + + public static Supporter create(final Member member, + final List technicalTags + ) { + return create(new ReviewCount(0), member, technicalTags); + } + + public static Supporter create(final ReviewCount reviewCount, + final Member member, + final List technicalTags + ) { + final Supporter supporter = Supporter.builder() + .reviewCount(reviewCount) + .member(member) + .supporterTechnicalTags(new SupporterTechnicalTags(new ArrayList<>())) + .build(); + + final List supporterTechnicalTags = technicalTags.stream() + .map(technicalTag -> SupporterTechnicalTag.builder() + .supporter(supporter) + .technicalTag(technicalTag) + .build()) + .toList(); + + supporter.addAllSupporterTechnicalTags(supporterTechnicalTags); + return supporter; + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterRunnerPostFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterRunnerPostFixture.java new file mode 100644 index 000000000..c48140d79 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterRunnerPostFixture.java @@ -0,0 +1,20 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.runnerpost.RunnerPost; +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.supporter.SupporterRunnerPost; +import touch.baton.domain.supporter.vo.Message; + +public abstract class SupporterRunnerPostFixture { + + private SupporterRunnerPostFixture() { + } + + public static SupporterRunnerPost create(final RunnerPost runnerPost, final Supporter supporter) { + return SupporterRunnerPost.builder() + .runnerPost(runnerPost) + .supporter(supporter) + .message(new Message("안녕하세요. 테스트용 서포터입니다.")) + .build(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagFixture.java new file mode 100644 index 000000000..b67a77f5a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagFixture.java @@ -0,0 +1,18 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.supporter.Supporter; +import touch.baton.domain.technicaltag.SupporterTechnicalTag; +import touch.baton.domain.technicaltag.TechnicalTag; + +public abstract class SupporterTechnicalTagFixture { + + private SupporterTechnicalTagFixture() { + } + + public static SupporterTechnicalTag create(final Supporter supporter, final TechnicalTag technicalTag) { + return SupporterTechnicalTag.builder() + .supporter(supporter) + .technicalTag(technicalTag) + .build(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagsFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagsFixture.java new file mode 100644 index 000000000..da7600f6e --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/SupporterTechnicalTagsFixture.java @@ -0,0 +1,16 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.technicaltag.SupporterTechnicalTag; +import touch.baton.domain.technicaltag.SupporterTechnicalTags; + +import java.util.List; + +public abstract class SupporterTechnicalTagsFixture { + + private SupporterTechnicalTagsFixture() { + } + + public static SupporterTechnicalTags create(final List supporterTechnicalTags) { + return new SupporterTechnicalTags(supporterTechnicalTags); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/TagFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/TagFixture.java new file mode 100644 index 000000000..6c40b717a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/TagFixture.java @@ -0,0 +1,29 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.tag.Tag; +import touch.baton.fixture.vo.TagNameFixture; + +public abstract class TagFixture { + + private TagFixture() { + } + + public static Tag create(final TagName tagName) { + return Tag.builder() + .tagName(tagName) + .build(); + } + + public static Tag createJava() { + return create(TagNameFixture.tagName("Java")); + } + + public static Tag createSpring() { + return create(TagNameFixture.tagName("Spring")); + } + + public static Tag createReact() { + return create(TagNameFixture.tagName("React")); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/TechnicalTagFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/TechnicalTagFixture.java new file mode 100644 index 000000000..3464d9c7d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/TechnicalTagFixture.java @@ -0,0 +1,30 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.common.vo.TagName; +import touch.baton.domain.technicaltag.TechnicalTag; + +import static touch.baton.fixture.vo.TagNameFixture.tagName; + +public abstract class TechnicalTagFixture { + + private TechnicalTagFixture() { + } + + public static TechnicalTag create(final TagName tagName) { + return TechnicalTag.builder() + .tagName(tagName) + .build(); + } + + public static TechnicalTag createJava() { + return create(tagName("Java")); + } + + public static TechnicalTag createSpring() { + return create(tagName("Spring")); + } + + public static TechnicalTag createReact() { + return create(tagName("React")); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/CompanyFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/CompanyFixture.java new file mode 100644 index 000000000..44532716e --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/CompanyFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.member.vo.Company; + +public abstract class CompanyFixture { + + private CompanyFixture() { + } + + public static Company company(final String value) { + return new Company(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/ContentsFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/ContentsFixture.java new file mode 100644 index 000000000..fce95cf6f --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/ContentsFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.common.vo.Contents; + +public abstract class ContentsFixture { + + private ContentsFixture() { + } + + public static Contents contents(final String value) { + return new Contents(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/DeadlineFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/DeadlineFixture.java new file mode 100644 index 000000000..a706c4283 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/DeadlineFixture.java @@ -0,0 +1,15 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.runnerpost.vo.Deadline; + +import java.time.LocalDateTime; + +public abstract class DeadlineFixture { + + private DeadlineFixture() { + } + + public static Deadline deadline(final LocalDateTime value) { + return new Deadline(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/DescriptionFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/DescriptionFixture.java new file mode 100644 index 000000000..c2a2fa263 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/DescriptionFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.feedback.vo.Description; + +public class DescriptionFixture { + + private DescriptionFixture() { + } + + public static Description description(final String value) { + return new Description(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/GithubUrlFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/GithubUrlFixture.java new file mode 100644 index 000000000..6eedbdc2d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/GithubUrlFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.member.vo.GithubUrl; + +public abstract class GithubUrlFixture { + + private GithubUrlFixture() { + } + + public static GithubUrl githubUrl(final String value) { + return new GithubUrl(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/ImageUrlFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/ImageUrlFixture.java new file mode 100644 index 000000000..3f3667ffe --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/ImageUrlFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.member.vo.ImageUrl; + +public abstract class ImageUrlFixture { + + private ImageUrlFixture() { + } + + public static ImageUrl imageUrl(final String value) { + return new ImageUrl(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/IntroductionFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/IntroductionFixture.java new file mode 100644 index 000000000..596bca95d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/IntroductionFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.common.vo.Introduction; + +public abstract class IntroductionFixture { + + private IntroductionFixture() { + } + + public static Introduction introduction(final String value) { + return new Introduction(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/MemberNameFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/MemberNameFixture.java new file mode 100644 index 000000000..2160545eb --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/MemberNameFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.member.vo.MemberName; + +public abstract class MemberNameFixture { + + private MemberNameFixture() { + } + + public static MemberName memberName(final String value) { + return new MemberName(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/MessageFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/MessageFixture.java new file mode 100644 index 000000000..7cd2c8c5a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/MessageFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.supporter.vo.Message; + +public abstract class MessageFixture { + + private MessageFixture() { + } + + public static Message message(final String value) { + return new Message(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/OauthIdFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/OauthIdFixture.java new file mode 100644 index 000000000..519ba6766 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/OauthIdFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.member.vo.OauthId; + +public abstract class OauthIdFixture { + + private OauthIdFixture() { + } + + public static OauthId oauthId(final String value) { + return new OauthId(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/PullRequestUrlFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/PullRequestUrlFixture.java new file mode 100644 index 000000000..a28447b46 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/PullRequestUrlFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.runnerpost.vo.PullRequestUrl; + +public abstract class PullRequestUrlFixture { + + private PullRequestUrlFixture() { + } + + public static PullRequestUrl pullRequestUrl(final String value) { + return new PullRequestUrl(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/ReviewCountFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/ReviewCountFixture.java new file mode 100644 index 000000000..59a70efa8 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/ReviewCountFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.supporter.vo.ReviewCount; + +public abstract class ReviewCountFixture { + + private ReviewCountFixture() { + } + + public static ReviewCount reviewCount(final int value) { + return new ReviewCount(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/SocialIdFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/SocialIdFixture.java new file mode 100644 index 000000000..2552b5314 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/SocialIdFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.member.vo.SocialId; + +public abstract class SocialIdFixture { + + private SocialIdFixture() { + } + + public static SocialId socialId(final String value) { + return new SocialId(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/TagNameFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/TagNameFixture.java new file mode 100644 index 000000000..6fcc79eb2 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/TagNameFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.common.vo.TagName; + +public abstract class TagNameFixture { + + private TagNameFixture() { + } + + public static TagName tagName(final String value) { + return new TagName(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/TitleFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/TitleFixture.java new file mode 100644 index 000000000..1feb686cf --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/TitleFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.common.vo.Title; + +public abstract class TitleFixture { + + private TitleFixture() { + } + + public static Title title(final String value) { + return new Title(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/WatchedCountFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/WatchedCountFixture.java new file mode 100644 index 000000000..54b5551cb --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/WatchedCountFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.common.vo.WatchedCount; + +public abstract class WatchedCountFixture { + + private WatchedCountFixture() { + } + + public static WatchedCount watchedCount(final int value) { + return new WatchedCount(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/infra/auth/jwt/JwtEncoderAndDecoderTest.java b/backend/baton/src/test/java/touch/baton/infra/auth/jwt/JwtEncoderAndDecoderTest.java new file mode 100644 index 000000000..6418f8a76 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/infra/auth/jwt/JwtEncoderAndDecoderTest.java @@ -0,0 +1,61 @@ +package touch.baton.infra.auth.jwt; + +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import touch.baton.domain.oauth.exception.OauthRequestException; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtEncoderAndDecoderTest { + + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + private JwtDecoder jwtDecoder; + + private JwtEncoder jwtEncoder; + + private JwtConfig jwtConfig; + + @BeforeEach + void setUp() { + this.jwtConfig = new JwtConfig("hyenahyenahyenahyenahyenahyenahyenahyenahyenahyenahyenahyena", "hyena"); + this.jwtDecoder = new JwtDecoder(this.jwtConfig); + this.jwtEncoder = new JwtEncoder(this.jwtConfig); + } + + @DisplayName("Claim 으로 socialId 를 넣어 인코딩한 JWT 를 디코드했을 때 socialId 을 구할 수 있다.") + @Test + void encode_and_decode() { + // given + final String encodedJwt = jwtEncoder.jwtToken(Map.of("socialId", "testSocialId")); + + // when + final Claims claims = jwtDecoder.parseJwtToken(encodedJwt); + final String socialId = claims.get("socialId", String.class); + + // then + assertThat(socialId).isEqualTo("testSocialId"); + } + + @DisplayName("인코드할 때 사용한 secretKey 가 디코드할 때 사용할 secretKey 와 다를 경우 예외가 발생한다.") + @Test + void fail_decode_with_wrong_secretKey() { + // given + final String encodedJwt = jwtEncoder.jwtToken(Map.of("socialId", "testSocialId")); + + // when + final JwtConfig wrongJwtConfig = new JwtConfig("wrongSecretKeywrongSecretKeywrongSecretKey", "hyena"); + final JwtDecoder wrongJwtDecoder = new JwtDecoder(wrongJwtConfig); + + // then + assertThatThrownBy(() -> wrongJwtDecoder.parseJwtToken(encodedJwt)) + .isInstanceOf(OauthRequestException.class); + } +}