diff --git a/backend/src/main/java/codezap/category/controller/CategoryController.java b/backend/src/main/java/codezap/category/controller/CategoryController.java new file mode 100644 index 000000000..7ef8c0788 --- /dev/null +++ b/backend/src/main/java/codezap/category/controller/CategoryController.java @@ -0,0 +1,60 @@ +package codezap.category.controller; + +import java.net.URI; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.dto.response.FindAllCategoriesResponse; +import codezap.category.service.CategoryService; +import codezap.global.validation.ValidationSequence; + +@RestController +@RequestMapping("/categories") +public class CategoryController implements SpringDocCategoryController { + + private final CategoryService categoryService; + + public CategoryController(CategoryService categoryService) { + this.categoryService = categoryService; + } + + @PostMapping + public ResponseEntity createCategory( + @Validated(ValidationSequence.class) @RequestBody CreateCategoryRequest createCategoryRequest + ) { + Long createdCategoryId = categoryService.create(createCategoryRequest); + return ResponseEntity.created(URI.create("/categories/" + createdCategoryId)) + .build(); + } + + @GetMapping + public ResponseEntity getCategories() { + return ResponseEntity.ok(categoryService.findAll()); + } + + @PutMapping("/{id}") + public ResponseEntity updateCategory( + @PathVariable Long id, + @Validated(ValidationSequence.class) @RequestBody UpdateCategoryRequest updateCategoryRequest + ) { + categoryService.update(id, updateCategoryRequest); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id) { + categoryService.deleteById(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java new file mode 100644 index 000000000..c288e47d1 --- /dev/null +++ b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java @@ -0,0 +1,56 @@ +package codezap.category.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.dto.response.FindAllCategoriesResponse; +import codezap.global.swagger.error.ApiErrorResponse; +import codezap.global.swagger.error.ErrorCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "카테고리 CRUD API", description = "카테고리 생성, 목록 조회, 삭제, 수정 API") +public interface SpringDocCategoryController { + + @Operation(summary = "카테고리 생성", description = """ + 새로운 카테고리를 생성합니다. \n + 새로운 카테고리의 이름이 필요합니다. \n + """) + @ApiResponse(responseCode = "201", description = "카테고리 생성 성공", headers = { + @Header(name = "생성된 카테고리의 API 경로", example = "/categories/1")}) + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories", errorCases = { + @ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."), + @ErrorCase(description = "카테고리 이름이 255자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 255자까지 입력 가능합니다."), + @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다.") + }) + ResponseEntity createCategory(CreateCategoryRequest createCategoryRequest); + + @Operation(summary = "카테고리 목록 조회", description = "생성된 모든 카테고리를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = {@Content(schema = @Schema(implementation = FindAllCategoriesResponse.class))}) + ResponseEntity getCategories(); + + @Operation(summary = "카테고리 수정", description = "해당하는 식별자의 카테고리를 수정합니다.") + @ApiResponse(responseCode = "200", description = "카테고리 수정 성공") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = { + @ErrorCase(description = "해당하는 id 값인 카테고리가 없는 경우", + exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."), + @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", + exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다.") + }) + ResponseEntity updateCategory(Long id, UpdateCategoryRequest updateCategoryRequest); + + @Operation(summary = "카테고리 삭제", description = "해당하는 식별자의 카테고리를 삭제합니다.") + @ApiResponse(responseCode = "204", description = "카테고리 삭제 성공") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = { + @ErrorCase(description = "삭제하려는 카테고리에 템플릿이 존재하는 경우", + exampleMessage = "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."), + }) + ResponseEntity deleteCategory(Long id); +} diff --git a/backend/src/main/java/codezap/category/domain/Category.java b/backend/src/main/java/codezap/category/domain/Category.java new file mode 100644 index 000000000..df1818d5e --- /dev/null +++ b/backend/src/main/java/codezap/category/domain/Category.java @@ -0,0 +1,32 @@ +package codezap.category.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Category extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + public Category(String name) { + this.name = name; + } + + public void updateName(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java b/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java new file mode 100644 index 000000000..9587c9d04 --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java @@ -0,0 +1,16 @@ +package codezap.category.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateCategoryRequest( + @Schema(description = "카테고리 이름", example = "Spring") + @NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "카테고리 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String name +) { +} diff --git a/backend/src/main/java/codezap/category/dto/request/UpdateCategoryRequest.java b/backend/src/main/java/codezap/category/dto/request/UpdateCategoryRequest.java new file mode 100644 index 000000000..52362219b --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/request/UpdateCategoryRequest.java @@ -0,0 +1,16 @@ +package codezap.category.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import io.swagger.v3.oas.annotations.media.Schema; + +public record UpdateCategoryRequest( + @Schema(description = "카테고리 이름", example = "Spring") + @NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "카테고리 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String name +) { +} diff --git a/backend/src/main/java/codezap/category/dto/response/FindAllCategoriesResponse.java b/backend/src/main/java/codezap/category/dto/response/FindAllCategoriesResponse.java new file mode 100644 index 000000000..bac6dccf6 --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/response/FindAllCategoriesResponse.java @@ -0,0 +1,19 @@ +package codezap.category.dto.response; + +import java.util.List; + +import codezap.category.domain.Category; +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindAllCategoriesResponse( + @Schema(description = "카테고리 목록") + List categories +) { + public static FindAllCategoriesResponse from(List categories) { + return new FindAllCategoriesResponse( + categories.stream() + .map(FindCategoryResponse::from) + .toList() + ); + } +} diff --git a/backend/src/main/java/codezap/category/dto/response/FindCategoryResponse.java b/backend/src/main/java/codezap/category/dto/response/FindCategoryResponse.java new file mode 100644 index 000000000..699fda8d6 --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/response/FindCategoryResponse.java @@ -0,0 +1,15 @@ +package codezap.category.dto.response; + +import codezap.category.domain.Category; +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindCategoryResponse( + @Schema(description = "카테고리 식별자", example = "1") + Long id, + @Schema(description = "카테고리 이름", example = "Spring") + String name +) { + public static FindCategoryResponse from(Category category) { + return new FindCategoryResponse(category.getId(), category.getName()); + } +} diff --git a/backend/src/main/java/codezap/category/repository/CategoryRepository.java b/backend/src/main/java/codezap/category/repository/CategoryRepository.java new file mode 100644 index 000000000..0f710a2c4 --- /dev/null +++ b/backend/src/main/java/codezap/category/repository/CategoryRepository.java @@ -0,0 +1,17 @@ +package codezap.category.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.http.HttpStatus; + +import codezap.category.domain.Category; +import codezap.global.exception.CodeZapException; + +public interface CategoryRepository extends JpaRepository { + + default Category fetchById(Long id) { + return findById(id).orElseThrow( + () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 카테고리가 존재하지 않습니다.")); + } + + boolean existsByName(String name); +} diff --git a/backend/src/main/java/codezap/category/service/CategoryService.java b/backend/src/main/java/codezap/category/service/CategoryService.java new file mode 100644 index 000000000..908ce5189 --- /dev/null +++ b/backend/src/main/java/codezap/category/service/CategoryService.java @@ -0,0 +1,69 @@ +package codezap.category.service; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import codezap.category.domain.Category; +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.dto.response.FindAllCategoriesResponse; +import codezap.category.repository.CategoryRepository; +import codezap.global.exception.CodeZapException; +import codezap.template.repository.TemplateRepository; + +@Service +public class CategoryService { + + private static final long DEFAULT_CATEGORY = 1L; + private final CategoryRepository categoryRepository; + private final TemplateRepository templateRepository; + + public CategoryService(CategoryRepository categoryRepository, TemplateRepository templateRepository) { + this.categoryRepository = categoryRepository; + this.templateRepository = templateRepository; + } + + @Transactional + public Long create(CreateCategoryRequest createCategoryRequest) { + String categoryName = createCategoryRequest.name(); + validateDuplicatedCategory(categoryName); + Category category = new Category(categoryName); + return categoryRepository.save(category).getId(); + } + + public FindAllCategoriesResponse findAll() { + return FindAllCategoriesResponse.from(categoryRepository.findAll()); + } + + @Transactional + public void update(Long id, UpdateCategoryRequest updateCategoryRequest) { + validateDuplicatedCategory(updateCategoryRequest.name()); + Category category = categoryRepository.fetchById(id); + category.updateName(updateCategoryRequest.name()); + } + + private void validateDuplicatedCategory(String categoryName) { + if (categoryRepository.existsByName(categoryName)) { + throw new CodeZapException(HttpStatus.CONFLICT, "이름이 " + categoryName + "인 카테고리가 이미 존재합니다."); + } + } + + public void deleteById(Long id) { + assertNoTemplates(id); + assertDefaultCategory(id); + categoryRepository.deleteById(id); + } + + private void assertNoTemplates(Long id) { + if (templateRepository.existsByCategoryId(id)) { + throw new CodeZapException(HttpStatus.BAD_REQUEST, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."); + } + } + + private static void assertDefaultCategory(Long id) { + if (id == DEFAULT_CATEGORY) { + throw new CodeZapException(HttpStatus.BAD_REQUEST, "기본 카테고리는 삭제할 수 없습니다."); + } + } +} diff --git a/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java b/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java index b69d3863d..d451e9840 100644 --- a/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java +++ b/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java @@ -10,6 +10,7 @@ public class WebCorsConfiguration implements WebMvcConfigurer { public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") - .allowedMethods("*"); + .allowedMethods("*") + .exposedHeaders("*"); } } diff --git a/backend/src/main/java/codezap/global/validation/ValidationGroups.java b/backend/src/main/java/codezap/global/validation/ValidationGroups.java new file mode 100644 index 000000000..327e62fbb --- /dev/null +++ b/backend/src/main/java/codezap/global/validation/ValidationGroups.java @@ -0,0 +1,11 @@ +package codezap.global.validation; + +public class ValidationGroups { + public interface NotNullGroup {} + + public interface SnippetOrdinalGroup {} + + public interface SnippetCountGroup {} + + public interface SizeCheckGroup {} +} diff --git a/backend/src/main/java/codezap/global/validation/ValidationSequence.java b/backend/src/main/java/codezap/global/validation/ValidationSequence.java new file mode 100644 index 000000000..c2da469db --- /dev/null +++ b/backend/src/main/java/codezap/global/validation/ValidationSequence.java @@ -0,0 +1,16 @@ +package codezap.global.validation; + +import jakarta.validation.GroupSequence; + +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import codezap.global.validation.ValidationGroups.SnippetCountGroup; +import codezap.global.validation.ValidationGroups.SnippetOrdinalGroup; + +@GroupSequence({ + NotNullGroup.class, + SizeCheckGroup.class, + SnippetCountGroup.class, + SnippetOrdinalGroup.class}) +public interface ValidationSequence { +} diff --git a/backend/src/main/java/codezap/member/domain/Member.java b/backend/src/main/java/codezap/member/domain/Member.java index 711092d33..8023e2991 100644 --- a/backend/src/main/java/codezap/member/domain/Member.java +++ b/backend/src/main/java/codezap/member/domain/Member.java @@ -6,12 +6,13 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member { @Id diff --git a/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java b/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java index 99f1a6bdb..cd7493a51 100644 --- a/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java +++ b/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java @@ -8,7 +8,7 @@ import codezap.template.dto.request.CreateTemplateRequest; import codezap.template.dto.request.UpdateTemplateRequest; import codezap.template.dto.response.FindAllTemplatesResponse; -import codezap.template.dto.response.FindTemplateByIdResponse; +import codezap.template.dto.response.FindTemplateResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; @@ -21,7 +21,7 @@ public interface SpringDocTemplateController { @Operation(summary = "템플릿 생성", description = """ 새로운 템플릿을 생성합니다. \n - 템플릿의 제목, 썸네일 스니펫의 순서, 스니펫 목록이 필요합니다. \n + 템플릿의 제목, 썸네일 스니펫의 순서, 스니펫 목록, 카테고리 ID, 태그 목록이 필요합니다. \n 스니펫 목록은 파일 이름, 소스 코드, 해당 스니펫의 순서가 필요합니다. \n * 썸네일 스니펫은 1로 고정입니다. (2024.07.15 기준) \n * 모든 스니펫 순서는 1부터 시작합니다. \n @@ -31,7 +31,7 @@ public interface SpringDocTemplateController { @Header(name = "생성된 템플릿의 API 경로", example = "/templates/1")}) @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1", errorCases = { @ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "템플릿 이름 null 입니다."), - @ErrorCase(description = "제목 또는 스니펫 파일명이 255자를 초과한 경우", exampleMessage = "제목은 최대 255자까지 입력 가능합니다."), + @ErrorCase(description = "제목 또는 스니펫 파일 또는 태그 이름이 255자를 초과한 경우", exampleMessage = "제목은 최대 255자까지 입력 가능합니다."), @ErrorCase(description = "썸네일 스니펫의 순서가 1이 아닌 경우", exampleMessage = "썸네일 스니펫의 순서가 잘못되었습니다."), @ErrorCase(description = "스니펫 순서가 잘못된 경우", exampleMessage = "스니펫 순서가 잘못되었습니다."), @ErrorCase(description = "스니펫 내용 65,535 byte를 초과한 경우", exampleMessage = "파일 내용은 최대 65,535 byte까지 입력 가능합니다.") @@ -49,7 +49,7 @@ public interface SpringDocTemplateController { @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1", errorCases = { @ErrorCase(description = "해당하는 id 값인 템플릿이 없는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다."), }) - ResponseEntity getTemplateById(Long id); + ResponseEntity getTemplateById(Long id); @Operation(summary = "템플릿 수정", description = "해당하는 식별자의 템플릿을 수정합니다.") @ApiResponse(responseCode = "200", description = "템플릿 수정 성공") diff --git a/backend/src/main/java/codezap/template/controller/TemplateController.java b/backend/src/main/java/codezap/template/controller/TemplateController.java index 89c93323a..15298c9b5 100644 --- a/backend/src/main/java/codezap/template/controller/TemplateController.java +++ b/backend/src/main/java/codezap/template/controller/TemplateController.java @@ -2,9 +2,8 @@ import java.net.URI; -import jakarta.validation.Valid; - import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -13,10 +12,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import codezap.global.validation.ValidationSequence; import codezap.template.dto.request.CreateTemplateRequest; import codezap.template.dto.request.UpdateTemplateRequest; import codezap.template.dto.response.FindAllTemplatesResponse; -import codezap.template.dto.response.FindTemplateByIdResponse; +import codezap.template.dto.response.FindTemplateResponse; import codezap.template.service.TemplateService; @RestController @@ -30,8 +30,11 @@ public TemplateController(TemplateService templateService) { } @PostMapping - public ResponseEntity create(@Valid @RequestBody CreateTemplateRequest createTemplateRequest) { - return ResponseEntity.created(URI.create("/templates/" + templateService.create(createTemplateRequest))) + public ResponseEntity create( + @Validated(ValidationSequence.class) @RequestBody CreateTemplateRequest createTemplateRequest + ) { + Long createdTemplateId = templateService.createTemplate(createTemplateRequest); + return ResponseEntity.created(URI.create("/templates/" + createdTemplateId)) .build(); } @@ -41,14 +44,14 @@ public ResponseEntity getTemplates() { } @GetMapping("/{id}") - public ResponseEntity getTemplateById(@PathVariable Long id) { + public ResponseEntity getTemplateById(@PathVariable Long id) { return ResponseEntity.ok(templateService.findById(id)); } @PostMapping("/{id}") public ResponseEntity updateTemplate( @PathVariable Long id, - @Valid @RequestBody UpdateTemplateRequest updateTemplateRequest + @Validated(ValidationSequence.class) @RequestBody UpdateTemplateRequest updateTemplateRequest ) { templateService.update(id, updateTemplateRequest); return ResponseEntity.ok().build(); diff --git a/backend/src/main/java/codezap/template/domain/Snippet.java b/backend/src/main/java/codezap/template/domain/Snippet.java index 8cf0291a5..31974afe8 100644 --- a/backend/src/main/java/codezap/template/domain/Snippet.java +++ b/backend/src/main/java/codezap/template/domain/Snippet.java @@ -12,15 +12,16 @@ import jakarta.persistence.ManyToOne; import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@NoArgsConstructor public class Snippet extends BaseTimeEntity { - private static final String CODE_LINE_BREAK = "\n"; + private static final String LINE_BREAK = "\n"; private static final int THUMBNAIL_SNIPPET_LINE_HEIGHT = 10; @Id @@ -47,9 +48,9 @@ public Snippet(Template template, String filename, String content, Integer ordin } public String getThumbnailContent() { - return Arrays.stream(content.split(CODE_LINE_BREAK)) + return Arrays.stream(content.split(LINE_BREAK)) .limit(THUMBNAIL_SNIPPET_LINE_HEIGHT) - .collect(Collectors.joining(CODE_LINE_BREAK)); + .collect(Collectors.joining(LINE_BREAK)); } public void updateSnippet(String filename, String content, Integer ordinal) { diff --git a/backend/src/main/java/codezap/template/domain/Tag.java b/backend/src/main/java/codezap/template/domain/Tag.java new file mode 100644 index 000000000..7d6eb3d51 --- /dev/null +++ b/backend/src/main/java/codezap/template/domain/Tag.java @@ -0,0 +1,29 @@ +package codezap.template.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Tag extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + public Tag(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/codezap/template/domain/Template.java b/backend/src/main/java/codezap/template/domain/Template.java index 1178d79c1..d4f17316c 100644 --- a/backend/src/main/java/codezap/template/domain/Template.java +++ b/backend/src/main/java/codezap/template/domain/Template.java @@ -5,14 +5,17 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import codezap.category.domain.Category; import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@NoArgsConstructor public class Template extends BaseTimeEntity { @Id @@ -22,11 +25,21 @@ public class Template extends BaseTimeEntity { @Column(nullable = false) private String title; - public Template(String title) { + @Column(columnDefinition = "TEXT") + private String description; + + @ManyToOne(optional = false) + private Category category; + + public Template(String title, String description, Category category) { this.title = title; + this.description = description; + this.category = category; } - public void updateTitle(String title) { + public void updateTemplate(String title, String description, Category category) { this.title = title; + this.description = description; + this.category = category; } } diff --git a/backend/src/main/java/codezap/template/domain/TemplateTag.java b/backend/src/main/java/codezap/template/domain/TemplateTag.java new file mode 100644 index 000000000..02a94916d --- /dev/null +++ b/backend/src/main/java/codezap/template/domain/TemplateTag.java @@ -0,0 +1,52 @@ +package codezap.template.domain; + +import java.io.Serializable; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; + +import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class TemplateTag extends BaseTimeEntity { + + @Embeddable + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Getter + @EqualsAndHashCode + private static class TemplateTagId implements Serializable { + private Long templateId; + private Long tagId; + } + + @EmbeddedId + private TemplateTagId id; + + @ManyToOne + @MapsId("templateId") + @JoinColumn(name = "template_id") + private Template template; + + @ManyToOne + @MapsId("tagId") + @JoinColumn(name = "tag_id") + private Tag tag; + + public TemplateTag(Template template, Tag tag) { + this.id = new TemplateTagId(template.getId(), tag.getId()); + this.template = template; + this.tag = tag; + } +} diff --git a/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java b/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java index 8c6b5feea..47664f625 100644 --- a/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java +++ b/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java @@ -7,12 +7,13 @@ import jakarta.persistence.OneToOne; import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@NoArgsConstructor public class ThumbnailSnippet extends BaseTimeEntity { @Id diff --git a/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java b/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java index 34397eda3..cc527635d 100644 --- a/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java @@ -1,20 +1,23 @@ package codezap.template.dto.request; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; import io.swagger.v3.oas.annotations.media.Schema; public record CreateSnippetRequest( @Schema(description = "파일 이름", example = "Main.java") - @NotNull(message = "파일 이름이 null 입니다.") - @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.") + @NotBlank(message = "파일 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String filename, @Schema(description = "소스 코드", example = "public class Main { // ...") - @NotNull(message = "파일 내용이 null 입니다.") - @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.") + @NotBlank(message = "파일 내용이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) String content, @Schema(description = "스니펫 순서", example = "1") diff --git a/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java b/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java index 9663ba500..dac09639e 100644 --- a/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java @@ -3,22 +3,40 @@ import java.util.List; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; import codezap.template.dto.request.validation.ValidatedSnippetsOrdinalRequest; import io.swagger.v3.oas.annotations.media.Schema; public record CreateTemplateRequest( @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") - @NotNull(message = "템플릿 이름이 null 입니다.") - @Size(max = 255, message = "템플릿 이름은 최대 255자까지 입력 가능합니다.") + @NotBlank(message = "템플릿 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "템플릿 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String title, + @Schema(description = "템플릿 설명", example = "JWT를 사용하여 로그인 기능을 구현함") + @NotNull(message = "템플릿 설명이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "템플릿 설명은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String description, + @Schema(description = "템플릿의 스니펫 내역") - @NotNull(message = "스니펫 리스트가 null 입니다.") + @NotNull(message = "스니펫 리스트가 null 입니다.", groups = NotNullGroup.class) + @Size(min = 1, message = "스니펫은 최소 1개 입력해야 합니다.", groups = SizeCheckGroup.class) @Valid - List snippets + List snippets, + + @Schema(description = "카테고리 ID", example = "1") + @NotNull(message = "카테고리 id가 null 입니다.") + Long categoryId, + + @Schema(description = "태그 리스트") + @NotNull(message = "태그 리스트가 null 입니다.") + List tags ) implements ValidatedSnippetsOrdinalRequest { @Override public List extractSnippetsOrdinal() { diff --git a/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java b/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java index bf669729e..bce59a6d8 100644 --- a/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java @@ -1,9 +1,12 @@ package codezap.template.dto.request; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; import io.swagger.v3.oas.annotations.media.Schema; public record UpdateSnippetRequest( @@ -12,13 +15,13 @@ public record UpdateSnippetRequest( Long id, @Schema(description = "파일 이름", example = "Main.java") - @NotNull(message = "파일 이름이 null 입니다.") - @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.") + @NotBlank(message = "파일 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String filename, @Schema(description = "소스 코드", example = "public class Main { // ...") - @NotNull(message = "파일 내용이 null 입니다.") - @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.") + @NotBlank(message = "파일 내용이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) String content, @Schema(description = "스니펫 순서", example = "1") diff --git a/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java b/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java index 06e2cfcaf..e99232cfe 100644 --- a/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java @@ -3,28 +3,51 @@ import java.util.List; import java.util.stream.Stream; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import codezap.template.dto.request.validation.ValidatedSnippetsCountRequest; import codezap.template.dto.request.validation.ValidatedSnippetsOrdinalRequest; import io.swagger.v3.oas.annotations.media.Schema; public record UpdateTemplateRequest( @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") - @NotNull(message = "템플릿 이름이 null 입니다.") + @NotBlank(message = "템플릿 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "템플릿 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String title, + @Schema(description = "템플릿 설명", example = "JWT를 사용하여 로그인 기능을 구현함") + @NotNull(message = "템플릿 설명이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "템플릿 설명은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String description, + @Schema(description = "새로 추가한 스니펫 내역") - @NotNull(message = "createSnippets 리스트가 null 입니다.") + @NotNull(message = "createSnippets 리스트가 null 입니다.", groups = NotNullGroup.class) + @Valid List createSnippets, @Schema(description = "삭제, 생성 스니펫을 제외한 모든 스니펫 내역") - @NotNull(message = "updateSnippets 리스트가 null 입니다.") + @NotNull(message = "updateSnippets 리스트가 null 입니다.", groups = NotNullGroup.class) + @Valid List updateSnippets, @Schema(description = "삭제한 스니펫 식별자") @NotNull(message = "deleteSnippetIds 리스트가 null 입니다.") - List deleteSnippetIds -) implements ValidatedSnippetsOrdinalRequest { + List deleteSnippetIds, + + @Schema(description = "카테고리 ID", example = "1") + @NotNull(message = "카테고리 id가 null 입니다.") + Long categoryId, + + @Schema(description = "태그 리스트") + @NotNull(message = "태그 리스트가 null 입니다.") + List tags +) implements ValidatedSnippetsOrdinalRequest, ValidatedSnippetsCountRequest { @Override public List extractSnippetsOrdinal() { return Stream.concat( @@ -32,4 +55,9 @@ public List extractSnippetsOrdinal() { createSnippets.stream().map(CreateSnippetRequest::ordinal) ).toList(); } + + @Override + public Integer countSnippets() { + return updateSnippets.size() + createSnippets.size(); + } } diff --git a/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCount.java b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCount.java new file mode 100644 index 000000000..86f41a911 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCount.java @@ -0,0 +1,21 @@ +package codezap.template.dto.request.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = SnippetsCountValidator.class) +public @interface SnippetsCount { + + String message(); + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCountValidator.java b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCountValidator.java new file mode 100644 index 000000000..a68729f6d --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCountValidator.java @@ -0,0 +1,15 @@ +package codezap.template.dto.request.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class SnippetsCountValidator implements ConstraintValidator { + + @Override + public boolean isValid(ValidatedSnippetsCountRequest validatedSnippetsCountRequest, + ConstraintValidatorContext constraintValidatorContext + ) { + Integer snippetsCount = validatedSnippetsCountRequest.countSnippets(); + return snippetsCount > 0; + } +} diff --git a/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsCountRequest.java b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsCountRequest.java new file mode 100644 index 000000000..2de66a8b7 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsCountRequest.java @@ -0,0 +1,9 @@ +package codezap.template.dto.request.validation; + +import codezap.global.validation.ValidationGroups.SnippetCountGroup; + +@SnippetsCount(message = "스니펫은 최소 1개 입력해야 합니다.", groups = SnippetCountGroup.class) +public interface ValidatedSnippetsCountRequest { + + Integer countSnippets(); +} diff --git a/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java index 5adb86fc6..7a09a5f06 100644 --- a/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java @@ -2,7 +2,9 @@ import java.util.List; -@SnippetsOrdinal(message = "스니펫 순서가 잘못되었습니다.") +import codezap.global.validation.ValidationGroups.SnippetOrdinalGroup; + +@SnippetsOrdinal(message = "스니펫 순서가 잘못되었습니다.", groups = SnippetOrdinalGroup.class) public interface ValidatedSnippetsOrdinalRequest { List extractSnippetsOrdinal(); diff --git a/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java b/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java index 34815a2f7..d2520a934 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java @@ -6,10 +6,13 @@ public record FindAllSnippetByTemplateResponse( @Schema(description = "파일 식별자", example = "0") Long id, + @Schema(description = "파일 이름", example = "Main.java") String filename, + @Schema(description = "소스 코드", example = "public class Main { // ...") String content, + @Schema(description = "스니펫의 순서", example = "1") int ordinal ) { diff --git a/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java b/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java new file mode 100644 index 000000000..08a167a37 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java @@ -0,0 +1,16 @@ +package codezap.template.dto.response; + +import codezap.template.domain.Tag; +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindTagResponse( + @Schema(description = "태그 식별자", example = "1") + Long id, + + @Schema(description = "태그 이름", example = "스프링") + String name +) { + public static FindTagResponse from(Tag tag) { + return new FindTagResponse(tag.getId(), tag.getName()); + } +} diff --git a/backend/src/main/java/codezap/template/dto/response/FindTemplateByIdResponse.java b/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java similarity index 54% rename from backend/src/main/java/codezap/template/dto/response/FindTemplateByIdResponse.java rename to backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java index 6fccceb7c..42fe1f294 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindTemplateByIdResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java @@ -3,25 +3,42 @@ import java.time.LocalDateTime; import java.util.List; +import codezap.category.dto.response.FindCategoryResponse; import codezap.template.domain.Snippet; +import codezap.template.domain.Tag; import codezap.template.domain.Template; import io.swagger.v3.oas.annotations.media.Schema; -public record FindTemplateByIdResponse( +public record FindTemplateResponse( @Schema(description = "템플릿 식별자", example = "0") Long id, + @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") String title, + + @Schema(description = "템플릿 설명", example = "JWT를 사용하여 로그인 기능을 구현함") + String description, + @Schema(description = "스니펫 목록") List snippets, + + @Schema(description = "카테고리 정보") + FindCategoryResponse category, + + @Schema(description = "태그 목록") + List tags, + @Schema(description = "템플릿 수정 시간", example = "2024-11-11 12:00", type = "string") LocalDateTime modifiedAt ) { - public static FindTemplateByIdResponse of(Template template, List snippets) { - return new FindTemplateByIdResponse( + public static FindTemplateResponse of(Template template, List snippets, List tags) { + return new FindTemplateResponse( template.getId(), template.getTitle(), + template.getDescription(), mapToFindAllSnippetByTemplateResponse(snippets), + FindCategoryResponse.from(template.getCategory()), + mapToFindTagByTemplateResponse(tags), template.getModifiedAt() ); } @@ -33,4 +50,10 @@ private static List mapToFindAllSnippetByTempl .map(FindAllSnippetByTemplateResponse::from) .toList(); } + + private static List mapToFindTagByTemplateResponse(List tags) { + return tags.stream() + .map(FindTagResponse::from) + .toList(); + } } diff --git a/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java b/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java index 1cdf7303e..de47fbe4f 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java @@ -6,6 +6,7 @@ public record FindThumbnailSnippetResponse( @Schema(description = "파일 이름", example = "Main.java") String filename, + @Schema(description = "목록 조회 시 보여질 코드", example = "public class Main { // ...") String thumbnailContent ) { diff --git a/backend/src/main/java/codezap/template/repository/SnippetRepository.java b/backend/src/main/java/codezap/template/repository/SnippetRepository.java index 898b5d8ba..bfe6da88e 100644 --- a/backend/src/main/java/codezap/template/repository/SnippetRepository.java +++ b/backend/src/main/java/codezap/template/repository/SnippetRepository.java @@ -1,6 +1,7 @@ package codezap.template.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.http.HttpStatus; @@ -17,7 +18,7 @@ default Snippet fetchById(Long id) { List findAllByTemplate(Template template); - Snippet findByTemplateAndOrdinal(Template template, int ordinal); + Optional findByTemplateAndOrdinal(Template template, int ordinal); List findAllByTemplateAndOrdinal(Template template, int ordinal); diff --git a/backend/src/main/java/codezap/template/repository/TagRepository.java b/backend/src/main/java/codezap/template/repository/TagRepository.java new file mode 100644 index 000000000..8eb30c93e --- /dev/null +++ b/backend/src/main/java/codezap/template/repository/TagRepository.java @@ -0,0 +1,13 @@ +package codezap.template.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import codezap.template.domain.Tag; + +public interface TagRepository extends JpaRepository { + boolean existsByName(String name); + + Optional findByName(String name); +} diff --git a/backend/src/main/java/codezap/template/repository/TemplateRepository.java b/backend/src/main/java/codezap/template/repository/TemplateRepository.java index 47b00b246..127eef4cf 100644 --- a/backend/src/main/java/codezap/template/repository/TemplateRepository.java +++ b/backend/src/main/java/codezap/template/repository/TemplateRepository.java @@ -12,4 +12,6 @@ default Template fetchById(Long id) { return findById(id).orElseThrow( () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 템플릿이 존재하지 않습니다.")); } + + boolean existsByCategoryId(Long categoryId); } diff --git a/backend/src/main/java/codezap/template/repository/TemplateTagRepository.java b/backend/src/main/java/codezap/template/repository/TemplateTagRepository.java new file mode 100644 index 000000000..a0de2a4fb --- /dev/null +++ b/backend/src/main/java/codezap/template/repository/TemplateTagRepository.java @@ -0,0 +1,15 @@ +package codezap.template.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import codezap.template.domain.Template; +import codezap.template.domain.TemplateTag; + +public interface TemplateTagRepository extends JpaRepository { + + List findAllByTemplate(Template template); + + void deleteAllByTemplateId(Long id); +} diff --git a/backend/src/main/java/codezap/template/repository/ThumbnailSnippetRepository.java b/backend/src/main/java/codezap/template/repository/ThumbnailSnippetRepository.java index bb836ed6e..a8fa02650 100644 --- a/backend/src/main/java/codezap/template/repository/ThumbnailSnippetRepository.java +++ b/backend/src/main/java/codezap/template/repository/ThumbnailSnippetRepository.java @@ -1,12 +1,14 @@ package codezap.template.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import codezap.template.domain.Template; import codezap.template.domain.ThumbnailSnippet; public interface ThumbnailSnippetRepository extends JpaRepository { - ThumbnailSnippet findByTemplate(Template template); + Optional findByTemplate(Template template); void deleteByTemplateId(Long id); } diff --git a/backend/src/main/java/codezap/template/service/TemplateService.java b/backend/src/main/java/codezap/template/service/TemplateService.java index 57be444c7..281c9a08f 100644 --- a/backend/src/main/java/codezap/template/service/TemplateService.java +++ b/backend/src/main/java/codezap/template/service/TemplateService.java @@ -3,72 +3,128 @@ import java.util.List; import java.util.Objects; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import codezap.category.domain.Category; +import codezap.category.repository.CategoryRepository; +import codezap.global.exception.CodeZapException; import codezap.template.domain.Snippet; +import codezap.template.domain.Tag; import codezap.template.domain.Template; +import codezap.template.domain.TemplateTag; import codezap.template.domain.ThumbnailSnippet; import codezap.template.dto.request.CreateSnippetRequest; import codezap.template.dto.request.CreateTemplateRequest; import codezap.template.dto.request.UpdateSnippetRequest; import codezap.template.dto.request.UpdateTemplateRequest; import codezap.template.dto.response.FindAllTemplatesResponse; -import codezap.template.dto.response.FindTemplateByIdResponse; +import codezap.template.dto.response.FindTemplateResponse; import codezap.template.repository.SnippetRepository; +import codezap.template.repository.TagRepository; import codezap.template.repository.TemplateRepository; +import codezap.template.repository.TemplateTagRepository; import codezap.template.repository.ThumbnailSnippetRepository; @Service public class TemplateService { - public static final int FIRST_ORDINAL = 1; + private static final int FIRST_ORDINAL = 1; private final ThumbnailSnippetRepository thumbnailSnippetRepository; private final TemplateRepository templateRepository; private final SnippetRepository snippetRepository; + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + private final TemplateTagRepository templateTagRepository; public TemplateService(ThumbnailSnippetRepository thumbnailSnippetRepository, - TemplateRepository templateRepository, SnippetRepository snippetRepository + TemplateRepository templateRepository, SnippetRepository snippetRepository, + CategoryRepository categoryRepository, TagRepository tagRepository, + TemplateTagRepository templateTagRepository ) { this.thumbnailSnippetRepository = thumbnailSnippetRepository; this.templateRepository = templateRepository; this.snippetRepository = snippetRepository; + this.categoryRepository = categoryRepository; + this.tagRepository = tagRepository; + this.templateTagRepository = templateTagRepository; } @Transactional - public Long create(CreateTemplateRequest createTemplateRequest) { + public Long createTemplate(CreateTemplateRequest createTemplateRequest) { + Category category = categoryRepository.fetchById(createTemplateRequest.categoryId()); Template template = templateRepository.save( - new Template(createTemplateRequest.title())); - - createTemplateRequest.snippets() - .forEach(createSnippetRequest -> createSnippet(createSnippetRequest, template)); + new Template(createTemplateRequest.title(), createTemplateRequest.description(), category) + ); + createTags(createTemplateRequest, template); + snippetRepository.saveAll( + createTemplateRequest.snippets().stream() + .map(createSnippetRequest -> createSnippet(createSnippetRequest, template)) + .toList() + ); - Snippet thumbnailSnippet = snippetRepository.findByTemplateAndOrdinal(template, FIRST_ORDINAL); + Snippet thumbnailSnippet = snippetRepository.findByTemplateAndOrdinal(template, FIRST_ORDINAL) + .orElseThrow(this::throwNotFoundSnippet); thumbnailSnippetRepository.save(new ThumbnailSnippet(template, thumbnailSnippet)); return template.getId(); } + private void createTags(CreateTemplateRequest createTemplateRequest, Template template) { + tagRepository.saveAll( + createTemplateRequest.tags().stream() + .filter(tagName -> !tagRepository.existsByName(tagName)) + .map(Tag::new) + .toList() + ); + + templateTagRepository.saveAll( + createTemplateRequest.tags().stream() + .map(tag -> tagRepository.findByName(tag).orElseThrow(this::throwNotFoundTag)) + .map(tag -> new TemplateTag(template, tag)) + .toList() + ); + } + + private Snippet createSnippet(CreateSnippetRequest createSnippetRequest, Template template) { + return new Snippet( + template, createSnippetRequest.filename(), + createSnippetRequest.content(), + createSnippetRequest.ordinal() + ); + } + public FindAllTemplatesResponse findAll() { return FindAllTemplatesResponse.from(thumbnailSnippetRepository.findAll()); } - public FindTemplateByIdResponse findById(Long id) { + public FindTemplateResponse findById(Long id) { Template template = templateRepository.fetchById(id); List snippets = snippetRepository.findAllByTemplate(template); - return FindTemplateByIdResponse.of(template, snippets); + List tags = templateTagRepository.findAllByTemplate(template).stream() + .map(TemplateTag::getTag) + .toList(); + return FindTemplateResponse.of(template, snippets, tags); } @Transactional public void update(Long templateId, UpdateTemplateRequest updateTemplateRequest) { + Category category = categoryRepository.fetchById(updateTemplateRequest.categoryId()); Template template = templateRepository.fetchById(templateId); - template.updateTitle(updateTemplateRequest.title()); + template.updateTemplate(updateTemplateRequest.title(), updateTemplateRequest.description(), category); + updateSnippets(updateTemplateRequest, template); + updateTags(updateTemplateRequest, template); + validateSnippetsCount(updateTemplateRequest, template); + } + private void updateSnippets(UpdateTemplateRequest updateTemplateRequest, Template template) { updateTemplateRequest.updateSnippets().forEach(this::updateSnippet); updateTemplateRequest.createSnippets() .forEach(createSnippetRequest -> createSnippet(createSnippetRequest, template)); - ThumbnailSnippet thumbnailSnippet = thumbnailSnippetRepository.findByTemplate(template); + ThumbnailSnippet thumbnailSnippet = thumbnailSnippetRepository.findByTemplate(template) + .orElseThrow(this::throwNotFoundThumbnailSnippet); if (isThumbnailSnippetDeleted(updateTemplateRequest, thumbnailSnippet)) { updateThumbnailSnippet(template, thumbnailSnippet); @@ -77,11 +133,10 @@ public void update(Long templateId, UpdateTemplateRequest updateTemplateRequest) updateTemplateRequest.deleteSnippetIds().forEach(snippetRepository::deleteById); } - @Transactional - public void deleteById(Long id) { - thumbnailSnippetRepository.deleteByTemplateId(id); - snippetRepository.deleteByTemplateId(id); - templateRepository.deleteById(id); + private void updateSnippet(UpdateSnippetRequest updateSnippetRequest) { + Snippet snippet = snippetRepository.fetchById(updateSnippetRequest.id()); + snippet.updateSnippet(updateSnippetRequest.filename(), updateSnippetRequest.content(), + updateSnippetRequest.ordinal()); } private static boolean isThumbnailSnippetDeleted( @@ -99,19 +154,47 @@ private void updateThumbnailSnippet(Template template, ThumbnailSnippet thumbnai .ifPresent(thumbnailSnippet::updateThumbnailSnippet); } - private void createSnippet(CreateSnippetRequest createSnippetRequest, Template template) { - snippetRepository.save( - new Snippet( - template, createSnippetRequest.filename(), - createSnippetRequest.content(), - createSnippetRequest.ordinal() - ) + private void updateTags(UpdateTemplateRequest updateTemplateRequest, Template template) { + templateTagRepository.deleteAllByTemplateId(template.getId()); + tagRepository.saveAll( + updateTemplateRequest.tags().stream() + .filter(tagName -> !tagRepository.existsByName(tagName)) + .map(Tag::new) + .toList() + ); + + templateTagRepository.saveAll( + updateTemplateRequest.tags().stream() + .map(tag -> tagRepository.findByName(tag).orElseThrow(this::throwNotFoundTag)) + .map(tag -> new TemplateTag(template, tag)) + .toList() ); } - private void updateSnippet(UpdateSnippetRequest updateSnippetRequest) { - Snippet snippet = snippetRepository.fetchById(updateSnippetRequest.id()); - snippet.updateSnippet(updateSnippetRequest.filename(), updateSnippetRequest.content(), - updateSnippetRequest.ordinal()); + private void validateSnippetsCount(UpdateTemplateRequest updateTemplateRequest, Template template) { + if (updateTemplateRequest.updateSnippets().size() + updateTemplateRequest.createSnippets().size() + != snippetRepository.findAllByTemplate(template).size()) { + throw new CodeZapException(HttpStatus.BAD_REQUEST, "스니펫의 정보가 정확하지 않습니다."); + } + } + + @Transactional + public void deleteById(Long id) { + thumbnailSnippetRepository.deleteByTemplateId(id); + snippetRepository.deleteByTemplateId(id); + templateTagRepository.deleteAllByTemplateId(id); + templateRepository.deleteById(id); + } + + private CodeZapException throwNotFoundSnippet() { + throw new CodeZapException(HttpStatus.NOT_FOUND, "해당하는 스니펫이 존재하지 않습니다."); + } + + private CodeZapException throwNotFoundTag() { + throw new CodeZapException(HttpStatus.BAD_REQUEST, "해당하는 태그가 존재하지 않습니다."); + } + + private CodeZapException throwNotFoundThumbnailSnippet() { + throw new CodeZapException(HttpStatus.BAD_REQUEST, "해당하는 썸네일 스니펫이 존재하지 않습니다."); } } diff --git a/backend/src/test/java/codezap/category/controller/CategoryControllerTest.java b/backend/src/test/java/codezap/category/controller/CategoryControllerTest.java new file mode 100644 index 000000000..224efb7bc --- /dev/null +++ b/backend/src/test/java/codezap/category/controller/CategoryControllerTest.java @@ -0,0 +1,204 @@ +package codezap.category.controller; + +import static org.hamcrest.Matchers.is; + +import java.util.List; + +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.service.CategoryService; +import codezap.template.dto.request.CreateSnippetRequest; +import codezap.template.dto.request.CreateTemplateRequest; +import codezap.template.service.TemplateService; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Sql(value = "/clear.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(value = "/clear.sql", executionPhase = ExecutionPhase.AFTER_TEST_CLASS) +class CategoryControllerTest { + + private static final int MAX_LENGTH = 255; + + @LocalServerPort + int port; + + @Autowired + private CategoryService categoryService; + @Autowired + private TemplateService templateService; + + @BeforeEach + void setting() { + RestAssured.port = port; + } + + @Nested + @DisplayName("카테고리 생성 테스트") + class createCategoryTest { + @Test + @DisplayName("카테고리 생성 성공") + void createCategorySuccess() { + CreateCategoryRequest createCategoryRequest = new CreateCategoryRequest("a".repeat(MAX_LENGTH)); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(createCategoryRequest) + .when().post("/categories") + .then().log().all() + .header("Location", "/categories/1") + .statusCode(201); + } + + @Test + @DisplayName("카테고리 생성 실패: 카테고리 이름 길이 초과") + void createCategoryFailWithLongName() { + CreateCategoryRequest createCategoryRequest = new CreateCategoryRequest("a".repeat(MAX_LENGTH + 1)); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(createCategoryRequest) + .when().post("/categories") + .then().log().all() + .statusCode(400) + .body("detail", is("카테고리 이름은 최대 255자까지 입력 가능합니다.")); + } + } + + @Test + @DisplayName("카테고리 전체 조회 성공") + void findAllCategoriesSuccess() { + CreateCategoryRequest createCategoryRequest1 = new CreateCategoryRequest("category1"); + CreateCategoryRequest createCategoryRequest2 = new CreateCategoryRequest("category2"); + categoryService.create(createCategoryRequest1); + categoryService.create(createCategoryRequest2); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().get("/categories") + .then().log().all() + .statusCode(200) + .body("categories.size()", is(2)); + } + + @Nested + @DisplayName("카테고리 수정 테스트") + class updateCategoryTest { + + Long savedCategoryId; + + @BeforeEach + void saveCategory() { + savedCategoryId = categoryService.create(new CreateCategoryRequest("category1")); + } + + @Test + @DisplayName("카테고리 수정 성공") + void updateCategorySuccess() { + UpdateCategoryRequest updateCategoryRequest = new UpdateCategoryRequest("a".repeat(MAX_LENGTH)); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(updateCategoryRequest) + .when().put("/categories/" + savedCategoryId) + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("카테고리 수정 실패: 카테고리 이름 길이 초과") + void updateCategoryFailWithLongName() { + UpdateCategoryRequest updateCategoryRequest = new UpdateCategoryRequest("a".repeat(MAX_LENGTH + 1)); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(updateCategoryRequest) + .when().put("/categories/" + savedCategoryId) + .then().log().all() + .statusCode(400) + .body("detail", is("카테고리 이름은 최대 255자까지 입력 가능합니다.")); + } + + @Test + @DisplayName("카테고리 수정 실패: 중복된 이름의 카테고리 존재") + void updateCategoryFailWithDuplicatedName() { + // given + String duplicatedName = "duplicatedName"; + categoryService.create(new CreateCategoryRequest(duplicatedName)); + + UpdateCategoryRequest createCategoryRequest = new UpdateCategoryRequest(duplicatedName); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(createCategoryRequest) + .when().put("/categories/" + savedCategoryId) + .then().log().all() + .statusCode(409) + .body("detail", is("이름이 " + duplicatedName + "인 카테고리가 이미 존재합니다.")); + } + } + + + @Nested + @DisplayName("카테고리 삭제 테스트") + class deleteCategoryTest { + + Long savedCategoryId; + + @BeforeEach + void saveCategory() { + categoryService.create(new CreateCategoryRequest("category1")); + savedCategoryId = categoryService.create(new CreateCategoryRequest("category2")); + } + + @Test + @DisplayName("카테고리 삭제 성공") + void deleteCategorySuccess() { + RestAssured.given().log().all() + .when().delete("/categories/" + savedCategoryId) + .then().log().all() + .statusCode(204); + } + + @Test + @DisplayName("카테고리 수정 성공: 존재하지 않는 카테고리의 삭제 요청") + void updateCategoryFailWithDuplicatedName() { + RestAssured.given().log().all() + .when().delete("/categories/" + savedCategoryId + 1) + .then().log().all() + .statusCode(204); + } + + @Test + @DisplayName("카테고리 삭제 실패: 템플릿이 존재하는 카테고리는 삭제 불가능") + void updateCategoryFailWithLongName() { + // given + templateService.createTemplate(new CreateTemplateRequest( + "title", + "description", + List.of(new CreateSnippetRequest("filename", "content", 1)), + savedCategoryId, + List.of("tag1", "tag2") + )); + + // when & then + RestAssured.given().log().all() + .when().delete("/categories/" + savedCategoryId) + .then().log().all() + .statusCode(400) + .body("detail", is("템플릿이 존재하는 카테고리는 삭제할 수 없습니다.")); + } + } +} diff --git a/backend/src/test/java/codezap/category/service/CategoryServiceTest.java b/backend/src/test/java/codezap/category/service/CategoryServiceTest.java new file mode 100644 index 000000000..5dd77c66a --- /dev/null +++ b/backend/src/test/java/codezap/category/service/CategoryServiceTest.java @@ -0,0 +1,106 @@ +package codezap.category.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import codezap.category.domain.Category; +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.dto.response.FindAllCategoriesResponse; +import codezap.category.repository.CategoryRepository; +import codezap.global.exception.CodeZapException; +import io.restassured.RestAssured; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Sql(value = "/clear.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(value = "/clear.sql", executionPhase = ExecutionPhase.AFTER_TEST_CLASS) +class CategoryServiceTest { + + @LocalServerPort + int port; + + @Autowired + private CategoryService categoryService; + + @Autowired + private CategoryRepository categoryRepository; + + @BeforeEach + void setting() { + RestAssured.port = port; + } + + @Nested + @DisplayName("카테고리 생성 테스트") + class createCategoryTest { + @Test + @DisplayName("카테고리 생성 성공") + void createCategorySuccess() { + CreateCategoryRequest createCategoryRequest = new CreateCategoryRequest("category1"); + + Long categoryId = categoryService.create(createCategoryRequest); + + assertThat(categoryId).isEqualTo(1L); + } + + @Test + @DisplayName("카테고리 생성 실패: 중복된 이름의 카테고리 이름 생성") + void createCategoryFailWithDuplicateName() { + categoryRepository.save(new Category("category")); + CreateCategoryRequest createCategoryRequest = new CreateCategoryRequest("category"); + + assertThatThrownBy(() -> categoryService.create(createCategoryRequest)) + .isInstanceOf(CodeZapException.class) + .hasMessage("이름이 " + createCategoryRequest.name() + "인 카테고리가 이미 존재합니다."); + } + } + + @Test + @DisplayName("카테고리 전체 조회 테스트") + void findAllCategoriesSuccess() { + categoryRepository.save(new Category("category1")); + categoryRepository.save(new Category("category2")); + + FindAllCategoriesResponse findAllCategoriesResponse = categoryService.findAll(); + + assertThat(findAllCategoriesResponse.categories()).hasSize(2); + } + + @Test + @DisplayName("카테고리 수정 성공") + void updateCategorySuccess() { + // given + Category savedCategory = categoryRepository.save(new Category("category1")); + + // when + categoryService.update(savedCategory.getId(), new UpdateCategoryRequest("updateName")); + + // then + assertThat(categoryRepository.fetchById(savedCategory.getId()).getName()).isEqualTo("updateName"); + } + + @Test + @DisplayName("카테고리 삭제 성공") + void deleteCategorySuccess() { + // given + categoryRepository.save(new Category("category1")); + Category savedCategory = categoryRepository.save(new Category("category1")); + + // when + categoryService.deleteById(savedCategory.getId()); + + // then + assertThat(categoryRepository.findById(savedCategory.getId())).isEmpty(); + } +} diff --git a/backend/src/test/java/codezap/template/controller/TemplateControllerTest.java b/backend/src/test/java/codezap/template/controller/TemplateControllerTest.java index 12833e4ef..217695f9d 100644 --- a/backend/src/test/java/codezap/template/controller/TemplateControllerTest.java +++ b/backend/src/test/java/codezap/template/controller/TemplateControllerTest.java @@ -17,6 +17,8 @@ import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.Sql.ExecutionPhase; +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.service.CategoryService; import codezap.template.dto.request.CreateSnippetRequest; import codezap.template.dto.request.CreateTemplateRequest; import codezap.template.dto.request.UpdateSnippetRequest; @@ -35,6 +37,9 @@ class TemplateControllerTest { @Autowired private TemplateService templateService; + @Autowired + private CategoryService categoryService; + @LocalServerPort int port; @@ -52,9 +57,15 @@ class createTemplateTest { @CsvSource({"a, 65535", "ㄱ, 21845"}) void createTemplateSuccess(String repeatTarget, int maxLength) { String maxTitle = "a".repeat(MAX_LENGTH); - CreateTemplateRequest templateRequest = new CreateTemplateRequest(maxTitle, - List.of(new CreateSnippetRequest("a".repeat(MAX_LENGTH), repeatTarget.repeat(maxLength), 1))); - + categoryService.create(new CreateCategoryRequest("category")); + CreateTemplateRequest templateRequest = new CreateTemplateRequest( + maxTitle, + repeatTarget.repeat(maxLength), + List.of(new CreateSnippetRequest("a".repeat(MAX_LENGTH), repeatTarget.repeat(maxLength), 1)), + 1L, + List.of("tag1", "tag2") + ); + RestAssured.given().log().all() .contentType(ContentType.JSON) .body(templateRequest) @@ -68,9 +79,15 @@ void createTemplateSuccess(String repeatTarget, int maxLength) { @DisplayName("템플릿 생성 실패: 템플릿 이름 길이 초과") void createTemplateFailWithLongTitle() { String exceededTitle = "a".repeat(MAX_LENGTH + 1); - CreateTemplateRequest templateRequest = new CreateTemplateRequest(exceededTitle, - List.of(new CreateSnippetRequest("a", "content", 1))); - + categoryService.create(new CreateCategoryRequest("category")); + CreateTemplateRequest templateRequest = new CreateTemplateRequest( + exceededTitle, + "description", + List.of(new CreateSnippetRequest("a", "content", 1)), + 1L, + List.of("tag1", "tag2") + ); + RestAssured.given().log().all() .contentType(ContentType.JSON) .body(templateRequest) @@ -84,8 +101,14 @@ void createTemplateFailWithLongTitle() { @DisplayName("템플릿 생성 실패: 파일 이름 길이 초과") void createTemplateFailWithLongFileName() { String exceededTitle = "a".repeat(MAX_LENGTH + 1); - CreateTemplateRequest templateRequest = new CreateTemplateRequest("title", - List.of(new CreateSnippetRequest(exceededTitle, "content", 1))); + categoryService.create(new CreateCategoryRequest("category")); + CreateTemplateRequest templateRequest = new CreateTemplateRequest( + "title", + "description", + List.of(new CreateSnippetRequest(exceededTitle, "content", 1)), + 1L, + List.of("tag1", "tag2") + ); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -100,8 +123,14 @@ void createTemplateFailWithLongFileName() { @DisplayName("템플릿 생성 실패: 파일 내용 길이 초과") @CsvSource({"a, 65536", "ㄱ, 21846"}) void createTemplateFailWithLongContent(String repeatTarget, int exceededLength) { - CreateTemplateRequest templateRequest = new CreateTemplateRequest("title", - List.of(new CreateSnippetRequest("title", repeatTarget.repeat(exceededLength), 1))); + categoryService.create(new CreateCategoryRequest("category")); + CreateTemplateRequest templateRequest = new CreateTemplateRequest( + "title", + "description", + List.of(new CreateSnippetRequest("title", repeatTarget.repeat(exceededLength), 1)), + 1L, + List.of("tag1", "tag2") + ); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -112,13 +141,41 @@ void createTemplateFailWithLongContent(String repeatTarget, int exceededLength) .body("detail", is("파일 내용은 최대 65,535 Byte까지 입력 가능합니다.")); } + @ParameterizedTest + @DisplayName("템플릿 생성 실패: 템플릿 설명 길이 초과") + @CsvSource({"a, 65536", "ㄱ, 21846"}) + void createTemplateFailWithLongDescription(String repeatTarget, int exceededLength) { + categoryService.create(new CreateCategoryRequest("category")); + CreateTemplateRequest templateRequest = new CreateTemplateRequest( + "title", + repeatTarget.repeat(exceededLength), + List.of(new CreateSnippetRequest("title", "content", 1)), + 1L, + List.of("tag1", "tag2") + ); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(templateRequest) + .when().post("/templates") + .then().log().all() + .statusCode(400) + .body("detail", is("템플릿 설명은 최대 65,535 Byte까지 입력 가능합니다.")); + } + @ParameterizedTest @DisplayName("템플릿 생성 실패: 잘못된 스니펫 순서 입력") @CsvSource({"0, 1", "1, 3", "2, 1"}) void createTemplateFailWithWrongSnippetOrdinal(int firstIndex, int secondIndex) { - CreateTemplateRequest templateRequest = new CreateTemplateRequest("title", + categoryService.create(new CreateCategoryRequest("category")); + CreateTemplateRequest templateRequest = new CreateTemplateRequest( + "title", + "description", List.of(new CreateSnippetRequest("title", "content", firstIndex), - new CreateSnippetRequest("title", "content", secondIndex))); + new CreateSnippetRequest("title", "content", secondIndex)), + 1L, + List.of("tag1", "tag2") + ); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -134,10 +191,11 @@ void createTemplateFailWithWrongSnippetOrdinal(int firstIndex, int secondIndex) @DisplayName("템플릿 전체 조회 성공") void findAllTemplatesSuccess() { // given + categoryService.create(new CreateCategoryRequest("category")); CreateTemplateRequest templateRequest1 = createTemplateRequestWithTwoSnippets("title1"); CreateTemplateRequest templateRequest2 = createTemplateRequestWithTwoSnippets("title2"); - templateService.create(templateRequest1); - templateService.create(templateRequest2); + templateService.createTemplate(templateRequest1); + templateService.createTemplate(templateRequest2); // when & then RestAssured.given().log().all() @@ -155,8 +213,9 @@ class findTemplateTest { @DisplayName("템플릿 상세 조회 성공") void findOneTemplateSuccess() { // given + categoryService.create(new CreateCategoryRequest("category")); CreateTemplateRequest templateRequest = createTemplateRequestWithTwoSnippets("title"); - templateService.create(templateRequest); + templateService.createTemplate(templateRequest); // when & then RestAssured.given().log().all() @@ -164,7 +223,10 @@ void findOneTemplateSuccess() { .then().log().all() .statusCode(200) .body("title", is(templateRequest.title()), - "snippets.size()", is(2)); + "snippets.size()", is(2), + "category.id", is(1), + "category.name", is("category"), + "tags.size()", is(2)); } @Test @@ -187,11 +249,10 @@ class updateTemplateTest { @DisplayName("템플릿 수정 성공") void updateTemplateSuccess() { // given - CreateTemplateRequest templateRequest = createTemplateRequestWithTwoSnippets("title"); - templateService.create(templateRequest); - + createTemplateAndTwoCategories(); UpdateTemplateRequest updateTemplateRequest = new UpdateTemplateRequest( "updateTitle", + "description", List.of( new CreateSnippetRequest("filename3", "content3", 2), new CreateSnippetRequest("filename4", "content4", 3) @@ -199,7 +260,9 @@ void updateTemplateSuccess() { List.of( new UpdateSnippetRequest(2L, "updateFilename2", "updateContent2", 1) ), - List.of(1L) + List.of(1L), + 2L, + List.of("tag1", "tag3") ); // when & then @@ -211,17 +274,140 @@ void updateTemplateSuccess() { .statusCode(200); } + @Test + @DisplayName("템플릿 수정 실패: 템플릿 이름 길이 초과") + void updateTemplateFailWithLongName() { + // given + String exceededTitle = "a".repeat(MAX_LENGTH + 1); + createTemplateAndTwoCategories(); + UpdateTemplateRequest updateTemplateRequest = new UpdateTemplateRequest( + exceededTitle, + "description", + List.of( + new CreateSnippetRequest("filename3", "content3", 2), + new CreateSnippetRequest("filename4", "content4", 3) + ), + List.of( + new UpdateSnippetRequest(2L, "updateFilename2", "updateContent2", 1) + ), + List.of(1L), + 2L, + List.of("tag1", "tag3") + ); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(updateTemplateRequest) + .when().post("/templates/1") + .then().log().all() + .statusCode(400) + .body("detail", is("템플릿 이름은 최대 255자까지 입력 가능합니다.")); + } + + @Test + @DisplayName("템플릿 생성 실패: 파일 이름 길이 초과") + void updateTemplateFailWithLongFileName() { + // given + String exceededTitle = "a".repeat(MAX_LENGTH + 1); + createTemplateAndTwoCategories(); + UpdateTemplateRequest updateTemplateRequest = new UpdateTemplateRequest( + "title", + "description", + List.of( + new CreateSnippetRequest(exceededTitle, "content3", 2), + new CreateSnippetRequest("filename4", "content4", 3) + ), + List.of( + new UpdateSnippetRequest(2L, "updateFilename2", "updateContent2", 1) + ), + List.of(1L), + 2L, + List.of("tag1", "tag3") + ); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(updateTemplateRequest) + .when().post("/templates/1") + .then().log().all() + .statusCode(400) + .body("detail", is("파일 이름은 최대 255자까지 입력 가능합니다.")); + } + + @ParameterizedTest + @DisplayName("템플릿 생성 실패: 파일 내용 길이 초과") + @CsvSource({"a, 65536", "ㄱ, 21846"}) + void updateTemplateFailWithLongFileContent(String repeatTarget, int exceedLength) { + // given + createTemplateAndTwoCategories(); + UpdateTemplateRequest updateTemplateRequest = new UpdateTemplateRequest( + "title", + "description", + List.of( + new CreateSnippetRequest("filename3", repeatTarget.repeat(exceedLength), 2), + new CreateSnippetRequest("filename4", "content4", 3) + ), + List.of( + new UpdateSnippetRequest(2L, "updateFilename2", "updateContent2", 1) + ), + List.of(1L), + 2L, + List.of("tag1", "tag3") + ); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(updateTemplateRequest) + .when().post("/templates/1") + .then().log().all() + .statusCode(400) + .body("detail", is("파일 내용은 최대 65,535 Byte까지 입력 가능합니다.")); + } + + @ParameterizedTest + @DisplayName("템플릿 생성 실패: 템플릿 설명 길이 초과") + @CsvSource({"a, 65536", "ㄱ, 21846"}) + void updateTemplateFailWithLongContent(String repeatTarget, int exceedLength) { + // given + createTemplateAndTwoCategories(); + UpdateTemplateRequest updateTemplateRequest = new UpdateTemplateRequest( + "title", + repeatTarget.repeat(exceedLength), + List.of( + new CreateSnippetRequest("filename3", "content3", 2), + new CreateSnippetRequest("filename4", "content4", 3) + ), + List.of( + new UpdateSnippetRequest(2L, "updateFilename2", "updateContent2", 1) + ), + List.of(1L), + 2L, + List.of("tag1", "tag3") + ); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(updateTemplateRequest) + .when().post("/templates/1") + .then().log().all() + .statusCode(400) + .body("detail", is("템플릿 설명은 최대 65,535 Byte까지 입력 가능합니다.")); + } + // 정상 요청: 2, 3, 1 @ParameterizedTest @DisplayName("템플릿 수정 실패: 잘못된 스니펫 순서 입력") @CsvSource({"1, 2, 1", "3, 2, 1", "0, 2, 1"}) void updateTemplateFailWithWrongSnippetOrdinal(int createOrdinal1, int createOrdinal2, int updateOrdinal) { // given - CreateTemplateRequest templateRequest = createTemplateRequestWithTwoSnippets("title"); - templateService.create(templateRequest); - + createTemplateAndTwoCategories(); UpdateTemplateRequest updateTemplateRequest = new UpdateTemplateRequest( "updateTitle", + "description", List.of( new CreateSnippetRequest("filename3", "content3", createOrdinal1), new CreateSnippetRequest("filename4", "content4", createOrdinal2) @@ -229,7 +415,9 @@ void updateTemplateFailWithWrongSnippetOrdinal(int createOrdinal1, int createOrd List.of( new UpdateSnippetRequest(2L, "updateFilename2", "updateContent2", updateOrdinal) ), - List.of(1L) + List.of(1L), + 2L, + List.of("tag1", "tag3") ); // when & then @@ -241,6 +429,13 @@ void updateTemplateFailWithWrongSnippetOrdinal(int createOrdinal1, int createOrd .statusCode(400) .body("detail", is("스니펫 순서가 잘못되었습니다.")); } + + private void createTemplateAndTwoCategories() { + categoryService.create(new CreateCategoryRequest("category1")); + categoryService.create(new CreateCategoryRequest("category2")); + CreateTemplateRequest templateRequest = createTemplateRequestWithTwoSnippets("title"); + templateService.createTemplate(templateRequest); + } } @Nested @@ -251,8 +446,9 @@ class deleteTemplateTest { @DisplayName("템플릿 삭제 성공") void deleteTemplateSuccess() { // given + categoryService.create(new CreateCategoryRequest("category")); CreateTemplateRequest templateRequest = createTemplateRequestWithTwoSnippets("title"); - templateService.create(templateRequest); + templateService.createTemplate(templateRequest); // when & then RestAssured.given().log().all() @@ -276,10 +472,13 @@ void deleteTemplateSuccessWithNotFoundTemplate() { private static CreateTemplateRequest createTemplateRequestWithTwoSnippets(String title) { CreateTemplateRequest templateRequest = new CreateTemplateRequest( title, + "description", List.of( new CreateSnippetRequest("filename1", "content1", 1), new CreateSnippetRequest("filename2", "content2", 2) - ) + ), + 1L, + List.of("tag1", "tag2") ); return templateRequest; } diff --git a/backend/src/test/java/codezap/template/repository/SnippetRepositoryTest.java b/backend/src/test/java/codezap/template/repository/SnippetRepositoryTest.java index 750ce4055..d4c3dc05c 100644 --- a/backend/src/test/java/codezap/template/repository/SnippetRepositoryTest.java +++ b/backend/src/test/java/codezap/template/repository/SnippetRepositoryTest.java @@ -9,10 +9,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.Sql.ExecutionPhase; import org.springframework.transaction.annotation.Transactional; +import codezap.category.domain.Category; +import codezap.category.repository.CategoryRepository; +import codezap.global.exception.CodeZapException; import codezap.template.domain.Snippet; import codezap.template.domain.Template; @@ -26,15 +30,19 @@ class SnippetRepositoryTest { private SnippetRepository snippetRepository; @Autowired private TemplateRepository templateRepository; + @Autowired + private CategoryRepository categoryRepository; @Test @DisplayName("단일 스니펫 찾기 성공: 템플릿과 순서") void findOneSnippetSuccessWithTemplateAndOrdinal() { - Template template = templateRepository.save(new Template("title")); + Category category = categoryRepository.save(new Category("category")); + Template template = templateRepository.save(new Template("title", "description", category)); Snippet snippet1 = snippetRepository.save(new Snippet(template, "filename1", "content1", 1)); Snippet snippet2 = snippetRepository.save(new Snippet(template, "filename2", "content2", 2)); - Snippet foundSnippet = snippetRepository.findByTemplateAndOrdinal(template, 2); + Snippet foundSnippet = snippetRepository.findByTemplateAndOrdinal(template, 2) + .orElseThrow(() -> new CodeZapException(HttpStatus.NOT_FOUND, "해당하는 스니펫이 존재하지 않습니다.")); assertAll( () -> assertThat(foundSnippet.getTemplate().getTitle()).isEqualTo(template.getTitle()), @@ -47,7 +55,8 @@ void findOneSnippetSuccessWithTemplateAndOrdinal() { @Test @DisplayName("스니펫 리스트 찾기 성공: 템플릿과 순서") void findSnippetsSuccessWithTemplateAndOrdinal() { - Template template = templateRepository.save(new Template("title")); + Category category = categoryRepository.save(new Category("category")); + Template template = templateRepository.save(new Template("title", "description", category)); Snippet snippet1 = snippetRepository.save(new Snippet(template, "filename1", "content1", 1)); Snippet snippet2 = snippetRepository.save(new Snippet(template, "filename2", "content2", 2)); Snippet snippet3 = snippetRepository.save(new Snippet(template, "filename3", "content3", 2)); diff --git a/backend/src/test/java/codezap/template/service/TemplateServiceTest.java b/backend/src/test/java/codezap/template/service/TemplateServiceTest.java index 0bde5fd7a..801ca13b4 100644 --- a/backend/src/test/java/codezap/template/service/TemplateServiceTest.java +++ b/backend/src/test/java/codezap/template/service/TemplateServiceTest.java @@ -15,17 +15,23 @@ import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.Sql.ExecutionPhase; +import codezap.category.domain.Category; +import codezap.category.repository.CategoryRepository; import codezap.template.domain.Snippet; +import codezap.template.domain.Tag; import codezap.template.domain.Template; +import codezap.template.domain.TemplateTag; import codezap.template.domain.ThumbnailSnippet; import codezap.template.dto.request.CreateSnippetRequest; import codezap.template.dto.request.CreateTemplateRequest; import codezap.template.dto.request.UpdateSnippetRequest; import codezap.template.dto.request.UpdateTemplateRequest; import codezap.template.dto.response.FindAllTemplatesResponse; -import codezap.template.dto.response.FindTemplateByIdResponse; +import codezap.template.dto.response.FindTemplateResponse; import codezap.template.repository.SnippetRepository; +import codezap.template.repository.TagRepository; import codezap.template.repository.TemplateRepository; +import codezap.template.repository.TemplateTagRepository; import codezap.template.repository.ThumbnailSnippetRepository; import io.restassured.RestAssured; @@ -34,18 +40,29 @@ @Sql(value = "/clear.sql", executionPhase = ExecutionPhase.AFTER_TEST_CLASS) class TemplateServiceTest { + @LocalServerPort + int port; + @Autowired private TemplateService templateService; - @LocalServerPort - int port; @Autowired private TemplateRepository templateRepository; + @Autowired private SnippetRepository snippetRepository; + @Autowired private ThumbnailSnippetRepository thumbnailSnippetRepository; + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private TemplateTagRepository templateTagRepository; + @Autowired + private TagRepository tagRepository; + @BeforeEach void setting() { RestAssured.port = port; @@ -55,13 +72,19 @@ void setting() { @DisplayName("템플릿 생성 성공") void createTemplateSuccess() { // given + categoryRepository.save(new Category("category")); CreateTemplateRequest createTemplateRequest = makeTemplateRequest("title"); // when - templateService.create(createTemplateRequest); + Long id = templateService.createTemplate(createTemplateRequest); + Template template = templateRepository.fetchById(id); // then - assertThat(templateRepository.findAll()).hasSize(1); + assertAll( + () -> assertThat(templateRepository.findAll()).hasSize(1), + () -> assertThat(template.getTitle()).isEqualTo(createTemplateRequest.title()), + () -> assertThat(template.getCategory().getName()).isEqualTo("category") + ); } @Test @@ -86,12 +109,15 @@ void findOneTemplateSuccess() { Template template = saveTemplate(createdTemplate); // when - FindTemplateByIdResponse foundTemplate = templateService.findById(template.getId()); + FindTemplateResponse foundTemplate = templateService.findById(template.getId()); // then assertAll( () -> assertThat(foundTemplate.title()).isEqualTo(template.getTitle()), - () -> assertThat(foundTemplate.snippets()).hasSize(snippetRepository.findAllByTemplate(template).size()) + () -> assertThat(foundTemplate.snippets()).hasSize( + snippetRepository.findAllByTemplate(template).size()), + () -> assertThat(foundTemplate.category().id()).isEqualTo(template.getCategory().getId()), + () -> assertThat(foundTemplate.tags()).hasSize(2) ); } @@ -101,18 +127,26 @@ void updateTemplateSuccess() { // given CreateTemplateRequest createdTemplate = makeTemplateRequest("title"); Template template = saveTemplate(createdTemplate); + categoryRepository.save(new Category("category2")); // when UpdateTemplateRequest updateTemplateRequest = makeUpdateTemplateRequest("updateTitle"); templateService.update(template.getId(), updateTemplateRequest); + Template updateTemplate = templateRepository.fetchById(template.getId()); List snippets = snippetRepository.findAllByTemplate(template); ThumbnailSnippet thumbnailSnippet = thumbnailSnippetRepository.findById(template.getId()).get(); + List tags = templateTagRepository.findAllByTemplate(updateTemplate).stream() + .map(TemplateTag::getTag) + .toList(); // then assertAll( - () -> assertThat(updateTemplateRequest.title()).isEqualTo("updateTitle"), + () -> assertThat(updateTemplate.getTitle()).isEqualTo("updateTitle"), () -> assertThat(thumbnailSnippet.getSnippet().getId()).isEqualTo(2L), - () -> assertThat(snippets).hasSize(3) + () -> assertThat(snippets).hasSize(3), + () -> assertThat(updateTemplate.getCategory().getId()).isEqualTo(2L), + () -> assertThat(tags).hasSize(2), + () -> assertThat(tags.get(1).getName()).isEqualTo("tag3") ); } @@ -137,16 +171,20 @@ void deleteTemplateSuccess() { private CreateTemplateRequest makeTemplateRequest(String title) { return new CreateTemplateRequest( title, + "description", List.of( new CreateSnippetRequest("filename1", "content1", 1), new CreateSnippetRequest("filename2", "content2", 2) - ) + ), + 1L, + List.of("tag1", "tag2") ); } private UpdateTemplateRequest makeUpdateTemplateRequest(String title) { return new UpdateTemplateRequest( title, + "description", List.of( new CreateSnippetRequest("filename3", "content3", 2), new CreateSnippetRequest("filename4", "content4", 3) @@ -154,15 +192,28 @@ private UpdateTemplateRequest makeUpdateTemplateRequest(String title) { List.of( new UpdateSnippetRequest(2L, "filename2", "content2", 1) ), - List.of(1L) + List.of(1L), + 2L, + List.of("tag1", "tag3") ); } private Template saveTemplate(CreateTemplateRequest createTemplateRequest) { - Template savedTemplate = templateRepository.save(new Template(createTemplateRequest.title())); + Category category = categoryRepository.save(new Category("category")); + Template savedTemplate = templateRepository.save( + new Template( + createTemplateRequest.title(), + createTemplateRequest.description(), + category + ) + ); Snippet savedFirstSnippet = snippetRepository.save(new Snippet(savedTemplate, "filename1", "content1", 1)); snippetRepository.save(new Snippet(savedTemplate, "filename2", "content2", 2)); thumbnailSnippetRepository.save(new ThumbnailSnippet(savedTemplate, savedFirstSnippet)); + createTemplateRequest.tags().stream() + .map(Tag::new) + .map(tagRepository::save) + .forEach(tag -> templateTagRepository.save(new TemplateTag(savedTemplate, tag))); return savedTemplate; } diff --git a/backend/src/test/resources/clear.sql b/backend/src/test/resources/clear.sql index 845a987b2..9aada0497 100644 --- a/backend/src/test/resources/clear.sql +++ b/backend/src/test/resources/clear.sql @@ -1,16 +1,51 @@ DROP TABLE IF EXISTS thumbnail_snippet; DROP TABLE IF EXISTS snippet; +DROP TABLE IF EXISTS template_tag; +DROP TABLE IF EXISTS tag; DROP TABLE IF EXISTS template; +DROP TABLE IF EXISTS category; + +CREATE TABLE category +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + created_at DATETIME(6) NOT NULL, + modified_at DATETIME(6) NOT NULL, + PRIMARY KEY (id) +); CREATE TABLE template ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(255) NOT NULL, + description TEXT, + category_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + modified_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (category_id) REFERENCES category (id) +); + +create table tag +( + id BIGINT NOT NULL auto_increment, + name VARCHAR(255) NOT NULL, created_at DATETIME(6) NOT NULL, modified_at DATETIME(6) NOT NULL, PRIMARY KEY (id) ); +create table template_tag +( + template_id BIGINT NOT NULL, + tag_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + modified_at DATETIME(6) NOT NULL, + PRIMARY KEY (template_id, tag_id), + FOREIGN KEY (template_id) REFERENCES template (id), + FOREIGN KEY (tag_id) REFERENCES tag (id) +); + CREATE TABLE snippet ( id BIGINT NOT NULL AUTO_INCREMENT,