diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 415ef62..288cee5 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,8 +1,6 @@ name: Java CI with Gradle on: - push: - branches: [ "develop" ] pull_request: branches: [ "develop" ] @@ -45,4 +43,85 @@ jobs: uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: - files: '**/build/test-results/test/TEST-*.xml' \ No newline at end of file + files: '**/build/test-results/test/TEST-*.xml' + + cd: + runs-on: ubuntu-latest + needs: ci + + steps: + - uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: Project + path: ./build/libs + + # Docker Hub login + - name: Login to Docker Hub + run: | + echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + # Build and Push Docker Image + - name: Build and Push Docker Image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/devlog:latest . + docker push ${{ secrets.DOCKER_USERNAME }}/devlog:latest + + - name: Create .ssh directory and add EC2 host key + run: | + mkdir -p ~/.ssh + ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts + + - name: Create private key file + run: | + echo "${{ secrets.SSH_PRIVATE_KEY }}" > private_key.pem + chmod 600 private_key.pem + + - name: Upload project files to EC2 + run: | + rsync -avz --progress --checksum --exclude 'node_modules' --exclude '.git' \ + -e "ssh -i private_key.pem" ./ \ + ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:/home/${{ secrets.EC2_USER }}/project/ + + - name: Deploy to EC2 and restart Docker containers using Docker Compose + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /home/${{ secrets.EC2_USER }}/project/ + + # 환경 변수 파일 생성 + echo "MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }}" > .env + echo "MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}" >> .env + echo "DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }}" >> .env + + # Stop and remove existing containers + docker-compose down || true + + # Pull the latest Docker image + #docker-compose pull + + # Start the containers using Docker Compose + docker-compose up -d --build + + #- name: Add EC2 public IP to /etc/hosts + # run: | + # echo "$EC2_PUBLIC_IP backend" | sudo tee -a /etc/hosts + # env: + # EC2_PUBLIC_IP: ${{ secrets.EC2_HOST }} + + #- name: Wait for init server + # run: sleep 650 + + #- name: Test HTTP response + # run: | + # curl -v http://backend:8080 + + + - name: Remove private key file + run: rm -f private_key.pem + diff --git a/.gitignore b/.gitignore index c2065bc..f48676a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ bin/ *.iws *.iml *.ipr -out/ +*.properties !**/src/main/**/out/ !**/src/test/**/out/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b3105b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# 최신 21-jdk-alpine 이미지로부터 시작 +FROM openjdk:21-jdk-slim + +EXPOSE 8080 + +# 작업 디렉토리를 /app으로 설정 +WORKDIR /app + +# 현재 디렉토리의 모든 파일을 컨테이너의 /app 디렉토리로 복사 +COPY . . + +#ENV SPRING_PROFILES_ACTIVE=prod +# 빌드된 JAR 파일을 컨테이너로 복사 +ARG JAR_FILE=build/libs/devlog-0.0.1-SNAPSHOT.jar +RUN mv ${JAR_FILE} app.jar + +# 컨테이너가 실행될 때 실행될 명령어 지정 +# 오류남! 왜냐하면, spring.profiles.active=prod를 사용하려면, application.properties에 spring.profiles.active=prod를 추가해야함 +ENTRYPOINT ["java", "-jar", "app.jar"] +#ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar","app.jar"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1e6d09 --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +## 글과 댓글, 대댓글 기능 구현하기 + +- 우리가 만드는 사이트 - 개발 진로 관련 정보 공유 블로그 + +## 글 작성하기 + +- 로그인한 사용자만이 글을 작성할 수 있다. + - 제약사항 + - 글을 작성하기 위해서는 로그인을 해야한다. + - 작성완료될 글은 제목이나 내용이 공백일 수 없다. + + - 추가로 고려해볼만한 사항 + - 사진과 같은 파일을 삽입하는 경우 + - 업로드된 파일이 보여질 위치를 링크와 같은 형태로 조정하는 방식 → velog 방식 + - 삽입을 하면 그 파일자체를 보여주어 이를 작성자가 옮길 수 있도록 하는 방식 → notion 방식 + - 업로드 될 파일의 크기 제한은 얼마나? + + - 글의 문법 + - 네이버 블로그와 같은 기본적인 문법을 따르도록 할까? (최근엔 마크다운 서식을 따르도록 업데이트 되었다고함) + - 아니면 notion,velog와 같이 마크다운을 사용가능하도록 할까? + + - 시나리오 + - 글 작성(create)테스트 + - 성공 테스트 + - given + - 로그인한 사용자 + - 제목과 글이 공백이 아님 + - when + - ‘글 작성하기’ 버튼을 누른 후 글을 작성함 + - ‘작성’ 버튼을 눌러 글 작성을 완료함 + - then + - 성공적으로 글이 작성됨(201 created) + + - 예외 테스트 + - 상황 1. 로그인 하지 않은 사용자 + - → 이 경우는 스프링 시큐리티 url 설정으로 쉽게 해결 가능할 것이라 예측 + - given + - 로그인하지않은 사용자 + - when + - 사용자가 ‘글 작성하기’ 버튼을 클릭함 + - then + - 로그인을 권유하는 알림을 띄우도록 함. → 프론트 + - 오류는 (403 Forbidden) + + - 상황 2. 로그인 O, 글 제목 또는 내용이 공백인 경우 + - given + - 로그인한 사용자 + - 글 제목 또는 내용을 공백으로 제공 + - when + - 사용자가 ‘글 작성하기’버튼을 클릭 후 글을 작성하고 ‘작성’을 눌러 글 저장을 시도 + - then + - 글에서 공백으로 주어진 부분을 하이라이트하여 보여줄 수 있도록 함 →프론트 + - 400 bad request + + +--- + +- 작성된 글은 아이디, 제목 등을 통해 조회가 가능해야한다. + - 제약사항 + - ‘내 글 보기’에서는 자신이 작성한 글만 조회가 되어야 한다. + - 이때, ‘내 글 보기’ 라는 버튼은 로그인 한 사용자들에게만 보이는 버튼 + - url을 바꾸어 접근 시도시 인가되지 않은 사용자이므로 거부하도록 시큐리티 설정 + - 만일 velog와 같이 태그를 달 수 있도록 한다면 태그를 통해서도 조회가 가능해야한다. + + - 시나리오 + - 글 조회(Read)테스트 + - 성공 테스트 + - 글을 작성한 작성자는 ‘내 글 보기’를 통해 자신이 쓴 글을 조회 가능하다. + - given + - 로그인한 사용자 + - 작성된 글이 존재함 + - when + - 내 글 보기 버튼을 클릭하여 작성 글 조회 + - then + - 성공적으로 자신이 쓴 글을 확인할 수 있음 + - 200 ok + - 예외 테스트 + - 상황 1. + - → 이 경우는 스프링 시큐리티 url 설정으로 쉽게 해결 가능할 것이라 예측 + - given + - 로그인하지않은 사용자 + - when + - 사용자가 ‘글 작성하기’ 버튼을 클릭함 + - then + - 로그인을 권유하는 알림을 띄우도록 함. + - 오류는 (403 Forbidden) + + - 상황 2. 로그인 O, 글 제목 또는 내용이 공백인 경우 + - given + - 로그인한 사용자 + - 글 제목 또는 내용을 공백으로 제공 + - when + - 사용자가 ‘글 작성하기’버튼을 클릭 후 글을 작성하고 ‘작성’을 눌러 글 저장을 시도 + - then + - 글에서 공백으로 주어진 부분을 하이라이트하여 보여줄 수 있도록 함 →프론트 + - 400 bad request + + +--- + +- 작성된 글은 검색결과에 노출되어야 한다. + - 제약사항 + - 만일 글에 태그를 달 수 있도록 한다면 태그를 통해서도 조회가 가능해야한다. + - 글 검색시 글의 제목 또는 내용에 포함된 단어를 통해 검색결과에 도출되어야 한다. + - 글 작성자를 이용하여 검색 시, ‘ 내 글 보기’와 같이 그 사람이 작성한 글만 조회가 되어야 한다. + - 아이디는 완전히 일치하는 경우만 찾을 수 있도록 + + - 시나리오 + - 성공 테스트 + - given + - 검색할 문자열 (공백 x) + - 문자열을 통해 검색 가능한 작성된 글 + - when + - 문자열을 통해 검색을 시도 + - then + - 검색 가능한 글이 정상적으로 검색결과에 노출됨 + - 200 ok + - 예외 테스트 + - 검색할 문자열이 공백인 경우 + - given + - 검색할 문자열이 공백으로 제공 + - when + - 공백으로 검색시도 + - then + - 공백으로 검색이 불가능하다는 오류메세지 띄우기 + - 400 bad request + + +--- + +- 글을 수정할 수 있다. + - 제약사항 + - 글 수정은 그 글을 작성한 작성자만의 권한이다 + - 글을 작성한 작성자가 자신이 글을 작성할 때 사용한 아이디로 로그인 된 상태 + - 즉, 내 글 보기를 통해 해당 글이 보이는 상태 + - 수정한 제목, 내용이 공백이여서는 안된다. + - 수정하기 사용시, 원래 작성했던 글의 템플릿 그대로 작성틀로 옮겨져야한다. + - 수정한 내용을 작성을 눌러 저장하면 그 글에 수정된 내용이 덮어쓰기 되어야한다. + - 시나리오 + - 성공 테스트 + - 수정하기 사용시, 원래 작성했던 글의 템플릿 그대로 작성틀로 옮겨져야한다. + - given + - 로그인한 사용자 + - when + - 자신이 작성한 글을 눌러 수정버튼을 클릭 + - then + - 수정한 글의 내용을 그대로 ‘작성하기’에서 사용했던 틀에 옮겨진 채로 화면에 노출 + - 200 ok + - 수정한 내용을 작성을 눌러 저장하면 그 글에 수정된 내용이 덮어쓰기 되어야한다. + - given + - 로그인한 사용자 + - when + - 작성 버튼을 눌러 글 저장 시도 + - then + - 수정한 글의 내용을 원래 작성했던 글에 덮어쓰기 해야함. + - 200 ok + - 예외 테스트 + - 수정된 내용이 공백인 경우 + - given + - 로그인한 사용자 + - 수정된 내용이 공백으로 제공 + - when + - 작성 버튼을 눌러 글 저장 시도 + - then + - 공백인 부분을 하이라이트하여 보여줌 → 프론트 + - 400 bad request + +--- + +- 글은 삭제할 수 있다. + - 제약사항 + - 글을 삭제하는 것은 그 글의 작성자와 관리자만의 권한이다. + - 글을 작성한 작성자가 자신이 글을 작성할 때 사용한 아이디로 로그인 된 상태 + - 즉, 내 글 보기를 통해 해당 글이 보이는 상태 + - 삭제된 글은 글의 댓글, 대댓글 모두 같이 삭제됨 + - 삭제된 글을 url로 접근하려할 시 404 not found + - 진짜로 삭제할 것인지 물어보는 창을 띄우도록하기 + - 시나리오 + - 성공테스트 + - given + - 로그인한 사용자 + - 로그인한 사용자가 작성한 삭제할 글 + - when + - 삭제 버튼을 통해 삭제시도 + - then + - 삭제 성공 + - 200 ok + + +--- + +--- + +--- + +## 댓글 기능 구현하기 + +-대댓글도 같은 로직을 따를 것. 다만, 다르게 구현할 필요는 있을듯 + +- 댓글은 로그인한 사용자들만 달 수 있다. + - 제약사항 + - 로그인한 사용자들만 댓글 작성이 가능 + - 댓글은 공백일 수 없음 + - 작성자가 자신의 글에 댓글을 달면, 표시를 다르게하여 작성자임이 확인되도록 한다. + - 삭제되지 않은 글에만 댓글 작성 가능 + - 시나리오 + - 성공 테스트 + - given + - 로그인한 사용자 + - 공백이 아닌 댓글 문자열 + - when + - 로그인한 사용자가 공백이 아닌 댓글 작성 시도 + - then + - 성공적으로 댓글이 작성된다 + - 201 created + +--- + +- 댓글은 작성자/관리자만이 삭제할 수 있다. + - 제약사항 + - 로그인한 사용자중 댓글을 작성한 사람만 댓글 삭제가 가능하다. + - 시나리오 + - given + - 로그인한 사용자 + - 로그인한 사용자가 작성한 삭제할 댓글 + - when + - 삭제 버튼을 통해 삭제시도 + - then + - 삭제 성공 + - 200 ok + + +--- + +- 대댓글은 상위 댓글이 삭제되더라도 삭제되지 않는다. + - 제약사항 + - 시나리오 + - 성공테스트 + - given + - 삭제할 대댓글을 가진 댓글 + - when + - 삭제 버튼을 통해 댓글 삭제시도 + - then + - 삭제 성공 + - 200 ok + - 삭제 후에도 대댓글은 남아있도록함. + + --- + +--- +### 참고 사이트 + +[대댓글 (댓글의 댓글) 기능 구현하기](https://velog.io/@hhss2259/%EB%8C%80%EB%8C%93%EA%B8%80-%EB%8C%93%EA%B8%80%EC%9D%98-%EB%8C%93%EA%B8%80-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0) + +[[HTTP] HTTP 상태 401(Unauthorized) vs 403(Forbidden) 차이](https://mangkyu.tistory.com/146) + +[[사소한 TIP] Spring Data JPA에서 FindBy 와 FindAllBy 차이점](https://revf.tistory.com/270) diff --git a/build/libs/devlog-0.0.1-SNAPSHOT.jar b/build/libs/devlog-0.0.1-SNAPSHOT.jar new file mode 100644 index 0000000..2bf0e54 Binary files /dev/null and b/build/libs/devlog-0.0.1-SNAPSHOT.jar differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4349966 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + mysql: + container_name: mysql + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: devlog + MYSQL_USER: testuser + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + TZ: 'Asia/Seoul' + ports: + - "3306:3306" + volumes: + - ./mysql/conf.d:/etc/mysql/conf.d + - mysql-data:/var/lib/mysql + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_general_ci + networks: + - backend-network + + backend: + container_name: backend + depends_on: + - mysql + environment: + SPRING_DATASOURCE_DRIVER_CLASS_NAME: com.mysql.cj.jdbc.Driver + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/devlog?useSSL=false&allowPublicKeyRetrieval=true + SPRING_DATASOURCE_USERNAME: testuser + SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD} + build: + context: ./ + dockerfile: ./Dockerfile + networks: + - backend-network + restart: on-failure + ports: + - "8080:8080" + + nginx: + container_name: nginx + image: nginx + depends_on: + - mysql + - backend + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + networks: + - backend-network + +networks: + backend-network: + driver: bridge + +volumes: + mysql-data: # 🔧 추가됨 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..8489d0b --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,42 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; +events { + worker_connections 1024; +} +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 프론트엔드 upstream 설정 + #upstream todo { + # server web:3000; + #} + + server { + listen 80; + # / 경로로 오는 요청을 백엔드 / 경로로 포워딩 + location / { + proxy_pass http://backend:8080/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # / 경로로 오는 요청을 프론트엔드 upstream 의 / 경로로 포워딩 + #location / { + # proxy_pass http://myweb-web/; + #} + } + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Comment/Controller/CommentController.java b/src/main/java/apptive/devlog/Comment/Controller/CommentController.java new file mode 100644 index 0000000..6125536 --- /dev/null +++ b/src/main/java/apptive/devlog/Comment/Controller/CommentController.java @@ -0,0 +1,41 @@ +package apptive.devlog.Comment.Controller; + +import apptive.devlog.Global.Response.Result.ResultCode; +import apptive.devlog.Global.Response.Result.ResultResponse; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Comment.Dto.CreateCommentRequest; +import apptive.devlog.Comment.Service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/posts/{postId}/comments") +public class CommentController { + + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment( + @PathVariable Long postId, + @RequestBody CreateCommentRequest request, + @AuthenticationPrincipal Member member) { + Long commentId = commentService.createComment(postId, request, member); + final ResultResponse response = new ResultResponse(ResultCode.COMMENT_SUCCESS,new CommentIdResponse(commentId)); + return new ResponseEntity<>(response,response.getStatus()); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal Member member) { + commentService.deleteComment(postId, commentId, member); + final ResultResponse response = new ResultResponse(ResultCode.COMMENT_DELETE_SUCCESS,null); + return new ResponseEntity<>(response,response.getStatus()); + } + + private record CommentIdResponse(Long commentId) {} +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Comment/Domain/Comment.java b/src/main/java/apptive/devlog/Comment/Domain/Comment.java new file mode 100644 index 0000000..51bc7bd --- /dev/null +++ b/src/main/java/apptive/devlog/Comment/Domain/Comment.java @@ -0,0 +1,66 @@ +package apptive.devlog.Comment.Domain; + +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Post.Domain.Post; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@NoArgsConstructor +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private Member author; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) + private List children = new ArrayList<>(); + + @Column(nullable = false) + private Integer depth = 0; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public void addChild(Comment child) { + this.children.add(child); + child.setParent(this); + child.setDepth(this.depth + 1); + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Comment/Dto/CreateCommentRequest.java b/src/main/java/apptive/devlog/Comment/Dto/CreateCommentRequest.java new file mode 100644 index 0000000..fdec96e --- /dev/null +++ b/src/main/java/apptive/devlog/Comment/Dto/CreateCommentRequest.java @@ -0,0 +1,22 @@ +package apptive.devlog.Comment.Dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class CreateCommentRequest { + private String content; + private Long parentId; + + public CreateCommentRequest(String content) { + this.content = content; + } + + public CreateCommentRequest(String content, Long parentId) { + this.content = content; + this.parentId = parentId; + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Comment/Repository/CommentRepository.java b/src/main/java/apptive/devlog/Comment/Repository/CommentRepository.java new file mode 100644 index 0000000..843a1b1 --- /dev/null +++ b/src/main/java/apptive/devlog/Comment/Repository/CommentRepository.java @@ -0,0 +1,25 @@ +package apptive.devlog.Comment.Repository; + +import apptive.devlog.Comment.Domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + // 특정 게시글의 최상위 댓글들을 조회 + @Query("SELECT c FROM Comment c WHERE c.post.id = :postId AND c.parent IS NULL ORDER BY c.createdAt DESC") + List findRootCommentsByPostId(@Param("postId") Long postId); + + // 특정 댓글의 모든 대댓글을 조회 + @Query("SELECT c FROM Comment c WHERE c.parent.id = :parentId ORDER BY c.createdAt ASC") + List findChildrenByParentId(@Param("parentId") Long parentId); + + // 특정 게시글의 모든 댓글을 조회 (계층 구조 포함) + @Query("SELECT c FROM Comment c WHERE c.post.id = :postId ORDER BY c.createdAt DESC") + List findAllCommentsByPostId(@Param("postId") Long postId); +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Comment/Service/CommentService.java b/src/main/java/apptive/devlog/Comment/Service/CommentService.java new file mode 100644 index 0000000..f6cf2ed --- /dev/null +++ b/src/main/java/apptive/devlog/Comment/Service/CommentService.java @@ -0,0 +1,9 @@ +package apptive.devlog.Comment.Service; + +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Comment.Dto.CreateCommentRequest; + +public interface CommentService { + Long createComment(Long postId, CreateCommentRequest request, Member member); + void deleteComment(Long postId, Long commentId, Member member); +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Comment/Service/CommentServiceImpl.java b/src/main/java/apptive/devlog/Comment/Service/CommentServiceImpl.java new file mode 100644 index 0000000..ae5c198 --- /dev/null +++ b/src/main/java/apptive/devlog/Comment/Service/CommentServiceImpl.java @@ -0,0 +1,83 @@ +package apptive.devlog.Comment.Service; + +import apptive.devlog.Global.Exception.*; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Post.Domain.Post; +import apptive.devlog.Post.Repository.PostRepository; +import apptive.devlog.Comment.Domain.Comment; +import apptive.devlog.Comment.Dto.CreateCommentRequest; +import apptive.devlog.Comment.Repository.CommentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentServiceImpl implements CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @Override + @Transactional + public Long createComment(Long postId, CreateCommentRequest request, Member member) { + validateCommentContent(request); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException()); + + Comment comment = new Comment(); + comment.setContent(request.getContent()); + comment.setAuthor(member); + comment.setPost(post); + + // 대댓글인 경우 + if (request.getParentId() != null) { + Comment parentComment = commentRepository.findById(request.getParentId()) + .orElseThrow(() -> new CommentNotFoundException()); + + // 같은 게시글의 댓글인지 확인 + if (!parentComment.getPost().getId().equals(postId)) { + throw new CommentNotFoundException(); + } + + parentComment.addChild(comment); + } + + Comment savedComment = commentRepository.save(comment); + return savedComment.getId(); + } + + @Override + @Transactional + public void deleteComment(Long postId, Long commentId, Member member) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException()); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentNotFoundException()); + + // 같은 게시글의 댓글인지 확인 + if (!comment.getPost().getId().equals(postId)) { + throw new CommentNotFoundException(); + } + + if (!comment.getAuthor().equals(member)) { + throw new CommentNoPermissionException(); + } + + // 대댓글이 있는 경우 내용만 삭제하고 댓글은 유지 + if (!comment.getChildren().isEmpty()) { + comment.setContent("삭제된 댓글입니다."); + } else { + commentRepository.delete(comment); + } + } + + private void validateCommentContent(CreateCommentRequest request) { + if (request.getContent() == null || request.getContent().trim().isEmpty()) { + throw new CommentContentBlankException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Auth/Attribute/Provider.java b/src/main/java/apptive/devlog/Global/Auth/Attribute/Provider.java index 02f5e42..c0cb0ab 100644 --- a/src/main/java/apptive/devlog/Global/Auth/Attribute/Provider.java +++ b/src/main/java/apptive/devlog/Global/Auth/Attribute/Provider.java @@ -1,19 +1,17 @@ package apptive.devlog.Global.Auth.Attribute; -import apptive.devlog.Global.Exception.InvalidRequestException; - public enum Provider { DEVLOG, - GOOGLE, - KAKAO, - NAVER; + google, + kakao, + naver; public static Provider from(String name) { try { return Provider.valueOf(name.toUpperCase()); } catch (IllegalArgumentException e) { - throw new InvalidRequestException(); + throw new IllegalArgumentException(); } } } diff --git a/src/main/java/apptive/devlog/Global/Auth/Controller/AuthController.java b/src/main/java/apptive/devlog/Global/Auth/Controller/AuthController.java index 516e573..fc6169c 100644 --- a/src/main/java/apptive/devlog/Global/Auth/Controller/AuthController.java +++ b/src/main/java/apptive/devlog/Global/Auth/Controller/AuthController.java @@ -5,7 +5,6 @@ import apptive.devlog.Global.Response.Result.ResultCode; import apptive.devlog.Global.Response.Result.ResultResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -19,7 +18,7 @@ public class AuthController { public ResponseEntity login(@RequestBody LoginRequestDto loginRequestDto){ authService.login(loginRequestDto); final ResultResponse response = new ResultResponse(ResultCode.LOGIN_SUCCESS,ResultCode.LOGIN_SUCCESS.getMessage()); - return new ResponseEntity<>(response, HttpStatus.valueOf(ResultCode.LOGIN_SUCCESS.getStatus())); + return new ResponseEntity<>(response, response.getStatus()); } @PostMapping("/logout") @@ -27,7 +26,7 @@ public ResponseEntity logout(@RequestHeader("Authorization") Str String token = authorizationHeader.replace("Bearer ", ""); authService.logout(token); final ResultResponse response = new ResultResponse(ResultCode.LOGOUT_SUCCESS,ResultCode.LOGOUT_SUCCESS.getMessage()); - return new ResponseEntity<>(response, HttpStatus.valueOf(ResultCode.LOGOUT_SUCCESS.getStatus())); + return new ResponseEntity<>(response, response.getStatus()); } @PostMapping("/withdrawal") @@ -35,6 +34,6 @@ public ResponseEntity withdrawal(@RequestHeader("Authorization") String token = authorizationHeader.replace("Bearer ", ""); authService.withdrawal(token); final ResultResponse response = new ResultResponse(ResultCode.DELETE_SUCCESS,ResultCode.DELETE_SUCCESS.getMessage()); - return new ResponseEntity<>(response, HttpStatus.valueOf(ResultCode.DELETE_SUCCESS.getStatus())); + return new ResponseEntity<>(response, response.getStatus()); } } diff --git a/src/main/java/apptive/devlog/Global/Auth/Dto/SignUpDto.java b/src/main/java/apptive/devlog/Global/Auth/Dto/SignUpDto.java index 470fd30..ab6a17d 100644 --- a/src/main/java/apptive/devlog/Global/Auth/Dto/SignUpDto.java +++ b/src/main/java/apptive/devlog/Global/Auth/Dto/SignUpDto.java @@ -1,6 +1,6 @@ package apptive.devlog.Global.Auth.Dto; -import apptive.devlog.Global.Enum.Gender; +import apptive.devlog.Member.Enum.Gender; import lombok.AllArgsConstructor; import lombok.Getter; @Getter diff --git a/src/main/java/apptive/devlog/Global/Auth/Service/AuthServiceImpl.java b/src/main/java/apptive/devlog/Global/Auth/Service/AuthServiceImpl.java index fe4e70b..26e3026 100644 --- a/src/main/java/apptive/devlog/Global/Auth/Service/AuthServiceImpl.java +++ b/src/main/java/apptive/devlog/Global/Auth/Service/AuthServiceImpl.java @@ -2,7 +2,6 @@ import apptive.devlog.Global.Auth.Dto.LoginRequestDto; import apptive.devlog.Global.Auth.Jwt.JwtTokenProvider; -import apptive.devlog.Global.Exception.InvalidRequestException; import apptive.devlog.Global.Exception.InvalidTokenException; import apptive.devlog.Global.Exception.MemberAlreadyLogoutException; import apptive.devlog.Global.Exception.MemberNotExistException; diff --git a/src/main/java/apptive/devlog/Global/Config/SpringSecurityConfig.java b/src/main/java/apptive/devlog/Global/Config/SpringSecurityConfig.java index b3351da..7a7ebd7 100644 --- a/src/main/java/apptive/devlog/Global/Config/SpringSecurityConfig.java +++ b/src/main/java/apptive/devlog/Global/Config/SpringSecurityConfig.java @@ -49,8 +49,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용하지 않음 .authorizeHttpRequests(auth -> auth - .requestMatchers("/user/signup").permitAll() // 회원가입은 누구나 접근 가능하도록 수정! - .requestMatchers("/user/**").authenticated() // 멤버만 사용가능하도록 통제 + .requestMatchers("/members/**").authenticated() // 멤버만 사용가능하도록 통제 + .requestMatchers("/members/signup").permitAll() // 회원가입은 누구나 접근 가능하도록 수정! .anyRequest().permitAll() ) .exceptionHandling(ex -> ex diff --git a/src/main/java/apptive/devlog/Global/Config/WebConfig.java b/src/main/java/apptive/devlog/Global/Config/WebConfig.java new file mode 100644 index 0000000..fc1dd8b --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Config/WebConfig.java @@ -0,0 +1,32 @@ +package apptive.devlog.Global.Config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/"); + } + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addViewController("/").setViewName("forward:/index.html"); + registry.addViewController("/login").setViewName("forward:/index.html"); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .maxAge(3600); + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Exception/CommentContentBlankException.java b/src/main/java/apptive/devlog/Global/Exception/CommentContentBlankException.java new file mode 100644 index 0000000..31ce940 --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/CommentContentBlankException.java @@ -0,0 +1,16 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; + +public class CommentContentBlankException extends RuntimeException { + private final ErrorCode errorCode; + + public CommentContentBlankException() { + super(ErrorCode.COMMENT_CONTENT_BLANK.getMessage()); + this.errorCode = ErrorCode.COMMENT_CONTENT_BLANK; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/apptive/devlog/Global/Exception/CommentNoPermissionException.java b/src/main/java/apptive/devlog/Global/Exception/CommentNoPermissionException.java new file mode 100644 index 0000000..7c8b77a --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/CommentNoPermissionException.java @@ -0,0 +1,16 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; + +public class CommentNoPermissionException extends RuntimeException { + private final ErrorCode errorCode; + + public CommentNoPermissionException() { + super(ErrorCode.COMMENT_NO_PERMISSION.getMessage()); + this.errorCode = ErrorCode.COMMENT_NO_PERMISSION; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/apptive/devlog/Global/Exception/CommentNotFoundException.java b/src/main/java/apptive/devlog/Global/Exception/CommentNotFoundException.java new file mode 100644 index 0000000..923a739 --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/CommentNotFoundException.java @@ -0,0 +1,16 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; + +public class CommentNotFoundException extends RuntimeException { + private final ErrorCode errorCode; + + public CommentNotFoundException() { + super(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + this.errorCode = ErrorCode.COMMENT_NOT_FOUND; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/apptive/devlog/Global/Exception/InvalidTokenException.java b/src/main/java/apptive/devlog/Global/Exception/InvalidTokenException.java index 401dc98..215001c 100644 --- a/src/main/java/apptive/devlog/Global/Exception/InvalidTokenException.java +++ b/src/main/java/apptive/devlog/Global/Exception/InvalidTokenException.java @@ -7,7 +7,7 @@ public class InvalidTokenException extends RuntimeException { public InvalidTokenException() { super(ErrorCode.INVALID_TOKEN.getMessage()); - this.errorCode = ErrorCode.INVALID_REQUEST; + this.errorCode = ErrorCode.INVALID_TOKEN; } public ErrorCode getErrorCode() { diff --git a/src/main/java/apptive/devlog/Global/Exception/PostContentBlankException.java b/src/main/java/apptive/devlog/Global/Exception/PostContentBlankException.java new file mode 100644 index 0000000..a57edf6 --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/PostContentBlankException.java @@ -0,0 +1,19 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; +import lombok.Getter; + +@Getter +public class PostContentBlankException extends RuntimeException { + private final ErrorCode errorCode; + + + public PostContentBlankException() { + super(ErrorCode.POST_CONTENT_BLANK.getMessage()); + this.errorCode = ErrorCode.POST_CONTENT_BLANK; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Exception/PostNoPermissionException.java b/src/main/java/apptive/devlog/Global/Exception/PostNoPermissionException.java new file mode 100644 index 0000000..cf2ffe7 --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/PostNoPermissionException.java @@ -0,0 +1,19 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; +import lombok.Getter; + +@Getter +public class PostNoPermissionException extends RuntimeException { + private final ErrorCode errorCode; + + + public PostNoPermissionException() { + super(ErrorCode.POST_NO_PERMISSION.getMessage()); + this.errorCode = ErrorCode.POST_NO_PERMISSION; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Exception/PostNotFoundException.java b/src/main/java/apptive/devlog/Global/Exception/PostNotFoundException.java new file mode 100644 index 0000000..9b36419 --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/PostNotFoundException.java @@ -0,0 +1,19 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; +import lombok.Getter; + +@Getter +public class PostNotFoundException extends RuntimeException { + private final ErrorCode errorCode; + + + public PostNotFoundException() { + super(ErrorCode.POST_NOT_FOUND.getMessage()); + this.errorCode = ErrorCode.POST_NOT_FOUND; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Exception/PostTitleBlankException.java b/src/main/java/apptive/devlog/Global/Exception/PostTitleBlankException.java new file mode 100644 index 0000000..fb3451d --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/PostTitleBlankException.java @@ -0,0 +1,19 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; +import lombok.Getter; + +@Getter +public class PostTitleBlankException extends RuntimeException { + private final ErrorCode errorCode; + + + public PostTitleBlankException() { + super(ErrorCode.POST_TITLE_BLANK.getMessage()); + this.errorCode = ErrorCode.POST_TITLE_BLANK; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Exception/ReplyContentBlankException.java b/src/main/java/apptive/devlog/Global/Exception/ReplyContentBlankException.java new file mode 100644 index 0000000..8b6e34f --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/ReplyContentBlankException.java @@ -0,0 +1,16 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; + +public class ReplyContentBlankException extends RuntimeException { + private final ErrorCode errorCode; + + public ReplyContentBlankException() { + super(ErrorCode.REPLY_CONTENT_BLANK.getMessage()); + this.errorCode = ErrorCode.REPLY_CONTENT_BLANK; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/apptive/devlog/Global/Exception/ReplyNoPermissionException.java b/src/main/java/apptive/devlog/Global/Exception/ReplyNoPermissionException.java new file mode 100644 index 0000000..d914315 --- /dev/null +++ b/src/main/java/apptive/devlog/Global/Exception/ReplyNoPermissionException.java @@ -0,0 +1,16 @@ +package apptive.devlog.Global.Exception; + +import apptive.devlog.Global.Response.Error.ErrorCode; + +public class ReplyNoPermissionException extends RuntimeException { + private final ErrorCode errorCode; + + public ReplyNoPermissionException() { + super(ErrorCode.REPLY_NO_PERMISSION.getMessage()); + this.errorCode = ErrorCode.REPLY_NO_PERMISSION; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/apptive/devlog/Global/Exception/InvalidRequestException.java b/src/main/java/apptive/devlog/Global/Exception/ReplyNotFoundException.java similarity index 50% rename from src/main/java/apptive/devlog/Global/Exception/InvalidRequestException.java rename to src/main/java/apptive/devlog/Global/Exception/ReplyNotFoundException.java index 5fe8e48..e89a09d 100644 --- a/src/main/java/apptive/devlog/Global/Exception/InvalidRequestException.java +++ b/src/main/java/apptive/devlog/Global/Exception/ReplyNotFoundException.java @@ -2,12 +2,12 @@ import apptive.devlog.Global.Response.Error.ErrorCode; -public class InvalidRequestException extends RuntimeException { +public class ReplyNotFoundException extends RuntimeException { private final ErrorCode errorCode; - public InvalidRequestException() { - super(ErrorCode.INVALID_REQUEST.getMessage()); - this.errorCode = ErrorCode.INVALID_REQUEST; + public ReplyNotFoundException() { + super(ErrorCode.REPLY_NOT_FOUND.getMessage()); + this.errorCode = ErrorCode.REPLY_NOT_FOUND; } public ErrorCode getErrorCode() { diff --git a/src/main/java/apptive/devlog/Global/Response/Error/ErrorCode.java b/src/main/java/apptive/devlog/Global/Response/Error/ErrorCode.java index 1493c39..24533bb 100644 --- a/src/main/java/apptive/devlog/Global/Response/Error/ErrorCode.java +++ b/src/main/java/apptive/devlog/Global/Response/Error/ErrorCode.java @@ -2,34 +2,49 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum ErrorCode { // Common - INTERNAL_SERVER_ERROR(500, "C001", "internal server error"), - INVALID_INPUT_VALUE(400, "C002", "invalid input type"), - METHOD_NOT_ALLOWED(405, "C003", "method not allowed"), - INVALID_TYPE_VALUE(400, "C004", "invalid type value"), - BAD_CREDENTIALS(400, "C005", "bad credentials"), - - INVALID_REQUEST(403, "M003", "잘못된 요청입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C001", "internal server error"), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C002", "invalid input type"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C003", "method not allowed"), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "C004", "invalid type value"), + BAD_CREDENTIALS(HttpStatus.BAD_REQUEST, "C005", "bad credentials"), // Member - NOT_EXIST(404, "M001", "Not exist"), - EMAIL_DUPLICATION(400, "M002", "이미 사용중인 이메일입니다."), - NO_AUTHORITY(403, "M003", "권한이 없습니다."), - NEED_LOGIN(401, "M004", "로그인이 필요합니다."), - AUTHENTICATION_NOT_FOUND(401, "M005", "Security Context에 인증 정보가 없습니다."), - MEMBER_ALREADY_LOGOUT(400, "M006", "이미 로그아웃된 유저입니다."), - NICKNAME_DUPLICATION(400, "M007", "이미 사용중인 닉네임입니다."), - INVALID_TOKEN(403, "M008", "유효하지않은 토큰입니다."), - MEMBER_NOT_EXIST(403, "M009", "존재하지 않는 유저입니다."), + NOT_EXIST(HttpStatus.NOT_FOUND, "M001", "Not exist"), + EMAIL_DUPLICATION(HttpStatus.BAD_REQUEST, "M002", "이미 사용중인 이메일입니다."), + NO_AUTHORITY(HttpStatus.FORBIDDEN, "M003", "권한이 없습니다."), + NEED_LOGIN(HttpStatus.UNAUTHORIZED, "M004", "로그인이 필요합니다."), + AUTHENTICATION_NOT_FOUND(HttpStatus.UNAUTHORIZED, "M005", "Security Context에 인증 정보가 없습니다."), + MEMBER_ALREADY_LOGOUT(HttpStatus.BAD_REQUEST, "M006", "이미 로그아웃된 유저입니다."), + NICKNAME_DUPLICATION(HttpStatus.BAD_REQUEST, "M007", "이미 사용중인 닉네임입니다."), + INVALID_TOKEN(HttpStatus.FORBIDDEN, "M008", "유효하지않은 토큰입니다."), + MEMBER_NOT_EXIST(HttpStatus.FORBIDDEN, "M009", "존재하지 않는 유저입니다."), // Auth - REFRESH_TOKEN_INVALID(400, "A001", "refresh token invalid."); + REFRESH_TOKEN_INVALID(HttpStatus.BAD_REQUEST, "A001", "refresh token invalid."), + + // 게시글 관련 에러 코드 + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "존재하지 않는 게시글입니다."), + POST_NO_PERMISSION(HttpStatus.FORBIDDEN, "P002", "게시글 접근 권한이 없습니다."), + POST_TITLE_BLANK(HttpStatus.BAD_REQUEST, "P003", "제목은 공백일 수 없습니다."), + POST_CONTENT_BLANK(HttpStatus.BAD_REQUEST, "P004", "내용은 공백일 수 없습니다."), + + // 댓글 관련 에러 코드 + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "C001", "존재하지 않는 댓글입니다."), + COMMENT_NO_PERMISSION(HttpStatus.FORBIDDEN, "C002", "댓글 접근 권한이 없습니다."), + COMMENT_CONTENT_BLANK(HttpStatus.BAD_REQUEST, "C003", "댓글 내용은 공백일 수 없습니다."), + + // 대댓글 관련 에러 코드 + REPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "존재하지 않는 대댓글입니다."), + REPLY_NO_PERMISSION(HttpStatus.FORBIDDEN, "R002", "대댓글 접근 권한이 없습니다."), + REPLY_CONTENT_BLANK(HttpStatus.BAD_REQUEST, "R003", "대댓글 내용은 공백일 수 없습니다."); - private int status; + private HttpStatus status; private final String code; private final String message; } \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Response/Error/ErrorResponse.java b/src/main/java/apptive/devlog/Global/Response/Error/ErrorResponse.java index 8a3f96e..5295248 100644 --- a/src/main/java/apptive/devlog/Global/Response/Error/ErrorResponse.java +++ b/src/main/java/apptive/devlog/Global/Response/Error/ErrorResponse.java @@ -4,6 +4,7 @@ import lombok.AccessLevel; import lombok.Getter; // Lombok: getter 자동 생성 import lombok.NoArgsConstructor; // Lombok: 기본 생성자 자동 생성 +import org.springframework.http.HttpStatus; import org.springframework.validation.BindingResult; // Spring Validation 결과를 담는 객체 import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; // 잘못된 타입 매핑 예외 @@ -16,7 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) // 기본 생성자를 protected로 생성 public class ErrorResponse { - private int status; // HTTP 상태 코드 (예: 400, 404) + private HttpStatus status; // HTTP 상태 코드 (예: 400, 404) private String code; // 에러 코드 문자열 (예: "INVALID_INPUT") private String message; // 에러 메시지 private List errors; // 필드 오류 리스트 diff --git a/src/main/java/apptive/devlog/Global/Response/Error/GlobalExceptionHandler.java b/src/main/java/apptive/devlog/Global/Response/Error/GlobalExceptionHandler.java index c13d53b..96ec0d5 100644 --- a/src/main/java/apptive/devlog/Global/Response/Error/GlobalExceptionHandler.java +++ b/src/main/java/apptive/devlog/Global/Response/Error/GlobalExceptionHandler.java @@ -1,9 +1,9 @@ package apptive.devlog.Global.Response.Error; import apptive.devlog.Global.Exception.*; +import apptive.devlog.Global.Response.Result.ResultResponse; import jakarta.persistence.EntityNotFoundException; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.BadCredentialsException; @@ -22,90 +22,145 @@ public class GlobalExceptionHandler { @ExceptionHandler protected ResponseEntity handleBadCredentialException(BadCredentialsException e) { final ErrorResponse response = ErrorResponse.of(BAD_CREDENTIALS); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { final ErrorResponse response = ErrorResponse.of(NOT_EXIST, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleEntityNotFoundException(EntityNotFoundException e) { final ErrorResponse response = ErrorResponse.of(INVALID_INPUT_VALUE, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleAccessDeniedException(AccessDeniedException e) { final ErrorResponse response = ErrorResponse.of(NO_AUTHORITY, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { final ErrorResponse response = ErrorResponse.of(e); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { final ErrorResponse response = ErrorResponse.of(METHOD_NOT_ALLOWED); - return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED); + return new ResponseEntity<>(response, response.getStatus()); } // @Valid, @Validated 에서 binding error 발생 시 (@RequestBody) @ExceptionHandler protected ResponseEntity handleBindException(BindException e) { final ErrorResponse response = ErrorResponse.of(INVALID_INPUT_VALUE, e.getBindingResult()); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(response, response.getStatus()); } //이메일 중복 예외 코드 핸들링 @ExceptionHandler protected ResponseEntity handleEmailDuplicationException(EmailDuplicationException e) { final ErrorResponse response = ErrorResponse.of(EMAIL_DUPLICATION, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(response, response.getStatus()); } // 닉네임 중복 예외 코드 핸들링 @ExceptionHandler protected ResponseEntity handleNicknameDuplicationException(NicknameDuplicationException e) { final ErrorResponse response = ErrorResponse.of(NICKNAME_DUPLICATION, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); - } - - @ExceptionHandler - protected ResponseEntity handleInvalidRequestException(InvalidRequestException e) { - final ErrorResponse response = ErrorResponse.of(INVALID_REQUEST, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleInvalidTokenException(InvalidTokenException e) { final ErrorResponse response = ErrorResponse.of(INVALID_TOKEN, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleMemberNotExistException(MemberNotExistException e) { final ErrorResponse response = ErrorResponse.of(MEMBER_NOT_EXIST, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + return new ResponseEntity<>(response, response.getStatus()); } @ExceptionHandler protected ResponseEntity handleMemberAlreadyLogoutException(MemberAlreadyLogoutException e) { final ErrorResponse response = ErrorResponse.of(MEMBER_ALREADY_LOGOUT, e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(response, response.getStatus()); + } + + // 게시글 관련 핸들러 + @ExceptionHandler + protected ResponseEntity handlePostNotFoundException(PostNotFoundException e) { + final ErrorResponse response = ErrorResponse.of(POST_NOT_FOUND, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + @ExceptionHandler + protected ResponseEntity handlePostNoPermissionException(PostNoPermissionException e) { + final ErrorResponse response = ErrorResponse.of(POST_NO_PERMISSION, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + @ExceptionHandler + protected ResponseEntity handlePostTitleBlankException(PostTitleBlankException e) { + final ErrorResponse response = ErrorResponse.of(POST_TITLE_BLANK, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + + @ExceptionHandler + protected ResponseEntity handlePostContentBlankException(PostContentBlankException e) { + final ErrorResponse response = ErrorResponse.of(POST_CONTENT_BLANK, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + + //댓글관련 핸들러 + @ExceptionHandler + protected ResponseEntity handleCommentNotFoundException(CommentNotFoundException e) { + final ErrorResponse response = ErrorResponse.of(COMMENT_NOT_FOUND, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); } + @ExceptionHandler + protected ResponseEntity handleCommentNoPermissionException(CommentNoPermissionException e) { + final ErrorResponse response = ErrorResponse.of(COMMENT_NO_PERMISSION, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + + @ExceptionHandler + protected ResponseEntity handleCommentContentBlankException(CommentContentBlankException e) { + final ErrorResponse response = ErrorResponse.of(COMMENT_CONTENT_BLANK, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + + //대댓글 관련 핸들러 + @ExceptionHandler + protected ResponseEntity handleReplyNotFoundException(ReplyNotFoundException e) { + final ErrorResponse response = ErrorResponse.of(REPLY_NOT_FOUND, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + @ExceptionHandler + protected ResponseEntity handleReplyNoPermissionException(ReplyNoPermissionException e) { + final ErrorResponse response = ErrorResponse.of(REPLY_NO_PERMISSION, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + + @ExceptionHandler + protected ResponseEntity handleReplyContentBlankException(ReplyContentBlankException e) { + final ErrorResponse response = ErrorResponse.of(REPLY_CONTENT_BLANK, e.getMessage()); + return new ResponseEntity<>(response, response.getStatus()); + } + + // 그 밖에 발생하는 모든 예외처리가 이곳으로 모인다. @ExceptionHandler(Exception.class) protected ResponseEntity handleException(Exception e) { log.error("Exception: ", e); final ErrorResponse response = ErrorResponse.of(INTERNAL_SERVER_ERROR); - return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + return new ResponseEntity<>(response, response.getStatus()); } } \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Global/Response/Result/ResultCode.java b/src/main/java/apptive/devlog/Global/Response/Result/ResultCode.java index 9e99d22..07c1660 100644 --- a/src/main/java/apptive/devlog/Global/Response/Result/ResultCode.java +++ b/src/main/java/apptive/devlog/Global/Response/Result/ResultCode.java @@ -2,23 +2,41 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum ResultCode { // Member - REGISTER_SUCCESS(200, "M001", "회원가입 되었습니다."), - LOGIN_SUCCESS(200, "M002", "로그인 되었습니다."), - REISSUE_SUCCESS(200, "M003", "재발급 되었습니다."), - LOGOUT_SUCCESS(200, "M004", "로그아웃 되었습니다."), - GET_MY_INFO_SUCCESS(200, "M005", "내 정보 조회 완료"), + REGISTER_SUCCESS(HttpStatus.CREATED, "M001", "회원가입 되었습니다."), + LOGIN_SUCCESS(HttpStatus.OK, "M002", "로그인 되었습니다."), + REISSUE_SUCCESS(HttpStatus.OK, "M003", "재발급 되었습니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "M004", "로그아웃 되었습니다."), + GET_MY_INFO_SUCCESS(HttpStatus.OK, "M005", "내 정보 조회 완료"), - UPDATE_SUCCESS(200, "M006", "업데이트 완료"), - DELETE_SUCCESS(200, "M007", "삭제 완료"); + UPDATE_SUCCESS(HttpStatus.OK, "M006", "업데이트 완료"), + DELETE_SUCCESS(HttpStatus.OK, "M007", "삭제 완료"), + //게시글 관련 result + POST_SUCCESS(HttpStatus.CREATED, "P001", "게시글 작성에 성공하였습니다."), - private int status; + POST_DELETE_SUCCESS(HttpStatus.OK, "P002", "게시글을 성공적으로 삭제하였습니다."), + + POST_READ_SUCCESS(HttpStatus.OK, "P003", "게시글을 성공적으로 조회하였습니다."), + POST_UPDATE_SUCCESS(HttpStatus.OK, "P004", "게시글을 성공적으로 수정하였습니다."), + + //댓글 관련 result + COMMENT_SUCCESS(HttpStatus.CREATED, "C001", "댓글 작성에 성공하였습니다."), + + COMMENT_DELETE_SUCCESS(HttpStatus.OK, "C002", "댓글을 성공적으로 삭제하였습니다."), + + REPLY_SUCCESS(HttpStatus.CREATED, "C003", "대댓글 작성에 성공하였습니다."), + + REPLY_DELETE_SUCCESS(HttpStatus.OK, "C004", "대댓글을 성공적으로 삭제하였습니다."); + + + private HttpStatus status; private final String code; private final String message; } diff --git a/src/main/java/apptive/devlog/Global/Response/Result/ResultResponse.java b/src/main/java/apptive/devlog/Global/Response/Result/ResultResponse.java index 99ee490..09714c6 100644 --- a/src/main/java/apptive/devlog/Global/Response/Result/ResultResponse.java +++ b/src/main/java/apptive/devlog/Global/Response/Result/ResultResponse.java @@ -1,11 +1,12 @@ package apptive.devlog.Global.Response.Result; import lombok.Getter; +import org.springframework.http.HttpStatus; @Getter public class ResultResponse { - private int status; + private HttpStatus status; private String code; private String message; private Object data; diff --git a/src/main/java/apptive/devlog/Member/Controller/MemberController.java b/src/main/java/apptive/devlog/Member/Controller/MemberController.java index 51ab71d..0480ca2 100644 --- a/src/main/java/apptive/devlog/Member/Controller/MemberController.java +++ b/src/main/java/apptive/devlog/Member/Controller/MemberController.java @@ -17,7 +17,7 @@ import java.nio.file.AccessDeniedException; @RestController -@RequestMapping("/user") +@RequestMapping("/members") @RequiredArgsConstructor public class MemberController { @@ -27,14 +27,14 @@ public class MemberController { public ResponseEntity signup(@RequestBody SignUpDto signUpDto) { Member member = memberService.signUp(signUpDto); final ResultResponse response = new ResultResponse(ResultCode.REGISTER_SUCCESS,member); - return new ResponseEntity<>(response,HttpStatus.valueOf(ResultCode.REGISTER_SUCCESS.getStatus())); + return new ResponseEntity<>(response,response.getStatus()); } - @PostMapping("/update") + @PatchMapping("/update") public ResponseEntity updateProfile(@RequestBody UpdateProfileRequest updateProfileRequest,@RequestHeader("Authorization") String authorizationHeader) throws AccessDeniedException { String token = authorizationHeader.replace("Bearer ", ""); memberService.updateProfile(new UpdateProfileDto(updateProfileRequest.getNickname(),updateProfileRequest.getPassword(),token)); final ResultResponse response = new ResultResponse(ResultCode.UPDATE_SUCCESS,ResultCode.UPDATE_SUCCESS.getMessage()); - return new ResponseEntity<>(response, HttpStatus.valueOf(ResultCode.UPDATE_SUCCESS.getStatus())); + return new ResponseEntity<>(response, response.getStatus()); } } diff --git a/src/main/java/apptive/devlog/Member/Domain/Member.java b/src/main/java/apptive/devlog/Member/Domain/Member.java index 6433e5e..520a19a 100644 --- a/src/main/java/apptive/devlog/Member/Domain/Member.java +++ b/src/main/java/apptive/devlog/Member/Domain/Member.java @@ -1,7 +1,7 @@ package apptive.devlog.Member.Domain; import apptive.devlog.Global.Auth.Attribute.Provider; -import apptive.devlog.Global.Enum.Gender; +import apptive.devlog.Member.Enum.Gender; import jakarta.persistence.*; import lombok.*; @@ -28,7 +28,8 @@ public class Member { private String nickname; @Column(name = "birth", nullable = false) private LocalDate birth; - @Column(name = "gender", nullable = false) + @Column(name = "gender", nullable = false, columnDefinition = "VARCHAR(20)") + @Enumerated(EnumType.STRING) private Gender gender; @Column(name = "password") private String password; @@ -36,6 +37,9 @@ public class Member { @Column(name = "provider", nullable = false) private Provider provider; + @Column(name = "mail_opt_out", nullable = false) + private boolean mailOptOut = false; + public Member(String email, String name, String nickname, LocalDate birth, Gender gender, String encodePwd) { this.email = email; this.name = name; @@ -67,5 +71,17 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(email, password); } + + public boolean isMailOptOut() { + return mailOptOut; + } + + public void setMailOptOut(boolean mailOptOut) { + this.mailOptOut = mailOptOut; + } + + public String getEmail() { + return this.email; + } } diff --git a/src/main/java/apptive/devlog/Member/Enum/Gender.java b/src/main/java/apptive/devlog/Member/Enum/Gender.java new file mode 100644 index 0000000..776f3b8 --- /dev/null +++ b/src/main/java/apptive/devlog/Member/Enum/Gender.java @@ -0,0 +1,13 @@ +package apptive.devlog.Member.Enum; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Gender { + MALE, FEMALE; + + // 필요시 커스텀 메소드 추가 가능 +} + diff --git a/src/main/java/apptive/devlog/Post/Controller/PostController.java b/src/main/java/apptive/devlog/Post/Controller/PostController.java new file mode 100644 index 0000000..309e1ec --- /dev/null +++ b/src/main/java/apptive/devlog/Post/Controller/PostController.java @@ -0,0 +1,84 @@ +package apptive.devlog.Post.Controller; + +import apptive.devlog.Global.Response.Result.ResultCode; +import apptive.devlog.Global.Response.Result.ResultResponse; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Post.Domain.Post; +import apptive.devlog.Post.Dto.CreatePostRequest; +import apptive.devlog.Post.Dto.UpdatePostRequest; +import apptive.devlog.Post.Service.PostService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/posts") +public class PostController { + + private final PostService postService; + + @PostMapping + public ResponseEntity createPost( + @RequestBody CreatePostRequest request, + @AuthenticationPrincipal Member member) { + Long postId = postService.createPost(request, member); + final ResultResponse response = new ResultResponse(ResultCode.POST_SUCCESS,new PostIdResponse(postId)); + return new ResponseEntity<>(response,response.getStatus()); + } + + @GetMapping("/{postId}") + public ResponseEntity getPost(@PathVariable Long postId) { + Post post = postService.getOnePost(postId); + final ResultResponse response = new ResultResponse(ResultCode.POST_READ_SUCCESS, post); + return new ResponseEntity<>(response,response.getStatus()); + } + + @GetMapping("/posts/me") + public ResponseEntity> getMyPosts(@AuthenticationPrincipal Member author) { + return ResponseEntity.ok(postService.getMyPosts(author)); + } + + @GetMapping("/posts/title") + public ResponseEntity> getPostByTitle(@RequestParam String title) { + return ResponseEntity.ok(postService.getPostsByTitle(title)); + } + + @GetMapping("/posts/content") + public ResponseEntity> getPostByContent(@RequestParam String content) { + return ResponseEntity.ok(postService.getPostsByContent(content)); + } + + @GetMapping("/posts/tc") + public ResponseEntity> getPostByTitleAndContent(@RequestParam String title, @RequestParam String content) { + return ResponseEntity.ok(postService.getPostsByTitleAndContent(title,content)); + } + + @GetMapping("/posts/author") + public ResponseEntity> getPostsByAuthor(@RequestParam String nickname) { + return ResponseEntity.ok(postService.getPostsByAuthor(nickname)); + } + @PutMapping("/{postId}") + public ResponseEntity updatePost( + @PathVariable Long postId, + @RequestBody UpdatePostRequest request, + @AuthenticationPrincipal Member member) { + postService.updatePost(postId, request, member); + final ResultResponse response = new ResultResponse(ResultCode.POST_UPDATE_SUCCESS, null); + return new ResponseEntity<>(response,response.getStatus()); + } + + @DeleteMapping("/{postId}") + public ResponseEntity deletePost( + @PathVariable Long postId, + @AuthenticationPrincipal Member member) { + postService.deletePost(postId, member); + final ResultResponse response = new ResultResponse(ResultCode.POST_DELETE_SUCCESS, null); + return new ResponseEntity<>(response,response.getStatus()); + } + + private record PostIdResponse(Long postId) {} +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Post/Domain/Post.java b/src/main/java/apptive/devlog/Post/Domain/Post.java new file mode 100644 index 0000000..24a367b --- /dev/null +++ b/src/main/java/apptive/devlog/Post/Domain/Post.java @@ -0,0 +1,46 @@ +package apptive.devlog.Post.Domain; + +import apptive.devlog.Member.Domain.Member; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +public class Post { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private Member author; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Post/Dto/CreatePostRequest.java b/src/main/java/apptive/devlog/Post/Dto/CreatePostRequest.java new file mode 100644 index 0000000..dc90dbf --- /dev/null +++ b/src/main/java/apptive/devlog/Post/Dto/CreatePostRequest.java @@ -0,0 +1,18 @@ +package apptive.devlog.Post.Dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class CreatePostRequest { + private String title; + private String content; + + public CreatePostRequest(String title, String content) { + this.title = title; + this.content = content; + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Post/Dto/UpdatePostRequest.java b/src/main/java/apptive/devlog/Post/Dto/UpdatePostRequest.java new file mode 100644 index 0000000..7129777 --- /dev/null +++ b/src/main/java/apptive/devlog/Post/Dto/UpdatePostRequest.java @@ -0,0 +1,18 @@ +package apptive.devlog.Post.Dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class UpdatePostRequest { + private String title; + private String content; + + public UpdatePostRequest(String title, String content) { + this.title = title; + this.content = content; + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Post/Repository/PostRepository.java b/src/main/java/apptive/devlog/Post/Repository/PostRepository.java new file mode 100644 index 0000000..8608d61 --- /dev/null +++ b/src/main/java/apptive/devlog/Post/Repository/PostRepository.java @@ -0,0 +1,23 @@ +package apptive.devlog.Post.Repository; + +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Post.Domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PostRepository extends JpaRepository { + + Optional findById(Long postId); + + List findByTitleContaining(String title); + + List findByContentContaining(String content); + + List findByTitleContainingAndContentContaining(String title, String content); + + List findByAuthor(Optional author); +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Post/Service/PostService.java b/src/main/java/apptive/devlog/Post/Service/PostService.java new file mode 100644 index 0000000..756ce87 --- /dev/null +++ b/src/main/java/apptive/devlog/Post/Service/PostService.java @@ -0,0 +1,26 @@ +package apptive.devlog.Post.Service; + +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Post.Domain.Post; +import apptive.devlog.Post.Dto.CreatePostRequest; +import apptive.devlog.Post.Dto.UpdatePostRequest; + +import java.util.List; + +public interface PostService { + Long createPost(CreatePostRequest request, Member member); + Post getOnePost(Long postId); + + List getPostsByTitle(String title); + + List getPostsByContent(String content); + + List getMyPosts(Member Author); + List getPostsByAuthor(String nickname); + + List getPostsByTitleAndContent(String title,String content); + + + void updatePost(Long postId, UpdatePostRequest request, Member member); + void deletePost(Long postId, Member member); +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/Post/Service/PostServiceImpl.java b/src/main/java/apptive/devlog/Post/Service/PostServiceImpl.java new file mode 100644 index 0000000..f762da3 --- /dev/null +++ b/src/main/java/apptive/devlog/Post/Service/PostServiceImpl.java @@ -0,0 +1,125 @@ +package apptive.devlog.Post.Service; + +import apptive.devlog.Global.Exception.PostContentBlankException; +import apptive.devlog.Global.Exception.PostNoPermissionException; +import apptive.devlog.Global.Exception.PostNotFoundException; +import apptive.devlog.Global.Exception.PostTitleBlankException; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Member.Repository.MemberRepository; +import apptive.devlog.Post.Domain.Post; +import apptive.devlog.Post.Dto.CreatePostRequest; +import apptive.devlog.Post.Dto.UpdatePostRequest; +import apptive.devlog.Post.Repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostServiceImpl implements PostService { + + private final PostRepository postRepository; + private final MemberRepository memberRepository; + + @Override + @Transactional + public Long createPost(CreatePostRequest request, Member member) { + validateCreatePostRequest(request); + + Post post = new Post(); + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + post.setAuthor(member); + + Post savedPost = postRepository.save(post); + return savedPost.getId(); + } + + private void validateCreatePostRequest(CreatePostRequest request) { + if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + throw new PostTitleBlankException(); + } + if (request.getContent() == null || request.getContent().trim().isEmpty()) { + throw new PostContentBlankException(); + } + } + + private static List requireNonEmpty(List list) { + return Optional.ofNullable(list) + .filter(l -> !l.isEmpty()) + .orElseThrow(PostNotFoundException::new); + } + + + @Override + public Post getOnePost(Long postId) { + return postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + } + + @Override + public List getPostsByTitle(String title) { + return requireNonEmpty(postRepository.findByTitleContaining(title)); + + } + + @Override + public List getPostsByContent(String content) { + return requireNonEmpty(postRepository.findByContentContaining(content)); + + } + + @Override + public List getMyPosts(Member author) { + return requireNonEmpty(postRepository.findByAuthor(Optional.ofNullable(author))); + + } + + @Override + public List getPostsByAuthor(String nickname) { + Optional author = memberRepository.findByNickname(nickname); + return requireNonEmpty(postRepository.findByAuthor(author)); + + } + @Override + public List getPostsByTitleAndContent(String title, String content) { + return requireNonEmpty(postRepository.findByTitleContainingAndContentContaining(title,content)); + + } + + @Override + @Transactional + public void updatePost(Long postId, UpdatePostRequest request, Member member) { + Post post = getOnePost(postId); + + if (!post.getAuthor().equals(member)) { + throw new PostNoPermissionException(); + } + if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + throw new PostTitleBlankException(); + } + if (request.getContent() == null || request.getContent().trim().isEmpty()) { + throw new PostContentBlankException(); + } + + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + postRepository.save(post); + } + + @Override + @Transactional + public void deletePost(Long postId, Member member) { + Post post = getOnePost(postId); + + if (!post.getAuthor().equals(member)) { + throw new PostNoPermissionException(); + } + + postRepository.delete(post); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d66646a..e14bbe5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,10 +2,10 @@ spring.application.name=devlog server.address=0.0.0.0 server.port=8080 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.datasource.url=jdbc:mysql://localhost:3306/devlog?useSSL=false&allowPublicKeyRetrieval=true +spring.datasource.url=jdbc:mysql://mysql:3306/devlog?useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=testuser spring.datasource.password=testpassworD -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true @@ -20,14 +20,14 @@ spring.security.oauth2.client.registration.google.scope=email, profile spring.security.oauth2.client.registration.naver.client-id=qtvs_HGwrSLmNoHm2wUa spring.security.oauth2.client.registration.naver.client-secret=Ff7MU_X2cZ -spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver +spring.security.oauth2.client.registration.naver.redirect-uri=http://devlog.servehalflife.com/login/oauth2/code/naver spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.naver.client-name=Naver spring.security.oauth2.client.registration.naver.scope=name, email spring.security.oauth2.client.registration.kakao.client-id=ffd4c18329c01243044e68dade3ef42b spring.security.oauth2.client.registration.kakao.client-secret=04840d70ac2f0082f3ee5a47ec73b770 -spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao +spring.security.oauth2.client.registration.kakao.redirect-uri=http://devlog.servehalflife.com/login/oauth2/code/kakao spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.kakao.scope=profile_nickname, profile_image, account_email diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..3199da6 --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,173 @@ +/* 기본 스타일 */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Noto Sans KR', sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* 헤더 */ +header { + background-color: #2c3e50; + color: white; + padding: 20px; + text-align: center; + border-radius: 5px 5px 0 0; +} + +/* 네비게이션 */ +nav { + background-color: #34495e; + padding: 10px; +} + +nav ul { + list-style-type: none; + display: flex; + justify-content: center; +} + +nav ul li { + margin: 0 15px; +} + +nav ul li a { + color: white; + text-decoration: none; + font-weight: bold; + padding: 5px 10px; + border-radius: 3px; + transition: background-color 0.3s; +} + +nav ul li a:hover { + background-color: #1abc9c; +} + +/* 메인 콘텐츠 */ +main { + padding: 20px 0; +} + +.api-section { + margin-bottom: 40px; +} + +.api-section h2 { + border-bottom: 2px solid #2c3e50; + padding-bottom: 10px; + margin-bottom: 20px; + color: #2c3e50; +} + +/* 카드 스타일 */ +.card { + background-color: white; + border-radius: 5px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.card h3 { + margin-bottom: 15px; + color: #2c3e50; +} + +/* 폼 스타일 */ +.form-group { + margin-bottom: 15px; +} + +label { + display: block; + margin-bottom: 5px; + font-weight: bold; +} + +input, textarea, select { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 16px; +} + +textarea { + min-height: 100px; + resize: vertical; +} + +button { + background-color: #3498db; + color: white; + border: none; + padding: 10px 15px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.3s; +} + +button:hover { + background-color: #2980b9; +} + +/* 응답 영역 */ +.response { + margin-top: 15px; + padding: 15px; + border-radius: 4px; + background-color: #f9f9f9; + border-left: 4px solid #3498db; + min-height: 50px; + white-space: pre-wrap; + font-family: monospace; + display: none; +} + +.response.success { + border-left-color: #2ecc71; + background-color: #f0fff0; + display: block; +} + +.response.error { + border-left-color: #e74c3c; + background-color: #fff0f0; + display: block; +} + +/* 푸터 */ +footer { + text-align: center; + padding: 20px; + background-color: #2c3e50; + color: white; + border-radius: 0 0 5px 5px; + margin-top: 20px; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + nav ul { + flex-direction: column; + align-items: center; + } + + nav ul li { + margin: 5px 0; + } +} \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..6324f8d --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,216 @@ + + + + + + DevLog API 테스트 + + + +
+
+

