Skip to content

Commit c4b7c1f

Browse files
authored
Merge pull request #161 from prgrms-aibe-devcourse/feature/metrics
feat: 프로메테우스 기능 추가 / S3 테스트 코드 추가 (피드백 버전)
2 parents 271f8ed + 7d7e46b commit c4b7c1f

7 files changed

Lines changed: 469 additions & 3 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ dependencies {
6767
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
6868
testImplementation 'org.testcontainers:junit-jupiter'
6969
testImplementation 'org.testcontainers:mysql'
70+
71+
// Metrics Collection (Prometheus)
72+
implementation 'io.micrometer:micrometer-registry-prometheus'
7073
}
7174

7275
// ─── 테스트용 MySQL 컨테이너 생명주기 태스크 ─────────────────────────────────

src/main/java/com/Rootin/global/config/SecurityConfig.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5252
.authorizeHttpRequests(auth -> auth
5353
.requestMatchers("/test", "/h2-console/**").permitAll()
5454
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
55-
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
55+
.requestMatchers("/actuator/health", "/actuator/health/**", "/actuator/prometheus").permitAll() // Test/매트릭 수집도구들
5656

5757
// 사용자 인증을 발급해주는 API 경로
5858
.requestMatchers(
@@ -139,8 +139,11 @@ public AuthenticationManager authenticationManager(
139139
@Bean
140140
public CorsConfigurationSource corsConfigurationSource() {
141141
CorsConfiguration configuration = new CorsConfiguration();
142+
// 도메인 허용
142143
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
144+
// HTTP 메서드 허용
143145
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
146+
// 헤더 허용
144147
configuration.setAllowedHeaders(List.of("*"));
145148
configuration.setAllowCredentials(true);
146149
configuration.setMaxAge(3600L);

src/main/resources/application.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ management:
3636
endpoints:
3737
web:
3838
exposure:
39-
include: health
39+
include: health, prometheus # CI/CD health check와 메트릭 수집도구 프로메테우스
4040
endpoint:
4141
health:
4242
show-details: never
@@ -77,7 +77,7 @@ spring:
7777
jpa:
7878
database-platform: org.hibernate.dialect.MySQLDialect
7979
hibernate:
80-
ddl-auto: update
80+
ddl-auto: validate
8181
show-sql: false
8282
properties:
8383
hibernate:
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package com.Rootin.domain.s3.controller;
2+
3+
import com.Rootin.domain.garden.service.PotService;
4+
import com.Rootin.domain.s3.dto.PresignedUrlRequest;
5+
import com.Rootin.global.config.TestSecurityConfig;
6+
import com.Rootin.global.exception.CustomException;
7+
import com.Rootin.global.jwt.JwtUserDetails;
8+
import com.Rootin.global.s3.S3Service;
9+
import com.fasterxml.jackson.databind.ObjectMapper;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.DisplayName;
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.params.ParameterizedTest;
14+
import org.junit.jupiter.params.provider.ValueSource;
15+
import org.springframework.beans.factory.annotation.Autowired;
16+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
17+
import org.springframework.boot.test.mock.mockito.MockBean;
18+
import org.springframework.context.annotation.Import;
19+
import org.springframework.http.MediaType;
20+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
21+
import org.springframework.test.context.ActiveProfiles;
22+
import org.springframework.test.web.servlet.MockMvc;
23+
24+
import java.util.List;
25+
26+
import static org.hamcrest.Matchers.containsString;
27+
import static org.mockito.ArgumentMatchers.anyString;
28+
import static org.mockito.ArgumentMatchers.eq;
29+
import static org.mockito.BDDMockito.doNothing;
30+
import static org.mockito.BDDMockito.doThrow;
31+
import static org.mockito.BDDMockito.given;
32+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
33+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
34+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
35+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
36+
37+
@WebMvcTest(PresignedUrlController.class)
38+
@ActiveProfiles("test")
39+
@Import(TestSecurityConfig.class)
40+
class PresignedUrlControllerTest {
41+
42+
@Autowired
43+
private MockMvc mockMvc;
44+
45+
@Autowired
46+
private ObjectMapper objectMapper;
47+
48+
@MockBean
49+
private S3Service s3Service;
50+
51+
@MockBean
52+
private PotService potService;
53+
54+
private JwtUserDetails userDetails;
55+
56+
private static final Long USER_ID = 1L;
57+
private static final Long POT_ID = 10L;
58+
private static final String PRESIGNED_URL = "https://rootin-bucket.s3.ap-northeast-2.amazonaws.com/til-images/1/10/uuid.jpg?X-Amz-Signature=abc";
59+
private static final String IMAGE_URL = "https://rootin-bucket.s3.ap-northeast-2.amazonaws.com/til-images/1/10/uuid.jpg";
60+
61+
@BeforeEach
62+
void setUp() {
63+
userDetails = new JwtUserDetails(
64+
USER_ID,
65+
"test@rootin.com",
66+
List.of(new SimpleGrantedAuthority("ROLE_USER"))
67+
);
68+
}
69+
70+
@Test
71+
@DisplayName("유효한 JPEG 요청이면 presignedUrl과 imageUrl을 포함한 200 OK를 반환한다")
72+
void getPresignedUrl_jpeg_success() throws Exception {
73+
PresignedUrlRequest request = buildRequest("image/jpeg", POT_ID);
74+
75+
doNothing().when(potService).validateOwnership(USER_ID, POT_ID);
76+
given(s3Service.generatePresignedPutUrl(anyString(), eq("image/jpeg"))).willReturn(PRESIGNED_URL);
77+
given(s3Service.getFileUrl(anyString())).willReturn(IMAGE_URL);
78+
79+
mockMvc.perform(post("/tils/image/presigned-url")
80+
.with(user(userDetails))
81+
.contentType(MediaType.APPLICATION_JSON)
82+
.content(objectMapper.writeValueAsString(request)))
83+
.andExpect(status().isOk())
84+
.andExpect(jsonPath("$.success").value(true))
85+
.andExpect(jsonPath("$.data.presignedUrl").value(containsString("X-Amz-Signature")))
86+
.andExpect(jsonPath("$.data.imageUrl").value(containsString("til-images")));
87+
}
88+
89+
@ParameterizedTest
90+
@ValueSource(strings = {"image/png", "image/webp"})
91+
@DisplayName("image/png, image/webp contentType도 정상 처리되어 200 OK를 반환한다")
92+
void getPresignedUrl_supportedContentTypes_success(String contentType) throws Exception {
93+
PresignedUrlRequest request = buildRequest(contentType, POT_ID);
94+
95+
doNothing().when(potService).validateOwnership(USER_ID, POT_ID);
96+
given(s3Service.generatePresignedPutUrl(anyString(), eq(contentType))).willReturn(PRESIGNED_URL);
97+
given(s3Service.getFileUrl(anyString())).willReturn(IMAGE_URL);
98+
99+
mockMvc.perform(post("/tils/image/presigned-url")
100+
.with(user(userDetails))
101+
.contentType(MediaType.APPLICATION_JSON)
102+
.content(objectMapper.writeValueAsString(request)))
103+
.andExpect(status().isOk())
104+
.andExpect(jsonPath("$.success").value(true));
105+
}
106+
107+
@Test
108+
@DisplayName("contentType이 blank이면 @NotBlank 검증에 실패해 400 Bad Request를 반환한다")
109+
void getPresignedUrl_blankContentType_returns400() throws Exception {
110+
PresignedUrlRequest request = buildRequest("", POT_ID);
111+
112+
mockMvc.perform(post("/tils/image/presigned-url")
113+
.with(user(userDetails))
114+
.contentType(MediaType.APPLICATION_JSON)
115+
.content(objectMapper.writeValueAsString(request)))
116+
.andExpect(status().isBadRequest());
117+
}
118+
119+
@Test
120+
@DisplayName("potId가 null이면 @NotNull 검증에 실패해 400 Bad Request를 반환한다")
121+
void getPresignedUrl_nullPotId_returns400() throws Exception {
122+
String requestJson = """
123+
{ "contentType": "image/jpeg" }
124+
""";
125+
126+
mockMvc.perform(post("/tils/image/presigned-url")
127+
.with(user(userDetails))
128+
.contentType(MediaType.APPLICATION_JSON)
129+
.content(requestJson))
130+
.andExpect(status().isBadRequest());
131+
}
132+
133+
@Test
134+
@DisplayName("지원하지 않는 contentType(image/gif)이면 400 Bad Request를 반환한다")
135+
void getPresignedUrl_unsupportedContentType_returns400() throws Exception {
136+
PresignedUrlRequest request = buildRequest("image/gif", POT_ID);
137+
138+
doNothing().when(potService).validateOwnership(USER_ID, POT_ID);
139+
140+
mockMvc.perform(post("/tils/image/presigned-url")
141+
.with(user(userDetails))
142+
.contentType(MediaType.APPLICATION_JSON)
143+
.content(objectMapper.writeValueAsString(request)))
144+
.andExpect(status().isBadRequest());
145+
}
146+
147+
@Test
148+
@DisplayName("화분 소유자가 아닌 경우 403 Forbidden을 반환한다")
149+
void getPresignedUrl_notOwner_returns403() throws Exception {
150+
PresignedUrlRequest request = buildRequest("image/jpeg", POT_ID);
151+
152+
doThrow(CustomException.forbidden("해당 화분에 접근할 권한이 없습니다."))
153+
.when(potService).validateOwnership(USER_ID, POT_ID);
154+
155+
mockMvc.perform(post("/tils/image/presigned-url")
156+
.with(user(userDetails))
157+
.contentType(MediaType.APPLICATION_JSON)
158+
.content(objectMapper.writeValueAsString(request)))
159+
.andExpect(status().isForbidden());
160+
}
161+
162+
@Test
163+
@DisplayName("존재하지 않는 화분 ID이면 404 Not Found를 반환한다")
164+
void getPresignedUrl_potNotFound_returns404() throws Exception {
165+
Long unknownPotId = 999L;
166+
PresignedUrlRequest request = buildRequest("image/jpeg", unknownPotId);
167+
168+
doThrow(CustomException.notFound("존재하지 않는 화분입니다. ID: " + unknownPotId))
169+
.when(potService).validateOwnership(USER_ID, unknownPotId);
170+
171+
mockMvc.perform(post("/tils/image/presigned-url")
172+
.with(user(userDetails))
173+
.contentType(MediaType.APPLICATION_JSON)
174+
.content(objectMapper.writeValueAsString(request)))
175+
.andExpect(status().isNotFound());
176+
}
177+
178+
@Test
179+
@DisplayName("요청 바디가 없으면 400 Bad Request를 반환한다")
180+
void getPresignedUrl_emptyBody_returns400() throws Exception {
181+
mockMvc.perform(post("/tils/image/presigned-url")
182+
.with(user(userDetails))
183+
.contentType(MediaType.APPLICATION_JSON)
184+
.content("{}"))
185+
.andExpect(status().isBadRequest());
186+
}
187+
188+
private PresignedUrlRequest buildRequest(String contentType, Long potId) {
189+
PresignedUrlRequest request = new PresignedUrlRequest();
190+
request.setContentType(contentType);
191+
request.setPotId(potId);
192+
return request;
193+
}
194+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.Rootin.global.s3;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
import static org.assertj.core.api.Assertions.assertThatNoException;
9+
10+
class S3ConfigTest {
11+
12+
private final S3Config s3Config = new S3Config();
13+
14+
@Test
15+
@DisplayName("명시적 accessKey/secretKey가 있으면 StaticCredentialsProvider로 S3Presigner 빈이 생성된다")
16+
void s3Presigner_withExplicitCredentials_createsBean() {
17+
S3Properties props = buildProps("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", null);
18+
19+
S3Presigner presigner = s3Config.s3Presigner(props);
20+
21+
assertThat(presigner).isNotNull();
22+
presigner.close();
23+
}
24+
25+
@Test
26+
@DisplayName("accessKey가 빈 문자열이면 DefaultCredentialsProvider로 S3Presigner 빈이 생성된다")
27+
void s3Presigner_withEmptyCredentials_createsBean() {
28+
S3Properties props = buildProps("", "", null);
29+
30+
// DefaultCredentialsProvider는 lazy 로딩이므로 빈 생성 단계에서 예외가 발생하지 않는다
31+
assertThatNoException().isThrownBy(() -> {
32+
S3Presigner presigner = s3Config.s3Presigner(props);
33+
presigner.close();
34+
});
35+
}
36+
37+
@Test
38+
@DisplayName("accessKey가 null이면 DefaultCredentialsProvider로 대체된다")
39+
void s3Presigner_withNullCredentials_createsBean() {
40+
S3Properties props = buildProps(null, null, null);
41+
42+
assertThatNoException().isThrownBy(() -> {
43+
S3Presigner presigner = s3Config.s3Presigner(props);
44+
presigner.close();
45+
});
46+
}
47+
48+
@Test
49+
@DisplayName("LocalStack 엔드포인트가 설정되면 path-style 접근으로 S3Presigner 빈이 생성된다")
50+
void s3Presigner_withLocalStackEndpoint_createsBean() {
51+
S3Properties props = buildProps("test-key", "test-secret", "http://localhost:4566");
52+
53+
S3Presigner presigner = s3Config.s3Presigner(props);
54+
55+
assertThat(presigner).isNotNull();
56+
presigner.close();
57+
}
58+
59+
@Test
60+
@DisplayName("엔드포인트가 빈 문자열이면 표준 AWS 엔드포인트로 S3Presigner 빈이 생성된다")
61+
void s3Presigner_withBlankEndpoint_createsStandardBean() {
62+
S3Properties props = buildProps("test-key", "test-secret", "");
63+
64+
S3Presigner presigner = s3Config.s3Presigner(props);
65+
66+
assertThat(presigner).isNotNull();
67+
presigner.close();
68+
}
69+
70+
@Test
71+
@DisplayName("리전이 us-east-1이어도 정상적으로 S3Presigner 빈이 생성된다")
72+
void s3Presigner_withDifferentRegion_createsBean() {
73+
S3Properties.Credentials creds = new S3Properties.Credentials();
74+
creds.setAccessKey("test-key");
75+
creds.setSecretKey("test-secret");
76+
77+
S3Properties.S3 s3 = new S3Properties.S3();
78+
s3.setBucket("us-test-bucket");
79+
80+
S3Properties props = new S3Properties();
81+
props.setRegion("us-east-1");
82+
props.setCredentials(creds);
83+
props.setS3(s3);
84+
85+
S3Presigner presigner = s3Config.s3Presigner(props);
86+
87+
assertThat(presigner).isNotNull();
88+
presigner.close();
89+
}
90+
91+
private S3Properties buildProps(String accessKey, String secretKey, String endpoint) {
92+
S3Properties.Credentials credentials = new S3Properties.Credentials();
93+
credentials.setAccessKey(accessKey);
94+
credentials.setSecretKey(secretKey);
95+
96+
S3Properties.S3 s3 = new S3Properties.S3();
97+
s3.setBucket("test-bucket");
98+
if (endpoint != null) {
99+
s3.setEndpoint(endpoint);
100+
}
101+
102+
S3Properties props = new S3Properties();
103+
props.setRegion("ap-northeast-2");
104+
props.setCredentials(credentials);
105+
props.setS3(s3);
106+
107+
return props;
108+
}
109+
}

0 commit comments

Comments
 (0)