From bef83ea509eafcc8e447caf4b13a330b857fc572 Mon Sep 17 00:00:00 2001 From: YoonJuHo Date: Thu, 22 Aug 2024 16:48:58 +0900 Subject: [PATCH] =?UTF-8?q?deploy:=20=EC=8A=A4=ED=83=80=EC=B9=B4=ED=86=A0?= =?UTF-8?q?=20v1.0.0=20#312=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init: 프로젝트 세팅 * refactor: PR 템플릿 파일명 및 경로 수정 * refactor: pr 템플릿 경로 수정 Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: devhoya97 Co-authored-by: YoonJuHo * build: develop-be 브랜치의 CI 설정 #6 (#7) * build: 초기 ci 템플릿 생성 Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: devhoya97 Co-authored-by: YoonJuHo * build: ci 초기 트리거 설정 Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: devhoya97 Co-authored-by: YoonJuHo * fix: 권한 수정 설정 Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: devhoya97 Co-authored-by: YoonJuHo * fix: working directory 설정 Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: devhoya97 Co-authored-by: YoonJuHo * fix: 권한 재설정 Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: devhoya97 Co-authored-by: YoonJuHo --------- Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: devhoya97 Co-authored-by: YoonJuHo * feat: Entity 구성 #2 (#17) * chore: 데이터 베이스 설정 * feat: Base Entity 구성 * feat: Pin Entity 구성 * feat: Travel Entity 구성 * feat: Member Entity 구성 * feat: Mate Entity 구성 * feat: Visit Entity 구성 * feat: Visit Image Entity 구성 * feat: Visit Log Entity 구성 * refactor: Table 애노테이션 삭제 * refactor: Soft Delete 적용 * feat: ControllerAdvice 생성 #29 (#34) * feat: Visit domain skeleton 구현 #31 (#37) Co-authored-by: linirini <2001yerin@naver.com> * feat: Travel Domain Skeleton Code 작성 #32 (#36) * feat: travel skeleton code 작성 * feat: travel 생성, 수정 dto 작성 및 예외 핸들링 * feat: Mate 도메인 빌더 추가 * style: 코드 컨벤션 준수를 위한 공백 제거 * build: Docker Compose Setting #27 (#40) * chore: gitignore 파일 추가 * chore: mysql 디펜던시 추가 * chore: Profile 분리 * feat: Docker 파일 설정 * feat: 여행 상세 생성 API 구현 #18 (#43) * build: RestAssured 의존성 추가 * test: 여행 상세 생성 인수 테스트 작성 * feat: 임시 MemberIdArgumentResolver 구현 * feat: Lombok 추가 * feat: Database 초기화 구현 * feat: 여행 상세 성공 서비스 구현 * fix: resolveArgument 반환 타입 오류 수정 * feat: 여행 상세 생성 성공 컨트롤러 구현 * feat: 여행 상세 생성 시 필수값 누락 검증 구현 * test: 글자 수 제한 검증 인수 테스트 추가 * refactor: 생성자에 builder 지정 * feat: 시작 날짜와 끝 날짜 도메인 검증 구현 * feat: 시작 날짜와 끝 날짜 예외 처리 테스트 및 구현 * style: 코드 컨벤션 적용 * refactor: parameter명 변경 * feat: transactional 적용 * style: paremeter 형식 통일 * style: parameter 형식 통일 * refactor: display name 오류 수정 * refactor: 불필요한 상수 제거 * refactor: paramterized test로 리팩터링 * style: 개행 제거 * refactor: 인자 변경 * refactor: 공통 예외 클래스명 변경 * feat: 범위 예외 핸들러 추가 * refactor: 서비스, 통합 테스트 보일러 플레이트 코드 제거 * refactor: builder 사용 시 필수 값 누락 제약 추가 * refactor: 도메인으로 변환하는 메서드를 dto에 추가 * build: CD yml 파일 구성 #28 (#53) * feat: CI/CD 설정 * feat: CI/CD 검증용 트리거 설정 * fix: CI/CD workflow 수정 * fix: CI/CD workflow 재수정 * fix: CI/CD workflow 절대 경로 수정 * chore: DDL 생성 전략 변경 * chore: dev 환경 DDL 생성 전략 변경 * refactor: 검증용 트리거 제거 * fix: 도커 이미지 기반 컨테이너 생성으로 변경 * refactor: 중간 테이블 엔티티 수정 #56 (#57) * refactor: 중간 테이블명 TravelMember로 변경 * refactor: 중간 테이블 OneToMany 필드 추가 * refactor: Member OneToMany 제거 * refactor: OneToMany List 초기화 * refactor: 연관관계 편의 메서드 사용 * chore: ddl 전략 임시 변경 * chore: ddl 전략 변경 * feat: 특정 방문 기록 삭제 API 구현 #26 (#42) * feat: 특정 방문 기록 삭제 API 구현 * feat: 양수가 아닌 id로 특정 방문 기록 삭제를 시도할 때 예외 처리 기능 구현 * feat: 방문 기록 삭제 시 방문 로그도 함께 삭제되는 기능 구현 * refactor: 커스텀 예외를 제거하는 방향으로 변경 * fix: 예외를 못 잡던 문제 해결 * refactor: 메서드명 적절하게 변경 * build: Docker Compose Setting #27 (#40) * chore: gitignore 파일 추가 * chore: mysql 디펜던시 추가 * chore: Profile 분리 * feat: Docker 파일 설정 * feat: 여행 상세 생성 API 구현 #18 (#43) * build: RestAssured 의존성 추가 * test: 여행 상세 생성 인수 테스트 작성 * feat: 임시 MemberIdArgumentResolver 구현 * feat: Lombok 추가 * feat: Database 초기화 구현 * feat: 여행 상세 성공 서비스 구현 * fix: resolveArgument 반환 타입 오류 수정 * feat: 여행 상세 생성 성공 컨트롤러 구현 * feat: 여행 상세 생성 시 필수값 누락 검증 구현 * test: 글자 수 제한 검증 인수 테스트 추가 * refactor: 생성자에 builder 지정 * feat: 시작 날짜와 끝 날짜 도메인 검증 구현 * feat: 시작 날짜와 끝 날짜 예외 처리 테스트 및 구현 * style: 코드 컨벤션 적용 * refactor: parameter명 변경 * feat: transactional 적용 * style: paremeter 형식 통일 * style: parameter 형식 통일 * refactor: display name 오류 수정 * refactor: 불필요한 상수 제거 * refactor: paramterized test로 리팩터링 * style: 개행 제거 * refactor: 인자 변경 * refactor: 공통 예외 클래스명 변경 * feat: 범위 예외 핸들러 추가 * refactor: 서비스, 통합 테스트 보일러 플레이트 코드 제거 * refactor: builder 사용 시 필수 값 누락 제약 추가 * refactor: 도메인으로 변환하는 메서드를 dto에 추가 * build: CD yml 파일 구성 #28 (#53) * feat: CI/CD 설정 * feat: CI/CD 검증용 트리거 설정 * fix: CI/CD workflow 수정 * fix: CI/CD workflow 재수정 * fix: CI/CD workflow 절대 경로 수정 * chore: DDL 생성 전략 변경 * chore: dev 환경 DDL 생성 전략 변경 * refactor: 검증용 트리거 제거 * fix: 도커 이미지 기반 컨테이너 생성으로 변경 * fix: rebase 과정에서 파일이 꼬인 문제 해결 * test: HttpHeaders.AUTHORIZATION 사용 * refactor: 중간 테이블 엔티티 수정 #56 (#57) * refactor: 중간 테이블명 TravelMember로 변경 * refactor: 중간 테이블 OneToMany 필드 추가 * refactor: Member OneToMany 제거 * refactor: OneToMany List 초기화 * refactor: 연관관계 편의 메서드 사용 * chore: ddl 전략 임시 변경 * chore: ddl 전략 변경 * feat: Pin, Visit, VisitLog 생성자에 builder 추가 * feat: Pin repository 추가 * refactor: visit이 삭제되기 전에 visit에 의존하는 visitLog들이 먼저 삭제되도록 순서 변경 * test: 방문 기록 삭제에 대한 서비스 슬라이스 테스트 추가 * test: 방문 기록이 갖는 모든 방문 로그 삭제 메서드 테스트 * fix: Modifying을 사용할 때 영속성컨텍스트와 관련하여 발생하던 문제 해결 * refactor: visitLog의 content를 필수값으로 변경 * test: 컨벤션에 맞게 Controller 테스트 클래스 변경 * fix: ConstraintViolationException의 예외 메시지를 정해둔 형식에 맞게 변경 --------- Co-authored-by: YoonJuHo Co-authored-by: linirini <101927543+linirini@users.noreply.github.com> * refactor: 여행 상세 생성 서비스 반환 타입 변경 (#63) * feat: 여행 상세 목록 조회 API 구현 #19 (#60) * test: 여행 상세 목록 조회 통합 테스트 작성 * feat: 여행 상세 목록 조회 DTO 구현 * feat: 모든 여행 상세 목록 조회 서비스 구현 * refactor: 미사용 반환값 제거 * feat: 년도 조건에 따른 여행 상세 조회 서비스 구현 * test: import 수정 * test: 년도와 사용자 식별자로 여행 목록 조회하는 JPQL 테스트 추가 * style: 코드 컨벤션 적용 * test: 여행 상세 목록 조회 컨트롤러 구현 * test: disabled 제거 및 테스트 오류 수정 * refactor: 불필요한 변수 분리 제거 * refactor: Optional로 분기 처리 * test: DisplayName 수정 * refactor: DTO 이름 변경 * feat: 방문 기록 생성 API 구현 #21 (#64) * feat: 방문 기록 생성 기능 구현 * feat: getter 및 builder 추가 * feat: VisitService에 Transactional 적용 * test: 방문 기록 생성 테스트 * fix: 오타 수정 * style: 코드 컨벤션 적용 * fix: deleteById에 Transactional annotation 추가 * refactor: builder 파라미터 NonNull 설정 추가 * refactor: 데이터 개수 감소 * refactor: 예외 메시지 구체화 및 상태 코드 변경 * feat: 특정 여행 상세 수정 API 구현 #22 (#62) * build: Docker Compose Setting #27 (#40) * chore: gitignore 파일 추가 * chore: mysql 디펜던시 추가 * chore: Profile 분리 * feat: Docker 파일 설정 * feat: 여행 상세 생성 API 구현 #18 (#43) * build: RestAssured 의존성 추가 * test: 여행 상세 생성 인수 테스트 작성 * feat: 임시 MemberIdArgumentResolver 구현 * feat: Lombok 추가 * feat: Database 초기화 구현 * feat: 여행 상세 성공 서비스 구현 * fix: resolveArgument 반환 타입 오류 수정 * feat: 여행 상세 생성 성공 컨트롤러 구현 * feat: 여행 상세 생성 시 필수값 누락 검증 구현 * test: 글자 수 제한 검증 인수 테스트 추가 * refactor: 생성자에 builder 지정 * feat: 시작 날짜와 끝 날짜 도메인 검증 구현 * feat: 시작 날짜와 끝 날짜 예외 처리 테스트 및 구현 * style: 코드 컨벤션 적용 * refactor: parameter명 변경 * feat: transactional 적용 * style: paremeter 형식 통일 * style: parameter 형식 통일 * refactor: display name 오류 수정 * refactor: 불필요한 상수 제거 * refactor: paramterized test로 리팩터링 * style: 개행 제거 * refactor: 인자 변경 * refactor: 공통 예외 클래스명 변경 * feat: 범위 예외 핸들러 추가 * refactor: 서비스, 통합 테스트 보일러 플레이트 코드 제거 * refactor: builder 사용 시 필수 값 누락 제약 추가 * refactor: 도메인으로 변환하는 메서드를 dto에 추가 * build: CD yml 파일 구성 #28 (#53) * feat: CI/CD 설정 * feat: CI/CD 검증용 트리거 설정 * fix: CI/CD workflow 수정 * fix: CI/CD workflow 재수정 * fix: CI/CD workflow 절대 경로 수정 * chore: DDL 생성 전략 변경 * chore: dev 환경 DDL 생성 전략 변경 * refactor: 검증용 트리거 제거 * fix: 도커 이미지 기반 컨테이너 생성으로 변경 * refactor: 중간 테이블 엔티티 수정 #56 (#57) * refactor: 중간 테이블명 TravelMember로 변경 * refactor: 중간 테이블 OneToMany 필드 추가 * refactor: Member OneToMany 제거 * refactor: OneToMany List 초기화 * refactor: 연관관계 편의 메서드 사용 * chore: ddl 전략 임시 변경 * chore: ddl 전략 변경 * feat: 비어있는 요청 에러 핸들링 추가 * feat: 특정 여행 상세 수정 서비스 구현 * feat: 특정 여행 상세 수정 컨트롤러 구현 * feat: 비어있는 요청 에러 핸들링 추가 * feat: 특정 여행 상세 수정 서비스 구현 * feat: 특정 여행 상세 수정 컨트롤러 구현 * refactor: DirtiesContext 삭제 * refactor: Transactional 읽기 전용 옵션 구성 * feat: 방문 기록 날짜 검증 로직 추가 * refactor: 메서드 체이닝 적용 * refactor: 수정 작업 테스트 환경 동일하게 유지 --------- Co-authored-by: linirini <101927543+linirini@users.noreply.github.com> Co-authored-by: devhoya97 <146502065+devhoya97@users.noreply.github.com> * fix: 논리적 삭제 데이터는 조회에서 제외 #66 (#68) * test: 쿼리 메서드 사용 * fix: sqlDelete문에 테이블명 변경사항 반영 * fix: 삭제된 데이터 제외하고 조회하도록 조건 추가 * fix: 삭제된 데이터 제외하고 조회하도록 조건 추가 * fix: 특정 방문 기록 삭제 API 호출 시 관련된 VisitImage를 모두 삭제하도록 수정 #65 (#67) * feat: visitId에 맞는 visitImage들을 모두 삭제하는 기능 구현 * fix: visit을 삭제해도 visit에 포함된 모든 visitImage들이 삭제되지 않던 문제 해결 * test: 엔티티 생성시 가독성을 위한 개행 삭제 * refactor: JPQL에서 VisitLog를 vl로 축약 * fix: 충돌해결 * test: 경계값에 포함되지 않는 변수 제거 * feat: 특정 여행 상세 조회 API 구현 #20 (#73) * test: 특정 여행 상세 조회 통합 테스트 작성 * feat: 특정 여행 상세 조회 DTO 구현 * fix: 삭제되지 않은 데이터만 찾도록 쿼리 메서드 수정 * feat: 특정 여행 상세 조회 서비스 구현 * feat: 특정 여행 상세 조회 컨트롤러 구현 * test: 존재하지 않는 특정 여행 상세 조회 테스트 * feat: null 필드 응답에 미포함 구현 * style: 코드 컨벤션 적용 * fix: 응답 형식 오류 수정 * feat: 특정 여행 상세 삭제 API 구현 #24 (#72) * style: 코드 컨벤션 적용 * feat: 특정 여행 상세 삭제 서비스 구현 * feat: 특정 여행 상세 삭제 컨트롤러 구현 * refactor: 검증 메서드 분리 * refactor: Visit 논리적 삭제 전파 순서 수정 * feat: 특정 방문 기록 조회 API 구현 #25 (#76) * feat: 특정 방문 기록 조회 API 기능 구현 * fix: Repository 조회시 논리적 삭제가 되지 않은 엔티티들만 가져오도록 변경 * test: System.out 메서드 제거 * refactor: 메서드명 통일 및 CRUD 순서로 배치 * refactor: 사용하지 않는 DTO 제거 * test: 서비스 메서드명 변경에 따른 테스트 메서드명 변경 * fix: 특정 방문 기록이 몇 번째 방문인지 계산할 때, 더 늦게 방문한 기록까지 세던 문제 해결 * test: 몇 번째 방문인지 계산할 때, 이전의 방문만 셀 수 있는지 테스트 * feat: Pin 연관관계 추가 #80 (#83) * feat: Pin에 Member 연관관계 추가 * refactor: private 보조 메서드 순서 변경 * feat: logging 추가 #86 (#89) * feat: 간단한 Error Logging 추가 * refactor: Logging Level 변경 * feat: VisitLog, VisitImage 양방향 관계 설정 및 논리적 삭제 제거 #87 (#88) * feat: visitLog, visitImage 논리적 삭제 제거 * feat: visitLog, visitImage 양방향 설정 및 양방향 관계 설정에 따른 여행, 방문기록 삭제 로직 변경 * fix: 여행 상세 수정 날짜 필터링 오류와 썸네일 저장 오류 수정 #90 (#91) * fix: 여행 상세 수정 날짜 필터링 오류 수정 * fix: 여행 상세 생성 시 썸네일을 저장하지 않는 오류 수정 * refactor: dto 필드 수정 (#95) * feat: 여행 상세 목록 조회 시 최신순 정렬 #96 (#100) * feat: 여행 상세 목록 최신순으로 조회 * refactor: JPQL 메서드명 변경 * feat: 특정 여행 상세 조회 API에서 방문 기록 오래된 순 정렬 #101 (#102) * refactor: 반환값 제거 및 미사용 Param 제거 * feat: 특정 여행 상세 조회 시 방문 기록 오래된 순 조회 구현 * fix: Travel 삭제시 발생하는 오류 수정 #103 (#105) * fix: 여행에 포함된 방문 기록의 존재 여부를 검사할 때 논리적으로 삭제되지 않은 방문 기록만 고려하도록 수정 * fix: 여행을 삭제하면 연관된 TravelMember에 논리적 삭제가 전파되도록 수정 * refactor: JPQL에서 쿼리메서드로 변경 * refactor: @SQLRestriction으로 soft-delete하도록 변경 #106 (#107) * refactor: @SQLRestriction으로 soft-delete하도록 변경 * fix: 정렬 조건 누락 추가 * test: displayName 변경 * docs: swagger 컨벤션 설정 및 적용 (#116) * build: 중복 의존성 정의 제거 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * build: OpenApi 의존성 추가 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * chore: 전역적인 media type 설정 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * chore: open api skeleton code 작성 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * fix: constraint redefine 불가로 인한 오류 수정 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * style: 의미없는 개행 제거 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * docs: 누락된 설명 추가 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls --------- Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * feat: Entity 수정 (#119) * feat: 엔티티 구조 변경 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * style: 불필요한 개행 제거 Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls --------- Co-authored-by: YoonJuHo Co-authored-by: devhoya97 Co-authored-by: BurningFalls * build: 사용하지 않는 도커 이미지 삭제 workflow 구성 #84 (#120) * build: CD 작업 시 기존 도커 이미지 삭제 * build: CD 작업 시 기존 도커 이미지 삭제 순서 변경 * build: CD 트리거 수정 * refactor: 엔티티 수정 #125 (#126) * refactor: base entity 필드명 수정 * refactor: visitLog에 base Entity 추가 및 논리적 삭제 구현 * feat: 로그인 API 구현 #123 (#128) * build: jwt 의존성 추가 * chore: jwt 관련 환경 설정 추가 * test: 회원 생성 및 토큰 발급 성공 테스트 & 닉네임 중복 테스트 추가 * feat: 토큰 발급 구현 * feat: 로그인 서비스 구현 * feat: 로그인 컨트롤러 구현 * feat: 닉네임 VO 분리 및 예외 처리 구현 * refactor: getter 재정의 제거 * test: 닉네임 형식 예외 처리에 따른 테스트 수정 * feat: 필수값 누락 예외 처리 구현 * style: 코드 컨벤션 적용 * refactor: 누락된 dto 패키지 추가 * refactor: 애너테이션명 변경 * feat: 토큰 파싱해서 member 찾도록 resolver 구현 * fix: @MemberId -> @LoginMember로 변경되며 long에서 member로 타입 변경에 따른 테스트 실패 수정 * style: 코드 컨벤션 적용 * feat: 401 예외 핸들러 구현 * docs: swagger 명세 추가 * feat: 인가 관련 예외 & 핸들러 추가 * test: authorization 헤더 로직 구현에 따른 테스트 수정 * chore: CI run 임시 수정 * refactor: 필드 접근 제어자 수정 * refactor: 변수 분리 * test: 테스트명 수정 * refactor: handler 메서드명 변경 * docs: 예외 설명 추가 * refactor: 상수 재활용 * docs: schema description 수정 * refactor: 불필요한 개행 제거 * docs: 예외 발생 상황 설명 수정 * test: 토큰 생성 검증 추가 * chore: CI run 이전 상태로 수정 * docs: 응답 명세 작성 * feat: transactional 적용 * chore: CI run을 self hosted로 임시 변경 * fix: CD 실패로 인한 workflow 수정 (#135) * build: jwt 의존성 추가 * chore: jwt 관련 환경 설정 추가 * test: 회원 생성 및 토큰 발급 성공 테스트 & 닉네임 중복 테스트 추가 * feat: 토큰 발급 구현 * feat: 로그인 서비스 구현 * feat: 로그인 컨트롤러 구현 * feat: 닉네임 VO 분리 및 예외 처리 구현 * refactor: getter 재정의 제거 * test: 닉네임 형식 예외 처리에 따른 테스트 수정 * feat: 필수값 누락 예외 처리 구현 * style: 코드 컨벤션 적용 * refactor: 누락된 dto 패키지 추가 * refactor: 애너테이션명 변경 * feat: 토큰 파싱해서 member 찾도록 resolver 구현 * fix: @MemberId -> @LoginMember로 변경되며 long에서 member로 타입 변경에 따른 테스트 실패 수정 * style: 코드 컨벤션 적용 * feat: 401 예외 핸들러 구현 * docs: swagger 명세 추가 * feat: 인가 관련 예외 & 핸들러 추가 * test: authorization 헤더 로직 구현에 따른 테스트 수정 * chore: CI run 임시 수정 * refactor: 필드 접근 제어자 수정 * refactor: 변수 분리 * test: 테스트명 수정 * refactor: handler 메서드명 변경 * docs: 예외 설명 추가 * refactor: 상수 재활용 * docs: schema description 수정 * refactor: 불필요한 개행 제거 * docs: 예외 발생 상황 설명 수정 * test: 토큰 생성 검증 추가 * chore: CI run 이전 상태로 수정 * docs: 응답 명세 작성 * feat: transactional 적용 * chore: CI run을 self hosted로 임시 변경 * chore: CI run을 self hosted로 권한 부여 * chore: CI/CD workflow 트리거 임시 설정 * chore: CI/CD runs on 재설정 * chore: CI/CD workflow 권한 재설정 * chore: CI/CD workflow 권한 재설정 * chore: CI/CD workflow 임시용 트리거 제거 * fix: TravelResponses 필드 wrapping 오류 수정 (#145) * refactor: 방문기록 조회/수정 도메인 변경으로 인한 수정 #121 (#131) * feat: 특정 방문 기록 조회 API 문서화 * test: Test Fixture 생성 * refactor: 특정 방문 기록 조회 서비스 수정 * test: 특정 방문 기록 조회 컨트롤러 단위 테스트 추가 * refactor: API 명세에 맞게 변수명 변경 * feat: 일급컬렉션 구성 및 연관관계 편의 메서드 위치 변경 * feat: 특정 방문 기록 수정 서비스 구현 * feat: 특정 방문 기록 수정 컨트롤러 구현 * fix: ci 환경 변경 * feat: Multipart 문서화 및 검증 로직 추가 * refactor: 검증하고자 하는 부분을 명시적으로 표현 * refactor: 상수 접근제어자 변경 * refactor: NoArgsConstructor 접근 제어자 변경 * refactor: 생성자 Builder로 표현 * refactor: 부정으로만 사용되는 메서드 명 변경 * refactor: 메서드 명 변경 * refactor: 테스트 검증 방법 변경 * fix: 수정 요청 값 필수 * feat: 메시지 검증 로직 추가 * refactor: 불필요한 Content 애노테이션 제거 * refactor: API 명세 요청 변수 명 변경으로 인한 필드 명 수정 * refactor: 메서드 분리 * fix: AuthService Mocking * refactor: 명세에 맞게 닉네임 필드 명 변경 * refactor: 방문 기록 생성/삭제 도메인 변경으로 인한 수정 #122 (#129) * refactor: api명세에 맞게 필드명 변경 * test: TDD를 위한 컨트롤러 테스트코드 작성 * refactor: 방문 상세 생성 컨트롤러 api명세에 맞게 리팩토링 * refactor: 코드 컨벤션에 맞게 필드와 어노테이션을 다른 줄로 구분 * fix: 여행 식별자가 양수인지 검증하는 코드 추가 * test: 방문 기록을 생성할 수 없는 케이스 테스트 * feat: 사진이 5장을 초과하면 예외처리 기능 구현 * refactor: API 명세의 이름과 변수명 통일 * test: 방문 기록 생성 테스트 추가 * test: 메서드 명을 명확하게 변경 * fix: visitImagesUrl이 null일 때 NPE가 발생하는 문제 수정 * test: 양수가 아닌 식별자로 방문 기록 삭제시 예외 발생 테스트 * refactor: 코드 컨벤션에 맞게 컨트롤러 코드 수정 * test: Visit을 삭제하면 VisitImage도 삭제되는지 테스트 * refactor: 방문 기록 생성시 경계값을 테스트하면서 필요없어지는 메서드 제거 * refactor: 방문 기록 삭제 시 visitId는 null일 수 없으므로 long 타입으로 변경 * test: 방문 기록과 관련된 통합테스트 제거 * test: invalidVisitRequestProvider의 위치를 맨 위로 이동 * fix: 여행 기간에 포함되지 않는 방문 기록은 생성하지 못하도록 수정 * refactor: 가독성을 위한 예외 메시지 수정 * refactor: 불필요한 개행 제거 * refactor: 컨벤션에 맞게 메서드 위치 변경 * test: 가독성을 위한 개행 추가 * refactor: 검증 메서드명을 더 명확하게 수정 * feat: 방문 기록 생성에 Swagger 적용 * fix: visitImageFile이 필수 값으로 설정되어 있던 버그 수정 * refactor: 패키지 위치 적절하게 변경 * feat: 방문 기록 생성 DTO에 Swagger 적용 * feat: 방문 기록 삭제 Swagger 적용 * test: 방문 기록 생성시 경계값 성공 테스트 추가 * refactor: dto에 Schema 설명 추가 * refactor: 방문 사진이 없는 경우 null이 아닌 빈 리스트로 오므로 null 체크 제거 * test: mockMvc 검증에서 content 활용 * test: 가독성을 위한 변경 * refactor: 추후 ExceptionHandler에서 처리할 상황 제거 * refactor: RequestPart value와 dto 변수명을 명세에 맞게 변경 * refactor: null 값을 다룰 가능성이 없는 필드에 Long이 아닌 long을 사용 * test: DisplayName을 더 명확하게 수정 * refactor: 코드 컨벤션에 맞게 개행 제거 * test: 상수 활용 * refacotr: VisitControllerDocs에 @Parameter 추가 * refacotr: 컨트롤러 메서드 순서를 CRUD순으로 정렬 * refactor: 방문기록 생성 시 이미지가 없어도 빈 리스트가 오므로 required=false 제거 * test: 자동정렬로 인한 의도치 않은 개행 제거 * feat: 여행 상세 생성 API 수정 #141 (#147) * refactor: where 검증절 이동 * feat: 여행 상세 생성 서비스에서 multipart 처리 위한 기반 코드 구현 * feat: 여행 상세 생성 컨트롤러에서 multipartFile 받도록 구현 * docs: 여행 상세 생성 명세서 작성 * docs: 여행 상세 생성 명세서 상 key 오류 수정 * docs: 여행 상세 생성 명세서 상 설명 오타 수정 * refactor: cascadeType 변경 및 부모 엔티티가 관리하도록 수정 * test: 모호한 displayName 수정 * refactor: persist 전파 위해 순서 변경 * feat: 여행 상세 목록 조회, 특정 여행 상세 조회, 특정 여행 상세 삭제 API 수정 #148 (#149) * docs: 여행 상세 목록 조회 API 문서화 * docs: 특정 여행 상세 조회 API 문서화 * docs: 공통 예외 문서화 * docs: 특정 여행 상세 삭제 API 문서화 * refactor: 응답 변수 분리 * feat: 특정 여행 상세 조회 시 권한 예외 처리 구현 * feat: 특정 여행 상세 삭제 시 권한 예외 처리 구현 * refactor: 메서드 순서 조정 (CRUD 순서) * fix: dto 필드 오류 수정 * test: 여행 상세 목록 조회 테스트 작성 * test: 특정 여행 상세 조회 테스트 작성 * test: 특정 여행 상세 삭제 컨트롤러 테스트 작성 * test: 여행 상세 목록 조회 JPQL 테스트 수정 * docs: example 제거 * fix: 동일성 비교 * test: 가독성있게 pathVariable 분리 * refactor: 방문기록 썸네일 메서드 분리 * fix: 삭제하려는 여행 상세 없을 시 예외 발생하지 않도록 수정 * refactor: member entity 외 논리적 삭제 제거 #132 (#156) * refactor: member 외 soft delete 제거 * chore: ddl 교체 위한 환경 임시 변경 * fix: 닉네임 형식 수정 #157 (#158) * fix: 닉네임 형식 수정 * chore: ddl 변경 위한 환경 임시변경 * feat: AWS S3 SDK 구현 (#137) * build: aws sdk 의존성 추가 * chore: application-secrets 반영하도록 변경 * chore: multipart 최대파일크기와 최대요청크기를 10MB로 확장 * feat: S3Client 설정 커스텀 * feat: S3Exception 에러 핸들러 추가 * feat: s3Client를 사용하는 CloudStorageClient 생성 * feat: 이미지를 S3에 올리고 URL을 받아오는 비즈니스 로직 작성 * feat: file upload API 구현 * chore: pull_request에도 CD가 적용되도록 임시 변경 * chore: secret 변수들을 env로 관리하도록 변경 * chore: dev 서버도 멀티파트 용량 확장 * chore: application.yml 파일에 cloud 관련 재설정 * chore: cd 과정에서 환경 변수 설정하기 * fix: env 파일 인식하도록 수정 * fix: CI/CD에서 env를 읽을 수 있도록 수정 * chore: pull_request 시 CD 돌아가지 않도록 수정 * chore: pull_request 시 CD 돌아가도록 임시 수정 * fix: dev에 빠진 security 설정 추가 * chore: dev에도 cloud 관련 설정 추가 * chore: yml 파일 롤백 * chore: ci/cd workflow 롤백 * chore: cloud 관련 설정 추가 * chore: 이미지 용량 제한 늘리는 설정 추가 * chore: yml에 실제 값 대입 * chore: pull_request에도 CD가 적용되도록 임시 수정 * fix: s3Client build를 CLoudStorageClient에서 수행 * chore: cloud 관련 설정 값 대입 * refactor: s3Client build를 S3ClientConfig에서 수행 * refactor: s3Client build를 CloudStorageClient에서 수행 * fix: 파일 경로 오류 수정 * fix: 파일 경로 오류 수정 * fix: 파일 경로 오류 수정 * fix: 파일 경로 오류 수정 * fix: 파일 경로 오류 수정 * fix: 파일 경로가 버킷을 포함하지 않도록 수정 * feat: file 이름이 겹치는 경우, UUID를 뒤에 붙이는 기능 구현 * chore: push에만 cd가 적용되도록 다시 변경 * refactor: 에러 메시지 변경 * refactor: MultipartFile 여러 개 받을 수 있도록 수정 * feat: S3 객체 삭제하는 API 구현 * chore: pull_request에도 cd가 적용되도록 다시 변경 * chore: push에만 cd가 적용되도록 다시 변경 * style: ci/cd workflow endline 롤백 * feat: 방문 기록 관련 인가 구현 #140 (#161) * feature: 특정 방문기록 조회시 인가 처리 * feature: 특정 방문기록 수정, 삭제시 인가 처리 * style: 미사용 import 제거 * feat: 방문 기록 생성 시 여행 상세의 주인인지 인가 추가 * refactor: 불필요한 개행 제거 * test: 테스트 실패 지점을 하나로 수정 * chore: 서버 DDL 생성 전략 변경 * feat: 여행 상세 수정 API 수정 #142 (#159) * refactor: 썸네일이 없는 경우 기존 썸네일 유지 * feat: 여행 수정 서비스 multipart와 인가 기능 추가 * feat: 여행 수정 컨트롤러 multipart와 인가 기능 추가 * fix: 여행 썸네일 추출 임시 로직 구성 * refactor: 400 에러 메세지 응답 API 문서에 추가 * docs: id 예시 값 추가 * chore: 개발 서버 DDL 생성 전략 변경 * refactor: 이미지 수정 요청 분기 처리 위치 변경 및 테스트 작성 * feat: 이미지가 필요한 API에 S3 적용 #166 (#168) * test: S3 테스트를 위해 fake 객체 생성 * feat: 여행 상세 생성 시 S3에 썸네일 저장 * feat: 여행 상세 수정 시 S3에 썸네일 대치 * feat: 방문 기록 생성 시 S3에 이미지 저장 * feat: 방문 기록 수정 시 S3에 이미지 대치 * chore: pull request에도 CD가 돌아가도록 임시 설정 * chore: pull request에도 CD가 돌아가도록 임시 설정한 것 원상복구 * refactor: Objects.isNull 활용 및 메서드 위치 변경 * feat: 로깅 프레임워크 적용 #134 (#171) * feat: 로거 환경 설정 * feat: 로거 형식 정의 * feat: 요청/응답 로깅 구현 * feat: 예외에 대한 로거 형식 적용 * feat: token 유무 식별 로그 추가 * refactor: thread 식별명 추가 * refactor: 예외 발생 구체 클래스/메서드 로깅 * chore: CD 트리거 수정 * chore: CD 트리거 복원 * chore: 임시 예외 케이스 생성 및 로그 테스트 * chore: 임시 예외 케이스 수정 및 로그 테스트 * chore: 임시 예외 케이스 재수정 및 로그 테스트 * chore: 임시 예외 케이스 삭제 * chore: CD 트리거 복원 * fix: Logging 데이터 변경 * chore: CD 트리거 복원 * fix: Logging 데이터 오류 수정 * chore: CD 트리거 복원 * feat: 로깅 White List 추가 * feat: 방문 기록 목록 조회시 시간 순으로도 정렬되는 기능 구현 (#175) * feat: 방문 기록의 방문 날짜 저장 시, 시간까지 저장하도록 변경 * fix: request dto에서 LocalDateTime에 대한 패턴이 시간까지 포함하도록 변경 * refactor: 여행에 포함된 날짜인지 비교시 LocalDateTime을 넘겨주도록 변경 * refactor: 사진 url 관련 dto 필드명 끝에 url 추가 * refactor: 기대한대로 작동하지 않는 ExceptionHandler 메서드 주석 처리 * refactor: 파일 이름 및 형식 오류 수정 #176 (#177) * refactor: 파일 이름을 UUID로만 구성하도록 수정 * refactor: content-type을 multipart/formed-data로 고정 * feat: swagger https 적용하기 #184 (#185) * feat: swagger가 https 접근 가능하도록 하는 기능 구현 * chore: pull_request에도 CD가 적용되도록 임시 변경 * style: push에만 CD가 적용되도록 롤백 * feat: 빈/공백 문자열 예외 처리 #186 (#187) * fix: 여행 제목은 공백 문자열 불가, 1자 이상 30자 이하로 설정할 수 있도록 예외 처리 * fix: 방문 기록의 이름은 공백 문자열 불가, 1자 이상 30자 이하로 설정할 수 있도록 예외 처리 * fix: 닉네임의 이름은 공백 문자열 불가, 1자 이상 20자 이하로 설정할 수 있도록 예외 처리 * test: displayName 변경 * fix: Swagger 인증 헤더 형식 변경 #188 (#189) * fix: Swagger 인증 헤더 수정 * refactor: 로깅 정보 수정 * chore: stage/dev 서버 분리 #192 (#197) * fix: 포트 수정 * refactor: 설정 파일 profile 별로 분리 * fix: timezone 설정 * refactor: ci-cd 파일명 변경 * refactor: ci-cd 분리 * test: 경계값 테스트로 수정 * test: 경계값 테스트로 수정 및 발생하는 오류 수정 * chore: back-end 개발용 CD 트리거 변경 * fix: 불필요한 파일 삭제 * refactor: stage용 환경 파일 분리 * refactor: DockerFile 분리 * refactor: 태그 설정 * refactor: 태그 설정 * fix: 태그 설정 * fix: 태그 설정 * fix: 태그 설정 * fix: 태그 설정 * fix: 태그 설정 * fix: 태그 설정 * fix: 태그 설정 * fix: 태그 설정 수정 * refactor: CI runs-on 변경 * refactor: dev용 CICD trigger 변경 * refactor: dev용 CICD runs on 변경 * refactor: dev용 CICD trigger 변경 * refactor: runner 재설치로 인해 임시로 변경했던 dev용 CICD runs-on & trigger 복구 * refactor: hub push 시 로그인 재수행 * fix: 명령어 오류 수정 * feat: 단체 계정으로 dockerhub 변경 * refactor: 정상 작동 확인 후 트리거 복구 * fix: image push 시 권한 오류 수정 (#200) * feat:admin용 계정 로직 추가 (#201) * fix: add stage logging (#204) * feat: 이미지 확장자와 content-type 설정 #196 (#202) * feat: content-type을 확장자로 분석하는 기능 구현 * chore: PR CD 임시 적용 * chore: PR CD 해제 * refactor: dev, stage, local 환경의 swagger url 설정 (#208) * refactor: 이미지 용량 제한 확장 (한 이미지: 20MB, 한 요청: 100MB) (#206) * fix: 이미지 전송 안되는 에러 수정 #209 (#210) * chore: PR CD 임시 적용 * fix: 파일 형식에 .추가 및 디폴트 형식 변경 * temp: 확인용 에러 * temp: 롤백 * refactor: 디폴트 mime type 변경 * temp: 일단 image의 내부 메서드 사용 * fix: file-extension 지정 롤백 * fix: content-type 지정 * temp: 에러 체크를 위해 메시지 임시 변경 * temp: 에러 메시지 롤백 * feat: content-type 확장자로부터 추출 * chore: PR CD 해제 * refactor: API명세 변경에 따른 URI, DTO 변수명 변경 #211 (#212) * refactor: URI, DTO 변수명 변경 * refactor: DTO 클래스명을 API명세에 맞게 변경 * refactor: imageFile 변수명 변경 * test: pathVariable명을 클래스명을 고려하여 변경 * refactor: 엔티티, 메서드명 API 명세에 맞게 변경 * refactor: 여행을 추억으로, 방문을 순간으로 네이밍 변경 * feat: 추억 목록 조회 API 수정 #215 (#216) * feat: startAt, endAt 필드 nullable하게 변경 * feat: memory의 createdAt 기준 최신순 정렬로 변경 * style: code convention 적용 * style: 응답 형식 변경 (mates 제거 및 기간 미필수 응답 필드로 변경) * feat: 올바르지 않은 년도 형식 예외 처리 * refactor: 추억 상세 -> 추억 * test: 메세지 오류 수정 * test: 저장 순서 오류 수정 * refactor: fixture 패키지 이동 * refactor: fixture 분리 * test: 경계값 검증으로 수정 * feat: 사용자 로깅, Nginx 로깅, DB 로깅 #190 (#224) * feat: MDC 적용 * refactor: 중복 예외 제거 * feat: 사용자 식별 로깅 추가 * refactor: 예외 메세지 형식 json으로 변경 * feat: 추억 삭제 API 수정 #221 (#222) * feat: 변경사항 docs 반영 * feat: 순간이 존재하는 경우 추억을 삭제할 수 없었던 예외 제거 * style: code convention 적용 * feat: 추억 삭제 시 속한 순간도 함께 삭제되도록 서비스 구현 * refactor: 불필요한 개행 제거 * feat: 추억 조회 API 수정 #227 (#228) * feat: 기간 필수 여부 변경에 따른 어노테이션 추가 * docs: 도메인명 변경에 따른 명세서 수정 * build: stage 서버 CICD 임시 비활성화 #234 (#235) * chore: stage 비활성화 적용 전 dev에서 시범 적용 * chore: dev 서버 cicd 비활성화 해제 * chore: dev 서버 cicd 트리거 복구 * chore: stage 서버 cicd 임시 비활성화 * feat: 댓글 생성, 조회 API 구현 #214 (#225) * refactor: 댓글과 관련된 클래스를 별도의 패키지로 분리 * test: tdd를 위한 댓글 생성 서비스 테스트 추가 * feat: 댓글 생성 서비스 메서드 구현 * test: 댓글 생성 관련 컨트롤러 테스트 코드 작성 * feat: 댓글 생성 기능 구현 * feat: 댓글 조회 서비스 메서드를 위한 tdd 틀 작성 * feat: 댓글 조회 서비스 메서드 구현 * test: 댓글 컨트롤러 테스트 클래스 패키지 위치 변경 * refactor: 댓글 읽기 메서드명을 더 명확하게 변경 * test: 댓글 읽기 테스트 코드 추가 * feat: 댓글 읽기 컨트롤러 메서드 구현 * feat: 댓글 생성, 조회 API에 swagger 적용 및 순간 기록을 순간으로 변경 * fix: Swagger 적용으로 인한 문제 해결 * test: 순간 기록이라는 말을 순간으로 변경 * refactor: 댓글의 글자수로 인한 예외 메시지에 '1자 이상'이라는 말을 제거 * fix: 댓글 생성 메서드에 Transactional 적용 * chore: stage 서버 CI/CD 활성화 * feat: 감정 선택 API 구현 #230 (#236) * feat: 기분 유형 생성 * feat: Moment 비즈니스 로직에 기분 표현 적용 * feat: 기분 표현 컨트롤러 구현 * feat: default 기분 생성 * style: 코드 컨벤션 적용 * refactor: 예외 메세지 변경 * feat: 순간 생성 API 구현 #226 (#229) * refactor: 기한이 없는 memory 구현 * test: 기한없는 Memory에 Moment 생성 테스트 * feat: Moment 생성 서비스 구현 * feat: Moment 생성 컨트롤러 구현 * refactor: builder 선택 필드 제외 * style: 잘못된 네이밍 수정 * refactor: MomentImages 생성 책임 Moment로 위임 * feat: 하나의 사진 업로드 API 생성 #256 (#258) * feat: api 이름 captures로 변경 * feat: RequestBody imageFiles로 이름 변경 * refactor: 변수명 iamge -> file로 통합 * refactor: requestparam -> requestpart로 변경 * feat: 다섯 장을 넘기지 않도록 예외 추가 * feat: 빈 배열을 받는 경우 로직을 수행하지 않도록 변경 * style: CamelCase 적용 * refactor: 에러 메시지 수정 * feat: 특정 content-type을 처리하도록 명시 * feat: validated 어노테이션 추가해서 유효성 검사 수행 * test: 사진 개수에 따른 성공/실패 테스트 수행 * test: 빈 멀티파일 리스트가 들어올 시, 빈 url 리스트가 들어오는 테스트 수행 * refactor: byte 처리에서 나는 오류를 StaccatoException으로 처리 * chore: dev 서버 PR CD 임시 적용 * refactor: API명 captures -> images로 변경 * chore: dev 서버 PR CD 해제 * fix: test에도 변경된 api명 적용 * feat: 파일을 한 장만 업로드하도록 변경 * feat: dto를 반환하는 새로운 메서드 생성 * test: 테스트 Disabled * refactor: CloudStorage -> Image로 이름 단순화 * feat: S3 객체 삭제 로직 삭제 * refactor: 미사용 import 삭제 * refactor: 전체 경로를 yml에서 지정 * refactor: getFileExtension 메서드 리팩터링 * feat: ImageUrlResponse 생성 * refactor: file을 전부 image로 변경 * refactor: S3Client를 S3ObjectClient로 변경 * refactor: S3Exception 로깅에 EXCEPTION_LOGGING_FORM 적용 * feat: 로그인한 사용자만 images API를 사용가능하게 함 * refactor: ImageExtension을 사용하는 Service 폴더로 이동 * feat: yml에서 설정한 파일 용량 제한 예외를 잡는 MultipartExceptionHandler 구현 * refactor: 충돌방지 이름변경 * refactor: @Size 사라지면서 Validated 삭제 * test: 컨트롤러 단위 테스트 수행 * refactor: 미사용 import문 삭제 * style: /images API swagger 적용 * refactor: file -> image * refactor: uploadImages -> uploadImage * refactor: 미사용 import문 삭제 * chore: PR CD를 수동으로 실행 가능하게 설정 * refactor: 기존 테스트 삭제 * fix: 반영되지 않은 수정사항 관련 테스트 disabled * chore: dev 서버 PR CD 임시 해제 * fix: 이미지 저장 폴더 재지정 * refactor: 테스트 메서드 네이밍 수정 * refactor: 폴더명 수정 * refactor: 닫는 괄호 추가 * refactor: S3ObjectClient를 infrastructure 패키지로 이동 * refactor: 컨벤션에 맞추어 줄바꿈 * refactor: infra 패키지를 image 패키지 내부로 이동 * feat: 추억 생성 API 수정 #238 (#260) * feat: multipartFile 제거 및 contentType을 application/json으로 변경 * refactor: term(startAt, endAt) 객체 분리 * feat: startAt, endAt 중 누락 예외 처리 * fix: 기간이 없을 경우 순간 날짜 포함 여부 예외 처리 오류 수정 * test: 기간 포함 날짜 검증 테스트 추가 * refactor: 가독성 있게 로직 수정 * docs: 요청 형식 설명 수정 * feat: 댓글 수정 API 구현 #245 (#254) * test: Fixture를 활용하도록 기존 테스트 변경 * feat: 댓글을 생성하는 기능 해피케이스 구현 * feat: 댓글을 찾을 수 없는 경우 예외 발생 테스트 * feat: 본인이 달지 않은 댓글에 대해 수정을 시도하면 예외 발생 기능 구현 * test: 조회 권한이 없는 순간에 달린 댓글들 조회를 시도했을 때 예외 발생 테스트 추가 * feat: 댓글 수정 컨트롤러 메서드 구현 * test: 양수가 아닌 댓글 식별자로 댓글 수정 시 예외 발생 테스트 * feat: 댓글 내용을 입력하지 않거나 빈 문자열로 입력 후 댓글 수정 시 예외처리 * refactor: 댓글 생성 시 최소 글자수 조건이 NotBlank에 의해 필요 없으므로 삭제 * refactor: 순서가 불필요하므로 GroupSequence 설정 제거 * feat: updateDTO에 Swagger 적용 * test: 실수로 빠뜨린 when & then 적용 * feat: 댓글 삭제 API 구현 #255 (#257) * test: Fixture를 활용하도록 기존 테스트 변경 * feat: 댓글을 생성하는 기능 해피케이스 구현 * feat: 댓글을 찾을 수 없는 경우 예외 발생 테스트 * feat: 본인이 달지 않은 댓글에 대해 수정을 시도하면 예외 발생 기능 구현 * test: 조회 권한이 없는 순간에 달린 댓글들 조회를 시도했을 때 예외 발생 테스트 추가 * feat: 댓글 수정 컨트롤러 메서드 구현 * test: 양수가 아닌 댓글 식별자로 댓글 수정 시 예외 발생 테스트 * feat: 댓글 내용을 입력하지 않거나 빈 문자열로 입력 후 댓글 수정 시 예외처리 * refactor: 댓글 생성 시 최소 글자수 조건이 NotBlank에 의해 필요 없으므로 삭제 * refactor: 순서가 불필요하므로 GroupSequence 설정 제거 * feat: updateDTO에 Swagger 적용 * feat: 댓글 삭제 API 해피케이스 구현 * feat: 본인이 쓴 댓글이 아닌데 삭제를 시도하면 예외 처리 기능 구현 * feat: 댓글 삭제 컨트롤러 메서드 구현 * test: 댓글 식별자가 양수가 아닐 경우 댓글 삭제 실패 테스트 * feat: 댓글 삭제 API에 Swagger 적용 * feat: 추억 수정 API 수정 #261 (#262) * feat: 이미지 컨트롤러 분리로 변경된 사항 반영 * refactor: 미사용 메서드 제거 * test: 인증 관련 테스트 추가 * docs: 명세서 누락 및 오류 수정 * test: aaa 주석 수정 * chore: dev 서버 push 트리거 제거 * feat: 순간 수정 API 구현 #244 (#248) * refactor: Moment 수정 서비스 로직 수정 * refactor: Moment 수정 컨트롤러 로직 수정 * refactor: 레거시 코드 변경 및 예외 메세지 변경 * docs: 누락 DTO 명세 추가 * docs: 명세 수정 * feat: 순간 삭제 API 구현 #243 (#250) * style: 네이밍 컨벤션 적용 * docs: 명세 수정 * feat: 순간 조회/목록 조회 API 구현 #251 (#253) * feat: 순간 조회/목록 조회 서비스 로직 구현 * feat: 순간 조회/목록 조회 컨트롤러 로직 구현 * test: 메서드 쿼리 검증 테스트 추가 * refactor: 클래스 명 수정 * test: 불필요한 테스트 데이터 삭제 * feat: image upload 예외 처리 추가 #268 (#269) * feat: MissingServletRequestPartException 에러 핸들링 * chore: dev 서버 PR CD 임시 해제 * chore: dev 서버 push cd 삭제 * refactor: 같은 메시지 주는 예외 동일한 exceptionHandler로 묶기 * refactor: 예외 핸들러를 다시 분리 * refactor: 에러 메시지 적절하게 변경 * feat: 서버 별로 이미지 저장 경로 설정 (#272) * refactor: S3 로직 리팩터링 #274 (#275) * refactor: 미사용 메서드 삭제 * refactor: 미사용 import 삭제 * refactor: 명세 변경에 따른 swagger 메시지 변경 * refactor: 요청 크기 제한 100->20으로 변경 * refactor: 메서드 순서 변경 * refactor: 개행 삭제 * chore: 운영 서버 구축 #264 (#270) * chore: prod 서버 환경설정 * feat: prod 환경 로깅 설정 * chore: prod 환경 테스트를 위한 CD 트리거 변경 * fix: env 파일 경로 수정 * chore: 로그 파일 저장 위치 지정 * chore: 로그 폴더 생성 명령 삭제 * chore: 로그 생성 위치 변경 * chore: 도커 이미지 재실행 코드 추가 * chore: 도커 이미지 재실행 코드 수정 * chore: 로그 콘솔 출력 * chore: 로그 저장 위치 수정 * feat: 운영 환경에서 어드민 로직 비활성화 * refactor: main에 push시에만 prod cd trigger 실행하도록 workflow 변경 --------- Co-authored-by: yoonjuho * fix: 닉네임 앞뒤 공백 제거 #277 (#278) * fix: 닉네임 앞뒤 공백 제거 * fix: 닉네임 요청 형식에서 앞뒤 공백 제거 NPE 해결 * fix: 순간 조회 응답 형식 수정 (#276) * fix: 순간 조회 응답 형식 수정 * fix: 순간 목록 응답 인자 명 수정 * feat: 추억 이름 중복 불가 예외 처리 #280 (#282) * feat: 추억 제목 중복 검사 구현 * docs: 예외 발생 케이스 문서화 * test: 픽스처 활용 * feat: 추억 수정 시 이미 존재하는 타 추억 이름으로 변경 불가능 예외 처리 * test: 주석 오타 수정 * fix: 순간 날짜 반환 형식 변경 #283 (#286) * fix: 순간 날짜 응답 형식 수정 * refactor: 메서드 분리 로직 삭제 * fix: 날짜 ms 제거 * feat: 현재 날짜를 포함하고 있는 추억 목록 조회 구현 #281 (#285) * feat: 특정 날짜를 포함하는 모든 추억 조회 기능 구현 * feat: 특정 날짜를 포함하는 모든 추억 조회 기능 구현 * test: 메시지 변경으로 인한 테스트코드 수정 * feat: 날짜로 추억 목록 조회 컨트롤러 분리 * style: 미사용 import 제거 * feat: 날짜를 포함하는 모든 추억을 조회시 기간이 없는 추억도 함께 조회 * refactor: 순간 수정 이미지 순서 적용 #287 (#288) * refactor: 순간 수정 이미지 순서 적용 * test: 순간 수정 이미지 순서 검증 테스트 추가 * refactor: 순간 수정 이미지 순서 중복 로직 삭제 * refactor: 사용되지 않는 메서드 삭제 * fix: 순간 조회 응답 필드 추가 #292 (#293) * fix: 순간 응답 필드에 추억 관련 필드 추가 * test: 픽스쳐 사용 * refactor: 예외 메시지 수정 #294 (#298) * refactor: 예외 메시지의 순간을 스타카토로 변경 Co-authored-by: devhoya97 * refactor: 예외 메시지 수정 Co-authored-by: devhoya97 * refactor: 순간 -> 스타카토 Co-authored-by: devhoya97 * docs: 문서 수정 Co-authored-by: devhoya97 --------- Co-authored-by: devhoya97 * chore: 운영 서버에서 명세서 비활성화 #302 (#303) * feat : 스타카토 제목, 추억 제목에 trim 적용 #305 (#307) * refactor: 예외 메시지의 순간을 스타카토로 변경 Co-authored-by: devhoya97 * refactor: 예외 메시지 수정 Co-authored-by: devhoya97 * refactor: 순간 -> 스타카토 Co-authored-by: devhoya97 * refactor: 추억 생성 시 title에 trim 적용 * refactor: 스타카토 생성 시 placeName에 trim 적용 * fix: dto에서 size 검증 시 min 조건 제거 --------- Co-authored-by: linirini <2001yerin@naver.com> * chore: ci에 jacoco 추가 (#309) * chore: ci에 jacoco 추가 * chore: ci에 jacoco 위한 권한 변경 * chore: ci에 jacoco 위한 권한 변경 * chore: test report 경로 오류 수정 * build: jacoco 빌드 설정 * build: jacoco 대상에서 builder 제외 * build: jacoco 제한 제거 * build: jacocoCoverageVerification 제거 * chore: 단위 테스트 결과 가져오기 적용 * build: CI/CD 트리거 수정 --------- Co-authored-by: devhoya97 Co-authored-by: somin Co-authored-by: BurningFalls Co-authored-by: linirini <2001yerin@naver.com> Co-authored-by: linirini <101927543+linirini@users.noreply.github.com> Co-authored-by: devhoya97 <146502065+devhoya97@users.noreply.github.com> --- .github/workflows/backend-ci-cd-dev.yml | 63 ++++ .github/workflows/backend-ci-cd-prod.yml | 70 ++++ .github/workflows/backend-ci-cd-stage.yml | 63 ++++ .github/workflows/backend-ci.yml | 51 +++ backend/.gitignore | 7 + backend/.idea/.gitignore | 8 + backend/Dockerfile.dev | 5 + backend/Dockerfile.prod | 6 + backend/Dockerfile.stage | 5 + backend/build.gradle | 78 ++++ backend/docker-compose.yml | 35 ++ backend/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/gradlew | 249 ++++++++++++ backend/gradlew.bat | 92 +++++ backend/settings.gradle | 1 + .../com/staccato/StaccatoApplication.java | 13 + .../auth/controller/AuthController.java | 26 ++ .../auth/controller/AuthControllerDocs.java | 34 ++ .../staccato/auth/service/AuthService.java | 66 ++++ .../service/dto/request/LoginRequest.java | 30 ++ .../service/dto/response/LoginResponse.java | 10 + .../comment/controller/CommentController.java | 73 ++++ .../docs/CommentControllerDocs.java | 81 ++++ .../com/staccato/comment/domain/Comment.java | 63 ++++ .../comment/repository/CommentRepository.java | 11 + .../comment/service/CommentService.java | 82 ++++ .../service/dto/request/CommentRequest.java | 32 ++ .../dto/request/CommentUpdateRequest.java | 15 + .../service/dto/response/CommentResponse.java | 32 ++ .../dto/response/CommentResponses.java | 15 + .../staccato/config/JpaAuditingConfig.java | 9 + .../com/staccato/config/OpenApiConfig.java | 38 ++ .../com/staccato/config/WebMvcConfig.java | 22 ++ .../staccato/config/auth/AdminProperties.java | 8 + .../com/staccato/config/auth/LoginMember.java | 11 + .../auth/LoginMemberArgumentResolver.java | 33 ++ .../staccato/config/auth/TokenProperties.java | 7 + .../staccato/config/auth/TokenProvider.java | 45 +++ .../staccato/config/domain/BaseEntity.java | 22 ++ .../java/com/staccato/config/log/LogForm.java | 33 ++ .../staccato/config/log/LoggingFilter.java | 58 +++ .../staccato/exception/ExceptionResponse.java | 10 + .../exception/ForbiddenException.java | 19 + .../exception/GlobalExceptionHandler.java | 124 ++++++ .../staccato/exception/StaccatoException.java | 19 + .../exception/UnauthorizedException.java | 19 + .../exception/validation/ValidationSteps.java | 15 + .../image/controller/ImageController.java | 35 ++ .../controller/docs/ImageControllerDocs.java | 35 ++ .../staccato/image/domain/ImageExtension.java | 26 ++ .../image/infrastructure/S3ObjectClient.java | 54 +++ .../staccato/image/service/ImageService.java | 54 +++ .../image/service/dto/ImageUrlResponse.java | 10 + .../com/staccato/member/domain/Member.java | 45 +++ .../com/staccato/member/domain/Nickname.java | 36 ++ .../member/repository/MemberRepository.java | 14 + .../service/dto/response/MemberResponse.java | 21 ++ .../memory/controller/MemoryController.java | 88 +++++ .../controller/docs/MemoryControllerDocs.java | 115 ++++++ .../com/staccato/memory/domain/Memory.java | 102 +++++ .../staccato/memory/domain/MemoryMember.java | 46 +++ .../java/com/staccato/memory/domain/Term.java | 59 +++ .../repository/MemoryMemberRepository.java | 22 ++ .../memory/repository/MemoryRepository.java | 9 + .../memory/service/MemoryService.java | 119 ++++++ .../service/dto/request/MemoryRequest.java | 47 +++ .../dto/response/MemoryDetailResponse.java | 49 +++ .../dto/response/MemoryIdResponse.java | 10 + .../dto/response/MemoryNameResponse.java | 17 + .../dto/response/MemoryNameResponses.java | 18 + .../service/dto/response/MemoryResponse.java | 35 ++ .../service/dto/response/MemoryResponses.java | 18 + .../service/dto/response/MomentResponse.java | 25 ++ .../moment/controller/MomentController.java | 91 +++++ .../controller/docs/MomentControllerDocs.java | 117 ++++++ ...MultipartJackson2HttpMessageConverter.java | 31 ++ .../com/staccato/moment/domain/Feeling.java | 31 ++ .../com/staccato/moment/domain/Moment.java | 103 +++++ .../staccato/moment/domain/MomentImage.java | 39 ++ .../staccato/moment/domain/MomentImages.java | 56 +++ .../java/com/staccato/moment/domain/Spot.java | 23 ++ .../repository/MomentImageRepository.java | 11 + .../moment/repository/MomentRepository.java | 16 + .../moment/service/MomentService.java | 96 +++++ .../service/dto/request/FeelingRequest.java | 14 + .../service/dto/request/MomentRequest.java | 67 ++++ .../dto/request/MomentUpdateRequest.java | 26 ++ .../dto/response/MomentDetailResponse.java | 46 +++ .../dto/response/MomentIdResponse.java | 10 + .../dto/response/MomentLocationResponse.java | 21 ++ .../dto/response/MomentLocationResponses.java | 9 + .../src/main/resources/application-dev.yml | 52 +++ .../src/main/resources/application-local.yml | 50 +++ .../src/main/resources/application-prod.yml | 54 +++ .../src/main/resources/application-stage.yml | 52 +++ backend/src/main/resources/application.yml | 4 + .../src/main/resources/console-appender.xml | 7 + backend/src/main/resources/error-appender.xml | 20 + backend/src/main/resources/info-appender.xml | 20 + backend/src/main/resources/logback-spring.xml | 57 +++ backend/src/main/resources/warn-appender.xml | 20 + .../java/com/staccato/IntegrationTest.java | 22 ++ .../java/com/staccato/ServiceSliceTest.java | 13 + .../staccato/StaccatoApplicationTests.java | 13 + .../test/java/com/staccato/TestConfig.java | 15 + .../auth/controller/AuthControllerTest.java | 77 ++++ .../auth/service/AuthServiceTest.java | 68 ++++ .../controller/CommentControllerTest.java | 249 ++++++++++++ .../comment/service/CommentServiceTest.java | 209 ++++++++++ .../config/auth/TokenProviderTest.java | 97 +++++ .../fixture/Member/MemberFixture.java | 13 + .../fixture/memory/MemoryFixture.java | 51 +++ .../memory/MemoryNameResponsesFixture.java | 14 + .../fixture/memory/MemoryRequestFixture.java | 37 ++ .../memory/MemoryResponsesFixture.java | 18 + .../fixture/moment/CommentFixture.java | 15 + .../moment/MomentDetailResponseFixture.java | 21 ++ .../fixture/moment/MomentFixture.java | 50 +++ .../MomentLocationResponsesFixture.java | 16 + .../image/controller/ImageControllerTest.java | 54 +++ .../infrastructure/FakeS3ObjectClient.java | 16 + .../staccato/member/domain/NicknameTest.java | 36 ++ .../controller/MemoryControllerTest.java | 269 +++++++++++++ .../staccato/memory/domain/MemoryTest.java | 59 +++ .../com/staccato/memory/domain/TermTest.java | 69 ++++ .../MemoryMemberRepositoryTest.java | 62 +++ .../memory/service/MemoryServiceTest.java | 317 ++++++++++++++++ .../dto/request/MemoryRequestTest.java | 30 ++ .../controller/MomentControllerTest.java | 356 ++++++++++++++++++ .../staccato/moment/domain/FeelingTest.java | 26 ++ .../moment/domain/MomentImagesTest.java | 56 +++ .../staccato/moment/domain/MomentTest.java | 111 ++++++ .../repository/MomentRepositoryTest.java | 67 ++++ .../moment/service/MomentServiceTest.java | 307 +++++++++++++++ .../dto/request/MomentRequestTest.java | 34 ++ .../com/staccato/util/DatabaseCleaner.java | 49 +++ .../util/DatabaseCleanerExtension.java | 13 + 138 files changed, 6996 insertions(+) create mode 100644 .github/workflows/backend-ci-cd-dev.yml create mode 100644 .github/workflows/backend-ci-cd-prod.yml create mode 100644 .github/workflows/backend-ci-cd-stage.yml create mode 100644 .github/workflows/backend-ci.yml create mode 100644 backend/.gitignore create mode 100644 backend/.idea/.gitignore create mode 100644 backend/Dockerfile.dev create mode 100644 backend/Dockerfile.prod create mode 100644 backend/Dockerfile.stage create mode 100644 backend/build.gradle create mode 100644 backend/docker-compose.yml create mode 100644 backend/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/gradlew create mode 100644 backend/gradlew.bat create mode 100644 backend/settings.gradle create mode 100644 backend/src/main/java/com/staccato/StaccatoApplication.java create mode 100644 backend/src/main/java/com/staccato/auth/controller/AuthController.java create mode 100644 backend/src/main/java/com/staccato/auth/controller/AuthControllerDocs.java create mode 100644 backend/src/main/java/com/staccato/auth/service/AuthService.java create mode 100644 backend/src/main/java/com/staccato/auth/service/dto/request/LoginRequest.java create mode 100644 backend/src/main/java/com/staccato/auth/service/dto/response/LoginResponse.java create mode 100644 backend/src/main/java/com/staccato/comment/controller/CommentController.java create mode 100644 backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java create mode 100644 backend/src/main/java/com/staccato/comment/domain/Comment.java create mode 100644 backend/src/main/java/com/staccato/comment/repository/CommentRepository.java create mode 100644 backend/src/main/java/com/staccato/comment/service/CommentService.java create mode 100644 backend/src/main/java/com/staccato/comment/service/dto/request/CommentRequest.java create mode 100644 backend/src/main/java/com/staccato/comment/service/dto/request/CommentUpdateRequest.java create mode 100644 backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponse.java create mode 100644 backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponses.java create mode 100644 backend/src/main/java/com/staccato/config/JpaAuditingConfig.java create mode 100644 backend/src/main/java/com/staccato/config/OpenApiConfig.java create mode 100644 backend/src/main/java/com/staccato/config/WebMvcConfig.java create mode 100644 backend/src/main/java/com/staccato/config/auth/AdminProperties.java create mode 100644 backend/src/main/java/com/staccato/config/auth/LoginMember.java create mode 100644 backend/src/main/java/com/staccato/config/auth/LoginMemberArgumentResolver.java create mode 100644 backend/src/main/java/com/staccato/config/auth/TokenProperties.java create mode 100644 backend/src/main/java/com/staccato/config/auth/TokenProvider.java create mode 100644 backend/src/main/java/com/staccato/config/domain/BaseEntity.java create mode 100644 backend/src/main/java/com/staccato/config/log/LogForm.java create mode 100644 backend/src/main/java/com/staccato/config/log/LoggingFilter.java create mode 100644 backend/src/main/java/com/staccato/exception/ExceptionResponse.java create mode 100644 backend/src/main/java/com/staccato/exception/ForbiddenException.java create mode 100644 backend/src/main/java/com/staccato/exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/staccato/exception/StaccatoException.java create mode 100644 backend/src/main/java/com/staccato/exception/UnauthorizedException.java create mode 100644 backend/src/main/java/com/staccato/exception/validation/ValidationSteps.java create mode 100644 backend/src/main/java/com/staccato/image/controller/ImageController.java create mode 100644 backend/src/main/java/com/staccato/image/controller/docs/ImageControllerDocs.java create mode 100644 backend/src/main/java/com/staccato/image/domain/ImageExtension.java create mode 100644 backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java create mode 100644 backend/src/main/java/com/staccato/image/service/ImageService.java create mode 100644 backend/src/main/java/com/staccato/image/service/dto/ImageUrlResponse.java create mode 100644 backend/src/main/java/com/staccato/member/domain/Member.java create mode 100644 backend/src/main/java/com/staccato/member/domain/Nickname.java create mode 100644 backend/src/main/java/com/staccato/member/repository/MemberRepository.java create mode 100644 backend/src/main/java/com/staccato/member/service/dto/response/MemberResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/controller/MemoryController.java create mode 100644 backend/src/main/java/com/staccato/memory/controller/docs/MemoryControllerDocs.java create mode 100644 backend/src/main/java/com/staccato/memory/domain/Memory.java create mode 100644 backend/src/main/java/com/staccato/memory/domain/MemoryMember.java create mode 100644 backend/src/main/java/com/staccato/memory/domain/Term.java create mode 100644 backend/src/main/java/com/staccato/memory/repository/MemoryMemberRepository.java create mode 100644 backend/src/main/java/com/staccato/memory/repository/MemoryRepository.java create mode 100644 backend/src/main/java/com/staccato/memory/service/MemoryService.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/request/MemoryRequest.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/MemoryDetailResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/MemoryIdResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponses.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponses.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/MomentResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/controller/MomentController.java create mode 100644 backend/src/main/java/com/staccato/moment/controller/docs/MomentControllerDocs.java create mode 100644 backend/src/main/java/com/staccato/moment/controller/docs/MultipartJackson2HttpMessageConverter.java create mode 100644 backend/src/main/java/com/staccato/moment/domain/Feeling.java create mode 100644 backend/src/main/java/com/staccato/moment/domain/Moment.java create mode 100644 backend/src/main/java/com/staccato/moment/domain/MomentImage.java create mode 100644 backend/src/main/java/com/staccato/moment/domain/MomentImages.java create mode 100644 backend/src/main/java/com/staccato/moment/domain/Spot.java create mode 100644 backend/src/main/java/com/staccato/moment/repository/MomentImageRepository.java create mode 100644 backend/src/main/java/com/staccato/moment/repository/MomentRepository.java create mode 100644 backend/src/main/java/com/staccato/moment/service/MomentService.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/request/FeelingRequest.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/request/MomentRequest.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/request/MomentUpdateRequest.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/MomentDetailResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/MomentIdResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponses.java create mode 100644 backend/src/main/resources/application-dev.yml create mode 100644 backend/src/main/resources/application-local.yml create mode 100644 backend/src/main/resources/application-prod.yml create mode 100644 backend/src/main/resources/application-stage.yml create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/main/resources/console-appender.xml create mode 100644 backend/src/main/resources/error-appender.xml create mode 100644 backend/src/main/resources/info-appender.xml create mode 100644 backend/src/main/resources/logback-spring.xml create mode 100644 backend/src/main/resources/warn-appender.xml create mode 100644 backend/src/test/java/com/staccato/IntegrationTest.java create mode 100644 backend/src/test/java/com/staccato/ServiceSliceTest.java create mode 100644 backend/src/test/java/com/staccato/StaccatoApplicationTests.java create mode 100644 backend/src/test/java/com/staccato/TestConfig.java create mode 100644 backend/src/test/java/com/staccato/auth/controller/AuthControllerTest.java create mode 100644 backend/src/test/java/com/staccato/auth/service/AuthServiceTest.java create mode 100644 backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java create mode 100644 backend/src/test/java/com/staccato/comment/service/CommentServiceTest.java create mode 100644 backend/src/test/java/com/staccato/config/auth/TokenProviderTest.java create mode 100644 backend/src/test/java/com/staccato/fixture/Member/MemberFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/memory/MemoryFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/memory/MemoryNameResponsesFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/memory/MemoryRequestFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/memory/MemoryResponsesFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/moment/CommentFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/moment/MomentDetailResponseFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/moment/MomentFixture.java create mode 100644 backend/src/test/java/com/staccato/fixture/moment/MomentLocationResponsesFixture.java create mode 100644 backend/src/test/java/com/staccato/image/controller/ImageControllerTest.java create mode 100644 backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java create mode 100644 backend/src/test/java/com/staccato/member/domain/NicknameTest.java create mode 100644 backend/src/test/java/com/staccato/memory/controller/MemoryControllerTest.java create mode 100644 backend/src/test/java/com/staccato/memory/domain/MemoryTest.java create mode 100644 backend/src/test/java/com/staccato/memory/domain/TermTest.java create mode 100644 backend/src/test/java/com/staccato/memory/repository/MemoryMemberRepositoryTest.java create mode 100644 backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java create mode 100644 backend/src/test/java/com/staccato/memory/service/dto/request/MemoryRequestTest.java create mode 100644 backend/src/test/java/com/staccato/moment/controller/MomentControllerTest.java create mode 100644 backend/src/test/java/com/staccato/moment/domain/FeelingTest.java create mode 100644 backend/src/test/java/com/staccato/moment/domain/MomentImagesTest.java create mode 100644 backend/src/test/java/com/staccato/moment/domain/MomentTest.java create mode 100644 backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java create mode 100644 backend/src/test/java/com/staccato/moment/service/MomentServiceTest.java create mode 100644 backend/src/test/java/com/staccato/moment/service/dto/request/MomentRequestTest.java create mode 100644 backend/src/test/java/com/staccato/util/DatabaseCleaner.java create mode 100644 backend/src/test/java/com/staccato/util/DatabaseCleanerExtension.java diff --git a/.github/workflows/backend-ci-cd-dev.yml b/.github/workflows/backend-ci-cd-dev.yml new file mode 100644 index 000000000..a8fa76037 --- /dev/null +++ b/.github/workflows/backend-ci-cd-dev.yml @@ -0,0 +1,63 @@ +name: Backend CI/CD dev + +on: + pull_request: + branches: [ "develop-be" ] + +jobs: + ci: + runs-on: [self-hosted, dev] + + defaults: + run: + shell: bash + working-directory: ./backend + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Login to Docker Hub + uses: docker/login-action@v3.3.0 + with: + username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + + - name: Docker Image Build + run: | + sudo docker build --platform linux/arm64 -t staccato/staccato:dev -f Dockerfile.dev . + + - name: Docker Hub Push + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker push staccato/staccato:dev + + cd: + needs: ci + runs-on: [self-hosted, dev] + steps: + - name: Pull Docker image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker pull staccato/staccato:dev + + - name: Docker Compose up + run: | + sudo docker compose -f /home/ubuntu/staccato/docker-compose.yml up -d + sudo docker image prune -af diff --git a/.github/workflows/backend-ci-cd-prod.yml b/.github/workflows/backend-ci-cd-prod.yml new file mode 100644 index 000000000..b151e9bb2 --- /dev/null +++ b/.github/workflows/backend-ci-cd-prod.yml @@ -0,0 +1,70 @@ +name: Backend CI/CD prod + +on: + push: + branches: [ "main", "develop" ] +jobs: + ci: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: ./backend + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Login to Docker Hub + uses: docker/login-action@v3.3.0 + with: + username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + + - name: Docker Image Build + run: | + sudo docker build --platform linux/arm64 -t staccato/staccato:prod -f Dockerfile.prod . + + - name: Docker Hub Push + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker push staccato/staccato:prod + + cd: + needs: ci + runs-on: [self-hosted, prod] + steps: + - name: Pull Docker image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker pull staccato/staccato:prod + + - name: Stop and remove existing container + run: | + sudo docker stop staccato-backend-app || true + sudo docker rm staccato-backend-app || true + + - name: Docker run + run: | + sudo docker run --env-file /home/ubuntu/staccato/.env \ + -v /home/ubuntu/staccato/logs:/logs \ + -p 8080:8080 \ + -d --name staccato-backend-app staccato/staccato:prod + sudo docker image prune -af diff --git a/.github/workflows/backend-ci-cd-stage.yml b/.github/workflows/backend-ci-cd-stage.yml new file mode 100644 index 000000000..12e134329 --- /dev/null +++ b/.github/workflows/backend-ci-cd-stage.yml @@ -0,0 +1,63 @@ +name: Backend CI/CD stage + +on: + push: + branches: [ "develop-be" ] + +jobs: + ci: + runs-on: [self-hosted, stage] + + defaults: + run: + shell: bash + working-directory: ./backend + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Login to Docker Hub + uses: docker/login-action@v3.3.0 + with: + username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + + - name: Docker Image Build + run: | + sudo docker build --platform linux/arm64 -t staccato/staccato:stage -f Dockerfile.stage . + + - name: Docker Hub Push + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker push staccato/staccato:stage + + cd: + needs: ci + runs-on: [self-hosted, stage] + steps: + - name: Pull Docker image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker pull staccato/staccato:stage + + - name: Docker Compose up + run: | + sudo docker-compose -f /home/ubuntu/staccato/docker-compose.yml up -d + sudo docker image prune -af diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 000000000..ebe7e0afe --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,51 @@ +name: Backend CI + +on: + pull_request: + branches: [ "develop-be" ] + +permissions: write-all + +jobs: + build: + + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: ./backend + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Test with Gradle + run: ./gradlew build + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: ${{ github.workspace }}/backend/build/test-results/**/*.xml + + - name: Jacoco Report to PR + id: jacoco + uses: madrapps/jacoco-report@v1.6.1 + with: + paths: ${{ github.workspace }}/backend/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 70 + min-coverage-changed-files: 70 + title: "🌻Test Coverage Report" + update-comment: true diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..a521d28f4 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +.env + +.idea + +.gradle + +mysql diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/backend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 000000000..35d757d26 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,5 @@ +FROM openjdk:17 +EXPOSE 8080 +ARG JAR_FILE=./build/libs/*-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar","-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=dev","app.jar"] diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 000000000..dad47861b --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,6 @@ +FROM openjdk:17 +EXPOSE 8080 +ARG JAR_FILE=./build/libs/*-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar + +ENTRYPOINT ["java", "-jar","-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=prod","app.jar"] diff --git a/backend/Dockerfile.stage b/backend/Dockerfile.stage new file mode 100644 index 000000000..6f195c1e4 --- /dev/null +++ b/backend/Dockerfile.stage @@ -0,0 +1,5 @@ +FROM openjdk:17 +EXPOSE 8080 +ARG JAR_FILE=./build/libs/*-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar","-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=stage","app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 000000000..606ec4c3d --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,78 @@ +plugins { + id 'java' + id 'jacoco' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' +} + +group = 'com' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +jacoco { + toolVersion '0.8.8' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'software.amazon.awssdk:s3:2.26.21' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + testImplementation 'io.rest-assured:rest-assured:5.3.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec")) + + reports { + html.required.set(true) + xml.required.set(true) + csv.required.set(false) + } +} + +/*jacocoTestCoverageVerification { + violationRules { + rule { + element 'CLASS' + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.7 + } + + excludes = ['*.config.*','*.*Builder.*'] + } + } +}*/ diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 000000000..af3f73bdf --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3.8" +services: + database: + container_name: staccato-database + image: mysql:8.0.30 + environment: + - MYSQL_DATABASE=staccato + - MYSQL_USER=${MYSQL_USER} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + volumes: + - ./mysql:/var/lib/mysql + ports: + - "3306:3306" + restart: always + networks: + - springboot-mysql-network + application: + container_name: staccato-backend-app + image: ${STACCATO_IMAGE} + depends_on: + - database + environment: + - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} + ports: + - "8080:8080" + restart: always + networks: + - springboot-mysql-network + +networks: + springboot-mysql-network: + driver: bridge diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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
NnX)EH+Nua)3Y(c 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +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=SC2039,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=SC2039,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 + + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +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/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 000000000..25da30dbd --- /dev/null +++ b/backend/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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +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/settings.gradle b/backend/settings.gradle new file mode 100644 index 000000000..acf487e12 --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'staccato' diff --git a/backend/src/main/java/com/staccato/StaccatoApplication.java b/backend/src/main/java/com/staccato/StaccatoApplication.java new file mode 100644 index 000000000..a2fea7d56 --- /dev/null +++ b/backend/src/main/java/com/staccato/StaccatoApplication.java @@ -0,0 +1,13 @@ +package com.staccato; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class StaccatoApplication { + + public static void main(String[] args) { + SpringApplication.run(StaccatoApplication.class, args); + } + +} diff --git a/backend/src/main/java/com/staccato/auth/controller/AuthController.java b/backend/src/main/java/com/staccato/auth/controller/AuthController.java new file mode 100644 index 000000000..cc8df62a1 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/controller/AuthController.java @@ -0,0 +1,26 @@ +package com.staccato.auth.controller; + +import jakarta.validation.Valid; + +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.RestController; + +import com.staccato.auth.service.AuthService; +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest) { + LoginResponse loginResponse = authService.login(loginRequest); + return ResponseEntity.ok(loginResponse); + } +} diff --git a/backend/src/main/java/com/staccato/auth/controller/AuthControllerDocs.java b/backend/src/main/java/com/staccato/auth/controller/AuthControllerDocs.java new file mode 100644 index 000000000..6b2f2aaf8 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/controller/AuthControllerDocs.java @@ -0,0 +1,34 @@ +package com.staccato.auth.controller; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; + +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Authorization", description = "Authorization API") +public interface AuthControllerDocs { + @Operation(summary = "등록 및 로그인", description = "애플리케이션을 최초 실행할 때 한 번만 닉네임 입력을 받고, 식별 코드를 발급합니다.") + @ApiResponses(value = { + @ApiResponse(description = "등록 및 로그인 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 이미 존재하는 닉네임일 때 + + (2) 닉네임의 형식이 잘못되었을 때 (한글, 영어, 마침표(.), 언더바(_)만 사용 가능) + + (3) 닉네임이 20자를 초과하였을 때 + + (4) 닉네임을 입력하지 않았을 때 + """, + responseCode = "400") + }) + ResponseEntity login(@Valid LoginRequest loginRequest); +} diff --git a/backend/src/main/java/com/staccato/auth/service/AuthService.java b/backend/src/main/java/com/staccato/auth/service/AuthService.java new file mode 100644 index 000000000..cea3eb95a --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/service/AuthService.java @@ -0,0 +1,66 @@ +package com.staccato.auth.service; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; +import com.staccato.config.auth.AdminProperties; +import com.staccato.config.auth.TokenProvider; +import com.staccato.config.log.LogForm; +import com.staccato.exception.StaccatoException; +import com.staccato.exception.UnauthorizedException; +import com.staccato.member.domain.Member; +import com.staccato.member.domain.Nickname; +import com.staccato.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@EnableConfigurationProperties(AdminProperties.class) +public class AuthService { + private final MemberRepository memberRepository; + private final TokenProvider tokenProvider; + private final AdminProperties adminProperties; + private final Environment environment; + + @Transactional + public LoginResponse login(LoginRequest loginRequest) { + if (isNotProdProfile() && adminProperties.key().equals(loginRequest.nickname())) { + return new LoginResponse(adminProperties.token()); + } + Member member = createMember(loginRequest); + String token = tokenProvider.create(member); + return new LoginResponse(token); + } + + private boolean isNotProdProfile() { + return !environment.acceptsProfiles(Profiles.of("prod")); + } + + private Member createMember(LoginRequest loginRequest) { + Member member = loginRequest.toMember(); + validateNickname(member.getNickname()); + return memberRepository.save(member); + } + + private void validateNickname(Nickname nickname) { + if (memberRepository.existsByNickname(nickname)) { + throw new StaccatoException("이미 존재하는 닉네임입니다. 다시 설정해주세요."); + } + } + + public Member extractFromToken(String token) { + Member member = memberRepository.findById(tokenProvider.extractMemberId(token)) + .orElseThrow(UnauthorizedException::new); + log.info(LogForm.LOGIN_MEMBER_FORM, member.getId(), member.getNickname().getNickname()); + return member; + } +} diff --git a/backend/src/main/java/com/staccato/auth/service/dto/request/LoginRequest.java b/backend/src/main/java/com/staccato/auth/service/dto/request/LoginRequest.java new file mode 100644 index 000000000..8402bce00 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/service/dto/request/LoginRequest.java @@ -0,0 +1,30 @@ +package com.staccato.auth.service.dto.request; + +import java.util.Objects; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "회원을 등록하기 위한 요청 형식입니다.") +public record LoginRequest( + @Schema(example = "hi_staccato") + @NotBlank(message = "닉네임을 입력해주세요.") + @Size(min = 1, max = 20, message = "1자 이상 20자 이하의 닉네임으로 설정해주세요.") + String nickname +) { + public LoginRequest { + if(!Objects.isNull(nickname)){ + nickname = nickname.trim(); + } + } + + public Member toMember() { + return Member.builder() + .nickname(nickname) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/auth/service/dto/response/LoginResponse.java b/backend/src/main/java/com/staccato/auth/service/dto/response/LoginResponse.java new file mode 100644 index 000000000..07b6097e7 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/service/dto/response/LoginResponse.java @@ -0,0 +1,10 @@ +package com.staccato.auth.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "회원 등록 시 발급되는 토큰에 대한 응답 형식입니다.") +public record LoginResponse( + @Schema(example = "{tokenString}") + String token +) { +} diff --git a/backend/src/main/java/com/staccato/comment/controller/CommentController.java b/backend/src/main/java/com/staccato/comment/controller/CommentController.java new file mode 100644 index 000000000..af50a13e5 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/controller/CommentController.java @@ -0,0 +1,73 @@ +package com.staccato.comment.controller; + +import java.net.URI; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.comment.controller.docs.CommentControllerDocs; +import com.staccato.comment.service.CommentService; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.config.auth.LoginMember; +import com.staccato.member.domain.Member; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/comments") +@RequiredArgsConstructor +@Validated +public class CommentController implements CommentControllerDocs { + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment( + @LoginMember Member member, + @Valid @RequestBody CommentRequest commentRequest + ) { + long commentId = commentService.createComment(commentRequest, member); + return ResponseEntity.created(URI.create("/comments/" + commentId)) + .build(); + } + + @GetMapping + public ResponseEntity readCommentsByMomentId( + @LoginMember Member member, + @RequestParam @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId + ) { + CommentResponses commentResponses = commentService.readAllCommentsByMomentId(member, momentId); + return ResponseEntity.ok().body(commentResponses); + } + + @PutMapping + public ResponseEntity updateComment( + @LoginMember Member member, + @RequestParam @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @Valid @RequestBody CommentUpdateRequest commentUpdateRequest + ) { + commentService.updateComment(member, commentId, commentUpdateRequest); + return ResponseEntity.ok().build(); + } + + @DeleteMapping + public ResponseEntity deleteComment( + @RequestParam @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @LoginMember Member member + ) { + commentService.deleteComment(commentId, member); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java b/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java new file mode 100644 index 000000000..3aa7bf733 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java @@ -0,0 +1,81 @@ +package com.staccato.comment.controller.docs; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; + +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Comment", description = "Comment API") +public interface CommentControllerDocs { + @Operation(summary = "댓글 생성", description = "댓글을 생성합니다.") + @ApiResponses(value = { + @ApiResponse(description = "댓글 생성 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 스타카토 식별자가 양수가 아닐 때 + + (2) 스타카토를 선택하지 않았을 때 + + (3) 요청한 스타카토를 찾을 수 없을 때 + + (4) 댓글 내용이 공백 뿐이거나 없을 때 + + (5) 댓글이 공백 포함 500자 초과일 때 + """, + responseCode = "400") + }) + ResponseEntity createComment( + @Parameter(hidden = true) Member member, + @Parameter(description = "댓글 생성 시 요구 형식") @Valid CommentRequest commentRequest); + + @Operation(summary = "댓글 조회", description = "스타카토에 속한 모든 댓글을 생성 순으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(description = "댓글 조회 성공", responseCode = "200"), + @ApiResponse(description = "스타카토 식별자가 양수가 아닐 때 발생", responseCode = "400"), + }) + ResponseEntity readCommentsByMomentId( + @Parameter(hidden = true) Member member, + @Parameter(description = "댓글이 속한 스타카토 식별자", example = "1") @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId); + + @Operation(summary = "댓글 수정", description = "댓글을 수정합니다.") + @ApiResponses(value = { + @ApiResponse(description = "댓글 수정 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 댓글 식별자가 양수가 아닐 때 + + (2) 요청한 댓글을 찾을 수 없을 때 + + (3) 댓글 내용이 공백 뿐이거나 없을 때 + + (4) 댓글이 공백 포함 500자 초과일 때 + """, + responseCode = "400") + }) + ResponseEntity updateComment( + @Parameter(hidden = true) Member member, + @Parameter(description = "댓글 식별자", example = "1") @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @Parameter(description = "댓글 수정 시 요구 형식") @Valid CommentUpdateRequest commentUpdateRequest); + + @Operation(summary = "댓글 삭제", description = "댓글을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(description = "댓글 삭제 성공", responseCode = "200"), + @ApiResponse(description = "댓글 식별자가 양수가 아닐 시 댓글 삭제 실패", responseCode = "400") + }) + ResponseEntity deleteComment( + @Parameter(description = "댓글 식별자", example = "1") @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @Parameter(hidden = true) Member member); +} diff --git a/backend/src/main/java/com/staccato/comment/domain/Comment.java b/backend/src/main/java/com/staccato/comment/domain/Comment.java new file mode 100644 index 000000000..ede448994 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/domain/Comment.java @@ -0,0 +1,63 @@ +package com.staccato.comment.domain; + +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +import com.staccato.config.domain.BaseEntity; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Table(indexes = { + @Index(name = "idx_moment_id", columnList = "moment_id") +}) +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "moment_id", nullable = false) + private Moment moment; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + public Comment(@NonNull String content, @NotNull Moment moment, @NonNull Member member) { + this.content = content; + this.moment = moment; + this.member = member; + moment.addComment(this); + } + + public void changeContent(String content) { + this.content = content; + } + + public boolean isNotOwnedBy(Member member) { + return !Objects.equals(this.member, member); + } +} diff --git a/backend/src/main/java/com/staccato/comment/repository/CommentRepository.java b/backend/src/main/java/com/staccato/comment/repository/CommentRepository.java new file mode 100644 index 000000000..a9802336a --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/repository/CommentRepository.java @@ -0,0 +1,11 @@ +package com.staccato.comment.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.comment.domain.Comment; + +public interface CommentRepository extends JpaRepository { + List findAllByMomentIdOrderByCreatedAtAsc(long momentId); +} diff --git a/backend/src/main/java/com/staccato/comment/service/CommentService.java b/backend/src/main/java/com/staccato/comment/service/CommentService.java new file mode 100644 index 000000000..cc5553be9 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/CommentService.java @@ -0,0 +1,82 @@ +package com.staccato.comment.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.comment.domain.Comment; +import com.staccato.comment.repository.CommentRepository; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final MomentRepository momentRepository; + + @Transactional + public long createComment(CommentRequest commentRequest, Member member) { + Moment moment = getMoment(commentRequest.momentId()); + validateOwner(moment.getMemory(), member); + Comment comment = commentRequest.toComment(moment, member); + + return commentRepository.save(comment).getId(); + } + + public CommentResponses readAllCommentsByMomentId(Member member, Long momentId) { + Moment moment = getMoment(momentId); + validateOwner(moment.getMemory(), member); + List comments = commentRepository.findAllByMomentIdOrderByCreatedAtAsc(momentId); + + return CommentResponses.from(comments); + } + + @Transactional + public void updateComment(Member member, Long commentId, CommentUpdateRequest commentUpdateRequest) { + Comment comment = getComment(commentId); + validateCommentOwner(comment, member); + comment.changeContent(commentUpdateRequest.content()); + } + + private Moment getMoment(long momentId) { + return momentRepository.findById(momentId) + .orElseThrow(() -> new StaccatoException("요청하신 스타카토를 찾을 수 없어요.")); + } + + private Comment getComment(long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new StaccatoException("요청하신 댓글을 찾을 수 없어요.")); + } + + private void validateOwner(Memory memory, Member member) { + if (memory.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } + + private void validateCommentOwner(Comment comment, Member member) { + if (comment.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } + + @Transactional + public void deleteComment(long commentId, Member member) { + commentRepository.findById(commentId).ifPresent(comment -> { + validateCommentOwner(comment, member); + commentRepository.deleteById(commentId); + }); + } +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/request/CommentRequest.java b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentRequest.java new file mode 100644 index 000000000..69c4a50c6 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentRequest.java @@ -0,0 +1,32 @@ +package com.staccato.comment.service.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import com.staccato.comment.domain.Comment; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "댓글 생성 시 요청 형식입니다.") +public record CommentRequest( + @Schema(example = "1") + @NotNull(message = "스타카토를 선택해주세요.") + @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") + Long momentId, + @Schema(example = "예시 댓글 내용") + @NotBlank(message = "댓글 내용을 입력해주세요.") + @Size(max = 500, message = "댓글은 공백 포함 500자 이하로 입력해주세요.") + String content +) { + public Comment toComment(Moment moment, Member member) { + return Comment.builder() + .content(content) + .moment(moment) + .member(member) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/request/CommentUpdateRequest.java b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentUpdateRequest.java new file mode 100644 index 000000000..98ed05f8f --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentUpdateRequest.java @@ -0,0 +1,15 @@ +package com.staccato.comment.service.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "댓글 수정 시 요청 형식입니다.") +public record CommentUpdateRequest( + @Schema(example = "예시 수정된 댓글 내용") + @NotBlank(message = "댓글 내용을 입력해주세요.") + @Size(max = 500, message = "댓글은 공백 포함 500자 이하로 입력해주세요.") + String content +) { +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponse.java b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponse.java new file mode 100644 index 000000000..e5e73e20d --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponse.java @@ -0,0 +1,32 @@ +package com.staccato.comment.service.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.comment.domain.Comment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토에 대해 함께 한 친구와 나눈 대화 응답 형식입니다.") +public record CommentResponse( + @Schema(example = "1") + Long commentId, + @Schema(example = "1") + Long memberId, + @Schema(example = "카고") + String nickname, + @Schema(example = "https://example.com/images/kargo.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memberImageUrl, + @Schema(example = "즐거운 추억") + @JsonInclude(JsonInclude.Include.NON_NULL) + String content +) { + public CommentResponse(Comment comment) { + this( + comment.getId(), + comment.getMember().getId(), + comment.getMember().getNickname().getNickname(), + comment.getMember().getImageUrl(), + comment.getContent() + ); + } +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponses.java b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponses.java new file mode 100644 index 000000000..f8dc239c1 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponses.java @@ -0,0 +1,15 @@ +package com.staccato.comment.service.dto.response; + +import java.util.List; + +import com.staccato.comment.domain.Comment; + +public record CommentResponses(List comments) { + public static CommentResponses from(List comments) { + return new CommentResponses( + comments.stream() + .map(CommentResponse::new) + .toList() + ); + } +} diff --git a/backend/src/main/java/com/staccato/config/JpaAuditingConfig.java b/backend/src/main/java/com/staccato/config/JpaAuditingConfig.java new file mode 100644 index 000000000..0c609456f --- /dev/null +++ b/backend/src/main/java/com/staccato/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.staccato.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/backend/src/main/java/com/staccato/config/OpenApiConfig.java b/backend/src/main/java/com/staccato/config/OpenApiConfig.java new file mode 100644 index 000000000..17cf24004 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/OpenApiConfig.java @@ -0,0 +1,38 @@ +package com.staccato.config; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import io.swagger.v3.oas.models.security.SecurityScheme.Type; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class OpenApiConfig { + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .servers(Arrays.asList( + new Server().url("https://stage.staccato.kr").description("Stage Server URL"), + new Server().url("https://dev.staccato.kr").description("Dev Server URL"), + new Server().url("http://localhost:8080").description("Local Server URL") + )) + .addSecurityItem(new SecurityRequirement().addList("Auth")) + .components(attachBearerAuthScheme()); + } + + private Components attachBearerAuthScheme() { + return new Components().addSecuritySchemes("Auth", + new SecurityScheme() + .name("Authorization") + .type(Type.APIKEY) + .in(In.HEADER) + .description("Enter your token in the Authorization header")); + } +} diff --git a/backend/src/main/java/com/staccato/config/WebMvcConfig.java b/backend/src/main/java/com/staccato/config/WebMvcConfig.java new file mode 100644 index 000000000..d2b9a4c1e --- /dev/null +++ b/backend/src/main/java/com/staccato/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.staccato.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.staccato.config.auth.LoginMemberArgumentResolver; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberArgumentResolver); + } +} diff --git a/backend/src/main/java/com/staccato/config/auth/AdminProperties.java b/backend/src/main/java/com/staccato/config/auth/AdminProperties.java new file mode 100644 index 000000000..22dc27337 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/AdminProperties.java @@ -0,0 +1,8 @@ +package com.staccato.config.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "security.admin") +public record AdminProperties(String key, String token) { +} + diff --git a/backend/src/main/java/com/staccato/config/auth/LoginMember.java b/backend/src/main/java/com/staccato/config/auth/LoginMember.java new file mode 100644 index 000000000..f219d7ae6 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/LoginMember.java @@ -0,0 +1,11 @@ +package com.staccato.config.auth; + +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 LoginMember { +} diff --git a/backend/src/main/java/com/staccato/config/auth/LoginMemberArgumentResolver.java b/backend/src/main/java/com/staccato/config/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..2cd872bc7 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,33 @@ +package com.staccato.config.auth; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +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 com.staccato.auth.service.AuthService; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + String token = request.getHeader(HttpHeaders.AUTHORIZATION); + return authService.extractFromToken(token); + } +} diff --git a/backend/src/main/java/com/staccato/config/auth/TokenProperties.java b/backend/src/main/java/com/staccato/config/auth/TokenProperties.java new file mode 100644 index 000000000..cba9995de --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/TokenProperties.java @@ -0,0 +1,7 @@ +package com.staccato.config.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "security.jwt.token") +public record TokenProperties(String secretKey) { +} diff --git a/backend/src/main/java/com/staccato/config/auth/TokenProvider.java b/backend/src/main/java/com/staccato/config/auth/TokenProvider.java new file mode 100644 index 000000000..8da818a38 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/TokenProvider.java @@ -0,0 +1,45 @@ +package com.staccato.config.auth; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +import com.staccato.exception.UnauthorizedException; +import com.staccato.member.domain.Member; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties(TokenProperties.class) +public class TokenProvider { + private final TokenProperties tokenProperties; + + public String create(Member member) { + return Jwts.builder() + .claim("id", member.getId()) + .claim("nickname", member.getNickname().getNickname()) + .claim("createdAt", member.getCreatedAt().toString()) + .signWith(SignatureAlgorithm.HS256, tokenProperties.secretKey().getBytes()) + .compact(); + } + + public long extractMemberId(String token) { + Claims claims = getPayload(token); + return claims.get("id", Long.class); + } + + public Claims getPayload(String token) { + try { + return Jwts.parser() + .setSigningKey(tokenProperties.secretKey().getBytes()) + .parseClaimsJws(token) + .getBody(); + } catch (JwtException | IllegalArgumentException e) { + throw new UnauthorizedException(); + } + } +} diff --git a/backend/src/main/java/com/staccato/config/domain/BaseEntity.java b/backend/src/main/java/com/staccato/config/domain/BaseEntity.java new file mode 100644 index 000000000..a623e66c2 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/domain/BaseEntity.java @@ -0,0 +1,22 @@ +package com.staccato.config.domain; + +import java.time.LocalDateTime; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.Getter; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseEntity { + @CreatedDate + private LocalDateTime createdAt; + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/staccato/config/log/LogForm.java b/backend/src/main/java/com/staccato/config/log/LogForm.java new file mode 100644 index 000000000..72e88fe0c --- /dev/null +++ b/backend/src/main/java/com/staccato/config/log/LogForm.java @@ -0,0 +1,33 @@ +package com.staccato.config.log; + +public class LogForm { + private static final String DELIMITER = ",\n "; + private static final String INDENT = " "; + + public static final String REQUEST_LOGGING_FORM = "\n{\n" + + INDENT + "\"httpStatus\": \"{}\"" + DELIMITER + + "\"httpMethod\": \"{}\"" + DELIMITER + + "\"requestUri\": \"{}\"" + DELIMITER + + "\"tokenExists\": \"{}\"" + DELIMITER + + "\"processingTimeMs\": \"{}\"\n" + + "}"; + + public static final String LOGIN_MEMBER_FORM = "\n{\n" + + INDENT + "\"loginMemberId\": \"{}\"" + DELIMITER + + "\"loginMemberNickname\": \"{}\"\n" + + "}"; + + public static final String CUSTOM_EXCEPTION_LOGGING_FORM = "\n{\n" + + INDENT + "\"exceptionResponse\": \"{}\"\n" + + "}"; + + public static final String EXCEPTION_LOGGING_FORM = "\n{\n" + + INDENT + "\"exceptionResponse\": \"{}\"\n" + DELIMITER + + "\"exceptionMessage\": \"{}\"\n" + + "}"; + + public static final String ERROR_LOGGING_FORM = "\n{\n" + + INDENT + "\"exceptionResponse\": \"{}\"" + DELIMITER + + "\"exceptionMessage\": \"{}\"\n" + + "}"; +} diff --git a/backend/src/main/java/com/staccato/config/log/LoggingFilter.java b/backend/src/main/java/com/staccato/config/log/LoggingFilter.java new file mode 100644 index 000000000..2b15297b0 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/log/LoggingFilter.java @@ -0,0 +1,58 @@ +package com.staccato.config.log; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.slf4j.MDC; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StopWatch; +import org.springframework.web.filter.OncePerRequestFilter; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class LoggingFilter extends OncePerRequestFilter { + private static final String IDENTIFIER = "request_id"; + private static final List WHITE_LIST = List.of("/h2-console/**", "/favicon/**", "/swagger-ui/**", "/v3/api-docs/**"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + MDC.put(IDENTIFIER, UUID.randomUUID().toString()); + String token = request.getHeader(HttpHeaders.AUTHORIZATION); + try { + filterChain.doFilter(request, response); + } finally { + stopWatch.stop(); + log.info(LogForm.REQUEST_LOGGING_FORM, + response.getStatus(), + request.getMethod(), + request.getRequestURI(), + tokenExists(token), + stopWatch.getTotalTimeMillis()); + MDC.clear(); + } + } + + private boolean tokenExists(String token) { + return !(Objects.isNull(token) || token.isBlank()); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String requestURI = request.getRequestURI(); + AntPathMatcher antPathMatcher = new AntPathMatcher(); + return WHITE_LIST.stream().anyMatch(path -> antPathMatcher.match(path, requestURI)); + } +} diff --git a/backend/src/main/java/com/staccato/exception/ExceptionResponse.java b/backend/src/main/java/com/staccato/exception/ExceptionResponse.java new file mode 100644 index 000000000..dcaa51acf --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/ExceptionResponse.java @@ -0,0 +1,10 @@ +package com.staccato.exception; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "예외에 대한 응답 형식입니다.") +public record ExceptionResponse( + String status, + String message +) { +} diff --git a/backend/src/main/java/com/staccato/exception/ForbiddenException.java b/backend/src/main/java/com/staccato/exception/ForbiddenException.java new file mode 100644 index 000000000..8c3c56fa9 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/ForbiddenException.java @@ -0,0 +1,19 @@ +package com.staccato.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException() { + super("요청하신 작업을 처리할 권한이 없습니다."); + } + + public ForbiddenException(final String message) { + super(message); + } + + public ForbiddenException(final String message, final Throwable cause) { + super(message, cause); + } + + public ForbiddenException(final Throwable cause) { + super(cause); + } +} diff --git a/backend/src/main/java/com/staccato/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/staccato/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..a5d9866ce --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/GlobalExceptionHandler.java @@ -0,0 +1,124 @@ +package com.staccato.exception; + +import java.util.Optional; + +import jakarta.validation.ConstraintViolationException; + +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +import com.staccato.config.log.LogForm; + +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + String exceptionMessage = "올바르지 않은 쿼리 스트링 형식입니다."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String exceptionMessage = Optional.ofNullable(e.getBindingResult().getFieldError()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .orElse("요청 형식이 잘못되었습니다."); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String exceptionMessage = e.getConstraintViolations() + .iterator() + .next() + .getMessage(); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + String exceptionMessage = "요청 본문을 읽을 수 없습니다. 올바른 형식으로 데이터를 제공해주세요."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(S3Exception.class) + public ResponseEntity handleS3Exception(S3Exception e) { + String exceptionMessage = "이미지 처리에 실패했습니다."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity handleMissingServletRequestPartException(MissingServletRequestPartException e) { + String exceptionMessage = "요청된 파트가 누락되었습니다. 올바른 데이터를 제공해주세요."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(MultipartException.class) + public ResponseEntity handleMultipartException(MultipartException e) { + String exceptionMessage = "20MB 이하의 사진을 업로드해 주세요."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.PAYLOAD_TOO_LARGE.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(StaccatoException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleStaccatoException(StaccatoException e) { + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), e.getMessage()); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(UnauthorizedException.class) + @ApiResponse(description = "사용자 인증 실패", responseCode = "401") + public ResponseEntity handleUnauthorizedException(UnauthorizedException e) { + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.UNAUTHORIZED.toString(), e.getMessage()); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(exceptionResponse); + } + + @ExceptionHandler(ForbiddenException.class) + @ApiResponse(description = "사용자가 권한을 가지고 있지 않은 작업을 시도 시 발생", responseCode = "403") + public ResponseEntity handleForbiddenException(ForbiddenException e) { + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.FORBIDDEN.toString(), e.getMessage()); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(exceptionResponse); + } + + @ExceptionHandler(RuntimeException.class) + @ApiResponse(responseCode = "500") + public ResponseEntity handleInternalServerErrorException(RuntimeException e) { + String exceptionMessage = "예기치 못한 서버 오류입니다. 다시 시도해주세요."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.INTERNAL_SERVER_ERROR.toString(), exceptionMessage); + log.error(LogForm.ERROR_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.internalServerError().body(exceptionResponse); + } +} diff --git a/backend/src/main/java/com/staccato/exception/StaccatoException.java b/backend/src/main/java/com/staccato/exception/StaccatoException.java new file mode 100644 index 000000000..4fe740672 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/StaccatoException.java @@ -0,0 +1,19 @@ +package com.staccato.exception; + +public class StaccatoException extends RuntimeException { + public StaccatoException() { + super(); + } + + public StaccatoException(String message) { + super(message); + } + + public StaccatoException(String message, Throwable cause) { + super(message, cause); + } + + public StaccatoException(Throwable cause) { + super(cause); + } +} diff --git a/backend/src/main/java/com/staccato/exception/UnauthorizedException.java b/backend/src/main/java/com/staccato/exception/UnauthorizedException.java new file mode 100644 index 000000000..3b0078662 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/UnauthorizedException.java @@ -0,0 +1,19 @@ +package com.staccato.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException() { + super("인증되지 않은 사용자입니다."); + } + + public UnauthorizedException(final String message) { + super(message); + } + + public UnauthorizedException(final String message, final Throwable cause) { + super(message, cause); + } + + public UnauthorizedException(final Throwable cause) { + super(cause); + } +} diff --git a/backend/src/main/java/com/staccato/exception/validation/ValidationSteps.java b/backend/src/main/java/com/staccato/exception/validation/ValidationSteps.java new file mode 100644 index 000000000..e7043a210 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/validation/ValidationSteps.java @@ -0,0 +1,15 @@ +package com.staccato.exception.validation; + +import jakarta.validation.GroupSequence; + +public class ValidationSteps { + public interface FirstStep { + } + + public interface SecondStep { + } + + @GroupSequence({FirstStep.class, SecondStep.class}) + public interface ValidationSequence { + } +} diff --git a/backend/src/main/java/com/staccato/image/controller/ImageController.java b/backend/src/main/java/com/staccato/image/controller/ImageController.java new file mode 100644 index 000000000..aeceb9216 --- /dev/null +++ b/backend/src/main/java/com/staccato/image/controller/ImageController.java @@ -0,0 +1,35 @@ +package com.staccato.image.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.staccato.config.auth.LoginMember; +import com.staccato.image.controller.docs.ImageControllerDocs; +import com.staccato.image.service.ImageService; +import com.staccato.image.service.dto.ImageUrlResponse; +import com.staccato.member.domain.Member; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/images") +@RequiredArgsConstructor +public class ImageController implements ImageControllerDocs { + private final ImageService imageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadImage( + @RequestPart(value = "imageFile") MultipartFile image, + @LoginMember Member member + ) { + ImageUrlResponse imageUrlResponse = imageService.uploadImage(image); + + return ResponseEntity.status(HttpStatus.CREATED).body(imageUrlResponse); + } +} diff --git a/backend/src/main/java/com/staccato/image/controller/docs/ImageControllerDocs.java b/backend/src/main/java/com/staccato/image/controller/docs/ImageControllerDocs.java new file mode 100644 index 000000000..fbbb9455a --- /dev/null +++ b/backend/src/main/java/com/staccato/image/controller/docs/ImageControllerDocs.java @@ -0,0 +1,35 @@ +package com.staccato.image.controller.docs; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.staccato.config.auth.LoginMember; +import com.staccato.image.service.dto.ImageUrlResponse; +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Image", description = "Image API") +public interface ImageControllerDocs { + + @Operation(summary = "이미지 업로드", description = "이미지를 업로드하고 S3 url을 가져옵니다.") + @ApiResponses(value = { + @ApiResponse(description = "이미지 업로드 성공", responseCode = "201"), + @ApiResponse(description = "전송된 파일이 손상되었거나 지원되지 않는 형식일 때", responseCode = "400"), + @ApiResponse(description = "요청된 파트가 누락되었을 때", responseCode = "400"), + @ApiResponse(description = "20MB 초과의 사진을 업로드 하려고 할 때", responseCode = "413") + }) + ResponseEntity uploadImage( + @Parameter(description = "업로드할 이미지 파일 (PNG, JPG, JPEG, WEBP) 형식 지원, 최대 20MB", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "imageFile") MultipartFile image, + @Parameter(hidden = true) @LoginMember Member member); +} diff --git a/backend/src/main/java/com/staccato/image/domain/ImageExtension.java b/backend/src/main/java/com/staccato/image/domain/ImageExtension.java new file mode 100644 index 000000000..b578154b0 --- /dev/null +++ b/backend/src/main/java/com/staccato/image/domain/ImageExtension.java @@ -0,0 +1,26 @@ +package com.staccato.image.domain; + +import java.util.Arrays; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageExtension { + PNG(".png", "image/png"), + JPG(".jpg", "image/jpg"), + JPEG(".jpeg", "image/jpeg"), + WEBP(".webp", "image/webp"); + + private final String extension; + private final String contentType; + + public static String getContentType(String extension) { + return Arrays.stream(ImageExtension.values()) + .filter(imageExtension -> imageExtension.getExtension().equalsIgnoreCase(extension)) + .map(ImageExtension::getContentType) + .findFirst() + .orElse("application/octet-stream"); + } +} diff --git a/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java b/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java new file mode 100644 index 000000000..ded00e41b --- /dev/null +++ b/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java @@ -0,0 +1,54 @@ +package com.staccato.image.infrastructure; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Component +public class S3ObjectClient { + private final S3Client s3Client; + private final String bucketName; + private final String endPoint; + private final String cloudFrontEndPoint; + + public S3ObjectClient( + @Value("${cloud.aws.s3.bucket}") String bucketName, + @Value("${cloud.aws.s3.endpoint}") String endPoint, + @Value("${cloud.aws.cloudfront.endpoint}") String cloudFrontEndPoint + ) { + this.s3Client = software.amazon.awssdk.services.s3.S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(InstanceProfileCredentialsProvider.create()) + .build(); + this.bucketName = bucketName; + this.endPoint = endPoint; + this.cloudFrontEndPoint = cloudFrontEndPoint; + } + + public void putS3Object(String objectKey, String contentType, byte[] imageBytes) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentType(contentType) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(imageBytes)); + } + + public String getUrl(String keyName) { + GetUrlRequest request = GetUrlRequest.builder() + .bucket(bucketName) + .key(keyName) + .build(); + + String url = s3Client.utilities().getUrl(request).toString(); + + return url.replace(endPoint, cloudFrontEndPoint); + } +} diff --git a/backend/src/main/java/com/staccato/image/service/ImageService.java b/backend/src/main/java/com/staccato/image/service/ImageService.java new file mode 100644 index 000000000..873d594af --- /dev/null +++ b/backend/src/main/java/com/staccato/image/service/ImageService.java @@ -0,0 +1,54 @@ +package com.staccato.image.service; + +import java.io.IOException; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.staccato.exception.StaccatoException; +import com.staccato.image.domain.ImageExtension; +import com.staccato.image.infrastructure.S3ObjectClient; +import com.staccato.image.service.dto.ImageUrlResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ImageService { + private static final String TEAM_FOLDER_NAME = "staccato/"; + + @Value("${image.folder.name}") + private String imageFolderName; + + private final S3ObjectClient s3ObjectClient; + + public ImageUrlResponse uploadImage(MultipartFile image) { + String imageExtension = getImageExtension(image); + String key = TEAM_FOLDER_NAME + imageFolderName + UUID.randomUUID() + imageExtension; + String contentType = ImageExtension.getContentType(imageExtension); + byte[] imageBytes = getImageBytes(image); + + s3ObjectClient.putS3Object(key, contentType, imageBytes); + String imageUrl = s3ObjectClient.getUrl(key); + + return new ImageUrlResponse(imageUrl); + } + + private String getImageExtension(MultipartFile image) { + String imageName = image.getOriginalFilename(); + if (imageName == null || !imageName.contains(".")) { + return ""; + } + return imageName.substring(imageName.lastIndexOf('.')); + } + + private byte[] getImageBytes(MultipartFile image) { + try { + return image.getBytes(); + } catch (IOException e) { + throw new StaccatoException("전송된 파일이 손상되었거나 지원되지 않는 형식입니다."); + } + } +} diff --git a/backend/src/main/java/com/staccato/image/service/dto/ImageUrlResponse.java b/backend/src/main/java/com/staccato/image/service/dto/ImageUrlResponse.java new file mode 100644 index 000000000..a6290269e --- /dev/null +++ b/backend/src/main/java/com/staccato/image/service/dto/ImageUrlResponse.java @@ -0,0 +1,10 @@ +package com.staccato.image.service.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "이미지 업로드를 했을 때 응답 형식입니다.") +public record ImageUrlResponse( + @Schema(example = "https://d1234abcdefg.cloudfront.net/staccato/image/abcdefg.jpg") + String imageUrl +) { +} diff --git a/backend/src/main/java/com/staccato/member/domain/Member.java b/backend/src/main/java/com/staccato/member/domain/Member.java new file mode 100644 index 000000000..2a4997c2a --- /dev/null +++ b/backend/src/main/java/com/staccato/member/domain/Member.java @@ -0,0 +1,45 @@ +package com.staccato.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.staccato.config.domain.BaseEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE member SET is_deleted = true WHERE id = ?") +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@SQLRestriction("is_deleted = false") +public class Member extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + @Column(nullable = false, unique = true) + @Embedded + private Nickname nickname; + @Column(columnDefinition = "TEXT") + private String imageUrl; + private Boolean isDeleted = false; + + @Builder + public Member(@NonNull String nickname, String imageUrl) { + this.nickname = new Nickname(nickname); + this.imageUrl = imageUrl; + } +} diff --git a/backend/src/main/java/com/staccato/member/domain/Nickname.java b/backend/src/main/java/com/staccato/member/domain/Nickname.java new file mode 100644 index 000000000..a4a84a5af --- /dev/null +++ b/backend/src/main/java/com/staccato/member/domain/Nickname.java @@ -0,0 +1,36 @@ +package com.staccato.member.domain; + +import java.util.regex.Pattern; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import com.staccato.exception.StaccatoException; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Embeddable +@EqualsAndHashCode +public class Nickname { + private static final Pattern NICKNAME_REGEX = Pattern.compile("^[ㄱ-ㅎㅏ-ㅣ가-힣0-9a-zA-Z._]+$"); + private static final int MAX_LENGTH = 20; + + @Column(nullable = false, length = MAX_LENGTH) + private String nickname; + + public Nickname(String nickname) { + String trimmedNickname = nickname.trim(); + validateRegex(trimmedNickname); + this.nickname = trimmedNickname; + } + + private static void validateRegex(String nickname) { + if (!NICKNAME_REGEX.matcher(nickname).matches()) { + throw new StaccatoException("올바르지 않은 닉네임 형식입니다."); + } + } +} diff --git a/backend/src/main/java/com/staccato/member/repository/MemberRepository.java b/backend/src/main/java/com/staccato/member/repository/MemberRepository.java new file mode 100644 index 000000000..04c2ae2d2 --- /dev/null +++ b/backend/src/main/java/com/staccato/member/repository/MemberRepository.java @@ -0,0 +1,14 @@ +package com.staccato.member.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.member.domain.Member; +import com.staccato.member.domain.Nickname; + +public interface MemberRepository extends JpaRepository { + Optional findByIdAndIsDeletedIsFalse(long memberId); + + boolean existsByNickname(Nickname nickname); +} diff --git a/backend/src/main/java/com/staccato/member/service/dto/response/MemberResponse.java b/backend/src/main/java/com/staccato/member/service/dto/response/MemberResponse.java new file mode 100644 index 000000000..676f9ebe2 --- /dev/null +++ b/backend/src/main/java/com/staccato/member/service/dto/response/MemberResponse.java @@ -0,0 +1,21 @@ +package com.staccato.member.service.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "여러 회원 정보를 표시할 때 필요한 정보에 대한 응답 형식입니다.") +public record MemberResponse( + @Schema(example = "1") + Long memberId, + @Schema(example = "staccato") + String nickname, + @Schema(example = "https://example.com/members/profile.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memberImageUrl +) { + public MemberResponse(Member member) { + this(member.getId(), member.getNickname().getNickname(), member.getImageUrl()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/controller/MemoryController.java b/backend/src/main/java/com/staccato/memory/controller/MemoryController.java new file mode 100644 index 000000000..76c74c4be --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/controller/MemoryController.java @@ -0,0 +1,88 @@ +package com.staccato.memory.controller; + +import java.net.URI; +import java.time.LocalDate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.config.auth.LoginMember; +import com.staccato.member.domain.Member; +import com.staccato.memory.controller.docs.MemoryControllerDocs; +import com.staccato.memory.service.MemoryService; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryResponses; +import com.staccato.memory.service.dto.response.MemoryNameResponses; + +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequestMapping("/memories") +@RequiredArgsConstructor +public class MemoryController implements MemoryControllerDocs { + private final MemoryService memoryService; + + @PostMapping + public ResponseEntity createMemory( + @Valid @RequestBody MemoryRequest memoryRequest, + @LoginMember Member member + ) { + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest, member); + return ResponseEntity.created(URI.create("/memories/" + memoryIdResponse.memoryId())).body(memoryIdResponse); + } + + @GetMapping + public ResponseEntity readAllMemories(@LoginMember Member member) { + MemoryResponses memoryResponses = memoryService.readAllMemories(member); + return ResponseEntity.ok(memoryResponses); + } + + @GetMapping("/candidates") + public ResponseEntity readAllCandidateMemories( + @LoginMember Member member, + @RequestParam(value = "currentDate") LocalDate currentDate + ) { + MemoryNameResponses memoryNameResponses = memoryService.readAllMemoriesIncludingDate(member, currentDate); + return ResponseEntity.ok(memoryNameResponses); + } + + @GetMapping("/{memoryId}") + public ResponseEntity readMemory( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "추억 식별자는 양수로 이루어져야 합니다.") long memoryId) { + MemoryDetailResponse memoryDetailResponse = memoryService.readMemoryById(memoryId, member); + return ResponseEntity.ok(memoryDetailResponse); + } + + @PutMapping(path = "/{memoryId}") + public ResponseEntity updateMemory( + @PathVariable @Min(value = 1L, message = "추억 식별자는 양수로 이루어져야 합니다.") long memoryId, + @Valid @RequestBody MemoryRequest memoryRequest, + @LoginMember Member member) { + memoryService.updateMemory(memoryRequest, memoryId, member); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{memoryId}") + public ResponseEntity deleteMemory( + @PathVariable @Min(value = 1L, message = "추억 식별자는 양수로 이루어져야 합니다.") long memoryId, + @LoginMember Member member) { + memoryService.deleteMemory(memoryId, member); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/controller/docs/MemoryControllerDocs.java b/backend/src/main/java/com/staccato/memory/controller/docs/MemoryControllerDocs.java new file mode 100644 index 000000000..00f610da2 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/controller/docs/MemoryControllerDocs.java @@ -0,0 +1,115 @@ +package com.staccato.memory.controller.docs; + +import java.time.LocalDate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; + +import com.staccato.member.domain.Member; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Memory", description = "Memory API") +public interface MemoryControllerDocs { + @Operation(summary = "추억 생성", description = "추억(썸네일, 제목, 내용, 기간)을 생성합니다.") + @ApiResponses(value = { + @ApiResponse(description = "추억 생성 성공", responseCode = "201"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 필수 값(추억 제목, 기간)이 누락되었을 때 + + (2) 날짜 형식(yyyy-MM-dd)이 잘못되었을 때 + + (3) 제목이 공백 포함 30자를 초과했을 때 + + (4) 내용이 공백 포함 500자를 초과했을 때 + + (5) 기간 설정이 잘못되었을 때 + + (6) 이미 존재하는 추억 이름일 때 + """, + responseCode = "400") + }) + ResponseEntity createMemory( + @Parameter(required = true) @Valid MemoryRequest memoryRequest, + @Parameter(hidden = true) Member member); + + @Operation(summary = "추억 목록 조회", description = "사용자의 모든 추억 목록을 조회합니다.") + @ApiResponse(description = "추억 목록 조회 성공", responseCode = "200") + ResponseEntity readAllMemories(@Parameter(hidden = true) Member member); + + @Operation(summary = "특정 날짜를 포함하는 사용자의 모든 추억 목록 조회", description = "특정 날짜를 포함하는 사용자의 모든 추억 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(description = "추억 목록 조회 성공", responseCode = "200"), + @ApiResponse(description = "입력받은 현재 날짜가 유효하지 않을 때 발생", responseCode = "400") + }) + ResponseEntity readAllCandidateMemories( + @Parameter(hidden = true) Member member, + @Parameter(description = "현재 날짜", example = "2024-08-21") LocalDate currentDate); + + @Operation(summary = "추억 조회", description = "사용자의 추억을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(description = "추억 조회 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 존재하지 않는 추억을 조회하려고 했을 때 + + (2) Path Variable 형식이 잘못되었을 때 + """, + responseCode = "400") + }) + ResponseEntity readMemory( + @Parameter(hidden = true) Member member, + @Parameter(description = "추억 ID", example = "1") @Min(value = 1L, message = "추억 식별자는 양수로 이루어져야 합니다.") long memoryId); + + @Operation(summary = "추억 수정", description = "추억 정보(썸네일, 제목, 내용, 기간)를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(description = "추억 수정 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 필수 값(추억 제목, 기간)이 누락되었을 때 + + (2) 날짜 형식(yyyy-MM-dd)이 잘못되었을 때 + + (3) 제목이 공백 포함 30자를 초과했을 때 + + (4) 내용이 공백 포함 500자를 초과했을 때 + + (5) 기간 설정이 잘못되었을 때 + + (6) 변경하려는 추억 기간이 이미 존재하는 스타카토를 포함하지 않을 때 + + (7) 수정하려는 추억이 존재하지 않을 때 + + (8) Path Variable 형식이 잘못되었을 때 + """, + responseCode = "400") + }) + ResponseEntity updateMemory( + @Parameter(description = "추억 ID", example = "1") @Min(value = 1L, message = "추억 식별자는 양수로 이루어져야 합니다.") long memoryId, + @Parameter(required = true) @Valid MemoryRequest memoryRequest, + @Parameter(hidden = true) Member member); + + @Operation(summary = "추억 삭제", description = "사용자의 추억을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(description = "추억 삭제 성공", responseCode = "200"), + @ApiResponse(description = "Path Variable 형식이 잘못되었을 때 발생", responseCode = "400") + }) + ResponseEntity deleteMemory( + @Parameter(description = "추억 ID", example = "1") @Min(value = 1L, message = "추억 식별자는 양수로 이루어져야 합니다.") long memoryId, + @Parameter(hidden = true) Member member); +} diff --git a/backend/src/main/java/com/staccato/memory/domain/Memory.java b/backend/src/main/java/com/staccato/memory/domain/Memory.java new file mode 100644 index 000000000..08c9a7dfb --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/domain/Memory.java @@ -0,0 +1,102 @@ +package com.staccato.memory.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +import com.staccato.config.domain.BaseEntity; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Memory extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(columnDefinition = "TEXT") + private String thumbnailUrl; + @Column(nullable = false, length = 50) + private String title; + @Column(columnDefinition = "TEXT") + private String description; + @Column + @Embedded + private Term term; + @OneToMany(mappedBy = "memory", orphanRemoval = true, cascade = CascadeType.ALL) + private List memoryMembers = new ArrayList<>(); + + @Builder + public Memory(String thumbnailUrl, @NonNull String title, String description, LocalDate startAt, LocalDate endAt) { + this.thumbnailUrl = thumbnailUrl; + this.title = title.trim(); + this.description = description; + this.term = new Term(startAt, endAt); + } + + public void addMemoryMember(MemoryMember memoryMember) { + memoryMembers.add(memoryMember); + } + + public void addMemoryMember(Member member) { + MemoryMember memoryMember = MemoryMember.builder() + .memory(this) + .member(member) + .build(); + memoryMembers.add(memoryMember); + } + + public void update(Memory updatedMemory, List moments) { + validateDuration(updatedMemory, moments); + this.thumbnailUrl = updatedMemory.getThumbnailUrl(); + this.title = updatedMemory.getTitle(); + this.description = updatedMemory.getDescription(); + this.term = updatedMemory.getTerm(); + } + + private void validateDuration(Memory updatedMemory, List moments) { + moments.stream() + .filter(moment -> updatedMemory.isWithoutDuration(moment.getVisitedAt())) + .findAny() + .ifPresent(moment -> { + throw new StaccatoException("기간이 이미 존재하는 스타카토를 포함하지 않아요. 다시 설정해주세요."); + }); + } + + public boolean isWithoutDuration(LocalDateTime date) { + return term.doesNotContain(date); + } + + public List getMates() { + return memoryMembers.stream() + .map(MemoryMember::getMember) + .toList(); + } + + public boolean isNotOwnedBy(Member member) { + return memoryMembers.stream() + .noneMatch(memoryMember -> memoryMember.isMember(member)); + } + + public boolean isNotSameTitle(String title) { + return !this.title.equals(title); + } +} diff --git a/backend/src/main/java/com/staccato/memory/domain/MemoryMember.java b/backend/src/main/java/com/staccato/memory/domain/MemoryMember.java new file mode 100644 index 000000000..c368b95b9 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/domain/MemoryMember.java @@ -0,0 +1,46 @@ +package com.staccato.memory.domain; + +import java.util.Objects; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +import com.staccato.config.domain.BaseEntity; +import com.staccato.member.domain.Member; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemoryMember extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "memory_id", nullable = false) + private Memory memory; + + @Builder + public MemoryMember(@NonNull Member member, @NonNull Memory memory) { + this.member = member; + this.memory = memory; + memory.addMemoryMember(this); + } + + public boolean isMember(Member member) { + return Objects.equals(this.member, member); + } +} diff --git a/backend/src/main/java/com/staccato/memory/domain/Term.java b/backend/src/main/java/com/staccato/memory/domain/Term.java new file mode 100644 index 000000000..d42527b1d --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/domain/Term.java @@ -0,0 +1,59 @@ +package com.staccato.memory.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.chrono.ChronoLocalDate; +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import com.staccato.exception.StaccatoException; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Embeddable +public class Term { + @Column + private LocalDate startAt; + @Column + private LocalDate endAt; + + public Term(LocalDate startAt, LocalDate endAt) { + validateTermDates(startAt, endAt); + this.startAt = startAt; + this.endAt = endAt; + } + + private void validateTermDates(LocalDate startAt, LocalDate endAt) { + if (isOnlyOneDatePresent(startAt, endAt)) { + throw new StaccatoException("추억 시작 날짜와 끝 날짜를 모두 입력해주세요."); + } + if (isInvalidTerm(startAt, endAt)) { + throw new StaccatoException("끝 날짜가 시작 날짜보다 앞설 수 없어요."); + } + } + + private boolean isOnlyOneDatePresent(LocalDate startAt, LocalDate endAt) { + return (Objects.nonNull(startAt) && Objects.isNull(endAt)) || (Objects.isNull(startAt) && Objects.nonNull(endAt)); + } + + private boolean isInvalidTerm(LocalDate startAt, LocalDate endAt) { + return isExist(startAt, endAt) && endAt.isBefore(startAt); + } + + public boolean doesNotContain(LocalDateTime date) { + if(isExist(startAt, endAt)) { + ChronoLocalDate targetDate = ChronoLocalDate.from(date); + return (startAt.isAfter(targetDate) || endAt.isBefore(targetDate)); + } + return false; + } + + private boolean isExist(LocalDate startAt, LocalDate endAt) { + return Objects.nonNull(startAt) && Objects.nonNull(endAt); + } +} diff --git a/backend/src/main/java/com/staccato/memory/repository/MemoryMemberRepository.java b/backend/src/main/java/com/staccato/memory/repository/MemoryMemberRepository.java new file mode 100644 index 000000000..b21768370 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/repository/MemoryMemberRepository.java @@ -0,0 +1,22 @@ +package com.staccato.memory.repository; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.staccato.memory.domain.MemoryMember; + +public interface MemoryMemberRepository extends JpaRepository { + List findAllByMemberIdOrderByMemoryCreatedAtDesc(long memberId); + + @Query(""" + SELECT mm FROM MemoryMember mm WHERE mm.member.id = :memberId + AND ((mm.memory.term.startAt is null AND mm.memory.term.endAt is null) + or (:date BETWEEN mm.memory.term.startAt AND mm.memory.term.endAt)) + ORDER BY mm.memory.createdAt DESC + """) + List findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(@Param("memberId") long memberId, @Param("date") LocalDate date); +} diff --git a/backend/src/main/java/com/staccato/memory/repository/MemoryRepository.java b/backend/src/main/java/com/staccato/memory/repository/MemoryRepository.java new file mode 100644 index 000000000..76e006028 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/repository/MemoryRepository.java @@ -0,0 +1,9 @@ +package com.staccato.memory.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.memory.domain.Memory; + +public interface MemoryRepository extends JpaRepository { + boolean existsByTitle(String title); +} diff --git a/backend/src/main/java/com/staccato/memory/service/MemoryService.java b/backend/src/main/java/com/staccato/memory/service/MemoryService.java new file mode 100644 index 000000000..6cc89e30c --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/MemoryService.java @@ -0,0 +1,119 @@ +package com.staccato.memory.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; +import com.staccato.memory.repository.MemoryMemberRepository; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; +import com.staccato.memory.service.dto.response.MomentResponse; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemoryService { + private final MemoryRepository memoryRepository; + private final MemoryMemberRepository memoryMemberRepository; + private final MomentRepository momentRepository; + + @Transactional + public MemoryIdResponse createMemory(MemoryRequest memoryRequest, Member member) { + validateMemoryTitle(memoryRequest.memoryTitle()); + Memory memory = memoryRequest.toMemory(); + memory.addMemoryMember(member); + memoryRepository.save(memory); + return new MemoryIdResponse(memory.getId()); + } + + public MemoryResponses readAllMemories(Member member) { + List memoryMembers = memoryMemberRepository.findAllByMemberIdOrderByMemoryCreatedAtDesc(member.getId()); + return MemoryResponses.from( + memoryMembers.stream() + .map(MemoryMember::getMemory) + .toList() + ); + } + + public MemoryNameResponses readAllMemoriesIncludingDate(Member member, LocalDate currentDate) { + List memoryMembers = memoryMemberRepository.findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(member.getId(), currentDate); + return MemoryNameResponses.from( + memoryMembers.stream() + .map(MemoryMember::getMemory) + .toList() + ); + } + + public MemoryDetailResponse readMemoryById(long memoryId, Member member) { + Memory memory = getMemoryById(memoryId); + validateOwner(memory, member); + List momentResponses = getMomentResponses(momentRepository.findAllByMemoryIdOrderByVisitedAt(memoryId)); + return new MemoryDetailResponse(memory, momentResponses); + } + + private List getMomentResponses(List moments) { + return moments.stream() + .map(moment -> new MomentResponse(moment, getMomentThumbnail(moment))) + .toList(); + } + + private String getMomentThumbnail(Moment moment) { + if (moment.hasImage()) { + return moment.getThumbnailUrl(); + } + return null; + } + + @Transactional + public void updateMemory(MemoryRequest memoryRequest, Long memoryId, Member member) { + Memory originMemory = getMemoryById(memoryId); + validateOwner(originMemory, member); + if (originMemory.isNotSameTitle(memoryRequest.memoryTitle())) { + validateMemoryTitle(memoryRequest.memoryTitle()); + } + Memory updatedMemory = memoryRequest.toMemory(); + List moments = momentRepository.findAllByMemoryIdOrderByVisitedAt(memoryId); + originMemory.update(updatedMemory, moments); + } + + private Memory getMemoryById(long memoryId) { + return memoryRepository.findById(memoryId) + .orElseThrow(() -> new StaccatoException("요청하신 추억을 찾을 수 없어요.")); + } + + private void validateMemoryTitle(String title) { + if (memoryRepository.existsByTitle(title)) { + throw new StaccatoException("같은 이름을 가진 추억이 있어요. 다른 이름으로 설정해주세요."); + } + } + + @Transactional + public void deleteMemory(long memoryId, Member member) { + memoryRepository.findById(memoryId).ifPresent(memory -> { + validateOwner(memory, member); + momentRepository.deleteAllByMemoryId(memoryId); + memoryRepository.deleteById(memoryId); + }); + } + + private void validateOwner(Memory memory, Member member) { + if (memory.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/request/MemoryRequest.java b/backend/src/main/java/com/staccato/memory/service/dto/request/MemoryRequest.java new file mode 100644 index 000000000..7118a1539 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/request/MemoryRequest.java @@ -0,0 +1,47 @@ +package com.staccato.memory.service.dto.request; + +import java.time.LocalDate; +import java.util.Objects; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "추억을 생성/수정하기 위한 요청 형식입니다.") +public record MemoryRequest( + @Schema(example = "http://example.com/london.png") + String memoryThumbnailUrl, + @Schema(example = "런던 추억") + @NotBlank(message = "추억 제목을 입력해주세요.") + @Size(max = 30, message = "제목은 공백 포함 30자 이하로 설정해주세요.") + String memoryTitle, + @Schema(example = "런던 시내 탐방") + @Size(max = 500, message = "내용의 최대 허용 글자수는 공백 포함 500자입니다.") + String description, + @Schema(example = "2024-07-27") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate startAt, + @Schema(example = "2024-07-29") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate endAt) { + public MemoryRequest { + if (Objects.nonNull(memoryTitle)) { + memoryTitle = memoryTitle.trim(); + } + } + + public Memory toMemory() { + return Memory.builder() + .thumbnailUrl(memoryThumbnailUrl) + .title(memoryTitle) + .description(description) + .startAt(startAt) + .endAt(endAt) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryDetailResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryDetailResponse.java new file mode 100644 index 000000000..737fc6e5f --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryDetailResponse.java @@ -0,0 +1,49 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.member.service.dto.response.MemberResponse; +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "추억에 대한 응답 형식입니다.") +public record MemoryDetailResponse( + @Schema(example = "1") + Long memoryId, + @Schema(example = "https://example.com/memorys/geumohrm.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memoryThumbnailUrl, + @Schema(example = "런던 추억") + String memoryTitle, + @Schema(example = "런던 시내 탐방") + @JsonInclude(JsonInclude.Include.NON_NULL) + String description, + @Schema(example = "2024-07-27") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate startAt, + @Schema(example = "2024-07-29") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate endAt, + List mates, + List moments +) { + public MemoryDetailResponse(Memory memory, List momentResponses) { + this( + memory.getId(), + memory.getThumbnailUrl(), + memory.getTitle(), + memory.getDescription(), + memory.getTerm().getStartAt(), + memory.getTerm().getEndAt(), + toMemberResponses(memory), + momentResponses + ); + } + + private static List toMemberResponses(Memory memory) { + return memory.getMates().stream().map(MemberResponse::new).toList(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryIdResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryIdResponse.java new file mode 100644 index 000000000..b1aa0a825 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryIdResponse.java @@ -0,0 +1,10 @@ +package com.staccato.memory.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "추억을 생성했을 때에 대한 응답 형식입니다.") +public record MemoryIdResponse( + @Schema(example = "1") + long memoryId +) { +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponse.java new file mode 100644 index 000000000..27f155535 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponse.java @@ -0,0 +1,17 @@ +package com.staccato.memory.service.dto.response; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "특정 날짜를 포함하는 추억 목록 조회 시 각각의 추억에 대한 응답 형식입니다.") +public record MemoryNameResponse( + @Schema(example = "1") + Long memoryId, + @Schema(example = "런던 추억") + String memoryTitle +) { + public MemoryNameResponse(Memory memory) { + this(memory.getId(), memory.getTitle()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponses.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponses.java new file mode 100644 index 000000000..57f18c92f --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponses.java @@ -0,0 +1,18 @@ +package com.staccato.memory.service.dto.response; + +import java.util.List; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "특정 날짜를 포함하는 추억 목록 조회 시 반환되는 응답 형식입니다.") +public record MemoryNameResponses( + List memories +) { + public static MemoryNameResponses from(List memories) { + return new MemoryNameResponses(memories.stream() + .map(MemoryNameResponse::new) + .toList()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponse.java new file mode 100644 index 000000000..06dd0a3de --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponse.java @@ -0,0 +1,35 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "추억 목록 조회 시 각각의 추억에 대한 응답 형식입니다.") +public record MemoryResponse( + @Schema(example = "1") + Long memoryId, + @Schema(example = "https://example.com/memorys/geumohrm.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memoryThumbnailUrl, + @Schema(example = "런던 추억") + String memoryTitle, + @Schema(example = "2024-07-27") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate startAt, + @Schema(example = "2024-07-29") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate endAt +) { + public MemoryResponse(Memory memory) { + this( + memory.getId(), + memory.getThumbnailUrl(), + memory.getTitle(), + memory.getTerm().getStartAt(), + memory.getTerm().getEndAt() + ); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponses.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponses.java new file mode 100644 index 000000000..402c934f8 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponses.java @@ -0,0 +1,18 @@ +package com.staccato.memory.service.dto.response; + +import java.util.List; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "추억 목록 조회 시 반환 되는 응답 형식입니다.") +public record MemoryResponses( + List memories +) { + public static MemoryResponses from(List memories) { + return new MemoryResponses(memories.stream() + .map(MemoryResponse::new) + .toList()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MomentResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MomentResponse.java new file mode 100644 index 000000000..95eed9370 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MomentResponse.java @@ -0,0 +1,25 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "추억 조회 시 보여주는 스타카토의 정보에 대한 응답 형식입니다.") +public record MomentResponse( + @Schema(example = "1") + Long momentId, + @Schema(example = "런던 아이") + String placeName, + @Schema(example = "https://example.com/memorys/london_eye.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String momentImageUrl, + @Schema(example = "2024-07-27T11:58:20") + LocalDateTime visitedAt +) { + public MomentResponse(Moment moment, String momentImageUrl) { + this(moment.getId(), moment.getPlaceName(), momentImageUrl, moment.getVisitedAt()); + } +} diff --git a/backend/src/main/java/com/staccato/moment/controller/MomentController.java b/backend/src/main/java/com/staccato/moment/controller/MomentController.java new file mode 100644 index 000000000..c29828063 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/MomentController.java @@ -0,0 +1,91 @@ +package com.staccato.moment.controller; + +import java.net.URI; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.config.auth.LoginMember; +import com.staccato.member.domain.Member; +import com.staccato.moment.controller.docs.MomentControllerDocs; +import com.staccato.moment.service.MomentService; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/moments") +@RequiredArgsConstructor +@Validated +public class MomentController implements MomentControllerDocs { + private final MomentService momentService; + + @PostMapping + public ResponseEntity createMoment( + @LoginMember Member member, + @Valid @RequestBody MomentRequest momentRequest + ) { + MomentIdResponse momentIdResponse = momentService.createMoment(momentRequest, member); + return ResponseEntity.created(URI.create("/moments/" + momentIdResponse.momentId())) + .body(momentIdResponse); + } + + @GetMapping + public ResponseEntity readAllMoment(@LoginMember Member member) { + MomentLocationResponses momentLocationResponses = momentService.readAllMoment(member); + return ResponseEntity.ok().body(momentLocationResponses); + } + + @GetMapping("/{momentId}") + public ResponseEntity readMomentById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId) { + MomentDetailResponse momentDetailResponse = momentService.readMomentById(momentId, member); + return ResponseEntity.ok().body(momentDetailResponse); + } + + @PutMapping(path = "/{momentId}") + public ResponseEntity updateMomentById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId, + @Valid @RequestBody MomentUpdateRequest request + ) { + momentService.updateMomentById(momentId, request, member); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{momentId}") + public ResponseEntity deleteMomentById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId + ) { + momentService.deleteMomentById(momentId, member); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{momentId}/feeling") + public ResponseEntity updateMomentFeelingById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId, + @Valid @RequestBody FeelingRequest feelingRequest + ) { + momentService.updateMomentFeelingById(momentId, member, feelingRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/controller/docs/MomentControllerDocs.java b/backend/src/main/java/com/staccato/moment/controller/docs/MomentControllerDocs.java new file mode 100644 index 000000000..380aad8f5 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/docs/MomentControllerDocs.java @@ -0,0 +1,117 @@ +package com.staccato.moment.controller.docs; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +import com.staccato.member.domain.Member; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Moment", description = "Moment API") +public interface MomentControllerDocs { + @Operation(summary = "스타카토 생성", description = "스타카토를 생성합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 생성 성공", responseCode = "201"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 필수 값(사진을 제외한 모든 값)이 누락되었을 때 + + (2) 존재하지 않는 memoryId일 때 + + (3) 올바르지 않은 날짜 형식일 때 + + (4) 사진이 5장을 초과했을 때 + + (5) 스타카토 날짜가 추억 기간에 포함되지 않을 때 + """, + responseCode = "400") + }) + ResponseEntity createMoment( + @Parameter(hidden = true) Member member, + @Parameter(required = true) @Valid MomentRequest momentRequest + ); + + @Operation(summary = "스타카토 목록 조회", description = "스타카토 목록을 조회합니다.") + @ApiResponse(description = "스타카토 목록 조회 성공", responseCode = "200") + ResponseEntity readAllMoment(@Parameter(hidden = true) Member member); + + @Operation(summary = "스타카토 조회", description = "스타카토를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 조회 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 조회하려는 스타카토가 존재하지 않을 때 + + (2) Path Variable 형식이 잘못되었을 때 + """, + responseCode = "400") + }) + ResponseEntity readMomentById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId); + + @Operation(summary = "스타카토 수정", description = "스타카토를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 수정 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 조회하려는 스타카토가 존재하지 않을 때 + + (2) Path Variable 형식이 잘못되었을 때 + + (3) 사진의 총 갯수가 5장을 초과하였을 때 + """, + responseCode = "400") + }) + ResponseEntity updateMomentById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId, + @Parameter(required = true) @Valid MomentUpdateRequest request); + + @Operation(summary = "스타카토 삭제", description = "스타카토를 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 삭제에 성공했거나 해당 스타카토가 존재하지 않는 경우", responseCode = "200"), + @ApiResponse(description = "스타카토 식별자에 양수가 아닌 값을 기입했을 경우", responseCode = "400") + }) + ResponseEntity deleteMomentById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId + ); + + @Operation(summary = "스타카토 기분 선택", description = "스타카토의 기분을 선택합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 기분 선택 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 조회하려는 스타카토가 존재하지 않을 때 + + (2) Path Variable 형식이 잘못되었을 때 + + (3) RequestBody 형식이 잘못되었을 때 + + (4) 요청한 기분 표현을 찾을 수 없을 때 + """, + responseCode = "400") + }) + ResponseEntity updateMomentFeelingById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long momentId, + @Parameter(required = true) @Valid FeelingRequest feelingRequest); +} diff --git a/backend/src/main/java/com/staccato/moment/controller/docs/MultipartJackson2HttpMessageConverter.java b/backend/src/main/java/com/staccato/moment/controller/docs/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 000000000..aad02c6ff --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/docs/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,31 @@ +package com.staccato.moment.controller.docs; + +import java.lang.reflect.Type; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/Feeling.java b/backend/src/main/java/com/staccato/moment/domain/Feeling.java new file mode 100644 index 000000000..fbec64c3c --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/Feeling.java @@ -0,0 +1,31 @@ +package com.staccato.moment.domain; + +import java.util.Arrays; + +import com.staccato.exception.StaccatoException; + +public enum Feeling { + HAPPY("happy"), + ANGRY("angry"), + SAD("sad"), + SCARED("scared"), + EXCITED("excited"), + NOTHING("nothing"); + + private final String feeling; + + Feeling(String feeling) { + this.feeling = feeling; + } + + public static Feeling match(String value) { + return Arrays.stream(values()) + .filter(mood -> mood.getValue().equals(value)) + .findFirst() + .orElseThrow(() -> new StaccatoException("요청하신 기분 표현을 찾을 수 없어요.")); + } + + public String getValue() { + return feeling; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/Moment.java b/backend/src/main/java/com/staccato/moment/domain/Moment.java new file mode 100644 index 000000000..4f3adef79 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/Moment.java @@ -0,0 +1,103 @@ +package com.staccato.moment.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; + +import com.staccato.comment.domain.Comment; +import com.staccato.config.domain.BaseEntity; +import com.staccato.exception.StaccatoException; +import com.staccato.memory.domain.Memory; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Moment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private LocalDateTime visitedAt; + @Column(nullable = false) + private String placeName; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Feeling feeling = Feeling.NOTHING; + @Column(nullable = false) + @Embedded + private Spot spot; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "memory_id", nullable = false) + private Memory memory; + @Embedded + private MomentImages momentImages = new MomentImages(); + @OneToMany(mappedBy = "moment", orphanRemoval = true, cascade = CascadeType.REMOVE) + private List comments = new ArrayList<>(); + + @Builder + public Moment( + @NonNull LocalDateTime visitedAt, + @NonNull String placeName, + @NonNull String address, + @NonNull BigDecimal latitude, + @NonNull BigDecimal longitude, + @NonNull MomentImages momentImages, + @NonNull Memory memory + ) { + validateIsWithinMemoryDuration(visitedAt, memory); + this.visitedAt = visitedAt.truncatedTo(ChronoUnit.SECONDS); + this.placeName = placeName.trim(); + this.spot = new Spot(address, latitude, longitude); + this.momentImages.addAll(momentImages, this); + this.memory = memory; + } + + private void validateIsWithinMemoryDuration(LocalDateTime visitedAt, Memory memory) { + if (memory.isWithoutDuration(visitedAt)) { + throw new StaccatoException("추억에 포함되지 않는 날짜입니다."); + } + } + + public void addComment(Comment comment) { + this.comments.add(comment); + } + + public void update(String placeName, MomentImages newMomentImages) { + this.placeName = placeName; + this.momentImages.update(newMomentImages, this); + } + + public String getThumbnailUrl() { + return momentImages.getImages().get(0).getImageUrl(); + } + + public boolean hasImage() { + return momentImages.isNotEmpty(); + } + + public void changeFeeling(Feeling feeling) { + this.feeling = feeling; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/MomentImage.java b/backend/src/main/java/com/staccato/moment/domain/MomentImage.java new file mode 100644 index 000000000..35486b35e --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/MomentImage.java @@ -0,0 +1,39 @@ +package com.staccato.moment.domain; + +import jakarta.annotation.Nonnull; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MomentImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(columnDefinition = "TEXT") + private String imageUrl; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "moment_id", nullable = false) + private Moment moment; + + @Builder + public MomentImage(@Nonnull String imageUrl) { + this.imageUrl = imageUrl; + } + + protected void belongTo(Moment moment) { + this.moment = moment; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/MomentImages.java b/backend/src/main/java/com/staccato/moment/domain/MomentImages.java new file mode 100644 index 000000000..9d4b32802 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/MomentImages.java @@ -0,0 +1,56 @@ +package com.staccato.moment.domain; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; + +import com.staccato.exception.StaccatoException; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MomentImages { + private static final int MAX_COUNT = 5; + @OneToMany(mappedBy = "moment", orphanRemoval = true, cascade = CascadeType.ALL) + private List images = new ArrayList<>(); + + public MomentImages(List addedImages) { + validateNumberOfImages(addedImages); + this.images.addAll(addedImages.stream() + .map(MomentImage::new) + .toList()); + } + + private void validateNumberOfImages(List addedImages) { + if (addedImages.size() > MAX_COUNT) { + throw new StaccatoException("사진은 5장을 초과할 수 없습니다."); + } + } + + protected void addAll(MomentImages newMomentImages, Moment moment) { + newMomentImages.images.forEach(image -> { + this.images.add(image); + image.belongTo(moment); + }); + } + + protected void update(MomentImages momentImages, Moment moment) { + removeExistsImages(new ArrayList<>(images)); + addAll(momentImages, moment); + } + + private void removeExistsImages(List originalImages) { + originalImages.forEach(this.images::remove); + } + + public boolean isNotEmpty() { + return !images.isEmpty(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/Spot.java b/backend/src/main/java/com/staccato/moment/domain/Spot.java new file mode 100644 index 000000000..40bee500a --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/Spot.java @@ -0,0 +1,23 @@ +package com.staccato.moment.domain; + +import java.math.BigDecimal; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Embeddable +public class Spot { + @Column(nullable = false) + private String address; + @Column(nullable = false, columnDefinition = "DECIMAL(16, 14)") + private BigDecimal latitude; + @Column(nullable = false, columnDefinition = "DECIMAL(17, 14)") + private BigDecimal longitude; +} diff --git a/backend/src/main/java/com/staccato/moment/repository/MomentImageRepository.java b/backend/src/main/java/com/staccato/moment/repository/MomentImageRepository.java new file mode 100644 index 000000000..3a3a7b879 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/repository/MomentImageRepository.java @@ -0,0 +1,11 @@ +package com.staccato.moment.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.moment.domain.MomentImage; + +public interface MomentImageRepository extends JpaRepository { + Optional findFirstByMomentId(long momentId); +} diff --git a/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java b/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java new file mode 100644 index 000000000..4e23d4101 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java @@ -0,0 +1,16 @@ +package com.staccato.moment.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +public interface MomentRepository extends JpaRepository { + List findAllByMemoryIdOrderByVisitedAt(long memoryId); + + void deleteAllByMemoryId(long memoryId); + + List findAllByMemory_MemoryMembers_Member(Member member); +} diff --git a/backend/src/main/java/com/staccato/moment/service/MomentService.java b/backend/src/main/java/com/staccato/moment/service/MomentService.java new file mode 100644 index 000000000..56332b4a8 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/MomentService.java @@ -0,0 +1,96 @@ +package com.staccato.moment.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Feeling; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MomentService { + private final MomentRepository momentRepository; + private final MemoryRepository memoryRepository; + + @Transactional + public MomentIdResponse createMoment(MomentRequest momentRequest, Member member) { + Memory memory = getMemoryById(momentRequest.memoryId()); + validateOwner(memory, member); + Moment moment = momentRequest.toMoment(memory); + + momentRepository.save(moment); + + return new MomentIdResponse(moment.getId()); + } + + private Memory getMemoryById(long memoryId) { + return memoryRepository.findById(memoryId) + .orElseThrow(() -> new StaccatoException("요청하신 추억을 찾을 수 없어요.")); + } + + public MomentLocationResponses readAllMoment(Member member) { + return new MomentLocationResponses(momentRepository.findAllByMemory_MemoryMembers_Member(member) + .stream() + .map(MomentLocationResponse::new).toList()); + } + + public MomentDetailResponse readMomentById(long momentId, Member member) { + Moment moment = getMomentById(momentId); + validateOwner(moment.getMemory(), member); + return new MomentDetailResponse(moment); + } + + @Transactional + public void updateMomentById( + long momentId, + MomentUpdateRequest momentUpdateRequest, + Member member + ) { + Moment moment = getMomentById(momentId); + validateOwner(moment.getMemory(), member); + moment.update(momentUpdateRequest.placeName(), momentUpdateRequest.toMomentImages()); + } + + private Moment getMomentById(long momentId) { + return momentRepository.findById(momentId) + .orElseThrow(() -> new StaccatoException("요청하신 스타카토를 찾을 수 없어요.")); + } + + @Transactional + public void deleteMomentById(long momentId, Member member) { + momentRepository.findById(momentId).ifPresent(moment -> { + validateOwner(moment.getMemory(), member); + momentRepository.deleteById(momentId); + }); + } + + private void validateOwner(Memory memory, Member member) { + if (memory.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } + + @Transactional + public void updateMomentFeelingById(long momentId, Member member, FeelingRequest feelingRequest) { + Moment moment = getMomentById(momentId); + validateOwner(moment.getMemory(), member); + Feeling feeling = feelingRequest.toFeeling(); + moment.changeFeeling(feeling); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/request/FeelingRequest.java b/backend/src/main/java/com/staccato/moment/service/dto/request/FeelingRequest.java new file mode 100644 index 000000000..325d2f945 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/request/FeelingRequest.java @@ -0,0 +1,14 @@ +package com.staccato.moment.service.dto.request; + +import jakarta.validation.constraints.NotNull; + +import com.staccato.moment.domain.Feeling; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 기분 표현 요청") +public record FeelingRequest(@Schema(description = "기분 표현", example = "happy") @NotNull(message = "기분 값을 입력해주세요.") String feeling) { + public Feeling toFeeling() { + return Feeling.match(feeling); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/request/MomentRequest.java b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentRequest.java new file mode 100644 index 000000000..012e4b227 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentRequest.java @@ -0,0 +1,67 @@ +package com.staccato.moment.service.dto.request; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.staccato.memory.domain.Memory; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImages; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 생성 시 요청 형식입니다. 단, 멀티파트로 보내는 사진 파일은 여기에 포함되지 않습니다.") +public record MomentRequest( + @Schema(example = "런던 박물관") + @NotBlank(message = "스타카토 제목을 입력해주세요.") + @Size(max = 30, message = "스타카토 제목은 공백 포함 30자 이하로 설정해주세요.") + String placeName, + @Schema(example = "Great Russell St, London WC1B 3DG") + @NotNull(message = "스타카토의 주소를 입력해주세요.") + String address, + @Schema(example = "51.51978412729915") + @NotNull(message = "스타카토의 위도를 입력해주세요.") + BigDecimal latitude, + @Schema(example = "-0.12712788587027796") + @NotNull(message = "스타카토의 경도를 입력해주세요.") + BigDecimal longitude, + @Schema(example = "2024-07-27") + @NotNull(message = "스타카토 날짜를 입력해주세요.") + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime visitedAt, + @Schema(example = "1") + @NotNull(message = "추억을 선택해주세요.") + @Min(value = 1L, message = "추억 식별자는 양수로 이루어져야 합니다.") + long memoryId, + @ArraySchema( + arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + @Size(max = 5, message = "사진은 5장까지만 추가할 수 있어요.") + List momentImageUrls +) { + public MomentRequest { + if (Objects.nonNull(placeName)) { + placeName = placeName.trim(); + } + } + + public Moment toMoment(Memory memory) { + return Moment.builder() + .visitedAt(visitedAt) + .placeName(placeName) + .latitude(latitude) + .longitude(longitude) + .address(address) + .memory(memory) + .momentImages(new MomentImages(momentImageUrls)) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/request/MomentUpdateRequest.java b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentUpdateRequest.java new file mode 100644 index 000000000..336855a75 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentUpdateRequest.java @@ -0,0 +1,26 @@ +package com.staccato.moment.service.dto.request; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import com.staccato.moment.domain.MomentImages; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 수정 시 요청 형식입니다.") +public record MomentUpdateRequest( + @Schema(example = "남산 서울타워") + @NotNull(message = "스타카토 제목을 입력해주세요.") + String placeName, + @ArraySchema( + arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + @Size(max = 5, message = "사진은 5장까지만 추가할 수 있어요.") + List momentImageUrls) { + + public MomentImages toMomentImages() { + return new MomentImages(momentImageUrls); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentDetailResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentDetailResponse.java new file mode 100644 index 000000000..82ff55878 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentDetailResponse.java @@ -0,0 +1,46 @@ +package com.staccato.moment.service.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.staccato.comment.service.dto.response.CommentResponse; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImage; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토를 조회했을 때 응답 형식입니다.") +public record MomentDetailResponse( + @Schema(example = "1") + long momentId, + @Schema(example = "1") + long memoryId, + @Schema(example = "2024 서울 투어") + String memoryTitle, + @Schema(example = "남산 서울타워") + String placeName, + @ArraySchema(arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + List momentImageUrls, + @Schema(example = "2021-11-08T11:58:20") + LocalDateTime visitedAt, + @Schema(example = "happy") + String feeling, + @Schema(example = "서울 용산구 남산공원길 105") + String address, + List comments +) { + public MomentDetailResponse(Moment moment) { + this( + moment.getId(), + moment.getMemory().getId(), + moment.getMemory().getTitle(), + moment.getPlaceName(), + moment.getMomentImages().getImages().stream().map(MomentImage::getImageUrl).toList(), + moment.getVisitedAt(), + moment.getFeeling().getValue(), + moment.getSpot().getAddress(), + moment.getComments().stream().map(CommentResponse::new).toList() + ); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentIdResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentIdResponse.java new file mode 100644 index 000000000..e6988b164 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentIdResponse.java @@ -0,0 +1,10 @@ +package com.staccato.moment.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 생성 시 응답 형식입니다.") +public record MomentIdResponse( + @Schema(example = "1") + long momentId +) { +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponse.java new file mode 100644 index 000000000..7a16de0eb --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponse.java @@ -0,0 +1,21 @@ +package com.staccato.moment.service.dto.response; + +import java.math.BigDecimal; + +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 목록 중 하나의 스타카토에 해당하는 응답입니다.") +public record MomentLocationResponse( + @Schema(example = "1") + long momentId, + @Schema(example = "51.51978412729915") + BigDecimal latitude, + @Schema(example = "-0.12712788587027796") + BigDecimal longitude) { + + public MomentLocationResponse(Moment moment) { + this(moment.getId(), moment.getSpot().getLatitude(), moment.getSpot().getLongitude()); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponses.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponses.java new file mode 100644 index 000000000..96ac2986e --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponses.java @@ -0,0 +1,9 @@ +package com.staccato.moment.service.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 목록에 해당하는 응답입니다.") +public record MomentLocationResponses(List momentLocationResponses) { +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 000000000..e350c0309 --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,52 @@ +spring: + config: + activate: + on-profile: dev + application: + name: staccato + sql: + init: + mode: always + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + database: MYSQL + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: techcourse-project-2024 + endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + cloudfront: + endpoint: https://d25aribbn0gp8k.cloudfront.net + region: + static: ap-northeast-2 + stack: + auto: false +image: + folder: + name: image/ diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 000000000..d517a93c3 --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,50 @@ +spring: + config: + activate: + on-profile: local + application: + name: staccato + sql: + init: + mode: always + h2: + console: + enabled: true + datasource: + url: jdbc:h2:mem:staccato + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: techcourse-project-2024 + endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + cloudfront: + endpoint: https://d25aribbn0gp8k.cloudfront.net + region: + static: ap-northeast-2 + stack: + auto: false +image: + folder: + name: image/ diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 000000000..c17137e0d --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,54 @@ +spring: + config: + activate: + on-profile: prod + application: + name: staccato + sql: + init: + mode: always + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + database: MYSQL + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: validate + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + api-docs: + enabled: false +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: ${AWS_S3_BUCKET} + endpoint: ${AWS_S3_ENDPOINT} + cloudfront: + endpoint: ${AWS_CLOUDFRONT_ENDPOINT} + region: + static: ${AWS_REGION_STATIC} + stack: + auto: false +image: + folder: + name: image-prod/ diff --git a/backend/src/main/resources/application-stage.yml b/backend/src/main/resources/application-stage.yml new file mode 100644 index 000000000..aece96fb9 --- /dev/null +++ b/backend/src/main/resources/application-stage.yml @@ -0,0 +1,52 @@ +spring: + config: + activate: + on-profile: stage + application: + name: staccato + sql: + init: + mode: always + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + database: MYSQL + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: techcourse-project-2024 + endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + cloudfront: + endpoint: https://d25aribbn0gp8k.cloudfront.net + region: + static: ap-northeast-2 + stack: + auto: false +image: + folder: + name: image/ diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 000000000..543fa4da7 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,4 @@ +spring: + profiles: + active: local + diff --git a/backend/src/main/resources/console-appender.xml b/backend/src/main/resources/console-appender.xml new file mode 100644 index 000000000..46b452f4c --- /dev/null +++ b/backend/src/main/resources/console-appender.xml @@ -0,0 +1,7 @@ + + + + [%d{yyyy-MM-dd HH:mm:ss}:%-4relative] [%thread] [request_id=%X{request_id:-startup}] %highlight(%-5level) [%C.%M.-%L] - %msg%n + + + diff --git a/backend/src/main/resources/error-appender.xml b/backend/src/main/resources/error-appender.xml new file mode 100644 index 000000000..ff8f75fac --- /dev/null +++ b/backend/src/main/resources/error-appender.xml @@ -0,0 +1,20 @@ + + + ./logs/error/error-${BY_DATE}.log + + + ERROR + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level [%C.%M.-%L] - %msg%n + + + ./backup/error/error-%d{yyyy-MM-dd}.%i.log + 10MB + 15 + 3GB + + + diff --git a/backend/src/main/resources/info-appender.xml b/backend/src/main/resources/info-appender.xml new file mode 100644 index 000000000..da27ad381 --- /dev/null +++ b/backend/src/main/resources/info-appender.xml @@ -0,0 +1,20 @@ + + + ./logs/info/info-${BY_DATE}.log + + + INFO + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level - %msg%n + + + ./backup/info/info-%d{yyyy-MM-dd}.%i.log + 10MB + 15 + 3GB + + + diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..99a0b5a22 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/warn-appender.xml b/backend/src/main/resources/warn-appender.xml new file mode 100644 index 000000000..7abeb8b76 --- /dev/null +++ b/backend/src/main/resources/warn-appender.xml @@ -0,0 +1,20 @@ + + + ./logs/warn/warn-${BY_DATE}.log + + + WARN + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level [%C.%M.-%L] - %msg%n + + + ./backup/warn/warn-%d{yyyy-MM-dd}.%i.log + 10MB + 15 + 3GB + + + diff --git a/backend/src/test/java/com/staccato/IntegrationTest.java b/backend/src/test/java/com/staccato/IntegrationTest.java new file mode 100644 index 000000000..8e62b1d0b --- /dev/null +++ b/backend/src/test/java/com/staccato/IntegrationTest.java @@ -0,0 +1,22 @@ +package com.staccato; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import com.staccato.util.DatabaseCleanerExtension; + +import io.restassured.RestAssured; + +@ExtendWith(DatabaseCleanerExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class IntegrationTest { + @LocalServerPort + private int port; + + @BeforeEach + void setPort() { + RestAssured.port = port; + } +} diff --git a/backend/src/test/java/com/staccato/ServiceSliceTest.java b/backend/src/test/java/com/staccato/ServiceSliceTest.java new file mode 100644 index 000000000..89fe32199 --- /dev/null +++ b/backend/src/test/java/com/staccato/ServiceSliceTest.java @@ -0,0 +1,13 @@ +package com.staccato; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import com.staccato.util.DatabaseCleanerExtension; + +@ExtendWith(DatabaseCleanerExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@Import({TestConfig.class}) +public abstract class ServiceSliceTest { +} diff --git a/backend/src/test/java/com/staccato/StaccatoApplicationTests.java b/backend/src/test/java/com/staccato/StaccatoApplicationTests.java new file mode 100644 index 000000000..861820789 --- /dev/null +++ b/backend/src/test/java/com/staccato/StaccatoApplicationTests.java @@ -0,0 +1,13 @@ +package com.staccato; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class StaccatoApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/src/test/java/com/staccato/TestConfig.java b/backend/src/test/java/com/staccato/TestConfig.java new file mode 100644 index 000000000..040f083de --- /dev/null +++ b/backend/src/test/java/com/staccato/TestConfig.java @@ -0,0 +1,15 @@ +package com.staccato; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import com.staccato.image.infrastructure.FakeS3ObjectClient; +import com.staccato.image.infrastructure.S3ObjectClient; + +@TestConfiguration +public class TestConfig { + @Bean + public S3ObjectClient cloudStorageClient() { + return new FakeS3ObjectClient(); + } +} diff --git a/backend/src/test/java/com/staccato/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/staccato/auth/controller/AuthControllerTest.java new file mode 100644 index 000000000..075ba0d79 --- /dev/null +++ b/backend/src/test/java/com/staccato/auth/controller/AuthControllerTest.java @@ -0,0 +1,77 @@ +package com.staccato.auth.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; +import com.staccato.exception.ExceptionResponse; + +@WebMvcTest(AuthController.class) +class AuthControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private AuthService authService; + + @DisplayName("유효한 로그인 요청이 들어오면 성공 응답을 한다.") + @Test + void login() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest("staccato"); + LoginResponse loginResponse = new LoginResponse("staccatotoken"); + when(authService.login(loginRequest)).thenReturn(loginResponse); + + // when & then + mockMvc.perform(post("/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(loginResponse))); + } + + @DisplayName("닉네임을 입력하지 않으면 400을 반환한다.") + @Test + void cannotLoginIfBadRequest() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest(null); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "닉네임을 입력해주세요."); + + // when & then + mockMvc.perform(post("/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("20자를 초과하면 400을 반환한다.") + @Test + void cannotLoginIfLengthExceeded() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest("가".repeat(21)); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "1자 이상 20자 이하의 닉네임으로 설정해주세요."); + + // when & then + mockMvc.perform(post("/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/auth/service/AuthServiceTest.java b/backend/src/test/java/com/staccato/auth/service/AuthServiceTest.java new file mode 100644 index 000000000..2f69cc24e --- /dev/null +++ b/backend/src/test/java/com/staccato/auth/service/AuthServiceTest.java @@ -0,0 +1,68 @@ +package com.staccato.auth.service; + +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; +import com.staccato.exception.StaccatoException; +import com.staccato.exception.UnauthorizedException; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; + +class AuthServiceTest extends ServiceSliceTest { + @Autowired + private AuthService authService; + @Autowired + private MemberRepository memberRepository; + + @DisplayName("입력받은 닉네임으로 멤버를 저장하고, 토큰을 생성한다.") + @Test + void login() { + // given + String nickname = "staccato"; + LoginRequest loginRequest = new LoginRequest(nickname); + + // when + LoginResponse loginResponse = authService.login(loginRequest); + + // then + assertAll( + () -> assertThat(memberRepository.findAll()).hasSize(1), + () -> assertThat(loginResponse.token()).isNotNull() + ); + } + + @DisplayName("입력받은 닉네임이 이미 존재하는 닉네임인 경우 예외가 발생한다.") + @Test + void cannotLoginByDuplicated() { + // given + String nickname = "staccato"; + memberRepository.save(Member.builder().nickname(nickname).build()); + LoginRequest loginRequest = new LoginRequest(nickname); + + // when & then + assertThatThrownBy(() -> authService.login(loginRequest)) + .isInstanceOf(StaccatoException.class) + .hasMessage("이미 존재하는 닉네임입니다. 다시 설정해주세요."); + } + + @DisplayName("만약 전달 받은 토큰이 null일 경우 예외가 발생한다.") + @Test + void cannotExtractMemberByUnknown() { + // given + String nickname = "staccato"; + memberRepository.save(Member.builder().nickname(nickname).build()); + + // when & then + assertThatThrownBy(() -> authService.extractFromToken(null)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("인증되지 않은 사용자입니다."); + } +} diff --git a/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java b/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java new file mode 100644 index 000000000..a2b44caf8 --- /dev/null +++ b/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java @@ -0,0 +1,249 @@ +package com.staccato.comment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +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 org.junit.jupiter.params.provider.NullSource; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.comment.service.CommentService; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponse; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; + +@WebMvcTest(CommentController.class) +public class CommentControllerTest { + private static final int MAX_CONTENT_LENGTH = 500; + private static final int MIN_CONTENT_LENGTH = 1; + private static final long MIN_MOMENT_ID = 1L; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private CommentService commentService; + @MockBean + private AuthService authService; + + static Stream commentRequestProvider() { + return Stream.of( + new CommentRequest(MIN_MOMENT_ID, "1".repeat(MIN_CONTENT_LENGTH)), + new CommentRequest(MIN_MOMENT_ID, "1".repeat(MAX_CONTENT_LENGTH)) + ); + } + + static Stream invalidCommentRequestProvider() { + return Stream.of( + Arguments.of( + new CommentRequest(null, "예시 댓글 내용"), + "스타카토를 선택해주세요." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID - 1, "예시 댓글 내용"), + "스타카토 식별자는 양수로 이루어져야 합니다." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, null), + "댓글 내용을 입력해주세요." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, ""), + "댓글 내용을 입력해주세요." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, " "), + "댓글 내용을 입력해주세요." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, "1".repeat(MAX_CONTENT_LENGTH + 1)), + "댓글은 공백 포함 500자 이하로 입력해주세요." + ) + ); + } + + @DisplayName("올바른 형식으로 댓글을 생성하면 성공한다.") + @ParameterizedTest + @MethodSource("commentRequestProvider") + void createComment(CommentRequest commentRequest) throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + when(commentService.createComment(any(), any())).thenReturn(1L); + + // when & then + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(commentRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/comments/1")); + } + + @DisplayName("올바르지 않은 형식으로 정보를 입력하면, 댓글을 생성할 수 없다.") + @ParameterizedTest + @MethodSource("invalidCommentRequestProvider") + void createCommentFail(CommentRequest commentRequest, String expectedMessage) throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + when(commentService.createComment(any(), any())).thenReturn(1L); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(commentRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("올바른 형식으로 댓글 읽기를 시도하면 성공한다.") + @Test + void readCommentsByMomentId() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentResponses commentResponses = new CommentResponses(List.of( + new CommentResponse(1L, 1L, "member", "image.jpg", "내용") + )); + when(commentService.readAllCommentsByMomentId(any(), any())).thenReturn(commentResponses); + + // when & then + mockMvc.perform(get("/comments") + .param("momentId", "1") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(commentResponses))); + } + + @DisplayName("스타카토 식별자가 양수가 아닐 경우 댓글 읽기에 실패한다.") + @Test + void readCommentsByMomentIdFail() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentResponses commentResponses = new CommentResponses(List.of( + new CommentResponse(1L, 1L, "member", "image.jpg", "내용") + )); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 식별자는 양수로 이루어져야 합니다."); + when(commentService.readAllCommentsByMomentId(any(), any())).thenReturn(commentResponses); + + // when & then + mockMvc.perform(get("/comments") + .param("momentId", "0") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("올바른 형식으로 댓글 수정을 시도하면 성공한다.") + @Test + void updateComment() throws Exception { + // given + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest("updated content"); + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/comments") + .param("commentId", "1") + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("댓글 식별자가 양수가 아닐 경우 댓글 수정에 실패한다.") + @Test + void updateCommentFail() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest("updated content"); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "댓글 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(put("/comments") + .param("commentId", "0") + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("댓글 내용을 입력하지 않을 경우 댓글 수정에 실패한다.") + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void updateCommentFailByBlank(String updatedContent) throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "댓글 내용을 입력해주세요."); + + // when & then + mockMvc.perform(put("/comments") + .param("commentId", "1") + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("올바른 형식으로 댓글 삭제를 시도하면 성공한다.") + @Test + void deleteComment() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/comments") + .param("commentId", "1") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("댓글 식별자가 양수가 아닐 경우 댓글 삭제에 실패한다.") + @Test + void deleteCommentFail() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "댓글 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(delete("/comments") + .param("commentId", "0") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/comment/service/CommentServiceTest.java b/backend/src/test/java/com/staccato/comment/service/CommentServiceTest.java new file mode 100644 index 000000000..5f0e6334c --- /dev/null +++ b/backend/src/test/java/com/staccato/comment/service/CommentServiceTest.java @@ -0,0 +1,209 @@ +package com.staccato.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.comment.domain.Comment; +import com.staccato.comment.repository.CommentRepository; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponse; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.CommentFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +class CommentServiceTest extends ServiceSliceTest { + @Autowired + private CommentService commentService; + @Autowired + private CommentRepository commentRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MomentRepository momentRepository; + + @DisplayName("스타카토가 존재하면 댓글 생성에 성공한다.") + @Test + void createComment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + CommentRequest commentRequest = new CommentRequest(moment.getId(), "content"); + + // when + long commentId = commentService.createComment(commentRequest, member); + + // then + assertThat(commentRepository.findById(commentId)).isNotEmpty(); + } + + @DisplayName("존재하지 않는 스타카토에 댓글 생성을 시도하면 예외가 발생한다.") + @Test + void createCommentFailByNotExistMoment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + CommentRequest commentRequest = new CommentRequest(1L, "content"); + + // when & then + assertThatThrownBy(() -> commentService.createComment(commentRequest, member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("요청하신 스타카토를 찾을 수 없어요."); + } + + @DisplayName("권한이 없는 스타카토에 댓글 생성을 시도하면 예외가 발생한다.") + @Test + void createCommentFailByForbidden() { + // given + Member momentOwner = memberRepository.save(MemberFixture.create("momentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(momentOwner)); + momentRepository.save(MomentFixture.create(memory)); + CommentRequest commentRequest = new CommentRequest(1L, "content"); + + // when & then + assertThatThrownBy(() -> commentService.createComment(commentRequest, unexpectedMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("특정 스타카토에 속한 모든 댓글을 생성 순으로 조회한다.") + @Test + void readAllByMomentId() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Moment anotherMoment = momentRepository.save(MomentFixture.create(memory)); + CommentRequest commentRequest1 = new CommentRequest(moment.getId(), "content"); + CommentRequest commentRequest2 = new CommentRequest(moment.getId(), "content"); + CommentRequest commentRequestOfAnotherMoment = new CommentRequest(anotherMoment.getId(), "content"); + long commentId1 = commentService.createComment(commentRequest1, member); + long commentId2 = commentService.createComment(commentRequest2, member); + commentService.createComment(commentRequestOfAnotherMoment, member); + + // when + CommentResponses commentResponses = commentService.readAllCommentsByMomentId(member, moment.getId()); + + // then + assertThat(commentResponses.comments().stream().map(CommentResponse::commentId).toList()) + .containsExactly(commentId1, commentId2); + } + + @DisplayName("조회 권한이 없는 스타카토에 달린 댓글들 조회를 시도하면 예외가 발생한다.") + @Test + void readAllByMomentIdFailByForbidden() { + // given + Member momentOwner = memberRepository.save(MemberFixture.create("momentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(momentOwner)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + commentRepository.save(CommentFixture.create(moment, momentOwner)); + + // when & then + assertThatThrownBy(() -> commentService.readAllCommentsByMomentId(unexpectedMember, moment.getId())) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("본인이 쓴 댓글은 수정할 수 있다.") + @Test + void updateComment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, member)); + + String updatedContent = "updated content"; + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + + // when + commentService.updateComment(member, comment.getId(), commentUpdateRequest); + + // then + assertThat(commentRepository.findById(comment.getId()).get().getContent()).isEqualTo(updatedContent); + } + + @DisplayName("수정하려는 댓글을 찾을 수 없는 경우 예외가 발생한다.") + @Test + void updateCommentFailByNotExist() { + // given + long notExistCommentId = 1; + String updatedContent = "updated content"; + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + + // when & then + assertThatThrownBy(() -> commentService.updateComment(MemberFixture.create(), notExistCommentId, commentUpdateRequest)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("요청하신 댓글을 찾을 수 없어요."); + } + + @DisplayName("본인이 달지 않은 댓글에 대해 수정을 시도하면 예외가 발생한다.") + @Test + void updateCommentFailByForbidden() { + // given + Member momentOwner = memberRepository.save(MemberFixture.create("momentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(momentOwner)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, momentOwner)); + + String updatedContent = "updated content"; + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + + // when & then + assertThatThrownBy(() -> commentService.updateComment(unexpectedMember, comment.getId(), commentUpdateRequest)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("본인이 쓴 댓글은 삭제할 수 있다.") + @Test + void deleteComment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, member)); + + // when + commentService.deleteComment(comment.getId(), member); + + // then + assertThat(commentRepository.findById(comment.getId())).isEmpty(); + } + + @DisplayName("본인이 쓴 댓글이 아니면 삭제할 수 없다.") + @Test + void deleteCommentFail() { + // given + Member commentOwner = memberRepository.save(MemberFixture.create("commentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(commentOwner)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, commentOwner)); + + // when & then + assertThatThrownBy(() -> commentService.deleteComment(comment.getId(), unexpectedMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("요청하신 작업을 처리할 권한이 없습니다."); + } +} diff --git a/backend/src/test/java/com/staccato/config/auth/TokenProviderTest.java b/backend/src/test/java/com/staccato/config/auth/TokenProviderTest.java new file mode 100644 index 000000000..a81664121 --- /dev/null +++ b/backend/src/test/java/com/staccato/config/auth/TokenProviderTest.java @@ -0,0 +1,97 @@ +package com.staccato.config.auth; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; + +import com.staccato.exception.UnauthorizedException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; + +import io.jsonwebtoken.Claims; + +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +public class TokenProviderTest { + @MockBean + private TokenProperties tokenProperties; + @Autowired + private TokenProvider tokenProvider; + @Autowired + private MemberRepository memberRepository; + + private Member member; + private String secretKey = "my-secret-key"; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + when(tokenProperties.secretKey()).thenReturn(secretKey); + + member = memberRepository.save(MemberFixture.create()); + } + + @DisplayName("주어진 사용자 정보로 토큰을 생성한다.") + @Test + public void createToken() { + // given & when + String token = tokenProvider.create(member); + + // then + assertNotNull(token); + } + + @DisplayName("주어진 토큰에서 payload를 추출한다.") + @Test + public void getPayloadFromToken() { + // given + String token = tokenProvider.create(member); + + // when + Claims claims = tokenProvider.getPayload(token); + + // then + assertAll( + () -> assertNotNull(claims), + () -> assertThat(claims.get("id", Long.class)).isEqualTo(member.getId()), + () -> assertThat(claims.get("nickname")).isEqualTo(member.getNickname().getNickname()), + () -> assertThat(claims.get("createdAt")).isEqualTo(member.getCreatedAt().toString()) + ); + } + + @DisplayName("주어진 토큰에서 사용자 식별자를 추출한다.") + @Test + public void testExtractMemberId() { + // given + String token = tokenProvider.create(member); + + // when + long extractedId = tokenProvider.extractMemberId(token); + + // then + assertThat(extractedId).isEqualTo(member.getId()); + } + + @DisplayName("주어진 토큰이 올바르지 않으면 인증 오류가 발생한다.") + @Test + public void cannotGetPayloadByInvalidToken() { + // given + String invalidToken = "invalid.token.value"; + + // when & then + assertThatThrownBy(() -> tokenProvider.getPayload(invalidToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("인증되지 않은 사용자입니다."); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/Member/MemberFixture.java b/backend/src/test/java/com/staccato/fixture/Member/MemberFixture.java new file mode 100644 index 000000000..f214405ac --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/Member/MemberFixture.java @@ -0,0 +1,13 @@ +package com.staccato.fixture.Member; + +import com.staccato.member.domain.Member; + +public class MemberFixture { + public static Member create() { + return Member.builder().nickname("staccato").build(); + } + + public static Member create(String nickname) { + return Member.builder().nickname(nickname).build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryFixture.java new file mode 100644 index 000000000..8d40fc130 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryFixture.java @@ -0,0 +1,51 @@ +package com.staccato.fixture.memory; + +import java.time.LocalDate; + +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; + +public class MemoryFixture { + public static Memory create() { + return Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title("2024 여름 휴가") + .description("친구들과 함께한 여름 휴가 추억") + .startAt(LocalDate.of(2024, 7, 1)) + .endAt(LocalDate.of(2024, 7, 10)) + .build(); + } + + public static Memory create(String title) { + return Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title(title) + .description("친구들과 함께한 여름 휴가 추억") + .startAt(LocalDate.of(2024, 7, 1)) + .endAt(LocalDate.of(2024, 7, 10)) + .build(); + } + + public static Memory create(Member member) { + Memory memory = Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title("2024 여름 휴가") + .description("친구들과 함께한 여름 휴가 추억") + .startAt(LocalDate.of(2024, 7, 1)) + .endAt(LocalDate.of(2024, 7, 10)) + .build(); + memory.addMemoryMember(member); + + return memory; + } + + public static Memory create(LocalDate startAt, LocalDate endAt) { + return Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title("2024 여름 휴가") + .description("친구들과 함께한 여름 휴가 추억") + .startAt(startAt) + .endAt(endAt) + .build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryNameResponsesFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryNameResponsesFixture.java new file mode 100644 index 000000000..b1f283833 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryNameResponsesFixture.java @@ -0,0 +1,14 @@ +package com.staccato.fixture.memory; + +import java.util.Arrays; +import java.util.List; + +import com.staccato.memory.domain.Memory; +import com.staccato.memory.service.dto.response.MemoryNameResponses; + +public class MemoryNameResponsesFixture { + public static MemoryNameResponses create(Memory... memory) { + List memories = Arrays.stream(memory).toList(); + return MemoryNameResponses.from(memories); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryRequestFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryRequestFixture.java new file mode 100644 index 000000000..d9a078693 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryRequestFixture.java @@ -0,0 +1,37 @@ +package com.staccato.fixture.memory; + +import java.time.LocalDate; + +import com.staccato.memory.service.dto.request.MemoryRequest; + +public class MemoryRequestFixture { + public static MemoryRequest create(LocalDate startAt, LocalDate endAt) { + return new MemoryRequest( + "https://example.com/memorys/geumohrm.jpg", + "2023 여름 휴가", + "친구들과 함께한 여름 휴가 추억", + startAt, + endAt + ); + } + + public static MemoryRequest create(LocalDate startAt, LocalDate endAt, String title) { + return new MemoryRequest( + "https://example.com/memorys/geumohrm.jpg", + title, + "친구들과 함께한 여름 휴가 추억", + startAt, + endAt + ); + } + + public static MemoryRequest create(String imageUrl, LocalDate startAt, LocalDate endAt) { + return new MemoryRequest( + imageUrl, + "2023 여름 휴가", + "친구들과 함께한 여름 휴가 추억", + startAt, + endAt + ); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryResponsesFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryResponsesFixture.java new file mode 100644 index 000000000..e60b2bce1 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryResponsesFixture.java @@ -0,0 +1,18 @@ +package com.staccato.fixture.memory; + +import java.util.Arrays; +import java.util.List; + +import com.staccato.memory.domain.Memory; +import com.staccato.memory.service.dto.response.MemoryResponse; +import com.staccato.memory.service.dto.response.MemoryResponses; + +public class MemoryResponsesFixture { + public static MemoryResponses create(Memory... memories) { + return new MemoryResponses(convertToMemoryResponses(Arrays.stream(memories).toList())); + } + + private static List convertToMemoryResponses(List memory) { + return memory.stream().map(MemoryResponse::new).toList(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/CommentFixture.java b/backend/src/test/java/com/staccato/fixture/moment/CommentFixture.java new file mode 100644 index 000000000..9c6fac81e --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/CommentFixture.java @@ -0,0 +1,15 @@ +package com.staccato.fixture.moment; + +import com.staccato.comment.domain.Comment; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +public class CommentFixture { + public static Comment create(Moment moment, Member member) { + return Comment.builder() + .content("Sample Moment Log") + .moment(moment) + .member(member) + .build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/MomentDetailResponseFixture.java b/backend/src/test/java/com/staccato/fixture/moment/MomentDetailResponseFixture.java new file mode 100644 index 000000000..07aa254c4 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/MomentDetailResponseFixture.java @@ -0,0 +1,21 @@ +package com.staccato.fixture.moment; + +import java.time.LocalDateTime; +import java.util.List; + +import com.staccato.moment.service.dto.response.MomentDetailResponse; + +public class MomentDetailResponseFixture { + public static MomentDetailResponse create(long momentId, LocalDateTime visitedAt) { + return new MomentDetailResponse( + momentId, + 1, + "memoryTitle", + "placeName", + List.of("https://example1.com.jpg"), + visitedAt, + "happy", + "address", + List.of()); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/MomentFixture.java b/backend/src/test/java/com/staccato/fixture/moment/MomentFixture.java new file mode 100644 index 000000000..0273648fc --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/MomentFixture.java @@ -0,0 +1,50 @@ +package com.staccato.fixture.moment; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import com.staccato.memory.domain.Memory; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImages; + +public class MomentFixture { + private static final BigDecimal latitude = new BigDecimal("37.7749"); + private static final BigDecimal longitude = new BigDecimal("-122.4194"); + + public static Moment create(Memory memory) { + return Moment.builder() + .visitedAt(LocalDateTime.of(2024, 7, 1, 10, 0)) + .placeName("placeName") + .latitude(latitude) + .longitude(longitude) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build(); + } + + public static Moment create(Memory memory, LocalDateTime visitedAt) { + return Moment.builder() + .visitedAt(visitedAt) + .placeName("placeName") + .latitude(latitude) + .longitude(longitude) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build(); + } + + public static Moment createWithImages(Memory memory, LocalDateTime visitedAt, MomentImages momentImages) { + return Moment.builder() + .visitedAt(visitedAt) + .placeName("placeName") + .latitude(latitude) + .longitude(longitude) + .address("address") + .memory(memory) + .momentImages(momentImages) + .build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/MomentLocationResponsesFixture.java b/backend/src/test/java/com/staccato/fixture/moment/MomentLocationResponsesFixture.java new file mode 100644 index 000000000..2194dd463 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/MomentLocationResponsesFixture.java @@ -0,0 +1,16 @@ +package com.staccato.fixture.moment; + +import java.math.BigDecimal; +import java.util.List; + +import com.staccato.moment.service.dto.response.MomentLocationResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +public class MomentLocationResponsesFixture { + public static MomentLocationResponses create() { + return new MomentLocationResponses( + List.of(new MomentLocationResponse(1, BigDecimal.ONE, BigDecimal.ZERO), + new MomentLocationResponse(2, BigDecimal.ONE, BigDecimal.ZERO), + new MomentLocationResponse(3, BigDecimal.ONE, BigDecimal.ZERO))); + } +} diff --git a/backend/src/test/java/com/staccato/image/controller/ImageControllerTest.java b/backend/src/test/java/com/staccato/image/controller/ImageControllerTest.java new file mode 100644 index 000000000..61f5cbd6a --- /dev/null +++ b/backend/src/test/java/com/staccato/image/controller/ImageControllerTest.java @@ -0,0 +1,54 @@ +package com.staccato.image.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.image.service.ImageService; +import com.staccato.image.service.dto.ImageUrlResponse; +import com.staccato.member.domain.Member; + +@WebMvcTest(controllers = ImageController.class) +public class ImageControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private ImageService imageService; + @MockBean + private AuthService authService; + + @DisplayName("사진을 한 장 업로드하고 S3 url을 가져올 수 있다.") + @Test + void uploadFileTest() throws Exception { + // given + MockMultipartFile image = new MockMultipartFile("imageFile", new byte[0]); + ImageUrlResponse imageUrlResponse = new ImageUrlResponse("imageUrl"); + when(authService.extractFromToken(anyString())).thenReturn(Member.builder().nickname("staccato").build()); + when(imageService.uploadImage(any())).thenReturn(imageUrlResponse); + + // when & then + mockMvc.perform(multipart("/images") + .file(image) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(content().json(objectMapper.writeValueAsString(imageUrlResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java b/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java new file mode 100644 index 000000000..eaf25dd35 --- /dev/null +++ b/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java @@ -0,0 +1,16 @@ +package com.staccato.image.infrastructure; + +public class FakeS3ObjectClient extends S3ObjectClient { + public FakeS3ObjectClient() { + super("fakeBuket", "fakeEndPoint", "fakeCloudFrontEndPoint"); + } + + @Override + public void putS3Object(String objectKey, String contentType, byte[] imageBytes) { + } + + @Override + public String getUrl(String keyName) { + return "fakeUrl"; + } +} diff --git a/backend/src/test/java/com/staccato/member/domain/NicknameTest.java b/backend/src/test/java/com/staccato/member/domain/NicknameTest.java new file mode 100644 index 000000000..0a80bc057 --- /dev/null +++ b/backend/src/test/java/com/staccato/member/domain/NicknameTest.java @@ -0,0 +1,36 @@ +package com.staccato.member.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; + +class NicknameTest { + @DisplayName("유효한 닉네임을 생성한다.") + @Test + void CreateNickname() { + assertThatNoException().isThrownBy(() -> new Nickname("가ㄱㅏㅣㅎ.AZ1az_")); + } + + @DisplayName("닉네임의 형식이 잘못되었을 경우 예외를 발생시킨다.") + @Test + void cannotCreateNicknameByInvalidFormat() { + assertThatThrownBy(() -> new Nickname("//")) + .isInstanceOf(StaccatoException.class) + .hasMessage("올바르지 않은 닉네임 형식입니다."); + } + + @DisplayName("닉네임 맨 앞, 뒤 공백은 제거된다.") + @Test + void createNicknameAfterTrim() { + // given + Nickname nickname = new Nickname(" staccato "); + + // when & then + assertThat(nickname.getNickname()).isEqualTo("staccato"); + } +} diff --git a/backend/src/test/java/com/staccato/memory/controller/MemoryControllerTest.java b/backend/src/test/java/com/staccato/memory/controller/MemoryControllerTest.java new file mode 100644 index 000000000..ce269d163 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/controller/MemoryControllerTest.java @@ -0,0 +1,269 @@ +package com.staccato.memory.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +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 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.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.memory.MemoryNameResponsesFixture; +import com.staccato.fixture.memory.MemoryRequestFixture; +import com.staccato.fixture.memory.MemoryResponsesFixture; +import com.staccato.member.domain.Member; +import com.staccato.memory.service.MemoryService; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; + +@WebMvcTest(MemoryController.class) +class MemoryControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private MemoryService memoryService; + @MockBean + private AuthService authService; + + static Stream memoryRequestProvider() { + return Stream.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 휴가", "친구들과 함께한 여름 휴가 추억", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + new MemoryRequest(null, "2023 여름 휴가", "친구들과 함께한 여름 휴가 추억", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 휴가", null, LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)) + ); + } + + static Stream invalidMemoryRequestProvider() { + return Stream.of( + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", null, "친구들과 함께한 여름 휴가 추억", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "추억 제목을 입력해주세요." + ), + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", " ", "친구들과 함께한 여름 휴가 추억", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "추억 제목을 입력해주세요." + ), + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "가".repeat(31), "친구들과 함께한 여름 휴가 추억", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "제목은 공백 포함 30자 이하로 설정해주세요." + ), + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 휴가", "가".repeat(501), LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "내용의 최대 허용 글자수는 공백 포함 500자입니다." + ) + ); + } + + @DisplayName("사용자가 추억 정보를 입력하면, 새로운 추억을 생성한다.") + @ParameterizedTest + @MethodSource("memoryRequestProvider") + void createMemory(MemoryRequest memoryRequest) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(memoryService.createMemory(any(), any())).thenReturn(new MemoryIdResponse(1)); + + // when & then + mockMvc.perform(post("/memories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/memories/1")) + .andExpect(jsonPath("$.memoryId").value(1)); + } + + @DisplayName("사용자가 잘못된 형식으로 정보를 입력하면, 추억을 생성할 수 없다.") + @ParameterizedTest + @MethodSource("invalidMemoryRequestProvider") + void failCreateMemory(MemoryRequest memoryRequest, String expectedMessage) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(memoryService.createMemory(any(MemoryRequest.class), any(Member.class))).thenReturn(new MemoryIdResponse(1)); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(post("/memories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("사용자가 모든 추억 목록을 조회한다.") + @Test + void readAllMemory() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MemoryResponses memoryResponses = MemoryResponsesFixture.create(MemoryFixture.create()); + when(memoryService.readAllMemories(any(Member.class))).thenReturn(memoryResponses); + + // when & then + mockMvc.perform(get("/memories") + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(memoryResponses))); + } + + @DisplayName("특정 날짜를 포함하고 있는 모든 추억 목록을 조회한다.") + @Test + void readAllMemoryIncludingDate() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MemoryNameResponses memoryNameResponses = MemoryNameResponsesFixture.create(MemoryFixture.create()); + when(memoryService.readAllMemoriesIncludingDate(any(Member.class), any())).thenReturn(memoryNameResponses); + String currentDate = "2024-07-01"; + + // when & then + mockMvc.perform(get("/memories/candidates") + .header(HttpHeaders.AUTHORIZATION, "token") + .param("currentDate", currentDate)) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(memoryNameResponses))); + } + + @DisplayName("잘못된 날짜 형식으로 추억 목록 조회를 시도하면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"2024.07.01", "2024-07", "2024", "a"}) + void cannotReadAllMemoryByInvalidDateFormat(String currentDate) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "올바르지 않은 쿼리 스트링 형식입니다."); + + // when & then + mockMvc.perform(get("/memories/candidates") + .header(HttpHeaders.AUTHORIZATION, "token") + .param("currentDate", currentDate)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("사용자가 특정 추억을 조회한다.") + @Test + void readMemory() throws Exception { + // given + long memoryId = 1; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MemoryDetailResponse memoryDetailResponse = new MemoryDetailResponse(MemoryFixture.create(), List.of()); + when(memoryService.readMemoryById(anyLong(), any(Member.class))).thenReturn(memoryDetailResponse); + + // when & then + mockMvc.perform(get("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(memoryDetailResponse))); + } + + @DisplayName("적합한 경로변수와 데이터를 통해 스타카토 수정에 성공한다.") + @ParameterizedTest + @MethodSource("memoryRequestProvider") + void updateMemory(MemoryRequest memoryRequest) throws Exception { + // given + long memoryId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest).getBytes())) + .andExpect(status().isOk()); + } + + @DisplayName("사용자가 잘못된 형식으로 정보를 입력하면, 추억을 수정할 수 없다.") + @ParameterizedTest + @MethodSource("invalidMemoryRequestProvider") + void failUpdateMemory(MemoryRequest memoryRequest, String expectedMessage) throws Exception { + // given + long memoryId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(put("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest).getBytes())) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("적합하지 않은 경로변수의 경우 추억 수정에 실패한다.") + @Test + void failUpdateMemoryByInvalidPath() throws Exception { + // given + long memoryId = 0L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "추억 식별자는 양수로 이루어져야 합니다."); + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)); + + // when & then + mockMvc.perform(put("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest).getBytes())) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("사용자가 추억 식별자로 추억을 삭제한다.") + @Test + void deleteMemory() throws Exception { + // given + long memoryId = 1; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("사용자가 잘못된 추억 식별자로 삭제하려고 하면 예외가 발생한다.") + @Test + void cannotDeleteMemoryByInvalidId() throws Exception { + // given + long invalidId = 0; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "추억 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(delete("/memories/{memoryId}", invalidId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java b/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java new file mode 100644 index 000000000..8a2d34f30 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java @@ -0,0 +1,59 @@ +package com.staccato.memory.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.moment.domain.Moment; + +class MemoryTest { + @DisplayName("추억 생성 시 제목에는 앞뒤 공백이 잘린다.") + @Test + void trimMemoryTitle() { + // given + String expectedTitle = "title"; + + // when + Memory memory = Memory.builder().title(" title ").build(); + + // then + assertThat(memory.getTitle()).isEqualTo(expectedTitle); + } + + @DisplayName("추억을 수정 시 기존 스타카토 기록 날짜를 포함하지 않는 경우 수정에 실패한다.") + @Test + void validateDuration() { + // given + Memory memory = MemoryFixture.create(LocalDate.now(), LocalDate.now().plusDays(1)); + Memory updatedMemory = MemoryFixture.create(LocalDate.now().plusDays(1), LocalDate.now().plusDays(2)); + Moment moment = MomentFixture.create(memory, LocalDateTime.now()); + + // when & then + assertThatThrownBy(() -> memory.update(updatedMemory, List.of(moment))) + .isInstanceOf(StaccatoException.class) + .hasMessage("기간이 이미 존재하는 스타카토를 포함하지 않아요. 다시 설정해주세요."); + } + + @DisplayName("주어진 문자열과 제목이 같으면 거짓을 반환한다.") + @Test + void isNotSameTitle(){ + // given + String title = "title"; + Memory memory = Memory.builder().title(title).build(); + + // when + boolean result = memory.isNotSameTitle(title); + + // then + assertThat(result).isFalse(); + } +} diff --git a/backend/src/test/java/com/staccato/memory/domain/TermTest.java b/backend/src/test/java/com/staccato/memory/domain/TermTest.java new file mode 100644 index 000000000..1467baf91 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/domain/TermTest.java @@ -0,0 +1,69 @@ +package com.staccato.memory.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; + +class TermTest { + @DisplayName("끝 날짜는 시작 날짜보다 앞설 수 없다.") + @Test + void validateDate() { + assertThatCode(() -> new Term(LocalDate.now().plusDays(1), LocalDate.now())) + .isInstanceOf(StaccatoException.class) + .hasMessage("끝 날짜가 시작 날짜보다 앞설 수 없어요."); + } + + @DisplayName("특정 날짜가 추억 기간에 속하지 않으면 참을 반환한다.") + @Test + void isOutOfTerm() { + // given + Term term = new Term(LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)); + + // when & then + assertThat(term.doesNotContain(LocalDateTime.of(2023, 7, 11, 10, 0))).isTrue(); + } + + @DisplayName("특정 날짜가 추억 기간에 속하면 거짓을 반환한다.") + @Test + void isInTerm() { + // given + Term term = new Term(LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)); + + // when & then + assertThat(term.doesNotContain(LocalDateTime.of(2023, 7, 1, 10, 0))).isFalse(); + } + + @DisplayName("추억 기간이 없다면, 어떤 날짜든 거짓을 반환한다.") + @Test + void isNoTerm() { + // given + Term term = new Term(null, null); + + // when & then + assertThat(term.doesNotContain(LocalDateTime.of(2023, 7, 11, 10, 0))).isFalse(); + } + + @DisplayName("끝 날짜는 있는데, 시작 날짜가 누락되면 예외를 발생한다.") + @Test + void cannotCreateTermByNoStartAt() { + assertThatThrownBy(() -> new Term(null, LocalDate.now())) + .isInstanceOf(StaccatoException.class) + .hasMessage("추억 시작 날짜와 끝 날짜를 모두 입력해주세요."); + } + + @DisplayName("시작 날짜는 있는데, 끝 날짜가 누락되면 예외를 발생한다.") + @Test + void cannotCreateTermByNoEndAt() { + assertThatThrownBy(() -> new Term(LocalDate.now(), null)) + .isInstanceOf(StaccatoException.class) + .hasMessage("추억 시작 날짜와 끝 날짜를 모두 입력해주세요."); + } +} diff --git a/backend/src/test/java/com/staccato/memory/repository/MemoryMemberRepositoryTest.java b/backend/src/test/java/com/staccato/memory/repository/MemoryMemberRepositoryTest.java new file mode 100644 index 000000000..9061e531b --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/repository/MemoryMemberRepositoryTest.java @@ -0,0 +1,62 @@ +package com.staccato.memory.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; + +@DataJpaTest +class MemoryMemberRepositoryTest { + @Autowired + private MemoryMemberRepository memoryMemberRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryRepository memoryRepository; + + @DisplayName("사용자 식별자와 날짜로 추억 목록을 조회한다.") + @Test + void findAllByMemberIdAndDate() { + // given + Member member = memberRepository.save(MemberFixture.create()); + Memory memory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 30), LocalDate.of(2023, 12, 30))); + Memory memory2 = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 31), LocalDate.of(2023, 12, 31))); + memoryMemberRepository.save(new MemoryMember(member, memory)); + memoryMemberRepository.save(new MemoryMember(member, memory2)); + + // when + List result = memoryMemberRepository.findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(member.getId(), LocalDate.of(2023, 12, 31)); + + // then + assertThat(result).hasSize(1); + } + + @DisplayName("사용자 식별자와 날짜로 추억 목록을 조회할 때 추억에 기한이 없을 경우 함께 조회한다.") + @Test + void findAllByMemberIdAndDateWhenNull() { + // given + Member member = memberRepository.save(MemberFixture.create()); + Memory memory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 30), LocalDate.of(2023, 12, 30))); + Memory memory2 = memoryRepository.save(MemoryFixture.create(null, null)); + memoryMemberRepository.save(new MemoryMember(member, memory)); + memoryMemberRepository.save(new MemoryMember(member, memory2)); + + // when + List result = memoryMemberRepository.findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(member.getId(), LocalDate.of(2023, 12, 30)); + + // then + assertThat(result).hasSize(2); + } +} diff --git a/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java b/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java new file mode 100644 index 000000000..13739d7df --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java @@ -0,0 +1,317 @@ +package com.staccato.memory.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +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 org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryRequestFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; +import com.staccato.memory.repository.MemoryMemberRepository; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MomentResponse; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +class MemoryServiceTest extends ServiceSliceTest { + @Autowired + private MemoryService memoryService; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryMemberRepository memoryMemberRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MomentRepository momentRepository; + + static Stream dateProvider() { + return Stream.of( + Arguments.of(LocalDate.of(2024, 7, 1), 2), + Arguments.of(LocalDate.of(2024, 7, 2), 1) + ); + } + + static Stream updateMemoryProvider() { + return Stream.of( + Arguments.of( + MemoryRequestFixture.create(null, LocalDate.of(2024, 8, 1), LocalDate.of(2024, 8, 10)), null), + Arguments.of( + MemoryRequestFixture.create("imageUrl", LocalDate.of(2024, 8, 1), LocalDate.of(2024, 8, 10)), "imageUrl")); + } + + @DisplayName("추억 정보를 기반으로, 추억을 생성하고 작성자를 저장한다.") + @Test + void createMemory() { + // given + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)); + Member member = memberRepository.save(MemberFixture.create()); + + // when + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest, member); + MemoryMember memoryMember = memoryMemberRepository.findAllByMemberIdOrderByMemoryCreatedAtDesc(member.getId()) + .get(0); + + // then + assertAll( + () -> assertThat(memoryMember.getMember().getId()).isEqualTo(member.getId()), + () -> assertThat(memoryMember.getMemory().getId()).isEqualTo(memoryIdResponse.memoryId()) + ); + } + + @DisplayName("이미 존재하는 추억 이름으로 추억을 생성할 수 없다.") + @Test + void cannotCreateMemoryByDuplicatedTitle() { + // given + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)); + Member member = memberRepository.save(MemberFixture.create()); + memoryService.createMemory(memoryRequest, member); + + // when & then + assertThatThrownBy(() -> memoryService.createMemory(memoryRequest, member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("같은 이름을 가진 추억이 있어요. 다른 이름으로 설정해주세요."); + } + + @DisplayName("현재 날짜를 포함하는 모든 추억 목록을 조회한다.") + @MethodSource("dateProvider") + @ParameterizedTest + void readAllMemories(LocalDate currentDate, int expectedSize) { + // given + Member member = memberRepository.save(MemberFixture.create()); + memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 1), "title1"), member); + memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 2), "title2"), member); + + // when + MemoryNameResponses memoryNameResponses = memoryService.readAllMemoriesIncludingDate(member, currentDate); + + // then + assertThat(memoryNameResponses.memories()).hasSize(expectedSize); + } + + @DisplayName("특정 추억을 조회한다.") + @Test + void readMemoryById() { + // given + Member member = memberRepository.save(MemberFixture.create()); + + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when + MemoryDetailResponse memoryDetailResponse = memoryService.readMemoryById(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryDetailResponse.memoryId()).isEqualTo(memoryIdResponse.memoryId()), + () -> assertThat(memoryDetailResponse.mates()).hasSize(1) + ); + } + + @DisplayName("본인 것이 아닌 특정 추억을 조회하려고 하면 예외가 발생한다.") + @Test + void cannotReadMemoryByIdIfNotOwner() { + // given + Member member = memberRepository.save(MemberFixture.create("member")); + Member otherMember = memberRepository.save(MemberFixture.create("otherMember")); + + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when & then + assertThatThrownBy(() -> memoryService.readMemoryById(memoryIdResponse.memoryId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("특정 추억을 조회하면 스타카토는 오래된 순으로 반환한다.") + @Test + void readMemoryByIdOrderByVisitedAt() { + // given + Member member = memberRepository.save(MemberFixture.create()); + + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + Moment firstMoment = saveMoment(LocalDateTime.of(2023, 7, 1, 10, 0), memoryIdResponse.memoryId()); + Moment secondMoment = saveMoment(LocalDateTime.of(2023, 7, 1, 10, 10), memoryIdResponse.memoryId()); + Moment lastMoment = saveMoment(LocalDateTime.of(2023, 7, 5, 9, 0), memoryIdResponse.memoryId()); + + // when + MemoryDetailResponse memoryDetailResponse = memoryService.readMemoryById(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryDetailResponse.memoryId()).isEqualTo(memoryIdResponse.memoryId()), + () -> assertThat(memoryDetailResponse.moments()).hasSize(3), + () -> assertThat(memoryDetailResponse.moments().stream().map(MomentResponse::momentId).toList()) + .containsExactly(firstMoment.getId(), secondMoment.getId(), lastMoment.getId()) + ); + } + + @DisplayName("존재하지 않는 추억을 조회하려고 할 경우 예외가 발생한다.") + @Test + void failReadMemory() { + // given + Member member = memberRepository.save(MemberFixture.create()); + long unknownId = 1; + + // when & then + assertThatThrownBy(() -> memoryService.readMemoryById(unknownId, member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("요청하신 추억을 찾을 수 없어요."); + } + + @DisplayName("추억 정보를 기반으로, 추억을 수정한다.") + @MethodSource("updateMemoryProvider") + @ParameterizedTest + void updateMemory(MemoryRequest updatedMemory, String expected) { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when + memoryService.updateMemory(updatedMemory, memoryResponse.memoryId(), member); + Memory foundedMemory = memoryRepository.findById(memoryResponse.memoryId()).get(); + + // then + assertAll( + () -> assertThat(foundedMemory.getId()).isEqualTo(memoryResponse.memoryId()), + () -> assertThat(foundedMemory.getTitle()).isEqualTo(updatedMemory.memoryTitle()), + () -> assertThat(foundedMemory.getDescription()).isEqualTo(updatedMemory.description()), + () -> assertThat(foundedMemory.getTerm().getStartAt()).isEqualTo(updatedMemory.startAt()), + () -> assertThat(foundedMemory.getTerm().getEndAt()).isEqualTo(updatedMemory.endAt()), + () -> assertThat(foundedMemory.getThumbnailUrl()).isEqualTo(expected) + ); + } + + @DisplayName("존재하지 않는 추억을 수정하려 할 경우 예외가 발생한다.") + @Test + void failUpdateMemory() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)); + + // when & then + assertThatThrownBy(() -> memoryService.updateMemory(memoryRequest, 1L, member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("요청하신 추억을 찾을 수 없어요."); + } + + @DisplayName("본인 것이 아닌 추억을 수정하려고 하면 예외가 발생한다.") + @Test + void cannotUpdateMemoryIfNotOwner() { + // given + Member member = memberRepository.save(MemberFixture.create("member")); + Member otherMember = memberRepository.save(MemberFixture.create("otherMember")); + MemoryRequest updatedMemory = MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when & then + assertThatThrownBy(() -> memoryService.updateMemory(updatedMemory, memoryIdResponse.memoryId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("본래 해당 추억의 이름과 동일한 이름으로 추억을 수정할 수 있다.") + @Test + void updateMemoryByOriginTitle() { + // given + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)); + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest, member); + + // when & then + assertThatNoException().isThrownBy(() -> memoryService.updateMemory(memoryRequest, memoryIdResponse.memoryId(), member)); + } + + @DisplayName("이미 존재하는 이름으로 추억을 수정할 수 없다.") + @Test + void cannotUpdateMemoryByDuplicatedTitle() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryRequest memoryRequest1 = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10), "existingTitle"); + memoryService.createMemory(memoryRequest1, member); + MemoryRequest memoryRequest2 = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10), "otherTitle"); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest2, member); + + // when & then + assertThatThrownBy(() -> memoryService.updateMemory(memoryRequest1, memoryIdResponse.memoryId(), member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("같은 이름을 가진 추억이 있어요. 다른 이름으로 설정해주세요."); + ; + } + + @DisplayName("추억 식별값을 통해 추억을 삭제한다.") + @Test + void deleteMemory() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when + memoryService.deleteMemory(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryRepository.findById(memoryIdResponse.memoryId())).isEmpty(), + () -> assertThat(memoryMemberRepository.findAll()).hasSize(0) + ); + } + + @DisplayName("추억을 삭제하면 속한 스타카토들도 함께 삭제된다.") + @Test + void deleteMemoryWithMoment() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + saveMoment(LocalDateTime.of(2023, 7, 2, 10, 10), memoryIdResponse.memoryId()); + + // when + memoryService.deleteMemory(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryRepository.findById(memoryIdResponse.memoryId())).isEmpty(), + () -> assertThat(memoryMemberRepository.findAll()).hasSize(0), + () -> assertThat(momentRepository.findAll()).isEmpty() + ); + } + + private Moment saveMoment(LocalDateTime visitedAt, long memoryId) { + return momentRepository.save(MomentFixture.create(memoryRepository.findById(memoryId).get(), visitedAt)); + } + + @DisplayName("본인 것이 아닌 추억 상세를 삭제하려고 하면 예외가 발생한다.") + @Test + void cannotDeleteMemoryIfNotOwner() { + // given + Member member = memberRepository.save(MemberFixture.create("member")); + Member otherMember = memberRepository.save(MemberFixture.create("otherMember")); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when & then + assertThatThrownBy(() -> memoryService.deleteMemory(memoryIdResponse.memoryId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); + } +} diff --git a/backend/src/test/java/com/staccato/memory/service/dto/request/MemoryRequestTest.java b/backend/src/test/java/com/staccato/memory/service/dto/request/MemoryRequestTest.java new file mode 100644 index 000000000..0b5036827 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/service/dto/request/MemoryRequestTest.java @@ -0,0 +1,30 @@ +package com.staccato.memory.service.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MemoryRequestTest { + @DisplayName("MemoryRequest를 생성할 때 title에는 trim이 적용된다.") + @Test + void trimTitle() { + // given + String memoryTitle = " title "; + String expectedTitle = "title"; + + // when + MemoryRequest memoryRequest = new MemoryRequest( + "thumbnail", + memoryTitle, + "description", + LocalDate.of(2024, 8, 22), + LocalDate.of(2024, 8, 22) + ); + + // then + assertThat(memoryRequest.memoryTitle()).isEqualTo(expectedTitle); + } +} diff --git a/backend/src/test/java/com/staccato/moment/controller/MomentControllerTest.java b/backend/src/test/java/com/staccato/moment/controller/MomentControllerTest.java new file mode 100644 index 000000000..1e55a1bd9 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/controller/MomentControllerTest.java @@ -0,0 +1,356 @@ +package com.staccato.moment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +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 org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.moment.MomentDetailResponseFixture; +import com.staccato.fixture.moment.MomentLocationResponsesFixture; +import com.staccato.member.domain.Member; +import com.staccato.moment.service.MomentService; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +@WebMvcTest(controllers = MomentController.class) +class MomentControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private MomentService momentService; + @MockBean + private AuthService authService; + + static Stream invalidMomentRequestProvider() { + return Stream.of( + Arguments.of( + new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 0L, List.of("https://example.com/images/namsan_tower.jpg")), + "추억 식별자는 양수로 이루어져야 합니다." + ), + Arguments.of( + new MomentRequest(null, "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 제목을 입력해주세요." + ), + Arguments.of( + new MomentRequest(" ", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 제목을 입력해주세요." + ), + Arguments.of( + new MomentRequest("가".repeat(31), "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 제목은 공백 포함 30자 이하로 설정해주세요." + ), + Arguments.of( + new MomentRequest("placeName", "address", null, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토의 위도를 입력해주세요." + ), + Arguments.of( + new MomentRequest("placeName", "address", BigDecimal.ONE, null, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토의 경도를 입력해주세요." + ), + Arguments.of( + new MomentRequest("placeName", null, BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토의 주소를 입력해주세요." + ), + Arguments.of( + new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, null, 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 날짜를 입력해주세요." + ) + ); + } + + @DisplayName("스타카토 생성 시 사진 5장까지는 첨부 가능하다.") + @Test + void createMoment() throws Exception { + // given + MomentRequest momentRequest = new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, + List.of("https://example.com/images/namsan_tower1.jpg", + "https://example.com/images/namsan_tower2.jpg", + "https://example.com/images/namsan_tower3.jpg", + "https://example.com/images/namsan_tower4.jpg", + "https://example.com/images/namsan_tower5.jpg")); + String momentRequestJson = objectMapper.writeValueAsString(momentRequest); + MomentIdResponse momentIdResponse = new MomentIdResponse(1L); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/moments/1")) + .andExpect(content().json(objectMapper.writeValueAsString(momentIdResponse))); + } + + @DisplayName("올바르지 않은 날짜 형식으로 스타카토 생성을 요청하면 예외가 발생한다.") + @Test + void failCreateMomentWithInvalidVisitedAt() throws Exception { + // given + String momentRequestJson = """ + { + "placeName": "런던 박물관", + "address": "Great Russell St, London WC1B 3DG", + "latitude": 51.51978412729915, + "longitude": -0.12712788587027796, + "visitedAt": "2024/07/27T10:00:00", + "memoryId": 1 + } + """; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("요청 본문을 읽을 수 없습니다. 올바른 형식으로 데이터를 제공해주세요.")); + } + + @DisplayName("사진이 5장을 초과하면 스타카토 생성에 실패한다.") + @Test + void failCreateMomentByImageCount() throws Exception { + // given + MomentRequest momentRequest = new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, + List.of("https://example.com/images/namsan_tower1.jpg", + "https://example.com/images/namsan_tower2.jpg", + "https://example.com/images/namsan_tower3.jpg", + "https://example.com/images/namsan_tower4.jpg", + "https://example.com/images/namsan_tower5.jpg", + "https://example.com/images/namsan_tower6.jpg")); + String momentRequestJson = objectMapper.writeValueAsString(momentRequest); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "사진은 5장까지만 추가할 수 있어요."); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("사용자가 잘못된 요청 형식으로 정보를 입력하면, 스타카토를 생성할 수 없다.") + @ParameterizedTest + @MethodSource("invalidMomentRequestProvider") + void failCreateMoment(MomentRequest momentRequest, String expectedMessage) throws Exception { + // given + String momentRequestJson = objectMapper.writeValueAsString(momentRequest); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("스타카토 목록 조회에 성공한다.") + @Test + void readAllMoment() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MomentLocationResponses responses = MomentLocationResponsesFixture.create(); + when(momentService.readAllMoment(any(Member.class))).thenReturn(responses); + + // when & then + mockMvc.perform(get("/moments") + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(responses))); + } + + @DisplayName("적합한 경로변수를 통해 스타카토 조회에 성공한다.") + @Test + void readMomentById() throws Exception { + // given + long momentId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MomentDetailResponse response = MomentDetailResponseFixture.create(momentId, LocalDateTime.now()); + when(momentService.readMomentById(anyLong(), any(Member.class))).thenReturn(response); + + // when & then + mockMvc.perform(get("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))); + } + + @DisplayName("적합하지 않은 경로변수의 경우 스타카토 조회에 실패한다.") + @Test + void failReadMomentById() throws Exception { + // given + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(get("/moments/{momentId}", 0)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("적합한 경로변수를 통해 스타카토 수정에 성공한다.") + @Test + void updateMomentById() throws Exception { + // given + long momentId = 1L; + MomentUpdateRequest updateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isOk()); + } + + @DisplayName("추가하려는 사진이 5장이 넘는다면 스타카토 수정에 실패한다.") + @Test + void failUpdateMomentByImagesSize() throws Exception { + // given + long momentId = 1L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "사진은 5장까지만 추가할 수 있어요."); + MomentUpdateRequest updateRequest = new MomentUpdateRequest("placeName", + List.of("https://example.com/images/namsan_tower1.jpg", + "https://example.com/images/namsan_tower2.jpg", + "https://example.com/images/namsan_tower3.jpg", + "https://example.com/images/namsan_tower4.jpg", + "https://example.com/images/namsan_tower5.jpg", + "https://example.com/images/namsan_tower6.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("적합하지 않은 경로변수의 경우 스타카토 수정에 실패한다.") + @Test + void failUpdateMomentById() throws Exception { + // given + long momentId = 0L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 식별자는 양수로 이루어져야 합니다."); + MomentUpdateRequest updateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("스타카토 수정 시 장소 이름을 입력하지 않은 경우 수정에 실패한다.") + @Test + void failUpdateMomentByPlaceName() throws Exception { + // given + long momentId = 1L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 제목을 입력해주세요."); + MomentUpdateRequest updateRequest = new MomentUpdateRequest(null, List.of("https://example1.com.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("스타카토를 삭제한다.") + @Test + void deleteMomentById() throws Exception { + // given + long momentId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("양수가 아닌 id로 스타카토를 삭제할 수 없다.") + @Test + void failDeleteMomentById() throws Exception { + // given + long momentId = 0L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(delete("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("기분 선택을 하지 않은 경우 기분 수정에 실패한다.") + @Test + void failUpdateMomentFeelingById() throws Exception { + // given + long momentId = 1L; + FeelingRequest feelingRequest = new FeelingRequest(null); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "기분 값을 입력해주세요."); + + // when & then + mockMvc.perform(post("/moments/{momentId}/feeling", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .content(objectMapper.writeValueAsString(feelingRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/moment/domain/FeelingTest.java b/backend/src/test/java/com/staccato/moment/domain/FeelingTest.java new file mode 100644 index 000000000..5080edea6 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/domain/FeelingTest.java @@ -0,0 +1,26 @@ +package com.staccato.moment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; + +class FeelingTest { + + @DisplayName("기분을 선택할 수 있다.") + @Test + void match() { + assertThat(Feeling.match("happy")).isEqualTo(Feeling.HAPPY); + } + + @DisplayName("존재하지 않는 기분을 선택할 경우 예외가 발생한다.") + @Test + void failMatch() { + assertThatThrownBy(() -> Feeling.match("upset")) + .isInstanceOf(StaccatoException.class) + .hasMessage("요청하신 기분 표현을 찾을 수 없어요."); + } +} diff --git a/backend/src/test/java/com/staccato/moment/domain/MomentImagesTest.java b/backend/src/test/java/com/staccato/moment/domain/MomentImagesTest.java new file mode 100644 index 000000000..7e2a76ba2 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/domain/MomentImagesTest.java @@ -0,0 +1,56 @@ +package com.staccato.moment.domain; + +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 java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.memory.domain.Memory; + +class MomentImagesTest { + @DisplayName("생성하려는 사진의 갯수가 5장을 초과할 시 예외가 발생한다.") + @Test + void failAddMomentImages() { + // given & when & then + assertThatThrownBy(() -> new MomentImages(List.of("picture1", "picture2", "picture3", "picture4", "picture5", "picture6"))) + .isInstanceOf(StaccatoException.class) + .hasMessage("사진은 5장을 초과할 수 없습니다."); + } + + @DisplayName("사진을 추가할 때 총 사진의 갯수가 5장을 초과할 시 예외가 발생한다.") + @Test + void failUpdateMomentImages() { + // given & when & then + assertThatThrownBy(() -> new MomentImages(List.of("picture1", "picture2", "picture3", "picture4", "picture5", "picture6"))) + .isInstanceOf(StaccatoException.class) + .hasMessage("사진은 5장을 초과할 수 없습니다."); + } + + @DisplayName("사진들을 추가할 때 기존 사진이 포함되지 않은 경우 삭제 후 추가한다.") + @Test + void update() { + // given + Memory memory = Memory.builder().title("Sample Memory").startAt(LocalDate.now().minusDays(1)) + .endAt(LocalDate.now().plusDays(1)).build(); + MomentImages existingImages = new MomentImages(List.of("picture1", "picture3")); + MomentImages updatedImages = new MomentImages(List.of("picture1", "picture4")); + + // when + existingImages.update(updatedImages, MomentFixture.create(memory, LocalDateTime.now())); + + // then + List images = existingImages.getImages().stream().map(MomentImage::getImageUrl).toList(); + assertAll( + () -> assertThat(images).containsAll(List.of("picture1", "picture4")), + () -> assertThat(images.size()).isEqualTo(2) + ); + } +} diff --git a/backend/src/test/java/com/staccato/moment/domain/MomentTest.java b/backend/src/test/java/com/staccato/moment/domain/MomentTest.java new file mode 100644 index 000000000..b0666b7bc --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/domain/MomentTest.java @@ -0,0 +1,111 @@ +package com.staccato.moment.domain; + +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 java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +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 com.staccato.exception.StaccatoException; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.memory.domain.Memory; + +class MomentTest { + @DisplayName("추억 날짜 안에 스타카토 날짜가 포함되면 Moment을 생성할 수 있다.") + @Test + void createMoment() { + // given + Memory memory = Memory.builder() + .title("Sample Memory") + .startAt(LocalDate.now()) + .endAt(LocalDate.now().plusDays(1)) + .build(); + + // when & then + assertThatCode(() -> Moment.builder() + .visitedAt(LocalDateTime.now().plusDays(1)) + .placeName("placeName") + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build()).doesNotThrowAnyException(); + } + + @DisplayName("추억 기간이 없는 경우 스타카토를 날짜 상관없이 생성할 수 있다.") + @Test + void createMomentInUndefinedDuration() { + // given + Memory memory = Memory.builder() + .title("Sample Memory") + .build(); + + // when & then + assertThatCode(() -> Moment.builder() + .visitedAt(LocalDateTime.now().plusDays(1)) + .placeName("placeName") + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build()).doesNotThrowAnyException(); + } + + @DisplayName("스타카토 생성 시 placeName의 앞 뒤 공백이 제거된다.") + @Test + void trimPlaceName() { + // given + Memory memory = MemoryFixture.create(); + String placeName = " placeName "; + String expectedPlaceName = "placeName"; + + // when + Moment moment = Moment.builder() + .visitedAt(LocalDateTime.of(memory.getTerm().getStartAt(), LocalTime.MIN)) + .placeName(placeName) + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build(); + + // then + assertThat(moment.getPlaceName()).isEqualTo(expectedPlaceName); + } + + @DisplayName("추억 날짜 안에 스타카토 날짜가 포함되지 않으면 예외를 발생시킨다.") + @ValueSource(longs = {-1, 2}) + @ParameterizedTest + void failCreateMoment(long plusDays) { + // given + Memory memory = Memory.builder() + .title("Sample Memory") + .startAt(LocalDate.now()) + .endAt(LocalDate.now().plusDays(1)) + .build(); + + // when & then + assertThatThrownBy(() -> Moment.builder() + .visitedAt(LocalDateTime.now().plusDays(plusDays)) + .placeName("placeName") + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build()).isInstanceOf(StaccatoException.class) + .hasMessageContaining("추억에 포함되지 않는 날짜입니다."); + } +} diff --git a/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java b/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java new file mode 100644 index 000000000..87ce9689a --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java @@ -0,0 +1,67 @@ +package com.staccato.moment.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; +import com.staccato.memory.repository.MemoryMemberRepository; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Moment; + +@DataJpaTest +class MomentRepositoryTest { + @Autowired + private MomentRepository momentRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MemoryMemberRepository memoryMemberRepository; + + @DisplayName("사용자의 모든 스타카토를 조회한다.") + @Test + void findAllByMemory_MemoryMembers_Member() { + // given + Member member = memberRepository.save(MemberFixture.create()); + Member anotherMember = memberRepository.save(MemberFixture.create("anotherMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 31), LocalDate.of(2024, 1, 10))); + Memory memory2 = memoryRepository.save(MemoryFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10))); + Memory anotherMemberMemory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 10))); + memoryMemberRepository.save(new MemoryMember(member, memory)); + memoryMemberRepository.save(new MemoryMember(member, memory2)); + memoryMemberRepository.save(new MemoryMember(anotherMember, anotherMemberMemory)); + + Moment moment = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2023, 12, 31, 22, 20))); + Moment moment1 = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2024, 1, 1, 22, 20))); + Moment moment2 = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2024, 1, 1, 22, 21))); + Moment anotherMoment = momentRepository.save(MomentFixture.create(anotherMemberMemory, LocalDateTime.of(2024, 5, 1, 22, 21))); + + // when + List memberResult = momentRepository.findAllByMemory_MemoryMembers_Member(member); + List anotherMemberResult = momentRepository.findAllByMemory_MemoryMembers_Member(anotherMember); + + // then + assertAll( + () -> assertThat(memberResult.size()).isEqualTo(3), + () -> assertThat(memberResult).containsExactlyInAnyOrder(moment, moment1, moment2), + () -> assertThat(anotherMemberResult.size()).isEqualTo(1), + () -> assertThat(anotherMemberResult).containsExactlyInAnyOrder(anotherMoment) + ); + } +} diff --git a/backend/src/test/java/com/staccato/moment/service/MomentServiceTest.java b/backend/src/test/java/com/staccato/moment/service/MomentServiceTest.java new file mode 100644 index 000000000..595ca0b3c --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/service/MomentServiceTest.java @@ -0,0 +1,307 @@ +package com.staccato.moment.service; + +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 java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.comment.domain.Comment; +import com.staccato.comment.repository.CommentRepository; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.CommentFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Feeling; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImages; +import com.staccato.moment.repository.MomentImageRepository; +import com.staccato.moment.repository.MomentRepository; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +class MomentServiceTest extends ServiceSliceTest { + @Autowired + private MomentService momentService; + @Autowired + private MomentRepository momentRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired + private MomentImageRepository momentImageRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MemberRepository memberRepository; + + @DisplayName("사진 없이도 스타카토를 생성할 수 있다.") + @Test + void createMoment() { + // given + Member member = saveMember(); + saveMemory(member); + MomentRequest momentRequest = new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, List.of()); + + // when + long momentId = momentService.createMoment(momentRequest, member).momentId(); + + // then + assertThat(momentRepository.findById(momentId)).isNotEmpty(); + } + + @DisplayName("스타카토를 생성하면 Moment과 MomentImage들이 함께 저장되고 id를 반환한다.") + @Test + void createMomentWithMomentImages() { + // given + Member member = saveMember(); + saveMemory(member); + + // when + long momentId = momentService.createMoment(getMomentRequest(), member).momentId(); + + // then + assertAll( + () -> assertThat(momentRepository.findById(momentId)).isNotEmpty(), + () -> assertThat(momentImageRepository.findFirstByMomentId(momentId)).isNotEmpty() + ); + } + + @DisplayName("본인 것이 아닌 추억에 스타카토를 생성하려고 하면 예외가 발생한다.") + @Test + void cannotCreateMomentIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + saveMemory(member); + MomentRequest momentRequest = getMomentRequest(); + + // when & then + assertThatThrownBy(() -> momentService.createMoment(momentRequest, otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("존재하지 않는 추억에 스타카토 생성을 시도하면 예외가 발생한다.") + @Test + void failCreateMoment() { + // given + Member member = saveMember(); + + // when & then + assertThatThrownBy(() -> momentService.createMoment(getMomentRequest(), member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("요청하신 추억을 찾을 수 없어요."); + } + + private MomentRequest getMomentRequest() { + return new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, List.of("https://example.com/images/namsan_tower.jpg")); + } + + @DisplayName("스타카토 목록 조회에 성공한다.") + @Test + void readAllMoment() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + saveMomentWithImages(memory); + saveMomentWithImages(memory); + saveMomentWithImages(memory); + + // when + MomentLocationResponses actual = momentService.readAllMoment(member); + + // then + assertThat(actual).isEqualTo(new MomentLocationResponses( + List.of(new MomentLocationResponse(1L, new BigDecimal("37.7749").setScale(14, RoundingMode.HALF_UP), new BigDecimal("-122.4194").setScale(14, RoundingMode.HALF_UP)), + new MomentLocationResponse(2L, new BigDecimal("37.7749").setScale(14, RoundingMode.HALF_UP), new BigDecimal("-122.4194").setScale(14, RoundingMode.HALF_UP)), + new MomentLocationResponse(3L, new BigDecimal("37.7749").setScale(14, RoundingMode.HALF_UP), new BigDecimal("-122.4194").setScale(14, RoundingMode.HALF_UP))))); + } + + @DisplayName("스타카토 조회에 성공한다.") + @Test + void readMomentById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when + MomentDetailResponse actual = momentService.readMomentById(moment.getId(), member); + + // then + assertThat(actual).isEqualTo(new MomentDetailResponse(moment)); + } + + @DisplayName("본인 것이 아닌 스타카토를 조회하려고 하면 예외가 발생한다.") + @Test + void cannotReadMomentByIdIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when & then + assertThatThrownBy(() -> momentService.readMomentById(moment.getId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("존재하지 않는 스타카토를 조회하면 예외가 발생한다.") + @Test + void failReadMomentById() { + // given + Member member = saveMember(); + + // when & then + assertThatThrownBy(() -> momentService.readMomentById(1L, member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("요청하신 스타카토를 찾을 수 없어요."); + } + + @DisplayName("스타카토 수정에 성공한다.") + @Test + void updateMomentById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when + MomentUpdateRequest momentUpdateRequest = new MomentUpdateRequest("newPlaceName", List.of("https://existExample.com.jpg", "https://existExample2.com.jpg")); + momentService.updateMomentById(moment.getId(), momentUpdateRequest, member); + + // then + Moment foundedMoment = momentRepository.findById(moment.getId()).get(); + assertAll( + () -> assertThat(foundedMoment.getPlaceName()).isEqualTo("newPlaceName"), + () -> assertThat(momentImageRepository.findById(1L)).isEmpty(), + () -> assertThat(momentImageRepository.findById(2L)).isEmpty(), + () -> assertThat(momentImageRepository.findById(3L).get().getImageUrl()).isEqualTo("https://existExample.com.jpg"), + () -> assertThat(momentImageRepository.findById(4L).get().getImageUrl()).isEqualTo("https://existExample2.com.jpg"), + () -> assertThat(momentImageRepository.findById(3L).get().getMoment().getId()).isEqualTo(foundedMoment.getId()), + () -> assertThat(momentImageRepository.findById(4L).get().getMoment().getId()).isEqualTo(foundedMoment.getId()), + () -> assertThat(momentImageRepository.findAll().get(0).getImageUrl()).isEqualTo("https://existExample.com.jpg"), + () -> assertThat(momentImageRepository.findAll().get(1).getImageUrl()).isEqualTo("https://existExample2.com.jpg"), + () -> assertThat(momentImageRepository.findAll().size()).isEqualTo(2) + ); + } + + @DisplayName("본인 것이 아닌 스타카토를 수정하려고 하면 예외가 발생한다.") + @Test + void cannotUpdateMomentByIdIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + MomentUpdateRequest momentUpdateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + + // when & then + assertThatThrownBy(() -> momentService.updateMomentById(moment.getId(), momentUpdateRequest, otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("존재하지 않는 스타카토를 수정하면 예외가 발생한다.") + @Test + void failUpdateMomentById() { + // given + Member member = saveMember(); + MomentUpdateRequest momentUpdateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + + // when & then + assertThatThrownBy(() -> momentService.updateMomentById(1L, momentUpdateRequest, member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("요청하신 스타카토를 찾을 수 없어요."); + } + + @DisplayName("Moment을 삭제하면 이에 포함된 MomentImage와 MomentLog도 모두 삭제된다.") + @Test + void deleteMomentById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + Comment comment = commentRepository.save(CommentFixture.create(moment, member)); + + // when + momentService.deleteMomentById(moment.getId(), member); + + // then + assertAll( + () -> assertThat(momentRepository.findById(moment.getId())).isEmpty(), + () -> assertThat(commentRepository.findById(comment.getId())).isEmpty(), + () -> assertThat(momentImageRepository.findById(0L)).isEmpty(), + () -> assertThat(momentImageRepository.findById(1L)).isEmpty() + ); + } + + @DisplayName("본인 것이 아닌 스타카토를 삭제하려고 하면 예외가 발생한다.") + @Test + void cannotDeleteMomentByIdIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when & then + assertThatThrownBy(() -> momentService.deleteMomentById(moment.getId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); + } + + @DisplayName("스타카토의 기분을 선택할 수 있다.") + @Test + void updateMomentFeelingById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + FeelingRequest feelingRequest = new FeelingRequest("happy"); + + // when + momentService.updateMomentFeelingById(moment.getId(), member, feelingRequest); + + // then + assertAll( + () -> assertThat(momentRepository.findById(moment.getId())).isNotEmpty(), + () -> assertThat(momentRepository.findById(moment.getId()).get().getFeeling()).isEqualTo(Feeling.HAPPY) + ); + } + + private Member saveMember() { + return memberRepository.save(MemberFixture.create()); + } + + private Memory saveMemory(Member member) { + Memory memory = MemoryFixture.create(LocalDate.now(), LocalDate.now().plusDays(1)); + memory.addMemoryMember(member); + return memoryRepository.save(memory); + } + + private Moment saveMomentWithImages(Memory memory) { + Moment moment = MomentFixture.createWithImages(memory, LocalDateTime.now(), new MomentImages(List.of("https://oldExample.com.jpg", "https://existExample.com.jpg"))); + return momentRepository.save(moment); + } +} diff --git a/backend/src/test/java/com/staccato/moment/service/dto/request/MomentRequestTest.java b/backend/src/test/java/com/staccato/moment/service/dto/request/MomentRequestTest.java new file mode 100644 index 000000000..186a31228 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/service/dto/request/MomentRequestTest.java @@ -0,0 +1,34 @@ +package com.staccato.moment.service.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MomentRequestTest { + @DisplayName("MomentRequest를 생성할 때 placeName에는 trim이 적용된다.") + @Test + void trimPlaceName() { + // given + String placeName = " placeName "; + String expectedPlaceName = "placeName"; + + // when + MomentRequest momentRequest = new MomentRequest( + placeName, + "address", + BigDecimal.ONE, + BigDecimal.ONE, + LocalDateTime.of(2024, 8, 22, 10, 0), + 1L, + List.of() + ); + + // then + assertThat(momentRequest.placeName()).isEqualTo(expectedPlaceName); + } +} diff --git a/backend/src/test/java/com/staccato/util/DatabaseCleaner.java b/backend/src/test/java/com/staccato/util/DatabaseCleaner.java new file mode 100644 index 000000000..6247cc7c4 --- /dev/null +++ b/backend/src/test/java/com/staccato/util/DatabaseCleaner.java @@ -0,0 +1,49 @@ +package com.staccato.util; + +import java.util.List; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class DatabaseCleaner { + @PersistenceContext + private EntityManager entityManager; + + @Transactional + public void cleanUp() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String tableName : getTableNames()) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1") + .executeUpdate(); + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } + + private List getTableNames() { + return entityManager.getMetamodel().getEntities().stream() + .filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null) + .map(entityType -> camelToSnake(entityType.getName())) + .toList(); + } + + private String camelToSnake(String camel) { + StringBuilder snake = new StringBuilder(); + for (char c : camel.toCharArray()) { + if (Character.isUpperCase(c)) { + snake.append("_"); + } + snake.append(Character.toLowerCase(c)); + } + if (snake.charAt(0) == '_') { + snake.deleteCharAt(0); + } + return snake.toString(); + } +} diff --git a/backend/src/test/java/com/staccato/util/DatabaseCleanerExtension.java b/backend/src/test/java/com/staccato/util/DatabaseCleanerExtension.java new file mode 100644 index 000000000..b3d1aa1ad --- /dev/null +++ b/backend/src/test/java/com/staccato/util/DatabaseCleanerExtension.java @@ -0,0 +1,13 @@ +package com.staccato.util; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DatabaseCleanerExtension implements BeforeEachCallback { + @Override + public void beforeEach(ExtensionContext context) { + DatabaseCleaner databaseCleaner = SpringExtension.getApplicationContext(context).getBean(DatabaseCleaner.class); + databaseCleaner.cleanUp(); + } +}