diff --git a/spring-boot-api/build.gradle b/spring-boot-api/build.gradle index bd560e5..4abfa3a 100755 --- a/spring-boot-api/build.gradle +++ b/spring-boot-api/build.gradle @@ -24,6 +24,7 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-validation' // for lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/api/PostController.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/api/PostController.java new file mode 100755 index 0000000..4398eaa --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/api/PostController.java @@ -0,0 +1,85 @@ +package com.ssafy.springbootapi.domain.post.api; + +import com.ssafy.springbootapi.domain.post.application.PostService; +import com.ssafy.springbootapi.domain.post.domain.Post; +import com.ssafy.springbootapi.domain.post.dto.AddPostRequest; +import com.ssafy.springbootapi.domain.post.dto.PostResponse; +import com.ssafy.springbootapi.domain.post.dto.UpdatePostRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Post", description = "Post 관련 API 입니다.") +@RequiredArgsConstructor +@RestController +public class PostController { + + private final PostService postService; + + @Operation(summary = "게시글 등록") + @PostMapping("/api/v1/posts") + public ResponseEntity addPost(@Validated @RequestBody AddPostRequest request) { + Post savedPost = postService.save(request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(savedPost); + } + + + + /** 스웨거에 넣을때 + * { + * "page": 0, //페이지번호 + * "size": 3, //한페이지당 몇개 표시할지 + * "sort": [ + * "id,asc" // id 오름차순 + * ] + * } + * @param pageable + * @return + */ + @Operation(summary = "게시글 리스트 불러오기 페이지버전") + @GetMapping("/api/v1/posts") + public ResponseEntity> findAllPosts(Pageable pageable) { + Page posts = postService.findAll(pageable); + return ResponseEntity.ok() + .body(posts); + } + + + @Operation(summary = "게시글 아이디로 조회") + @GetMapping("/api/v1/posts/{id}") + public ResponseEntity findPost(@PathVariable Long id) { + Post post = postService.findById(id); + + return ResponseEntity.ok() + .body(new PostResponse(post)); + } + + @Operation(summary = "게시글 삭제") + @DeleteMapping("/api/v1/posts/{id}") + public ResponseEntity deletePost(@PathVariable Long id) { + postService.delete(id); + return ResponseEntity.ok() + .build(); + } + + @Operation(summary = "게시글 수정") + @PutMapping("/api/v1/posts/{id}") + public ResponseEntity updatePost(@PathVariable Long id, @RequestBody UpdatePostRequest request) { + Post updatedPost = postService.update(id, request); + + return ResponseEntity.ok() + .body(updatedPost); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/application/PostService.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/application/PostService.java new file mode 100755 index 0000000..0811a1b --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/application/PostService.java @@ -0,0 +1,88 @@ +package com.ssafy.springbootapi.domain.post.application; + +import com.ssafy.springbootapi.domain.post.dao.PostRepository; +import com.ssafy.springbootapi.domain.post.domain.Post; +import com.ssafy.springbootapi.domain.post.dto.AddPostRequest; +import com.ssafy.springbootapi.domain.post.dto.PostResponse; +import com.ssafy.springbootapi.domain.post.dto.UpdatePostRequest; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class PostService { + private final PostRepository postRepository; + + /** + * 게시글 등록 + * @param request + * @return + */ + public Post save(AddPostRequest request) { + return postRepository.save(request.toEntity()); + } + + /** + * 게시글 리스트 불러오기 + * @return + */ + public List findAll(){ + return postRepository.findAll(); + } + + /** + * id로 게시글 찾기 + * @param id 찾을 게시글 id + * @return + */ + public Post findById(long id) { + return postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("not found : " + id)); + } + + + /** + * 게시글 삭제 + * @param id 삭제할 게시글 id + */ + public void delete(long id) { + postRepository.deleteById(id); + } + + /** + * 게시글 수정 + * @param id 수정할 게시글 id + * @param request + * @return + */ + @Transactional + public Post update(long id, UpdatePostRequest request) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("not found : " + id)); + + post.update(request.getTitle(), request.getContent()); + + return post; + } + + + /** + * 페이지 나눠서 리스트 불러오기 + * @param pageable + * @return + */ + //페이징 기능 + public Page findAll(Pageable pageable) { + Page posts = postRepository.findAll(pageable); + return posts.map(PostResponse::new); // 여기서 post를 PostReponse객체로 전부 변환 + //이렇게 해주면 Post가 외부에 노출되는 것을 방지하고, 필요한 데이터만을 담은 DTO(PostResponse)를 클라이언트에 반환하게됨 + } + +} \ No newline at end of file diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dao/PostRepository.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dao/PostRepository.java new file mode 100755 index 0000000..19bceaf --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dao/PostRepository.java @@ -0,0 +1,12 @@ +package com.ssafy.springbootapi.domain.post.dao; + +import com.ssafy.springbootapi.domain.post.domain.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostRepository extends JpaRepository { + Page findAll(Pageable pageable); //페이징 기능기준 +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/domain/Post.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/domain/Post.java new file mode 100755 index 0000000..25e714c --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/domain/Post.java @@ -0,0 +1,59 @@ +package com.ssafy.springbootapi.domain.post.domain; + + +import com.ssafy.springbootapi.domain.user.domain.User; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Builder +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false) + private Long id; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "contents", nullable = false) + private String content; + + @CreatedDate + @Column(name = "created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Builder.Default + @Column(name = "likes") + private Long likes=0L; + + @Builder.Default + @Column(name = "dislikes") + private Long dislikes=0L; + + @Builder.Default + @Column(name = "views") + private Long views=0L; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + public void update(String title, String content) { + this.title = title; + this.content = content; + } + +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/domain/PostReply.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/domain/PostReply.java new file mode 100755 index 0000000..bed3c59 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/domain/PostReply.java @@ -0,0 +1,36 @@ +package com.ssafy.springbootapi.domain.post.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +public class PostReply { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false) + private Long id; + + @Column(name = "contents", nullable = false) + private String content; + + @CreatedDate + @Column(name = "created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "likes", nullable = false) + private Long likes; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/AddPostRequest.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/AddPostRequest.java new file mode 100644 index 0000000..cba21e2 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/AddPostRequest.java @@ -0,0 +1,29 @@ +package com.ssafy.springbootapi.domain.post.dto; + +import com.ssafy.springbootapi.domain.post.domain.Post; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class AddPostRequest { + @NotEmpty + @NotNull + private String title; + + @NotEmpty + @NotNull + private String content; + + public Post toEntity() { + return Post.builder() + .title(title) + .content(content) + .build(); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/PostResponse.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/PostResponse.java new file mode 100644 index 0000000..ad1e73d --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/PostResponse.java @@ -0,0 +1,15 @@ +package com.ssafy.springbootapi.domain.post.dto; + +import com.ssafy.springbootapi.domain.post.domain.Post; +import lombok.Getter; + +@Getter +public class PostResponse { + private final String title; + private final String content; + + public PostResponse(Post post) { + this.title = post.getTitle(); + this.content = post.getContent(); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/UpdatePostRequest.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/UpdatePostRequest.java new file mode 100644 index 0000000..a6eae07 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/post/dto/UpdatePostRequest.java @@ -0,0 +1,13 @@ +package com.ssafy.springbootapi.domain.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class UpdatePostRequest { + private String title; + private String content; +} diff --git a/spring-boot-api/src/main/resources/application.yml b/spring-boot-api/src/main/resources/application.yml index 408e372..3993b26 100644 --- a/spring-boot-api/src/main/resources/application.yml +++ b/spring-boot-api/src/main/resources/application.yml @@ -31,4 +31,11 @@ springdoc: display-query-params-without-oauth2: true doc-expansion: none paths-to-match: - - /api/** \ No newline at end of file + - /api/** + +logging: + level: + root: INFO +# com: +# ssafy.springbootapi.domain: DEBUG +# ssafy.springbootapi.domain 아래에 있는 것들만 디버그로 보고싶을때 사용 \ No newline at end of file diff --git a/spring-boot-api/src/test/java/com/ssafy/springbootapi/domain/post/api/PostApiControllerTest.java b/spring-boot-api/src/test/java/com/ssafy/springbootapi/domain/post/api/PostApiControllerTest.java new file mode 100644 index 0000000..bd88736 --- /dev/null +++ b/spring-boot-api/src/test/java/com/ssafy/springbootapi/domain/post/api/PostApiControllerTest.java @@ -0,0 +1,206 @@ +package com.ssafy.springbootapi.domain.post.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ssafy.springbootapi.domain.post.dao.PostRepository; +import com.ssafy.springbootapi.domain.post.domain.Post; +import com.ssafy.springbootapi.domain.post.dto.AddPostRequest; +import com.ssafy.springbootapi.domain.post.dto.UpdatePostRequest; +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.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@SpringBootTest +@AutoConfigureMockMvc +public class PostApiControllerTest { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + @Autowired + PostRepository postRepository; + + @BeforeEach + public void mockMvcSetUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .build(); + postRepository.deleteAll(); + } + + @DisplayName("게시글 생성 성공 테스트") + @Test + public void 게시글생성성공테스트() throws Exception { + // given + final String url = "/api/v1/posts"; + final String title = "title"; + final String content = "content"; + final AddPostRequest userRequest = new AddPostRequest(title, content); + + final String requestBody = objectMapper.writeValueAsString(userRequest); + + // when + ResultActions result = mockMvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)); + + // then + result.andExpect(status().isCreated()); + + List posts = postRepository.findAll(); + + assertThat(posts.size()).isEqualTo(1); + assertThat(posts.get(0).getTitle()).isEqualTo(title); + assertThat(posts.get(0).getContent()).isEqualTo(content); + } + + @DisplayName("게시글 생성 실패 테스트 - 유효하지 않은 입력 값") + @Test + public void 게시글생성실패테스트_유효하지않은입력값() throws Exception { + // given + final String url = "/api/v1/posts"; + final String invalidTitle = ""; // 유효하지 않은 제목 + final String invalidContent = ""; // 유효하지 않은 내용 + final AddPostRequest userRequestWithInvalidData = new AddPostRequest(invalidTitle, invalidContent); + + final String requestBody = objectMapper.writeValueAsString(userRequestWithInvalidData); + + // when + ResultActions result = mockMvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)); + + // then + result.andExpect(status().isBadRequest()); // 400 Bad Request 상태 코드 예상 + + List posts = postRepository.findAll(); + + // 게시글이 생성되지 않았으므로, 저장된 게시글이 없어야 함 + assertThat(posts.size()).isEqualTo(0); + } + + @DisplayName("게시글 페이징 조회 성공 테스트") + @Test + public void 게시글페이징조회성공테스트() throws Exception { + //given + final String url = "/api/v1/posts?page=0&size=10"; + final String title = "title"; + final String content = "content"; + + postRepository.save(Post.builder() + .title(title) + .content(content) + .build()); + + //when + final ResultActions resultActions = mockMvc.perform(get(url) + .contentType(MediaType.APPLICATION_JSON)); + + //then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].content").value(content)) + .andExpect(jsonPath("$.content[0].title").value(title)); + } + + @DisplayName("게시글 아이디 조회 성공 테스트") + @Test + public void 게시글아이디조회성공테스트() throws Exception { + //given + final String url = "/api/v1/posts/{id}"; + final String title = "title"; + final String content = "content"; + + Post savedPost = postRepository.save(Post.builder() + .title(title) + .content(content) + .build()); + + //when + final ResultActions resultActions = mockMvc.perform(get(url, savedPost.getId())); + + //then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value(content)) + .andExpect(jsonPath("$.title").value(title)); + + } + + @DisplayName("게시글 삭제 성공 테스트") + @Test + public void 게시글삭제성공테스트() throws Exception{ + // given + final String url = "/api/v1/posts/{id}"; + final String title = "title"; + final String content = "content"; + + Post savedPost = postRepository.save(Post.builder() + .title(title) + .content(content) + .build()); + + //when + mockMvc.perform(delete(url, savedPost.getId())) + .andExpect(status().isOk()); + + //then + List posts = postRepository.findAll(); + + assertThat(posts).isEmpty(); + } + + @DisplayName("게시글 수정 성공 테스트") + @Test + public void 게시글수정성공테스트() throws Exception { + // given + final String url = "/api/v1/posts/{id}"; + final String title = "title"; + final String content = "content"; + + Post savedPost = postRepository.save(Post.builder() + .title(title) + .content(content) + .build()); + + final String newTitle = "new title"; + final String newContent = "new content"; + + UpdatePostRequest request = new UpdatePostRequest(newTitle, newContent); + + //when + ResultActions result = mockMvc.perform(put(url, savedPost.getId()) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()); + Post post = postRepository.findById(savedPost.getId()).get(); + + assertThat(post.getTitle()).isEqualTo(newTitle); + assertThat(post.getContent()).isEqualTo(newContent); + } + +}