diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java index 8557ef5..da2cda1 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java @@ -3,5 +3,5 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "app.s3") -public record S3Properties(String region) { +public record S3Properties(String region, String bucket, String publicBaseUrl) { } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageApi.java new file mode 100644 index 0000000..f9b63d4 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageApi.java @@ -0,0 +1,38 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller; + +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "관리자 미디어 API") +public interface AdminImageApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", description = "5MB 초과(AC-4.1) 또는 허용 외 확장자(AC-4.2)"), + }) + @Operation(summary = "presigned URL 발급", description = "브라우저가 반환된 uploadUrl로 S3에 직접 PUT한다.") + PresignedUrlResponse issuePresignedUrl(@Valid PresignedUrlRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 이미지"), + }) + @Operation(summary = "업로드 완료 등록", description = "S3 PUT이 끝난 뒤 호출해야 라이브러리 목록에 노출된다(AC-4.3, AC-4.4).") + ImageCompleteResponse complete(Long id); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + }) + @Operation(summary = "미디어 라이브러리 목록", description = "complete된 이미지만 반환한다(AC-4.4).") + Page getImages(ImagePurpose purpose, Pageable pageable); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageController.java new file mode 100644 index 0000000..d1ccc55 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageController.java @@ -0,0 +1,45 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller; + +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import com.bcsdlab.bcsdinternalapiv2.media.service.AdminImageService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/images") +@RequiredArgsConstructor +public class AdminImageController implements AdminImageApi { + + private final AdminImageService adminImageService; + + @Override + @PostMapping("/presigned-url") + public PresignedUrlResponse issuePresignedUrl(@Valid @RequestBody PresignedUrlRequest request) { + return adminImageService.issuePresignedUrl(request); + } + + @Override + @PostMapping("/{id}/complete") + public ImageCompleteResponse complete(@PathVariable Long id) { + return adminImageService.complete(id); + } + + @Override + @GetMapping + public Page getImages(@RequestParam ImagePurpose purpose, Pageable pageable) { + return adminImageService.getImages(purpose, pageable); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/request/PresignedUrlRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/request/PresignedUrlRequest.java new file mode 100644 index 0000000..eb587a5 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/request/PresignedUrlRequest.java @@ -0,0 +1,27 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request; + +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +/** + * 확장자·용량 검증은 여기서 끝낸다(AC-4.1, AC-4.2). 서비스는 값을 그대로 신뢰한다. + */ +public record PresignedUrlRequest( + @NotBlank + @Pattern(regexp = "(?i).+\\.(png|jpe?g|webp|svg)$", message = "허용되지 않는 확장자입니다.") + String fileName, + + @NotBlank + String contentType, + + @NotNull + @Max(5L * 1024 * 1024) + Long byteSize, + + @NotNull + ImagePurpose purpose +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageAssetResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageAssetResponse.java new file mode 100644 index 0000000..626bfed --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageAssetResponse.java @@ -0,0 +1,16 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; +import java.time.Instant; + +public record ImageAssetResponse( + Long id, + String url, + String originalName, + Instant createdAt +) { + public static ImageAssetResponse from(ImageAsset imageAsset) { + return new ImageAssetResponse( + imageAsset.getId(), imageAsset.getUrl(), imageAsset.getOriginalName(), imageAsset.getCreatedAt()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageCompleteResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageCompleteResponse.java new file mode 100644 index 0000000..462facc --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageCompleteResponse.java @@ -0,0 +1,6 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response; + +public record ImageCompleteResponse( + String url +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/PresignedUrlResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/PresignedUrlResponse.java new file mode 100644 index 0000000..bfff5ce --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/PresignedUrlResponse.java @@ -0,0 +1,8 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response; + +public record PresignedUrlResponse( + Long imageId, + String uploadUrl, + String publicUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImageAsset.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImageAsset.java new file mode 100644 index 0000000..366a919 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImageAsset.java @@ -0,0 +1,85 @@ +package com.bcsdlab.bcsdinternalapiv2.media.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 미디어 라이브러리 한 항목(ADR-009). 콘텐츠 테이블과 FK로 묶지 않는다 — 이미지 행 삭제가 + * 콘텐츠를 깨뜨리지 않게 하기 위해서다. {@code is_confirmed}가 true인(=업로드가 + * {@code complete}로 끝난) 행만 라이브러리 목록에 보인다(AC-4.3, AC-4.4). + * + *

