From 0ec48d65ad4dede1768c5783afd68daf7d16946f Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 14 Sep 2026 11:58:00 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs(deployment):=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EA=B3=BC=20=EA=B3=84=ED=9A=8D=EC=9D=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 144 ++++++++++++++++++ .../prd.md | 101 ++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md create mode 100644 docs/20260914_배포검증readiness및deployment엔드포인트/prd.md diff --git a/docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md b/docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md new file mode 100644 index 00000000..ab02ea61 --- /dev/null +++ b/docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md @@ -0,0 +1,144 @@ +# OCI Blue/Green 배포 검증 endpoint 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-09-14 | +| 요구사항 기준 | `docs/20260914_배포검증readiness및deployment엔드포인트/prd.md` | +| 현재 Phase | Phase 1 배포 검증 endpoint | +| 현재 활성 Goal | 없음 | + +## 목표 + +배포 스크립트가 애플리케이션 포트에서 readiness를, management 포트에서 readiness와 배포 식별자를 확인할 수 있다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `4/4` | 없음 | 없음 | + +## 범위 + +### 포함 + +- `spring-boot-starter-actuator` 의존성 추가. +- `application.yml`(main/test)의 포트·probe·exposure·deployment property 설정. +- `DeploymentEndpoint` custom Actuator endpoint 구현. +- `SecurityConfig`의 `/readyz` permitAll 최소 변경. +- 위 동작을 검증하는 통합 test. + +### 제외 + +- Kubernetes 의존성·설정. +- 배포 스크립트(`scripts/`, `appspec.yml`) 변경. +- 신규 추상화/라이브러리, 기존 API 변경. + +## 기술적 제약 + +- 기술 스택: Kotlin 1.6.21, Spring Boot 2.7.14, Gradle Kotlin DSL, JUnit 5. +- 배포 식별자는 property placeholder만 사용하고 별도 환경변수 파싱·fallback 코드를 만들지 않는다. +- 응답 필드명은 snake_case 고정(`application_artifact_version`, `config_commit`). +- 검증: 신규 test 우선 실행 후 `./gradlew test` 전체 회귀(공통 보안·설정 변경이므로 전체 회귀 필요). + +## Phase 1 배포 검증 endpoint + +**Phase 결과:** readiness와 deployment endpoint가 지정된 포트 경계에 맞게 동작한다. + +**선행조건:** 없음. + +**Phase 완료 조건:** `P1-T1`~`P1-T4` 완료, `./gradlew test`와 `./gradlew bootJar` 성공 기록. + +### 구현 항목 + +#### Task 1.1 Actuator 의존성과 기본 설정 + +**Goal 실행 `P1-T1`:** Actuator를 추가하고 포트·exposure·deployment property 계약을 설정에 반영한다. + +- **시작 조건:** PRD `DEPLOY-001`, `DEPLOY-002`, `DEPLOY-005`. +- **완료 증거:** 설정 파일 diff와 애플리케이션 컨텍스트 기동 test 통과. +- **범위 밖:** endpoint 구현, security 변경. + +**Files:** + +- Modify: `build.gradle.kts` +- Modify: `src/main/resources/application.yml` +- Modify: `src/test/resources/application.yml` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt` + +- [x] **RED:** `DeploymentEndpointIntegrationTest`에서 management 포트 readiness/deployment 호출 test를 작성했다. +- [x] **RED 확인:** Actuator 부재로 `Could not resolve placeholder 'local.management.port'` 실패를 확인했다. +- [x] **GREEN:** `spring-boot-starter-actuator`와 포트·exposure·deployment 설정을 추가했다. +- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` 통과. +- [x] **REFACTOR:** deprecated `LocalServerPort` import 정리, `./gradlew ktlintCheck` 성공. + +#### Task 1.2 readiness 접근 허용 + +**Goal 실행 `P1-T2`:** `/readyz`와 management readiness가 인증 없이 200을 반환한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** readiness test 통과. +- **범위 밖:** 기타 endpoint의 security 정책 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt` + +- [x] **RED:** `/readyz` 200 기대 test 작성(`DeploymentEndpointIntegrationTest`). +- [x] **RED 확인:** Actuator/보안 미적용 상태 실패 확인. +- [x] **GREEN:** `SecurityConfig`에 `/readyz` permitAll과 `EndpointRequest.toAnyEndpoint()` permitAll 추가. +- [x] **GREEN 확인:** readiness test 2건 통과. +- [x] **REFACTOR:** 추가 변경 없음. + +#### Task 1.3 deployment custom Actuator endpoint + +**Goal 실행 `P1-T3`:** `/actuator/deployment`가 snake_case 배포 식별자를 반환한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 기본값·주입값 test 통과. +- **범위 밖:** 추가 필드. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt` + +- [x] **RED:** 기본값(`local`, 40자리 zero)과 주입값 검증 test 작성. +- [x] **RED 확인:** endpoint 미구현 실패 확인. +- [x] **GREEN:** `@Endpoint(id = "deployment")` + `@ReadOperation` 구현. +- [x] **GREEN 확인:** 기본값·주입값 test 통과. +- [x] **REFACTOR:** `./gradlew ktlintCheck` 성공. + +#### Task 1.4 포트 접근 경계 검증 + +**Goal 실행 `P1-T4`:** 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않음을 검증한다. + +- **시작 조건:** `P1-T3` 완료. +- **완료 증거:** 애플리케이션 포트 접근 test 통과. +- **범위 밖:** 그 외 경로 노출 정책. + +**Files:** + +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt` + +- [x] **RED:** 애플리케이션 포트 접근이 200이 아님을 기대하는 test 작성. +- [x] **RED 확인:** 초기 404 기대 단정이 `401 UNAUTHORIZED`로 실패함을 확인. +- [x] **GREEN:** management 포트 분리로 애플리케이션 포트에서는 endpoint가 매핑되지 않고 401로 차단됨을 확인, 단정을 "200 아님 + 배포 식별자 미포함" 계약으로 정정. +- [x] **GREEN 확인:** 해당 test 통과. +- [x] **REFACTOR:** `./gradlew test` 전체 회귀 성공. + +### Phase Gate `P1-GATE` + +- [x] `./gradlew test` 성공. +- [x] `./gradlew bootJar` 성공 및 실행 가능한 JAR 생성 확인. + +## 검증 기록 + +- 2026-09-14 `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` → RED 5 failed → GREEN 5 passed. +- 2026-09-14 `./gradlew ktlintCheck` → BUILD SUCCESSFUL. +- 2026-09-14 `./gradlew test` → BUILD SUCCESSFUL (전체 회귀). +- 2026-09-14 `./gradlew bootJar` → `build/libs/sodalive-0.0.1-SNAPSHOT.jar`(141MB), `Main-Class: org.springframework.boot.loader.JarLauncher` 확인. +- 참고: 기존 test 실행에는 `JWT_SECRET` 환경변수가 필요하며 이는 이번 변경 이전부터 동일한 전제다. +- 참고: 애플리케이션 포트에서 `/actuator/deployment`는 404가 아니라 401로 차단된다. `EndpointRequest` matcher가 management 포트 분리 시 애플리케이션 포트 요청에 매칭되지 않기 때문이며, 노출되지 않는다는 요구는 충족한다. diff --git a/docs/20260914_배포검증readiness및deployment엔드포인트/prd.md b/docs/20260914_배포검증readiness및deployment엔드포인트/prd.md new file mode 100644 index 00000000..4c2f80e7 --- /dev/null +++ b/docs/20260914_배포검증readiness및deployment엔드포인트/prd.md @@ -0,0 +1,101 @@ +# OCI Blue/Green 배포 검증용 readiness 및 deployment endpoint PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 완료 | +| 작성일 | 2026-09-14 | +| 최종 수정일 | 2026-09-14 | +| 대상 제품 | sodalive server 배포 검증 endpoint | +| 작성자·결정권자 | 요청 사용자 | +| 관련 구현 계획 | `docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md` | +| 관련 review | 없음 | + +## 1. Overview + +OCI Blue/Green 배포에서 배포 스크립트가 새로 기동한 인스턴스의 기동 완료 여부와, 실제로 어떤 artifact/config가 올라갔는지를 +HTTP로 확인할 수 있어야 한다. 이를 위해 Spring Boot Actuator의 readiness probe와 배포 식별 정보를 반환하는 custom Actuator +endpoint를 제공한다. + +## 2. Problem Statement + +- 현재 프로젝트에는 Actuator 의존성과 health endpoint가 없어 배포 스크립트가 기동 완료를 판정할 방법이 없다. +- 어떤 artifact 버전과 config commit이 배포되었는지 런타임에서 확인할 수 없다. +- 모든 요청이 Spring Security의 `anyRequest().authenticated()`로 보호되어 있어 인증 없는 probe 호출이 불가능하다. + +해결 판단 기준: 배포 스크립트가 인증 없이 `GET :8080/readyz`로 기동을 판정하고, 운영 전용 포트에서 +`GET :8082/actuator/deployment`로 배포 식별자를 확인할 수 있다. + +## 3. Goals + +- 애플리케이션 포트(8080)에서 인증 없이 readiness 확인 가능. +- Management 포트(8082)에서 readiness와 배포 식별 정보 확인 가능. +- 배포 식별자는 환경변수로 주입하고, 환경변수가 없어도 로컬/테스트 기동이 가능해야 한다. + +## 4. Non-Goals + +- Kubernetes 관련 의존성·설정 추가. +- liveness 기반 자동 재기동 정책, 배포 스크립트 자체 변경. +- health details/components 공개, 신규 추상화 계층 도입. + +## 5. 권한 + +- 인증 주체: 없음(배포 스크립트/로드밸런서). +- `/readyz`: 애플리케이션 포트에서 인증 없이 허용. +- `/actuator/**`: management 포트(8082)에서만 노출. 애플리케이션 포트에서는 노출하지 않는다. +- `/readyz` 응답에는 민감 정보나 배포 식별자를 포함하지 않는다. + +## 6. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 | +|---|---|---|---|---| +| `DEPLOY-001` | 확정 | `spring-boot-starter-actuator` 의존성을 추가한다(중복 추가 금지). | `build.gradle.kts`에 1회만 존재 | `P1-T1` | +| `DEPLOY-002` | 확정 | 애플리케이션 포트 8080, management 포트 8082로 분리한다. | `application.yml`에 `server.port: 8080`, `management.server.port: 8082` | `P1-T1` | +| `DEPLOY-003` | 확정 | health probes와 additional path를 활성화해 `/readyz`를 제공한다. | `GET :8080/readyz` → 200 | `P1-T2` | +| `DEPLOY-004` | 확정 | management 포트에서 readiness를 제공한다. | `GET :8082/actuator/health/readiness` → 200 | `P1-T2` | +| `DEPLOY-005` | 확정 | web exposure는 `health`, `deployment`만 포함하고 health details/components는 공개하지 않는다. | `management.endpoints.web.exposure.include: health,deployment`, `show-details: never`, `show-components: never` | `P1-T1` | +| `DEPLOY-006` | 확정 | `DEPLOY_ARTIFACT_VERSION`/`DEPLOY_CONFIG_COMMIT`을 property placeholder로 연결한다. | 환경변수 없으면 `local`, 40자리 zero commit | `P1-T3` | +| `DEPLOY-007` | 확정 | `/actuator/deployment`를 custom Actuator endpoint로 구현한다. | REST controller가 아닌 `@Endpoint` 구현체 존재 | `P1-T3` | +| `DEPLOY-008` | 확정 | 응답 필드는 정확히 `application_artifact_version`, `config_commit`이다. | snake_case JSON 검증 test 통과 | `P1-T3` | +| `DEPLOY-009` | 확정 | 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않는다. | `GET :8080/actuator/deployment` → 200 아님 | `P1-T4` | +| `DEPLOY-010` | 확정 | Security 정책 최소 변경으로 readiness가 401/403/redirect 되지 않는다. | `SecurityConfig`에 `/readyz` permitAll 추가 | `P1-T2` | + +## 7. API 계약 + +### 7.1 `GET :8080/readyz` + +- 인증 불필요. 응답 body는 Actuator 기본 health 응답(`{"status":"UP"}`), 세부 정보 비공개. + +### 7.2 `GET :8082/actuator/health/readiness` + +- 응답: `{"status":"UP"}`. + +### 7.3 `GET :8082/actuator/deployment` + +```json +{ + "application_artifact_version": "local", + "config_commit": "0000000000000000000000000000000000000000" +} +``` + +## 8. 성공 기준 + +- [x] `GET :8080/readyz`가 200을 반환한다. (`DEPLOY-003`, `DEPLOY-010`) +- [x] `GET :8082/actuator/health/readiness`가 200을 반환한다. (`DEPLOY-004`) +- [x] `GET :8082/actuator/deployment`가 주입값을 snake_case JSON으로 반환한다. (`DEPLOY-006`~`DEPLOY-008`) +- [x] `GET :8080/actuator/deployment`가 200을 반환하지 않는다(401로 차단). (`DEPLOY-009`) +- [x] `./gradlew test`, `./gradlew bootJar` 성공. + +## 9. Open Questions + +없음. + +## 10. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 | +|---|---|---|---|---|---| +| 2026-09-14 | `DEC-001` | 확정 | Kubernetes 의존성 없이 Actuator 기본 probe만 사용 | 사용자 요구사항 범위 제한 | `DEPLOY-003`, `DEPLOY-004` | +| 2026-09-14 | `DEC-002` | 확정 | 배포 식별자는 별도 파싱 코드 없이 Spring property placeholder로만 연결 | 사용자 요구사항, 단순성 유지 | `DEPLOY-006` | +| 2026-09-14 | `DEC-003` | 확정 | `/actuator/deployment`는 `@Endpoint` 기반 custom Actuator endpoint로 구현 | 사용자 요구사항(REST controller 금지) | `DEPLOY-007` | -- 2.54.0 From 7aeb07061540704f4b7c1623286b98ee4d783297 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 14 Sep 2026 11:58:07 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(deployment):=20actuator=20readiness=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../sodalive/configs/SecurityConfig.kt | 3 +++ src/main/resources/application.yml | 20 +++++++++++++++++++ src/test/resources/application.yml | 17 ++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 5649c034..5bae987e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,7 @@ repositories { dependencies { implementation("org.redisson:redisson-spring-data-27:3.19.2") + implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-aop") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-redis") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 65eaad1d..f6947191 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered @@ -156,6 +157,8 @@ class SecurityConfig( JwtFilter::class.java ) .authorizeRequests() + .antMatchers("/readyz").permitAll() + .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() .antMatchers("/member/check/email").permitAll() .antMatchers("/member/check/nickname").permitAll() .antMatchers("/member/signup").permitAll() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fb1188a9..f9ba76d7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,8 +1,28 @@ server: + port: 8080 shutdown: graceful env: ${SERVER_ENV} forward-headers-strategy: framework +management: + server: + port: 8082 + endpoints: + web: + exposure: + include: health,deployment + endpoint: + health: + probes: + enabled: true + add-additional-paths: true + show-details: never + show-components: never + +deployment: + artifact-version: ${DEPLOY_ARTIFACT_VERSION:local} + config-commit: ${DEPLOY_CONFIG_COMMIT:0000000000000000000000000000000000000000} + logging: level: com: diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ba17a889..2992752c 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,6 +1,23 @@ server: env: test +management: + endpoints: + web: + exposure: + include: health,deployment + endpoint: + health: + probes: + enabled: true + add-additional-paths: true + show-details: never + show-components: never + +deployment: + artifact-version: ${DEPLOY_ARTIFACT_VERSION:local} + config-commit: ${DEPLOY_CONFIG_COMMIT:0000000000000000000000000000000000000000} + logging: level: com: -- 2.54.0 From ca1f9b9d00931b4dacf34809a738628662d0bba4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 14 Sep 2026 11:58:15 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(deployment):=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8B=9D=EB=B3=84=EC=9E=90=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/deployment/DeploymentEndpoint.kt | 21 +++++ ...entEndpointInjectedValueIntegrationTest.kt | 50 +++++++++++ .../DeploymentEndpointIntegrationTest.kt | 85 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt b/src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt new file mode 100644 index 00000000..f82441d8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.deployment + +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.actuate.endpoint.annotation.Endpoint +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation +import org.springframework.stereotype.Component + +@Component +@Endpoint(id = "deployment") +class DeploymentEndpoint( + @Value("\${deployment.artifact-version}") + private val artifactVersion: String, + @Value("\${deployment.config-commit}") + private val configCommit: String +) { + @ReadOperation + fun deployment(): Map = mapOf( + "application_artifact_version" to artifactVersion, + "config_commit" to configCommit + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt new file mode 100644 index 00000000..07433b5f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.deployment + +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalManagementPort +import org.springframework.http.HttpStatus +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "management.server.port=0", + "deployment.artifact-version=1.2.3-20260914", + "deployment.config-commit=1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d", + "spring.datasource.url=jdbc:h2:mem:deployment-endpoint-injected-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class DeploymentEndpointInjectedValueIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate +) { + @LocalManagementPort + private var managementPort: Int = 0 + + @Test + @DisplayName("배포 식별자가 주입되면 /actuator/deployment는 주입된 값을 반환한다") + fun shouldReturnInjectedDeploymentIdentity() { + val response = restTemplate.getForEntity( + "http://localhost:$managementPort/actuator/deployment", + Map::class.java + ) + + assertEquals(HttpStatus.OK, response.statusCode) + assertEquals( + mapOf( + "application_artifact_version" to "1.2.3-20260914", + "config_commit" to "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d" + ), + response.body + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt new file mode 100644 index 00000000..690c5fb1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt @@ -0,0 +1,85 @@ +package kr.co.vividnext.sodalive.deployment + +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalManagementPort +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "management.server.port=0", + "spring.datasource.url=jdbc:h2:mem:deployment-endpoint-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class DeploymentEndpointIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate +) { + @LocalServerPort + private var serverPort: Int = 0 + + @LocalManagementPort + private var managementPort: Int = 0 + + @Test + @DisplayName("애플리케이션 포트의 /readyz는 인증 없이 200을 반환한다") + fun shouldReturnOkWhenReadyzIsCalledOnServerPort() { + val response = restTemplate.getForEntity("http://localhost:$serverPort/readyz", String::class.java) + + assertEquals(HttpStatus.OK, response.statusCode) + } + + @Test + @DisplayName("management 포트의 /actuator/health/readiness는 인증 없이 200을 반환한다") + fun shouldReturnOkWhenReadinessProbeIsCalledOnManagementPort() { + val response = restTemplate.getForEntity( + "http://localhost:$managementPort/actuator/health/readiness", + String::class.java + ) + + assertEquals(HttpStatus.OK, response.statusCode) + } + + @Test + @DisplayName("환경변수가 없으면 /actuator/deployment는 기본 배포 식별자를 snake_case로 반환한다") + fun shouldReturnDefaultDeploymentIdentityWhenEnvironmentVariablesAreNotProvided() { + val response = restTemplate.getForEntity( + "http://localhost:$managementPort/actuator/deployment", + Map::class.java + ) + + assertEquals(HttpStatus.OK, response.statusCode) + assertEquals( + mapOf( + "application_artifact_version" to "local", + "config_commit" to "0000000000000000000000000000000000000000" + ), + response.body + ) + } + + @Test + @DisplayName("애플리케이션 포트에서는 /actuator/deployment에 접근할 수 없다") + fun shouldNotExposeDeploymentEndpointOnServerPort() { + val response = restTemplate.getForEntity( + "http://localhost:$serverPort/actuator/deployment", + String::class.java + ) + + assertNotEquals(HttpStatus.OK, response.statusCode) + assertFalse(response.body?.contains("application_artifact_version") ?: false) + } +} -- 2.54.0 From 70f717aa6df97bca3701e1a2a3eec1b2270c78b0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 14 Sep 2026 14:50:37 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs(user-creator-chat):=20DM=20=EB=B0=A9?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 388 ++++++++++++++++++ .../prd.md | 159 +++++++ 2 files changed, 547 insertions(+) create mode 100644 docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md create mode 100644 docs/20260914_크리에이터_리스너_DM방_생성/prd.md diff --git a/docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md b/docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md new file mode 100644 index 00000000..7edd5511 --- /dev/null +++ b/docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md @@ -0,0 +1,388 @@ +# 크리에이터 시작 DM 방 생성 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `POST /api/v2/user-creator-chat/rooms/create`가 기존 리스너 시작 DM 생성과 새 크리에이터 시작 DM 생성을 모두 지원한다. + +**Architecture:** 기존 endpoint와 응답 DTO를 유지하고 request DTO만 `recipientId` 중심으로 확장한다. Controller에서 `request.recipientMemberId()`로 단일 상대 회원 ID를 산출하고, 기존 `UserCreatorChatService.createOrGetRoom(member, recipientId)` 흐름과 `validateRecipient` 정책을 재사용한다. 메시지 저장/전달/푸시는 기존 WebSocket 및 메시지 발송 흐름에 맡긴다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper + +--- + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 중 | +| 작성일 | 2026-09-14 | +| 요구사항 기준 | `docs/20260914_크리에이터_리스너_DM방_생성/prd.md` | +| API 기준 | PRD §8 | +| 현재 Phase | Phase 1 | +| 현재 활성 Goal | 없음 | + +## 목표 + +기존 `creatorId` 요청을 깨지 않으면서 신규 `recipientId` 요청으로 발신자 역할과 무관하게 유저-크리에이터 DM 방을 생성/조회할 수 있게 한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 진행 중 | `3/3` | `P1-GATE` | 전체 `./gradlew build`가 `:test` 단계에서 timeout 됨 | + +## 범위 + +### 포함 + +- `POST /api/v2/user-creator-chat/rooms/create` request DTO 확장 +- `recipientId` 표준 필드 추가 +- 기존 `creatorId` alias 유지 +- `recipientId`/`creatorId` 누락·불일치 validation +- 기존 방 재사용 및 새 방 생성 회귀 검증 +- `/create`가 메시지를 만들지 않는다는 회귀 검증 + +### 제외 + +- 첫 메시지 발송 request 추가 +- WebSocket 프로토콜 변경 +- FCM 푸시 정책 변경 +- DB 스키마 변경 +- 새 endpoint 추가 +- 기존 `creatorId` alias 제거 + +## 기술적 제약 + +- 공개 API 응답 `CreateUserCreatorChatRoomResponse(roomId)`는 변경하지 않는다. +- 신규 클라이언트는 `recipientId`를 사용하지만, 기존 클라이언트의 `creatorId` 요청도 허용한다. +- `recipientId`와 `creatorId`가 모두 있고 값이 다르면 `common.error.invalid_request`로 거부한다. +- 권한/상태 검증은 기존 `UserCreatorChatService.validateRecipient` 정책을 재사용한다. +- 새 공용 abstraction, 새 dependency, DB migration은 만들지 않는다. +- 구현 Task는 RED → GREEN → REFACTOR 순서로 수행한다. + +## 파일 구조 계획 + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt` + - `CreateUserCreatorChatRoomRequest`에 `recipientId` nullable 필드와 기존 `creatorId` nullable alias를 둔다. + - 단일 상대 ID 산출 함수를 DTO 내부에 둔다. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt` + - `request.recipientMemberId()` 결과를 `service.createOrGetRoom(member, recipientId)`에 전달한다. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - 함수 파라미터명만 의미에 맞게 `recipientId`로 바꾸는 것을 검토한다. 동작은 유지한다. +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + - 새 방 생성, 기존 방 재사용, 메시지 미생성, validation 회귀를 검증한다. +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt` + - request alias 해석 단위 테스트 또는 controller mapping 테스트를 보강한다. + +## Phase 1: `/create` request 일반화 + +**Phase 결과:** 기존 클라이언트와 신규 클라이언트 모두 같은 `/create` endpoint로 DM 방을 생성/조회할 수 있다. + +**선행조건:** PRD `DEC-001`~`DEC-003` 확정. + +**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 검증 기록 누적. + +### 구현 항목 + +#### Task 1.1 request DTO alias 계약 추가 + +**Goal 실행 `P1-T1`:** `CreateUserCreatorChatRoomRequest`가 `recipientId`와 기존 `creatorId` alias에서 하나의 상대 회원 ID를 산출한다. + +- **시작 조건:** PRD `DMROOM-001~004`, `DEC-003` 확인. +- **완료 증거:** DTO/Controller focused test 통과와 request 계약 문서 일치. +- **범위 밖:** 방 생성 repository 동작 변경, 메시지 발송. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt` + +**Interfaces:** + +- Consumes: request JSON `{ "recipientId": Long? }`, `{ "creatorId": Long? }` +- Produces: `CreateUserCreatorChatRoomRequest.recipientMemberId(): Long` + +- [x] **RED:** `UserCreatorChatControllerMappingTest`에 `recipientId`만 보낸 요청이 service에 해당 ID를 전달하는 실패 테스트를 작성한다. + +```kotlin +@Test +fun shouldCreateRoomWithRecipientId() { + val service = Mockito.mock(UserCreatorChatService::class.java) + val controller = UserCreatorChatController(service) + val member = Member(email = "creator@test.com", password = "pw", nickname = "creator") + member.id = 10L + Mockito.`when`(service.createOrGetRoom(member, 20L)) + .thenReturn(CreateUserCreatorChatRoomResponse(roomId = 30L)) + + val response = controller.createOrGetRoom( + member, + CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = null) + ) + + Mockito.verify(service).createOrGetRoom(member, 20L) + assertEquals(30L, response.data!!.roomId) +} +``` + +- [x] **RED:** 기존 `creatorId`만 보낸 요청도 service에 해당 ID를 전달하는 실패 테스트를 작성한다. + +```kotlin +@Test +fun shouldCreateRoomWithLegacyCreatorId() { + val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L) + + assertEquals(20L, request.recipientMemberId()) +} +``` + +- [x] **RED:** 두 필드가 모두 없거나 서로 다르면 `common.error.invalid_request`가 발생하는 실패 테스트를 작성한다. + +```kotlin +@Test +fun shouldRejectMissingOrConflictingRecipientIds() { + val missing = assertThrows(SodaException::class.java) { + CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = null).recipientMemberId() + } + assertEquals("common.error.invalid_request", missing.messageKey) + + val conflict = assertThrows(SodaException::class.java) { + CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = 21L).recipientMemberId() + } + assertEquals("common.error.invalid_request", conflict.messageKey) +} +``` + +- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest`를 실행해 `recipientId` 생성자 파라미터 또는 `recipientMemberId()` 부재로 실패하는 것을 확인한다. +- [x] **GREEN:** DTO를 최소 확장한다. + +```kotlin +data class CreateUserCreatorChatRoomRequest( + val recipientId: Long? = null, + val creatorId: Long? = null +) { + fun recipientMemberId(): Long { + if (recipientId != null && creatorId != null && recipientId != creatorId) { + throw SodaException(messageKey = "common.error.invalid_request") + } + return recipientId ?: creatorId ?: throw SodaException(messageKey = "common.error.invalid_request") + } +} +``` + +- [x] **GREEN:** Controller에서 `request.recipientMemberId()`를 service에 전달한다. + +```kotlin +ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId())) +``` + +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다. +- [x] **REFACTOR:** 테스트 helper 중 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다. + +#### Task 1.2 service 방 생성 정책 회귀 보강 + +**Goal 실행 `P1-T2`:** 발신자 역할과 무관하게 기존 수신자 검증, 기존 방 재사용, 메시지 미생성 정책을 유지한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** `UserCreatorChatServiceTest` focused test 통과. +- **범위 밖:** `validateRecipient` 정책 변경, WebSocket 발송 구현. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + +**Interfaces:** + +- Consumes: `createOrGetRoom(member: Member, recipientId: Long)` +- Produces: `CreateUserCreatorChatRoomResponse(roomId: Long)` + +- [x] **RED:** 크리에이터 역할 회원이 일반 회원에게 방을 생성할 수 있는 실패 테스트를 작성한다. + +```kotlin +@Test +fun shouldCreateRoomWhenCreatorStartsDmToUser() { + val creator = member(1L, "creator").apply { role = MemberRole.CREATOR } + val user = member(2L, "user") + Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user)) + Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null) + Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenReturn(room(10L)) + + val response = service.createOrGetRoom(creator, 2L) + + assertEquals(10L, response.roomId) + Mockito.verify(participantRepository).save(Mockito.argThat { it.member == creator }) + Mockito.verify(participantRepository).save(Mockito.argThat { it.member == user }) + Mockito.verifyNoInteractions(messageRepository) +} +``` + +- [x] **RED:** 기존 활성 방이 있으면 새 방과 메시지를 만들지 않는 회귀 테스트를 작성한다. + +```kotlin +@Test +fun shouldReturnExistingRoomWithoutCreatingMessage() { + val creator = member(1L, "creator").apply { role = MemberRole.CREATOR } + val user = member(2L, "user") + val existingRoom = room(10L) + Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user)) + Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(existingRoom) + + val response = service.createOrGetRoom(creator, 2L) + + assertEquals(10L, response.roomId) + Mockito.verify(roomRepository, Mockito.never()).save(Mockito.any(UserCreatorChatRoom::class.java)) + Mockito.verifyNoInteractions(messageRepository) +} +``` + +- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`를 실행해 누락된 동작 또는 helper compile 실패를 확인한다. +- [x] **GREEN:** 필요하면 `createOrGetRoom(member, creatorId)` 파라미터명을 `recipientId`로 변경한다. 함수 내부 로직은 기존 `memberRepository.findById`, `validateRecipient`, `findActiveRoomByParticipantMemberIds`, participant 저장 흐름을 유지한다. + +```kotlin +@Transactional +fun createOrGetRoom(member: Member, recipientId: Long): CreateUserCreatorChatRoomResponse { + val recipient = memberRepository.findById(recipientId).orElseThrow { + SodaException(messageKey = "message.error.recipient_not_found") + } + validateRecipient(member, recipient) + + val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, recipient.id!!) + if (existingRoom != null) { + return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!) + } + + val room = roomRepository.save(UserCreatorChatRoom()) + participantRepository.save(UserCreatorChatParticipant(room, member)) + participantRepository.save(UserCreatorChatParticipant(room, recipient)) + return CreateUserCreatorChatRoomResponse(roomId = room.id!!) +} +``` + +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다. +- [x] **REFACTOR:** 파라미터명 변경으로 테스트/문서와 의미가 맞는지 확인하고 새 abstraction 없이 종료한다. + +#### Task 1.3 통합 회귀 검증 보강 + +**Goal 실행 `P1-T3`:** 기존 리스너 시작 흐름과 신규 크리에이터 시작 흐름이 통합 환경에서 모두 동작한다. + +- **시작 조건:** `P1-T1`, `P1-T2` 완료. +- **완료 증거:** 통합 테스트와 focused 회귀 명령 통과. +- **범위 밖:** 전체 메시징 E2E, 푸시 발송 검증. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt` + +- [x] **RED:** 리스너가 `creatorId` alias로 기존처럼 방을 만들 수 있는 통합 회귀 테스트를 작성한다. +- [x] **RED:** 크리에이터가 일반 회원 ID로 방을 만들 수 있는 통합 테스트를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`를 실행해 현재 계약 미지원 실패를 확인한다. +- [x] **GREEN:** `P1-T1`, `P1-T2` 구현만으로 통합 테스트를 통과시킨다. 추가 production 코드를 만들지 않는다. +- [x] **GREEN 확인:** 같은 통합 테스트를 다시 실행해 성공을 확인한다. +- [x] **REFACTOR:** 통합 테스트 fixture 중 이번 Task가 만든 중복만 정리한다. + +### 완료 조건 + +- [x] `P1-T1`, `P1-T2`, `P1-T3`의 체크박스와 완료 증거가 모두 충족됐다. +- [x] PRD `DMROOM-001~007`이 구현 또는 명시적 제외로 추적된다. +- [x] 기존 `creatorId` 요청과 신규 `recipientId` 요청의 차이가 문서와 테스트에 남아 있다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책을 최종 판정한다. + +- **시작 조건:** Phase 1의 모든 Task goal 완료. +- **완료 증거:** 아래 명령 통과와 Progress 기록. +- **범위 밖:** 실패와 무관한 채팅방 목록, openRoom 응답, WebSocket 기능 수정. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest +./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest +./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest +./gradlew ktlintCheck +``` + +**Expected:** 모든 명령이 `BUILD SUCCESSFUL`이고, `/create` 호출만으로 메시지 저장/전달/푸시가 발생하지 않는 테스트가 통과한다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | PRD 확정 | 아니요 | request 계약 재확인 | +| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 service 정책 대조 | +| 3 | `P1-T3` | `P1-T1`, `P1-T2` | 아니요 | 통합 fixture 보정 | +| 4 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task로 회귀 수정 | + +```text +P1-T1 → P1-T2 → P1-T3 → P1-GATE +``` + +## 변경 금지 항목 + +- `/create`에서 메시지를 저장하거나 발송하지 않는다. +- 기존 `creatorId` request를 제거하지 않는다. +- `CreateUserCreatorChatRoomResponse` 필드를 바꾸지 않는다. +- DB schema, WebSocket message type, FCM event 계약을 변경하지 않는다. +- test를 삭제·skip·완화하지 않는다. +- 요청 범위 밖 리팩터링과 공용 abstraction을 추가하지 않는다. + +## Progress + +기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다. + +### P1-T1 1차 실행 — 2026-09-14 + +- 상태: 완료 +- 무엇을: `CreateUserCreatorChatRoomRequest`에 `recipientId` 표준 필드와 `creatorId` alias를 추가하고, controller가 `recipientMemberId()` 결과를 service에 전달하도록 구현했다. +- 왜: `DMROOM-001~004`, `DEC-003` 기준으로 신규 클라이언트와 기존 클라이언트 request를 모두 지원하기 위해서다. +- 어떻게: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — RED 확인, `recipientId` 생성자 파라미터와 `recipientMemberId()` 부재로 `compileTestKotlin` 실패. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — GREEN 확인, `BUILD SUCCESSFUL`. +- 남은 항목: 없음. +- 다음 행동: `P1-T2` 진행. + +### P1-T2 1차 실행 — 2026-09-14 + +- 상태: 완료 +- 무엇을: 크리에이터 시작 DM 방 생성, 기존 방 재사용, `/create` 메시지 미생성 정책을 `UserCreatorChatServiceTest`로 고정했다. production service는 파라미터명만 `recipientId`로 정리했다. +- 왜: `DMROOM-005~007`, `DEC-001`, `DEC-002` 기준으로 기존 수신자 검증과 메시지 발송 분리 정책을 유지하기 위해서다. +- 어떻게: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` — `BUILD SUCCESSFUL`. +- 남은 항목: 없음. +- 다음 행동: `P1-T3` 진행. + +### P1-T3 1차 실행 — 2026-09-14 + +- 상태: 완료 +- 무엇을: 리스너 시작 기존 흐름과 크리에이터 시작 신규 흐름을 `UserCreatorChatServiceIntegrationTest`에 추가했다. +- 왜: 실제 JPA 통합 환경에서 양방향 방 생성과 메시지 미생성을 확인하기 위해서다. +- 어떻게: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`. +- 남은 항목: 없음. +- 다음 행동: `P1-GATE` 진행. + +### P1-GATE 1차 실행 — 2026-09-14 + +- 상태: 차단 감사 중 +- 무엇을: Phase 1 focused test와 lint를 검증하고 전체 build를 시도했다. +- 왜: `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책과 공통 품질 기준을 판정하기 위해서다. +- 어떻게: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`. + - `./gradlew ktlintCheck` — 최초 import 정렬 오류로 실패 후 수정, 재실행 `BUILD SUCCESSFUL`. + - `./gradlew build` — 120초 timeout, 재시도 600초 timeout. 두 번 모두 `:test` 단계에서 종료되지 않아 전체 build 성공 증거는 확보하지 못했다. +- 남은 항목: 전체 build timeout 원인 분리 또는 별도 승인. +- 다음 행동: 변경 범위 리뷰와 전체 suite hang 원인 보고. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리한다. | 사용자 선택 A | `P1-T2`, PRD `DEC-001` | +| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole` 제한 없이 기존 수신자 검증 정책을 따른다. | 사용자 선택 B | `P1-T2`, PRD `DEC-002` | +| 2026-09-14 | `DEC-003` | 확정 | `recipientId`를 표준 필드로 추가하고 `creatorId` alias를 유지한다. | 기존 클라이언트 호환 필요 | `P1-T1`, PRD `DEC-003` | + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| 없음 | - | - | - | - | - | diff --git a/docs/20260914_크리에이터_리스너_DM방_생성/prd.md b/docs/20260914_크리에이터_리스너_DM방_생성/prd.md new file mode 100644 index 00000000..98540ed1 --- /dev/null +++ b/docs/20260914_크리에이터_리스너_DM방_생성/prd.md @@ -0,0 +1,159 @@ +# PRD: 크리에이터 시작 DM 방 생성 + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 기준 확정 | +| 작성일 | 2026-09-14 | +| 최종 수정일 | 2026-09-14 | +| 대상 제품 | 유저-크리에이터 DM | +| 작성자·결정권자 | 사용자 인터뷰 기준 | +| 관련 구현 계획 | `docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md` | +| 관련 review | 없음 | + +## 1. Overview + +현재 `POST /api/v2/user-creator-chat/rooms/create`는 일반 유저가 크리에이터에게 DM을 시작할 때 방을 생성하거나 기존 방을 반환한다. 이번 요구사항은 같은 DM 도메인에서 크리에이터도 상대 회원에게 먼저 DM 방을 만들 수 있게 하는 것이다. + +첫 메시지 저장, 실시간 전달, 푸시는 이번 API에서 처리하지 않는다. 방 생성 후 메시지는 기존 WebSocket 텍스트 발송 또는 기존 메시지 발송 흐름을 사용한다. + +## 2. Problem Statement + +- 현재 생성 요청 DTO는 `creatorId`만 받기 때문에 API 의미가 “일반 유저가 크리에이터에게 DM 시작”에 고정되어 있다. +- 크리에이터가 먼저 상대 회원에게 연락하려면 같은 유저-크리에이터 DM 방을 만들 수 있는 서버 계약이 필요하다. +- 기존 클라이언트는 이미 `creatorId`를 보내고 있으므로, 새 필드만 강제하면 기존 리스너 시작 DM 생성 흐름이 깨진다. + +문제 해결 여부는 신규 클라이언트가 `recipientId`로 방을 만들 수 있고, 기존 클라이언트가 `creatorId`로 같은 API를 계속 사용할 수 있는지로 판단한다. + +## 3. Goals + +- `POST /api/v2/user-creator-chat/rooms/create`가 발신자 역할과 무관하게 상대 회원 ID로 DM 방을 생성하거나 기존 방을 반환한다. +- 신규 표준 request 필드는 `recipientId`로 한다. +- 기존 클라이언트 호환을 위해 `creatorId` request 필드는 alias로 유지한다. +- 기존 리스너 → 크리에이터 방 생성 흐름은 계속 동작한다. +- 크리에이터 → 상대 회원 방 생성 흐름도 같은 참여자/중복 방 정책을 따른다. + +## 4. Non-Goals + +- `/create`에서 첫 텍스트 메시지를 저장하거나 발송하지 않는다. +- `/create`에서 WebSocket 메시지를 대신 보내지 않는다. +- FCM 푸시 발송 정책은 변경하지 않는다. +- DB 스키마를 변경하지 않는다. +- 새 endpoint를 만들지 않는다. +- 기존 `roomId` 응답 구조를 변경하지 않는다. +- `creatorId` alias 제거 시점이나 클라이언트 마이그레이션 일정은 이번 범위에서 정하지 않는다. + +## 5. Target Users and Permissions + +| 사용자 | 목표 | 주요 작업 | 사용 환경 | +|---|---|---|---| +| 리스너/일반 유저 | 크리에이터에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 | +| 크리에이터 | 상대 회원에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 | + +권한과 거부 조건: + +- 인증 주체: 로그인 `Member` +- 허용 역할: 별도 `MemberRole` 제한 없음 +- 수신자 조건: 활성 회원, AI 캐릭터 아님, 자기 자신 아님, 차단 정책 통과 +- 미인증: 기존처럼 `common.error.bad_credentials` +- 수신자 없음 또는 AI 캐릭터: 기존 정책에 맞춰 `message.error.recipient_not_found` +- 비활성 수신자: 기존처럼 `message.error.recipient_inactive` +- 자기 자신: 기존처럼 `common.error.invalid_request` +- 상대가 발신자를 차단한 경우: 기존처럼 `message.error.blocked_by_recipient` + +## 6. 핵심 사용자 흐름 + +1. 인증 회원이 DM을 시작할 상대 회원을 선택한다. +2. 클라이언트는 `POST /api/v2/user-creator-chat/rooms/create`에 `recipientId`를 보낸다. +3. 서버는 기존 alias인 `creatorId`만 온 요청도 허용한다. +4. 서버는 발신자와 수신자 사이의 활성 DM 방이 있으면 기존 `roomId`를 반환한다. +5. 활성 DM 방이 없으면 방과 두 참여자를 생성하고 새 `roomId`를 반환한다. +6. 클라이언트는 반환된 `roomId`로 기존 방 입장 및 메시지 발송 흐름을 진행한다. + +## 7. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `DMROOM-001` | 확정 | `/create`는 `recipientId`를 표준 상대 회원 ID로 받아 방을 생성/조회한다. | `recipientId`만 보낸 요청이 기존 `roomId` 응답을 받는다. | `P1-T1` | +| `DMROOM-002` | 확정 | 기존 클라이언트 호환을 위해 `creatorId`만 보낸 요청도 계속 허용한다. | 기존 `creatorId` request가 깨지지 않고 동일한 서비스 흐름을 탄다. | `P1-T1` | +| `DMROOM-003` | 확정 | `recipientId`와 `creatorId`가 모두 없으면 잘못된 요청으로 거부한다. | `common.error.invalid_request`로 실패한다. | `P1-T1` | +| `DMROOM-004` | 확정 | 두 필드가 모두 있고 값이 다르면 모호한 요청으로 거부한다. | `common.error.invalid_request`로 실패하고 방을 만들지 않는다. | `P1-T1` | +| `DMROOM-005` | 확정 | 수신자 권한/상태 검증은 기존 `validateRecipient` 정책을 재사용한다. | 비활성, AI 캐릭터, 자기 자신, 차단 관계의 기존 오류 key가 유지된다. | `P1-T2` | +| `DMROOM-006` | 확정 | 기존 활성 방이 있으면 새 방을 만들지 않고 기존 `roomId`를 반환한다. | 같은 두 회원으로 두 번 호출해도 활성 방은 1개다. | `P1-T2` | +| `DMROOM-007` | 확정 | `/create`는 첫 메시지 저장/전달/푸시를 수행하지 않는다. | 방 생성 후 `UserCreatorChatMessage`가 생성되지 않는다. | `P1-T2` | + +## 8. API 계약 + +### 8.1 Endpoint + +- Method: `POST` +- Path: `/api/v2/user-creator-chat/rooms/create` +- Auth: 로그인 회원 필수 +- Success envelope: 기존 `ApiResponse.ok(...)` + +### 8.2 Request + +```json +{ + "recipientId": 200, + "creatorId": 200 +} +``` + +- `recipientId`: 신규 표준 필드. 발신자 반대편 회원 ID다. +- `creatorId`: 기존 클라이언트 호환용 alias. 신규 클라이언트는 `recipientId`를 사용한다. +- 둘 중 하나만 보내는 요청을 허용한다. +- 둘 다 보내는 경우 값이 같으면 허용한다. +- 둘 다 보내는 경우 값이 다르면 `common.error.invalid_request`로 거부한다. +- 둘 다 없으면 `common.error.invalid_request`로 거부한다. + +### 8.3 Response + +```json +{ + "roomId": 123 +} +``` + +- 응답 DTO `CreateUserCreatorChatRoomResponse`의 필드는 변경하지 않는다. + +## 9. 보안과 데이터 취급 + +- 수신자 ID는 요청 본문 외 로그에 별도 기록하지 않는다. +- 차단 정책은 기존 `BlockMemberRepository.isBlocked(blockedMemberId = sender.id, memberId = recipient.id)` 기준을 유지한다. +- AI 캐릭터용 `Member`와의 DM 방은 계속 생성하지 않는다. +- 공개 API 스키마에서 기존 필드 제거는 금지한다. + +## 10. 성공 기준 + +- [x] 신규 `recipientId` 요청으로 크리에이터가 상대 회원과 DM 방을 만들 수 있다. +- [x] 기존 `creatorId` 요청으로 리스너가 크리에이터와 DM 방을 만들 수 있다. +- [x] 같은 두 회원의 중복 요청은 같은 활성 `roomId`를 반환한다. +- [x] `/create` 호출만으로 메시지, WebSocket 전달, FCM 푸시가 발생하지 않는다. +- [x] 오류 key는 기존 정책과 호환된다. + +## 11. Open Questions + +없음. 인터뷰로 아래 결정을 확정했다. + +## 12. 요구사항 추적표 + +| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 | +|---|---:|---|---|---| +| `DMROOM-001~004` | 1 | `P1-T1` | `UserCreatorChatControllerMappingTest`, `UserCreatorChatServiceTest` | request alias 계약 대조 | +| `DMROOM-005~007` | 1 | `P1-T2` | `UserCreatorChatServiceTest`, `UserCreatorChatServiceIntegrationTest` | 기존 메시지 발송 흐름 분리 확인 | + +## 13. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal | +|---|---|---|---|---|---| +| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리하고 첫 메시지는 발송하지 않는다. | 사용자 선택 A | `DMROOM-007`, `P1-T2` | +| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole`로 제한하지 않고 활성/AI 아님/자기 자신 아님/차단 정책으로 제한한다. | 사용자 선택 B, 기존 `validateRecipient` 정책 | `DMROOM-005`, `P1-T2` | +| 2026-09-14 | `DEC-003` | 확정 | 신규 표준 필드는 `recipientId`로 하고 기존 `creatorId`는 alias로 유지한다. | 기능 의미는 A가 맞지만 기존 클라이언트 동작 보존 필요 | `DMROOM-001~004`, `P1-T1` | + +## 14. 변경 관리 + +- 코드 구현 전 `plan-task.md`의 체크박스와 Goal 단위를 따른다. +- 구현 중 API 계약이 바뀌면 이 PRD의 Decision Log를 먼저 갱신한다. +- 기존 `creatorId` alias를 제거하려면 별도 PRD와 클라이언트 마이그레이션 계획을 작성한다. -- 2.54.0 From de1a9eb114e13e941f939bac8cc92b53cf698497 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 14 Sep 2026 14:50:44 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(user-creator-chat):=20DM=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=8B=A0=EC=9E=90=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=BC=EB=B0=98=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserCreatorChatController.kt | 2 +- .../dto/UserCreatorChatDtos.kt | 14 +++++- .../UserCreatorChatControllerMappingTest.kt | 43 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt index 02248ca4..3a7b5744 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt @@ -28,7 +28,7 @@ class UserCreatorChatController( @RequestBody request: CreateUserCreatorChatRoomRequest ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - ApiResponse.ok(service.createOrGetRoom(member, request.creatorId)) + ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId())) } @GetMapping("/{roomId}/open") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt index 3e8b8dbc..99f64a5b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt @@ -1,8 +1,18 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.dto +import kr.co.vividnext.sodalive.common.SodaException + data class CreateUserCreatorChatRoomRequest( - val creatorId: Long -) + val recipientId: Long? = null, + val creatorId: Long? = null +) { + fun recipientMemberId(): Long { + if (recipientId != null && creatorId != null && recipientId != creatorId) { + throw SodaException(messageKey = "common.error.invalid_request") + } + return recipientId ?: creatorId ?: throw SodaException(messageKey = "common.error.invalid_request") + } +} data class CreateUserCreatorChatRoomResponse( val roomId: Long diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt index 8b1e63e7..57758669 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt @@ -1,7 +1,13 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.usercreatorchat.controller.UserCreatorChatController +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomRequest +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -23,4 +29,41 @@ class UserCreatorChatControllerMappingTest { mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/messages/text")) .andExpect(status().isNotFound) } + + @Test + fun shouldCreateRoomWithRecipientId() { + val service = Mockito.mock(UserCreatorChatService::class.java) + val controller = UserCreatorChatController(service) + val member = Member(email = "creator@test.com", password = "pw", nickname = "creator").apply { id = 10L } + Mockito.`when`(service.createOrGetRoom(member, 20L)) + .thenReturn(CreateUserCreatorChatRoomResponse(roomId = 30L)) + + val response = controller.createOrGetRoom( + member, + CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = null) + ) + + Mockito.verify(service).createOrGetRoom(member, 20L) + assertEquals(30L, response.data!!.roomId) + } + + @Test + fun shouldCreateRoomWithLegacyCreatorId() { + val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L) + + assertEquals(20L, request.recipientMemberId()) + } + + @Test + fun shouldRejectMissingOrConflictingRecipientIds() { + val missing = assertThrows(SodaException::class.java) { + CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = null).recipientMemberId() + } + assertEquals("common.error.invalid_request", missing.messageKey) + + val conflict = assertThrows(SodaException::class.java) { + CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = 21L).recipientMemberId() + } + assertEquals("common.error.invalid_request", conflict.messageKey) + } } -- 2.54.0 From 62c834d28f0d142e81f2d303273eb1220cab0611 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 14 Sep 2026 14:50:54 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test(user-creator-chat):=20=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=8B=9C=EC=9E=91=20DM?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=9D=84=20=EA=B2=80=EC=A6=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UserCreatorChatService.kt | 10 ++--- .../UserCreatorChatServiceIntegrationTest.kt | 36 ++++++++++++++++++ .../UserCreatorChatServiceTest.kt | 38 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt index 7150767c..86e2be85 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt @@ -59,20 +59,20 @@ class UserCreatorChatService( private val cloudFrontHost: String ) { @Transactional - fun createOrGetRoom(member: Member, creatorId: Long): CreateUserCreatorChatRoomResponse { - val creator = memberRepository.findById(creatorId).orElseThrow { + fun createOrGetRoom(member: Member, recipientId: Long): CreateUserCreatorChatRoomResponse { + val recipient = memberRepository.findById(recipientId).orElseThrow { SodaException(messageKey = "message.error.recipient_not_found") } - validateRecipient(member, creator) + validateRecipient(member, recipient) - val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, creator.id!!) + val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, recipient.id!!) if (existingRoom != null) { return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!) } val room = roomRepository.save(UserCreatorChatRoom()) participantRepository.save(UserCreatorChatParticipant(room, member)) - participantRepository.save(UserCreatorChatParticipant(room, creator)) + participantRepository.save(UserCreatorChatParticipant(room, recipient)) return CreateUserCreatorChatRoomResponse(roomId = room.id!!) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt index 339dd17b..cff277d0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt @@ -31,6 +31,42 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor( private val messageRepository: UserCreatorChatMessageRepository, private val entityManager: EntityManager ) { + @Test + @DisplayName("기존 리스너 시작 DM 방 생성 흐름은 계속 동작한다") + fun shouldCreateRoomWhenListenerStartsDmToCreator() { + val user = memberRepository.save(Member(email = "listener-start@test.com", password = "pw", nickname = "listener")) + val creator = memberRepository.save( + Member(email = "creator-target@test.com", password = "pw", nickname = "creator", role = MemberRole.CREATOR) + ) + entityManager.flush() + entityManager.clear() + + val response = service.createOrGetRoom(user, creator.id!!) + + assertEquals(1, roomRepository.findAll().size) + assertEquals(roomRepository.findAll().single().id, response.roomId) + assertEquals(2, participantRepository.findAll().size) + assertEquals(0, messageRepository.findAll().size) + } + + @Test + @DisplayName("크리에이터도 일반 회원에게 DM 방을 먼저 생성할 수 있다") + fun shouldCreateRoomWhenCreatorStartsDmToListener() { + val creator = memberRepository.save( + Member(email = "creator-start@test.com", password = "pw", nickname = "creator", role = MemberRole.CREATOR) + ) + val listener = memberRepository.save(Member(email = "listener-target@test.com", password = "pw", nickname = "listener")) + entityManager.flush() + entityManager.clear() + + val response = service.createOrGetRoom(creator, listener.id!!) + + assertEquals(1, roomRepository.findAll().size) + assertEquals(roomRepository.findAll().single().id, response.roomId) + assertEquals(2, participantRepository.findAll().size) + assertEquals(0, messageRepository.findAll().size) + } + @Test @DisplayName("AI 캐릭터용 Member와는 유저-크리에이터 DM 방을 생성할 수 없다") fun shouldRejectCreateRoomWhenCreatorIsAiCharacterMember() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt index fd564faa..5ef9bd4b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository @@ -93,6 +94,43 @@ class UserCreatorChatServiceTest { Mockito.verify(participantRepository, Mockito.times(2)).save(Mockito.any(UserCreatorChatParticipant::class.java)) } + @Test + @DisplayName("크리에이터도 일반 회원에게 DM 방을 먼저 생성할 수 있고 메시지는 생성하지 않는다") + fun shouldCreateRoomWhenCreatorStartsDmToUser() { + val creator = member(1L, "creator").apply { role = MemberRole.CREATOR } + val user = member(2L, "user") + Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user)) + Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null) + Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatRoom).apply { id = 10L } + } + + val response = service.createOrGetRoom(creator, 2L) + + assertEquals(10L, response.roomId) + val participantCaptor = ArgumentCaptor.forClass(UserCreatorChatParticipant::class.java) + Mockito.verify(participantRepository, Mockito.times(2)).save(participantCaptor.capture()) + assertEquals(listOf(creator, user), participantCaptor.allValues.map { it.member }) + Mockito.verifyNoInteractions(messageRepository) + } + + @Test + @DisplayName("기존 활성 방이 있으면 새 방과 메시지를 생성하지 않고 기존 roomId를 반환한다") + fun shouldReturnExistingRoomWithoutCreatingMessage() { + val creator = member(1L, "creator").apply { role = MemberRole.CREATOR } + val user = member(2L, "user") + val existingRoom = room(10L) + Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user)) + Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(existingRoom) + + val response = service.createOrGetRoom(creator, 2L) + + assertEquals(10L, response.roomId) + Mockito.verify(roomRepository, Mockito.never()).save(Mockito.any(UserCreatorChatRoom::class.java)) + Mockito.verifyNoInteractions(participantRepository) + Mockito.verifyNoInteractions(messageRepository) + } + @Test @DisplayName("방 입장 응답은 상대방 닉네임과 프로필 이미지 URL을 포함한다") fun shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage() { -- 2.54.0