Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@
> Mysql
> Redis

### Junit5 테스트 환경
* DB : H2
* Redis : Testcontainers

### jacoco 보고서
![jacoco_test_report.png](docs%2Fimg%2Fjacoco_test_report.png)

### jacoco_report_aggregation 보고서
![jacoco_report_aggregation.png](docs%2Fimg%2Fjacoco_report_aggregation.png)

### JaCoCo
* `build.gradle.kts` 파일 `plugins`에 `id("jacoco")` 추가 후 `gradle`을 다시 빌드해야 한다.
* 그 뒤 하단에 `jacoco`, `tasks.jacocoTestCoverageVerification`, `tasks.jacocoTestReport`를 작성
* 빌드를 다시하지 않으면 빨간줄이 뜬다.
* 테스트 실행 및 보고서 생성 : `./gradlew clean test jacocoTestReport --console verbose`
* 테스트 실행 및 보고서 생성(커버리지 충족 확인) : `./gradlew clean test jacocoTestReport jacocoTestCoverageVerification --console verbose`
* `build.gradle.kts`파일에 보고서 저장경로를 설정하여 `/build/reports/jacoco/index.html` 해당파일을 확인하면 된다.
* `html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco")) //저장경로 설정`
* jacoco-report-aggregation 플러그인을 사용해 멀티모듈의 보고서를 하나로 묶어서 생성 가능
* jacoco-report-aggregation 플러그인 사용시 명령어 : `./gradlew testCodeCoverageReport`
* 저장 경로 : 위 플러그인 설정한 모듈 build 폴더
* `cinema-adapter/build/reports/jacoco/testCodeCoverageReport/html/index.html`

### 규칙
* `infrastruct` 계층에서의 결과값은 `domain model` 혹은 `Projection(필요한 속성만 조회)` 객체 로 리턴한다.
* `domain model`에서 `Dto`로 변환은 `application` 계층에서 한다.
Expand Down Expand Up @@ -471,7 +494,12 @@ constant_load ✓ [======================================] 000/100 VUs 10m0s 1
* 로그 확인 : $docker-compose logs -f // `-f`옵션을 주면 실시간
* 특정 서비스 시작 : $docker-compose start <서비스 이름>
* 특정 서비스 종료 : $docker-compose stop <서비스 이름>

* redis 접속 : docker exec -it redis_cinema redis-cli // redis_cinema = 컨테이너명
* redis 비밀번호 있는 경우 위 명령어 후 : AUTH 비밀번호
* 현재 존재하는 키 전체보기 : KEYS *
* 특정키 조회 : KEYS rate_limit:0:0:0:0:0:0:0:1
* 특정 패턴 조회 : KEYS rate_limit:*
* 모든키 삭제 : FLUSHALL

--------------------------------------------------------------
### 적용 아키텍처
Expand Down
58 changes: 57 additions & 1 deletion cinema-adapter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ plugins {
id("java")
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
id("java-test-fixtures") // 테스트 픽스처 활성화
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

텍스트 픽스처를 활용해주셨네요! 놓치기 쉬운 부분인데 세심하게 구현해 주셨어요!

//id("jacoco") // JaCoCo 플러그인 추가
id("jacoco-report-aggregation") // JaCoCo 멀티모듈 리포트 통합하기 위한 플러그인 (jacoco 플러그인을 내부적으로 포함)
}

group = "com.hanghae"
version = "0.0.1-SNAPSHOT"

jacoco {
toolVersion = "0.8.10"
}

repositories {
mavenCentral()
}
Expand All @@ -16,8 +23,57 @@ dependencies {
implementation(project(":cinema-infrastructure")) // RepositoryPort 구현체를 찾지 못해서 추가
implementation("org.springframework.boot:spring-boot-starter-web") // web
implementation("org.springframework.boot:spring-boot-starter-data-jpa") //jpa 레포지토리 의존성 문제로 추가 (@EnableJpaRepositories)
testFixturesImplementation(project(":cinema-domain")) // testFixtures에서 도메인 객체 사용 위함
testFixturesImplementation(project(":cinema-application")) // testFixtures에서 application 객체 사용 위함
// testFixturesImplementation로 의존성 추가한 계층은 /src/testFixtures 하위 경로에서만 사용 가능 하다.
// 양쪽 계층에 테스트 픽스처 활성화 설정(id("java-test-fixtures"))이 되어 있어야 한다.

testImplementation("org.springframework.boot:spring-boot-starter-data-redis") // test 코드에서 RedisTemplate 사용 위함

testImplementation ("org.testcontainers:testcontainers:1.19.3") //테스트 컨테이너
testImplementation ("org.testcontainers:junit-jupiter:1.19.3")

// JaCoCo Report Aggregation에서만 특정 모듈을 포함하도록 설정
jacocoAggregation(project(":cinema-domain"))
jacocoAggregation(project(":cinema-application"))
jacocoAggregation(project(":cinema-infrastructure"))
jacocoAggregation(project(":cinema-adapter"))
}

