diff --git a/build.gradle b/build.gradle index 610d6a6..f7c08a3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,29 +1,22 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.6' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' version '3.5.6' + id 'io.spring.dependency-management' version '1.1.6' + id 'java' } - group = 'com.example' version = '0.0.1-SNAPSHOT' -description = 'Demo project for Spring Boot' +java { sourceCompatibility = '21' } -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() -} +repositories { mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + runtimeOnly 'com.h2database:h2' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } -tasks.named('test') { - useJUnitPlatform() -} +tasks.named('test') { useJUnitPlatform() } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/example/devSns/domain/Comment.java b/src/main/java/com/example/devSns/domain/Comment.java new file mode 100644 index 0000000..5edc090 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Comment.java @@ -0,0 +1,35 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private Post post; + + @Builder + private Comment(String content, Post post) { + this.content = content; + this.post = post; + } + + public void update(String content) { + this.content = content; + } + + /** Post 편의 메서드에서만 설정하도록 제한 */ + void setPostInternal(Post post) { + this.post = post; + } +} diff --git a/src/main/java/com/example/devSns/domain/CommentRepository.java b/src/main/java/com/example/devSns/domain/CommentRepository.java new file mode 100644 index 0000000..d3059bd --- /dev/null +++ b/src/main/java/com/example/devSns/domain/CommentRepository.java @@ -0,0 +1,11 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + List findByPostIdOrderByIdAsc(Long postId); + Optional findByIdAndPostId(Long id, Long postId); +} diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java new file mode 100644 index 0000000..4accbc3 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -0,0 +1,47 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(nullable = false) + private String title; + + @Lob + private String content; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private final List comments = new ArrayList<>(); + + @Builder + private Post(String title, String content) { + this.title = title; + this.content = content; + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + /** 양방향 편의 메서드 */ + void addComment(Comment c) { + comments.add(c); + c.setPostInternal(this); + } + void removeComment(Comment c) { + comments.remove(c); + c.setPostInternal(null); + } +} diff --git a/src/main/java/com/example/devSns/domain/PostRepository.java b/src/main/java/com/example/devSns/domain/PostRepository.java new file mode 100644 index 0000000..aa17274 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostRepository.java @@ -0,0 +1,5 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { } diff --git a/src/main/java/com/example/devSns/service/CommentService.java b/src/main/java/com/example/devSns/service/CommentService.java new file mode 100644 index 0000000..4de8323 --- /dev/null +++ b/src/main/java/com/example/devSns/service/CommentService.java @@ -0,0 +1,56 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Comment; +import com.example.devSns.domain.CommentRepository; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +public class CommentService { + private final CommentRepository commentRepo; + private final PostRepository postRepo; + + public CommentService(CommentRepository commentRepo, PostRepository postRepo) { + this.commentRepo = commentRepo; + this.postRepo = postRepo; + } + + public List list(Long postId) { + ensurePostExists(postId); + return commentRepo.findByPostIdOrderByIdAsc(postId); + } + + public Comment get(Long postId, Long commentId) { + return commentRepo.findByIdAndPostId(commentId, postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Comment not found")); + } + + public Comment create(Long postId, String content) { + Post post = postRepo.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + Comment comment = Comment.builder().content(content).post(post).build(); + return commentRepo.save(comment); + } + + public Comment update(Long postId, Long commentId, String content) { + Comment comment = get(postId, commentId); + comment.update(content); + return commentRepo.save(comment); + } + + public void delete(Long postId, Long commentId) { + Comment comment = get(postId, commentId); + commentRepo.delete(comment); + } + + private void ensurePostExists(Long postId) { + if (!postRepo.existsById(postId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found"); + } + } +} diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java new file mode 100644 index 0000000..de944f6 --- /dev/null +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -0,0 +1,48 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +public class PostService { + + private final PostRepository repo; + + public PostService(PostRepository repo) { + this.repo = repo; + } + + public List findAll() { + return repo.findAll(); + } + + public Post findById(Long id) { + return repo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + } + + public Post create(String title, String content) { + Post post = Post.builder() + .title(title) + .content(content) + .build(); + return repo.save(post); + } + + public Post update(Long id, String title, String content) { + Post post = findById(id); + post.update(title, content); + return repo.save(post); + } + + public void delete(Long id) { + Post post = repo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + repo.delete(post); // 연관 댓글까지 안전하게 제거 + } +} diff --git a/src/main/java/com/example/devSns/web/CommentController.java b/src/main/java/com/example/devSns/web/CommentController.java new file mode 100644 index 0000000..115ee06 --- /dev/null +++ b/src/main/java/com/example/devSns/web/CommentController.java @@ -0,0 +1,50 @@ +package com.example.devSns.web; + +import com.example.devSns.service.CommentService; +import com.example.devSns.web.dto.CommentCreateRequest; +import com.example.devSns.web.dto.CommentUpdateRequest; +import com.example.devSns.web.dto.CommentResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/posts/{postId}/comments") +public class CommentController { + private final CommentService svc; + + public CommentController(CommentService svc) { + this.svc = svc; + } + + @GetMapping + public List list(@PathVariable Long postId) { + return svc.list(postId).stream().map(CommentResponse::from).toList(); + } + + @GetMapping("/{commentId}") + public CommentResponse get(@PathVariable Long postId, @PathVariable Long commentId) { + return CommentResponse.from(svc.get(postId, commentId)); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public CommentResponse create(@PathVariable Long postId, + @Valid @RequestBody CommentCreateRequest req) { + return CommentResponse.from(svc.create(postId, req.content())); + } + + @PutMapping("/{commentId}") + public CommentResponse update(@PathVariable Long postId, @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest req) { + return CommentResponse.from(svc.update(postId, commentId, req.content())); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{commentId}") + public void delete(@PathVariable Long postId, @PathVariable Long commentId) { + svc.delete(postId, commentId); + } +} diff --git a/src/main/java/com/example/devSns/web/PostController.java b/src/main/java/com/example/devSns/web/PostController.java new file mode 100644 index 0000000..ec1c6b2 --- /dev/null +++ b/src/main/java/com/example/devSns/web/PostController.java @@ -0,0 +1,53 @@ +package com.example.devSns.web; + +import com.example.devSns.domain.Post; +import com.example.devSns.service.PostService; +import com.example.devSns.web.dto.PostCreateRequest; +import com.example.devSns.web.dto.PostUpdateRequest; +import com.example.devSns.web.dto.PostResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/posts") +public class PostController { + + private final PostService svc; + + public PostController(PostService svc) { + this.svc = svc; + } + + @GetMapping + public List getAll() { + return svc.findAll().stream().map(PostResponse::from).toList(); + } + + @GetMapping("/{id}") + public PostResponse getOne(@PathVariable Long id) { + Post p = svc.findById(id); + return PostResponse.from(p); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public PostResponse create(@Valid @RequestBody PostCreateRequest req) { + Post saved = svc.create(req.title(), req.content()); + return PostResponse.from(saved); + } + + @PutMapping("/{id}") + public PostResponse update(@PathVariable Long id, @Valid @RequestBody PostUpdateRequest req) { + Post updated = svc.update(id, req.title(), req.content()); + return PostResponse.from(updated); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) { + svc.delete(id); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java new file mode 100644 index 0000000..f6848dd --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java @@ -0,0 +1,7 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentCreateRequest( + @NotBlank String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/CommentResponse.java b/src/main/java/com/example/devSns/web/dto/CommentResponse.java new file mode 100644 index 0000000..19eb489 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentResponse.java @@ -0,0 +1,12 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Comment; + +public record CommentResponse( + Long id, + String content +) { + public static CommentResponse from(Comment c) { + return new CommentResponse(c.getId(), c.getContent()); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java b/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..a80b258 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java @@ -0,0 +1,7 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentUpdateRequest( + @NotBlank String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java new file mode 100644 index 0000000..a486cad --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PostCreateRequest( + @NotBlank String title, + String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/PostResponse.java b/src/main/java/com/example/devSns/web/dto/PostResponse.java new file mode 100644 index 0000000..0130185 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostResponse.java @@ -0,0 +1,13 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Post; + +public record PostResponse( + Long id, + String title, + String content +) { + public static PostResponse from(Post p) { + return new PostResponse(p.getId(), p.getTitle(), p.getContent()); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java b/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java new file mode 100644 index 0000000..1315608 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PostUpdateRequest( + @NotBlank String title, + String content +) {} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d92d4f5 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate.ddl-auto: update + show-sql: true + h2: + console.enabled: true diff --git a/src/test/java/com/example/devSns/web/CommentControllerTest.java b/src/test/java/com/example/devSns/web/CommentControllerTest.java new file mode 100644 index 0000000..caa6c46 --- /dev/null +++ b/src/test/java/com/example/devSns/web/CommentControllerTest.java @@ -0,0 +1,98 @@ +package com.example.devSns.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +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.*; + +import jakarta.transaction.Transactional; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CommentControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper om; + + long postId; + + @BeforeEach + void setUp() throws Exception { + // 테스트용 게시글 하나 생성 + MvcResult res = mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","댓글 테스트 글","content","본문"))) + ).andExpect(status().isCreated()) + .andReturn(); + + postId = om.readTree(res.getResponse().getContentAsString()).get("id").asLong(); + } + + @Test + void comment_CRUD_flow() throws Exception { + // CREATE + MvcResult create = mockMvc.perform( + post("/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","첫 댓글"))) + ).andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.content").value("첫 댓글")) + .andReturn(); + + long commentId = om.readTree(create.getResponse().getContentAsString()).get("id").asLong(); + + // LIST + mockMvc.perform(get("/posts/{postId}/comments", postId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(commentId)); + + // DETAIL + mockMvc.perform(get("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("첫 댓글")); + + // UPDATE + mockMvc.perform( + put("/posts/{postId}/comments/{commentId}", postId, commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","수정된 댓글"))) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("수정된 댓글")); + + // DELETE + mockMvc.perform(delete("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isNoContent()); + + // 404 확인 + mockMvc.perform(get("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isNotFound()); + } + + @Test + void comment_create_validation_fail_when_blank() throws Exception { + mockMvc.perform( + post("/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content",""))) + ).andExpect(status().isBadRequest()); + } + + @Test + void comment_404_when_post_not_found() throws Exception { + mockMvc.perform( + post("/posts/{postId}/comments", 999_999L) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","x"))) + ).andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/example/devSns/web/PostControllerTest.java b/src/test/java/com/example/devSns/web/PostControllerTest.java new file mode 100644 index 0000000..b4f7580 --- /dev/null +++ b/src/test/java/com/example/devSns/web/PostControllerTest.java @@ -0,0 +1,77 @@ +package com.example.devSns.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +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.*; + +import jakarta.transaction.Transactional; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional // 각 테스트 후 롤백 +class PostControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper om; + + @Test + void post_CRUD_flow() throws Exception { + // CREATE + MvcResult createRes = mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","첫 글","content","내용입니다"))) + ).andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.title").value("첫 글")) + .andReturn(); + + long postId = om.readTree(createRes.getResponse().getContentAsString()).get("id").asLong(); + + // LIST + mockMvc.perform(get("/posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").exists()); + + // DETAIL + mockMvc.perform(get("/posts/{id}", postId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("첫 글")); + + // UPDATE + mockMvc.perform( + put("/posts/{id}", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","수정 제목","content","수정 내용"))) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정 제목")) + .andExpect(jsonPath("$.content").value("수정 내용")); + + // DELETE + mockMvc.perform(delete("/posts/{id}", postId)) + .andExpect(status().isNoContent()); + + // 404 확인 + mockMvc.perform(get("/posts/{id}", postId)) + .andExpect(status().isNotFound()); + } + + @Test + void post_create_validation_fail_when_title_blank() throws Exception { + mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","", "content","x"))) + ).andExpect(status().isBadRequest()); + } +}