비교 요약으로 돌아가기

Full output

Antigravity 원문 결과물

콘텐츠 상세 조회 API 구현 / Google / Gemini 3.6 Flash / 실행일 2026-07-22

02. 콘텐츠 상세 조회 API 구현 (Antigravity)

추가된 파일 목록

  1. src/main/java/com/samebrief/content/content/domain/ContentStatus.java
  2. src/main/java/com/samebrief/content/content/domain/Content.java
  3. src/main/java/com/samebrief/content/content/repository/ContentRepository.java
  4. src/main/java/com/samebrief/content/content/dto/ContentResponse.java
  5. src/main/java/com/samebrief/content/content/dto/ErrorResponse.java
  6. src/main/java/com/samebrief/content/content/service/ContentService.java
  7. src/main/java/com/samebrief/content/content/controller/ContentController.java
  8. src/test/java/com/samebrief/content/content/controller/ContentControllerTest.java

src/main/java/com/samebrief/content/content/domain/ContentStatus.java

package com.samebrief.content.content.domain;

public enum ContentStatus {
    PUBLISHED,
    DELETED
}

src/main/java/com/samebrief/content/content/domain/Content.java

package com.samebrief.content.content.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "contents")
public class Content {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String slug;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String body;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ContentStatus status;

    protected Content() {
    }

    public Content(Long id, String slug, String title, String body, ContentStatus status) {
        this.id = id;
        this.slug = slug;
        this.title = title;
        this.body = body;
        this.status = status;
    }

    public Long getId() {
        return id;
    }

    public String getSlug() {
        return slug;
    }

    public String getTitle() {
        return title;
    }

    public String getBody() {
        return body;
    }

    public ContentStatus getStatus() {
        return status;
    }
}

src/main/java/com/samebrief/content/content/repository/ContentRepository.java

package com.samebrief.content.content.repository;

import com.samebrief.content.content.domain.Content;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ContentRepository extends JpaRepository<Content, Long> {
}

src/main/java/com/samebrief/content/content/dto/ContentResponse.java

package com.samebrief.content.content.dto;

import com.samebrief.content.content.domain.Content;

public record ContentResponse(
    Long id,
    String slug,
    String title,
    String body
) {
    public static ContentResponse from(Content content) {
        return new ContentResponse(
            content.getId(),
            content.getSlug(),
            content.getTitle(),
            content.getBody()
        );
    }
}

src/main/java/com/samebrief/content/content/dto/ErrorResponse.java

package com.samebrief.content.content.dto;

public record ErrorResponse(
    String code,
    String message
) {
}

src/main/java/com/samebrief/content/content/service/ContentService.java

package com.samebrief.content.content.service;

import com.samebrief.content.content.domain.Content;
import com.samebrief.content.content.domain.ContentStatus;
import com.samebrief.content.content.dto.ContentResponse;
import com.samebrief.content.content.repository.ContentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class ContentService {

    private final ContentRepository contentRepository;

    public ContentService(ContentRepository contentRepository) {
        this.contentRepository = contentRepository;
    }

    public ContentResponse getContent(Long id) {
        if (id == null || id < 1) {
            throw new InvalidContentIdException();
        }

        Content content = contentRepository.findById(id)
                .orElseThrow(ContentNotFoundException::new);

        if (content.getStatus() == ContentStatus.DELETED) {
            throw new ContentDeletedException();
        }

        return ContentResponse.from(content);
    }

    public static class ContentNotFoundException extends RuntimeException {
    }

    public static class ContentDeletedException extends RuntimeException {
    }

    public static class InvalidContentIdException extends RuntimeException {
    }
}

src/main/java/com/samebrief/content/content/controller/ContentController.java

package com.samebrief.content.content.controller;

import com.samebrief.content.content.dto.ContentResponse;
import com.samebrief.content.content.dto.ErrorResponse;
import com.samebrief.content.content.service.ContentService;
import com.samebrief.content.content.service.ContentService.ContentDeletedException;
import com.samebrief.content.content.service.ContentService.ContentNotFoundException;
import com.samebrief.content.content.service.ContentService.InvalidContentIdException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@RestController
@RequestMapping("/api/v1/contents")
public class ContentController {

    private final ContentService contentService;