tasks.test {
useJUnitPlatform()
}
}

//jacoco-report-aggregation 관련 추가
tasks.check {
dependsOn(tasks.named<JacocoReport>("testCodeCoverageReport")) // <2>
}


tasks.jacocoTestReport {
dependsOn(tasks.test) // test 실행 후 리포트 생성

reports {
xml.required.set(false) // XML 리포트 비활성화
csv.required.set(false) // csv 리포트 비활성화
html.required.set(true) // HTML 리포트 활성화
html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco")) //저장경로 설정
}
}

tasks.jacocoTestCoverageVerification {
dependsOn(tasks.jacocoTestReport)

violationRules {
rule {
element = "CLASS"
limit {
counter = "BRANCH"
value = "COVEREDRATIO"
//minimum = BigDecimal("0.60") // 최소 60% 커버리지 필요
//excludes = listOf("com.hanghae.adapter.web.MovieController") // 테스트코드 미구현으로 검증 제외
minimum = BigDecimal("0.0") // 테스트코드 미구현으로 검증 제외
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.hanghae.adapter.web;

import com.hanghae.application.dto.*;
import com.hanghae.application.port.in.MovieReservationService;
import com.hanghae.application.dto.request.MovieScheduleRequestDto;
import com.hanghae.application.dto.response.MovieScheduleResponseDto;
import com.hanghae.application.dto.response.ShowingMovieScheduleResponseDto;
import com.hanghae.application.port.in.MovieScheduleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
Expand All @@ -13,30 +17,36 @@
@RequestMapping("/api")
public class MovieController {
private final MovieScheduleService movieScheduleService;
private final MovieReservationService movieReservationService;

//영화 상영 시간표 조회
@GetMapping("/v1/movie-schedules")
public ApiResponse<List<MovieScheduleResponseDto>> getMovieSchedules() {
return movieScheduleService.getMovieSchedules();
public ResponseEntity<ApiResponse<List<MovieScheduleResponseDto>>> getMovieSchedules() {
ApiResponse<List<MovieScheduleResponseDto>> response = movieScheduleService.getMovieSchedules();

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
}

//영화별 상영 시간표 조회 (grouping)
@GetMapping("/v2/movie-schedules")
public ApiResponse<List<ShowingMovieScheduleResponseDto>> getShowingMovieSchedules(@ModelAttribute MovieScheduleRequestDto requestDto) {
return movieScheduleService.getShowingMovieSchedules(requestDto);
}
public ResponseEntity<ApiResponse<List<ShowingMovieScheduleResponseDto>>> getShowingMovieSchedules(@ModelAttribute MovieScheduleRequestDto requestDto, HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}

//영화 예약
// TODO :: 좌석선택API와 영화예약API 분리
@PostMapping("/v1/movie-reservation")
public ApiResponse<Void> saveMovieReservation(@RequestBody MovieReservationRequestDto requestDto) {
return movieReservationService.saveMovieReservation(requestDto);
ApiResponse<List<ShowingMovieScheduleResponseDto>> response = movieScheduleService.getShowingMovieSchedules(requestDto, ip);

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
}

//redis 캐시삭제 (테스트용)
@GetMapping("/test/evict-cache")
public ApiResponse<Void> evictCache() {
return movieScheduleService.evictShowingMovieCache();
public ResponseEntity<ApiResponse<Void>> evictCache() {
ApiResponse<Void> response = movieScheduleService.evictShowingMovieCache();

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.hanghae.adapter.web;

import com.hanghae.application.dto.ApiResponse;
import com.hanghae.application.dto.request.MovieReservationRequestDto;
import com.hanghae.application.enums.HttpStatusCode;
import com.hanghae.application.port.in.MovieReservationService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class ReservationController {
private final MovieReservationService movieReservationService;

//영화 예약
// TODO :: 좌석선택API와 영화예약API 분리
@PostMapping("/v1/movie-reservation")
public ResponseEntity<ApiResponse<Void>> saveMovieReservation(@RequestBody MovieReservationRequestDto requestDto) {
ApiResponse<Void> response = movieReservationService.saveMovieReservation(requestDto);

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.hanghae.adapter.web.exception;

import com.hanghae.application.dto.ApiResponse;
import com.hanghae.application.enums.HttpStatusCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -14,21 +15,31 @@ public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException e) {
log.error("IllegalArgumentException occurred: {}", e.getMessage(), e); // 로그 추가
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatus.BAD_REQUEST.value());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiResponse);
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.BAD_REQUEST);
return ResponseEntity.status(HttpStatusCode.BAD_REQUEST.getCode()).body(apiResponse);
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
log.error("HttpMessageNotReadableException occurred: {}", e.getMessage(), e); // 로그 추가
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatus.BAD_REQUEST.value());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiResponse);
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.BAD_REQUEST);
return ResponseEntity.status(HttpStatusCode.BAD_REQUEST.getCode()).body(apiResponse);
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Void>> handleRuntimeException(RuntimeException e) {
log.error("RuntimeException occurred: {}", e.getMessage(), e); // 로그 추가
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR.value());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(apiResponse);
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.INTERNAL_SERVER_ERROR);
return ResponseEntity.status(HttpStatusCode.INTERNAL_SERVER_ERROR.getCode()).body(apiResponse);
}

/**
* 모든 예외를 처리하는 범용 핸들러 (위의 예외에 해당하지 않는 경우)
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception e) {
log.error("Unhandled exception occurred: {}", e.getMessage(), e);
ApiResponse<Void> apiResponse = ApiResponse.of("서버 내부에 오류가 발생했습니다.", HttpStatusCode.INTERNAL_SERVER_ERROR);
return ResponseEntity.status(HttpStatusCode.INTERNAL_SERVER_ERROR.getCode()).body(apiResponse);
}
}
24 changes: 24 additions & 0 deletions cinema-adapter/src/main/resources/application-test.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# H2
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=test_user
spring.datasource.password=test_password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

# h2 (http://localhost:8080/h2-console)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# JPA
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.generate-ddl=true
spring.jpa.open-in-view=false

# log
#logging.level.root=info



50 changes: 50 additions & 0 deletions cinema-adapter/src/main/resources/sql/reservationTest.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
INSERT INTO member (member_id, birth_date, created_by, created_at)
VALUES (1, '1999-01-01', 99, NOW());

INSERT INTO upload_file (file_id, file_path, file_name, origin_file_name, created_by, created_at)
VALUES (1, '/radis/test/', 'test1.png', 'origin.png', 99, NOW());

INSERT INTO upload_file (file_id, file_path, file_name, origin_file_name, created_by, created_at)
VALUES (2, '/radis/test/', 'test2.png', 'origin.png', 99, NOW());

INSERT INTO upload_file (file_id, file_path, file_name, origin_file_name, created_by, created_at)
VALUES (3, '/radis/test/', 'test3.png', 'origin.png', 99, NOW());

INSERT INTO movie (movie_id, file_id, title, rating, release_date, running_time_minutes ,genre, created_by, created_at)
VALUES (1, 1, '치토스', '2', '2024-12-11', 90, 'T', 99, NOW());

INSERT INTO movie (movie_id, file_id, title, rating, release_date, running_time_minutes ,genre, created_by, created_at)
VALUES (2, 2, '칸초', '1', '2024-11-17', 120, 'F', 99, NOW());

INSERT INTO movie (movie_id, file_id, title, rating, release_date, running_time_minutes ,genre, created_by, created_at)
VALUES (3, 3, '공공칠빵', '3', '2024-10-13', 110, 'A', 99, NOW());

INSERT INTO screen (screen_id, screen_name, created_by, created_at)
VALUES (1, '1관', 99, NOW());

INSERT INTO screen (screen_id, screen_name, created_by, created_at)
VALUES (2, '2관', 99, NOW());

INSERT INTO screening_schedule (schedule_id, movie_id, screen_id, show_start_at, created_by, created_at)
VALUES (1, 1, 1, '2025-02-13 10:50:00', 99, NOW());

INSERT INTO screening_schedule (schedule_id, movie_id, screen_id, show_start_at, created_by, created_at)
VALUES (2, 2, 2, '2025-02-20 12:00:00', 99, NOW());

INSERT INTO screening_schedule (schedule_id, movie_id, screen_id, show_start_at, created_by, created_at)
VALUES (3, 3, 1, '2025-02-17 13:20:00', 99, NOW());

INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (1, 1, 'A', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (2, 1, 'B', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (3, 1, 'C', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (4, 1, 'D', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (5, 1, 'E', 5, 99, NOW());

INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (6, 2, 'A', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (7, 2, 'B', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (8, 2, 'C', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (9, 2, 'D', 5, 99, NOW());
INSERT INTO screen_seat_layout (seat_layout_id, screen_id, seat_row, max_seat_number, created_by, created_at) VALUES (10, 2, 'E', 5, 99, NOW());

INSERT INTO ticket_reservation (ticket_id, schedule_id, screen_seat, member_id, created_by, created_at)
VALUES (1, 1, 'E01', 1, 99, NOW());
Binary file not shown.
Loading