DevLog API 테스트

+
+ + + +
+
+

인증 API

+
+

로그인

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+

로그아웃

+ +
+
+
+ +
+

회원 API

+
+

회원가입

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

프로필 수정

+
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+

게시물 API

+
+

게시물 작성

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+

게시물 조회

+
+
+ + +
+ +
+
+
+ +
+

게시물 검색

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+

게시물 수정

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

게시물 삭제

+
+
+ + +
+ +
+
+
+
+ +
+

댓글 API

+
+

댓글 작성

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+

댓글 삭제

+
+
+ + +
+
+ + +
+ +
+
+
+
+
+ +
+

DevLog API 테스트 인터페이스 © 2024

+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..249c248 --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,337 @@ +// 전역 변수 +let token = localStorage.getItem('token') || ''; + +// 페이지 로드 시 실행 +document.addEventListener('DOMContentLoaded', () => { + // 토큰이 있으면 로그인 상태 표시 + updateAuthStatus(); + + // 이벤트 리스너 등록 + registerEventListeners(); +}); + +// 인증 상태 업데이트 +function updateAuthStatus() { + const isLoggedIn = !!token; + + // 로그인 상태에 따라 UI 변경 + document.querySelectorAll('.requires-auth').forEach(el => { + el.style.display = isLoggedIn ? 'block' : 'none'; + }); + + document.querySelectorAll('.requires-no-auth').forEach(el => { + el.style.display = isLoggedIn ? 'none' : 'block'; + }); +} + +// 이벤트 리스너 등록 +function registerEventListeners() { + // 인증 관련 + document.getElementById('loginForm')?.addEventListener('submit', handleLogin); + document.getElementById('logoutBtn')?.addEventListener('click', handleLogout); + + // 회원 관련 + document.getElementById('signupForm')?.addEventListener('submit', handleSignup); + document.getElementById('updateProfileForm')?.addEventListener('submit', handleUpdateProfile); + + // 게시물 관련 + document.getElementById('createPostForm')?.addEventListener('submit', handleCreatePost); + document.getElementById('getPostForm')?.addEventListener('submit', handleGetPost); + document.getElementById('searchPostForm')?.addEventListener('submit', handleSearchPost); + document.getElementById('updatePostForm')?.addEventListener('submit', handleUpdatePost); + document.getElementById('deletePostForm')?.addEventListener('submit', handleDeletePost); + + // 댓글 관련 + document.getElementById('createCommentForm')?.addEventListener('submit', handleCreateComment); + document.getElementById('deleteCommentForm')?.addEventListener('submit', handleDeleteComment); +} + +// API 요청 함수 +async function apiRequest(url, method, data = null, requiresAuth = false) { + try { + const headers = { + 'Content-Type': 'application/json' + }; + + if (requiresAuth && token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const options = { + method, + headers + }; + + if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + options.body = JSON.stringify(data); + } + + const response = await fetch(url, options); + const responseData = await response.json(); + + if (!response.ok) { + throw new Error(responseData.message || '요청 처리 중 오류가 발생했습니다.'); + } + + return responseData; + } catch (error) { + console.error('API 요청 오류:', error); + throw error; + } +} + +// 응답 표시 함수 +function showResponse(elementId, data, isSuccess = true) { + const responseElement = document.getElementById(elementId); + if (!responseElement) return; + + responseElement.textContent = JSON.stringify(data, null, 2); + responseElement.classList.remove('success', 'error'); + responseElement.classList.add(isSuccess ? 'success' : 'error'); + responseElement.style.display = 'block'; +} + +// 인증 핸들러 +async function handleLogin(e) { + e.preventDefault(); + + const loginId = document.getElementById('loginId').value; + const password = document.getElementById('loginPassword').value; + + try { + const response = await apiRequest('/auth/login', 'POST', { + loginId, + password + }); + + // 토큰 저장 + if (response.data && response.data.token) { + token = response.data.token; + localStorage.setItem('token', token); + updateAuthStatus(); + } + + showResponse('loginResponse', response); + } catch (error) { + showResponse('loginResponse', { error: error.message }, false); + } +} + +async function handleLogout(e) { + e.preventDefault(); + + try { + if (!token) { + throw new Error('로그인되어 있지 않습니다.'); + } + + const response = await apiRequest('/auth/logout', 'POST', null, true); + + // 토큰 제거 + token = ''; + localStorage.removeItem('token'); + updateAuthStatus(); + + showResponse('logoutResponse', response); + } catch (error) { + showResponse('logoutResponse', { error: error.message }, false); + } +} + +// 회원 핸들러 +async function handleSignup(e) { + e.preventDefault(); + + const email = document.getElementById('signupEmail').value; + const password = document.getElementById('signupPassword').value; + const nickname = document.getElementById('signupNickname').value; + + try { + const response = await apiRequest('/members/signup', 'POST', { + email, + password, + nickname + }); + + showResponse('signupResponse', response); + } catch (error) { + showResponse('signupResponse', { error: error.message }, false); + } +} + +async function handleUpdateProfile(e) { + e.preventDefault(); + + const nickname = document.getElementById('updateNickname').value; + const password = document.getElementById('updatePassword').value; + + try { + if (!token) { + throw new Error('로그인이 필요합니다.'); + } + + const data = { nickname }; + if (password) { + data.password = password; + } + + const response = await apiRequest('/members/update', 'PATCH', data, true); + + showResponse('updateProfileResponse', response); + } catch (error) { + showResponse('updateProfileResponse', { error: error.message }, false); + } +} + +// 게시물 핸들러 +async function handleCreatePost(e) { + e.preventDefault(); + + const title = document.getElementById('postTitle').value; + const content = document.getElementById('postContent').value; + + try { + if (!token) { + throw new Error('로그인이 필요합니다.'); + } + + const response = await apiRequest('/api/posts', 'POST', { + title, + content + }, true); + + showResponse('createPostResponse', response); + } catch (error) { + showResponse('createPostResponse', { error: error.message }, false); + } +} + +async function handleGetPost(e) { + e.preventDefault(); + + const postId = document.getElementById('getPostId').value; + + try { + const response = await apiRequest(`/api/posts/${postId}`, 'GET'); + + showResponse('getPostResponse', response); + } catch (error) { + showResponse('getPostResponse', { error: error.message }, false); + } +} + +async function handleSearchPost(e) { + e.preventDefault(); + + const searchType = document.getElementById('searchType').value; + const searchQuery = document.getElementById('searchQuery').value; + + try { + let url = ''; + + switch (searchType) { + case 'title': + url = `/api/posts/posts/title?title=${encodeURIComponent(searchQuery)}`; + break; + case 'content': + url = `/api/posts/posts/content?content=${encodeURIComponent(searchQuery)}`; + break; + case 'tc': + url = `/api/posts/posts/tc?title=${encodeURIComponent(searchQuery)}&content=${encodeURIComponent(searchQuery)}`; + break; + case 'author': + url = `/api/posts/posts/author?nickname=${encodeURIComponent(searchQuery)}`; + break; + default: + throw new Error('잘못된 검색 유형입니다.'); + } + + const response = await apiRequest(url, 'GET'); + + showResponse('searchPostResponse', response); + } catch (error) { + showResponse('searchPostResponse', { error: error.message }, false); + } +} + +async function handleUpdatePost(e) { + e.preventDefault(); + + const postId = document.getElementById('updatePostId').value; + const title = document.getElementById('updatePostTitle').value; + const content = document.getElementById('updatePostContent').value; + + try { + if (!token) { + throw new Error('로그인이 필요합니다.'); + } + + const response = await apiRequest(`/api/posts/${postId}`, 'PUT', { + title, + content + }, true); + + showResponse('updatePostResponse', response); + } catch (error) { + showResponse('updatePostResponse', { error: error.message }, false); + } +} + +async function handleDeletePost(e) { + e.preventDefault(); + + const postId = document.getElementById('deletePostId').value; + + try { + if (!token) { + throw new Error('로그인이 필요합니다.'); + } + + const response = await apiRequest(`/api/posts/${postId}`, 'DELETE', null, true); + + showResponse('deletePostResponse', response); + } catch (error) { + showResponse('deletePostResponse', { error: error.message }, false); + } +} + +// 댓글 핸들러 +async function handleCreateComment(e) { + e.preventDefault(); + + const postId = document.getElementById('commentPostId').value; + const content = document.getElementById('commentContent').value; + + try { + if (!token) { + throw new Error('로그인이 필요합니다.'); + } + + const response = await apiRequest(`/api/posts/${postId}/comments`, 'POST', { + content + }, true); + + showResponse('createCommentResponse', response); + } catch (error) { + showResponse('createCommentResponse', { error: error.message }, false); + } +} + +async function handleDeleteComment(e) { + e.preventDefault(); + + const postId = document.getElementById('deleteCommentPostId').value; + const commentId = document.getElementById('deleteCommentId').value; + + try { + if (!token) { + throw new Error('로그인이 필요합니다.'); + } + + const response = await apiRequest(`/api/posts/${postId}/comments/${commentId}`, 'DELETE', null, true); + + showResponse('deleteCommentResponse', response); + } catch (error) { + showResponse('deleteCommentResponse', { error: error.message }, false); + } +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/Auth/Service/AuthServiceTest.java b/src/test/java/apptive/devlog/Auth/Service/AuthServiceTest.java index e926529..88bceff 100644 --- a/src/test/java/apptive/devlog/Auth/Service/AuthServiceTest.java +++ b/src/test/java/apptive/devlog/Auth/Service/AuthServiceTest.java @@ -5,7 +5,7 @@ import apptive.devlog.Global.Auth.Service.AuthService; import apptive.devlog.Global.Auth.Service.AuthServiceImpl; import apptive.devlog.Global.Auth.Validator.PasswordValidator; -import apptive.devlog.Global.Enum.Gender; +import apptive.devlog.Member.Enum.Gender; import apptive.devlog.Global.Exception.InvalidTokenException; import apptive.devlog.Global.Exception.MemberAlreadyLogoutException; import apptive.devlog.Global.Exception.MemberNotExistException; diff --git a/src/test/java/apptive/devlog/Comment/Controller/CommentControllerTest.java b/src/test/java/apptive/devlog/Comment/Controller/CommentControllerTest.java new file mode 100644 index 0000000..cb31a34 --- /dev/null +++ b/src/test/java/apptive/devlog/Comment/Controller/CommentControllerTest.java @@ -0,0 +1,180 @@ +package apptive.devlog.Comment.Controller; + +import apptive.devlog.Global.Exception.CommentContentBlankException; +import apptive.devlog.Global.Exception.CommentNoPermissionException; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Member.Enum.Gender; +import apptive.devlog.Comment.Dto.CreateCommentRequest; +import apptive.devlog.Comment.Service.CommentService; +import apptive.devlog.Comment.Service.CommentServiceImpl; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WithMockUser +@ExtendWith(MockitoExtension.class) +@WebMvcTest(CommentController.class) +@MockitoBean(name = "commentService", types = CommentServiceImpl.class) +@ActiveProfiles("test") +class CommentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private CommentService commentService; + + private CreateCommentRequest createCommentRequest; + private Member testMember; + + @BeforeEach + void setUp() { + createCommentRequest = new CreateCommentRequest( + "테스트 댓글 내용" + ); + + testMember = new Member( + "test@example.com", + "Test User", + "TestUser", + LocalDate.now(), + Gender.MALE, + "password123" + ); + } + + @Nested + @DisplayName("댓글 작성 테스트") + class CreateComment { + @Test + @DisplayName("최상위 댓글 작성 성공 테스트") + void createRootCommentSuccess() throws Exception { + // Given + Long postId = 1L; + + // When + when(commentService.createComment(any(Long.class), any(CreateCommentRequest.class), any(Member.class))) + .thenReturn(1L); + + // Then + mockMvc.perform(post("/api/posts/{postId}/comments", postId) + .with(csrf()) + .with(authentication( + new UsernamePasswordAuthenticationToken(testMember, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))) + )) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createCommentRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("댓글 작성에 성공하였습니다.")) + .andExpect(jsonPath("$.data.commentId").value(1L)); + } + + @Test + @DisplayName("대댓글 작성 성공 테스트") + void createReplyCommentSuccess() throws Exception { + // Given + Long postId = 1L; + Long parentCommentId = 1L; + CreateCommentRequest replyRequest = new CreateCommentRequest("대댓글 내용", parentCommentId); + + // When + when(commentService.createComment(any(Long.class), any(CreateCommentRequest.class), any(Member.class))) + .thenReturn(2L); + + // Then + mockMvc.perform(post("/api/posts/{postId}/comments", postId) + .with(csrf()) + .with(authentication( + new UsernamePasswordAuthenticationToken(testMember, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))) + )) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(replyRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("댓글 작성에 성공하였습니다.")) + .andExpect(jsonPath("$.data.commentId").value(2L)); + } + + @Test + @DisplayName("댓글 작성 실패 테스트 - 내용 공백") + void createCommentFail_BlankContent() throws Exception { + // Given + Long postId = 1L; + CreateCommentRequest blankRequest = new CreateCommentRequest(""); + when(commentService.createComment(any(Long.class), any(CreateCommentRequest.class), any(Member.class))) + .thenThrow(new CommentContentBlankException()); + + // When & Then + mockMvc.perform(post("/api/posts/{postId}/comments", postId) + .with(csrf()) + .with(authentication( + new UsernamePasswordAuthenticationToken(testMember, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))) + )) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blankRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("댓글 내용은 공백일 수 없습니다.")); + } + } + + @Nested + @DisplayName("댓글 삭제 테스트") + class DeleteComment { + @Test + @DisplayName("댓글 삭제 성공 테스트") + void deleteCommentSuccess() throws Exception { + // Given + Long postId = 1L; + Long commentId = 1L; + + // When & Then + mockMvc.perform(delete("/api/posts/{postId}/comments/{commentId}", postId, commentId) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("댓글을 성공적으로 삭제하였습니다.")); + } + + @Test + @DisplayName("댓글 삭제 실패 테스트 - 권한 없음") + void deleteCommentFail_NoPermission() throws Exception { + // Given + Long postId = 1L; + Long commentId = 1L; + + // When + doThrow(new CommentNoPermissionException()) + .when(commentService).deleteComment(anyLong(), anyLong(), any(Member.class)); + + // Then + mockMvc.perform(delete("/api/posts/{postId}/comments/{commentId}", postId, commentId) + .with(csrf()) + .with(authentication( + new UsernamePasswordAuthenticationToken(testMember, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))) + ))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value("댓글 접근 권한이 없습니다.")); + } + } +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/Comment/Service/CommentServiceTest.java b/src/test/java/apptive/devlog/Comment/Service/CommentServiceTest.java new file mode 100644 index 0000000..6e2baa8 --- /dev/null +++ b/src/test/java/apptive/devlog/Comment/Service/CommentServiceTest.java @@ -0,0 +1,155 @@ +package apptive.devlog.Comment.Service; + +import apptive.devlog.Global.Exception.CommentContentBlankException; +import apptive.devlog.Global.Exception.CommentNoPermissionException; +import apptive.devlog.Global.Exception.CommentNotFoundException; +import apptive.devlog.Global.Exception.PostNotFoundException; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Post.Domain.Post; +import apptive.devlog.Comment.Domain.Comment; +import apptive.devlog.Comment.Dto.CreateCommentRequest; +import apptive.devlog.Comment.Repository.CommentRepository; +import apptive.devlog.Post.Repository.PostRepository; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @InjectMocks + private CommentServiceImpl commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private PostRepository postRepository; + + private Member member; + private Post post; + private Comment comment; + private CreateCommentRequest createCommentRequest; + + @BeforeEach + void setUp() { + member = new Member(); + member.setEmail("test@example.com"); + member.setNickname("TestUser"); + + post = new Post(); + post.setId(1L); + post.setTitle("테스트 제목"); + post.setContent("테스트 내용"); + post.setAuthor(member); + + comment = new Comment(); + comment.setId(1L); + comment.setContent("테스트 댓글 내용"); + comment.setAuthor(member); + comment.setPost(post); + + createCommentRequest = new CreateCommentRequest( + "테스트 댓글 내용" + ); + } + + @Nested + @DisplayName("댓글 작성 테스트") + class CreateComment { + @Test + @DisplayName("댓글 작성 성공 테스트") + void createCommentSuccess() { + // Given + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(commentRepository.save(any(Comment.class))).thenReturn(comment); + + // When + Long commentId = commentService.createComment(1L, createCommentRequest, member); + + // Then + assertThat(commentId).isEqualTo(1L); + verify(commentRepository, times(1)).save(any(Comment.class)); + } + + @Test + @DisplayName("댓글 작성 실패 테스트 - 내용 공백") + void createCommentFail_BlankContent() { + // Given + createCommentRequest.setContent(""); + + // When & Then + assertThatThrownBy(() -> commentService.createComment(1L, createCommentRequest, member)) + .isInstanceOf(CommentContentBlankException.class) + .hasMessage("댓글 내용은 공백일 수 없습니다."); + } + + @Test + @DisplayName("댓글 작성 실패 테스트 - 존재하지 않는 게시글") + void createCommentFail_NotExistPost() { + // Given + when(postRepository.findById(999L)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> commentService.createComment(999L, createCommentRequest, member)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage("존재하지 않는 게시글입니다."); + } + } + + @Nested + @DisplayName("댓글 삭제 테스트") + class DeleteComment { + @Test + @DisplayName("댓글 삭제 성공 테스트") + void deleteCommentSuccess() { + // Given + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(commentRepository.findById(1L)).thenReturn(Optional.of(comment)); + + // When + commentService.deleteComment(1L, 1L, member); + + // Then + verify(commentRepository, times(1)).delete(comment); + } + + @Test + @DisplayName("댓글 삭제 실패 테스트 - 권한 없음") + void deleteCommentFail_NoPermission() { + // Given + Member otherMember = new Member(); + otherMember.setEmail("other@example.com"); + otherMember.setNickname("OtherUser"); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(commentRepository.findById(1L)).thenReturn(Optional.of(comment)); + + // When & Then + assertThatThrownBy(() -> commentService.deleteComment(1L, 1L, otherMember)) + .isInstanceOf(CommentNoPermissionException.class) + .hasMessage("댓글 접근 권한이 없습니다."); + } + + @Test + @DisplayName("댓글 삭제 실패 테스트 - 존재하지 않는 댓글") + void deleteCommentFail_NotExist() { + // Given + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(commentRepository.findById(999L)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> commentService.deleteComment(1L, 999L, member)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage("존재하지 않는 댓글입니다."); + } + } +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/Member/Controller/MemberControllerTest.java b/src/test/java/apptive/devlog/Member/Controller/MemberControllerTest.java index b21f928..a15dff8 100644 --- a/src/test/java/apptive/devlog/Member/Controller/MemberControllerTest.java +++ b/src/test/java/apptive/devlog/Member/Controller/MemberControllerTest.java @@ -1,8 +1,7 @@ package apptive.devlog.Member.Controller; import apptive.devlog.Global.Auth.Dto.SignUpDto; -import apptive.devlog.Global.Auth.Jwt.JwtTokenProvider; -import apptive.devlog.Global.Enum.Gender; +import apptive.devlog.Member.Enum.Gender; import apptive.devlog.Global.Exception.EmailDuplicationException; import apptive.devlog.Global.Exception.InvalidTokenException; import apptive.devlog.Global.Exception.MemberNotExistException; @@ -17,7 +16,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -27,14 +25,12 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.nio.file.AccessDeniedException; - import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; @WithMockUser // 테스트에서 인증된 사용자로 실행! @ExtendWith(MockitoExtension.class) // Mockito 확장 활성화 @@ -89,11 +85,11 @@ void signupSuccess() throws Exception { when(memberService.signUp(any(SignUpDto.class))).thenReturn(member); // Then: API 호출 및 검증 - mockMvc.perform(post("/user/signup") + mockMvc.perform(post("/members/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(signUpDto))) - .andExpect(status().isOk()) + .andExpect(status().isCreated()) .andExpect(jsonPath("$.message").value(ResultCode.REGISTER_SUCCESS.getMessage())) .andExpect(jsonPath("$.data.email").value("test@example.com")); } @@ -103,11 +99,10 @@ void signupSuccess() throws Exception { void loginFail_InvalidInput() throws Exception { // Given // When - doThrow(new IllegalArgumentException("입력값 누락")) - .when(memberService).signUp(any(SignUpDto.class)); + when(memberService.signUp(any(SignUpDto.class))).thenThrow(new IllegalArgumentException("입력값 누락")); // Then - mockMvc.perform(post("/user/signup") + mockMvc.perform(post("/members/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(signUpDto))) @@ -121,11 +116,10 @@ void loginFail_InvalidInput() throws Exception { void loginFail_ExistId() throws Exception { // Given // When - doThrow(new EmailDuplicationException()) - .when(memberService).signUp(any(SignUpDto.class)); + when(memberService.signUp(any(SignUpDto.class))).thenThrow(new EmailDuplicationException()); // Then - mockMvc.perform(post("/user/signup") + mockMvc.perform(post("/members/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(signUpDto))) @@ -138,11 +132,10 @@ void loginFail_ExistId() throws Exception { void loginFail_ExistNickname() throws Exception { // Given // When - doThrow(new NicknameDuplicationException()) - .when(memberService).signUp(any(SignUpDto.class)); + when(memberService.signUp(any(SignUpDto.class))).thenThrow(new NicknameDuplicationException()); // Then - mockMvc.perform(post("/user/signup") + mockMvc.perform(post("/members/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(signUpDto))) @@ -155,11 +148,10 @@ void loginFail_ExistNickname() throws Exception { void loginFail_InvalidPw() throws Exception { // Given // When - doThrow(new IllegalArgumentException("비밀번호가 유효하지 않습니다.")) - .when(memberService).signUp(any(SignUpDto.class)); + when(memberService.signUp(any(SignUpDto.class))).thenThrow(new IllegalArgumentException("비밀번호가 유효하지 않습니다.")); // Then - mockMvc.perform(post("/user/signup") + mockMvc.perform(post("/members/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(signUpDto))) @@ -172,11 +164,10 @@ void loginFail_InvalidPw() throws Exception { void loginFail_Birth() throws Exception { // Given // When - doThrow(new IllegalArgumentException("Invalid date format")) - .when(memberService).signUp(any(SignUpDto.class)); + when(memberService.signUp(any(SignUpDto.class))).thenThrow(new IllegalArgumentException("Invalid date format")); // Then - mockMvc.perform(post("/user/signup") + mockMvc.perform(post("/members/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(signUpDto))) @@ -202,7 +193,7 @@ void updateProfileSuccess() throws Exception { doNothing().when(memberService).updateProfile(any(UpdateProfileDto.class)); // Then: API 호출 및 검증 (컨트롤러는 성공 시 ResultResponse를 반환) - mockMvc.perform(post("/user/update") + mockMvc.perform(patch("/members/update") .with(csrf()) .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON) @@ -223,7 +214,7 @@ void updateFail_InvalidToken() throws Exception { doThrow(new InvalidTokenException()) .when(memberService).updateProfile(any(UpdateProfileDto.class)); - mockMvc.perform(post("/user/update") + mockMvc.perform(patch("/members/update") .with(csrf()) .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON) @@ -244,7 +235,7 @@ void updateFail_InvalidUser() throws Exception { doThrow(new MemberNotExistException()) .when(memberService).updateProfile(any(UpdateProfileDto.class)); - mockMvc.perform(post("/user/update") + mockMvc.perform(patch("/members/update") .with(csrf()) .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON) @@ -264,7 +255,7 @@ void updateFail_Blank() throws Exception { doThrow(new IllegalArgumentException("수정된 정보는 공백일 수 없습니다.")) .when(memberService).updateProfile(any(UpdateProfileDto.class)); - mockMvc.perform(post("/user/update") + mockMvc.perform(patch("/members/update") .with(csrf()) .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/apptive/devlog/Member/Service/MemberServiceTest.java b/src/test/java/apptive/devlog/Member/Service/MemberServiceTest.java index 01e3f74..fc0da14 100644 --- a/src/test/java/apptive/devlog/Member/Service/MemberServiceTest.java +++ b/src/test/java/apptive/devlog/Member/Service/MemberServiceTest.java @@ -4,7 +4,7 @@ import apptive.devlog.Global.Auth.Jwt.JwtTokenProvider; import apptive.devlog.Global.Auth.Service.AuthService; import apptive.devlog.Global.Auth.Validator.PasswordValidator; -import apptive.devlog.Global.Enum.Gender; +import apptive.devlog.Member.Enum.Gender; import apptive.devlog.Global.Exception.EmailDuplicationException; import apptive.devlog.Global.Exception.InvalidTokenException; import apptive.devlog.Global.Exception.MemberNotExistException; @@ -12,7 +12,6 @@ import apptive.devlog.Global.Response.Error.ErrorCode; import apptive.devlog.Member.Domain.Member; import apptive.devlog.Member.Dto.UpdateProfileDto; -import apptive.devlog.Member.Dto.UpdateProfileRequest; import apptive.devlog.Member.Repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/apptive/devlog/Post/Controller/PostControllerTest.java b/src/test/java/apptive/devlog/Post/Controller/PostControllerTest.java new file mode 100644 index 0000000..81d1629 --- /dev/null +++ b/src/test/java/apptive/devlog/Post/Controller/PostControllerTest.java @@ -0,0 +1,261 @@ +package apptive.devlog.Post.Controller; + +import apptive.devlog.Global.Exception.PostContentBlankException; +import apptive.devlog.Global.Exception.PostNoPermissionException; +import apptive.devlog.Global.Exception.PostNotFoundException; +import apptive.devlog.Global.Exception.PostTitleBlankException; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Member.Enum.Gender; +import apptive.devlog.Post.Domain.Post; +import apptive.devlog.Post.Dto.CreatePostRequest; +import apptive.devlog.Post.Dto.UpdatePostRequest; +import apptive.devlog.Post.Service.PostService; +import apptive.devlog.Post.Service.PostServiceImpl; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WithMockUser +@ExtendWith(MockitoExtension.class) +@WebMvcTest(PostController.class) +@MockitoBean(name = "postService", types = PostServiceImpl.class) +@ActiveProfiles("test") +class PostControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private PostService postService; + + private CreatePostRequest createPostRequest; + private UpdatePostRequest updatePostRequest; + private Member testMember; + + private Post post; + + @BeforeEach + void setUp() { + createPostRequest = new CreatePostRequest( + "테스트 제목", + "테스트 내용" + ); + + updatePostRequest = new UpdatePostRequest( + "수정된 제목", + "수정된 내용" + ); + + testMember = new Member( + "test@example.com", + "Test User", + "TestUser", + LocalDate.now(), + Gender.MALE, + "password123" + ); + + post = new Post(); + post.setId(1L); + post.setTitle("테스트 제목"); + post.setContent("테스트 내용"); + post.setAuthor(testMember); + } + + @Nested + @DisplayName("게시글 작성 테스트") + class CreatePost { + @Test + @DisplayName("게시글 작성 성공 테스트") + void createPostSuccess() throws Exception { + // When + when(postService.createPost(any(CreatePostRequest.class), eq(testMember))) + .thenReturn(1L); + + // Then + mockMvc.perform(post("/api/posts") + .with(csrf()) + .with(authentication( + new UsernamePasswordAuthenticationToken(testMember, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))) + )) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createPostRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("게시글 작성에 성공하였습니다.")) + .andExpect(jsonPath("$.data.postId").value(1L)); + + verify(postService).createPost(any(CreatePostRequest.class), any(Member.class)); + } + + @Test + @DisplayName("게시글 작성 실패 테스트 - 제목 공백") + void createPostFail_BlankTitle() throws Exception { + // Given + CreatePostRequest blankTitleRequest = new CreatePostRequest("", "테스트 내용"); + + // When + when(postService.createPost(any(CreatePostRequest.class), nullable(Member.class))) + .thenThrow(new PostTitleBlankException()); + + // Then + mockMvc.perform(post("/api/posts") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blankTitleRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("제목은 공백일 수 없습니다.")); + } + + @Test + @DisplayName("게시글 작성 실패 테스트 - 내용 공백") + void createPostFail_BlankContent() throws Exception { + // Given + CreatePostRequest blankContentRequest = new CreatePostRequest("테스트 제목", ""); + + // When + when(postService.createPost(any(CreatePostRequest.class), nullable(Member.class))) + .thenThrow(new PostContentBlankException()); + + + // Then + mockMvc.perform(post("/api/posts") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blankContentRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("내용은 공백일 수 없습니다.")); + } + } + + @Nested + @DisplayName("게시글 조회 테스트") + class ReadPost { + @Test + @DisplayName("게시글 조회 성공 테스트") + void readPostSuccess() throws Exception { + // Given + Long postId = 1L; + when(postService.getOnePost(postId)).thenReturn(post); + + + // When & Then + mockMvc.perform(get("/api/posts/{postId}", postId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").exists()) + .andExpect(jsonPath("$.data.content").exists()); + } + + @Test + @DisplayName("게시글 조회 실패 테스트 - 존재하지 않는 게시글") + void readPostFail_NotExist() throws Exception { + // Given + Long postId = 999L; + + // When + when(postService.getOnePost(postId)) + .thenThrow(new PostNotFoundException()); + + // Then + mockMvc.perform(get("/api/posts/{postId}", postId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + } + + @Nested + @DisplayName("게시글 수정 테스트") + class UpdatePost { + @Test + @DisplayName("게시글 수정 성공 테스트") + void updatePostSuccess() throws Exception { + // Given + Long postId = 1L; + + // When & Then + mockMvc.perform(put("/api/posts/{postId}", postId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updatePostRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("게시글을 성공적으로 수정하였습니다.")); + } + + @Test + @DisplayName("게시글 수정 실패 테스트 - 권한 없음") + void updatePostFail_NoPermission() throws Exception { + // Given + Long postId = 1L; + + // When + doThrow(new PostNoPermissionException()) + .when(postService).updatePost(anyLong(), any(UpdatePostRequest.class), nullable(Member.class)); + + // Then + mockMvc.perform(put("/api/posts/{postId}", postId) + .with(csrf()) + .with(user("test@example.com") // SecurityContext에 유저 정보 추가 + .roles("USER") + ).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updatePostRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value("게시글 접근 권한이 없습니다.")); + } + } + + @Nested + @DisplayName("게시글 삭제 테스트") + class DeletePost { + @Test + @DisplayName("게시글 삭제 성공 테스트") + void deletePostSuccess() throws Exception { + // Given + Long postId = 1L; + doNothing().when(postService).deletePost(postId,testMember); + + // When & Then + mockMvc.perform(delete("/api/posts/{postId}", postId) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("게시글을 성공적으로 삭제하였습니다.")); + } + + @Test + @DisplayName("게시글 삭제 실패 테스트 - 권한 없음") + void deletePostFail_NoPermission() throws Exception { + // Given + Long postId = 1L; + + // When + doThrow(new PostNoPermissionException()) + .when(postService).deletePost(anyLong(),nullable(Member.class)); + + // Then + mockMvc.perform(delete("/api/posts/{postId}", postId) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value("게시글 접근 권한이 없습니다.")); + } + } +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/Post/Service/PostServiceTest.java b/src/test/java/apptive/devlog/Post/Service/PostServiceTest.java new file mode 100644 index 0000000..b1b2649 --- /dev/null +++ b/src/test/java/apptive/devlog/Post/Service/PostServiceTest.java @@ -0,0 +1,204 @@ +package apptive.devlog.Post.Service; + +import apptive.devlog.Global.Exception.PostContentBlankException; +import apptive.devlog.Global.Exception.PostNoPermissionException; +import apptive.devlog.Global.Exception.PostNotFoundException; +import apptive.devlog.Global.Exception.PostTitleBlankException; +import apptive.devlog.Global.Response.Error.ErrorCode; +import apptive.devlog.Member.Domain.Member; +import apptive.devlog.Post.Domain.Post; +import apptive.devlog.Post.Dto.CreatePostRequest; +import apptive.devlog.Post.Dto.UpdatePostRequest; +import apptive.devlog.Post.Repository.PostRepository; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PostServiceTest { + + @InjectMocks + private PostServiceImpl postService; + + @Mock + private PostRepository postRepository; + + private Member member; + private Post post; + private CreatePostRequest createPostRequest; + private UpdatePostRequest updatePostRequest; + + @BeforeEach + void setUp() { + member = new Member(); + member.setEmail("test@example.com"); + member.setNickname("TestUser"); + + post = new Post(); + post.setId(1L); + post.setTitle("테스트 제목"); + post.setContent("테스트 내용"); + post.setAuthor(member); + + createPostRequest = new CreatePostRequest( + "테스트 제목", + "테스트 내용" + ); + + updatePostRequest = new UpdatePostRequest( + "수정된 제목", + "수정된 내용" + ); + } + + @Nested + @DisplayName("게시글 작성 테스트") + class CreatePost { + @Test + @DisplayName("게시글 작성 성공 테스트") + void createPostSuccess() { + // Given + when(postRepository.save(any(Post.class))).thenReturn(post); + + // When + Long postId = postService.createPost(createPostRequest, member); + + // Then + assertThat(postId).isEqualTo(1L); + verify(postRepository, times(1)).save(any(Post.class)); + } + + @Test + @DisplayName("게시글 작성 실패 테스트 - 제목 공백") + void createPostFail_BlankTitle() { + // Given + createPostRequest.setTitle(""); + + // When & Then + assertThatThrownBy(() -> postService.createPost(createPostRequest, member)) + .isInstanceOf(PostTitleBlankException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.POST_TITLE_BLANK); + } + + @Test + @DisplayName("게시글 작성 실패 테스트 - 내용 공백") + void createPostFail_BlankContent() { + // Given + createPostRequest.setContent(""); + + // When & Then + assertThatThrownBy(() -> postService.createPost(createPostRequest, member)) + .isInstanceOf(PostContentBlankException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.POST_CONTENT_BLANK); + } + } + + @Nested + @DisplayName("게시글 조회 테스트") + class ReadPost { + @Test + @DisplayName("게시글 조회 성공 테스트") + void readPostSuccess() { + // Given + when(postRepository.findById(anyLong())).thenReturn(Optional.of(post)); + + // When + Post foundPost = postService.getOnePost(1L); + + // Then + assertThat(foundPost).isNotNull(); + assertThat(foundPost.getTitle()).isEqualTo("테스트 제목"); + assertThat(foundPost.getContent()).isEqualTo("테스트 내용"); + } + + @Test + @DisplayName("게시글 조회 실패 테스트 - 존재하지 않는 게시글") + void readPostFail_NotExist() { + // Given + when(postRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> postService.getOnePost(999L)) + .isInstanceOf(PostNotFoundException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.POST_NOT_FOUND); + } + } + + @Nested + @DisplayName("게시글 수정 테스트") + class UpdatePost { + @Test + @DisplayName("게시글 수정 성공 테스트") + void updatePostSuccess() { + // Given + when(postRepository.findById(anyLong())).thenReturn(Optional.of(post)); + when(postRepository.save(any(Post.class))).thenReturn(post); + + // When + postService.updatePost(1L, updatePostRequest, member); + + // Then + assertThat(post.getTitle()).isEqualTo("수정된 제목"); + assertThat(post.getContent()).isEqualTo("수정된 내용"); + verify(postRepository, times(1)).save(any(Post.class)); + } + + @Test + @DisplayName("게시글 수정 실패 테스트 - 권한 없음") + void updatePostFail_NoPermission() { + // Given + Member otherMember = new Member(); + otherMember.setEmail("other@example.com"); + otherMember.setNickname("OtherUser"); + when(postRepository.findById(anyLong())).thenReturn(Optional.of(post)); + + // When & Then + assertThatThrownBy(() -> postService.updatePost(1L, updatePostRequest, otherMember)) + .isInstanceOf(PostNoPermissionException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.POST_NO_PERMISSION); + } + } + + @Nested + @DisplayName("게시글 삭제 테스트") + class DeletePost { + @Test + @DisplayName("게시글 삭제 성공 테스트") + void deletePostSuccess() { + // Given + when(postRepository.findById(anyLong())).thenReturn(Optional.of(post)); + doNothing().when(postRepository).delete(any(Post.class)); + + // When + postService.deletePost(1L, member); + + // Then + verify(postRepository, times(1)).delete(post); + } + + @Test + @DisplayName("게시글 삭제 실패 테스트 - 권한 없음") + void deletePostFail_NoPermission() { + // Given + Member otherMember = new Member(); + otherMember.setEmail("other@example.com"); + otherMember.setNickname("OtherUser"); + when(postRepository.findById(anyLong())).thenReturn(Optional.of(post)); + + // When & Then + assertThatThrownBy(() -> postService.deletePost(1L, otherMember)) + .isInstanceOf(PostNoPermissionException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.POST_NO_PERMISSION); + } + } +} \ No newline at end of file