diff --git a/BACK/spring-app/build.gradle b/BACK/spring-app/build.gradle index c9afb3a..0d8ec50 100644 --- a/BACK/spring-app/build.gradle +++ b/BACK/spring-app/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' compileOnly 'org.projectlombok:lombok' @@ -38,6 +39,8 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.findify:s3mock_2.13:0.2.6' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -45,3 +48,9 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.withType(Test) { + systemProperty "spring.profiles.active", "test" +} + + diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java index a9e6cde..85e4938 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java @@ -21,19 +21,19 @@ public class CategoryController { private final CategoryService categoryService; private final HashTagService hashTagService; - @GetMapping("/categorys") + @GetMapping("/categories") @Operation(summary = "카테고리 목록 전체 조회") public ResponseEntity>> showCategories() { - List categorys = categoryService.findAll(); - ResponseDto> listResponseDto = new ResponseDto<>(categorys); + List categories = categoryService.findAll(); + ResponseDto> listResponseDto = new ResponseDto<>(categories); return ResponseEntity.ok(listResponseDto); } - @GetMapping("/categorys/{categoryId}/hashtags") + @GetMapping("/categories/{categoryId}/hashtags") @Operation(summary = "특정 카테고리에 포함되는 해쉬태그 목록 조회") public ResponseEntity>> showHashTags(@PathVariable("categoryId") Long categoryId) { - List categorys = hashTagService.findManyByCategory(categoryId); - ResponseDto> listResponseDto = new ResponseDto<>(categorys); + List categories = hashTagService.findManyByCategory(categoryId); + ResponseDto> listResponseDto = new ResponseDto<>(categories); return ResponseEntity.ok(listResponseDto); } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java index 77b9266..b4c63e8 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java @@ -19,7 +19,7 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Table(name = "Categorys") +@Table(name = "Categories") public class Category { @Id @GeneratedValue diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/exception/CategoryNotFoundException.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/exception/CategoryNotFoundException.java index 7488320..0e60a2d 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/exception/CategoryNotFoundException.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/exception/CategoryNotFoundException.java @@ -1,8 +1,10 @@ package com.starchive.springapp.category.exception; +import static com.starchive.springapp.global.ErrorMessage.CATEGORY_NOT_FOUND; + public class CategoryNotFoundException extends RuntimeException { public CategoryNotFoundException() { - super("카테고리가 존재하지 않습니다."); + super(CATEGORY_NOT_FOUND); } public CategoryNotFoundException(String message) { diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java index ae20bcd..2357e4b 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java @@ -2,6 +2,7 @@ import com.starchive.springapp.category.domain.Category; import com.starchive.springapp.category.dto.CategoryDto; +import com.starchive.springapp.category.exception.CategoryNotFoundException; import com.starchive.springapp.category.repository.CategoryRepository; import java.util.List; import lombok.RequiredArgsConstructor; @@ -17,4 +18,8 @@ public List findAll() { return rootCateGories.stream().map(CategoryDto::from).toList(); } + public Category findOne(Long id) { + return categoryRepository.findById(id).orElseThrow(CategoryNotFoundException::new); + } + } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java new file mode 100644 index 0000000..4499ff2 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java @@ -0,0 +1,9 @@ +package com.starchive.springapp.global; + +public class ErrorMessage { + final public static String FAIL_UPLOAD = "이미지 업로드 실패"; + final public static String NOT_IMAGE_EXTENSION = "올바르지 않은 이미지 확장자(허용 확장자: \"jpg\", \"png\", \"gif\", \"jpeg\""; + final public static String INVALID_FILE_SIZE = "파일 크기가 너무 큽니다. 최대 이미지 크기: 5MB"; + final public static String CATEGORY_NOT_FOUND = "카테고리가 존재하지 않습니다."; + final public static String HASHTAG_NOT_FOUND = "카테고리가 존재하지 않습니다."; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java new file mode 100644 index 0000000..d90bf10 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package com.starchive.springapp.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java index 28f3b81..89c3465 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java @@ -1,7 +1,12 @@ package com.starchive.springapp.global.exception; +import static com.starchive.springapp.global.ErrorMessage.INVALID_FILE_SIZE; + +import com.starchive.springapp.category.exception.CategoryNotFoundException; +import com.starchive.springapp.hashtag.exception.HashTagNotFoundException; import java.util.HashMap; import java.util.Map; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -21,4 +26,16 @@ public ResponseEntity> handleValidationExceptions(MethodArgu return ResponseEntity.badRequest().body(errors); } + + @ExceptionHandler(CategoryNotFoundException.class) + public ResponseEntity handleMaxSizeException(CategoryNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ex.getMessage()); + } + + @ExceptionHandler(HashTagNotFoundException.class) + public ResponseEntity handleMaxSizeException(HashTagNotFoundException ex) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(INVALID_FILE_SIZE); + } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java index 3269b62..2be4d8f 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java @@ -6,14 +6,12 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Table(name = "HashTags") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Getter public class HashTag { @Id diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java index 86c47d4..fd8f36c 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java @@ -1,8 +1,10 @@ package com.starchive.springapp.hashtag.exception; +import static com.starchive.springapp.global.ErrorMessage.HASHTAG_NOT_FOUND; + public class HashTagNotFoundException extends RuntimeException { public HashTagNotFoundException() { - super("해쉬 태그가 존재 하지 않습니다."); + super(HASHTAG_NOT_FOUND); } public HashTagNotFoundException(String message) { diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java index d9fdd88..37583c7 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java @@ -10,7 +10,7 @@ @Repository public interface HashTagRepository extends JpaRepository { - public Optional findByName(String name); + Optional findByName(String name); @Query("select distinct h from HashTag h " + "join fetch PostHashTag ph on h.id = ph.hashTag.id " @@ -18,4 +18,7 @@ public interface HashTagRepository extends JpaRepository { + "join fetch Category c on p.category.id = c.id " + "where c.id = :categoryId") List findAllByCategoryId(@Param("categoryId") Long categoryId); + + + List findManyByIdIn(@Param("ids") List ids); } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java index a7aa7e2..dcb8f18 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java @@ -26,6 +26,10 @@ public HashTag save(String name) { return hashTagRepository.save(hashTag); } + public List findManyByIds(List ids) { + return hashTagRepository.findManyByIdIn(ids); + } + public HashTag findOne(String name) { return hashTagRepository.findByName(name).orElseThrow(HashTagNotFoundException::new); } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java new file mode 100644 index 0000000..407eef8 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java @@ -0,0 +1,29 @@ +package com.starchive.springapp.image.controller; + +import com.starchive.springapp.global.dto.ResponseDto; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.service.PostImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "게시글 이미지") +@RestController +@RequiredArgsConstructor +public class PostImageController { + private final PostImageService postImageService; + + @Operation(summary = "게시글에 포함될 이미지 업로드") + @PostMapping(value = "/postImage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> imageUpload(@RequestParam("image") MultipartFile image) { + PostImageDto postImageDto = postImageService.uploadImage(image); + + return ResponseEntity.ok(new ResponseDto<>(postImageDto)); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java new file mode 100644 index 0000000..17b1aab --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java @@ -0,0 +1,41 @@ +package com.starchive.springapp.image.domain; + +import static jakarta.persistence.FetchType.LAZY; + +import com.starchive.springapp.post.domain.Post; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class PostImage { + @Id + @GeneratedValue + private Long id; + + @Lob + String imagePath; + + LocalDateTime uploadDate; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "postId") + Post post; + + public PostImage(String imagePath) { + this.imagePath = imagePath; + this.uploadDate = LocalDateTime.now(); + } + + public void setPost(Post post) { + this.post = post; + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java new file mode 100644 index 0000000..91a47e2 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java @@ -0,0 +1,11 @@ +package com.starchive.springapp.image.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PostImageDto { + Long id; + String imagePath; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java new file mode 100644 index 0000000..5ce6f0f --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java @@ -0,0 +1,20 @@ +package com.starchive.springapp.image.repository; + +import com.starchive.springapp.image.domain.PostImage; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PostImageRepository extends JpaRepository { + @Query("SELECT p FROM PostImage p WHERE p.post IS NULL AND p.uploadDate < :cutoffDate") + List findOldOrphanedPostImages(@Param("cutoffDate") LocalDateTime cutoffDate); + + @Modifying + @Query("DELETE FROM PostImage p WHERE p.id IN :ids") + void deleteByIds(@Param("ids") List ids); + + List findManyByIdIn(@Param("ids") List ids); +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java new file mode 100644 index 0000000..8ff698c --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java @@ -0,0 +1,60 @@ +package com.starchive.springapp.image.service; + +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.s3.S3Service; +import java.net.URI; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class PostImageService { + private final S3Service s3Service; + private final PostImageRepository postImageRepository; + + public PostImageDto uploadImage(MultipartFile image) { + String imagePath = s3Service.saveFile(image); + PostImage postImage = new PostImage(imagePath); + + postImageRepository.save(postImage); + + return new PostImageDto(postImage.getId(), postImage.getImagePath()); + } + + public void setPost(List imageIds, Post post) { + List postImages = postImageRepository.findManyByIdIn(imageIds); + postImages.forEach(postImage -> { + postImage.setPost(post); + }); + } + + @Scheduled(cron = "0 0 2 * * ?") + public void deleteOldOrphanedPostImages() { + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(1); // 하루 전 + + List oldOrphanedPostImages = postImageRepository.findOldOrphanedPostImages(cutoffDate); + oldOrphanedPostImages.forEach(postImage -> { + s3Service.deleteObject(extractKeyFromUrl(postImage.getImagePath())); + postImageRepository.delete(postImage); + log.info("Deleted old orphaned PostImages: {}", postImage.getImagePath()); + }); + + } + + private String extractKeyFromUrl(String url) { + URI uri = URI.create(url); + return uri.getPath().substring(1); // 첫 번째 '/' 제거 + } + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/controller/PostController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/controller/PostController.java new file mode 100644 index 0000000..cbc626a --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/controller/PostController.java @@ -0,0 +1,28 @@ +package com.starchive.springapp.post.controller; + +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@Tag(name = "게시글") +public class PostController { + private final PostService postService; + + @PostMapping("/post") + @Operation(summary = "게시글 작성") + public ResponseEntity post(@Valid @RequestBody PostCreateRequest request) { + + postService.createPost(request); + + return ResponseEntity.status(201).build(); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/domain/Post.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/domain/Post.java index 8241b39..896ac78 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/post/domain/Post.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/domain/Post.java @@ -3,6 +3,7 @@ import static jakarta.persistence.FetchType.LAZY; import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.post.dto.PostCreateRequest; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -44,11 +45,22 @@ public class Post { private String password; @Column(nullable = false, name = "datetime") - private LocalDateTime dateTime; + private LocalDateTime createAt; @ManyToOne(fetch = LAZY) @JoinColumn(name = "categoryId") private Category category; + public static Post of(PostCreateRequest request, Category category) { + Post post = new Post(); + post.title = request.getTitle(); + post.content = request.getContent(); + post.author = request.getAuthor(); + post.password = request.getPassword(); + post.createAt = LocalDateTime.now(); + post.category = category; + + return post; + } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/dto/PostCreateRequest.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..924a049 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/dto/PostCreateRequest.java @@ -0,0 +1,48 @@ +package com.starchive.springapp.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PostCreateRequest { + @NotEmpty + @Size(max = 64) + @Schema(description = "게시글 제목", example = "게시글 제목 예시") + private String title; + + @NotEmpty + @Schema(description = "게시글 내용", example = "게시글 내용 예시") + private String content; + + @NotEmpty + @Size(max = 32) + @Schema(description = "작성자 이름", example = "홍길동") + private String author; + + @NotEmpty + @Size(max = 128) + @Schema(description = "비밀번호", example = "1234") + private String password; + + @NotNull + @Schema(description = "카테고리 ID", example = "1") + private Long categoryId; + + @Nullable + @Schema(description = "해쉬 태그 ID", example = "[1,2,3]") + private List hashTagIds; + + @Nullable + @Schema(description = "첨부 이미지 ID", example = "[1,2]") + private List imageIds; + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/repository/PostRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/repository/PostRepository.java new file mode 100644 index 0000000..50ef03b --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/repository/PostRepository.java @@ -0,0 +1,7 @@ +package com.starchive.springapp.post.repository; + +import com.starchive.springapp.post.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/service/PostService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/service/PostService.java new file mode 100644 index 0000000..57cf878 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/service/PostService.java @@ -0,0 +1,35 @@ +package com.starchive.springapp.post.service; + +import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.category.service.CategoryService; +import com.starchive.springapp.image.service.PostImageService; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.repository.PostRepository; +import com.starchive.springapp.posthashtag.service.PostHashTagService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class PostService { + private final PostRepository postRepository; + private final PostHashTagService postHashTagService; + private final CategoryService categoryService; + private final PostImageService postImageService; + + public void createPost(PostCreateRequest request) { + Category category = categoryService.findOne(request.getCategoryId()); + Post post = Post.of(request, category); + + postRepository.save(post); + + postHashTagService.storePostHashTag(request.getHashTagIds(), post); + + postImageService.setPost(request.getImageIds(), post); + + } + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/domain/PostHashTag.java b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/domain/PostHashTag.java index b69880a..b76e73e 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/domain/PostHashTag.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/domain/PostHashTag.java @@ -13,15 +13,11 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.NoArgsConstructor; @Entity @Table(name = "PostHashTag") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Builder -@AllArgsConstructor public class PostHashTag { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -36,5 +32,10 @@ public class PostHashTag { @JoinColumn(name = "hashTagId") private HashTag hashTag; + public PostHashTag(Post post, HashTag hashTag) { + this.post = post; + this.hashTag = hashTag; + } + } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/service/PostHashTagService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/service/PostHashTagService.java new file mode 100644 index 0000000..ca0f9e4 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/service/PostHashTagService.java @@ -0,0 +1,37 @@ +package com.starchive.springapp.posthashtag.service; + +import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.hashtag.service.HashTagService; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import com.starchive.springapp.posthashtag.repository.PostHashTagRepository; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class PostHashTagService { + private final PostHashTagRepository postHashTagRepository; + private final HashTagService hashTagService; + + public void storePostHashTag(List hashTagsIds, Post post) { + + if (hashTagsIds == null || hashTagsIds.isEmpty()) { + return; + } + + ArrayList postHashTags = new ArrayList<>(); + List hasTags = hashTagService.findManyByIds(hashTagsIds); + + hasTags.stream().forEach(hasTag -> { + PostHashTag postHashTag = new PostHashTag(post, hasTag); + postHashTags.add(postHashTag); + }); + + postHashTagRepository.saveAll(postHashTags); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java new file mode 100644 index 0000000..51ecfec --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java @@ -0,0 +1,37 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + @Primary + public AmazonS3 amazonS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } + + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java new file mode 100644 index 0000000..15b560c --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java @@ -0,0 +1,77 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.starchive.springapp.global.ErrorMessage; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + // 단일 파일 저장 + public String saveFile(MultipartFile file) { + String randomFilename = generateRandomFilename(file); + log.info("File upload started: {}, bucketName: {}", randomFilename, bucket); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + try { + amazonS3.putObject(bucket, randomFilename, file.getInputStream(), metadata); + } catch (AmazonS3Exception e) { + log.error("Amazon S3 error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } catch (SdkClientException e) { + log.error("AWS SDK client error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } catch (IOException e) { + log.error("IO error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } + + log.info("File upload completed: " + randomFilename); + + return amazonS3.getUrl(bucket, randomFilename).toString(); + } + + public void deleteObject(String key) { + amazonS3.deleteObject(bucket, key); + log.info("Deleted S3 Object: {}", key); + } + + // 랜덤파일명 생성 (파일명 중복 방지) + private String generateRandomFilename(MultipartFile multipartFile) { + String originalFilename = multipartFile.getOriginalFilename(); + String fileExtension = validateFileExtension(originalFilename); + String randomFilename = UUID.randomUUID() + "." + fileExtension; + return randomFilename; + } + + // 파일 확장자 체크 + private String validateFileExtension(String originalFilename) { + String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); + List allowedExtensions = Arrays.asList("jpg", "png", "gif", "jpeg"); + + if (!allowedExtensions.contains(fileExtension)) { + throw new RuntimeException(ErrorMessage.NOT_IMAGE_EXTENSION); + } + return fileExtension; + } +} diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java index 6b50b7e..0caa249 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java @@ -13,8 +13,6 @@ import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; @@ -24,7 +22,6 @@ @SpringBootTest @Transactional @AutoConfigureMockMvc -@AutoConfigureTestDatabase(replace = Replace.ANY) class CategoryControllerTest { @Autowired CategoryRepository categoryRepository; @@ -51,7 +48,7 @@ class CategoryControllerTest { categoryRepository.save(child3); // when - mockMvc.perform(get("/categorys") + mockMvc.perform(get("/categories") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()) @@ -75,7 +72,7 @@ class CategoryControllerTest { .content("알고리즘을 학습합시다.") .author("홍길동") .password("1234") - .dateTime(LocalDateTime.now()) + .createAt(LocalDateTime.now()) .category(category) .build(); entityManager.persist(post); @@ -85,20 +82,14 @@ class CategoryControllerTest { entityManager.persist(hashTag1); entityManager.persist(hashTag2); - PostHashTag postHashTag1 = PostHashTag.builder() - .post(post) - .hashTag(hashTag1) - .build(); + PostHashTag postHashTag1 = new PostHashTag(post, hashTag1); entityManager.persist(postHashTag1); - PostHashTag postHashTag2 = PostHashTag.builder() - .post(post) - .hashTag(hashTag2) - .build(); + PostHashTag postHashTag2 = new PostHashTag(post, hashTag2); entityManager.persist(postHashTag2); // When & Then - mockMvc.perform(get("/categorys/{categoryId}/hashtags", category.getId()) + mockMvc.perform(get("/categories/{categoryId}/hashtags", category.getId()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()) diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java index 11e9537..c733d82 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java @@ -8,14 +8,11 @@ import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; @SpringBootTest @Transactional -@AutoConfigureTestDatabase(replace = Replace.ANY) class CategoryDtoTest { @Autowired CategoryRepository categoryRepository; diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java index 1416399..492c85c 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java @@ -9,13 +9,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; @SpringBootTest @Transactional -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) class CategoryServiceTest { @Autowired CategoryRepository categoryRepository; diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java index 23f0b31..baf93a0 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java @@ -41,8 +41,8 @@ class HashTagControllerTest { public void 전체_해쉬태그_목록_반환_테스트() throws Exception { //given List mockTags = Arrays.asList( - new HashTag(1L, "Spring"), - new HashTag(2L, "Java") + new HashTag("Spring"), + new HashTag("Java") ).stream().map(HashTagDto::from).toList(); when(hashTagService.findAll()).thenReturn(mockTags); diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/repository/HashTagRepositoryTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/repository/HashTagRepositoryTest.java index 3e2f070..5393a81 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/repository/HashTagRepositoryTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/repository/HashTagRepositoryTest.java @@ -11,11 +11,9 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) class HashTagRepositoryTest { @Autowired private HashTagRepository hashTagRepository; @@ -34,7 +32,7 @@ class HashTagRepositoryTest { .content("알고리즘을 학습합시다.") .author("홍길동") .password("1234") - .dateTime(LocalDateTime.now()) + .createAt(LocalDateTime.now()) .category(category) .build(); entityManager.persist(post); @@ -44,16 +42,10 @@ class HashTagRepositoryTest { entityManager.persist(hashTag1); entityManager.persist(hashTag2); - PostHashTag postHashTag1 = PostHashTag.builder() - .post(post) - .hashTag(hashTag1) - .build(); + PostHashTag postHashTag1 = new PostHashTag(post, hashTag1); entityManager.persist(postHashTag1); - PostHashTag postHashTag2 = PostHashTag.builder() - .post(post) - .hashTag(hashTag2) - .build(); + PostHashTag postHashTag2 = new PostHashTag(post, hashTag2); entityManager.persist(postHashTag2); // When diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java index 5f994c2..008f0a5 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java @@ -71,13 +71,14 @@ class HashTagServiceTest { @Test public void 해쉬태그_삭제_테스트() throws Exception { //given - Post post1 = Post.builder().title("타이틀1").author("content").password("1234").dateTime(LocalDateTime.now()) + Post post1 = Post.builder().title("타이틀1").author("content").password("1234").createAt(LocalDateTime.now()) .build(); - Post post2 = Post.builder().title("타이틀2").author("content").password("1234").dateTime(LocalDateTime.now()) + Post post2 = Post.builder().title("타이틀2").author("content").password("1234").createAt(LocalDateTime.now()) .build(); HashTag hashTag1 = hashTagService.save("DP"); - PostHashTag postHashTag1 = PostHashTag.builder().post(post1).hashTag(hashTag1).build(); - PostHashTag postHashTag2 = PostHashTag.builder().post(post2).hashTag(hashTag1).build(); + + PostHashTag postHashTag1 = new PostHashTag(post1, hashTag1); + PostHashTag postHashTag2 = new PostHashTag(post2, hashTag1); em.persist(post1); em.persist(post2); em.persist(postHashTag1); diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java new file mode 100644 index 0000000..4dbfb6a --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java @@ -0,0 +1,54 @@ +package com.starchive.springapp.image.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.service.PostImageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +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.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(PostImageController.class) +@ExtendWith(MockitoExtension.class) +class PostImageControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private PostImageService postImageService; + + @InjectMocks + private PostImageController postImageController; + + @Test + public void 이미지_업로드_컨트롤러_단위_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String content = "테스트 내용"; + + MockMultipartFile file = new MockMultipartFile("image", path, contentType, content.getBytes()); + + Mockito.when(postImageService.uploadImage(Mockito.any(MockMultipartFile.class))) + .thenReturn(new PostImageDto(1L, "Https://" + path)); + //when + //then + mockMvc.perform(multipart("/postImage") + .file(file) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1L)) + .andExpect(jsonPath("$.data.imagePath").value("Https://" + path)); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java new file mode 100644 index 0000000..d7fb81a --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java @@ -0,0 +1,59 @@ +package com.starchive.springapp.image.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.s3.S3Service; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +@ExtendWith(MockitoExtension.class) +class PostImageServiceTest { + @Mock + private S3Service s3Service; + + @Mock + private PostImageRepository postImageRepository; + + @InjectMocks + private PostImageService postImageService; + + @Test + public void 이미지_업로드_서비스로직_단위_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String content = "테스트 내용"; + + MockMultipartFile file = new MockMultipartFile("test", path, contentType, content.getBytes()); + + when(s3Service.saveFile(Mockito.any(MockMultipartFile.class))).thenReturn("https://test.png"); + when(postImageRepository.save(Mockito.any())).then(invocation -> { + PostImage postImage = invocation.getArgument(0); + Field field = postImage.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(postImage, 1L); + return postImage; + }); + + //when + PostImageDto postImageDto = postImageService.uploadImage(file); + + //then + verify(s3Service).saveFile(Mockito.any(MockMultipartFile.class)); + verify(postImageRepository).save(Mockito.any()); + assertThat(postImageDto.getId()).isEqualTo(1L); + assertThat(postImageDto.getImagePath()).contains("test.png"); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/post/controller/PostControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/post/controller/PostControllerTest.java new file mode 100644 index 0000000..18c8e61 --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/post/controller/PostControllerTest.java @@ -0,0 +1,90 @@ +package com.starchive.springapp.post.controller; + + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.service.PostService; +import java.util.Arrays; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(PostController.class) +class PostControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private PostService postService; + + @Test + @DisplayName("게시물 생성 성공") + void createPost_Success() throws Exception { + // Given + PostCreateRequest request = new PostCreateRequest( + "Test Title", + "Test Content", + "Author Name", + "password123", + 1L, + Arrays.asList(101L, 102L), + Arrays.asList(201L, 202L) + ); + + // Mock PostService behavior + Mockito.doNothing().when(postService).createPost(Mockito.any(PostCreateRequest.class)); + + // When & Then) + mockMvc.perform(post("/post") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("게시물 생성 실패 - 필수 필드 누락") + void createPost_Failure_MissingFields() throws Exception { + // Given + PostCreateRequest request = new PostCreateRequest(); // 빈 객체로 필수 값 누락 + + // When & Then + mockMvc.perform(post("/post") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("게시물 생성 실패 - 제목 길이 초과") + void createPost_Failure_TitleTooLong() throws Exception { + // Given + String longTitle = "a".repeat(65); // 제목 길이를 명시적으로 초과시킴 (65자) + PostCreateRequest request = new PostCreateRequest( + longTitle, + "Test Content", + "Author Name", + "password123", + 1L, + null, + null + ); + + // When & Then + mockMvc.perform(post("/post") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/post/service/PostServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/post/service/PostServiceTest.java new file mode 100644 index 0000000..afee606 --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/post/service/PostServiceTest.java @@ -0,0 +1,78 @@ +package com.starchive.springapp.post.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.category.repository.CategoryRepository; +import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.hashtag.repository.HashTagRepository; +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.repository.PostRepository; +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import com.starchive.springapp.posthashtag.repository.PostHashTagRepository; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class PostServiceTest { + @Autowired + HashTagRepository hashTagRepository; + + @Autowired + CategoryRepository categoryRepository; + + @Autowired + PostImageRepository postImageRepository; + + @Autowired + PostHashTagRepository postHashTagRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + PostService postService; + + @Test + public void 게시글_작성_통합_테스트() throws Exception { + //given + HashTag hashTag = new HashTag("tag1"); + hashTagRepository.save(hashTag); + HashTag hashTag2 = new HashTag("tag2"); + hashTagRepository.save(hashTag2); + + PostImage postImage = new PostImage("imagePath"); + postImageRepository.save(postImage); + + Category category = new Category("예시카테고리", null); + categoryRepository.save(category); + + List hashTagIds = new ArrayList<>(List.of(hashTag.getId(), hashTag2.getId())); + List postImageIds = new ArrayList<>(List.of(postImage.getId())); + + PostCreateRequest postCreateRequest = + new PostCreateRequest("title", "content", "author", "password" + , category.getId(), hashTagIds, postImageIds); + + //when + postService.createPost(postCreateRequest); + + Post createdPost = postRepository.findAll().getFirst(); + List postHashTags = postHashTagRepository.findAll(); + + //then + assertThat(createdPost.getCategory().getId()).isEqualTo(category.getId()); + assertThat(postImage.getPost().getId()).isEqualTo(createdPost.getId()); + assertThat(postHashTags.size()).isEqualTo(2); + + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java new file mode 100644 index 0000000..f01c3fa --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java @@ -0,0 +1,48 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.AnonymousAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import io.findify.s3mock.S3Mock; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +class S3MockConfig { + + private String testBucketName = "test-bucket-in-memory"; + + private String testRegion = "na-east-1"; + + @Bean + public S3Mock s3Mock() { + return new S3Mock.Builder() + .withPort(8001) + .withInMemoryBackend() + .build(); + } + + @Bean(name = "MockS3Client") + public AmazonS3 amazonS3(S3Mock s3Mock) { + s3Mock.start(); + AwsClientBuilder.EndpointConfiguration endpoint = new AwsClientBuilder.EndpointConfiguration( + "http://localhost:8001", testRegion); + + AmazonS3 client = AmazonS3ClientBuilder + .standard() + .withPathStyleAccessEnabled(true) + .withEndpointConfiguration(endpoint) + .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials())) + .build(); + + client.createBucket(testBucketName); + + return client; + } + + public String getTestBucketName() { + return testBucketName; + } +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java new file mode 100644 index 0000000..3d0e3cb --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java @@ -0,0 +1,64 @@ +package com.starchive.springapp.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.amazonaws.services.s3.AmazonS3; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; + + +@SpringBootTest +@Import(S3MockConfig.class) +class S3ServiceTest { + + @Autowired + @Qualifier("MockS3Client") + private AmazonS3 amazonS3; + + @Autowired + S3MockConfig s3MockConfig; + + @Autowired + private S3Service s3Service; + + @Test + public void 이미지_업로드_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String bucket = s3MockConfig.getTestBucketName(); + + MockMultipartFile file = new MockMultipartFile("test", path, contentType, "test".getBytes()); + //Reflection s3Service + Field reflectionFieldFor_amazonS3 = s3Service.getClass().getDeclaredField("amazonS3"); + reflectionFieldFor_amazonS3.setAccessible(true); + reflectionFieldFor_amazonS3.set(s3Service, amazonS3); + + Field reflectionFieldFor_bucket = s3Service.getClass().getDeclaredField("bucket"); + reflectionFieldFor_bucket.setAccessible(true); + reflectionFieldFor_bucket.set(s3Service, bucket); + + //when + String urlPath = s3Service.saveFile(file); + + //then + assertThat(urlPath).contains(bucket); + + amazonS3.listBuckets().forEach(System.out::println); + amazonS3.listObjects(bucket).getObjectSummaries().forEach(System.out::println); + + assertThat(amazonS3.listObjects(bucket).getObjectSummaries().size()).isEqualTo(1); + + String key = urlPath.substring(urlPath.lastIndexOf("/") + 1); + + assertThat(amazonS3.listObjects(bucket).getObjectSummaries().get(0).getKey()) + .isEqualTo(key); + + } + +} \ No newline at end of file