    public ContentController(ContentService contentService) {
        this.contentService = contentService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<ContentResponse> getContent(@PathVariable Long id) {
        ContentResponse response = contentService.getContent(id);
        return ResponseEntity.ok(response);
    }

    @ExceptionHandler(InvalidContentIdException.class)
    public ResponseEntity<ErrorResponse> handleInvalidId(InvalidContentIdException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("INVALID_CONTENT_ID", "콘텐츠 ID는 1 이상의 정수여야 합니다."));
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("INVALID_CONTENT_ID", "콘텐츠 ID는 1 이상의 정수여야 합니다."));
    }

    @ExceptionHandler(ContentNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ContentNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("CONTENT_NOT_FOUND", "콘텐츠를 찾을 수 없습니다."));
    }

    @ExceptionHandler(ContentDeletedException.class)
    public ResponseEntity<ErrorResponse> handleDeleted(ContentDeletedException e) {
        return ResponseEntity.status(HttpStatus.GONE)
                .body(new ErrorResponse("CONTENT_DELETED", "삭제된 콘텐츠입니다."));
    }
}

src/test/java/com/samebrief/content/content/controller/ContentControllerTest.java

package com.samebrief.content.content.controller;

import com.samebrief.content.content.dto.ContentResponse;
import com.samebrief.content.content.service.ContentService;
import com.samebrief.content.content.service.ContentService.ContentDeletedException;
import com.samebrief.content.content.service.ContentService.ContentNotFoundException;
import com.samebrief.content.content.service.ContentService.InvalidContentIdException;
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;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(ContentController.class)
class ContentControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ContentService contentService;

    @Test
    @DisplayName("공개 상태 콘텐츠의 정상 조회: 200 OK 및 응답 필드 검증")
    void getContent_success() throws Exception {
        // given
        Long id = 42L;
        ContentResponse response = new ContentResponse(42L, "spring-api-basics", "Spring API 기초", "본문입니다.");
        given(contentService.getContent(id)).willReturn(response);

        // when & then
        mockMvc.perform(get("/api/v1/contents/{id}", id)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.slug").value("spring-api-basics"))
                .andExpect(jsonPath("$.title").value("Spring API 기초"))
                .andExpect(jsonPath("$.body").value("본문입니다."))
                .andExpect(jsonPath("$.status").doesNotExist());
    }

    @Test
    @DisplayName("존재하지 않는 ID 조회: 404 Not Found 및 CONTENT_NOT_FOUND 응답")
    void getContent_notFound() throws Exception {
        // given
        Long id = 999L;
        given(contentService.getContent(id)).willThrow(new ContentNotFoundException());

        // when & then
        mockMvc.perform(get("/api/v1/contents/{id}", id)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value("CONTENT_NOT_FOUND"))
                .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다."));
    }

    @Test
    @DisplayName("유효하지 않은 ID (0 이하 정수) 조회: 400 Bad Request 및 INVALID_CONTENT_ID 응답")
    void getContent_invalidId_nonPositive() throws Exception {
        // given
        Long id = 0L;
        given(contentService.getContent(id)).willThrow(new InvalidContentIdException());

        // when & then
        mockMvc.perform(get("/api/v1/contents/{id}", id)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_CONTENT_ID"))
                .andExpect(jsonPath("$.message").value("콘텐츠 ID는 1 이상의 정수여야 합니다."));
    }

    @Test
    @DisplayName("유효하지 않은 ID (비숫자) 조회: 400 Bad Request 및 INVALID_CONTENT_ID 응답")
    void getContent_invalidId_typeMismatch() throws Exception {
        // when & then
        mockMvc.perform(get("/api/v1/contents/{id}", "invalid-id")
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_CONTENT_ID"))
                .andExpect(jsonPath("$.message").value("콘텐츠 ID는 1 이상의 정수여야 합니다."));
    }

    @Test
    @DisplayName("삭제된 콘텐츠 조회: 410 Gone 및 CONTENT_DELETED 응답")
    void getContent_deleted() throws Exception {
        // given
        Long id = 42L;
        given(contentService.getContent(id)).willThrow(new ContentDeletedException());

        // when & then
        mockMvc.perform(get("/api/v1/contents/{id}", id)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isGone())
                .andExpect(jsonPath("$.code").value("CONTENT_DELETED"))
                .andExpect(jsonPath("$.message").value("삭제된 콘텐츠입니다."));
    }
}

실행 명령 및 예상 테스트 결과

테스트 실행 명령

./gradlew test --tests "com.samebrief.content.content.controller.ContentControllerTest"

예상 테스트 결과

BUILD SUCCESSFUL in 2s
5 tests completed, 0 failed