diff --git a/backend/build.gradle b/backend/build.gradle index d05f1ec8f..45472a48a 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -60,6 +60,9 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.h2database:h2' + implementation 'software.amazon.awssdk:s3:2.20.121' + compileOnly 'software.amazon.awssdk:url-connection-client:2.20.121' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.rest-assured:rest-assured:5.3.1' @@ -103,6 +106,7 @@ jacocoTestReport { '**/*Request*', '**/BaseTimeEntity', '**/*Dto*', + '**/S3*', '**/*Interceptor*', '**/*ArgumentResolver*', '**/*ExceptionHandler*', @@ -137,6 +141,7 @@ jacocoTestCoverageVerification { '*.*Application', '*.*Exception', '*.*Dto', + '*.S3*', '*.*Response', '*.*Request', '*.BaseTimeEntity', diff --git a/backend/src/main/java/zipgo/ZipgoApplication.java b/backend/src/main/java/zipgo/ZipgoApplication.java index c0f7e7208..91ea827b6 100644 --- a/backend/src/main/java/zipgo/ZipgoApplication.java +++ b/backend/src/main/java/zipgo/ZipgoApplication.java @@ -2,12 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import zipgo.auth.infra.kakao.config.KakaoCredentials; -import zipgo.common.config.JwtCredentials; @SpringBootApplication -@EnableConfigurationProperties({KakaoCredentials.class, JwtCredentials.class}) public class ZipgoApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/zipgo/common/GlobalExceptionHandler.java b/backend/src/main/java/zipgo/common/GlobalExceptionHandler.java index c64b080a1..c3c274543 100644 --- a/backend/src/main/java/zipgo/common/GlobalExceptionHandler.java +++ b/backend/src/main/java/zipgo/common/GlobalExceptionHandler.java @@ -12,6 +12,7 @@ import zipgo.auth.exception.AuthException; import zipgo.common.logging.LoggingUtils; import zipgo.member.exception.MemberException; +import zipgo.pet.exception.PetException; import zipgo.petfood.exception.PetFoodException; import zipgo.petfood.presentation.dto.ErrorResponse; import zipgo.review.exception.ReviewException; @@ -29,7 +30,11 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { PetFoodException.NotFound.class, ReviewException.NotFound.class, MemberException.NotFound.class, - AuthException.ResourceNotFound.class + AuthException.ResourceNotFound.class, + PetException.AgeNotFound.class, + PetException.GenderNotFound.class, + PetException.PetSizeNotFound.class, + PetException.BreedsNotFound.class }) public ResponseEntity handleNotFoundException(Exception exception) { LoggingUtils.error(exception); diff --git a/backend/src/main/java/zipgo/common/config/AdditionalConfiguration.java b/backend/src/main/java/zipgo/common/config/AdditionalConfiguration.java new file mode 100644 index 000000000..f65a100f4 --- /dev/null +++ b/backend/src/main/java/zipgo/common/config/AdditionalConfiguration.java @@ -0,0 +1,15 @@ +package zipgo.common.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import zipgo.auth.infra.kakao.config.KakaoCredentials; + +@Configuration +@EnableConfigurationProperties({ + KakaoCredentials.class, + JwtCredentials.class, + AwsS3Credentials.class +}) +public class AdditionalConfiguration { + +} diff --git a/backend/src/main/java/zipgo/common/config/AwsS3Credentials.java b/backend/src/main/java/zipgo/common/config/AwsS3Credentials.java new file mode 100644 index 000000000..f05932f1a --- /dev/null +++ b/backend/src/main/java/zipgo/common/config/AwsS3Credentials.java @@ -0,0 +1,17 @@ +package zipgo.common.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "cloud.aws.s3") +public class AwsS3Credentials { + + private final String bucket; + private final String zipgoDirectoryName; + private final String petImageDirectory; + private final String imageUrl; + +} diff --git a/backend/src/main/java/zipgo/common/config/S3Config.java b/backend/src/main/java/zipgo/common/config/S3Config.java new file mode 100644 index 000000000..94d0d4224 --- /dev/null +++ b/backend/src/main/java/zipgo/common/config/S3Config.java @@ -0,0 +1,21 @@ +package zipgo.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider; +import software.amazon.awssdk.services.s3.S3Client; + +import static software.amazon.awssdk.regions.Region.AP_NORTHEAST_2; + +@Configuration +public class S3Config { + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .credentialsProvider(ProfileCredentialsProvider.create()) + .region(AP_NORTHEAST_2) + .build(); + } + +} diff --git a/backend/src/main/java/zipgo/image/application/ImageService.java b/backend/src/main/java/zipgo/image/application/ImageService.java new file mode 100644 index 000000000..de9bbfa24 --- /dev/null +++ b/backend/src/main/java/zipgo/image/application/ImageService.java @@ -0,0 +1,20 @@ +package zipgo.image.application; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import zipgo.pet.application.ImageClient; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private final ImageClient imageClient; + + public String save(MultipartFile image) { + UUID uuid = UUID.randomUUID(); + return imageClient.upload(uuid.toString() ,image); + } + +} diff --git a/backend/src/main/java/zipgo/image/infrastructure/aws/s3/S3PetImageClient.java b/backend/src/main/java/zipgo/image/infrastructure/aws/s3/S3PetImageClient.java new file mode 100644 index 000000000..2e590c0f1 --- /dev/null +++ b/backend/src/main/java/zipgo/image/infrastructure/aws/s3/S3PetImageClient.java @@ -0,0 +1,26 @@ +package zipgo.image.infrastructure.aws.s3; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import zipgo.common.config.AwsS3Credentials; +import zipgo.pet.application.ImageClient; + +@Component +@RequiredArgsConstructor +public class S3PetImageClient implements ImageClient { + + private final AwsS3Credentials awsS3Credentials; + private final S3Uploader s3Uploader; + + @Override + public String upload(String name, MultipartFile file) { + String bucket = awsS3Credentials.getBucket(); + String zipgoDirectoryName = awsS3Credentials.getZipgoDirectoryName(); + String petImageDirectory = awsS3Credentials.getPetImageDirectory(); + String key = zipgoDirectoryName + petImageDirectory + name; + s3Uploader.upload(bucket, key, file); + return awsS3Credentials.getImageUrl() + petImageDirectory + name; + } + +} diff --git a/backend/src/main/java/zipgo/image/infrastructure/aws/s3/S3Uploader.java b/backend/src/main/java/zipgo/image/infrastructure/aws/s3/S3Uploader.java new file mode 100644 index 000000000..bb1d7ce15 --- /dev/null +++ b/backend/src/main/java/zipgo/image/infrastructure/aws/s3/S3Uploader.java @@ -0,0 +1,38 @@ +package zipgo.image.infrastructure.aws.s3; + +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Component +@RequiredArgsConstructor +public class S3Uploader { + + private final S3Client s3Client; + + public void upload(String bucket, String key, MultipartFile file) { + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType("image/png") + .build(); + try { + s3Client.putObject(objectRequest, RequestBody.fromBytes(getBytes(file))); + } catch (UnsupportedOperationException e) { + throw new IllegalArgumentException("엑세스 접근 중 예외가 발생했습니다."); + } + } + + private byte[] getBytes(MultipartFile file) { + try { + return file.getBytes(); + } catch (IOException e) { + throw new IllegalArgumentException("바이트 파싱 중 에러가 발생했습니다."); + } + } + +} diff --git a/backend/src/main/java/zipgo/image/presentaion/ImageController.java b/backend/src/main/java/zipgo/image/presentaion/ImageController.java new file mode 100644 index 000000000..d787c9c40 --- /dev/null +++ b/backend/src/main/java/zipgo/image/presentaion/ImageController.java @@ -0,0 +1,29 @@ +package zipgo.image.presentaion; + +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import zipgo.image.application.ImageService; +import zipgo.image.presentaion.dto.ImageResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/images") +public class ImageController { + + private final ImageService imageService; + + @PostMapping + public ResponseEntity upload( + @RequestPart(required = false, value = "image") MultipartFile imageFile + ) { + String url = imageService.save(imageFile); + return ResponseEntity.created(URI.create(url)).body(ImageResponse.from(url)); + } + +} diff --git a/backend/src/main/java/zipgo/image/presentaion/dto/ImageResponse.java b/backend/src/main/java/zipgo/image/presentaion/dto/ImageResponse.java new file mode 100644 index 000000000..cc20909e7 --- /dev/null +++ b/backend/src/main/java/zipgo/image/presentaion/dto/ImageResponse.java @@ -0,0 +1,11 @@ +package zipgo.image.presentaion.dto; + +public record ImageResponse ( + String imageUrl +) { + + public static ImageResponse from(String url) { + return new ImageResponse(url); + } + +} diff --git a/backend/src/main/java/zipgo/member/domain/repository/MemberRepository.java b/backend/src/main/java/zipgo/member/domain/repository/MemberRepository.java index d9e57a9c0..be3a0d019 100644 --- a/backend/src/main/java/zipgo/member/domain/repository/MemberRepository.java +++ b/backend/src/main/java/zipgo/member/domain/repository/MemberRepository.java @@ -9,8 +9,8 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); - default Member getById(Long id) { - return findById(id).orElseThrow(MemberException.NotFound::new); + default Member getById(Long memberId) { + return findById(memberId).orElseThrow(MemberException.NotFound::new); } } diff --git a/backend/src/main/java/zipgo/pet/application/ImageClient.java b/backend/src/main/java/zipgo/pet/application/ImageClient.java new file mode 100644 index 000000000..a93859e7b --- /dev/null +++ b/backend/src/main/java/zipgo/pet/application/ImageClient.java @@ -0,0 +1,9 @@ +package zipgo.pet.application; + +import org.springframework.web.multipart.MultipartFile; + +public interface ImageClient { + + String upload(String name, MultipartFile file); + +} diff --git a/backend/src/main/java/zipgo/pet/application/PetService.java b/backend/src/main/java/zipgo/pet/application/PetService.java new file mode 100644 index 000000000..e9e2b365f --- /dev/null +++ b/backend/src/main/java/zipgo/pet/application/PetService.java @@ -0,0 +1,35 @@ +package zipgo.pet.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import zipgo.member.domain.Member; +import zipgo.member.domain.repository.MemberRepository; +import zipgo.pet.domain.Breeds; +import zipgo.pet.domain.Pet; +import zipgo.pet.domain.PetSize; +import zipgo.pet.domain.repository.BreedsRepository; +import zipgo.pet.domain.repository.PetRepository; +import zipgo.pet.domain.repository.PetSizeRepository; +import zipgo.pet.presentation.dto.request.CreatePetRequest; + +@Service +@Transactional +@RequiredArgsConstructor +public class PetService { + + private final PetRepository petRepository; + private final MemberRepository memberRepository; + private final BreedsRepository breedsRepository; + private final PetSizeRepository petSizeRepository; + + public Long createPet(Long memberId, CreatePetRequest request) { + Member owner = memberRepository.getById(memberId); + PetSize petSize = petSizeRepository.getByName(request.petSize()); + Breeds breeds = breedsRepository.getByNameAndPetSizeId(request.breed(), petSize.getId()); + + Pet pet = petRepository.save(request.toEntity(owner, breeds)); + return pet.getId(); + } + +} diff --git a/backend/src/main/java/zipgo/pet/domain/AgeGroup.java b/backend/src/main/java/zipgo/pet/domain/AgeGroup.java index 01dbadfb6..b30bef13b 100644 --- a/backend/src/main/java/zipgo/pet/domain/AgeGroup.java +++ b/backend/src/main/java/zipgo/pet/domain/AgeGroup.java @@ -23,7 +23,7 @@ public static AgeGroup from(int age) { return Arrays.stream(values()) .filter(ageGroup -> ageGroup.greaterThanOrEqual <= age && age < ageGroup.lessThan) .findFirst() - .orElseThrow(PetException.NotFound::new); + .orElseThrow(PetException.AgeNotFound::new); } } diff --git a/backend/src/main/java/zipgo/pet/domain/Gender.java b/backend/src/main/java/zipgo/pet/domain/Gender.java index 54b748a8a..f94f21078 100644 --- a/backend/src/main/java/zipgo/pet/domain/Gender.java +++ b/backend/src/main/java/zipgo/pet/domain/Gender.java @@ -1,8 +1,23 @@ package zipgo.pet.domain; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import zipgo.pet.exception.PetException; + +@RequiredArgsConstructor public enum Gender { - MALE, - FEMALE; + MALE("남"), + FEMALE("여"), + ; + + private final String value; + + public static Gender from(String other) { + return Arrays.stream(values()) + .filter(gender -> gender.value.equals(other)) + .findFirst() + .orElseThrow(PetException.GenderNotFound::new); + } } diff --git a/backend/src/main/java/zipgo/pet/domain/repository/BreedsRepository.java b/backend/src/main/java/zipgo/pet/domain/repository/BreedsRepository.java index 72a85e75e..c45193bc0 100644 --- a/backend/src/main/java/zipgo/pet/domain/repository/BreedsRepository.java +++ b/backend/src/main/java/zipgo/pet/domain/repository/BreedsRepository.java @@ -1,8 +1,16 @@ package zipgo.pet.domain.repository; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import zipgo.pet.domain.Breeds; +import zipgo.pet.exception.PetException; public interface BreedsRepository extends JpaRepository { + Optional findByNameAndPetSizeId(String name, Long petSizeId); + + default Breeds getByNameAndPetSizeId(String name, Long petSizeId) { + return findByNameAndPetSizeId(name, petSizeId).orElseThrow(PetException.BreedsNotFound::new); + } + } diff --git a/backend/src/main/java/zipgo/pet/domain/repository/PetSizeRepository.java b/backend/src/main/java/zipgo/pet/domain/repository/PetSizeRepository.java index 62b2524f0..03fb51f7f 100644 --- a/backend/src/main/java/zipgo/pet/domain/repository/PetSizeRepository.java +++ b/backend/src/main/java/zipgo/pet/domain/repository/PetSizeRepository.java @@ -1,8 +1,16 @@ package zipgo.pet.domain.repository; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import zipgo.pet.domain.PetSize; +import zipgo.pet.exception.PetException; public interface PetSizeRepository extends JpaRepository { + Optional findByName(String name); + + default PetSize getByName(String name) { + return findByName(name).orElseThrow(PetException.PetSizeNotFound::new); + } + } diff --git a/backend/src/main/java/zipgo/pet/exception/PetException.java b/backend/src/main/java/zipgo/pet/exception/PetException.java index 8df8d85f6..737398e18 100644 --- a/backend/src/main/java/zipgo/pet/exception/PetException.java +++ b/backend/src/main/java/zipgo/pet/exception/PetException.java @@ -6,12 +6,36 @@ public PetException(String message) { super(message); } - public static class NotFound extends PetException { + public static class AgeNotFound extends PetException { - public NotFound() { + public AgeNotFound() { super("분류에 속하지 않는 나이입니다."); } } + public static class GenderNotFound extends PetException { + + public GenderNotFound() { + super("존재하지 않는 성별입니다."); + } + + } + + public static class BreedsNotFound extends PetException { + + public BreedsNotFound() { + super("존재하지 않는 견종입니다."); + } + + } + + public static class PetSizeNotFound extends PetException { + + public PetSizeNotFound() { + super("존재하지 않는 견종 크기입니다."); + } + + } + } diff --git a/backend/src/main/java/zipgo/pet/presentation/PetController.java b/backend/src/main/java/zipgo/pet/presentation/PetController.java new file mode 100644 index 000000000..5fa39ee9c --- /dev/null +++ b/backend/src/main/java/zipgo/pet/presentation/PetController.java @@ -0,0 +1,32 @@ +package zipgo.pet.presentation; + +import jakarta.validation.Valid; +import java.net.URI; +import lombok.AllArgsConstructor; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import zipgo.auth.presentation.Auth; +import zipgo.auth.presentation.dto.AuthDto; +import zipgo.pet.application.PetService; +import zipgo.pet.presentation.dto.request.CreatePetRequest; + +@RestController +@AllArgsConstructor +@RequestMapping("/pets") +public class PetController { + + private final PetService petService; + + @PostMapping + public ResponseEntity create( + @Auth AuthDto authDto, + @RequestBody @Valid CreatePetRequest request + ) { + Long petId = petService.createPet(authDto.id(), request); + return ResponseEntity.created(URI.create("/pets/" + petId)).build(); + } + +} diff --git a/backend/src/main/java/zipgo/pet/presentation/dto/request/CreatePetRequest.java b/backend/src/main/java/zipgo/pet/presentation/dto/request/CreatePetRequest.java new file mode 100644 index 000000000..db554e2bb --- /dev/null +++ b/backend/src/main/java/zipgo/pet/presentation/dto/request/CreatePetRequest.java @@ -0,0 +1,48 @@ +package zipgo.pet.presentation.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.time.Year; +import zipgo.member.domain.Member; +import zipgo.pet.domain.Breeds; +import zipgo.pet.domain.Gender; +import zipgo.pet.domain.Pet; + +public record CreatePetRequest ( + + @NotBlank(message = "Null 또는 공백이 포함될 수 없습니다. 올바른 값인지 확인해주세요.") + String name, + + @NotBlank(message = "Null 또는 공백이 포함될 수 없습니다. 올바른 값인지 확인해주세요.") + String gender, + + String image, + + @Max(20) + @Min(0) + Integer age, + + @NotBlank(message = "Null 또는 공백이 포함될 수 없습니다. 올바른 값인지 확인해주세요.") + String breed, + + @NotBlank(message = "Null 또는 공백이 포함될 수 없습니다. 올바른 값인지 확인해주세요.") + String petSize, + + @Min(0) + Double weight +) { + + public Pet toEntity(Member owner, Breeds breeds) { + int birthYear = Year.now().getValue() - age; + return Pet.builder() + .birthYear(Year.of(birthYear)) + .owner(owner) + .name(name) + .gender(Gender.from(gender)) + .breeds(breeds) + .weight(weight) + .build(); + } + +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 81e795ab4..64411f840 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -44,3 +44,13 @@ oauth: jwt: secret-key: ${ZIPGO_SECRET_KEY} expire-length: ${EXPIRE_LENGTH} + +--- +# aws +cloud: + aws: + s3: + bucket: ${BUCKET_NAME} + zipgo-directory-name: ${ZIPGO_DIRECTORY_NAME} + pet-image-directory: ${PET_IMAGE_DIRECTORY} + image-url: ${DEV_SERVER_IMAGE_URL} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 64bc0429f..bf23285d7 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -49,3 +49,13 @@ logging: org.hibernate.type: error --- +# cloud +cloud: + aws: + s3: + bucket: ${BUCKET_NAME} + zipgo-directory-name: ${ZIPGO_DIRECTORY_NAME} + pet-image-directory: ${PET_IMAGE_DIRECTORY} + image-url: ${DEV_SERVER_IMAGE_URL} + region: + static: ap-northeast-2 diff --git a/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java b/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java index 6940b9e4f..f47d7bfd6 100644 --- a/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java +++ b/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java @@ -33,7 +33,6 @@ @AutoConfigureRestDocs @ExtendWith(SpringExtension.class) @SuppressWarnings("NonAsciiCharacters") -@MockBean(JpaMetamodelMappingContext.class) @WebMvcTest(controllers = AuthController.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class AuthControllerTest { diff --git a/backend/src/test/java/zipgo/image/application/ImageServiceTest.java b/backend/src/test/java/zipgo/image/application/ImageServiceTest.java new file mode 100644 index 000000000..a57c62437 --- /dev/null +++ b/backend/src/test/java/zipgo/image/application/ImageServiceTest.java @@ -0,0 +1,42 @@ +package zipgo.image.application; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import zipgo.pet.application.ImageClient; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.when; + + +@SpringBootTest +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ImageServiceTest { + + @MockBean + private ImageClient imageClient; + + @Autowired + private ImageService imageService; + + @Test + void 이미지를_업로드_할_수_있다() { + // given + var 사진 = new MockMultipartFile("사진", "사진.png", "image/png", "사진".getBytes()); + when(imageClient.upload(any(), any())) + .thenReturn("생성된파일이름"); + + // when + String 저장된_사진 = imageService.save(사진); + + // then + Assertions.assertThat(저장된_사진).isEqualTo("생성된파일이름"); + } + +} diff --git a/backend/src/test/java/zipgo/image/presentaion/ImageControllerTest.java b/backend/src/test/java/zipgo/image/presentaion/ImageControllerTest.java new file mode 100644 index 000000000..7e9d32796 --- /dev/null +++ b/backend/src/test/java/zipgo/image/presentaion/ImageControllerTest.java @@ -0,0 +1,87 @@ +package zipgo.image.presentaion; + +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import zipgo.auth.presentation.AuthInterceptor; +import zipgo.auth.presentation.JwtArgumentResolver; +import zipgo.auth.support.JwtProvider; +import zipgo.image.application.ImageService; + +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.resourceDetails; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureRestDocs +@ExtendWith(SpringExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@WebMvcTest(controllers = ImageController.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ImageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ImageService imageService; + + @MockBean + private JwtProvider jwtProvider; + + @MockBean + private AuthInterceptor authInterceptor; + + @MockBean + private JwtArgumentResolver argumentResolver; + + @Test + void 사진_등록_성공하면_201_반환() throws Exception { + // given + var 사진_파일 = new MockMultipartFile("image", "사진.png", "image/png", "사진".getBytes()); + + when(imageService.save(사진_파일)) + .thenReturn("사진_주소"); + + // when + var 요청 = mockMvc.perform(multipart("/images") + .file(사진_파일) + .header("Authorization", "Bearer a1a2a3.b1b2b3.c1c2c3") + .accept(MediaType.APPLICATION_JSON)) + .andDo(성공_API_문서_생성()); + + // then + 요청.andExpect(status().isCreated()); + } + + private RestDocumentationResultHandler 성공_API_문서_생성() { + var 문서_정보 = resourceDetails().summary("사진 등록").description("사진을 등록합니다."); + return MockMvcRestDocumentationWrapper.document("사진 등록 - 성공", + 문서_정보, + requestHeaders(headerWithName("Authorization").description("인증을 위한 JWT")), + requestParts(partWithName("image").description("반려견 사진")), + responseFields(fieldWithPath("imageUrl").description("사진 링크").type(JsonFieldType.STRING)) + ); + } + +} diff --git a/backend/src/test/java/zipgo/pet/application/PetServiceTest.java b/backend/src/test/java/zipgo/pet/application/PetServiceTest.java new file mode 100644 index 000000000..f40f310a9 --- /dev/null +++ b/backend/src/test/java/zipgo/pet/application/PetServiceTest.java @@ -0,0 +1,81 @@ +package zipgo.pet.application; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import zipgo.common.service.ServiceTest; +import zipgo.member.domain.Member; +import zipgo.member.domain.repository.MemberRepository; +import zipgo.pet.domain.Breeds; +import zipgo.pet.domain.Pet; +import zipgo.pet.domain.PetSize; +import zipgo.pet.domain.repository.BreedsRepository; +import zipgo.pet.domain.repository.PetRepository; +import zipgo.pet.domain.repository.PetSizeRepository; +import zipgo.pet.presentation.dto.request.CreatePetRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + + +class PetServiceTest extends ServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BreedsRepository breedsRepository; + + @Autowired + private PetSizeRepository petSizeRepository; + + @Autowired + private PetRepository petRepository; + + @Autowired + private PetService petService; + + @Test + void 반려견을_등록_할_수_있다() { + // given + PetSize 소형견 = 소형견_등록(); + 품종_등록("포메라니안", 소형견); + Member 가비 = 멤버_등록("가비"); + + // when + Long petId = petService.createPet(가비.getId(), 반려견_등록_요청("갈비")); + + // then + Pet 반려견 = petRepository.findById(petId).get(); + assertAll( + () -> assertThat(반려견.getOwner().getName()).isEqualTo("가비"), + () -> assertThat(반려견.getName()).isEqualTo("갈비"), + () -> assertThat(반려견.getBreeds().getName()).isEqualTo("포메라니안") + ); + } + + private PetSize 소형견_등록() { + PetSize 소형견 = PetSize + .builder() + .name("소형견") + .build(); + return petSizeRepository.save(소형견); + } + + private Breeds 품종_등록(String 품종_이름, PetSize 크기) { + Breeds 품종 = Breeds.builder() + .name(품종_이름) + .petSize(크기) + .build(); + return breedsRepository.save(품종); + } + + private CreatePetRequest 반려견_등록_요청(String 반려견_이름) { + return new CreatePetRequest(반려견_이름,"남", 반려견_이름 + "img" ,5, "포메라니안", "소형견", 65.4); + } + + private Member 멤버_등록(String 이름) { + Member member = Member.builder().profileImgUrl("사진사진").email(이름 + "@zipgo.com").name(이름).build(); + return memberRepository.save(member); + } + +} diff --git a/backend/src/test/java/zipgo/pet/domain/AgeGroupTest.java b/backend/src/test/java/zipgo/pet/domain/AgeGroupTest.java index 6d9f13cdc..9161d2efe 100644 --- a/backend/src/test/java/zipgo/pet/domain/AgeGroupTest.java +++ b/backend/src/test/java/zipgo/pet/domain/AgeGroupTest.java @@ -53,7 +53,7 @@ class AgeGroupTest { void 나이_그룹에_속하지_않는_수는_예외가_발생한다(int age) { // expect assertThatThrownBy(() -> AgeGroup.from(age)) - .isInstanceOf(PetException.NotFound.class) + .isInstanceOf(PetException.AgeNotFound.class) .hasMessageContaining("분류에 속하지 않는 나이입니다."); } diff --git a/backend/src/test/java/zipgo/pet/domain/GenderTest.java b/backend/src/test/java/zipgo/pet/domain/GenderTest.java new file mode 100644 index 000000000..0f4f5277b --- /dev/null +++ b/backend/src/test/java/zipgo/pet/domain/GenderTest.java @@ -0,0 +1,25 @@ +package zipgo.pet.domain; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import zipgo.pet.exception.PetException; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class GenderTest { + + @Test + void 존재하지_않는_성별을_찾을_경우_예외가_발생한다() { + // given + String 존재하지_않는_성별 = "중성화"; + + // expect + assertThatThrownBy(() -> Gender.from(존재하지_않는_성별)) + .isInstanceOf(PetException.GenderNotFound.class) + .hasMessageContaining("존재하지 않는 성별입니다."); + } + +} diff --git a/backend/src/test/java/zipgo/pet/domain/fixture/BreedsFixture.java b/backend/src/test/java/zipgo/pet/domain/fixture/BreedsFixture.java index 2d16b2f00..1d839697a 100644 --- a/backend/src/test/java/zipgo/pet/domain/fixture/BreedsFixture.java +++ b/backend/src/test/java/zipgo/pet/domain/fixture/BreedsFixture.java @@ -5,6 +5,13 @@ public class BreedsFixture { + public static Breeds 품종_생성(String 이름, PetSize 대형견) { + return Breeds.builder() + .petSize(대형견) + .name(이름) + .build(); + } + public static Breeds 견종(PetSize 사이즈) { return Breeds.builder() .name("푸들") diff --git a/backend/src/test/java/zipgo/pet/domain/fixture/PetSizeFixture.java b/backend/src/test/java/zipgo/pet/domain/fixture/PetSizeFixture.java index d63d175c3..3de61da16 100644 --- a/backend/src/test/java/zipgo/pet/domain/fixture/PetSizeFixture.java +++ b/backend/src/test/java/zipgo/pet/domain/fixture/PetSizeFixture.java @@ -4,6 +4,13 @@ public class PetSizeFixture { + public static PetSize 대형견_생성() { + return PetSize + .builder() + .name("대형견") + .build(); + } + public static PetSize 소형견() { return PetSize.builder().name("소형견").build(); } diff --git a/backend/src/test/java/zipgo/pet/presentation/PetControllerTest.java b/backend/src/test/java/zipgo/pet/presentation/PetControllerTest.java new file mode 100644 index 000000000..4334e5e98 --- /dev/null +++ b/backend/src/test/java/zipgo/pet/presentation/PetControllerTest.java @@ -0,0 +1,123 @@ +package zipgo.pet.presentation; + +import com.epages.restdocs.apispec.ResourceSnippetDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.restdocs.restassured.RestDocumentationFilter; +import zipgo.common.acceptance.AcceptanceTest; +import zipgo.member.domain.repository.MemberRepository; +import zipgo.pet.domain.PetSize; +import zipgo.pet.domain.repository.BreedsRepository; +import zipgo.pet.domain.repository.PetSizeRepository; +import zipgo.pet.presentation.dto.request.CreatePetRequest; + +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.resourceDetails; +import static io.restassured.RestAssured.given; +import static io.restassured.http.ContentType.JSON; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static zipgo.pet.domain.fixture.BreedsFixture.품종_생성; +import static zipgo.pet.domain.fixture.PetSizeFixture.대형견_생성; +import static zipgo.review.fixture.MemberFixture.멤버_이름; + +class PetControllerTest extends AcceptanceTest { + + private ResourceSnippetDetails 문서_정보 = resourceDetails().summary("반려동물 등록하기").description("반려동물을 등록합니다."); + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BreedsRepository breedsRepository; + + @Autowired + private PetSizeRepository petSizeRepository; + + @BeforeEach + void setUp() { + memberRepository.save(멤버_이름("갈비")); + PetSize 대형견 = petSizeRepository.save(대형견_생성()); + breedsRepository.save(품종_생성("시베리안 허스키", 대형견)); + } + + @Nested + class 반려동물_등록_성공 { + + @Test + void 반려동물_등록_성공시_201_반환() { + // given + var token = jwtProvider.create("1"); + var 반려견_생성_요청 = new CreatePetRequest("상근이", "남", "아기사진", 3, "시베리안 허스키", "대형견", 57.8); + var 요청_준비 = given(spec).header("Authorization", "Bearer " + token).body(반려견_생성_요청) + .contentType(JSON).filter(성공_API_문서_생성()); + + // when + var 응답 = 요청_준비.when().post("/pets"); + + // then + 응답.then().assertThat().statusCode(CREATED.value()); + } + + } + + @Nested + class 반려동물_등록_실패 { + + @Test + void 존재하지_않는_견종일시_404_반환() { + var token = jwtProvider.create("1"); + var 반려견_생성_요청 = new CreatePetRequest("상근이", "남", "아기사진", 3, "존재하지 않는 종", "대형견", 57.8); + var 요청_준비 = given(spec).header("Authorization", "Bearer " + token).body(반려견_생성_요청) + .contentType(JSON).filter(API_예외응답_문서_생성()); + + // when + var 응답 = 요청_준비.when().post("/pets"); + + // then + 응답.then().assertThat().statusCode(NOT_FOUND.value()); + } + + @Test + void 존재하지_않는_견종_크기일시_404_반환() { + var token = jwtProvider.create("1"); + var 반려견_생성_요청 = new CreatePetRequest("상근이", "남", "아기사진", 3, "시베리안 허스키", "초초초 대형견", 57.8); + var 요청_준비 = given(spec).header("Authorization", "Bearer " + token).body(반려견_생성_요청) + .contentType(JSON).filter(API_예외응답_문서_생성()); + + // when + var 응답 = 요청_준비.when().post("/pets"); + + // then + 응답.then().assertThat().statusCode(NOT_FOUND.value()); + } + + } + + private RestDocumentationFilter 성공_API_문서_생성() { + return document("반려견 등록 - 성공", 문서_정보, + requestHeaders(headerWithName("Authorization").description("인증을 위한 JWT")), + requestFields( + fieldWithPath("name").description("반려견 이름").type(JsonFieldType.STRING), + fieldWithPath("age").description("반려견 나이").type(JsonFieldType.NUMBER), + fieldWithPath("image").description("이미지 링크").optional().type(JsonFieldType.STRING), + fieldWithPath("breed").description("견종").optional().type(JsonFieldType.STRING), + fieldWithPath("petSize").description("반려견 크기(소/중/대)").type(JsonFieldType.STRING), + fieldWithPath("gender").description("반려견 성별").type(JsonFieldType.STRING), + fieldWithPath("weight").description("반려견 몸무게").type(JsonFieldType.NUMBER)) + ); + } + + private RestDocumentationFilter API_예외응답_문서_생성() { + return document("반려견 등록 - 실패(없는 견종)", 문서_정보.responseSchema(에러_응답_형식), + requestHeaders(headerWithName("Authorization").description("인증을 위한 JWT"))); + } + +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index cacd57cd8..418a94dbe 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -26,7 +26,6 @@ oauth: client-id: this1-is2-zipgo3-test4-client5-id7 redirect-uri: test-redirect-uri client-secret: my_secret - logging: level: org: @@ -34,3 +33,10 @@ logging: type: descriptor: sql: trace +cloud: + aws: + s3: + bucket: 버킷이름 + zipgo-directory-name: 집고_디렉토리_이름 + pet-image-directory: 집고_디렉토리_이미지 + image-url: 집고_이미지_서버_유알엘 \ No newline at end of file