Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d1e6c09
test: 통합 테스트 컨텍스트 재사용 개선
Ryan-Dia Jun 19, 2026
80a429f
test: 통합 테스트 컨텍스트 단일화
Ryan-Dia Jun 19, 2026
b30b8ca
test: 테스트 실행 구조와 성능 개선
Ryan-Dia Jun 21, 2026
1d9d380
test: CleanUp 테이블 수집 구현
Ryan-Dia Jun 21, 2026
4b89c56
test: CleanUp 테이블 수집 중복 제거 및 의도 명확화
Ryan-Dia Jun 21, 2026
0b44dcb
test: CleanUp DELETE 정리 구현
Ryan-Dia Jun 21, 2026
eefa62c
test: CleanUp 정리 검증을 제외 테이블 직접 적재로 결정화
Ryan-Dia Jun 21, 2026
66a37bc
test: CleanUp 검증 보강 및 stale 주석 제거
Ryan-Dia Jun 21, 2026
c21daca
test: 통합 테스트 DB 정리 자동화 및 수용 테스트 opt-out
Ryan-Dia Jun 21, 2026
94eab56
test: 자동 정리 테스트 순서 명시 및 의도 주석 보강
Ryan-Dia Jun 21, 2026
83f5acf
test: challenge 통합 테스트 수동 DB 정리 제거
Ryan-Dia Jun 21, 2026
75e08cd
test: holiday를 정리 대상에 포함하고 충돌 우회 제거
Ryan-Dia Jun 21, 2026
ead0252
test: 정리 제외 테이블을 활성 테스트 미변경 참조 seed로 한정
Ryan-Dia Jun 21, 2026
ee6fd39
test: reading/article/subscribe 통합 테스트 수동 DB 정리 제거
Ryan-Dia Jun 22, 2026
3db6765
test: 나머지 통합 테스트 수동 DB 정리 제거
Ryan-Dia Jun 22, 2026
948612a
test: DB 정리 커넥션과 seed 격리 개선
Ryan-Dia Jun 22, 2026
050e8fa
test: 데일리 가이드 API 테스트 통합
Ryan-Dia Jun 22, 2026
b714bf5
test: 서비스 테스트 데이터 설정 단순화
Ryan-Dia Jun 22, 2026
d79e0b1
test: DisplayName 제거 및 테스트명 통일
Ryan-Dia Jun 22, 2026
fe8ddf0
test: 컨트롤러 테스트 JSON 인수 테스트 전환
Ryan-Dia Jun 22, 2026
d6d4d28
test: 지난 아티클 서비스 테스트 설정 단순화
Ryan-Dia Jun 22, 2026
f26cf27
test: 지난 아티클 조회를 컨트롤러 인수 테스트로 이전
Ryan-Dia Jun 22, 2026
229228a
test: 테스트 헬퍼 메서드명 정리
Ryan-Dia Jun 22, 2026
596c675
test: 컨트롤러 테스트 JSON 인수 테스트 전환
Ryan-Dia Jun 22, 2026
4938e2d
test: 테스트 식별자와 파일명 정리
Ryan-Dia Jun 22, 2026
626a72a
refactor: 테스트 지원 코드 도메인별 분리
Ryan-Dia Jun 22, 2026
471f034
refactor: 회원 탈퇴 테스트 fixture 단순화
Ryan-Dia Jun 22, 2026
8960c5c
test: 챌린지 API 테스트를 통합 테스트로 전환
Ryan-Dia Jun 22, 2026
a2c96fb
refactor: 챌린지 서비스 테스트 fixture 단순화
Ryan-Dia Jun 22, 2026
0301b58
test: 컨트롤러 인수 테스트 RestAssured 전환
Ryan-Dia Jun 22, 2026
aa2c6c9
test: ArticleService 테스트 픽스처 정리
Ryan-Dia Jun 22, 2026
8750266
test: 챌린지 테스트 경계 정리
Ryan-Dia Jun 22, 2026
4ce3f65
docs: 테스트 작성 AGENTS 규칙 추가
Ryan-Dia Jun 22, 2026
d332d0a
test: 서비스 테스트 커버리지 보강
Ryan-Dia Jun 22, 2026
9643606
test: 비활성 회원 탈퇴 이벤트 테스트 삭제
Ryan-Dia Jun 23, 2026
3c7af30
test: 매일메일 인수 테스트 데이터셋 정리
Ryan-Dia Jun 23, 2026
72cbb7e
merge: server-dev 변경 반영
Ryan-Dia Jun 23, 2026
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
40 changes: 40 additions & 0 deletions backend/bom-bom-server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ dependencies {

// test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.rest-assured:spring-mock-mvc")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.testcontainers:testcontainers")
testImplementation("org.testcontainers:testcontainers-mysql")
Expand Down Expand Up @@ -109,8 +110,47 @@ tasks.named<Delete>("clean") {
delete(layout.buildDirectory.dir("generated"))
}

val reuseTestcontainers = providers.gradleProperty("reuseTestcontainers")
.map(String::toBoolean)
.orElse(false)

fun Test.configureIntegrationExecution() {
maxParallelForks = 2

if (reuseTestcontainers.get()) {
environment("TESTCONTAINERS_REUSE_ENABLE", "true")
systemProperty("bombom.testcontainers.reuse", "true")
}
}

tasks.test {
useJUnitPlatform()
configureIntegrationExecution()
}

val testSourceSet = sourceSets.named("test")

tasks.register<Test>("unitTest") {
group = "verification"
description = "Spring Context 없이 실행되는 단위 테스트만 실행합니다."
testClassesDirs = testSourceSet.get().output.classesDirs
classpath = testSourceSet.get().runtimeClasspath
useJUnitPlatform {
excludeTags("integration")
}
maxParallelForks = 2
}

tasks.register<Test>("integrationTest") {
group = "verification"
description = "Spring Context 또는 DB를 사용하는 통합 테스트만 실행합니다."
testClassesDirs = testSourceSet.get().output.classesDirs
classpath = testSourceSet.get().runtimeClasspath
useJUnitPlatform {
includeTags("integration")
}
configureIntegrationExecution()
shouldRunAfter(tasks.named("unitTest"))
}

apply(from = "$projectDir/gradle/openapi-generator.gradle")
Expand Down
20 changes: 18 additions & 2 deletions backend/bom-bom-server/gradle/openapi-generated-format.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
def generatedOpenApiJavaDir = layout.projectDirectory.dir("generated/openapi")
def openApiGenerationFingerprintFile = generatedOpenApiJavaDir.file(".generation-fingerprint")
def lineSeparator = System.lineSeparator()
def recordPattern = ~/(?s)public record (\w+)\(\R(.*?)\R\) \{\R/

Expand Down Expand Up @@ -117,7 +118,10 @@ def normalizeRecordComponentBlankLines = { File file ->

def removeResponseContainerElementValidation = { File file ->
String text = file.getText("UTF-8")
file.setText(text.replaceAll(/List<@Valid /, "List<"), "UTF-8")
String normalized = text.replaceAll(/List<@Valid /, "List<")
if (normalized != text) {
file.setText(normalized, "UTF-8")
}
}

def formatResponseModel = { File file ->
Expand Down Expand Up @@ -198,7 +202,19 @@ tasks.named("spotlessOpenApiModelsApply") {
dependsOn("spotlessOpenApiModels")
}

[
"addOpenApiResponseFactories",
"spotlessJava",
"spotlessJavaApply",
"spotlessOpenApiModels",
"spotlessOpenApiModelsApply",
].each { taskName ->
tasks.named(taskName) {
onlyIf { !openApiGenerationFingerprintFile.asFile.exists() }
}
}

// 컴파일 전에는 생성 코드 포맷까지 끝난 상태를 보장한다.
tasks.named("compileJava") {
dependsOn("spotlessJavaApply", "spotlessOpenApiModelsApply")
dependsOn("writeOpenApiGenerationFingerprint")
}
68 changes: 65 additions & 3 deletions backend/bom-bom-server/gradle/openapi-generator.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ def openApiDomainGroups = [
],
]

def openApiGenerationFingerprintFile = generatedOpenApiDir.file(".generation-fingerprint")
def openApiGenerationInputFiles = files(
openApiSpecFile,
fileTree("src/main/resources/openapi-templates"),
layout.projectDirectory.file("gradle/openapi-generator.gradle"),
layout.projectDirectory.file("gradle/openapi-generated-format.gradle"),
)
def calculateOpenApiGenerationFingerprint = {
def digest = java.security.MessageDigest.getInstance("SHA-256")
openApiGenerationInputFiles.files
.findAll(File::exists)
.sort { left, right -> left.absolutePath <=> right.absolutePath }
.each { input ->
digest.update(input.absolutePath.getBytes("UTF-8"))
digest.update(input.bytes)
}
digest.update(openApiDomainGroups.toString().getBytes("UTF-8"))
digest.update(openApiConfigOptions.toString().getBytes("UTF-8"))
digest.update(openApiGlobalProperties.toString().getBytes("UTF-8"))
return digest.digest().encodeHex().toString()
}

// submodule이 초기화되지 않은 상태에서 빌드가 진행되지 않도록 막는다.
tasks.register("verifyOpenApiSpec") {
doLast {
Expand All @@ -69,10 +91,28 @@ tasks.register("cleanGeneratedOpenApi", Delete) {
delete(generatedOpenApiDir)
}

tasks.register("prepareOpenApiGeneration") {
dependsOn("verifyOpenApiSpec")

doLast {
String expectedFingerprint = calculateOpenApiGenerationFingerprint()
File fingerprintFile = openApiGenerationFingerprintFile.asFile
boolean generatedSourcesExist = !fileTree(generatedOpenApiDir) {
include("**/*.java")
}.isEmpty()
boolean fingerprintMatches = fingerprintFile.exists() &&
fingerprintFile.getText("UTF-8") == expectedFingerprint

if (!generatedSourcesExist || !fingerprintMatches) {
delete(generatedOpenApiDir)
}
}
}

def openApiDomainGenerateTasks = openApiDomainGroups.collect { domain ->
tasks.register("openApiGenerate${domain.name}", openApiGeneratorTaskClass) {
dependsOn("verifyOpenApiSpec")
mustRunAfter("cleanGeneratedOpenApi")
dependsOn("prepareOpenApiGeneration")
onlyIf { !openApiGenerationFingerprintFile.asFile.exists() }
generatorName.set("spring")
inputSpec.set(openApiSpecFile.asFile.absolutePath)
outputDir.set(generatedOpenApiDir.asFile.absolutePath)
Expand All @@ -97,11 +137,33 @@ def openApiDomainGenerateTasks = openApiDomainGroups.collect { domain ->
// OpenAPI 스펙으로부터 Spring 인터페이스와 모델을 도메인별 패키지에 생성한다.
tasks.named("openApiGenerate") {
dependsOn("verifyOpenApiSpec")
dependsOn("cleanGeneratedOpenApi")
dependsOn(openApiDomainGenerateTasks)
onlyIf { false }
}

tasks.register("regenerateOpenApi") {
group = "openapi tools"
description = "생성 결과를 삭제하고 OpenAPI 코드를 다시 생성합니다."
dependsOn("cleanGeneratedOpenApi", "writeOpenApiGenerationFingerprint")
}
openApiDomainGenerateTasks.each { generatorTask ->
generatorTask.configure {
mustRunAfter("cleanGeneratedOpenApi")
}
}

tasks.register("writeOpenApiGenerationFingerprint") {
dependsOn("spotlessJavaApply", "spotlessOpenApiModelsApply")
inputs.files(openApiGenerationInputFiles)
outputs.file(openApiGenerationFingerprintFile)

doLast {
File fingerprintFile = openApiGenerationFingerprintFile.asFile
fingerprintFile.parentFile.mkdirs()
fingerprintFile.setText(calculateOpenApiGenerationFingerprint(), "UTF-8")
}
}

sourceSets {
main {
java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

Expand All @@ -18,9 +18,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(CIllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(CIllegalArgumentException e) {
if (!e.getContext().isEmpty()) {
log.info("IllegalArgumentException: {} - Context: {}", e.getMessage(), e.getContext(), e);
log.info("IllegalArgumentException: {} - Context: {}", e.getMessage(), e.getContext());
} else {
log.info("IllegalArgumentException: ", e);
log.info("IllegalArgumentException: {}", e.getMessage());
}
return ResponseEntity.status(e.getHttpStatus())
.body(ErrorResponse.from(e.getErrorDetail()));
Expand All @@ -32,7 +32,7 @@ public ResponseEntity<ErrorResponse> handleConversionFailedException(ConversionF
if (cause instanceof CIllegalArgumentException illegalArg) {
return handleIllegalArgumentException(illegalArg);
}
log.info("Conversion failed: ", e);
log.info("Conversion failed: {}", e.getMessage());
return ResponseEntity.status(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION.getStatus())
.body(ErrorResponse.from(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION));
}
Expand All @@ -48,32 +48,32 @@ public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(
if (cause instanceof CIllegalArgumentException illegalArg) {
return handleIllegalArgumentException(illegalArg);
}
log.info("Method argument type mismatch: ", e);
log.info("Method argument type mismatch: name={}, value={}", e.getName(), e.getValue());
return ResponseEntity.status(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION.getStatus())
.body(ErrorResponse.from(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION));
}

@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ErrorResponse> handleUnauthorizedException(UnauthorizedException e) {
if (!e.getContext().isEmpty()) {
log.warn("UnauthorizedException: {} - Context: {}", e.getMessage(), e.getContext(), e);
log.warn("UnauthorizedException: {} - Context: {}", e.getMessage(), e.getContext());
} else {
log.warn("UnauthorizedException: ", e);
log.warn("UnauthorizedException: {}", e.getMessage());
}
return ResponseEntity.status(e.getHttpStatus())
.body(ErrorResponse.from(e.getErrorDetail()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
log.info("Validation failed: ", e);
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.info("Validation failed: {}", e.getBindingResult().getFieldErrors());
return ResponseEntity.status(ErrorDetail.INVALID_REQUEST_BODY_VALIDATION.getStatus())
.body(ErrorResponse.from(ErrorDetail.INVALID_REQUEST_BODY_VALIDATION));
}

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolationException(ConstraintViolationException e){
log.info("Constraint violation: ", e);
public ResponseEntity<ErrorResponse> handleConstraintViolationException(ConstraintViolationException e) {
log.info("Constraint violation: {}", e.getConstraintViolations());
return ResponseEntity.status(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION.getStatus())
.body(ErrorResponse.from(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION));
}
Expand All @@ -91,14 +91,14 @@ public ResponseEntity<ErrorResponse> handleCServerErrorException(CServerErrorExc

@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourceFoundException e) {
log.warn("No resource found: ", e);
log.warn("No resource found: {}", e.getResourcePath());
return ResponseEntity.status(ErrorDetail.ENTITY_NOT_FOUND.getStatus())
.body(ErrorResponse.from(ErrorDetail.ENTITY_NOT_FOUND));
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleNotReadable(HttpMessageNotReadableException e) {
log.info("Request body parse error: ", e);
log.info("Request body parse error: {}", e.getMessage());
return ResponseEntity.status(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION.getStatus())
.body(ErrorResponse.from(ErrorDetail.INVALID_REQUEST_PARAMETER_VALIDATION));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.bombom.api.v1.reading.scheduler;

import java.time.Clock;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -21,6 +22,7 @@ public class ReadingScheduler {

private final ReadingService readingService;
private final ContinueReadingShieldService continueReadingShieldService;
private final Clock clock;

@Scheduled(cron = DAILY_CRON, zone = TIME_ZONE)
@SchedulerLock(name = "daily_reset_reading_count", lockAtLeastFor = "PT4S", lockAtMostFor = "PT9S")
Expand Down Expand Up @@ -87,7 +89,7 @@ public void tenMinutelyCalculateContinueReadingRankingSnapshot() {
* - 00:10분부터: 업데이트 (10분간 데이터 쌓임)
*/
private boolean shouldSkipRankingUpdate() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now(clock);

// 매월 1일이고 시간이 00:00~00:09분 사이인 경우 스킵
if (now.getDayOfMonth() == 1) {
Expand Down
Loading
Loading