diff --git a/.claude/agents/chatforyou-external-expert.md b/.claude/agents/chatforyou-external-expert.md index ee59f5f4..4a2e1d98 100644 --- a/.claude/agents/chatforyou-external-expert.md +++ b/.claude/agents/chatforyou-external-expert.md @@ -48,6 +48,9 @@ color: orange | **프로젝트 적합성** | 기존 CLAUDE.md 컨벤션, 패턴과의 일관성 | | **통합 리스크** | 백엔드-프론트-테스트가 실제로 연결되었을 때의 위험 요소 | +> **[누락 위험 렌즈 — 동시성 코드 필수 체크]** 공유 상태(Redis 키, DB row, 파일 등)를 여러 replica/스레드가 만지는 로직을 검토할 때는 "재처리 안전성(idempotency: 같은 입력 두 번 처리해도 안전한가)"과 "레이스 안전성(TOCTOU: 스캔~처리 사이 값이 다른 것으로 바뀌면 안전한가)"을 **별개 질문으로 분리해서** 확인한다. 팀이 전자만 테스트하고 이를 "동시성 검증 완료"로 보고하는 경우가 있으므로, 후자(조건 없는 blind write/delete가 스캔 시점과 다른 최신 값을 덮어쓰거나 지우는 경로)가 실제로 존재하는지 코드에서 직접 추적한다. +> 사례(2026-07 bug_136_recording_partial_cleanup): 백엔드/QA 모두 "동일 마커 2회 처리"만 검증하고 "스캔 이후 다른 마커로 교체된 채 삭제"는 아무도 테스트하지 않아 external-expert 최초 발견 P1(마커 TOCTOU blind delete)으로 이어짐. + ### 3단계: Codex 교차검증 반복 루프 (cross-model review-rework loop — MANDATORY for L3) 같은 모델 계열(Claude)끼리의 검증은 블라인드 스팟이 상관관계를 가진다. 진짜 무상관 검증을 위해 OpenAI Codex 를 호출한다. diff --git a/.claude/agents/chatforyou-lead.md b/.claude/agents/chatforyou-lead.md index f04a86df..930cfcb1 100644 --- a/.claude/agents/chatforyou-lead.md +++ b/.claude/agents/chatforyou-lead.md @@ -203,6 +203,8 @@ commit 메시지는 vault 업데이트 완료 후 추천한다. ## 행동 규칙 - **commit / push 절대 금지** — commit 메시지 추천만 허용 + - 브랜치 생성/분리 작업 중에도 예외 없음. 새 브랜치에 "깨끗한 base"를 만들어주려고 기존에 쌓여있던 미커밋 변경사항을 임의로 커밋하거나 push하지 않는다 — 미커밋 상태 그대로 새 브랜치를 따거나, 정리가 필요하면 반드시 유저에게 먼저 확인한다. - PLAN 파일 없이 팀 작업 시작 금지 - 역할 경계를 명확히 유지 — 백엔드 코드를 직접 작성하지 않음 - 외부 전문가의 Critical 항목은 반드시 유저에게 전달하고, 해결 방안을 팀원에게 요청한다 +- **유저가 "중간 보고 없이 끝까지 진행"을 지시한 경우에도**, Phase(00~06)를 하나 완료할 때마다 해당 plan_docs 파일에 즉시 기록한다 (풀 리포트가 아니라 상태 체크포인트 목적). 세션 리밋 등으로 팀 리더가 중간에 응답불능이 되어도, 코디네이터가 이 문서만으로 마지막 완료 Phase를 파악해 새 팀 리더를 그 지점부터 재소집할 수 있어야 한다. diff --git a/.claude/agents/chatforyou-qa-expert.md b/.claude/agents/chatforyou-qa-expert.md index 723a79fe..2ea58e05 100644 --- a/.claude/agents/chatforyou-qa-expert.md +++ b/.claude/agents/chatforyou-qa-expert.md @@ -61,6 +61,12 @@ color: red > Service 단위 테스트(`@ExtendWith(MockitoExtension)`)는 백엔드 전문가 담당 +**동시성 테스트 시 반드시 구분할 두 질문 (하나만 검증하고 "동시성 검증 완료"로 끝내지 말 것)**: +1. **재처리 안전성(idempotency)** — 동일한 입력(같은 마커/같은 레코드)을 두 번 처리해도 안전한가? +2. **레이스 안전성(TOCTOU)** — 스캔/조회 시점과 실제 처리(수정·삭제) 시점 사이에 그 데이터가 다른 값으로 바뀌면 안전한가? (예: 공유 키를 여러 write 경로가 조건 없이 overwrite/blind-delete 하는 경우) + +1번만 테스트하고 2번을 놓치면 "동시성 검증했다"는 착시가 생긴다 — 공유 상태(Redis 키, DB row 등)를 여러 replica/스레드가 만지는 로직에서는 반드시 2번 시나리오(스캔 이후 값이 교체된 채 처리)도 별도 테스트로 재현한다. + ### 테스트 네이밍 ``` 메서드명_조건_기대결과 diff --git a/.claude/skills/chatforyou-dev-team.md b/.claude/skills/chatforyou-dev-team.md index 3acc672e..8827314e 100644 --- a/.claude/skills/chatforyou-dev-team.md +++ b/.claude/skills/chatforyou-dev-team.md @@ -285,7 +285,7 @@ FAIL / BLOCKED 시: ## 팀 공통 규칙 -- **commit / push 절대 금지** — 모든 팀원 공통 +- **commit / push 절대 금지** — 모든 팀원 공통. 브랜치 생성/분리 작업 중에도 예외 없음(새 브랜치의 "깨끗한 base"를 위해 기존 미커밋 변경사항을 임의로 커밋·push하지 않는다 — 정리가 필요하면 유저에게 먼저 확인). - **파일 소유권 엄수** — 배분되지 않은 파일 수정 금지 - **PLAN 파일 없이 시작 금지** - **구현 시작 전 `AGENT_GUIDE.md`와 관련 `docs/*.md` 재확인 필수** @@ -294,6 +294,17 @@ FAIL / BLOCKED 시: --- +## 팀 리더 생존 모니터링 (코디네이터 필수 확인) + +"중간 보고 생략, 끝까지 진행" 같은 지시로 팀 리더를 장시간 논스톱 실행시킬 경우, 세션/토큰 리밋으로 팀 리더가 응답불능이 될 수 있다. 코디네이터(메인 세션)는 아래를 따른다: + +1. 팀 리더의 `idle_notification`이 실질 내용(신규 산출물·진행 언급) 없이 반복되면, `SendMessage`로 직접 상태를 재확인한다. +2. `SendMessage`가 "agent... not reachable" 등으로 실패하면 팀 리더가 죽은 것으로 간주한다. +3. 이 경우 코드/문서 손실 여부부터 확인한다 — `plan_docs/00~06/[기능명].md` 중 어디까지 존재하는지, `git status`로 워킹트리 변경사항이 그대로 있는지 확인(각 Phase는 완료 즉시 문서화되므로 대부분 안전하게 보존되어 있다). +4. 유실이 없으면 새 `chatforyou-lead`를 재소집하되, **완료된 Phase는 재작업시키지 말고** 마지막 완료 Phase 다음 단계부터 이어서 진행하도록 명시적으로 지시한다. + +--- + ## AGENT_GUIDE Compliance Gate 이 skill을 통해 시작하는 모든 작업에도 `AGENT_GUIDE.md`의 Pre-Implementation Compliance Gate가 동일하게 적용된다. diff --git a/.codex/agents/chatforyou-external-expert.md b/.codex/agents/chatforyou-external-expert.md index e2c16fea..3c78a330 100644 --- a/.codex/agents/chatforyou-external-expert.md +++ b/.codex/agents/chatforyou-external-expert.md @@ -59,6 +59,10 @@ color: orange | **프로젝트 적합성** | 기존 AGENT_GUIDE.md 컨벤션, 패턴과의 일관성 | | **통합 리스크** | 백엔드-프론트-테스트가 실제로 연결되었을 때의 위험 요소 | +> **[누락 위험 렌즈 — 동시성 코드 필수 체크]** idempotency(같은 입력 재처리 안전성)와 TOCTOU(스캔 후 처리 시점 상태 변경 안전성)를 별도 질문으로 분리해 검토한다. 한쪽만 검증된 경우 "동시성 검증 완료"로 인정하지 않는다. +> +> 사례(2026-07 `bug_136_recording_partial_cleanup`): 같은 partial marker를 2회 처리하는 idempotency 테스트는 있었지만, 마커 스캔 후 같은 Redis key가 새 `recordingId`로 교체된 뒤 blind delete 하는 TOCTOU 경로가 누락될 수 있었다. cleanup/batch/Redis marker/TTL/lock 관련 리뷰에서는 "현재 값이 아직 내가 스캔한 값인가?"를 반드시 확인한다. + ### 3단계: Claude 교차검증 반복 루프 (cross-model review-rework loop — MANDATORY for L3) Codex host 의 외부 모델은 Claude다. 단발성 1회 검토가 아니라, **검토 → 분류 → 수정 → 재검토** 를 자동 반복하는 루프다. diff --git a/.codex/agents/chatforyou-lead.md b/.codex/agents/chatforyou-lead.md index 73a0cef5..81dff031 100644 --- a/.codex/agents/chatforyou-lead.md +++ b/.codex/agents/chatforyou-lead.md @@ -208,6 +208,8 @@ commit 메시지는 vault 업데이트 완료 후 추천한다. ## 행동 규칙 - **commit / push 절대 금지** — commit 메시지 추천만 허용 + - 브랜치 생성/분리 작업 중에도 예외 없음. 새 브랜치에 "깨끗한 base"를 만들어주려고 기존에 쌓여있던 미커밋 변경사항을 임의로 커밋하거나 push하지 않는다 — 미커밋 상태 그대로 새 브랜치를 따거나, 정리가 필요하면 반드시 유저에게 먼저 확인한다. - PLAN 파일 없이 팀 작업 시작 금지 - 역할 경계를 명확히 유지 — 백엔드 코드를 직접 작성하지 않음 - 외부 전문가의 Critical 항목은 반드시 유저에게 전달하고, 해결 방안을 팀원에게 요청한다 +- **유저가 "중간 보고 없이 끝까지 진행"을 지시한 경우에도**, Phase(00~06)를 하나 완료할 때마다 해당 `plan_docs` 파일에 즉시 기록한다. 중간 대화 보고를 생략할 수는 있어도 문서 체크포인트는 생략하지 않는다. diff --git a/.codex/agents/chatforyou-qa-expert.md b/.codex/agents/chatforyou-qa-expert.md index db8a4972..fb2b6d99 100644 --- a/.codex/agents/chatforyou-qa-expert.md +++ b/.codex/agents/chatforyou-qa-expert.md @@ -66,6 +66,12 @@ color: red > Service 단위 테스트(`@ExtendWith(MockitoExtension)`)는 백엔드 전문가 담당 +**동시성 테스트 시 반드시 구분할 두 질문 (하나만 검증하고 "동시성 검증 완료"로 끝내지 말 것)**: +1. **재처리 안전성(idempotency)** — 같은 작업/마커/이벤트를 2번 처리해도 결과가 안전한가? +2. **레이스 안전성(TOCTOU)** — 스캔한 상태와 실제 처리 시점의 현재 상태가 달라졌을 때도 안전한가? 특히 `scan -> file delete/log -> marker delete` 같은 흐름에서 현재 소유자/식별자가 바뀌었는지 재검증하는가? + +1번만 테스트하고 2번을 놓치면 "동일 입력 재처리"는 안전하지만 "중간에 새 값으로 교체된 상태"를 깨뜨릴 수 있다. cleanup/batch/Redis marker/lock/TTL 관련 코드는 두 질문을 별도 케이스로 확인한다. + ### 테스트 네이밍 ``` 메서드명_조건_기대결과 diff --git a/.codex/skills/chatforyou-dev-team/SKILL.md b/.codex/skills/chatforyou-dev-team/SKILL.md index 9f07a7cf..12303b60 100644 --- a/.codex/skills/chatforyou-dev-team/SKILL.md +++ b/.codex/skills/chatforyou-dev-team/SKILL.md @@ -302,7 +302,7 @@ STEP 3 QA로 넘어가기 전에 아래 항목이 모두 충족되어야 한다: ## 팀 공통 규칙 -- **commit / push 절대 금지** — 모든 팀원 공통 +- **commit / push 절대 금지** — 모든 팀원 공통. 브랜치 생성/분리 작업 중에도 예외 없음(새 브랜치의 "깨끗한 base"를 위해 기존 미커밋 변경사항을 임의로 커밋·push하지 않는다 — 정리가 필요하면 유저에게 먼저 확인). - **파일 소유권 엄수** — 배분되지 않은 파일 수정 금지 - **PLAN 파일 없이 시작 금지** - **구현 시작 전 `AGENT_GUIDE.md`와 관련 `docs/*.md` 재확인 필수** @@ -311,3 +311,14 @@ STEP 3 QA로 넘어가기 전에 아래 항목이 모두 충족되어야 한다: - TODO 체크박스는 대응 작업과 결정론 검증 evidence 가 실제로 완료된 뒤에만 `[x]` 로 표시 - Phase 03 종료와 Phase 05 진입 전 `scripts/verify-changes.sh` 결과를 기록한다 (`docs/agent/verification-protocol.md`) - custom agent 위임 시 `.codex/config.toml` 등록 정보와 `.codex/agents/*.toml` 설정을 source of truth로 사용 + +--- + +## 팀 리더 생존 모니터링 (코디네이터 필수 확인) + +"중간 보고 생략, 끝까지 진행" 같은 지시로 팀 리더를 장시간 논스톱 실행시킬 경우, 세션/토큰 리밋으로 팀 리더가 응답불능이 될 수 있다. 코디네이터(메인 세션)는 아래를 따른다: + +1. 팀 리더의 `idle_notification`이 실질 내용(신규 산출물·진행 언급) 없이 반복되면, `SendMessage`로 직접 상태를 재확인한다. +2. `SendMessage`가 "agent ... not reachable" 등으로 실패하면 팀 리더가 죽은 것으로 간주한다. +3. 이 경우 코드/문서 손실 여부부터 확인한다 — `plan_docs/00~06/[기능명].md` 중 어디까지 존재하는지, `git status`로 워킹트리 변경사항이 그대로 있는지 확인한다. 각 Phase는 완료 즉시 문서화되어야 하므로 대부분 안전하게 보존되어 있어야 한다. +4. 유실이 없으면 새 `chatforyou-lead`를 재소집하되, **완료된 Phase는 재작업시키지 말고** 마지막 완료 Phase 다음 단계부터 이어서 진행하도록 명시적으로 지시한다. diff --git a/.github/workflows/GitAction-k8s-deploy.yml b/.github/workflows/GitAction-k8s-deploy.yml index ab8b2354..f15b1dd0 100644 --- a/.github/workflows/GitAction-k8s-deploy.yml +++ b/.github/workflows/GitAction-k8s-deploy.yml @@ -2,7 +2,7 @@ name: CI/CD Pipeline with GitHub Actions for K8S on: push: - branches: [ "infra/cicd-test", "chatforyou_v2"] + branches: [ "infra/cicd-test", "chatforyou_v2", "bug/136"] # branches: [ "master-webrtc-catchmind", "infra/cicd-test" ] # pull_request: # branches: [ "master" ] diff --git a/plan_docs/00-base_plan/2026/07/bug_136_recording_partial_cleanup_plan.md b/plan_docs/00-base_plan/2026/07/bug_136_recording_partial_cleanup_plan.md new file mode 100644 index 00000000..e57e0407 --- /dev/null +++ b/plan_docs/00-base_plan/2026/07/bug_136_recording_partial_cleanup_plan.md @@ -0,0 +1,131 @@ +# [Base Plan] Bug #136 — 배포/종료 중 녹화 중단 시 partial 녹화 파일 정리 + +## 0. Prior Knowledge (연계 사이클) + +| Type | Note | Key Takeaway | +|------|------|--------------| +| CYCLE | bug_135_recording_inprogress (완료·머지, PR #142) | 중단 녹화의 in-progress 상태를 stopped 로 reset. reset 직전 파일 식별 정보를 별도 Redis 키 `room:recording:partial:{roomId}` 에 마커로 보존. #136 은 이 마커를 소비한다. | +| SPEC | 무중단 배포 복구 설계 | `partial recording file 정책`은 #135 에서 명시적으로 #136 으로 분리됨. | + +> 본 사이클은 #135 에서 심어둔 partial 마커 인프라를 소비하는 **신규 후처리 기능**이다. #135 의 녹화 상태 reset 로직은 재사용/변경하지 않는다. + +## 1. Summary (Goal & Scope) + +배포(Rolling Update) 또는 Pod 종료로 방 녹화가 중단되면 KMS 컨테이너의 로컬 mp4 는 finalize/remux 없이 남는다(재생 불가 partial). #135 는 이 파일의 식별 정보를 `room:recording:partial:{roomId}` 마커에 보존만 했고, 실제 파일 정리는 미구현이다. + +목표: + +- 주기적 배치가 partial 마커를 스캔하여 일정 나이(age) 이상 경과한 대상의 partial 파일을 삭제한다. +- 로컬 PVC(RWX NFS) 파일 삭제 + (존재 시) MinIO 객체 삭제. +- 삭제 사실을 `download_log` 테이블에 "시스템 자동 삭제" 상태로 기록한다. +- 정리 완료 후 마커를 명시적으로 삭제한다(TTL 자연 만료 대기 X). + +확정 정책 (유저 승인 완료): + +1. remux/finalize 없음 — partial mp4 는 복구 시도 없이 삭제 대상으로만 취급. +2. 비노출 — partial 파일은 목록/다운로드 UI 에 노출하지 않음(#135 재입장 "녹화 중단" 메시지로 충분). +3. 삭제 기준 — 크기/길이 임계값 없이 무조건 삭제(마커 나이 기준). +4. 배치 스캔 주기 — 1시간. +5. `DownloadLog` 에 "시스템 자동 삭제" 구분용 신규 상태값 추가. + +명시적 제외 범위: + +- partial 파일 재생 복구(remux/finalize) — 하지 않음. +- FE 노출/다운로드 — 하지 않음(비노출). + +## 2. Impact Analysis (Critical) + +- [Backend]: 영향 범위가 전적으로 백엔드에 한정된다. + - `webChat/batch/` — 신규 배치 잡. `RoomBatchJob` 패턴(`@Scheduled` cron + `@SchedulerLock`) 준수. RWX NFS 를 3 replica 가 공유하므로 동시 삭제 경합 방지 위해 shedlock **필수**. + - `webChat/service/redis/RedisService(+Impl)` — partial 마커 전체 목록을 가져오는 신규 스캔 메서드. 기존 `getKeysByPattern`(slaveTemplate, SCAN 기반)·`getChatRoomListForDelete` 패턴 참조. 마커 키 패턴 = `room:recording:partial:*`. + - `webChat/service/recording/` — 신규 정리 서비스. 스캔→age 필터→로컬 파일 삭제→MinIO 삭제(방어)→DownloadLog 기록→마커 삭제 오케스트레이션. + - `webChat/entity/DownloadLog.java` — `DownloadStatus` enum 에 시스템 자동 삭제 신규 값 추가. system-deletion 로그용 factory 보강 가능. + - `webChat/service/monitoring/DownloadLogService(+Impl)` — system-deletion 로그 저장 경로(기존 `saveDownloadLog` 재사용 가능). + - 참조(재사용): 로컬 파일 삭제 패턴 = `RecordingUploadService`/`RecordingFileService` 의 `localFilePath.replace("file://","")` → `new File(cleanPath).delete()`. MinIO 삭제 패턴 = `AbstractFileService.deleteFileDir` 의 `minioClient.removeObject(RemoveObjectArgs)`. +- [Frontend]: **변경 없음.** partial 녹화는 이미 어떤 목록에도 노출되지 않는다 — #135 가 `recordingInfo` 를 null 로 reset 했고, partial 은 MinIO 업로드 전 중단이라 `minioFilePath` 가 비어 있으며, `download_log` 에 SUCCESS 로 기록된 적이 없다. 따라서 비노출 정책은 신규 FE 코드 없이 이미 충족된다. +- [Desktop]: 영향 없음. FE 변경이 없으므로 Electron sync 불필요. +- [Infra/Deploy]: manifest 변경 없음. 신규 배치는 기존 스케줄러 인프라(shedlock 테이블) 위에서 동작. 신규 config property 2개(cleanup age, cron) 추가. + +## 3. Technology & Risks + +Risk Level: **L2** + +Reason: + +- 변경 지점이 **Redis 상태(마커 스캔/삭제) + 파일시스템/MinIO 삭제 + DB 영속(DownloadLog)** 이다 → `AGENT_GUIDE.md` §3 L2(Backend state / Redis / persistence). +- WebRTC / WebSocket / Signaling / Kurento / room lifecycle / Desktop **변경 없음** → L3 아님. 본 기능은 #135 가 심은 마커를 **소비만** 하며 녹화 시그널링·미디어 파이프라인을 건드리지 않는다. +- 추가되는 코드는 대부분 additive(신규 배치·신규 스캔 메서드·enum 값 추가·신규 서비스)로 기존 시그널링 심볼을 수정하지 않는다. +- Compound rule applied: no. +- 독립 PDCA: 기존 `bug_135` 사이클 재사용 금지 — 본 신규 `bug_136` 사이클로 진행. +- 필수 Phase: **00~05** (빌드·테스트 증거 필수). + +## 3-1. 핵심 설계 결정 (유저 승인 완료 — 2026-07-05 확정) + +- **(OPEN-1 확정)**: 옵션 A 채택. 삭제 임계값 = **4시간**, 신규 property `recording.partial.cleanup.age-seconds=14400`. 마커 TTL 은 6시간(21600s) 그대로 유지, #135 인프라(RedisServiceImpl TTL 소유) 변경 없음. +- **(OPEN-2 확정)**: `DownloadStatus` 신규 값 = **`SYSTEM_AUTO_DELETED`** (유저 직접 지정). + +아래는 결정 근거 기록(참고용). + +### (OPEN-1) 마커 TTL vs 삭제 age 임계값 경합 — 확정: 옵션 A (4h) + +승인된 정책 5번은 "markedAt 기준 경과시간이 TTL(6h) 이상이면 삭제"지만, 마커 Redis TTL 자체가 6h(`recording.partial.marker.ttl-seconds=21600`, RedisServiceImpl 단독 소유)다. + +문제: **Redis 가 6h 시점에 마커 키를 자동 evict** 하므로, "age >= 6h" 조건의 배치는 살아있는 마커를 거의 만나지 못한다. 마커가 먼저 사라지면 파일을 가리키는 포인터가 없어 partial 파일이 **영구 orphan** 이 된다(NFS 에 파일만 남고 삭제 트리거 소멸). 즉 삭제 age 임계값은 반드시 **마커 TTL 보다 작아야** 기능이 성립한다. + +부가 결합: 마커는 #135 재입장 "녹화 중단" 메시지의 수명도 겸한다(`notified` 플래그). 배치가 임계값 시점에 마커를 삭제하면 그 이후 재입장 사용자는 메시지를 못 본다 → 메시지 노출 창 = 삭제 임계값. + +| 옵션 | 삭제 age 임계값 | 마커 TTL | 재입장 메시지 창 | #135 config 변경 | 비고 | +|---|---|---|---|---|---| +| **A (권장)** | 신규 `recording.partial.cleanup.age-seconds` = 예 4h | 6h 유지 | 4h | 없음 | 배치 주기 1h 이므로 임계값~TTL 사이 2h(2회 실행) 여유 → 모든 마커를 TTL evict 전 확실히 처리 | +| B | 6h | ~8h 로 상향 | 6h | **있음** (#135 TTL 변경) | "6h" 문구에 충실하나 #135 인프라 config 수정 필요 | +| C | 짧게(예 1~2h) | 6h 유지 | 1~2h | 없음 | 파일은 pod 사망 시 이미 dead 라 조기 삭제 무해하나 메시지 창이 짧음 | + +권장: **옵션 A**. 삭제 임계값을 마커 TTL 보다 작은 별도 property 로 두어 배치가 TTL evict 전에 확실히 마커를 소비하게 하고, #135 인프라(6h TTL)는 그대로 둔다. 임계값 구체 수치(4h 제안)는 유저 확정 필요. + +### (OPEN-2) DownloadLog 신규 상태값 명칭 + 필드 매핑 — 확정: `SYSTEM_AUTO_DELETED` + +- 신규 `DownloadStatus` 값 = **`SYSTEM_AUTO_DELETED`** (확정). UPPER_SNAKE_CASE 컨벤션 준수. +- 마커는 `recordingUserId`(String), `recordingNickName` 만 보유하고 `userIdx`(Long)/`email` 는 없다. 따라서 system-deletion 로그: `userIdx=null`, `email=null`(또는 recordingUserId 매핑), `roomId=marker.roomId`, `targetType=RECORDING`, `targetId=recordingId`, `fileName=marker.fileName`, `filePath=marker.filePath`(또는 minioFilePath), `status=SYSTEM_AUTO_DELETED`. 구체 매핑은 백엔드 전문가 상세 설계 시 확정. + +## 4. File Ownership (제안 — 유저 승인 후 확정) + +| 파일/영역 | 담당 | +|------|------| +| `webChat/batch/RecordingPartialCleanupBatchJob.java` (신규, `@Scheduled` 1h + `@SchedulerLock`) | 백엔드 전문가 | +| `webChat/service/recording/` 신규 정리 서비스 (스캔→age필터→파일삭제→MinIO삭제→로그→마커삭제) | 백엔드 전문가 | +| `webChat/service/redis/RedisService(+Impl)` partial 마커 전체 스캔 메서드 (`room:recording:partial:*`) | 백엔드 전문가 | +| `webChat/entity/DownloadLog.java` (`DownloadStatus` 신규 값 + system-deletion factory) | 백엔드 전문가 | +| `webChat/service/monitoring/DownloadLogService(+Impl)` (system-deletion 로그 저장) | 백엔드 전문가 | +| config property (`recording.partial.cleanup.age-seconds`, cron) | 백엔드 전문가 | +| `springboot-backend/plan_docs/bug_136_recording_partial_cleanup.md` (full code guide) | 백엔드 전문가 | +| `springboot-backend/src/test/**` (age 경계·minioFilePath null 방어·마커 파일 부재 방어·로그 기록·SchedulerLock 시나리오·통합) | QA 전문가 | +| Frontend | **해당 없음** (비노출 정책 이미 충족) | + +## 5. Document Mapping + +- [x] 기본 계획: `plan_docs/00-base_plan/2026/07/bug_136_recording_partial_cleanup_plan.md` +- [x] 요구사항 및 데이터: `plan_docs/01-plan/bug_136_recording_partial_cleanup.md` +- [x] 인터페이스 및 시퀀스: `plan_docs/02-design/bug_136_recording_partial_cleanup.md` +- [x] 구현 가이드: `plan_docs/03-implementation/bug_136_recording_partial_cleanup.md` +- [x] 백엔드 구현 가이드: `springboot-backend/plan_docs/bug_136_recording_partial_cleanup.md` +- [ ] 프론트 구현 가이드: 해당 없음 (FE 변경 없음) +- [x] 갭 분석: `plan_docs/04-analyze/bug_136_recording_partial_cleanup.md` +- [x] 전문가 리뷰: `plan_docs/05-expert-review/bug_136_recording_partial_cleanup.md` +- [x] 최종 보고서: `plan_docs/06-report/bug_136_recording_partial_cleanup.md` + +## 6. Workflow Gate (L2) + +- [x] Pre-Implementation Compliance Gate 선언 (Risk L2 / compound no / phase 00-05 / 본 plan 경로 / BE-only impact) +- [x] **OPEN-1(TTL/임계값=4h) + OPEN-2(상태값=SYSTEM_AUTO_DELETED) 유저 결정 완료** (2026-07-05) +- [x] Phase 01 요구사항 + 데이터(신규 enum 값·config) 작성 +- [x] Phase 02 design (배치 시퀀스 + 검증 시나리오) +- [x] Phase 03 구현 (BE) — 빌드·테스트·컨벤션 통과 +- [x] Phase 04 gap 분석 +- [x] Phase 05 자체 재검토 (Claude 미사용: 토큰 부족으로 인한 Codex 개발/자체 재검토 전환) +- [ ] vault knowledge capture (또는 해당 없음 — 이유 명시) + +## 7. Branch + +현재 `bug/136` 브랜치에서 작업(#135 sourcery-ai 리뷰 반영 커밋 `a96fc2c` 이미 완료). 별도 워크트리 없이 이 브랜치에서 진행. + + diff --git a/springboot-backend/src/main/java/webChat/batch/RecordingPartialCleanupBatchJob.java b/springboot-backend/src/main/java/webChat/batch/RecordingPartialCleanupBatchJob.java new file mode 100644 index 00000000..4ae5dd59 --- /dev/null +++ b/springboot-backend/src/main/java/webChat/batch/RecordingPartialCleanupBatchJob.java @@ -0,0 +1,45 @@ +package webChat.batch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import webChat.exception.ChatForYouException; +import webChat.service.recording.CleanupResult; +import webChat.service.recording.RecordingPartialCleanupService; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RecordingPartialCleanupBatchJob { + + private final RecordingPartialCleanupService recordingPartialCleanupService; + + /** + * 중단된 방 녹화의 partial 파일을 매시 정각(1h 주기)에 정리한다. + * RWX NFS 를 다중 replica 가 공유한다. SchedulerLock 은 shedlock 배선 시 단일 replica 실행을 보장하며, + * 미배선 상태라도 각 정리 연산(로컬 삭제/MinIO 삭제/마커 삭제/로그 insert)이 idempotent 해 + * 다중 replica 동시 실행 시에도 데이터 손상 없이 안전하다. + */ + @Scheduled(cron = "${recording.partial.cleanup.cron:0 0 * * * *}", zone = "Asia/Seoul") + @SchedulerLock( + name = "cleanupPartialRecordingLock", + lockAtLeastFor = "30s", // 배선 시: 빨리 끝나도 최소 30초는 Lock 유지해 재진입 억제 + lockAtMostFor = "5m" // 배선 시: NFS/MinIO I/O 여유 + replica 사망 시 Lock 자동 해제 + ) + public void cleanupPartialRecording() { + try { + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + log.info("##########################"); + log.info("Partial Recording Cleanup :: deleted={}, skipped={}, failed={}", + result.deleted(), result.skipped(), result.failed()); + log.info("##########################"); + } catch (ChatForYouException e) { + log.error("partial 녹화 정리 배치 실패: code={}, message={}", + e.getErrorCode().getCode(), e.getMessage()); + } catch (Exception e) { + log.error("partial 녹화 정리 배치 중 예기치 않은 오류: {}", e.getMessage(), e); + } + } +} diff --git a/springboot-backend/src/main/java/webChat/entity/DownloadLog.java b/springboot-backend/src/main/java/webChat/entity/DownloadLog.java index 95695423..f2a61a03 100644 --- a/springboot-backend/src/main/java/webChat/entity/DownloadLog.java +++ b/springboot-backend/src/main/java/webChat/entity/DownloadLog.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.*; +import webChat.model.record.RecordingPartialMarker; /** * 다운로드 히스토리 로그 @@ -59,7 +60,7 @@ public enum DownloadType { @Getter public enum DownloadStatus { - SUCCESS, FAIL, PENDING + SUCCESS, FAIL, PENDING, SYSTEM_AUTO_DELETED } @Column(updatable = false) @@ -89,4 +90,23 @@ public static DownloadLog of(Long userIdx, String email, String roomId, .status(status) .build(); } + + /** + * 시스템 배치가 partial 녹화 파일을 자동 삭제한 사실을 기록하는 감사 로그를 만든다. + * of() 는 filePath.split 로 targetId 를 파생하고 filePath null 시 NPE 라, 마커의 recordingId 를 targetId 로 직접 사용한다. + */ + public static DownloadLog ofSystemAutoDeleted(RecordingPartialMarker marker) { + return DownloadLog.builder() + .userIdx(null) + .email(marker.getRecordingUserId()) + .roomId(marker.getRoomId()) + .targetType(DownloadType.RECORDING) + .targetId(marker.getRecordingId()) + .fileName(marker.getFileName()) + .filePath(marker.getFilePath()) + .ipAddress(null) + .userAgent(null) + .status(DownloadStatus.SYSTEM_AUTO_DELETED) + .build(); + } } diff --git a/springboot-backend/src/main/java/webChat/service/recording/CleanupResult.java b/springboot-backend/src/main/java/webChat/service/recording/CleanupResult.java new file mode 100644 index 00000000..8ec811ba --- /dev/null +++ b/springboot-backend/src/main/java/webChat/service/recording/CleanupResult.java @@ -0,0 +1,7 @@ +package webChat.service.recording; + +/** + * partial 녹화 정리 배치 1회 실행 결과 카운트. 로그·테스트 검증용. + */ +public record CleanupResult(int deleted, int skipped, int failed) { +} diff --git a/springboot-backend/src/main/java/webChat/service/recording/RecordingPartialCleanupService.java b/springboot-backend/src/main/java/webChat/service/recording/RecordingPartialCleanupService.java new file mode 100644 index 00000000..802e4c1d --- /dev/null +++ b/springboot-backend/src/main/java/webChat/service/recording/RecordingPartialCleanupService.java @@ -0,0 +1,154 @@ +package webChat.service.recording; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import webChat.entity.DownloadLog; +import webChat.model.record.RecordingPartialMarker; +import webChat.service.file.impl.RecordingFileService; +import webChat.service.monitoring.DownloadLogService; +import webChat.service.redis.RedisService; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +/** + * 중단된 방 녹화의 partial 파일을 정리한다. + * partial 마커 스캔 → age 필터 → 로컬 파일 삭제 → MinIO 방어 삭제 → 감사 로그 → 마커 삭제 순으로 오케스트레이션한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RecordingPartialCleanupService { + + private final RedisService redisService; + private final RecordingFileService recordingFileService; + private final DownloadLogService downloadLogService; + + @Value("${recording.partial.cleanup.age-seconds:14400}") + private long cleanupAgeSeconds; + + /** + * age 임계값을 넘긴 partial 녹화 파일을 정리한다. + * 마커 1건 실패가 배치 전체를 중단시키지 않도록 마커 단위로 예외를 격리한다. + * 각 연산(로컬 삭제/MinIO 삭제/마커 삭제/로그 insert)이 idempotent 하므로 다중 replica 가 동시에 + * 실행돼도 파일/마커 부재를 정상 흐름으로 흡수하며, 최악의 경우 감사 로그 중복 로우만 남는다. + */ + public CleanupResult cleanupExpiredPartialRecordings() { + List markers = redisService.getAllRecordingPartialMarkers(); + int deleted = 0; + int skipped = 0; + int failed = 0; + long now = System.currentTimeMillis(); + + for (RecordingPartialMarker marker : markers) { + try { + // markedAt 기준 age 가 임계값 미만이면 다음 회차로 미룬다(마커 TTL evict 전 재처리 기회 확보) + if (now - marker.getMarkedAt() < cleanupAgeSeconds * 1000L) { + skipped++; + continue; + } + if (!isCurrentMarker(marker)) { + skipped++; + log.warn("partial 정리 스킵: 최신 마커 불일치 roomId={}, recordingId={}", + marker.getRoomId(), marker.getRecordingId()); + continue; + } + deleteLocalRecordingDirectory(marker); + deleteMinioObjectIfPresent(marker); + downloadLogService.saveDownloadLog(DownloadLog.ofSystemAutoDeleted(marker)); + if (redisService.deleteRecordingPartialMarkerIfRecordingIdMatches( + marker.getRoomId(), marker.getRecordingId())) { + deleted++; + } else { + skipped++; + log.warn("partial 마커 삭제 스킵: roomId={}, recordingId={}", + marker.getRoomId(), marker.getRecordingId()); + } + } catch (Exception e) { + failed++; + log.error("partial 녹화 정리 실패: roomId={}, recordingId={}", + marker.getRoomId(), marker.getRecordingId(), e); + } + } + return new CleanupResult(deleted, skipped, failed); + } + + private boolean isCurrentMarker(RecordingPartialMarker marker) { + RecordingPartialMarker currentMarker = redisService.getRecordingPartialMarker(marker.getRoomId()); + return currentMarker != null + && Objects.equals(currentMarker.getRecordingId(), marker.getRecordingId()); + } + + // 로컬 PVC의 recordingId 디렉토리 삭제. roomId 디렉토리는 새 녹화와 충돌할 수 있어 삭제하지 않는다. + private void deleteLocalRecordingDirectory(RecordingPartialMarker marker) throws IOException { + String fileFullPath = marker.getFileFullPath(); + if (fileFullPath == null || fileFullPath.isBlank()) { + log.warn("partial 로컬 파일 경로 없음: roomId={}", marker.getRoomId()); + return; + } + Path localFilePath = Path.of(fileFullPath.replace("file://", "")); + Path recordingDirectory = localFilePath.getParent(); + if (recordingDirectory == null) { + log.warn("partial 로컬 파일 부모 디렉토리 없음: path={}", localFilePath); + return; + } + + if (!Objects.equals(recordingDirectory.getFileName().toString(), marker.getRecordingId())) { + deleteLocalFileOnly(localFilePath); + return; + } + + if (!Files.exists(recordingDirectory)) { + log.warn("partial 로컬 녹화 디렉토리 부재: path={}", recordingDirectory); + return; + } + deleteDirectoryRecursively(recordingDirectory); + log.info("partial 로컬 녹화 디렉토리 삭제: path={}", recordingDirectory); + } + + private void deleteLocalFileOnly(Path localFilePath) throws IOException { + File localFile = localFilePath.toFile(); + if (!localFile.exists()) { + log.warn("partial 로컬 파일 부재: path={}", localFilePath); + return; + } + if (localFile.delete()) { + log.info("partial 로컬 파일 삭제: path={}", localFilePath); + } else { + throw new IOException("partial 로컬 파일 삭제 실패: path=" + localFilePath); + } + } + + private void deleteDirectoryRecursively(Path directory) throws IOException { + try (Stream paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + // MinIO 객체 삭제. partial 은 업로드 전 중단이라 보통 minioFilePath 가 비어 있어 스킵이 정상 케이스다. + private void deleteMinioObjectIfPresent(RecordingPartialMarker marker) { + String minioFilePath = marker.getMinioFilePath(); + if (minioFilePath == null || minioFilePath.isBlank()) { + return; + } + recordingFileService.deleteFileDir(minioFilePath); + } +} diff --git a/springboot-backend/src/main/java/webChat/service/redis/RedisService.java b/springboot-backend/src/main/java/webChat/service/redis/RedisService.java index f1b5135f..cd605bb1 100644 --- a/springboot-backend/src/main/java/webChat/service/redis/RedisService.java +++ b/springboot-backend/src/main/java/webChat/service/redis/RedisService.java @@ -109,6 +109,16 @@ public interface RedisService { */ void deleteRecordingPartialMarker(String roomId); + /** + * 현재 저장된 방 부분 녹화 마커의 recordingId가 기대값과 같을 때만 삭제한다. + */ + boolean deleteRecordingPartialMarkerIfRecordingIdMatches(String roomId, String recordingId); + + /** + * 모든 partial 녹화 마커를 조회한다. 부분 파일 정리 배치가 age 필터링을 위해 소비한다. + */ + List getAllRecordingPartialMarkers(); + /** * claim 시점의 방 데이터를 master Redis에서 조회한다. */ diff --git a/springboot-backend/src/main/java/webChat/service/redis/impl/RedisServiceImpl.java b/springboot-backend/src/main/java/webChat/service/redis/impl/RedisServiceImpl.java index 356720ec..151befa6 100644 --- a/springboot-backend/src/main/java/webChat/service/redis/impl/RedisServiceImpl.java +++ b/springboot-backend/src/main/java/webChat/service/redis/impl/RedisServiceImpl.java @@ -304,7 +304,10 @@ public boolean deleteAllChatRoomData(String str) { (RedisCallback>) connection -> connection.scan(scanOptions))) { while (cursor.hasNext()) { - keysToDelete.add(new String(cursor.next(), StandardCharsets.UTF_8).replace("\"", "")); + String key = new String(cursor.next(), StandardCharsets.UTF_8).replace("\"", ""); + if (!key.startsWith(RECORDING_PARTIAL_PREFIX.getPrefix())) { + keysToDelete.add(key); + } } } catch (Exception e) { log.error("Error occurred while scanning and deleting keys", e); @@ -559,6 +562,80 @@ public void deleteRecordingPartialMarker(String roomId) { masterTemplate.delete(RECORDING_PARTIAL_PREFIX.getPrefix() + roomId); } + @Override + public boolean deleteRecordingPartialMarkerIfRecordingIdMatches(String roomId, String recordingId) { + String key = RECORDING_PARTIAL_PREFIX.getPrefix() + roomId; + Boolean deleted = masterTemplate.execute(new SessionCallback() { + @Override + public Boolean execute(@NotNull RedisOperations operations) { + operations.watch(key); + Object value = operations.opsForValue().get(key); + if (!(value instanceof RecordingPartialMarker marker) + || !Objects.equals(marker.getRecordingId(), recordingId)) { + operations.unwatch(); + return false; + } + + operations.multi(); + operations.delete(key); + List results = operations.exec(); + return isSingleDeleteTransactionSuccessful(results, key); + } + }); + return Boolean.TRUE.equals(deleted); + } + + private boolean isSingleDeleteTransactionSuccessful(List results, String key) { + if (results == null || results.isEmpty()) { + log.warn("Unexpected empty Redis transaction result when deleting recording partial marker: key={}", key); + return false; + } + if (results.size() != 1) { + log.warn("Unexpected Redis transaction result size when deleting recording partial marker: key={}, size={}, results={}", + key, results.size(), results); + return false; + } + + Object deleteResult = results.get(0); + if (deleteResult instanceof Boolean booleanResult) { + return booleanResult; + } + if (deleteResult instanceof Long longResult) { + return longResult > 0; + } + + log.warn("Unexpected Redis delete result when deleting recording partial marker: key={}, type={}, value={}", + key, deleteResult == null ? "null" : deleteResult.getClass().getName(), deleteResult); + return false; + } + + @Override + public List getAllRecordingPartialMarkers() { + String pattern = RECORDING_PARTIAL_PREFIX.getPrefix() + "*"; + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); + + try { + List markers = slaveTemplate.execute((RedisCallback>) connection -> { + List scannedMarkers = new ArrayList<>(); + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + String key = new String(cursor.next(), StandardCharsets.UTF_8); + // 값 조회는 기존 마커 read(getRecordingPartialMarker)와 동일하게 master 를 사용해 일관성을 유지 + Object value = masterTemplate.opsForValue().get(key); + if (value instanceof RecordingPartialMarker marker) { + scannedMarkers.add(marker); + } + } + } + return scannedMarkers; + }); + return markers == null ? List.of() : markers; + } catch (Exception e) { + log.error("Error scanning recording partial markers: ", e); + throw new ChatForYouException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + @Override public ChatRoom getChatRoomFromMaster(String roomId) { return (ChatRoom) masterTemplate.opsForHash().get(makeRedisKey(roomId), DataType.CHATROOM.getType()); diff --git a/springboot-backend/src/test/java/webChat/batch/RecordingPartialCleanupBatchJobTest.java b/springboot-backend/src/test/java/webChat/batch/RecordingPartialCleanupBatchJobTest.java new file mode 100644 index 00000000..a65f7c0c --- /dev/null +++ b/springboot-backend/src/test/java/webChat/batch/RecordingPartialCleanupBatchJobTest.java @@ -0,0 +1,75 @@ +package webChat.batch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import webChat.exception.ChatForYouException; +import webChat.exception.ErrorCode; +import webChat.service.recording.CleanupResult; +import webChat.service.recording.RecordingPartialCleanupService; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +/** + * RecordingPartialCleanupBatchJob 단위 테스트 — 배치 래퍼가 서비스 예외를 삼키고 + * 호출자(스케줄러)에게 전파하지 않는지 검증한다(RoomBatchJob 패턴 준수 확인). + */ +@ExtendWith(MockitoExtension.class) +class RecordingPartialCleanupBatchJobTest { + + @Mock + private RecordingPartialCleanupService recordingPartialCleanupService; + + @InjectMocks + private RecordingPartialCleanupBatchJob recordingPartialCleanupBatchJob; + + // 배치 로깅 검증용 카운트일 뿐 도메인 의미는 없어 로컬 헬퍼로 매직 리터럴을 분리한다. + private CleanupResult normalCleanupResult() { + return new CleanupResult(2, 1, 0); + } + + @Test + @DisplayName("cleanupPartialRecording_서비스정상반환_예외없이카운트로깅하고종료") + void cleanupPartialRecording_serviceSucceeds_completesWithoutException() { + // given + given(recordingPartialCleanupService.cleanupExpiredPartialRecordings()) + .willReturn(normalCleanupResult()); + + // when & then + // 예외 없이 정상 반환되고 서비스가 정확히 1회 호출된다 + assertThatCode(() -> recordingPartialCleanupBatchJob.cleanupPartialRecording()) + .doesNotThrowAnyException(); + verify(recordingPartialCleanupService).cleanupExpiredPartialRecordings(); + } + + @Test + @DisplayName("cleanupPartialRecording_서비스가ChatForYouException던짐_배치가삼키고전파하지않음") + void cleanupPartialRecording_serviceThrowsChatForYouException_swallowsAndDoesNotPropagate() { + // given + willThrow(new ChatForYouException(ErrorCode.INTERNAL_SERVER_ERROR)) + .given(recordingPartialCleanupService).cleanupExpiredPartialRecordings(); + + // when & then + // 배치 메서드가 예외를 로깅만 하고 호출자에게 전파하지 않는다 + assertThatCode(() -> recordingPartialCleanupBatchJob.cleanupPartialRecording()) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("cleanupPartialRecording_서비스가일반Exception던짐_배치가삼키고전파하지않음") + void cleanupPartialRecording_serviceThrowsGenericException_swallowsAndDoesNotPropagate() { + // given + willThrow(new RuntimeException("예기치 않은 오류")) + .given(recordingPartialCleanupService).cleanupExpiredPartialRecordings(); + + // when & then + assertThatCode(() -> recordingPartialCleanupBatchJob.cleanupPartialRecording()) + .doesNotThrowAnyException(); + } +} diff --git a/springboot-backend/src/test/java/webChat/entity/DownloadLogTest.java b/springboot-backend/src/test/java/webChat/entity/DownloadLogTest.java new file mode 100644 index 00000000..caa1aa5e --- /dev/null +++ b/springboot-backend/src/test/java/webChat/entity/DownloadLogTest.java @@ -0,0 +1,103 @@ +package webChat.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import webChat.model.record.RecordingPartialMarker; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * DownloadLog.ofSystemAutoDeleted 필드 매핑 검증. + * 시스템 배치가 partial 녹화 파일을 자동 삭제했을 때 감사 로그 필드가 마커 정보와 + * 정확히 매핑되는지(특히 targetId=recordingId, userIdx=null) 확인한다. + */ +class DownloadLogTest { + + private RecordingPartialMarker marker() { + return RecordingPartialMarker.builder() + .roomId("room-1") + .recordingId("rec-1") + .recordingUserId("user-1@test.com") + .recordingNickName("테스터") + .fileName("rec.mp4") + .filePath("room-1/rec-1/rec.mp4") + .fileFullPath("file:///data/room-1/rec-1/rec.mp4") + .minioFilePath(null) + .startAt(1_000_000L) + .markedAt(System.currentTimeMillis()) + .notified(false) + .build(); + } + + @Test + @DisplayName("ofSystemAutoDeleted_마커전달_status는SYSTEM_AUTO_DELETED이고targetType은RECORDING이다") + void ofSystemAutoDeleted_givenMarker_statusIsSystemAutoDeletedAndTargetTypeIsRecording() { + // given + RecordingPartialMarker marker = marker(); + + // when + DownloadLog result = DownloadLog.ofSystemAutoDeleted(marker); + + // then + assertThat(result.getStatus()).isEqualTo(DownloadLog.DownloadStatus.SYSTEM_AUTO_DELETED); + assertThat(result.getTargetType()).isEqualTo(DownloadLog.DownloadType.RECORDING); + } + + @Test + @DisplayName("ofSystemAutoDeleted_마커전달_targetId는recordingId를직접사용한다") + void ofSystemAutoDeleted_givenMarker_targetIdUsesRecordingIdDirectly() { + // given + RecordingPartialMarker marker = marker(); + + // when + DownloadLog result = DownloadLog.ofSystemAutoDeleted(marker); + + // then + // of() 의 filePath.split 파생 방식이 아니라 recordingId 를 그대로 사용한다 + assertThat(result.getTargetId()).isEqualTo("rec-1"); + } + + @Test + @DisplayName("ofSystemAutoDeleted_마커전달_roomId및fileName및filePath가마커값으로매핑된다") + void ofSystemAutoDeleted_givenMarker_roomIdFileNameFilePathMappedFromMarker() { + // given + RecordingPartialMarker marker = marker(); + + // when + DownloadLog result = DownloadLog.ofSystemAutoDeleted(marker); + + // then + assertThat(result.getRoomId()).isEqualTo("room-1"); + assertThat(result.getFileName()).isEqualTo("rec.mp4"); + assertThat(result.getFilePath()).isEqualTo("room-1/rec-1/rec.mp4"); + } + + @Test + @DisplayName("ofSystemAutoDeleted_마커전달_userIdx는null이고ipAddress및userAgent도null이다") + void ofSystemAutoDeleted_givenMarker_userIdxAndIpAddressAndUserAgentAreNull() { + // given + RecordingPartialMarker marker = marker(); + + // when + DownloadLog result = DownloadLog.ofSystemAutoDeleted(marker); + + // then + // 마커에 Long userIdx 가 없고, 시스템 작업이라 ip/agent 도 없다 + assertThat(result.getUserIdx()).isNull(); + assertThat(result.getIpAddress()).isNull(); + assertThat(result.getUserAgent()).isNull(); + } + + @Test + @DisplayName("ofSystemAutoDeleted_마커전달_email은recordingUserId로매핑된다") + void ofSystemAutoDeleted_givenMarker_emailMappedFromRecordingUserId() { + // given + RecordingPartialMarker marker = marker(); + + // when + DownloadLog result = DownloadLog.ofSystemAutoDeleted(marker); + + // then + assertThat(result.getEmail()).isEqualTo("user-1@test.com"); + } +} diff --git a/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupIdempotencyTest.java b/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupIdempotencyTest.java new file mode 100644 index 00000000..eb10f5a9 --- /dev/null +++ b/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupIdempotencyTest.java @@ -0,0 +1,110 @@ +package webChat.service.recording; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import webChat.model.record.RecordingPartialMarker; +import webChat.service.file.impl.RecordingFileService; +import webChat.service.monitoring.DownloadLogService; +import webChat.service.redis.RedisService; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * S8 — shedlock 이 이 저장소에서 no-op(LockProvider Bean 부재, 선재 결함)임을 전제로, + * 3 replica 가 동일 마커를 동시에 처리하는 상황을 "동일 마커에 대한 cleanup 2회 연속 실행"으로 + * 시뮬레이션한다. 두 replica 가 락 없이 동일 마커를 읽고(getAllRecordingPartialMarkers 가 + * 두 번 모두 같은 마커를 반환) 각자 정리 로직을 수행하는 시나리오를 재현해, 데이터 손상/크래시 + * 없이 두 번째 실행이 파일/마커 부재를 정상 흡수하는지, 그리고 유일한 부작용이 + * SYSTEM_AUTO_DELETED 감사 로그 중복 insert 뿐인지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +class RecordingPartialCleanupIdempotencyTest { + + private static final long AGE_SECONDS = 14400L; + + @Mock + private RedisService redisService; + + @Mock + private RecordingFileService recordingFileService; + + @Mock + private DownloadLogService downloadLogService; + + private RecordingPartialCleanupService recordingPartialCleanupService; + + private void setUpService() { + recordingPartialCleanupService = new RecordingPartialCleanupService( + redisService, recordingFileService, downloadLogService); + ReflectionTestUtils.setField(recordingPartialCleanupService, "cleanupAgeSeconds", AGE_SECONDS); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_동일마커를2회연속실행_두번째실행은파일마커부재를정상흡수하고크래시없음") + void cleanupExpiredPartialRecordings_sameMarkerProcessedTwice_secondRunAbsorbsAbsenceWithoutCrash( + @TempDir Path tempDir) throws Exception { + // given + // 두 replica 가 shedlock 없이 동일 마커를 동시에 읽었다고 가정 — + // getAllRecordingPartialMarkers() 가 두 번의 배치 실행 모두에서 동일 마커를 반환한다. + setUpService(); + File partialFile = tempDir.resolve("rec.mp4").toFile(); + assertThat(partialFile.createNewFile()).isTrue(); + + long expiredMarkedAt = System.currentTimeMillis() - (AGE_SECONDS * 1000L) - 1000L; + RecordingPartialMarker marker = RecordingPartialMarker.builder() + .roomId("room-race") + .recordingId("rec-race") + .recordingUserId("user-1") + .fileName("rec.mp4") + .filePath("room-race/rec-race/rec.mp4") + .fileFullPath(partialFile.getAbsolutePath()) + .minioFilePath(null) + .markedAt(expiredMarkedAt) + .build(); + + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(marker)); + given(redisService.getRecordingPartialMarker("room-race")).willReturn(marker); + given(redisService.deleteRecordingPartialMarkerIfRecordingIdMatches("room-race", "rec-race")) + .willReturn(true); + + // when + // replica A 실행 — 파일이 실제로 삭제되고 마커 삭제/로그 insert 가 호출된다 + CleanupResult firstRun = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + // 1차 실행은 정상 deleted, 파일이 실제로 사라짐 + assertThat(firstRun.deleted()).isEqualTo(1); + assertThat(firstRun.failed()).isZero(); + assertThat(partialFile).doesNotExist(); + + // when + // replica B 실행(동일 마커, 파일은 이미 A가 삭제함) — 크래시 없이 정상 흡수돼야 한다 + assertThatCode(() -> { + CleanupResult secondRun = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + // 2차 실행도 예외 없이 deleted 로 집계된다(파일 부재 = 정상 흐름, Redis DEL 도 부재 키에 no-op) + assertThat(secondRun.deleted()).isEqualTo(1); + assertThat(secondRun.failed()).isZero(); + }).doesNotThrowAnyException(); + + // then + // 유일한 부작용은 SYSTEM_AUTO_DELETED 감사 로그 중복 insert(2회) 뿐이다. + // MinIO/로컬 파일 삭제는 idempotent(부재에도 무해)하고, 실제 파일 삭제는 1회만 일어났다. + verify(downloadLogService, times(2)).saveDownloadLog(any()); + verify(redisService, times(2)) + .deleteRecordingPartialMarkerIfRecordingIdMatches("room-race", "rec-race"); + } +} diff --git a/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupServiceQaTest.java b/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupServiceQaTest.java new file mode 100644 index 00000000..0fc625e8 --- /dev/null +++ b/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupServiceQaTest.java @@ -0,0 +1,193 @@ +package webChat.service.recording; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import webChat.model.record.RecordingPartialMarker; +import webChat.service.file.impl.RecordingFileService; +import webChat.service.monitoring.DownloadLogService; +import webChat.service.redis.RedisService; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +/** + * RecordingPartialCleanupService QA 보강 테스트 — 백엔드 단위 테스트(RecordingPartialCleanupServiceTest)가 + * 다루지 않는 경계/방어/개별 실패 격리 시나리오(S4~S6)를 검증한다. + */ +@ExtendWith(MockitoExtension.class) +class RecordingPartialCleanupServiceQaTest { + + private static final long AGE_SECONDS = 14400L; + + @Mock + private RedisService redisService; + + @Mock + private RecordingFileService recordingFileService; + + @Mock + private DownloadLogService downloadLogService; + + private RecordingPartialCleanupService recordingPartialCleanupService; + + private void setUpService() { + recordingPartialCleanupService = new RecordingPartialCleanupService( + redisService, recordingFileService, downloadLogService); + ReflectionTestUtils.setField(recordingPartialCleanupService, "cleanupAgeSeconds", AGE_SECONDS); + } + + private long expiredMarkedAt() { + return System.currentTimeMillis() - (AGE_SECONDS * 1000L) - 1000L; + } + + private RecordingPartialMarker.RecordingPartialMarkerBuilder expiredMarkerBuilder(String roomId) { + return RecordingPartialMarker.builder() + .roomId(roomId) + .recordingId("rec-" + roomId) + .recordingUserId("user-1") + .fileName("rec.mp4") + .filePath(roomId + "/rec-" + roomId + "/rec.mp4") + .minioFilePath(null) + .markedAt(expiredMarkedAt()); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_S4_로컬파일이미부재_예외없이로그기록및마커삭제진행") + void cleanupExpiredPartialRecordings_localFileAlreadyAbsent_proceedsWithoutException(@TempDir Path tempDir) { + // given + // fileFullPath 가 가리키는 파일을 생성하지 않아 부재 상태를 재현 + setUpService(); + File missingFile = tempDir.resolve("missing-rec.mp4").toFile(); + assertThat(missingFile).doesNotExist(); + + RecordingPartialMarker marker = expiredMarkerBuilder("room-s4") + .fileFullPath(missingFile.getAbsolutePath()) + .build(); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(marker)); + given(redisService.getRecordingPartialMarker("room-s4")).willReturn(marker); + given(redisService.deleteRecordingPartialMarkerIfRecordingIdMatches("room-s4", "rec-room-s4")) + .willReturn(true); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + // 파일 부재가 예외로 이어지지 않고 warn 후 정상 흐름(deleted 집계)으로 진행된다 + assertThat(result.deleted()).isEqualTo(1); + assertThat(result.failed()).isZero(); + verify(downloadLogService).saveDownloadLog(any()); + verify(redisService).deleteRecordingPartialMarkerIfRecordingIdMatches("room-s4", "rec-room-s4"); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_S5_fileUri프리픽스_프리픽스제거된경로로파일삭제") + void cleanupExpiredPartialRecordings_fileUriPrefix_deletesWithStrippedPath(@TempDir Path tempDir) throws Exception { + // given + // fileFullPath = "file://" + 실제 임시파일 절대경로 + setUpService(); + Path recordingDirectory = tempDir.resolve("room-s5").resolve("rec-room-s5"); + Files.createDirectories(recordingDirectory); + File partialFile = recordingDirectory.resolve("rec-uri.mp4").toFile(); + assertThat(partialFile.createNewFile()).isTrue(); + + String fileUriPath = "file://" + partialFile.getAbsolutePath(); + RecordingPartialMarker marker = expiredMarkerBuilder("room-s5") + .fileFullPath(fileUriPath) + .build(); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(marker)); + given(redisService.getRecordingPartialMarker("room-s5")).willReturn(marker); + given(redisService.deleteRecordingPartialMarkerIfRecordingIdMatches("room-s5", "rec-room-s5")) + .willReturn(true); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + // file:// 프리픽스가 제거된 실제 경로의 recordingId 디렉토리가 삭제된다 + assertThat(result.deleted()).isEqualTo(1); + assertThat(partialFile).doesNotExist(); + assertThat(recordingDirectory).doesNotExist(); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_S6_1번째마커에서만실패_2번째마커deleted_배치중단없음") + void cleanupExpiredPartialRecordings_onlyFirstMarkerThrows_secondMarkerDeletedNormally(@TempDir Path tempDir) { + // given + // 마커 2건. 1번째 roomId 에 대해서만 deleteRecordingPartialMarker 가 예외를 던지도록 설정 + setUpService(); + RecordingPartialMarker firstMarker = expiredMarkerBuilder("room-fail-2") + .fileFullPath(null) + .build(); + RecordingPartialMarker secondMarker = expiredMarkerBuilder("room-ok-2") + .fileFullPath(null) + .build(); + given(redisService.getAllRecordingPartialMarkers()) + .willReturn(List.of(firstMarker, secondMarker)); + given(redisService.getRecordingPartialMarker("room-fail-2")).willReturn(firstMarker); + given(redisService.getRecordingPartialMarker("room-ok-2")).willReturn(secondMarker); + + willThrow(new RuntimeException("Redis 삭제 실패")) + .given(redisService).deleteRecordingPartialMarkerIfRecordingIdMatches( + eq("room-fail-2"), eq("rec-room-fail-2")); + given(redisService.deleteRecordingPartialMarkerIfRecordingIdMatches( + "room-ok-2", "rec-room-ok-2")).willReturn(true); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + // 1번째 failed 집계 + 2번째는 정상 deleted, 배치 전체가 중단되지 않는다 + assertThat(result.failed()).isEqualTo(1); + assertThat(result.deleted()).isEqualTo(1); + assertThat(result.skipped()).isZero(); + verify(redisService).deleteRecordingPartialMarkerIfRecordingIdMatches("room-ok-2", "rec-room-ok-2"); + verify(downloadLogService, org.mockito.Mockito.times(2)).saveDownloadLog(any()); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_TOCTOU_새마커가덮어써진경우_파일삭제와로그없이스킵") + void cleanupExpiredPartialRecordings_markerReplacedBeforeDelete_skipsWithoutFileDeleteOrLog(@TempDir Path tempDir) + throws Exception { + // given + setUpService(); + File partialFile = tempDir.resolve("old-rec.mp4").toFile(); + assertThat(partialFile.createNewFile()).isTrue(); + + RecordingPartialMarker oldMarker = expiredMarkerBuilder("room-race") + .recordingId("rec-old") + .fileFullPath(partialFile.getAbsolutePath()) + .build(); + RecordingPartialMarker newMarker = expiredMarkerBuilder("room-race") + .recordingId("rec-new") + .fileFullPath(tempDir.resolve("new-rec.mp4").toString()) + .build(); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(oldMarker)); + given(redisService.getRecordingPartialMarker("room-race")).willReturn(newMarker); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + assertThat(result.deleted()).isZero(); + assertThat(result.skipped()).isEqualTo(1); + assertThat(result.failed()).isZero(); + assertThat(partialFile).exists(); + org.mockito.Mockito.verify(downloadLogService, org.mockito.Mockito.never()).saveDownloadLog(any()); + org.mockito.Mockito.verify(redisService, org.mockito.Mockito.never()) + .deleteRecordingPartialMarkerIfRecordingIdMatches(any(), any()); + } +} diff --git a/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupServiceTest.java b/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupServiceTest.java new file mode 100644 index 00000000..44790aac --- /dev/null +++ b/springboot-backend/src/test/java/webChat/service/recording/RecordingPartialCleanupServiceTest.java @@ -0,0 +1,202 @@ +package webChat.service.recording; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import webChat.entity.DownloadLog; +import webChat.model.record.RecordingPartialMarker; +import webChat.service.file.impl.RecordingFileService; +import webChat.service.monitoring.DownloadLogService; +import webChat.service.redis.RedisService; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * RecordingPartialCleanupService 단위 테스트 — 구현 메서드의 정상 케이스 + 단순 예외. + * 경계값/개별 실패 격리/동시성/통합은 QA 전문가 담당. + */ +@ExtendWith(MockitoExtension.class) +class RecordingPartialCleanupServiceTest { + + private static final long AGE_SECONDS = 14400L; + + @Mock + private RedisService redisService; + + @Mock + private RecordingFileService recordingFileService; + + @Mock + private DownloadLogService downloadLogService; + + @InjectMocks + private RecordingPartialCleanupService recordingPartialCleanupService; + + private void setAgeThreshold() { + ReflectionTestUtils.setField(recordingPartialCleanupService, "cleanupAgeSeconds", AGE_SECONDS); + } + + private RecordingPartialMarker markerWith(long markedAt, String fileFullPath, String minioFilePath) { + return RecordingPartialMarker.builder() + .roomId("room-1") + .recordingId("rec-1") + .recordingUserId("user-1") + .fileName("rec.mp4") + .filePath("room-1/rec-1/rec.mp4") + .fileFullPath(fileFullPath) + .minioFilePath(minioFilePath) + .markedAt(markedAt) + .build(); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_임계값초과마커_파일삭제및로그및마커삭제") + void cleanupExpiredPartialRecordings_expiredMarker_deletesFileLogsAndRemovesMarker(@TempDir Path tempDir) throws IOException { + // given + setAgeThreshold(); + Path recordingDirectory = tempDir.resolve("room-1").resolve("rec-1"); + Files.createDirectories(recordingDirectory); + File partialFile = recordingDirectory.resolve("rec.mp4").toFile(); + assertThat(partialFile.createNewFile()).isTrue(); + Path otherRecordingDirectory = tempDir.resolve("room-1").resolve("rec-other"); + Files.createDirectories(otherRecordingDirectory); + + long expiredMarkedAt = System.currentTimeMillis() - (AGE_SECONDS * 1000L) - 1000L; + RecordingPartialMarker marker = markerWith(expiredMarkedAt, partialFile.getAbsolutePath(), null); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(marker)); + given(redisService.getRecordingPartialMarker("room-1")).willReturn(marker); + given(redisService.deleteRecordingPartialMarkerIfRecordingIdMatches("room-1", "rec-1")) + .willReturn(true); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + assertThat(result.deleted()).isEqualTo(1); + assertThat(result.skipped()).isZero(); + assertThat(result.failed()).isZero(); + assertThat(partialFile).doesNotExist(); + assertThat(recordingDirectory).doesNotExist(); + assertThat(otherRecordingDirectory).exists(); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(DownloadLog.class); + verify(downloadLogService).saveDownloadLog(logCaptor.capture()); + DownloadLog savedLog = logCaptor.getValue(); + assertThat(savedLog.getStatus()).isEqualTo(DownloadLog.DownloadStatus.SYSTEM_AUTO_DELETED); + assertThat(savedLog.getTargetId()).isEqualTo("rec-1"); + assertThat(savedLog.getRoomId()).isEqualTo("room-1"); + assertThat(savedLog.getFileName()).isEqualTo("rec.mp4"); + + verify(redisService).deleteRecordingPartialMarkerIfRecordingIdMatches("room-1", "rec-1"); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_마커조건부삭제실패_스킵으로집계") + void cleanupExpiredPartialRecordings_conditionalMarkerDeleteFails_countsAsSkipped(@TempDir Path tempDir) throws IOException { + // given + setAgeThreshold(); + Path recordingDirectory = tempDir.resolve("room-1").resolve("rec-1"); + Files.createDirectories(recordingDirectory); + File partialFile = recordingDirectory.resolve("rec.mp4").toFile(); + assertThat(partialFile.createNewFile()).isTrue(); + + long expiredMarkedAt = System.currentTimeMillis() - (AGE_SECONDS * 1000L) - 1000L; + RecordingPartialMarker marker = markerWith(expiredMarkedAt, partialFile.getAbsolutePath(), null); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(marker)); + given(redisService.getRecordingPartialMarker("room-1")).willReturn(marker); + given(redisService.deleteRecordingPartialMarkerIfRecordingIdMatches("room-1", "rec-1")) + .willReturn(false); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + assertThat(result.deleted()).isZero(); + assertThat(result.skipped()).isEqualTo(1); + assertThat(result.failed()).isZero(); + assertThat(partialFile).doesNotExist(); + assertThat(recordingDirectory).doesNotExist(); + verify(downloadLogService).saveDownloadLog(org.mockito.ArgumentMatchers.any()); + verify(redisService).deleteRecordingPartialMarkerIfRecordingIdMatches("room-1", "rec-1"); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_임계값미만마커_스킵") + void cleanupExpiredPartialRecordings_notExpiredMarker_skips() { + // given + setAgeThreshold(); + long freshMarkedAt = System.currentTimeMillis(); + RecordingPartialMarker marker = markerWith(freshMarkedAt, "/tmp/rec.mp4", null); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(marker)); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + assertThat(result.skipped()).isEqualTo(1); + assertThat(result.deleted()).isZero(); + assertThat(result.failed()).isZero(); + verify(downloadLogService, never()).saveDownloadLog(org.mockito.ArgumentMatchers.any()); + verify(redisService, never()).deleteRecordingPartialMarkerIfRecordingIdMatches( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_마커없음_카운트0반환") + void cleanupExpiredPartialRecordings_noMarkers_returnsZeroCounts() { + // given + setAgeThreshold(); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of()); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + assertThat(result.deleted()).isZero(); + assertThat(result.skipped()).isZero(); + assertThat(result.failed()).isZero(); + } + + @Test + @DisplayName("cleanupExpiredPartialRecordings_minioFilePath존재_MinIO객체삭제호출") + void cleanupExpiredPartialRecordings_minioPathPresent_callsMinioDelete(@TempDir Path tempDir) throws IOException { + // given + setAgeThreshold(); + Path recordingDirectory = tempDir.resolve("room-1").resolve("rec-1"); + Files.createDirectories(recordingDirectory); + File partialFile = recordingDirectory.resolve("rec.mp4").toFile(); + assertThat(partialFile.createNewFile()).isTrue(); + + long expiredMarkedAt = System.currentTimeMillis() - (AGE_SECONDS * 1000L) - 1000L; + String minioFilePath = "room-1/rec-1/rec.mp4"; + RecordingPartialMarker marker = markerWith(expiredMarkedAt, partialFile.getAbsolutePath(), minioFilePath); + given(redisService.getAllRecordingPartialMarkers()).willReturn(List.of(marker)); + given(redisService.getRecordingPartialMarker("room-1")).willReturn(marker); + given(redisService.deleteRecordingPartialMarkerIfRecordingIdMatches("room-1", "rec-1")) + .willReturn(true); + + // when + CleanupResult result = recordingPartialCleanupService.cleanupExpiredPartialRecordings(); + + // then + assertThat(result.deleted()).isEqualTo(1); + assertThat(recordingDirectory).doesNotExist(); + verify(recordingFileService).deleteFileDir(eq(minioFilePath)); + } +} diff --git a/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplDeleteAllChatRoomDataTest.java b/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplDeleteAllChatRoomDataTest.java new file mode 100644 index 00000000..1224aa0b --- /dev/null +++ b/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplDeleteAllChatRoomDataTest.java @@ -0,0 +1,82 @@ +package webChat.service.redis.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.dengliming.redismodule.redisearch.client.RediSearchClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; + +import java.nio.charset.StandardCharsets; +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class RedisServiceImplDeleteAllChatRoomDataTest { + + private static final String ROOM_ID = "room-qa-001"; + private static final String ROOM_KEY = "roomId:" + ROOM_ID; + private static final String PARTIAL_MARKER_KEY = "room:recording:partial:" + ROOM_ID; + + @Mock + private RedisTemplate masterTemplate; + @Mock + private RedisTemplate slaveTemplate; + @Mock + private ObjectMapper objectMapper; + @Mock + private RediSearchClient rediSearchClient; + @Mock + private RedisConnectionFactory connectionFactory; + @Mock + private RedisConnection connection; + @Mock + private Cursor cursor; + + private RedisServiceImpl sut; + + @BeforeEach + void setUp() { + sut = new RedisServiceImpl(masterTemplate, slaveTemplate, objectMapper, rediSearchClient); + } + + @Test + @DisplayName("deleteAllChatRoomData_roomId와partialMarker가같이매칭되어도partialMarker는삭제하지않는다") + void deleteAllChatRoomData_preservesRecordingPartialMarker() { + // given + given(slaveTemplate.execute(any(RedisCallback.class))).willAnswer(invocation -> { + RedisCallback callback = invocation.getArgument(0); + return callback.doInRedis(connection); + }); + given(connection.scan(any(ScanOptions.class))).willReturn(cursor); + given(cursor.hasNext()).willReturn(true, true, false); + given(cursor.next()).willReturn( + ROOM_KEY.getBytes(StandardCharsets.UTF_8), + PARTIAL_MARKER_KEY.getBytes(StandardCharsets.UTF_8) + ); + + // when + boolean result = sut.deleteAllChatRoomData(ROOM_ID); + + // then + assertThat(result).isTrue(); + ArgumentCaptor> keysCaptor = ArgumentCaptor.forClass(Collection.class); + verify(masterTemplate).delete(keysCaptor.capture()); + assertThat(keysCaptor.getValue()) + .containsExactly(ROOM_KEY) + .doesNotContain(PARTIAL_MARKER_KEY); + } +} diff --git a/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplRecordingPartialMarkerScanTest.java b/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplRecordingPartialMarkerScanTest.java new file mode 100644 index 00000000..abf6a90e --- /dev/null +++ b/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplRecordingPartialMarkerScanTest.java @@ -0,0 +1,164 @@ +package webChat.service.redis.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.dengliming.redismodule.redisearch.client.RediSearchClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.ValueOperations; +import webChat.model.record.RecordingPartialMarker; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +/** + * RedisServiceImpl.getAllRecordingPartialMarkers() SCAN 조회 테스트. + * 기존 RedisServiceImplRecordingPartialMarkerTest 와 동일하게 RedisTemplate/Cursor 를 모킹하는 + * 순수 단위 테스트 레이어(임베디드 Redis 미사용)를 따른다. save 한 마커들이 SCAN 으로 전부 + * 조회되는지, 마커 0건일 때 빈 리스트를 반환하는지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +class RedisServiceImplRecordingPartialMarkerScanTest { + + private static final String KEY_PREFIX = "room:recording:partial:"; + + @Mock + private RedisTemplate masterTemplate; + + @Mock + private RedisTemplate slaveTemplate; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private RediSearchClient rediSearchClient; + + @Mock + private ValueOperations valueOperations; + + @Mock + private RedisConnection connection; + + @Mock + private Cursor cursor; + + private RedisServiceImpl sut; + + @BeforeEach + void setUp() { + sut = new RedisServiceImpl(masterTemplate, slaveTemplate, objectMapper, rediSearchClient); + given(slaveTemplate.execute(any(RedisCallback.class))).willAnswer(invocation -> { + RedisCallback callback = invocation.getArgument(0); + return callback.doInRedis(connection); + }); + given(connection.scan(any(ScanOptions.class))).willReturn(cursor); + } + + private RecordingPartialMarker marker(String roomId, String recordingId) { + return RecordingPartialMarker.builder() + .roomId(roomId) + .recordingId(recordingId) + .recordingUserId("user-1") + .fileName("rec.mp4") + .filePath(roomId + "/" + recordingId + "/rec.mp4") + .fileFullPath("/data/" + roomId + "/" + recordingId + "/rec.mp4") + .minioFilePath(null) + .startAt(1_000_000L) + .markedAt(System.currentTimeMillis()) + .notified(false) + .build(); + } + + @Test + @DisplayName("getAllRecordingPartialMarkers_저장된마커2건_SCAN으로전부조회하고필드보존확인") + void getAllRecordingPartialMarkers_savedTwoMarkers_scansAllAndPreservesFields() { + // given + // SCAN 이 키 2건을 반환하고, 각 키에 대해 master get 이 저장된 마커를 반환(round-trip) + RecordingPartialMarker markerA = marker("room-a", "rec-a"); + RecordingPartialMarker markerB = marker("room-b", "rec-b"); + String keyA = KEY_PREFIX + "room-a"; + String keyB = KEY_PREFIX + "room-b"; + + given(cursor.hasNext()).willReturn(true, true, false); + given(cursor.next()).willReturn(keyA.getBytes(), keyB.getBytes()); + + given(masterTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(keyA)).willReturn(markerA); + given(valueOperations.get(keyB)).willReturn(markerB); + + // when + List result = sut.getAllRecordingPartialMarkers(); + + // then + // 저장된 2건이 모두 조회되고, 필드가 그대로 보존된다 + assertThat(result).hasSize(2); + assertThat(result).extracting(RecordingPartialMarker::getRoomId) + .containsExactlyInAnyOrder("room-a", "room-b"); + assertThat(result).extracting(RecordingPartialMarker::getRecordingId) + .containsExactlyInAnyOrder("rec-a", "rec-b"); + assertThat(result).extracting(RecordingPartialMarker::getFileName) + .containsOnly("rec.mp4"); + } + + @Test + @DisplayName("getAllRecordingPartialMarkers_마커0건_빈리스트반환") + void getAllRecordingPartialMarkers_noMarkers_returnsEmptyList() { + // given + // SCAN 이 키를 하나도 반환하지 않음 + given(cursor.hasNext()).willReturn(false); + + // when + List result = sut.getAllRecordingPartialMarkers(); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("getAllRecordingPartialMarkers_scan패턴은room콜론recording콜론partial콜론와일드카드이다") + void getAllRecordingPartialMarkers_usesRecordingPartialPrefixPattern() { + // given + given(cursor.hasNext()).willReturn(false); + + // when + sut.getAllRecordingPartialMarkers(); + + // then + // SCAN 이 room:recording:partial:* 패턴으로 호출된다(기존 마커 키 prefix 와 일관) + ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(ScanOptions.class); + org.mockito.Mockito.verify(connection).scan(optionsCaptor.capture()); + assertThat(optionsCaptor.getValue().getPattern()).isEqualTo(KEY_PREFIX + "*"); + } + + @Test + @DisplayName("getAllRecordingPartialMarkers_값이RecordingPartialMarker타입이아니면방어적으로제외한다") + void getAllRecordingPartialMarkers_valueNotMarkerType_isDefensivelyExcluded() { + // given + // 키는 스캔되지만 master get 결과가 null(TTL 만료 등 race) + String key = KEY_PREFIX + "room-race"; + given(cursor.hasNext()).willReturn(true, false); + given(cursor.next()).willReturn(key.getBytes()); + given(masterTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(key)).willReturn(null); + + // when + List result = sut.getAllRecordingPartialMarkers(); + + // then + // null/타입불일치 값은 결과에서 방어적으로 제외된다 + assertThat(result).isEmpty(); + } +} diff --git a/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplRecordingPartialMarkerTest.java b/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplRecordingPartialMarkerTest.java index 0c5bce2c..aefd5e01 100644 --- a/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplRecordingPartialMarkerTest.java +++ b/springboot-backend/src/test/java/webChat/service/redis/impl/RedisServiceImplRecordingPartialMarkerTest.java @@ -10,10 +10,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.util.ReflectionTestUtils; import webChat.model.record.RecordingPartialMarker; +import java.util.List; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -53,6 +56,9 @@ class RedisServiceImplRecordingPartialMarkerTest { @Mock private ValueOperations valueOperations; + @Mock + private RedisOperations redisOperations; + private RedisServiceImpl sut; @BeforeEach @@ -121,6 +127,72 @@ void deleteRecordingPartialMarker_deletesWithCorrectKey() { verify(masterTemplate).delete(EXPECTED_KEY); } + @Test + @DisplayName("deleteRecordingPartialMarkerIfRecordingIdMatches_조건부삭제콜백결과가true이면true반환") + void deleteRecordingPartialMarkerIfRecordingIdMatches_callbackTrue_returnsTrue() { + // given + given(masterTemplate.execute(any(SessionCallback.class))).willReturn(true); + + // when + boolean result = sut.deleteRecordingPartialMarkerIfRecordingIdMatches(ROOM_ID, "rec-qa-001"); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("deleteRecordingPartialMarkerIfRecordingIdMatches_조건부삭제콜백결과가false이면false반환") + void deleteRecordingPartialMarkerIfRecordingIdMatches_callbackFalse_returnsFalse() { + // given + given(masterTemplate.execute(any(SessionCallback.class))).willReturn(false); + + // when + boolean result = sut.deleteRecordingPartialMarkerIfRecordingIdMatches(ROOM_ID, "rec-other"); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("deleteRecordingPartialMarkerIfRecordingIdMatches_exec결과가Long1이면true반환") + void deleteRecordingPartialMarkerIfRecordingIdMatches_execLongOne_returnsTrue() { + // given + RecordingPartialMarker stored = fullMarker(); + given(masterTemplate.execute(any(SessionCallback.class))).willAnswer(invocation -> { + SessionCallback callback = invocation.getArgument(0); + return callback.execute(redisOperations); + }); + given(redisOperations.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(EXPECTED_KEY)).willReturn(stored); + given(redisOperations.exec()).willReturn(List.of(1L)); + + // when + boolean result = sut.deleteRecordingPartialMarkerIfRecordingIdMatches(ROOM_ID, "rec-qa-001"); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("deleteRecordingPartialMarkerIfRecordingIdMatches_exec결과타입이예상밖이면false반환") + void deleteRecordingPartialMarkerIfRecordingIdMatches_unexpectedExecResult_returnsFalse() { + // given + RecordingPartialMarker stored = fullMarker(); + given(masterTemplate.execute(any(SessionCallback.class))).willAnswer(invocation -> { + SessionCallback callback = invocation.getArgument(0); + return callback.execute(redisOperations); + }); + given(redisOperations.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(EXPECTED_KEY)).willReturn(stored); + given(redisOperations.exec()).willReturn(List.of("OK")); + + // when + boolean result = sut.deleteRecordingPartialMarkerIfRecordingIdMatches(ROOM_ID, "rec-qa-001"); + + // then + assertThat(result).isFalse(); + } + // ── notified 플래그 round-trip ──────────────────────────────────────────── @Test