{@code image_asset} 테이블에는 {@code updated_at}이 없다 — 업로드 후 수정 없이 + * 생성/확정만 하므로 {@link com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity}를 쓰지 + * 않고 직접 매핑한다. soft delete도 쓰지 않는다(라이브러리 항목은 물리 삭제 대상이 아니다). + */ +@Getter +@Entity +@Table(name = "image_asset") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ImageAsset { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "s3_key", nullable = false, updatable = false, unique = true) + private String s3Key; + + @Column(name = "url", nullable = false, updatable = false) + private String url; + + @Column(name = "original_name", nullable = false, updatable = false) + private String originalName; + + @Column(name = "content_type", nullable = false, updatable = false) + private String contentType; + + @Column(name = "byte_size", nullable = false, updatable = false) + private long byteSize; + + @Enumerated(EnumType.STRING) + @Column(name = "purpose", nullable = false, updatable = false) + private ImagePurpose purpose; + + @Column(name = "is_confirmed", nullable = false) + private boolean confirmed; + + @Column(name = "uploaded_by", updatable = false) + private Long uploadedBy; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Builder + private ImageAsset(String s3Key, String url, String originalName, String contentType, long byteSize, + ImagePurpose purpose, Long uploadedBy) { + this.s3Key = s3Key; + this.url = url; + this.originalName = originalName; + this.contentType = contentType; + this.byteSize = byteSize; + this.purpose = purpose; + this.confirmed = false; + this.uploadedBy = uploadedBy; + } + + public void confirm() { + this.confirmed = true; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImagePurpose.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImagePurpose.java new file mode 100644 index 0000000..0db7dd2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImagePurpose.java @@ -0,0 +1,10 @@ +package com.bcsdlab.bcsdinternalapiv2.media.model; + +public enum ImagePurpose { + TRACK_HERO, + STUDY_ICON, + TECH_ICON, + ACTIVITY, + ACTIVITY_CONTENT, + ETC +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/repository/ImageAssetRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/repository/ImageAssetRepository.java new file mode 100644 index 0000000..ffc9889 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/repository/ImageAssetRepository.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.media.repository; + +import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageAssetRepository extends JpaRepository { + + Page findAllByPurposeAndConfirmedTrueOrderByCreatedAtDesc(ImagePurpose purpose, Pageable pageable); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java new file mode 100644 index 0000000..b6ebe06 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java @@ -0,0 +1,93 @@ +package com.bcsdlab.bcsdinternalapiv2.media.service; + +import com.bcsdlab.bcsdinternalapiv2.global.config.S3Properties; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse; +import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaException; +import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaExceptionType; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import com.bcsdlab.bcsdinternalapiv2.media.repository.ImageAssetRepository; +import java.time.Duration; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +/** + * presigned URL 발급 → 브라우저가 S3에 직접 PUT → complete 등록(ADR-009). 서버는 이미지 + * 바이트를 경유하지 않는다 — API 컨테이너 메모리 한도(256MiB) 때문에 서버 측 이미지 + * 변환·버퍼링을 도입하지 않는다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminImageService { + + private static final Duration UPLOAD_URL_VALIDITY = Duration.ofMinutes(10); + + private final ImageAssetRepository imageAssetRepository; + private final S3Presigner s3Presigner; + private final S3Properties s3Properties; + + @Transactional + public PresignedUrlResponse issuePresignedUrl(PresignedUrlRequest request) { + String s3Key = "%s/%s.%s".formatted( + request.purpose().name().toLowerCase(), UUID.randomUUID(), extensionOf(request.fileName())); + String publicUrl = s3Properties.publicBaseUrl() + "/" + s3Key; + + ImageAsset image = imageAssetRepository.save(ImageAsset.builder() + .s3Key(s3Key) + .url(publicUrl) + .originalName(request.fileName()) + .contentType(request.contentType()) + .byteSize(request.byteSize()) + .purpose(request.purpose()) + .build()); + + String uploadUrl = presignPutUrl(s3Key, request.contentType()); + return new PresignedUrlResponse(image.getId(), uploadUrl, publicUrl); + } + + @Transactional + public ImageCompleteResponse complete(Long id) { + ImageAsset image = findOrThrow(id); + image.confirm(); + return new ImageCompleteResponse(image.getUrl()); + } + + public Page getImages(ImagePurpose purpose, Pageable pageable) { + return imageAssetRepository.findAllByPurposeAndConfirmedTrueOrderByCreatedAtDesc(purpose, pageable) + .map(ImageAssetResponse::from); + } + + private String presignPutUrl(String s3Key, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Properties.bucket()) + .key(s3Key) + .contentType(contentType) + .build(); + PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(UPLOAD_URL_VALIDITY) + .putObjectRequest(putObjectRequest) + .build()); + return presigned.url().toString(); + } + + private String extensionOf(String fileName) { + return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + } + + private ImageAsset findOrThrow(Long id) { + return imageAssetRepository.findById(id) + .orElseThrow(() -> new MediaException(MediaExceptionType.IMAGE_NOT_FOUND)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 16f9d74..7c0592f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -47,3 +47,5 @@ app: ses-region: ${AWS_SES_REGION:us-west-2} s3: region: ${AWS_S3_REGION:us-west-2} + bucket: ${AWS_S3_BUCKET:bcsd-internal} + public-base-url: ${AWS_S3_PUBLIC_BASE_URL:https://image.bcsdlab.com} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java new file mode 100644 index 0000000..999d6ba --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java @@ -0,0 +1,158 @@ +package com.bcsdlab.bcsdinternalapiv2.media; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.media.repository.ImageAssetRepository; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +class AdminImageIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private ImageAssetRepository imageAssetRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private S3Presigner s3Presigner; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + jdbcTemplate.update("delete from image_asset"); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + Member admin = memberRepository.save(Member.builder() + .studentNumber("20231111") + .password(passwordEncoder.encode(RAW_PASSWORD)) + .name("관리자") + .track(backend) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email("admin@bcsd.club") + .status(MemberStatus.ACTIVE) + .build()); + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"20231111\",\"password\":\"%s\",\"rememberMe\":false}" + .formatted(RAW_PASSWORD))) + .andReturn().getResponse().getContentAsString(); + adminToken = objectMapper.readTree(body).get("accessToken").asText(); + memberRepository.updateRole(admin.getId(), MemberRole.ADMIN); + + PresignedPutObjectRequest presigned = mock(PresignedPutObjectRequest.class); + when(presigned.url()).thenReturn(URI.create("https://s3.example.com/upload?X-Amz-Signature=fake").toURL()); + when(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))) + .thenReturn(presigned); + } + + @Test + @DisplayName("AC-4.1 5MB를 초과하면 400이다") + void 용량_초과는_400() throws Exception { + mockMvc.perform(post("/v1/admin/images/presigned-url") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileName\":\"hero.png\",\"contentType\":\"image/png\"," + + "\"byteSize\":6000000,\"purpose\":\"TRACK_HERO\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("AC-4.2 허용 외 확장자는 400이다") + void 허용외_확장자는_400() throws Exception { + mockMvc.perform(post("/v1/admin/images/presigned-url") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileName\":\"malware.exe\",\"contentType\":\"application/octet-stream\"," + + "\"byteSize\":1000,\"purpose\":\"TRACK_HERO\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("AC-4.3, AC-4.4 complete 전에는 라이브러리에 없고, complete 후에는 조회된다") + void complete_전후_라이브러리_노출() throws Exception { + String body = mockMvc.perform(post("/v1/admin/images/presigned-url") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileName\":\"hero.png\",\"contentType\":\"image/png\"," + + "\"byteSize\":1000,\"purpose\":\"TRACK_HERO\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.uploadUrl").exists()) + .andExpect(jsonPath("$.publicUrl").exists()) + .andReturn().getResponse().getContentAsString(); + long imageId = objectMapper.readTree(body).get("imageId").asLong(); + + mockMvc.perform(get("/v1/admin/images") + .header("Authorization", "Bearer " + adminToken) + .queryParam("purpose", "TRACK_HERO")) + .andExpect(jsonPath("$.content.length()").value(0)); + + mockMvc.perform(post("/v1/admin/images/" + imageId + "/complete") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.url").exists()); + + mockMvc.perform(get("/v1/admin/images") + .header("Authorization", "Bearer " + adminToken) + .queryParam("purpose", "TRACK_HERO")) + .andExpect(jsonPath("$.content.length()").value(1)); + + assertThat(imageAssetRepository.findById(imageId).orElseThrow().isConfirmed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 이미지의 complete는 404다") + void 존재하지_않는_이미지_complete는_404() throws Exception { + mockMvc.perform(post("/v1/admin/images/999999/complete") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()); + } +}