diff --git a/build.gradle b/build.gradle index dfd0328..fa4e7b7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.4.3' + id 'org.springframework.boot' version '3.3.3' id 'io.spring.dependency-management' version '1.1.7' id 'org.asciidoctor.jvm.convert' version '3.3.2' } @@ -39,6 +39,15 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // jwt + implementation 'com.auth0:java-jwt:4.2.1' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + + // password encoder + implementation 'org.springframework.security:spring-security-crypto' } tasks.named('test') { diff --git a/docs/code_review.md b/docs/code_review.md new file mode 100644 index 0000000..79eebf5 --- /dev/null +++ b/docs/code_review.md @@ -0,0 +1,130 @@ +# /api 는 클래스 레벨보다는 yml로 따로 빼는 건 어떨까요? + +--- +## 문제점 + +* 개발할 때는 /api 경로로 접근하지만 배포 환경에서는 /prod 경로로 접근한다고 가정한다. -> @RequestMapping("/api")를 전부 /prod로 바꿔야 한다. +* 또한 URI.create(~~) 부분도 모두 수정해야 한다. + +## 해결 + +* application.yml 파일에 경로를 미리 설정한다. +* URI 부분은 ServletUriComponentsBuilder의 fromCurrentRequestUri() 메서드를 사용하여 해결한다. -> 해당 메서드는 현재 요청을 보내는 경로를 받아오는 기능을 제공한다. + +# 에러 코드 재사용성 + +--- +## 문제점(현재 에러 코드 구조) + +~~~ +public class GlobalExceptionHandler { + @ExceptionHandler(GlobalException.class) + public ResponseEntity handleException(GlobalException e) { + } +} + +@Getter +public enum GlobalErrorCode { + +} + +@Getter +public class GlobalException extends RuntimeException { + + private final GlobalErrorCode errorCode; + + public GlobalException(GlobalErrorCode errorCode) { + } +} +~~~ + +* 현재 이 3단계 구조로, 특정 관심사 패키지마다 exception 을 관리하고 있음 +* 이렇게 할 경우, 패키지마다 계속해서 exception 을 생성 및 관리해야함 -> 코드 재활용이 안됌. + +## 해결책 + +* common 패키지에, 공통적으로 사용되는 errorCode 를 interface화 시킴. +* 이후 interface(errorCode) 를 사용한 BaseException 클래스를 생성 -> 다른 패키지 exception 에서 BaseException 상속받아 사용할 수 있음 +* 그렇기에 exception handler 도 하나로만 관리 가능 + +## 결론 + +* ErrorCode 인터페이스 하나 파서 관리 +* BaseException 생성, 그리고 이 클래스를 이용한 BaseExceptionHandler 생성 -> 핸들러 하나만 사용 가능 +* MemberException, ArticleException 등등 BaseException 을 상속받아 BaseExceptionHandler 에서 에러 처리 가능 +* ErrorCode 추가될때마다 exception 을 추가할 필요가 없어짐. + +--- + +# Offset Paging + +--- +## 장점 +* 구현이 쉽다 -> JPA에서 바로 Pageable 객체로 페이징 가능 +* 페이지 번호가 있는 프론트 구현 시, 잘 맞는다. + +## 단점 +* OFFSET 100000 같이 뒤 페이지로 갈수록 느려짐 → 인덱스가 있어도 느림 // 해당 문제는 커서 기반 페이징을 통해 해결 가능 -> offset 은 처음부터 모든 column을 조회해서 속도가 느리지만 커서 기반 페이징은 특정 id 를 기준으로 해당 부분부터 조회하기에 빠르다 +* 페이징 도중 데이터가 추가/삭제되면 순서가 뒤틀릴 수 있음 (같은 댓글이 여러 번 보이거나 사라짐) + + +# ```@ManyToOne``` vs 간접참조 + +--- + +## @ManyToOne + +* 객체를 통째로 다루기 떄문에 저장이 간편하다. +* 그렇기에 객체에 직접적으로 바로 접근이 가능하다. +* 하지만 연관관계 이슈 때문에 객체 조회 시 N+1 문제가 발생할 수 있다. +* 따라서 관계에 따라 접근 전략을 모색해야 한다. + +## 간접참조 + +* id 값만 저장하면 된다.(단순하고 가볍게 관리 가능) +* 따라서, id 값을 통해 조회를 또 다시 해야한다. +* 하지만 이는 객체지향과는 조금 거리가 멀다.(객체가 아닌, 단순 id 값만 가지고 있기 때문이다.) + +# ORM의 탄생 배경 + +--- + +* ORM 은 DB를 테이블처럼 다루지 말고 객체 처럼 다루자는 목표로 등장. + + +# 객체 관계에 따른 차이 + +--- + +## 단방향 매핑 (OneToMany or ManyToOne 한쪽만) + +* Comment → Article만 있는 구조 or Article -> Comment 만 있는 구조 +* 조회나 저장할 때 한쪽에서만 조작 가능 +~~~ +@Entity +public class Comment { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id") + private Article article; +} + +댓글을 저장할 때 어떤 게시글에 달리는지는 필요함 → Comment → Article +하지만 Article 입장에서는 굳이 댓글들을 알고 있을 필요 없음 +~~~ + +## 양방향 매핑 +* Article → Comment, Comment → Article 모두 존재 +* 양쪽이 서로를 참조하는 구조 +~~~ +// Article +@OneToMany(mappedBy = "article", cascade = CascadeType.ALL) +private List comments; + +// Comment +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "article_id") +private Article article; + +게시글 상세 페이지에서 댓글도 같이 조회하고 싶다 or 게시글 삭제 시 댓글도 자동 삭제되게 하고 싶다 +~~~ + diff --git a/docs/spring_annotation.md b/docs/spring_annotation.md new file mode 100644 index 0000000..84fad7a --- /dev/null +++ b/docs/spring_annotation.md @@ -0,0 +1,87 @@ +## **Java Bean Validation: `@NotNull`, `@NotEmpty`, `@NotBlank` 차이점** + +--- + +### 1. @NotNull + +* null 값만 허용하지 않음 +* 빈 값("")과 공백(" ")은 허용됨 + +### 2. @NotEmpty + +* null 값과 빈 값("") 모두 허용되지 않음 +* 공백(" ")은 허용됨 + +### 3. @NotBlank + +* null 값, 빈 값(""), 공백(" ") 모두 허용되지 않음 + +**회원 가입 시, @NotBlank 어노테이션 활용하는 것이 좋을 것 같음** + + +## **Lombok의 생성자 어노테이션 비교 (`@AllArgsConstructor`, `@RequiredArgsConstructor`, `@NoArgsConstructor`)** + +--- + +### 1. @AllArgsConstructor + +* 클래스의 모든 필드를 초기화하는 생성자를 자동 생성 +* @NonNull 필드가 null이면 NullPointerException 발생 + +### 2. @RequiredArgsConstructor + +* final 필드 및 @NonNull 필드만 포함하는 생성자를 생성 +* 초기화된 final 필드는 생성자에서 제외됨 + +### 3. @NoArgsConstructor + +* 매개변수가 없는 기본 생성자를 생성 +* final 필드가 있을 경우 force = true 옵션 필요 + +### JPA 에서 기본 생성자가 필요한 이유(protected 쓰는 이유까지) + +1. JPA는 데이터베이스에서 조회한 결과를 기반으로 엔티티 객체를 생성해야 합니다. +2. Reflection을 이용하여 객체를 만들기 때문에, 기본 생성자가 반드시 필요합니다. +3. 외부에서 기본 생성자를 직접 호출하는 것을 막기 위해 protected 사용합니다. +3. 만약 기본 생성자가 없으면 InstantiationException 오류 발생! + + +## @RequestParam vs @PathVariable 차이점 + +--- + +### 1. @RequestParam + +* `@RequestParam`은 **쿼리 문자열**에서 값을 추출합니다. - (쿼리 파라미터) +* 예시 +~~~ +@GetMapping("/foos") +@ResponseBody +public String getFooByIdUsingQueryParam(@RequestParam String id) { + return "ID: " + id; +} +~~~ + +해당 요청을 처리하는 URL는 다음과 같습니다. - > `http://localhost:8080/spring-mvc-basics/foos?id=abc` + +* @RequestParam은 URL 디코딩되어 값을 추출합니다. +`http://localhost:8080/foos?id=ab+c` -> `ab c` 를 추출합니다. (+ 가 공백으로 디코딩 됌) +* @RequestParam은 필터링이나 검색 조건을 전달할 때 유용합니다. + +### 2. @PathVariable + +* `@PathVariable`은 **URI 경로**에서 값을 추출합니다. - (URI 경로) +* 예시 +~~~ +@GetMapping({"/myfoos/optional", "/myfoos/optional/{id}"}) +@ResponseBody +public String getFooByOptionalId(@PathVariable(required = false) String id){ + return "ID: " + id; +} +~~~ + +해당 요청을 처리하는 URL 은 다음과 같습니다. -> `http://localhost:8080/spring-mvc-basics/myfoos/optional/abc` + +* @PathVariable은 URI 경로에서 값을 추출하기 때문에 값이 인코딩되지 않습니다. +`http://localhost:8080/foos/id=ab+c` -> `ab+b` 값을 정확하게 추출합니다. +* @PathVariable은 주로 리소스를 식별할 때 유용합니다. diff --git a/docs/spring_cs.md b/docs/spring_cs.md new file mode 100644 index 0000000..e87d9ec --- /dev/null +++ b/docs/spring_cs.md @@ -0,0 +1,23 @@ +## @Transactional(readOnly = true) 를 사용한 경우 vs 사용하지 않은 경우 + +--- + +### 1. @Transactional(readOnly = true)를 사용하지 않은 경우 + +1. 트랜잭션이 시작됩니다. +2. SELECT 쿼리를 실행하여 데이터 조회합니다. +3. 트랜잭션이 쓰기 모드이므로 변경 감지(Dirty Checking)가 활성화됩니다. +4. 조회한 엔티티를 영속성 컨텍스트(Persistence Context)에 저장합니다. +5. JPA가 Book 엔티티를 관리하기 시작합니다. +6. 트랜잭션이 종료되면, 변경된 엔티티가 있으면 UPDATE 쿼리를 실행합니다. +7. 변경이 없어도 flush()가 호출되면서 DB와 동기화 수행합니다. → 불필요한 성능 저하 가능 + + +### 2. @Transactional(readOnly = true) 사용한 경우 + +1. 트랜잭션이 시작됩니다. (READ ONLY) +2. SELECT 쿼리를 실행하여 데이터 조회합니다. +3. 쓰기 관련 기능(변경 감지, flush, dirty checking)이 비활성화 됩니다. +4. JPA가 엔티티를 관리하지 않습니다. (영속성 컨텍스트 제외) +5. 트랜잭션이 종료됩니다. + diff --git a/docs/test.md b/docs/test.md new file mode 100644 index 0000000..8819816 --- /dev/null +++ b/docs/test.md @@ -0,0 +1,143 @@ +# Spring Test + +--- + +## @DataJpaTest와 JUnit을 사용한 Repository 테스트 + +--- + +### 개요 + +* Spring Boot 애플리케이션에서 Spring Data JPA를 사용하여 데이터베이스를 다룰 때, JPA Repository가 제대로 동작하는지 테스트하는 것이 중요합니다. + +---- + +### @DataJpaTest + +* @DataJpaTest 란? -> JPA Repository 테스트를 위한 애너테이션입니다. +* 최소한의 Spring 컨텍스트만 로드하여 테스트 속도를 높이고, EntityManager와 TestEntityManager를 자동 제공합니다. +~~~ +EntityManager란? + +-> JPA(Java Persistence API)에서 제공하는 기본적인 데이터베이스 관리 도구입니다. + Spring Boot에서 JPA를 사용할 때, 일반적으로 EntityManager를 사용하여 데이터베이스 작업을 수행합니다. + +EntityManager의 주요 기능 + +1. 엔티티 저장: persist(entity) +2. 엔티티 수정: merge(entity) +3. 엔티티 삭제: remove(entity) +4. 엔티티 조회: find(Class, primaryKey), createQuery() + +----------------------------------------------------------------------------------------- +EntityTestManager란? + +-> 테스트용으로 제공되는 EntityManager입니다. + Spring Boot의 @DataJpaTest에서 사용할 수 있으며, 일반 EntityManager보다 테스트에 최적화되어 있습니다. + +TestEntityManager의 특징 + +1. 테스트 중 사용할 수 있도록 간편한 메서드 제공 +2. 트랜잭션이 자동 롤백되어 데이터가 남지 않음 +3. flush()를 명시적으로 호출하여 즉시 쿼리를 실행 가능 + +~~~ +* 애플리케이션 전체를 로드하지 않고, JPA 관련 Bean들만 로드하여 테스트 성능을 최적화합니다. + +--- + +### @DataJpaTest의 선택적 파라미터 + +**properties 속성** +* 테스트 환경에서 특정 Spring Boot 설정 값을 변경할 수 있습니다. +~~~ +@DataJpaTest(properties = { + "spring.datasource.url=jdbc:h2:mem:testdb", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +public class UserRepositoryTest { + // 테스트 메서드 작성 +} +~~~ + +**showSql 속성** +* SQL 쿼리 로그를 출력할지 설정하는 옵션입니다. +~~~ +@DataJpaTest(showSql = false) +public class UserRepositoryTest { + // 테스트 메서드 작성 +} +~~~ + +**includeFilters와 excludeFilters** +* 테스트할 컴포넌트(클래스)를 선택적으로 포함하거나 제외할 수 있습니다. + +--- + +### 주요 기능 + +* 테스트 환경 구성 + * @DataJpaTest는 테스트를 위한 DB 환경을 자동으로 구성해줍니다. + * 일반적으로 H2 인메모리 데이터베이스를 사용하여, 테스트 후 자동으로 데이터가 삭제됩니다. + +* 의존성 주입 + * @DataJpaTest를 사용하면 Repository 빈이 자동으로 주입되어 테스트할 수 있습니다. + +* 기본적으로 트랜잭션 롤백 + * 각 테스트 메서드는 트랜잭션 내부에서 실행되며, 테스트가 끝나면 데이터가 자동으로 롤백됩니다. + * 테스트 간 데이터가 격리(독립적) 되므로, 테스트 실행 순서에 영향을 받지 않습니다. + +--- + +### Repository 테스트 구현 + +* 테스트 라이프사이클 관리 +~~~ +private User testUser; + +@BeforeEach +public void setUp() { + testUser = new User(); + testUser.setUsername("testuser"); + testUser.setPassword("password"); + userRepository.save(testUser); +} + +@AfterEach +public void tearDown() { + userRepository.delete(testUser); +} +~~~ +1. @BeforeEach : 각 테스트 시작 전 실행 → 테스트 데이터를 초기화 +2. @AfterEach : 각 테스트 종료 후 실행 → 테스트 데이터 정리(삭제) + + +출처: baeldung + + + +## Service layer Test + +--- + +### 1. UnitTest + +* Service layer 만 테스트를 진행한다. +* 외부 의존성은 모두 mocking 해서 처리한다. +* 테스트 속도가 빠르고 로직을 집중적으로 테스트 할 수 있다. + +### 2. IntegrationTest(Spring Context 전체를 올림) + +* ``@SpringBootTest`` 와 ``@Transactional`` 어노테이션을 사용하여 테스트 진행한다. +* 실제 Bean 들을 다 올리기 때문에 실제 동작을 더 잘 검증할 수 있다. +* 하지만 속도 느리고 무겁다. + + +## Service Layer Unit 테스트의 문제점 + +---- +* 보통 서비스 계층에서 단위 테스트를 진행하면 mocking 을 통해 진행한다. +* but mocking 은 테스트가 성공한다는 전제를 가지고 있음(행위 기반이면) +* 하지만 해당 로직이 틀린 로직이였다면 ? -> 해당 테스트는 깨진 테스트 +* 따라서 해당 문제를 방지하기 위해서 -> 서비스 코드를 작성과 동시에 moking 테스트를 진행하여 올바른 반환값을 확인 + 통합 테스트로 의도하는 대로 동작하는 지 확인하는 방법이 필요 +* 결론은 moking 하는 테스트와 통합테스트 둘 다 하는 것이 맞는 것 같다. diff --git "a/docs/\352\270\260\353\212\245_\352\265\254\355\230\204_\353\252\251\353\241\235.md" "b/docs/\352\270\260\353\212\245_\352\265\254\355\230\204_\353\252\251\353\241\235.md" new file mode 100644 index 0000000..add865e --- /dev/null +++ "b/docs/\352\270\260\353\212\245_\352\265\254\355\230\204_\353\252\251\353\241\235.md" @@ -0,0 +1,216 @@ +## API + +### 유저 CRUD + +* 회원가입 기능. + * endPoint : [POST, /api/members/signUp] + * 설명 : 이름, 닉네임, 아이디, 비밀번호 저장. / 아이디 중복 확인 기능, 닉네임 중복 확인 기능 포함 + * Exception : 아이디 중복 시, 닉네임 중복 시 + * request : + ~~~ + { + "memberName": "juna", + "memberNickName": "juju", + "memberLoginId": "aaa", + "memberPassword": "ssssss" + } + ~~~ + * response : url location, + ~~~ + { + "memberId": "1", + "token": "jwtToken" + } + ~~~ + + + +* 로그인 기능. + * endPoint : [POST, /api/members/login] + * 설명 : 로그인 기능. 쿠키 생성, 세션 생성. + * Exception : 아이디 혹은 비밀번호가 일치하지 않을 경우. + * request : + ~~~ + { + "memberLoginId": "aaa", + "memberPassword": "ssssss" + } + ~~~ + * response : X + +### **모든 마이페이지 기능은 로그인 한 유저만 접근할 수 있도록 구현** + +* 정보 수정 기능.(마이페이지) + * endPoint : [PATCH, /api/members] + * 설명 : 이름, 닉네임, 아이디, 비밀번호 수정. + * Exception : 로그인 정보 가져왔을 때 값이 없을 경우. + * request : + ~~~ + { + "memberName": "hun", + "memberNickName": "hhun", + "memberLoginId": "ddd", + "memberPassword": "sssss" + } + ~~~ + * response : x + +* 멤버 정보 조회 기능(마이페이지) + * endPoint : [GET, /api/members] + * 설명 : 유저 정보 가져옴. + * Exception : 로그인 정보 가져왔을 때 값이 없을 경우. + * request : x + * response : member + + +* 계정 탈퇴 기능(마이페이지) + * endPoint : [DELETE, /api/members] + * 설명 : 유저 탈퇴. + * Exception : 로그인 정보 가져왔을 때 값이 없을 경우. + * request : x + * response : member + +### 게시물 CRUD + +* 글 작성 기능. (해당 유저만 작성할 수 있도록.) + * endPoint : [POST, /api/articles] + * 설명 : 글 작성 + * Exception : 쿠키나 세션 가져왔을 때 값이 없을 경우. + * request : + ~~~ + { + "title": "가가가", + "content": "나나나" + } + ~~~ + * response : url location 으로 반환 + + +* 모든 글 조회 기능 (유저 아니여도 접근 가능) + * endPoint : [GET, /api/articles] + * 설명 : 글 모두 끌어오기 + * Exception : 작성된 글이 없을 경우 + * request : x + * response : 조회된 모든 글 반환 + + +* 글 하나만 조회하는 기능 (유저 아니어도 접근 가능) + * endPoint : [GET, api/articles/{articleId}] + * 설명 : 해당 글 조회하기 + * Exception : 작성된 글이 없을 경우 + * request : x + * response : 게시글 반환 + + +* 글 수정하는 기능 (해당 글쓴 유저만 접근 가능) + * endPoint : [GET, api/articles/{articleId}] + * 설명 : 해당 글 조회하기 + * Exception : 작성된 글이 없을 경우, 해당 게시물에 대한 권한이 없을 경우 + * request : + ~~~ + { + "title": "가", + "content": "나" + } + ~~~ + * response : 수정된 글 반환 + + +* 게시물 삭제 기능(해당 글쓴 유저만 삭제 가능) + * endPoint : [GET, api/articles/{articleId}] + * 설명 : 해당 글 삭제하기 + * Exception : 작성된 글이 없을 경우, 해당 게시물에 대한 권한이 없을 경우 + * request : X + * response : 삭제된 게시물 반환 + + +* 유저 글 모아보는 기능(마이페이지) + * endPoint : [GET, api/members/articles] + * 설명 : 유저에 해당하는 글만 모아 보기 + * Exception : 작성된 글이 없을 경우 + * request : X + * response : 유저의 모든 게시물 반환 + + +### 댓글 CRUD + + +* 댓글 달기 기능.(유저만 접근 가능) + * endPoint : [POST, api/articles/{articleId}/comments] + * 설명 : 댓글 작성하기 + * Exception : 작성된 글이 없을 경우, 로그인 상태 아닐 경우 + * request : + ~~~ + { + "content": "노잼" + } + ~~~ + * response : 게시글 url 반환 (api/articles/{articleId}) + + +* 게시물에 대한 댓글 조회 기능.(유저 아니어도 접근 가능) + * endPoint : [GET, api/articles/{articleId}/comments] + * 설명 : 게시물에 대한 댓글 조회하기 + * Exception : 작성된 글이 없을 경우, 댓글 없을 경우 + * request : x + * response : + ~~~ + { + "commentResponses": [ + { + "articleId": 1, + "memberNickName": "juj", + "content": "노잼s" + }, + { + "articleId": 1, + "memberNickName": "juj", + "content": "노잼잼" + } + ] + } + ~~~ + + +* 댓글 수정 (해당 댓글 작성자만 수정 가능) + * endPoint : [PATCH, api/comments/{commentId}] + * 설명 : 댓글 수정하기 + * Exception : 작성된 글이 없을 경우, 댓글 없을 경우 + * request : + ~~~ + { + "content": "게시판" + } + ~~~ + * response : + ~~~ + { + "articleId": 1, + "memberNickName": "juj", + "content": "게시판" + } + ~~~ + + +* 댓글 삭제 (해당 댓글 작성자만 삭제 가능) + * endPoint : [DELETE, api/comments/{commentId}] + * 설명 : 댓글 삭제하기 + * Exception : 작성된 글이 없을 경우, 댓글 없을 경우 + * request : x + * response : + ~~~ + { + "articleId": 1, + "memberNickName": "juj", + "content": "게시판" + } + ~~~ + + +* 유저 댓글 모아 보는 기능(마이페이지) + * endPoint : [GET, api/members/comments] + * 설명 : 유저 댓글 조회하기 + * Exception : 작성된 글이 없을 경우, 댓글 없을 경우 + * request : + * response : 유저의 모든 댓글 +--- diff --git a/src/main/java/com/board/article/controller/ArticleController.java b/src/main/java/com/board/article/controller/ArticleController.java new file mode 100644 index 0000000..8846eae --- /dev/null +++ b/src/main/java/com/board/article/controller/ArticleController.java @@ -0,0 +1,109 @@ +package com.board.article.controller; + +import com.board.article.controller.dto.request.ArticleRequest; +import com.board.article.controller.dto.response.ArticleResponse; +import com.board.article.controller.dto.response.PageArticleResponse; +import com.board.article.domain.Article; +import com.board.article.service.ArticleService; +import com.board.global.resolver.annotation.Auth; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequiredArgsConstructor +public class ArticleController { + + private final ArticleService articleService; + + @PostMapping("/articles") + public ResponseEntity createArticle(@RequestBody ArticleRequest request, @Auth Long memberId) { + Article article = articleService.createArticle(memberId, request.title(), request.content()); + ArticleResponse response = new ArticleResponse( + article.getId(), + article.getMemberId(), + article.getTitle(), + article.getContent() + ); + + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri() + .path("/{id}") + .buildAndExpand(response.articleId()) + .toUri(); + + return ResponseEntity.created(location) + .body(response); + } + + @GetMapping("/articles") + public ResponseEntity showAllArticles( + @RequestParam Long lastId, + @RequestParam int size + ) { + Page
articles = articleService.findAllArticles(lastId, size); + return ResponseEntity.ok(new PageArticleResponse(articles)); + } + + @GetMapping("/articles/{articleId}") + public ResponseEntity showArticle(@PathVariable Long articleId) { + Article article = articleService.findArticle(articleId); + ArticleResponse response = new ArticleResponse( + article.getId(), + article.getMemberId(), + article.getTitle(), + article.getContent() + ); + + return ResponseEntity.ok(response); + } + + @GetMapping("/members/me/articles") + public ResponseEntity showMemberArticles( + @Auth Long memberId, + @RequestParam int page, + @RequestParam int size + ) { + Page
articles = articleService.findMemberArticles(memberId, page, size); + return ResponseEntity.ok(new PageArticleResponse(articles)); + } + + @PatchMapping("/articles/{articleId}") + public ResponseEntity updateArticle( + @RequestBody ArticleRequest request, + @PathVariable Long articleId, + @Auth Long memberId + ) { + Article article = articleService.updateArticle(articleId, memberId, request.title(), request.content()); + ArticleResponse response = new ArticleResponse( + article.getId(), + article.getMemberId(), + article.getTitle(), + article.getContent() + ); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/articles/{articleId}") + public ResponseEntity deleteArticle(@PathVariable Long articleId, @Auth Long memberId) { + Article article = articleService.deleteArticle(articleId, memberId); + ArticleResponse response = new ArticleResponse( + article.getId(), + article.getMemberId(), + article.getTitle(), + article.getContent() + ); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/board/article/controller/dto/request/ArticleRequest.java b/src/main/java/com/board/article/controller/dto/request/ArticleRequest.java new file mode 100644 index 0000000..2b444b6 --- /dev/null +++ b/src/main/java/com/board/article/controller/dto/request/ArticleRequest.java @@ -0,0 +1,7 @@ +package com.board.article.controller.dto.request; + +public record ArticleRequest( + String title, + String content +) { +} diff --git a/src/main/java/com/board/article/controller/dto/response/ArticleResponse.java b/src/main/java/com/board/article/controller/dto/response/ArticleResponse.java new file mode 100644 index 0000000..277c68d --- /dev/null +++ b/src/main/java/com/board/article/controller/dto/response/ArticleResponse.java @@ -0,0 +1,9 @@ +package com.board.article.controller.dto.response; + +public record ArticleResponse( + Long articleId, + Long memberId, + String title, + String content +) { +} diff --git a/src/main/java/com/board/article/controller/dto/response/ArticleResponses.java b/src/main/java/com/board/article/controller/dto/response/ArticleResponses.java new file mode 100644 index 0000000..9ee8be7 --- /dev/null +++ b/src/main/java/com/board/article/controller/dto/response/ArticleResponses.java @@ -0,0 +1,8 @@ +package com.board.article.controller.dto.response; + +import java.util.List; + +public record ArticleResponses( + List articleResponses +) { +} diff --git a/src/main/java/com/board/article/controller/dto/response/PageArticleResponse.java b/src/main/java/com/board/article/controller/dto/response/PageArticleResponse.java new file mode 100644 index 0000000..4b55545 --- /dev/null +++ b/src/main/java/com/board/article/controller/dto/response/PageArticleResponse.java @@ -0,0 +1,29 @@ +package com.board.article.controller.dto.response; + +import com.board.article.domain.Article; +import java.util.List; +import org.springframework.data.domain.Page; + +public record PageArticleResponse( + List articleResponses, + int pageNumber, + int pageSize, + long totalElements, + int totalPages +) { + + public PageArticleResponse(Page
page) { + this( + page.map(article -> new ArticleResponse( + article.getId(), + article.getMemberId(), + article.getTitle(), + article.getContent() + )).getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/src/main/java/com/board/article/domain/Article.java b/src/main/java/com/board/article/domain/Article.java new file mode 100644 index 0000000..9c63641 --- /dev/null +++ b/src/main/java/com/board/article/domain/Article.java @@ -0,0 +1,56 @@ +package com.board.article.domain; + +import com.board.article.exception.ArticleErrorCode; +import com.board.article.exception.ArticleException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Article { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private Long memberId; + + @Column(nullable = false) + @NotBlank + private String title; + + @Column(nullable = false) + @NotBlank + private String content; + + public Article(Long memberId, String title, String content) { + this.memberId = memberId; + this.title = title; + this.content = content; + } + + public void update(String title, String content) { + if (title != null) { + this.title = title; + } + if (content != null) { + this.content = content; + } + } + + public void validateAccessAboutArticle(Long memberId) { + if(!Objects.equals(this.memberId, memberId)) { + throw new ArticleException(ArticleErrorCode.FORBIDDEN_ACCESS_ARTICLE); + } + } +} diff --git a/src/main/java/com/board/article/exception/ArticleErrorCode.java b/src/main/java/com/board/article/exception/ArticleErrorCode.java new file mode 100644 index 0000000..0df0898 --- /dev/null +++ b/src/main/java/com/board/article/exception/ArticleErrorCode.java @@ -0,0 +1,35 @@ +package com.board.article.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import org.springframework.http.HttpStatus; + +public enum ArticleErrorCode implements BaseErrorCode { + + NOT_FOUND_ARTICLE(HttpStatus.NOT_FOUND, "A001", "게시글을 찾을 수 없습니다."), + FORBIDDEN_ACCESS_ARTICLE(HttpStatus.BAD_REQUEST, "A002", "게시글에 대한 권한을 가지고 있지 않습니다."); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; + + ArticleErrorCode(HttpStatus httpStatus, String customCode, String message) { + this.httpStatus = httpStatus; + this.customCode = customCode; + this.message = message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String customCode() { + return customCode; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/board/article/exception/ArticleException.java b/src/main/java/com/board/article/exception/ArticleException.java new file mode 100644 index 0000000..0b7a8cb --- /dev/null +++ b/src/main/java/com/board/article/exception/ArticleException.java @@ -0,0 +1,11 @@ +package com.board.article.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import com.board.common.exception.exceptions.BaseException; + +public class ArticleException extends BaseException { + + public ArticleException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/board/article/loader/ArticleDataLoader.java b/src/main/java/com/board/article/loader/ArticleDataLoader.java new file mode 100644 index 0000000..3d03241 --- /dev/null +++ b/src/main/java/com/board/article/loader/ArticleDataLoader.java @@ -0,0 +1,20 @@ +package com.board.article.loader; + +import com.board.article.domain.Article; +import com.board.article.repository.ArticleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ArticleDataLoader implements CommandLineRunner { + + private final ArticleRepository repository; + + @Override + public void run(String... args) throws Exception { + repository.save(new Article(1L, "신형만에 관하여", "아내는 신봉선")); + repository.save(new Article(1L, "신짱구", "바보")); + } +} diff --git a/src/main/java/com/board/article/repository/ArticleRepository.java b/src/main/java/com/board/article/repository/ArticleRepository.java new file mode 100644 index 0000000..157eacb --- /dev/null +++ b/src/main/java/com/board/article/repository/ArticleRepository.java @@ -0,0 +1,20 @@ +package com.board.article.repository; + +import com.board.article.domain.Article; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ArticleRepository extends JpaRepository { + + Page
findArticleByMemberId(Long memberId, Pageable pageable); + + Page
findByIdLessThanOrderByIdDesc(Long lastId, Pageable pageable); + + @Modifying + @Query("update Article a set a.memberId = null where a.memberId = :memberId") + void updateMemberIdToNUll(@Param("memberId") Long memberId); +} diff --git a/src/main/java/com/board/article/service/ArticleService.java b/src/main/java/com/board/article/service/ArticleService.java new file mode 100644 index 0000000..a912e67 --- /dev/null +++ b/src/main/java/com/board/article/service/ArticleService.java @@ -0,0 +1,73 @@ +package com.board.article.service; + +import com.board.article.domain.Article; +import com.board.article.exception.ArticleErrorCode; +import com.board.article.exception.ArticleException; +import com.board.article.repository.ArticleRepository; +import com.board.article.service.event.ArticleDeletedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ArticleService { + + private static final String PAGE_SORT_DELIMITER = "id"; + private static final int NO_OFFSET_PAGING_PAGE = 0; + + private final ArticleRepository articleRepository; + private final ApplicationEventPublisher eventPublisher; + + public Article createArticle(Long memberId, String title, String content) { + Article article = new Article(memberId, title, content); + articleRepository.save(article); + + return article; + } + + public Article updateArticle(Long articleId, Long memberId, String title, String content) { + Article article = findArticle(articleId); + article.validateAccessAboutArticle(memberId); + article.update(title, content); + + return article; + } + + public Article deleteArticle(Long articleId, Long memberId) { + Article article = findArticle(articleId); + article.validateAccessAboutArticle(memberId); + articleRepository.delete(article); + eventPublisher.publishEvent(new ArticleDeletedEvent(articleId)); + + return article; + } + + public void updateMemberIdToNUll(Long memberId) { + articleRepository.updateMemberIdToNUll(memberId); + } + + @Transactional(readOnly = true) + public Page
findAllArticles(Long lastId, int size) { + Pageable articlePageable = PageRequest.of(NO_OFFSET_PAGING_PAGE, size, Sort.by(PAGE_SORT_DELIMITER).descending()); + return articleRepository.findByIdLessThanOrderByIdDesc(lastId, articlePageable); + } + + @Transactional(readOnly = true) + public Article findArticle(Long articleId) { + return articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.NOT_FOUND_ARTICLE)); + } + + @Transactional(readOnly = true) + public Page
findMemberArticles(Long memberId, int page, int size) { + Pageable articlePageable = PageRequest.of(page, size, Sort.by(PAGE_SORT_DELIMITER).descending()); + return articleRepository.findArticleByMemberId(memberId, articlePageable); + } +} diff --git a/src/main/java/com/board/article/service/event/ArticleDeletedEvent.java b/src/main/java/com/board/article/service/event/ArticleDeletedEvent.java new file mode 100644 index 0000000..90d2121 --- /dev/null +++ b/src/main/java/com/board/article/service/event/ArticleDeletedEvent.java @@ -0,0 +1,6 @@ +package com.board.article.service.event; + +public record ArticleDeletedEvent( + Long articleId +) { +} diff --git a/src/main/java/com/board/article/service/event/ArticleEventListener.java b/src/main/java/com/board/article/service/event/ArticleEventListener.java new file mode 100644 index 0000000..387d6fe --- /dev/null +++ b/src/main/java/com/board/article/service/event/ArticleEventListener.java @@ -0,0 +1,22 @@ +package com.board.article.service.event; + +import com.board.comment.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class ArticleEventListener { + + private final CommentService commentService; + + @Transactional + @EventListener + public void handleMemberDeletedEvent(ArticleDeletedEvent event) { + Long articleId = event.articleId(); + + commentService.deleteCommentByArticle(articleId); + } +} diff --git a/src/main/java/com/board/comment/controller/CommentController.java b/src/main/java/com/board/comment/controller/CommentController.java new file mode 100644 index 0000000..55a9066 --- /dev/null +++ b/src/main/java/com/board/comment/controller/CommentController.java @@ -0,0 +1,91 @@ +package com.board.comment.controller; + +import com.board.comment.controller.dto.reponse.CommentResponse; +import com.board.comment.controller.dto.reponse.PageCommentResponse; +import com.board.comment.controller.dto.request.CommentRequest; +import com.board.comment.domain.Comment; +import com.board.comment.service.CommentService; +import com.board.global.resolver.annotation.Auth; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @PostMapping("/articles/{articleId}/comments") + public ResponseEntity createComment(@RequestBody CommentRequest request, @Auth Long memberId, + @PathVariable Long articleId) { + Comment comment = commentService.createComment(request.content(), memberId, articleId); + CommentResponse response = new CommentResponse( + comment.getMemberId(), + comment.getArticleId(), + comment.getContent() + ); + + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri() + .path("/{id}") + .buildAndExpand(response.articleId()) + .toUri(); + + return ResponseEntity.created(location).body(response); + } + + @GetMapping("/articles/{articleId}/comments") + public ResponseEntity showArticleComments( + @PathVariable Long articleId, + @RequestParam Long lastId, + @RequestParam int size + ) { + Page comments = commentService.findArticleComments(articleId, lastId, size); + return ResponseEntity.ok(new PageCommentResponse(comments)); + } + + @GetMapping("members/me/comments") + public ResponseEntity showMemberComments( + @Auth Long memberId, + @RequestParam int page, + @RequestParam int size + ) { + Page comments = commentService.findMemberComments(memberId, page, size); + return ResponseEntity.ok(new PageCommentResponse(comments)); + } + + @PatchMapping("/comments/{commentId}") + public ResponseEntity updateComment(@RequestBody CommentRequest request, @Auth Long memberId, + @PathVariable Long commentId) { + Comment comment = commentService.updateComment(request, memberId, commentId); + CommentResponse response = new CommentResponse( + comment.getMemberId(), + comment.getArticleId(), + comment.getContent() + ); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment(@Auth Long memberId, @PathVariable Long commentId) { + Comment comment = commentService.deleteComment(memberId, commentId); + CommentResponse response = new CommentResponse( + comment.getMemberId(), + comment.getArticleId(), + comment.getContent() + ); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/board/comment/controller/dto/reponse/CommentResponse.java b/src/main/java/com/board/comment/controller/dto/reponse/CommentResponse.java new file mode 100644 index 0000000..9de028b --- /dev/null +++ b/src/main/java/com/board/comment/controller/dto/reponse/CommentResponse.java @@ -0,0 +1,8 @@ +package com.board.comment.controller.dto.reponse; + +public record CommentResponse( + Long memberId, + Long articleId, + String content +) { +} diff --git a/src/main/java/com/board/comment/controller/dto/reponse/CommentResponses.java b/src/main/java/com/board/comment/controller/dto/reponse/CommentResponses.java new file mode 100644 index 0000000..3b1450f --- /dev/null +++ b/src/main/java/com/board/comment/controller/dto/reponse/CommentResponses.java @@ -0,0 +1,8 @@ +package com.board.comment.controller.dto.reponse; + +import java.util.List; + +public record CommentResponses( + List commentResponses +) { +} diff --git a/src/main/java/com/board/comment/controller/dto/reponse/PageCommentResponse.java b/src/main/java/com/board/comment/controller/dto/reponse/PageCommentResponse.java new file mode 100644 index 0000000..fa8a4df --- /dev/null +++ b/src/main/java/com/board/comment/controller/dto/reponse/PageCommentResponse.java @@ -0,0 +1,28 @@ +package com.board.comment.controller.dto.reponse; + +import com.board.comment.domain.Comment; +import java.util.List; +import org.springframework.data.domain.Page; + +public record PageCommentResponse( + List commentResponses, + int pageNumber, + int pageSize, + long totalElements, + int totalPages +) { + + public PageCommentResponse(Page page) { + this( + page.map(comment -> new CommentResponse( + comment.getMemberId(), + comment.getArticleId(), + comment.getContent() + )).getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/src/main/java/com/board/comment/controller/dto/request/CommentRequest.java b/src/main/java/com/board/comment/controller/dto/request/CommentRequest.java new file mode 100644 index 0000000..1866c38 --- /dev/null +++ b/src/main/java/com/board/comment/controller/dto/request/CommentRequest.java @@ -0,0 +1,6 @@ +package com.board.comment.controller.dto.request; + +public record CommentRequest( + String content +) { +} diff --git a/src/main/java/com/board/comment/domain/Comment.java b/src/main/java/com/board/comment/domain/Comment.java new file mode 100644 index 0000000..7e95f64 --- /dev/null +++ b/src/main/java/com/board/comment/domain/Comment.java @@ -0,0 +1,51 @@ +package com.board.comment.domain; + +import com.board.comment.exception.CommentErrorCode; +import com.board.comment.exception.CommentException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private Long memberId; + + @Column + private Long articleId; + + + @Column(nullable = false) + private String content; + + public Comment(Long memberId, Long articleId, String content) { + this.memberId = memberId; + this.articleId = articleId; + this.content = content; + } + + public void update(String content) { + if (content != null) { + this.content = content; + } + } + + public void validateAccessAboutComment(Long memberId) { + if(!Objects.equals(this.memberId, memberId)) { + throw new CommentException(CommentErrorCode.FORBIDDEN_ACCESS_COMMENT); + } + } +} diff --git a/src/main/java/com/board/comment/exception/CommentErrorCode.java b/src/main/java/com/board/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..b18a00d --- /dev/null +++ b/src/main/java/com/board/comment/exception/CommentErrorCode.java @@ -0,0 +1,35 @@ +package com.board.comment.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import org.springframework.http.HttpStatus; + +public enum CommentErrorCode implements BaseErrorCode { + + NOT_FOUND_COMMENT(HttpStatus.NOT_FOUND, "C001", "댓글을 찾을 수 없습니다."), + FORBIDDEN_ACCESS_COMMENT(HttpStatus.BAD_REQUEST, "C002", "댓글에 대한 권한이 없습니다."); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; + + CommentErrorCode(HttpStatus httpStatus, String customCode, String message) { + this.httpStatus = httpStatus; + this.customCode = customCode; + this.message = message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String customCode() { + return customCode; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/board/comment/exception/CommentException.java b/src/main/java/com/board/comment/exception/CommentException.java new file mode 100644 index 0000000..2a725ba --- /dev/null +++ b/src/main/java/com/board/comment/exception/CommentException.java @@ -0,0 +1,11 @@ +package com.board.comment.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import com.board.common.exception.exceptions.BaseException; + +public class CommentException extends BaseException { + + public CommentException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/board/comment/loader/CommentDataLoader.java b/src/main/java/com/board/comment/loader/CommentDataLoader.java new file mode 100644 index 0000000..ffdc7f0 --- /dev/null +++ b/src/main/java/com/board/comment/loader/CommentDataLoader.java @@ -0,0 +1,22 @@ +package com.board.comment.loader; + +import com.board.comment.domain.Comment; +import com.board.comment.repository.CommentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentDataLoader implements CommandLineRunner { + + private final CommentRepository repository; + + @Override + public void run(String... args) throws Exception { + repository.save(new Comment(1L, 1L, "첫 번째 게시글 댓글 1")); + repository.save(new Comment(1L, 1L, "첫 번째 게시글 댓글 2")); + repository.save(new Comment(1L, 2L, "두 번째 게시글 댓글 1")); + repository.save(new Comment(1L, 2L, "두 번째 게시글 댓글 2")); + } +} diff --git a/src/main/java/com/board/comment/repository/CommentRepository.java b/src/main/java/com/board/comment/repository/CommentRepository.java new file mode 100644 index 0000000..ee74ee6 --- /dev/null +++ b/src/main/java/com/board/comment/repository/CommentRepository.java @@ -0,0 +1,25 @@ +package com.board.comment.repository; + +import com.board.comment.domain.Comment; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommentRepository extends JpaRepository { + + List findByArticleId(Long articleId); + + Page findByArticleIdAndIdLessThanOrderByIdDesc(Long articleId, Long lastId, Pageable pageable); + + Page findAllByMemberId(Long memberId, Pageable pageable); + + @Modifying + @Query("update Comment c set c.memberId = null where c.memberId = :memberId") + void updateMemberIdToNull(@Param("memberId") Long memberId); +} diff --git a/src/main/java/com/board/comment/service/CommentService.java b/src/main/java/com/board/comment/service/CommentService.java new file mode 100644 index 0000000..cc297ab --- /dev/null +++ b/src/main/java/com/board/comment/service/CommentService.java @@ -0,0 +1,77 @@ +package com.board.comment.service; + +import com.board.comment.controller.dto.request.CommentRequest; +import com.board.comment.domain.Comment; +import com.board.comment.exception.CommentErrorCode; +import com.board.comment.exception.CommentException; +import com.board.comment.repository.CommentRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommentService { + + private static final String PAGE_SORT_DELIMITER = "id"; + private static final int NO_OFFSET_PAGING_PAGE = 0; + + private final CommentRepository commentRepository; + + public Comment createComment(String content, Long memberId, Long articleId) { + Comment comment = new Comment(memberId, articleId, content); + commentRepository.save(comment); + + return comment; + } + + public Comment updateComment(CommentRequest request, Long memberId, Long commentId) { + Comment comment = findComment(commentId); + comment.validateAccessAboutComment(memberId); + comment.update(request.content()); + + return comment; + } + + public Comment deleteComment(Long memberId, Long commentId) { + Comment comment = findComment(commentId); + comment.validateAccessAboutComment(memberId); + commentRepository.delete(comment); + + return comment; + } + + public void updateMemberIdToNull(Long memberId) { + commentRepository.updateMemberIdToNull(memberId); + } + + public void deleteCommentByArticle(Long articleId) { + List comment = commentRepository.findByArticleId(articleId); + + commentRepository.deleteAll(comment); + } + + @Transactional(readOnly = true) + public Page findArticleComments(Long articleId, Long lastId, int size) { + Pageable commentPageable = PageRequest.of(NO_OFFSET_PAGING_PAGE, size, Sort.by(PAGE_SORT_DELIMITER).descending()); + return commentRepository.findByArticleIdAndIdLessThanOrderByIdDesc(articleId, lastId, commentPageable); + } + + @Transactional(readOnly = true) + public Page findMemberComments(Long memberId, int page, int size) { + Pageable commentPageable = PageRequest.of(page, size, Sort.by(PAGE_SORT_DELIMITER).descending()); + return commentRepository.findAllByMemberId(memberId, commentPageable); + } + + @Transactional(readOnly = true) + public Comment findComment(Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new CommentException(CommentErrorCode.NOT_FOUND_COMMENT)); + } +} diff --git a/src/main/java/com/board/common/exception/exceptionHandler/BaseExceptionHandler.java b/src/main/java/com/board/common/exception/exceptionHandler/BaseExceptionHandler.java new file mode 100644 index 0000000..4ec57ef --- /dev/null +++ b/src/main/java/com/board/common/exception/exceptionHandler/BaseExceptionHandler.java @@ -0,0 +1,23 @@ +package com.board.common.exception.exceptionHandler; + +import com.board.common.exception.exceptionHandler.dto.ErrorResponse; +import com.board.common.exception.exceptions.BaseErrorCode; +import com.board.common.exception.exceptions.BaseException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class BaseExceptionHandler { + + @ExceptionHandler(BaseException.class) + public ResponseEntity handleException(BaseException e) { + BaseErrorCode baseErrorCode = e.baseErrorCode(); + ErrorResponse response = new ErrorResponse( + baseErrorCode.customCode(), + baseErrorCode.message() + ); + + return ResponseEntity.status(baseErrorCode.httpStatus()).body(response); + } +} diff --git a/src/main/java/com/board/common/exception/exceptionHandler/dto/ErrorResponse.java b/src/main/java/com/board/common/exception/exceptionHandler/dto/ErrorResponse.java new file mode 100644 index 0000000..78dff96 --- /dev/null +++ b/src/main/java/com/board/common/exception/exceptionHandler/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.board.common.exception.exceptionHandler.dto; + +public record ErrorResponse( + String customCode, + String message +) { +} diff --git a/src/main/java/com/board/common/exception/exceptions/BaseErrorCode.java b/src/main/java/com/board/common/exception/exceptions/BaseErrorCode.java new file mode 100644 index 0000000..255ecf4 --- /dev/null +++ b/src/main/java/com/board/common/exception/exceptions/BaseErrorCode.java @@ -0,0 +1,10 @@ +package com.board.common.exception.exceptions; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + + HttpStatus httpStatus(); + String customCode(); + String message(); +} diff --git a/src/main/java/com/board/common/exception/exceptions/BaseException.java b/src/main/java/com/board/common/exception/exceptions/BaseException.java new file mode 100644 index 0000000..295b063 --- /dev/null +++ b/src/main/java/com/board/common/exception/exceptions/BaseException.java @@ -0,0 +1,15 @@ +package com.board.common.exception.exceptions; + +public class BaseException extends RuntimeException{ + + private final BaseErrorCode errorCode; + + public BaseException(BaseErrorCode errorCode) { + super(errorCode.customCode() + ": " + errorCode.message()); + this.errorCode = errorCode; + } + + public BaseErrorCode baseErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/board/global/config/AuthConfig.java b/src/main/java/com/board/global/config/AuthConfig.java new file mode 100644 index 0000000..2e6f669 --- /dev/null +++ b/src/main/java/com/board/global/config/AuthConfig.java @@ -0,0 +1,15 @@ +package com.board.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class AuthConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/board/global/config/SwaggerConfig.java b/src/main/java/com/board/global/config/SwaggerConfig.java new file mode 100644 index 0000000..1459840 --- /dev/null +++ b/src/main/java/com/board/global/config/SwaggerConfig.java @@ -0,0 +1,23 @@ +package com.board.global.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "Board API", + description = "Swagger 연습을 위한 Open API 문서" + ) +) +@SecurityScheme( + name = "BearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" +) +public class SwaggerConfig { +} diff --git a/src/main/java/com/board/global/config/WebMvcConfig.java b/src/main/java/com/board/global/config/WebMvcConfig.java new file mode 100644 index 0000000..812a4d9 --- /dev/null +++ b/src/main/java/com/board/global/config/WebMvcConfig.java @@ -0,0 +1,31 @@ +package com.board.global.config; + +import com.board.global.interceptor.AuthInterceptor; +import com.board.global.resolver.AuthArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Component +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthInterceptor interceptor; + private final AuthArgumentResolver resolver; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(interceptor) + .addPathPatterns("/api/comments/**") + .addPathPatterns("/api/members/**") + .addPathPatterns("/api/articles/**"); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(resolver); + } +} diff --git a/src/main/java/com/board/global/exception/GlobalErrorCode.java b/src/main/java/com/board/global/exception/GlobalErrorCode.java new file mode 100644 index 0000000..e09ad50 --- /dev/null +++ b/src/main/java/com/board/global/exception/GlobalErrorCode.java @@ -0,0 +1,37 @@ +package com.board.global.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import org.springframework.http.HttpStatus; + +public enum GlobalErrorCode implements BaseErrorCode { + + NOT_FOUND_TOKEN(HttpStatus.NOT_FOUND, "T001", "토큰을 찾을 수 없습니다."), + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "T002", "토큰이 유효하지 않습니다."), + CAN_NOT_VERIFY_TOKEN(HttpStatus.BAD_REQUEST, "T003", "토큰을 검증할 수 없습니다."), + EXPIRED_TOKEN(HttpStatus.CONFLICT, "T004", "토큰 유효기간이 만료되었습니다."); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; + + GlobalErrorCode(HttpStatus httpStatus, String customCode, String message) { + this.httpStatus = httpStatus; + this.customCode = customCode; + this.message = message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String customCode() { + return customCode; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/board/global/exception/GlobalException.java b/src/main/java/com/board/global/exception/GlobalException.java new file mode 100644 index 0000000..cf9ad2e --- /dev/null +++ b/src/main/java/com/board/global/exception/GlobalException.java @@ -0,0 +1,11 @@ +package com.board.global.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import com.board.common.exception.exceptions.BaseException; + +public class GlobalException extends BaseException { + + public GlobalException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/board/global/interceptor/AuthInterceptor.java b/src/main/java/com/board/global/interceptor/AuthInterceptor.java new file mode 100644 index 0000000..7f1bdc9 --- /dev/null +++ b/src/main/java/com/board/global/interceptor/AuthInterceptor.java @@ -0,0 +1,34 @@ +package com.board.global.interceptor; + +import com.board.global.exception.GlobalErrorCode; +import com.board.global.exception.GlobalException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Objects; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private static final String TOKEN_HEADER_NAME = "Authorization"; + private static final String TOKEN_START_NAME = "Bearer "; + private static final String ALLOW_HTTP_METHOD = "GET"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if(isAllowHttpMethod(request)) { + return true; + } + + String tokenHeader = Optional.ofNullable(request.getHeader(TOKEN_HEADER_NAME)) + .orElseThrow(() -> new GlobalException(GlobalErrorCode.NOT_FOUND_TOKEN)); + + return tokenHeader.startsWith(TOKEN_START_NAME); + } + + private boolean isAllowHttpMethod(HttpServletRequest request) { + return Objects.equals(request.getMethod(), ALLOW_HTTP_METHOD); + } +} diff --git a/src/main/java/com/board/global/resolver/AuthArgumentResolver.java b/src/main/java/com/board/global/resolver/AuthArgumentResolver.java new file mode 100644 index 0000000..4c218e1 --- /dev/null +++ b/src/main/java/com/board/global/resolver/AuthArgumentResolver.java @@ -0,0 +1,46 @@ +package com.board.global.resolver; + +import com.board.global.exception.GlobalErrorCode; +import com.board.global.exception.GlobalException; +import com.board.global.resolver.annotation.Auth; +import com.board.member.Infrastructure.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String TOKEN_HEADER_NAME = "Authorization"; + private static final String TOKEN_START_NAME = "Bearer "; + private static final int TOKEN_BODY_DELIMITER = 7; + + private final JwtTokenProvider tokenProvider; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class) && parameter.getParameterType() == Long.class; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + String tokenHeader = request.getHeader(TOKEN_HEADER_NAME); + validateToken(tokenHeader); + String token = tokenHeader.substring(TOKEN_BODY_DELIMITER); + return tokenProvider.extractMemberId(token); + } + + private void validateToken(String token) { + if(token == null || !token.startsWith(TOKEN_START_NAME)) { + throw new GlobalException(GlobalErrorCode.NOT_FOUND_TOKEN); + } + } +} diff --git a/src/main/java/com/board/global/resolver/annotation/Auth.java b/src/main/java/com/board/global/resolver/annotation/Auth.java new file mode 100644 index 0000000..108be29 --- /dev/null +++ b/src/main/java/com/board/global/resolver/annotation/Auth.java @@ -0,0 +1,11 @@ +package com.board.global.resolver.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Auth { +} diff --git a/src/main/java/com/board/global/resolver/exception/TokenExpirationException.java b/src/main/java/com/board/global/resolver/exception/TokenExpirationException.java new file mode 100644 index 0000000..1bd32ba --- /dev/null +++ b/src/main/java/com/board/global/resolver/exception/TokenExpirationException.java @@ -0,0 +1,11 @@ +package com.board.global.resolver.exception; + +import com.board.global.exception.GlobalErrorCode; +import com.board.global.exception.GlobalException; + +public class TokenExpirationException extends GlobalException { + + public TokenExpirationException() { + super(GlobalErrorCode.EXPIRED_TOKEN); + } +} diff --git a/src/main/java/com/board/global/resolver/exception/TokenInvalidException.java b/src/main/java/com/board/global/resolver/exception/TokenInvalidException.java new file mode 100644 index 0000000..66af9fb --- /dev/null +++ b/src/main/java/com/board/global/resolver/exception/TokenInvalidException.java @@ -0,0 +1,11 @@ +package com.board.global.resolver.exception; + +import com.board.global.exception.GlobalErrorCode; +import com.board.global.exception.GlobalException; + +public class TokenInvalidException extends GlobalException { + + public TokenInvalidException() { + super(GlobalErrorCode.INVALID_TOKEN); + } +} diff --git a/src/main/java/com/board/global/resolver/exception/TokenVerificationException.java b/src/main/java/com/board/global/resolver/exception/TokenVerificationException.java new file mode 100644 index 0000000..1281f6c --- /dev/null +++ b/src/main/java/com/board/global/resolver/exception/TokenVerificationException.java @@ -0,0 +1,11 @@ +package com.board.global.resolver.exception; + +import com.board.global.exception.GlobalErrorCode; +import com.board.global.exception.GlobalException; + +public class TokenVerificationException extends GlobalException { + + public TokenVerificationException() { + super(GlobalErrorCode.CAN_NOT_VERIFY_TOKEN); + } +} diff --git a/src/main/java/com/board/member/Infrastructure/JwtTokenProvider.java b/src/main/java/com/board/member/Infrastructure/JwtTokenProvider.java new file mode 100644 index 0000000..5431eeb --- /dev/null +++ b/src/main/java/com/board/member/Infrastructure/JwtTokenProvider.java @@ -0,0 +1,72 @@ +package com.board.member.Infrastructure; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.exceptions.TokenExpiredException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.board.global.resolver.exception.TokenExpirationException; +import com.board.global.resolver.exception.TokenInvalidException; +import com.board.global.resolver.exception.TokenVerificationException; +import com.board.member.domain.auth.TokenProvider; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider implements TokenProvider { + + private final Algorithm algorithm; + private final long expirationPeriod; + + public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, + @Value("${jwt.expiration-period}") long expirationPeriod) { + this.algorithm = Algorithm.HMAC256(secretKey); + this.expirationPeriod = expirationPeriod; + } + + @Override + public String create(Long memberId) { + return JWT.create() + .withClaim("memberId", memberId) + .withIssuedAt(issuedDate()) + .withExpiresAt(expiredDate()) + .withJWTId(UUID.randomUUID().toString()) + .sign(algorithm); + } + + @Override + public Long extractMemberId(String token) { + try { + return extractToken(token); + } catch (JWTDecodeException e) { + throw new TokenVerificationException(); + } catch (TokenExpiredException e) { + throw new TokenExpirationException(); + } + } + + private Date issuedDate() { + return new Date(System.currentTimeMillis()); + } + + private Date expiredDate() { + return new Date(System.currentTimeMillis() + expirationPeriod * 1000L); //2시간 + } + + private Long extractToken(String token) { + return verifyToken(token).getClaim("memberId") + .asLong(); + } + + private DecodedJWT verifyToken(String token) { + JWTVerifier verifier = JWT.require(algorithm) + .build(); + + return Optional.of(verifier.verify(token)) + .orElseThrow(TokenInvalidException::new); + } +} diff --git a/src/main/java/com/board/member/controller/auth/AuthController.java b/src/main/java/com/board/member/controller/auth/AuthController.java new file mode 100644 index 0000000..4c73355 --- /dev/null +++ b/src/main/java/com/board/member/controller/auth/AuthController.java @@ -0,0 +1,41 @@ +package com.board.member.controller.auth; + +import com.board.member.controller.auth.dto.request.LoginRequest; +import com.board.member.controller.auth.dto.request.SignUpRequest; +import com.board.member.controller.auth.dto.response.SignUpResponse; +import com.board.member.service.auth.AuthService; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/signUp") + public ResponseEntity signUp(@RequestBody SignUpRequest request) { + Long memberId = authService.signUp(request.loginId(), request.memberName(), request.memberNickName(), + request.password()); + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri() + .path("/{id}") + .buildAndExpand(memberId) + .toUri(); + + return ResponseEntity.created(location).body(new SignUpResponse(memberId)); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.set("Authorization", "Bearer " + authService.login(request.loginId(), request.password())); + return ResponseEntity.status(HttpStatus.OK).body(httpHeaders); + } +} diff --git a/src/main/java/com/board/member/controller/auth/dto/request/LoginRequest.java b/src/main/java/com/board/member/controller/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..ed20876 --- /dev/null +++ b/src/main/java/com/board/member/controller/auth/dto/request/LoginRequest.java @@ -0,0 +1,7 @@ +package com.board.member.controller.auth.dto.request; + +public record LoginRequest( + String loginId, + String password +) { +} diff --git a/src/main/java/com/board/member/controller/auth/dto/request/SignUpRequest.java b/src/main/java/com/board/member/controller/auth/dto/request/SignUpRequest.java new file mode 100644 index 0000000..d2641a0 --- /dev/null +++ b/src/main/java/com/board/member/controller/auth/dto/request/SignUpRequest.java @@ -0,0 +1,9 @@ +package com.board.member.controller.auth.dto.request; + +public record SignUpRequest( + String memberName, + String memberNickName, + String loginId, + String password +) { +} diff --git a/src/main/java/com/board/member/controller/auth/dto/response/SignUpResponse.java b/src/main/java/com/board/member/controller/auth/dto/response/SignUpResponse.java new file mode 100644 index 0000000..284facf --- /dev/null +++ b/src/main/java/com/board/member/controller/auth/dto/response/SignUpResponse.java @@ -0,0 +1,6 @@ +package com.board.member.controller.auth.dto.response; + +public record SignUpResponse( + Long memberId +) { +} diff --git a/src/main/java/com/board/member/controller/member/MemberController.java b/src/main/java/com/board/member/controller/member/MemberController.java new file mode 100644 index 0000000..168c3b9 --- /dev/null +++ b/src/main/java/com/board/member/controller/member/MemberController.java @@ -0,0 +1,66 @@ +package com.board.member.controller.member; + +import com.board.global.resolver.annotation.Auth; +import com.board.member.controller.member.dto.reponse.MemberResponse; +import com.board.member.controller.member.dto.request.MemberRequest; +import com.board.member.domain.member.Member; +import com.board.member.service.member.MemberService; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@OpenAPIDefinition( + info = @Info(title = "My API", version = "1.0", description = "API Documentation") +) +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/members") + public ResponseEntity showMember(@Auth Long memberId) { + Member member = memberService.findMember(memberId); + MemberResponse response = new MemberResponse( + member.getMemberName(), + member.getMemberNickName(), + member.getMemberLoginId(), + member.getMemberPassword() + ); + + return ResponseEntity.ok(response); + } + + @PatchMapping("/members") + public ResponseEntity updateMember(@Auth Long memberId, @RequestBody MemberRequest request) { + Member member = memberService.updateMember(memberId, request.name(), request.nickName(), request.id(), + request.password()); + MemberResponse response = new MemberResponse( + member.getMemberName(), + member.getMemberNickName(), + member.getMemberLoginId(), + member.getMemberPassword() + ); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/members") + public ResponseEntity deleteMember(@Auth Long memberId) { + Member member = memberService.deleteMember(memberId); + MemberResponse response = new MemberResponse( + member.getMemberName(), + member.getMemberNickName(), + member.getMemberLoginId(), + member.getMemberPassword() + ); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/board/member/controller/member/dto/reponse/MemberResponse.java b/src/main/java/com/board/member/controller/member/dto/reponse/MemberResponse.java new file mode 100644 index 0000000..cd7054e --- /dev/null +++ b/src/main/java/com/board/member/controller/member/dto/reponse/MemberResponse.java @@ -0,0 +1,9 @@ +package com.board.member.controller.member.dto.reponse; + +public record MemberResponse( + String name, + String nickName, + String id, + String password +) { +} diff --git a/src/main/java/com/board/member/controller/member/dto/request/MemberRequest.java b/src/main/java/com/board/member/controller/member/dto/request/MemberRequest.java new file mode 100644 index 0000000..2187fc5 --- /dev/null +++ b/src/main/java/com/board/member/controller/member/dto/request/MemberRequest.java @@ -0,0 +1,9 @@ +package com.board.member.controller.member.dto.request; + +public record MemberRequest( + String name, + String nickName, + String id, + String password +) { +} diff --git a/src/main/java/com/board/member/domain/auth/TokenProvider.java b/src/main/java/com/board/member/domain/auth/TokenProvider.java new file mode 100644 index 0000000..4a4f17b --- /dev/null +++ b/src/main/java/com/board/member/domain/auth/TokenProvider.java @@ -0,0 +1,7 @@ +package com.board.member.domain.auth; + +public interface TokenProvider { + + String create(Long memberId); + Long extractMemberId(String token); +} diff --git a/src/main/java/com/board/member/domain/member/Member.java b/src/main/java/com/board/member/domain/member/Member.java new file mode 100644 index 0000000..3f88b60 --- /dev/null +++ b/src/main/java/com/board/member/domain/member/Member.java @@ -0,0 +1,59 @@ +package com.board.member.domain.member; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @NotBlank + private String memberName; + + @Column(nullable = false) + @NotBlank + private String memberNickName; + + @Column(nullable = false) + @NotBlank + private String memberLoginId; + + @Column(nullable = false) + @NotBlank + private String memberPassword; + + public Member(String memberName, String memberNickName, String memberLoginId, String memberPassword) { + this.memberName = memberName; + this.memberNickName = memberNickName; + this.memberLoginId = memberLoginId; + this.memberPassword = memberPassword; + } + + public void update(String memberName, String memberNickName, String memberLoginId, String memberPassword) { + if (memberName != null) { + this.memberName = memberName; + } + if (memberNickName != null) { + this.memberNickName = memberNickName; + } + if (memberLoginId != null) { + this.memberLoginId = memberLoginId; + } + if (memberPassword != null) { + this.memberPassword = memberPassword; + } + } +} diff --git a/src/main/java/com/board/member/domain/member/exception/NotMatchPasswordException.java b/src/main/java/com/board/member/domain/member/exception/NotMatchPasswordException.java new file mode 100644 index 0000000..5ffa9b2 --- /dev/null +++ b/src/main/java/com/board/member/domain/member/exception/NotMatchPasswordException.java @@ -0,0 +1,11 @@ +package com.board.member.domain.member.exception; + +import com.board.member.exception.MemberErrorCode; +import com.board.member.exception.MemberException; + +public class NotMatchPasswordException extends MemberException { + + public NotMatchPasswordException() { + super(MemberErrorCode.NOT_MATCH_PASSWORD); + } +} diff --git a/src/main/java/com/board/member/exception/MemberErrorCode.java b/src/main/java/com/board/member/exception/MemberErrorCode.java new file mode 100644 index 0000000..67549ee --- /dev/null +++ b/src/main/java/com/board/member/exception/MemberErrorCode.java @@ -0,0 +1,38 @@ +package com.board.member.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import org.springframework.http.HttpStatus; + +public enum MemberErrorCode implements BaseErrorCode { + + DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "M001", "이미 존재하는 아이디 입니다."), + DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "M002", "이미 존재하는 닉네임 입니다."), + NOT_MATCH_PASSWORD(HttpStatus.CONFLICT, "M003", "비밀번호가 일치하지 않습니다."), + NOT_MATCH_LOGIN_ID(HttpStatus.NOT_FOUND, "M004", "아이디가 틀렸습니다."), + NOT_FOUND_MEMBER(HttpStatus.NOT_FOUND, "M005", "유저를 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; + + MemberErrorCode(HttpStatus httpStatus, String customCode, String message) { + this.httpStatus = httpStatus; + this.customCode = customCode; + this.message = message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String customCode() { + return customCode; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/board/member/exception/MemberException.java b/src/main/java/com/board/member/exception/MemberException.java new file mode 100644 index 0000000..42d3105 --- /dev/null +++ b/src/main/java/com/board/member/exception/MemberException.java @@ -0,0 +1,11 @@ +package com.board.member.exception; + +import com.board.common.exception.exceptions.BaseErrorCode; +import com.board.common.exception.exceptions.BaseException; + +public class MemberException extends BaseException { + + public MemberException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/board/member/loader/MemberDataLoader.java b/src/main/java/com/board/member/loader/MemberDataLoader.java new file mode 100644 index 0000000..d4ba012 --- /dev/null +++ b/src/main/java/com/board/member/loader/MemberDataLoader.java @@ -0,0 +1,22 @@ +package com.board.member.loader; + +import com.board.member.domain.member.Member; +import com.board.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberDataLoader implements CommandLineRunner { + + private final MemberRepository repository; + private final PasswordEncoder passwordEncoder; + + @Override + public void run(String... args) throws Exception { + repository.save(new Member("신짱구", "짱구", "aaa", passwordEncoder.encode("password123"))); + repository.save(new Member("신짱아", "짱아", "sss", passwordEncoder.encode("password456"))); + } +} diff --git a/src/main/java/com/board/member/repository/MemberRepository.java b/src/main/java/com/board/member/repository/MemberRepository.java new file mode 100644 index 0000000..33406dd --- /dev/null +++ b/src/main/java/com/board/member/repository/MemberRepository.java @@ -0,0 +1,15 @@ +package com.board.member.repository; + +import com.board.member.domain.member.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MemberRepository extends JpaRepository { + + boolean existsByMemberNickName(String memberNickName); + boolean existsByMemberLoginId(String loginId); + Optional findMemberByMemberLoginId(String loginId); + Optional findMemberById(Long id); +} diff --git a/src/main/java/com/board/member/service/auth/AuthService.java b/src/main/java/com/board/member/service/auth/AuthService.java new file mode 100644 index 0000000..1af611a --- /dev/null +++ b/src/main/java/com/board/member/service/auth/AuthService.java @@ -0,0 +1,61 @@ +package com.board.member.service.auth; + +import com.board.member.domain.auth.TokenProvider; +import com.board.member.domain.member.Member; +import com.board.member.exception.MemberErrorCode; +import com.board.member.exception.MemberException; +import com.board.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final MemberRepository memberRepository; + private final TokenProvider tokenProvider; + private final PasswordEncoder passwordEncoder; + + public Long signUp(String loginId, String name, String nickName, String password) { + checkDuplicateLoginId(loginId); + checkDuplicateNickName(nickName); + Member member = new Member(name, nickName, loginId, passwordEncoder.encode(password)); + memberRepository.save(member); + + return member.getId(); + } + + @Transactional(readOnly = true) + public String login(String loginId, String password) { + Member member = findMemberByLoginId(loginId); + checkPassword(password, member.getMemberPassword()); + + return tokenProvider.create(member.getId()); + } + + private Member findMemberByLoginId(String loginId) { + return memberRepository.findMemberByMemberLoginId(loginId) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_MATCH_LOGIN_ID)); + } + + private void checkPassword(String requestPassword, String password) { + if (!passwordEncoder.matches(requestPassword, password)) { + throw new MemberException(MemberErrorCode.NOT_MATCH_PASSWORD); + } + } + + private void checkDuplicateLoginId(String loginId) { + if (memberRepository.existsByMemberLoginId(loginId)) { + throw new MemberException(MemberErrorCode.DUPLICATE_LOGIN_ID); + } + } + + private void checkDuplicateNickName(String memberNickName) { + if (memberRepository.existsByMemberNickName(memberNickName)) { + throw new MemberException(MemberErrorCode.DUPLICATE_NICKNAME); + } + } +} diff --git a/src/main/java/com/board/member/service/member/MemberService.java b/src/main/java/com/board/member/service/member/MemberService.java new file mode 100644 index 0000000..8f454bf --- /dev/null +++ b/src/main/java/com/board/member/service/member/MemberService.java @@ -0,0 +1,41 @@ +package com.board.member.service.member; + +import com.board.member.domain.member.Member; +import com.board.member.exception.MemberErrorCode; +import com.board.member.exception.MemberException; +import com.board.member.repository.MemberRepository; +import com.board.member.service.member.event.MemberDeletedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final ApplicationEventPublisher eventPublisher; + + public Member updateMember(Long memberId, String requestName, String requestNickName, String requestId, String requestPassword) { + Member member = findMember(memberId); + member.update(requestName, requestNickName, requestId, requestPassword); + + return member; + } + + public Member deleteMember(Long memberId) { + Member member = findMember(memberId); + memberRepository.delete(member); + eventPublisher.publishEvent(new MemberDeletedEvent(memberId)); + + return member; + } + + @Transactional(readOnly = true) + public Member findMember(Long memberId) { + return memberRepository.findMemberById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND_MEMBER)); + } +} diff --git a/src/main/java/com/board/member/service/member/event/MemberDeletedEvent.java b/src/main/java/com/board/member/service/member/event/MemberDeletedEvent.java new file mode 100644 index 0000000..c4097a5 --- /dev/null +++ b/src/main/java/com/board/member/service/member/event/MemberDeletedEvent.java @@ -0,0 +1,6 @@ +package com.board.member.service.member.event; + +public record MemberDeletedEvent( + Long memberId +) { +} diff --git a/src/main/java/com/board/member/service/member/event/MemberEventListener.java b/src/main/java/com/board/member/service/member/event/MemberEventListener.java new file mode 100644 index 0000000..856570e --- /dev/null +++ b/src/main/java/com/board/member/service/member/event/MemberEventListener.java @@ -0,0 +1,25 @@ +package com.board.member.service.member.event; + +import com.board.article.service.ArticleService; +import com.board.comment.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class MemberEventListener { + + private final ArticleService articleService; + private final CommentService commentService; + + @Transactional + @EventListener + public void handleMemberDeletedEvent(MemberDeletedEvent event) { + Long memberId = event.memberId(); + + articleService.updateMemberIdToNUll(memberId); + commentService.updateMemberIdToNull(memberId); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..7c731d9 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,18 @@ +spring: + h2: + console: + enabled: true + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL + username: sa + password: sa + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + default_batch_fetch_size: 100 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index efe2ab5..74d6d73 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,4 @@ spring.application.name=board + +jwt.secret=secretkeysecretkeysecretkeysecretkeysecretkeysecretkeysecretkeysecretkeysecretkey +jwt.expiration-period=7200 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..f1b2bbc --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +server: + servlet: + context-path: /api diff --git a/src/test/java/com/board/article/controller/ArticleControllerTest.java b/src/test/java/com/board/article/controller/ArticleControllerTest.java new file mode 100644 index 0000000..b0e5b85 --- /dev/null +++ b/src/test/java/com/board/article/controller/ArticleControllerTest.java @@ -0,0 +1,155 @@ +package com.board.article.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.board.article.controller.dto.request.ArticleRequest; +import com.board.article.domain.Article; +import com.board.article.service.ArticleService; +import com.board.global.resolver.AuthArgumentResolver; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ArticleController.class) +class ArticleControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ArticleService articleService; + + @MockBean + private AuthArgumentResolver authArgumentResolver; + + private Article article; + private Page
articlePage; + + @BeforeEach + void setUp() throws Exception { + given(authArgumentResolver.supportsParameter(any())).willReturn(true); + given(authArgumentResolver.resolveArgument(any(), any(), any(), any())).willReturn(1L); + + article = new Article(1L, "제목", "내용"); + ReflectionTestUtils.setField(article, "id", 1L); + + List
articles = List.of(article); + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + articlePage = new PageImpl<>(articles, pageable, articles.size()); + } + + @Test + @DisplayName("게시글을 생성한다.") + void createArticle() throws Exception { + ArticleRequest request = new ArticleRequest("제목", "내용"); + given(articleService.createArticle(anyLong(), any(), any())).willReturn(article); + + mockMvc.perform(post("/articles") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/articles/1")) + .andExpect(jsonPath("$.articleId").value(1L)) + .andExpect(jsonPath("$.title").value("제목")) + .andExpect(jsonPath("$.content").value("내용")); + } + + @Test + @DisplayName("모든 게시글을 조회한다.") + void showAllArticles() throws Exception { + given(articleService.findAllArticles(0L, 5)).willReturn(articlePage); + + mockMvc.perform(get("/articles") + .param("lastId", "0") + .param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.articleResponses[0].articleId").value(1L)) + .andExpect(jsonPath("$.articleResponses[0].title").value("제목")) + .andExpect(jsonPath("$.articleResponses[0].content").value("내용")) + .andExpect(jsonPath("$.pageNumber").value(0)) + .andExpect(jsonPath("$.pageSize").value(10)) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.totalPages").value(1)); + } + + @Test + @DisplayName("게시글 하나를 조회한다.") + void showArticle() throws Exception { + given(articleService.findArticle(1L)).willReturn(article); + + mockMvc.perform(get("/articles/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.articleId").value(1L)) + .andExpect(jsonPath("$.title").value("제목")) + .andExpect(jsonPath("$.content").value("내용")); + } + + @Test + @DisplayName("회원의 게시글을 조회한다.") + void showMemberArticles() throws Exception { + given(articleService.findMemberArticles(1L, 0, 10)).willReturn(articlePage); + + mockMvc.perform(get("/members/me/articles") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.articleResponses[0].articleId").value(1L)) + .andExpect(jsonPath("$.articleResponses[0].title").value("제목")) + .andExpect(jsonPath("$.articleResponses[0].content").value("내용")); + } + + @Test + @DisplayName("게시글을 수정한다.") + void updateArticle() throws Exception { + ArticleRequest request = new ArticleRequest("수정된 제목", "수정된 내용"); + Article updatedArticle = new Article(1L, "수정된 제목", "수정된 내용"); + ReflectionTestUtils.setField(updatedArticle, "id", 1L); + + given(articleService.updateArticle(anyLong(), anyLong(), any(), any())).willReturn(updatedArticle); + + mockMvc.perform(patch("/articles/1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.articleId").value(1L)) + .andExpect(jsonPath("$.title").value("수정된 제목")) + .andExpect(jsonPath("$.content").value("수정된 내용")); + } + + @Test + @DisplayName("게시글을 삭제한다.") + void deleteArticle() throws Exception { + given(articleService.deleteArticle(1L, 1L)).willReturn(article); + + mockMvc.perform(delete("/articles/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.articleId").value(1L)) + .andExpect(jsonPath("$.title").value("제목")) + .andExpect(jsonPath("$.content").value("내용")); + } +} diff --git a/src/test/java/com/board/article/loader/ArticleTestDataLoader.java b/src/test/java/com/board/article/loader/ArticleTestDataLoader.java new file mode 100644 index 0000000..d5379ce --- /dev/null +++ b/src/test/java/com/board/article/loader/ArticleTestDataLoader.java @@ -0,0 +1,25 @@ +package com.board.article.loader; + +import com.board.article.domain.Article; +import com.board.article.repository.ArticleRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class ArticleTestDataLoader { + + private final ArticleRepository repository; + + public ArticleTestDataLoader(ArticleRepository repository) { + this.repository = repository; + } + + @Bean + public CommandLineRunner testData() { + return args -> { + repository.save(new Article(1L, "신형만에 관하여", "아내는 신봉선")); + repository.save(new Article(1L, "신짱구", "바보")); + }; + } +} diff --git a/src/test/java/com/board/article/repository/ArticleRepositoryTest.java b/src/test/java/com/board/article/repository/ArticleRepositoryTest.java new file mode 100644 index 0000000..73684bb --- /dev/null +++ b/src/test/java/com/board/article/repository/ArticleRepositoryTest.java @@ -0,0 +1,55 @@ +package com.board.article.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.board.article.domain.Article; +import com.board.article.loader.ArticleTestDataLoader; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DataJpaTest +@Import(ArticleTestDataLoader.class) +class ArticleRepositoryTest { + + @Autowired + private ArticleRepository articleRepository; + + @Test + @DisplayName("멤버 아이디에 해당하는 게시글을 가져온다.(offset-paging)") + void findArticleByMemberId() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + Long memberId = 1L; + + // when + Page
articles = articleRepository.findArticleByMemberId(memberId, pageable); + + // then + assertThat(articles).hasSize(2); + assertThat(articles).extracting(Article::getTitle) + .containsExactly("신형만에 관하여", "신짱구"); + } + + @Test + @DisplayName("lastId보다 작은 ID를 가진 게시글을 내림차순으로 가져온다. (no-offset-paging)") + void findByIdLessThanOrderByIdDesc() { + // given + Long lastId = 2L; + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + + // when + Page
articles = articleRepository.findByIdLessThanOrderByIdDesc(lastId, pageable); + + // then + assertThat(articles).hasSize(1); + assertThat(articles).extracting(Article::getContent) + .containsExactly("아내는 신봉선"); + } +} diff --git a/src/test/java/com/board/article/service/ArticleServiceTest.java b/src/test/java/com/board/article/service/ArticleServiceTest.java new file mode 100644 index 0000000..e12d550 --- /dev/null +++ b/src/test/java/com/board/article/service/ArticleServiceTest.java @@ -0,0 +1,200 @@ +package com.board.article.service; + +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.BDDMockito.given; + +import com.board.article.controller.dto.request.ArticleRequest; +import com.board.article.domain.Article; +import com.board.article.exception.ArticleErrorCode; +import com.board.article.exception.ArticleException; +import com.board.article.repository.ArticleRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +class ArticleServiceTest { + + @Mock + private ArticleRepository articleRepository; + + @InjectMocks + private ArticleService articleService; + + private Article article; + private Page
articlePage; + + @BeforeEach + void set() { + article = new Article(1L, "제목1", "내용1"); + List
articleList = List.of(article); + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + articlePage = new PageImpl<>(articleList, pageable, articleList.size()); + } + + + @Nested + class 정상_동작_테스트를_진행한다 { + + @Test + @DisplayName("게시글을 생성한다.") + void createArticle() { + // given + ArticleRequest request = new ArticleRequest("제목1", "내용1"); + Long memberId = 1L; + given(articleRepository.save(any(Article.class))).willReturn(article); + + // when + Article response = articleService.createArticle(memberId, request.title(), request.content()); + + // then + assertThat(response) + .extracting(Article::getTitle, Article::getContent, Article::getMemberId) + .containsExactly("제목1", "내용1", memberId); + } + + @Test + @DisplayName("모든 게시글을 조회한다.") + void showAllArticles() { + // given + Long lastId = 0L; + int size = 10; + Pageable pageable = PageRequest.of(0, size, Sort.by("id").descending()); + given(articleRepository.findByIdLessThanOrderByIdDesc(lastId, pageable)) + .willReturn(articlePage); + + // when + Page
responses = articleService.findAllArticles(lastId, size); + + // then + assertThat(responses).hasSize(1) + .extracting(Article::getTitle) + .containsExactly("제목1"); + } + + @Test + @DisplayName("게시글을 단건 조회한다.") + void showArticle() { + // given + Long articleId = 1L; + given(articleRepository.findById(articleId)).willReturn(Optional.of(article)); + + // when + Article response = articleService.findArticle(articleId); + + // then + assertThat(response.getTitle()).isEqualTo("제목1"); + } + + @Test + @DisplayName("유저가 작성한 게시글을 모두 조회한다.") + void showMemberArticles() { + // given + Long memberId = 1L; + int page = 0; + int size = 10; + Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending()); + given(articleRepository.findArticleByMemberId(memberId, pageable)).willReturn(articlePage); + + // when + Page
responses = articleService.findMemberArticles(memberId, page, size); + + // then + assertThat(responses).hasSize(1) + .extracting(Article::getTitle) + .containsExactly("제목1"); + } + + @Test + @DisplayName("게시글을 수정한다.") + void updateArticle() { + // given + Long articleId = 1L; + Long memberId = 1L; + ArticleRequest request = new ArticleRequest("수정된 제목", "수정된 내용"); + given(articleRepository.findById(articleId)).willReturn(Optional.of(article)); + + // when + Article response = articleService.updateArticle(articleId, memberId, request.title(), request.content()); + + // then + assertThat(response.getTitle()).isEqualTo("수정된 제목"); + } + + @Test + @DisplayName("게시글을 삭제한다.") + void deleteArticle() { + // given + Long articleId = 1L; + Long memberId = 1L; + given(articleRepository.findById(articleId)).willReturn(Optional.of(article)); + + // when + Article response = articleService.deleteArticle(articleId, memberId); + + // then + assertThat(response.getTitle()).isEqualTo("제목1"); + } + } + + @Nested + class 예외_처리_테스트를_진행한다 { + + @Test + @DisplayName("존재하지 않는 게시글을 조회할 경우 예외가 발생한다.") + void notFoundArticle() { + // given + Long articleId = 3L; + given(articleRepository.findById(articleId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> articleService.findArticle(articleId)) + .isInstanceOf(ArticleException.class) + .hasMessageContaining(ArticleErrorCode.NOT_FOUND_ARTICLE.message()); + } + + @Test + @DisplayName("게시글 작성자가 아닌 사용자가 게시글을 수정하려 할 경우 예외가 발생한다.") + void forbiddenUpdateArticle() { + // given + Long articleId = 1L; + Long memberId = 2L; + ArticleRequest request = new ArticleRequest("수정 제목", "수정 내용"); + given(articleRepository.findById(articleId)).willReturn(Optional.of(article)); + + // when & then + assertThatThrownBy(() -> articleService.updateArticle(articleId, memberId, request.title(), request.content())) + .isInstanceOf(ArticleException.class) + .hasMessageContaining(ArticleErrorCode.FORBIDDEN_ACCESS_ARTICLE.message()); + } + + @Test + @DisplayName("게시글 작성자가 아닌 사용자가 게시글을 삭제하려 할 경우 예외가 발생한다.") + void forbiddenDeleteArticle() { + // given + Long articleId = 1L; + Long memberId = 2L; + given(articleRepository.findById(articleId)).willReturn(Optional.of(article)); + + // when & then + assertThatThrownBy(() -> articleService.deleteArticle(articleId, memberId)) + .isInstanceOf(ArticleException.class) + .hasMessageContaining(ArticleErrorCode.FORBIDDEN_ACCESS_ARTICLE.message()); + } + } +} diff --git a/src/test/java/com/board/comment/controller/CommentControllerTest.java b/src/test/java/com/board/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..ecf069f --- /dev/null +++ b/src/test/java/com/board/comment/controller/CommentControllerTest.java @@ -0,0 +1,135 @@ +package com.board.comment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.board.comment.controller.dto.request.CommentRequest; +import com.board.comment.domain.Comment; +import com.board.comment.service.CommentService; +import com.board.global.resolver.AuthArgumentResolver; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(CommentController.class) +class CommentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private CommentService commentService; + + @MockBean + private AuthArgumentResolver authArgumentResolver; + + private Comment response; + private Page commentPage; + @BeforeEach + void setUp() throws Exception { + response = new Comment(1L, 1L, "댓글 내용"); + List responses = List.of(response); + + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + commentPage = new PageImpl<>(responses, pageable, responses.size()); + + given(authArgumentResolver.supportsParameter(any())).willReturn(true); + given(authArgumentResolver.resolveArgument(any(), any(), any(), any())).willReturn(1L); + } + + @Test + @DisplayName("댓글을 생성한다.") + void createComment() throws Exception { + // given + CommentRequest request = new CommentRequest("댓글 내용"); + given(commentService.createComment(any(), any(), any())).willReturn(response); + + // when & then + mockMvc.perform(post("/articles/1/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("댓글 내용")); + } + + @Test + @DisplayName("게시글의 댓글들을 조회한다.") + void showArticleComments() throws Exception { + // given + Long lastId = 3L; + int size = 5; + given(commentService.findArticleComments(1L, lastId, size)).willReturn(commentPage); + + // when & then + mockMvc.perform(get("/articles/1/comments") + .param("lastId", String.valueOf(lastId)) + .param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commentResponses[0].content").value("댓글 내용")); + } + + @Test + @DisplayName("회원이 작성한 댓글들을 조회한다.") + void showMemberComments() throws Exception { + // given + int page = 0; + int size = 10; + given(commentService.findMemberComments(1L, page, size)).willReturn(commentPage); + + // when & then + mockMvc.perform(get("/members/me/comments") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commentResponses[0].content").value("댓글 내용")); + } + + @Test + @DisplayName("댓글을 수정한다.") + void updateComment() throws Exception { + // given + CommentRequest request = new CommentRequest("수정된 댓글"); + Comment updatedResponse = new Comment(1L, 1L,"수정된 댓글"); + given(commentService.updateComment(any(), any(), any())).willReturn(updatedResponse); + + // when & then + mockMvc.perform(patch("/comments/1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("수정된 댓글")); + } + + @Test + @DisplayName("댓글을 삭제한다.") + void deleteComment() throws Exception { + // given + given(commentService.deleteComment(1L, 1L)).willReturn(response); + + // when & then + mockMvc.perform(delete("/comments/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("댓글 내용")); + } +} diff --git a/src/test/java/com/board/comment/loader/CommentTestDataLoader.java b/src/test/java/com/board/comment/loader/CommentTestDataLoader.java new file mode 100644 index 0000000..dc92b0d --- /dev/null +++ b/src/test/java/com/board/comment/loader/CommentTestDataLoader.java @@ -0,0 +1,27 @@ +package com.board.comment.loader; + +import com.board.comment.domain.Comment; +import com.board.comment.repository.CommentRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class CommentTestDataLoader { + + private final CommentRepository repository; + + public CommentTestDataLoader(CommentRepository repository) { + this.repository = repository; + } + + @Bean + public CommandLineRunner testData() { + return args -> { + repository.save(new Comment(1L, 1L, "첫 번째 게시글 댓글 1")); + repository.save(new Comment(1L, 1L, "첫 번째 게시글 댓글 2")); + repository.save(new Comment(1L, 2L, "두 번째 게시글 댓글 1")); + repository.save(new Comment(1L, 2L, "두 번째 게시글 댓글 2")); + }; + } +} diff --git a/src/test/java/com/board/comment/repository/CommentRepositoryTest.java b/src/test/java/com/board/comment/repository/CommentRepositoryTest.java new file mode 100644 index 0000000..f70f1be --- /dev/null +++ b/src/test/java/com/board/comment/repository/CommentRepositoryTest.java @@ -0,0 +1,56 @@ +package com.board.comment.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.board.comment.domain.Comment; +import com.board.comment.loader.CommentTestDataLoader; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DataJpaTest +@Import(CommentTestDataLoader.class) +class CommentRepositoryTest { + + @Autowired + private CommentRepository commentRepository; + + @Test + @DisplayName("lastId보다 작은 ID를 가진 댓글을 내림차순으로 가져온다. (no-offset-paging)") + void findAllByArticleId() { + // given + Long articleId = 1L; + Long lastId = 3L; + Pageable pageable = PageRequest.of(0, 5, Sort.by("id").descending()); + + // when + Page comments = commentRepository.findByArticleIdAndIdLessThanOrderByIdDesc(articleId, lastId, pageable); + + // then + assertThat(comments).hasSize(2) + .extracting(Comment::getContent) + .containsExactly("첫 번째 게시글 댓글 2", "첫 번째 게시글 댓글 1"); + } + + @Test + @DisplayName("멤버 아이디에 해당하는 모든 댓글을 조회한다.(offset-paging)") + void findAllByMemberId() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + + // when + Page comments = commentRepository.findAllByMemberId(memberId, pageable); + + // then + assertThat(comments).hasSize(4) + .extracting(Comment::getMemberId) + .containsExactly(1L, 1L, 1L, 1L); + } +} diff --git a/src/test/java/com/board/comment/service/CommentServiceTest.java b/src/test/java/com/board/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..062422f --- /dev/null +++ b/src/test/java/com/board/comment/service/CommentServiceTest.java @@ -0,0 +1,204 @@ +package com.board.comment.service; + +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.BDDMockito.given; + +import com.board.comment.controller.dto.request.CommentRequest; +import com.board.comment.domain.Comment; +import com.board.comment.exception.CommentErrorCode; +import com.board.comment.exception.CommentException; +import com.board.comment.repository.CommentRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +class CommentServiceTest { + + @Mock + private CommentRepository commentRepository; + + @InjectMocks + private CommentService commentService; + + private Comment comment; + private List comments; + private Page commentPage; + + @BeforeEach + void set() { + comment = new Comment(1L, 1L, "첫 번째 게시글 댓글 1"); + comments = List.of(comment); + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + commentPage = new PageImpl<>(comments, pageable, comments.size()); + } + + @Nested + class 정상_동작_테스트를_진행한다 { + + @Test + @DisplayName("댓글을 저장한다.") + void createComment() { + // given + CommentRequest request = new CommentRequest("첫 번째 게시글 댓글1"); + Long memberId = 1L; + Long articleId = 1L; + + given(commentRepository.save(any(Comment.class))).willReturn(comment); + + // when + Comment response = commentService.createComment(request.content(), memberId, articleId); + + // then + assertThat(response) + .extracting(Comment::getContent, Comment::getMemberId, Comment::getArticleId) + .containsExactly("첫 번째 게시글 댓글1", memberId, articleId); + } + + @Test + @DisplayName("게시글에 해당하는 모든 댓글을 조회한다.") + void showArticleComments() { + // given + Long articleId = 1L; + Long lastId = 0L; + int size = 5; + Pageable pageable = PageRequest.of(0, size, Sort.by("id").descending()); + given(commentRepository.findByArticleIdAndIdLessThanOrderByIdDesc(articleId, lastId, pageable)) + .willReturn(commentPage); + + // when + Page responses = commentService.findArticleComments(articleId, lastId, size); + + // then + assertThat(responses).hasSize(1) + .extracting(Comment::getContent) + .containsExactly("첫 번째 게시글 댓글 1"); + } + + @Test + @DisplayName("유저 아이디에 해당하는 모든 댓글을 조회한다.") + void showMemberArticles() { + // given + Long memberId = 1L; + int page = 0; + int size = 10; + Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending()); + Page commentPage = new PageImpl<>(comments, pageable, comments.size()); + given(commentRepository.findAllByMemberId(memberId, pageable)).willReturn(commentPage); + + // when + Page responses = commentService.findMemberComments(memberId, page, size); + + // then + assertThat(responses).hasSize(1) + .extracting(Comment::getContent) + .containsExactly("첫 번째 게시글 댓글 1"); + } + + @Test + @DisplayName("댓글을 업데이트 한다.") + void updateComment() { + // given + Long commentId = 1L; + Long memberId = 1L; + CommentRequest request = new CommentRequest("수정된 게시글"); + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + Comment response = commentService.updateComment(request, memberId, commentId); + + // then + assertThat(response.getContent()).isEqualTo("수정된 게시글"); + } + + @Test + @DisplayName("댓글을 삭제한다.") + void deleteComment() { + // given + Long memberId = 1L; + Long commentId = 1L; + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + Comment response = commentService.deleteComment(memberId, commentId); + + // then + assertThat(response.getContent()).isEqualTo("첫 번째 게시글 댓글 1"); + } + + @Test + @DisplayName("특정 댓글을 조회한다.") + void getComment() { + // given + Long commentId = 1L; + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + Comment response = commentService.findComment(commentId); + + // then + assertThat(response.getContent()).isEqualTo("첫 번째 게시글 댓글 1"); + } + } + + @Nested + class 예외_처리_테스트를_진행한다 { + + @Test + @DisplayName("존재하지 않는 댓글을 조회한다.") + void getCommentException() { + // given + Long commentId = 3L; + given(commentRepository.findById(commentId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.findComment(commentId)) + .isInstanceOf(CommentException.class) + .hasMessageContaining(CommentErrorCode.NOT_FOUND_COMMENT.message()); + } + + @Test + @DisplayName("댓글 작성자가 아닌 유저가 댓글을 수정하려 할 경우 예외가 발생한다.") + void forbiddenUpdateComment() { + // given + Long memberId = 2L; + Long commentId = 1L; + CommentRequest request = new CommentRequest("댓글 수정"); + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.updateComment(request, memberId, commentId)) + .isInstanceOf(CommentException.class) + .hasMessageContaining(CommentErrorCode.FORBIDDEN_ACCESS_COMMENT.message()); + } + + @Test + @DisplayName("댓글 작성자가 아닌 유저가 댓글을 삭제하려 할 경우 예외가 발생한다.") + void forbiddenDeleteComment() { + // given + Long memberId = 2L; + Long commentId = 1L; + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.deleteComment(memberId, commentId)) + .isInstanceOf(CommentException.class) + .hasMessageContaining(CommentErrorCode.FORBIDDEN_ACCESS_COMMENT.message()); + } + } +} diff --git a/src/test/java/com/board/member/controller/auth/AuthControllerTest.java b/src/test/java/com/board/member/controller/auth/AuthControllerTest.java new file mode 100644 index 0000000..33319c8 --- /dev/null +++ b/src/test/java/com/board/member/controller/auth/AuthControllerTest.java @@ -0,0 +1,71 @@ +package com.board.member.controller.auth; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.board.member.Infrastructure.JwtTokenProvider; +import com.board.member.controller.auth.dto.request.LoginRequest; +import com.board.member.controller.auth.dto.request.SignUpRequest; +import com.board.member.service.auth.AuthService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(AuthController.class) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + @MockBean + private AuthService authService; + + @Test + @DisplayName("회원가입을 진행한다.") + void signUp() throws Exception { + // given + SignUpRequest request = new SignUpRequest("신짱구", "짱구", "aaa", "password123"); + given(authService.signUp(request.loginId(), request.memberName(), request.memberNickName(), request.password())).willReturn(1L); + given(jwtTokenProvider.create(1L)).willReturn("token"); + + // when & then + mockMvc.perform(post("/signUp") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/signUp/1")) + .andExpect(jsonPath("$.memberId").value(1L)); + } + + @Test + @DisplayName("로그인을 진행한다.") + void login() throws Exception { + // given + LoginRequest request = new LoginRequest("aaa", "111"); + String token = "token"; + given(authService.login(request.loginId(), request.password())).willReturn(token); + given(jwtTokenProvider.create(1L)).willReturn(token); + + // when & then + mockMvc.perform(post("/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(content().string("Authorization=Bearer+token")); + } +} diff --git a/src/test/java/com/board/member/controller/member/MemberControllerTest.java b/src/test/java/com/board/member/controller/member/MemberControllerTest.java new file mode 100644 index 0000000..bf1d14c --- /dev/null +++ b/src/test/java/com/board/member/controller/member/MemberControllerTest.java @@ -0,0 +1,91 @@ +package com.board.member.controller.member; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.board.global.resolver.AuthArgumentResolver; +import com.board.member.controller.member.dto.request.MemberRequest; +import com.board.member.domain.member.Member; +import com.board.member.service.member.MemberService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(MemberController.class) +class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private MemberService memberService; + + @MockBean + private AuthArgumentResolver authArgumentResolver; + + private Member member; + + @BeforeEach + void set() throws Exception { + member = new Member("신짱구", "짱구", "aaa", "password123"); + given(authArgumentResolver.supportsParameter(any())).willReturn(true); + given(authArgumentResolver.resolveArgument(any(), any(), any(), any())).willReturn(1L); + } + + @Test + @DisplayName("유저 정보를 조회한다.") + void showMember() throws Exception { + // given + Long memberId = 1L; + given(memberService.findMember(memberId)).willReturn(member); + + // when & then + mockMvc.perform(get("/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("신짱구")); + } + + @Test + @DisplayName("유저 정보를 수정한다.") + void updateMember() throws Exception { + // given + MemberRequest request = new MemberRequest("신짱구", "짱구", "newId", "newPassword"); + Member updatedResponse = new Member("신짱구", "짱구", "newId", "newPassword"); + given(memberService.updateMember(1L, request.name(), request.nickName(), request.id(), request.password())).willReturn(updatedResponse); + + // when & then + mockMvc.perform(patch("/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("newId")); + } + + @Test + @DisplayName("유저 정보를 삭제한다.") + void deleteMember() throws Exception { + // given + given(memberService.deleteMember(1L)).willReturn(member); + + // when & then + mockMvc.perform(delete("/members")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("신짱구")); + } +} diff --git a/src/test/java/com/board/member/loader/MemberTestDataLoader.java b/src/test/java/com/board/member/loader/MemberTestDataLoader.java new file mode 100644 index 0000000..8651e64 --- /dev/null +++ b/src/test/java/com/board/member/loader/MemberTestDataLoader.java @@ -0,0 +1,25 @@ +package com.board.member.loader; + +import com.board.member.domain.member.Member; +import com.board.member.repository.MemberRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class MemberTestDataLoader { + + private final MemberRepository repository; + + MemberTestDataLoader(MemberRepository memberRepository) { + this.repository = memberRepository; + } + + @Bean + public CommandLineRunner testData() { + return args -> { + repository.save(new Member("신짱구", "짱구", "aaa", "password123")); + repository.save(new Member("신짱아", "짱아", "sss", "password456")); + }; + } +} diff --git a/src/test/java/com/board/member/repository/MemberRepositoryTest.java b/src/test/java/com/board/member/repository/MemberRepositoryTest.java new file mode 100644 index 0000000..c684222 --- /dev/null +++ b/src/test/java/com/board/member/repository/MemberRepositoryTest.java @@ -0,0 +1,86 @@ +package com.board.member.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.board.member.domain.member.Member; +import com.board.member.loader.MemberTestDataLoader; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(MemberTestDataLoader.class) +class MemberRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + private Member member; + + @BeforeEach + void set() { + member = new Member("신짱구", "짱구", "aaa", "password123"); + } + + @Test + @DisplayName("특정 닉네임에 해당하는 유저가 있는지 판단한다.") + void existsByMemberNickName() { + // given + String nickName = "짱구"; + + // when + boolean isExistMember = memberRepository.existsByMemberNickName(nickName); + + // then + assertThat(isExistMember).isTrue(); + } + + @Test + @DisplayName("특정 아이디에 해당하는 유저가 있는지 판단한다.") + void existsByMemberLoginId() { + // given + String nickName = "aaa"; + + // when + boolean isExistMember = memberRepository.existsByMemberLoginId(nickName); + + // then + assertThat(isExistMember).isTrue(); + } + + @Test + @DisplayName("특정 로그인 아이디에 해당하는 유저를 조회한다.") + void findMemberByMemberLoginId() { + // given + String id = "aaa"; + + // when + Optional foundMember = memberRepository.findMemberByMemberLoginId(id); + + // then + assertThat(foundMember).isPresent(); + assertThat(foundMember.get()).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(member); + } + + @Test + @DisplayName("특정 아이디에 해당하는 유저를 조회한다.") + void findMemberById() { + // given + Long memberId = 1L; + + // when + Optional foundMember = memberRepository.findMemberById(memberId); + + // then + assertThat(foundMember).isPresent(); + assertThat(foundMember.get()).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(member); + } +} diff --git a/src/test/java/com/board/member/service/auth/AuthServiceTest.java b/src/test/java/com/board/member/service/auth/AuthServiceTest.java new file mode 100644 index 0000000..20afb74 --- /dev/null +++ b/src/test/java/com/board/member/service/auth/AuthServiceTest.java @@ -0,0 +1,151 @@ +package com.board.member.service.auth; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.board.member.controller.auth.dto.request.LoginRequest; +import com.board.member.controller.auth.dto.request.SignUpRequest; +import com.board.member.domain.auth.TokenProvider; +import com.board.member.domain.member.Member; +import com.board.member.exception.MemberErrorCode; +import com.board.member.exception.MemberException; +import com.board.member.repository.MemberRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +class AuthServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private TokenProvider tokenProvider; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AuthService authService; + + private Member member; + private SignUpRequest signUpRequest; + private LoginRequest loginRequest; + + @BeforeEach + void setUp() { + member = new Member("홍길동", "길동이", "gildong123", "1234"); + ReflectionTestUtils.setField(member, "id", 1L); + signUpRequest = new SignUpRequest("홍길동", "길동이", "gildong123", "1234"); + loginRequest = new LoginRequest("gildong123", "1234"); + } + + @Nested + class 회원가입_테스트 { + + @Test + @DisplayName("정상적으로 회원가입을 수행한다.") + void signUpSuccess() { + // given + given(memberRepository.existsByMemberLoginId("gildong123")).willReturn(false); + given(memberRepository.existsByMemberNickName("길동이")).willReturn(false); + given(passwordEncoder.encode("1234")).willReturn("encoded-password"); + given(memberRepository.save(any(Member.class))).willAnswer(invocation -> { + Member saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 1L); + return saved; + }); + + // when + Long memberId = authService.signUp(signUpRequest.loginId(), signUpRequest.memberName(), signUpRequest.memberNickName(), + signUpRequest.password()); + + // then + assertThat(memberId).isEqualTo(1L); + } + + @Test + @DisplayName("중복된 아이디로 회원가입할 경우 예외가 발생한다.") + void signUpDuplicateLoginId() { + // given + given(memberRepository.existsByMemberLoginId("gildong123")).willReturn(true); + + // when & then + assertThatThrownBy(() -> authService.signUp(signUpRequest.loginId(), signUpRequest.memberName(), signUpRequest.memberNickName(), + signUpRequest.password())) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberErrorCode.DUPLICATE_LOGIN_ID.message()); + } + + @Test + @DisplayName("중복된 닉네임으로 회원가입할 경우 예외가 발생한다.") + void signUpDuplicateNickName() { + // given + given(memberRepository.existsByMemberLoginId("gildong123")).willReturn(false); + given(memberRepository.existsByMemberNickName("길동이")).willReturn(true); + + // when & then + assertThatThrownBy(() -> authService.signUp(signUpRequest.loginId(), signUpRequest.memberName(), signUpRequest.memberNickName(), + signUpRequest.password())) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberErrorCode.DUPLICATE_NICKNAME.message()); + } + } + + @Nested + class 로그인_테스트 { + + @Test + @DisplayName("정상적으로 로그인을 수행한다.") + void loginSuccess() { + // given + Long memberId = 1L; + given(memberRepository.findMemberByMemberLoginId("gildong123")).willReturn(Optional.of(member)); + given(passwordEncoder.matches("1234", "1234")).willReturn(true); + given(tokenProvider.create(memberId)).willReturn("fake-jwt-token"); + + // when + String token = authService.login(loginRequest.loginId(), loginRequest.password()); + + // then + assertThat(token).isEqualTo("fake-jwt-token"); + } + + @Test + @DisplayName("존재하지 않는 아이디로 로그인 시도 시 예외가 발생한다.") + void loginInvalidLoginId() { + // given + given(memberRepository.findMemberByMemberLoginId("gildong123")).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.login(loginRequest.loginId(), loginRequest.password())) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberErrorCode.NOT_MATCH_LOGIN_ID.message()); + } + + @Test + @DisplayName("비밀번호가 일치하지 않을 경우 예외가 발생한다.") + void loginInvalidPassword() { + // given + Member wrongPasswordMember = new Member("홍길동", "길동이", "gildong123", "다른비밀번호"); + given(memberRepository.findMemberByMemberLoginId("gildong123")).willReturn(Optional.of(wrongPasswordMember)); + + // when & then + assertThatThrownBy(() -> authService.login(loginRequest.loginId(), loginRequest.password())) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberErrorCode.NOT_MATCH_PASSWORD.message()); + } + } +} diff --git a/src/test/java/com/board/member/service/member/MemberServiceTest.java b/src/test/java/com/board/member/service/member/MemberServiceTest.java new file mode 100644 index 0000000..d63555d --- /dev/null +++ b/src/test/java/com/board/member/service/member/MemberServiceTest.java @@ -0,0 +1,107 @@ +package com.board.member.service.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.board.member.controller.member.dto.request.MemberRequest; +import com.board.member.domain.member.Member; +import com.board.member.exception.MemberErrorCode; +import com.board.member.exception.MemberException; +import com.board.member.repository.MemberRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberServiceTest { + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private MemberService memberService; + + private Member member; + + @BeforeEach + void set() { + member = new Member("신짱구", "짱구", "aaa", "password123"); + } + + @Nested + class 정상_동작_테스트를_진행한다 { + + @Test + @DisplayName("멤버 아이디에 해당하는 유저를 보여준다.") + void showMember() { + // given + Long memberId = 1L; + given(memberRepository.findMemberById(memberId)).willReturn(Optional.of(member)); + + // when + Member response = memberService.findMember(memberId); + + // then + assertThat(response.getMemberName()).isEqualTo("신짱구"); + } + + @Test + @DisplayName("멤버 정보를 업데이트 한다.") + void updateMember() { + // given + Long memberId = 1L; + MemberRequest request = new MemberRequest( + "홍길동", + "길동", + "sss", + "123" + ); + given(memberRepository.findMemberById(memberId)).willReturn(Optional.ofNullable(member)); + + // when + Member response = memberService.updateMember(memberId, request.name(), request.nickName(), request.id(), request.password()); + + // then + assertThat(response.getMemberName()).isEqualTo("홍길동"); + } + + @Test + @DisplayName("멤버를 삭제한다.") + void deleteMember() { + // given + Long memberId = 1L; + given(memberRepository.findMemberById(memberId)).willReturn(Optional.of(member)); + + // when + Member response = memberService.deleteMember(memberId); + + // then + assertThat(response.getMemberName()).isEqualTo("신짱구"); + } + } + + @Nested + class 예외_처리_테스트를_진행한다 { + + @Test + @DisplayName("멤버 아이디에 해당하는 유저가 없을 경우 예외를 반환한다.") + void showMemberException() { + // given + Long memberId = 2L; + given(memberRepository.findMemberById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberService.findMember(memberId)) + .isInstanceOf(MemberException.class) + .hasMessageContaining(MemberErrorCode.NOT_FOUND_MEMBER.message()); + } + } +}