Skip to content

Commit

Permalink
feat: 기관 보고용 사용자 데이터 제공 API 개발 완료 (#107)
Browse files Browse the repository at this point in the history
  • Loading branch information
limehee authored Feb 18, 2025
1 parent 5d91c34 commit 488f2d8
Show file tree
Hide file tree
Showing 99 changed files with 3,494 additions and 717 deletions.
19 changes: 12 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ allprojects {
limit {
counter = "BRANCH"
value = "COVEREDRATIO"
minimum = "0.70".toBigDecimal()
minimum = "0.50".toBigDecimal()
}

limit {
Expand All @@ -123,14 +123,15 @@ allprojects {
}

excludes = listOf(
"com.stempo.**.Test*.*",
"com.stempo.**.*Test.*",
"com.stempo.**.*Dto.*",
"com.stempo.**.*Entity.*",
"com.stempo.**.*Test*",
"com.stempo.**.*Dto*",
"com.stempo.**.*Entity*",
"com.stempo.**.*Service.*",
"com.stempo.**.*Repository.*",
"com.stempo.**.*Exception.*",
"com.stempo.ApiApplication.*",
"com.stempo.**.*Exception*",
"com.stempo.**.AuthorizeRequestsCustomizer*",
"com.stempo.**.SecurityConfig*",
"com.stempo.ApiApplication*",
)
}
}
Expand Down Expand Up @@ -190,4 +191,8 @@ allprojects {
tasks.withType<JavaExec> {
ext["springConfigLocation"]?.let { systemProperty("spring.config.additional-location", it) }
}

tasks.named("checkstyleMain") {
dependsOn("compileTestJava")
}
}
4 changes: 4 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ object Dependencies {
"com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:${Versions.owaspJavaHtmlSanitizer}"
const val googleAuthenticator = "com.warrenstrange:googleauth:${Versions.googleAuthenticator}"
const val apacheHttpClient = "org.apache.httpcomponents.client5:httpclient5:${Versions.apacheHttpClient}"
const val querydslJpa = "com.querydsl:querydsl-jpa:${Versions.querydsl}"
const val querydslApt = "com.querydsl:querydsl-apt:${Versions.querydsl}"
const val jakartaAnnotationApi = "jakarta.annotation:jakarta.annotation-api"
const val jakartaPersistenceApi = "jakarta.persistence:jakarta.persistence-api"

// Test dependencies
const val springBootStarterTest = "org.springframework.boot:spring-boot-starter-test"
Expand Down
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ object Versions {
const val jacoco = "0.8.12"
const val apacheHttpClient = "5.2.3"
const val mockWebServer = "4.12.0"
const val querydsl = "5.1.0:jakarta"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.stempo.config;

import jakarta.validation.constraints.NotNull;
import java.io.FileNotFoundException;
import java.io.IOException;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.resource.PathResourceResolver;

public class CustomPathResourceResolver extends PathResourceResolver {

@Override
protected Resource getResource(@NotNull String resourcePath, @NotNull Resource location)
throws IOException {
Resource resource = location.createRelative(resourcePath);
if (resource.exists() && resource.isReadable()) {
return resource;
}
throw new FileNotFoundException("Resource not found: " + resourcePath);
}
}
23 changes: 4 additions & 19 deletions stempo-api/src/main/java/com/stempo/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
package com.stempo.config;

import com.stempo.interceptor.ApiLoggingInterceptor;
import jakarta.validation.constraints.NotNull;
import java.io.FileNotFoundException;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;

@Configuration
@RequiredArgsConstructor
Expand All @@ -31,20 +26,10 @@ public class WebConfig implements WebMvcConfigurer {
public void addResourceHandlers(ResourceHandlerRegistry registry) {
log.info("Resource UploadedFile Mapped : {} -> {}", fileUrl, filePath);
registry
.addResourceHandler(fileUrl + "/**")
.addResourceLocations("file://" + filePath + "/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(@NotNull String resourcePath, @NotNull Resource location)
throws IOException {
Resource resource = location.createRelative(resourcePath);
if (resource.exists() && resource.isReadable()) {
return resource;
}
throw new FileNotFoundException("Resource not found: " + resourcePath);
}
});
.addResourceHandler(fileUrl + "/**")
.addResourceLocations("file://" + filePath + "/")
.resourceChain(true)
.addResolver(new CustomPathResourceResolver());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.stempo.controller;

import com.stempo.dto.ApiResponse;
import com.stempo.dto.response.PersonalRhythmSettingsResponseDto;
import com.stempo.dto.response.PersonalTrainingSettingsResponseDto;
import com.stempo.dto.response.RecordReportResponseDto;
import com.stempo.dto.response.RhythmReportResponseDto;
import com.stempo.dto.response.UserDataResponseDto;
import com.stempo.service.RecordReportService;
import com.stempo.service.UserDataAggregationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Tag(name = "Admin Report", description = "사용자 활동 데이터 집계 및 분석")
public class AdminReportController {

private final UserDataAggregationService userDataAggregationService;
private final RecordReportService recordReportService;

@Operation(summary = "[A] 사용자 전체 이용 데이터 조회", description = "ROLE_ADMIN 이상의 권한이 필요함<br>"
+ "사용자의 보행 훈련 및 과제 데이터를 조회함")
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/v1/admin/report/user-data")
public ApiResponse<List<UserDataResponseDto>> getUserData(
@RequestParam(name = "deviceTags") List<String> deviceTags
) {
List<UserDataResponseDto> userData = userDataAggregationService.getUserData(deviceTags);
return ApiResponse.success(userData);
}

@Operation(summary = "[A] 사용자 보행 훈련 기록 조회", description = "ROLE_ADMIN 이상의 권한이 필요함")
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/v1/admin/report/user-data/training")
public ApiResponse<List<RecordReportResponseDto>> getRecordReport(
@RequestParam(name = "deviceTags") List<String> deviceTags,
@RequestParam(name = "startDate", required = false) LocalDate startDate,
@RequestParam(name = "endDate", required = false) LocalDate endDate
) {
List<RecordReportResponseDto> recordReport =
recordReportService.getRecordReport(deviceTags, startDate, endDate);
return ApiResponse.success(recordReport);
}

@Operation(summary = "[A] 사용자 보행 분석 지표 설정값 조회", description = "ROLE_ADMIN 이상의 권한이 필요함<br>"
+ "사용자의 첫 번째 보행 훈련 데이터와 과제 데이터를 조회함")
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/v1/admin/report/user-data/training-setting")
public ApiResponse<List<PersonalTrainingSettingsResponseDto>> getPersonalTrainingSettings(
@RequestParam(name = "deviceTags") List<String> deviceTags
) {
List<PersonalTrainingSettingsResponseDto> personalTrainingSettings =
userDataAggregationService.getPersonalTrainingSettings(deviceTags);
return ApiResponse.success(personalTrainingSettings);
}

@Operation(summary = "[A] 사용자 보행 훈련에 사용된 리듬 데이터 조회", description = "ROLE_ADMIN 이상의 권한이 필요함")
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/v1/admin/report/user-data/rhythm")
public ApiResponse<List<RhythmReportResponseDto>> getRhythmReport(
@RequestParam(name = "deviceTags") List<String> deviceTags,
@RequestParam(name = "startDate", required = false) LocalDate startDate,
@RequestParam(name = "endDate", required = false) LocalDate endDate
) {
List<RhythmReportResponseDto> recordReport =
recordReportService.getRhythmReport(deviceTags, startDate, endDate);
return ApiResponse.success(recordReport);
}

@Operation(summary = "[A] 사용자 맞춤형 리듬 설정값 조회", description = "ROLE_ADMIN 이상의 권한이 필요함<br>"
+ "사용자가 처음 애플리케이션을 실행할 때 온보딩 과정에서 추천받은 리듬 설정값과 마지막으로 실행한 보행 훈련에서 설정한 리듬 설정값을 조회함")
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/v1/admin/report/user-data/rhythm-setting")
public ApiResponse<List<PersonalRhythmSettingsResponseDto>> getPersonalRhythmSettings(
@RequestParam(name = "deviceTags") List<String> deviceTags
) {
List<PersonalRhythmSettingsResponseDto> personalRhythmSettings =
recordReportService.getPersonalRhythmSettings(deviceTags);
return ApiResponse.success(personalRhythmSettings);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.responses.ApiResponses;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
Expand Down Expand Up @@ -56,20 +58,20 @@ void setUp() {

// 'success' 필드 검증
Schema<?> successSchema = (Schema<?>) apiResponse.getContent()
.get("application/json")
.getSchema()
.getProperties()
.get("success");
.get("application/json")
.getSchema()
.getProperties()
.get("success");

assertThat(successSchema).isNotNull();
assertThat(successSchema.getExample()).isEqualTo(true);

// 'data' 필드 검증
Schema<?> dataSchema = (Schema<?>) apiResponse.getContent()
.get("application/json")
.getSchema()
.getProperties()
.get("data");
.get("application/json")
.getSchema()
.getProperties()
.get("data");

assertThat(dataSchema).isNotNull();
assertThat(dataSchema.getExample()).isEqualTo("test-data");
Expand All @@ -85,12 +87,82 @@ void setUp() {
assertThat(operation.getResponses().get("200")).isNull();
}

@Test
void SuccessApiResponse_애노테이션의_data가_유효한_JSON인_경우_파싱하여_반환한다() throws NoSuchMethodException {
// given
Method testMethod = this.getClass().getDeclaredMethod("annotatedJsonTestMethod");
SuccessApiResponse successApiResponse = testMethod.getAnnotation(SuccessApiResponse.class);
when(handlerMethod.getMethodAnnotation(SuccessApiResponse.class)).thenReturn(successApiResponse);

// when
customizer.customize(operation, handlerMethod);

// then
ApiResponse apiResponse = operation.getResponses().get("200");
assertThat(apiResponse).isNotNull();

Schema<?> dataSchema = (Schema<?>) apiResponse.getContent()
.get("application/json")
.getSchema()
.getProperties()
.get("data");
assertThat(dataSchema).isNotNull();

Object example = dataSchema.getExample();
assertThat(example).isInstanceOf(Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> exampleMap = (Map<String, Object>) example;
Map<String, Object> expectedMap = new LinkedHashMap<>();
expectedMap.put("key", "value");
assertThat(exampleMap).isEqualTo(expectedMap);
}

@Test
void SuccessApiResponse_애노테이션의_data가_빈문자열인_경우_null로_설정한다() throws NoSuchMethodException {
// given
Method testMethod = this.getClass().getDeclaredMethod("annotatedEmptyDataTestMethod");
SuccessApiResponse successApiResponse = testMethod.getAnnotation(SuccessApiResponse.class);
when(handlerMethod.getMethodAnnotation(SuccessApiResponse.class)).thenReturn(successApiResponse);

// when
customizer.customize(operation, handlerMethod);

// then
ApiResponse apiResponse = operation.getResponses().get("200");
assertThat(apiResponse).isNotNull();
Schema<?> dataSchema = (Schema<?>) apiResponse.getContent()
.get("application/json")
.getSchema()
.getProperties()
.get("data");
assertThat(dataSchema).isNotNull();
assertThat(dataSchema.getExample()).isNull();
}

// 기존 테스트용 애노테이션 (유효하지 않은 JSON인 경우)
@SuccessApiResponse(
description = "Success",
data = "test-data",
dataType = String.class,
dataDescription = "test-data-description")
description = "Success",
data = "test-data",
dataType = String.class,
dataDescription = "test-data-description")
private void annotatedTestMethod() {
}

// data가 유효한 JSON인 경우
@SuccessApiResponse(
description = "Success JSON",
data = "{\"key\": \"value\"}",
dataType = Map.class,
dataDescription = "JSON data description")
private void annotatedJsonTestMethod() {
}

// data가 빈 문자열인 경우
@SuccessApiResponse(
description = "Success Empty",
data = "",
dataType = Void.class,
dataDescription = "Empty data")
private void annotatedEmptyDataTestMethod() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.stempo.config;

import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.io.FileNotFoundException;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;

class CustomPathResourceResolverTest {

@Test
void 존재하는_리소스를_반환한다() throws IOException {
// given
CustomPathResourceResolver resolver = new CustomPathResourceResolver();
String resourcePath = "test.txt";
Resource location = mock(Resource.class);
Resource relativeResource = mock(Resource.class);
when(location.createRelative(resourcePath)).thenReturn(relativeResource);
when(relativeResource.exists()).thenReturn(true);
when(relativeResource.isReadable()).thenReturn(true);

// when
Resource result = resolver.getResource(resourcePath, location);

// then
assertSame(relativeResource, result, "존재하고 읽을 수 있는 리소스가 반환되어야 한다.");
}

@Test
void 존재하지_않는_리소스인_경우_예외를_던진다() throws IOException {
// given
CustomPathResourceResolver resolver = new CustomPathResourceResolver();
String resourcePath = "nonexistent.txt";
Resource location = mock(Resource.class);
Resource relativeResource = mock(Resource.class);
when(location.createRelative(resourcePath)).thenReturn(relativeResource);
when(relativeResource.exists()).thenReturn(false);
when(relativeResource.isReadable()).thenReturn(false);

// when, then
FileNotFoundException exception = assertThrows(
FileNotFoundException.class,
() -> resolver.getResource(resourcePath, location),
"존재하지 않는 리소스 호출 시 FileNotFoundException이 발생해야 한다."
);
assertTrue(exception.getMessage().contains("Resource not found: " + resourcePath));
}
}
Loading

0 comments on commit 488f2d8

Please sign in to comment.