diff --git a/docs/20260805_오디오콘텐츠댓글답글/api-contract.md b/docs/20260805_오디오콘텐츠댓글답글/api-contract.md new file mode 100644 index 0000000..e741213 --- /dev/null +++ b/docs/20260805_오디오콘텐츠댓글답글/api-contract.md @@ -0,0 +1,88 @@ +# 오디오 콘텐츠 댓글 첫 답글 API Contract + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 상태 | 기존 계약 재사용 확정 | +| 작성일 | 2026-08-05 | +| 원본 계약 | [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json) | +| 관련 PRD | [prd.md](./prd.md) | +| 관련 계획 | [plan-task.md](./plan-task.md) | + +## 계약 변경 여부 + +백엔드 API 변경은 없다. 이 문서는 이번 기능이 소비하는 기존 OpenAPI 범위와 +프론트엔드 전송값만 좁게 기록한다. 충돌하면 원본 OpenAPI가 우선한다. + +## Endpoint + +### 직접 답글 목록 + +```http +GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies?page=0&size=20 +Authorization: Bearer {jwt-token} +Accept-Language: ko +``` + +- `commentId`: 답글 영역을 연 원댓글 ID +- 성공: `data={ totalCount, items }` +- 답글 0개도 `totalCount=0`, `items=[]`인 정상 성공이다. + +### 댓글 또는 답글 작성 + +```http +POST /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments +Authorization: Bearer {jwt-token} +Accept-Language: ko +Content-Type: application/json +``` + +첫 답글 request: + +```json +{ + "comment": "답글 내용", + "parentId": 1102, + "isSecret": false, + "languageCode": null +} +``` + +| field | 형식 | 이번 기능의 값 | +|---|---|---| +| `comment` | string, required | trim 후 빈 문자열이 아닌 입력값 | +| `parentId` | nullable int64, optional | 답글 대상 활성 원댓글 ID | +| `isSecret` | boolean, optional | `false` | +| `languageCode` | nullable string, optional | `null` | + +- `parentId`가 있으면 같은 Audio target의 활성 원댓글에 대한 직접 답글이다. +- 답글 ID를 `parentId`로 보내는 3단계 작성은 허용하지 않는다. +- 성공 envelope의 `data`는 `null`이다. +- 성공 후 원댓글 목록과 열린 root의 현재 답글 page를 재조회한다. + +## 오류 응답 + +원본 OpenAPI의 공통 오류 envelope와 다음 status를 그대로 사용한다. + +| Status | 처리 | +|---:|---| +| 400 | invalid target·parent 또는 binding 오류를 화면 alert로 표시 | +| 401 | 공통 session 만료 처리 | +| 403 | 공통 접근 거부 처리 | +| 404 | target 또는 root를 찾을 수 없음 표시 | +| 405, 406, 415, 500 | 서버 message를 우선 표시하고 기존 재시도 정책 적용 | + +도메인별 message key를 새로 추정하지 않는다. + +## 프론트엔드 연결 + +| 역할 | 기존 구현 | +|---|---| +| target path 선택 | `commentCollectionPath()` | +| 답글 조회 | `getReplies()` | +| 답글 작성 | `createComment()` | +| request schema | `audioCommentCreateRequestSchema` | +| 성공 후 재조회 | `CommentThread.runMutation()` | + +API, schema, mock handler와 store는 이번 기능에서 변경하지 않는다. diff --git a/docs/20260805_오디오콘텐츠댓글답글/plan-task.md b/docs/20260805_오디오콘텐츠댓글답글/plan-task.md new file mode 100644 index 0000000..9c33522 --- /dev/null +++ b/docs/20260805_오디오콘텐츠댓글답글/plan-task.md @@ -0,0 +1,238 @@ +# 오디오 콘텐츠 댓글 답글 작성 진입 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 기능 구현·검증 및 `P1-R1` 문서 정합성 보완 완료 | +| 작성일 | 2026-08-05 | +| 요구사항 기준 | [prd.md](./prd.md) | +| API 기준 | [api-contract.md](./api-contract.md) | +| 현재 Phase | Phase 1 구현·검증 | +| 현재 활성 Goal | 없음 | + +## 목표 + +활성 오디오 콘텐츠의 답글 0개 원댓글에서도 기존 답글 form을 열어 첫 답글을 +작성할 수 있게 한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `2/2` | 없음 | 없음 | + +- 답글 API, form, 조회·작성·재조회 흐름은 이미 구현돼 있다. +- 활성 Audio의 `replyCount === 0` root에는 `답글 작성` 진입이 구현돼 있다. +- unit·mock E2E와 정적 검증이 완료됐고 애플리케이션 코드와 test가 현재 working tree에 반영돼 있다. + +## 범위의 포함·제외 + +### 포함 + +- 활성 Audio root의 `replyCount === 0`일 때 `답글 작성` 버튼 표시 +- 기존 답글 영역, form, GET·POST와 mutation 상태 재사용 +- Audio 첫 답글과 Community·비활성·reply row 경계 회귀 test +- 기존 Comments Chromium mock E2E와 정적 검증 + +### 제외 + +- Community 댓글 답글 진입 조건 변경 +- 새 endpoint, DTO, component, state library 또는 dependency +- form 상시 노출, reply-of-reply, payload 정책 변경 +- 기존 답글 수정·삭제·pagination 리팩터링 + +## 기술적 제약 + +- React·TypeScript strict, Vitest·React Testing Library와 기존 Playwright 구성을 사용한다. +- [api-contract.md](./api-contract.md)의 기존 GET·POST만 사용한다. +- `CommentThread`, `CommentItem`, `CommentForm`의 현재 책임 경계를 유지한다. +- 새 추상화보다 기존 reply state와 `toggleReplies()`를 재사용한다. +- RED → GREEN → REFACTOR 순서와 최소 변경을 지킨다. + +## Phase 1. 첫 답글 작성 진입 구현·검증 + +**Phase 결과:** 관리자가 활성 Audio의 답글 0개 원댓글에서 첫 답글을 작성하고 +기존 Community·읽기 전용·2단계 경계가 유지된다. + +**선행조건:** `ACR-001~005`와 기존 Audio 댓글 GET·POST 계약 확정. + +**Phase 완료 조건:** `P1-T1`, `P1-GATE` 완료와 Progress 기록. + +### Task 1.1 오디오 첫 답글 진입 + +**Goal 실행 `P1-T1`:** Audio의 답글 0개 원댓글에 기존 답글 영역을 여는 +`답글 작성` action을 추가한다. + +- **시작 조건:** [prd.md](./prd.md)의 `ACR-001~005`, [api-contract.md](./api-contract.md). +- **완료 증거:** TDD 체크박스, focused·회귀·E2E·정적 검증과 Progress 기록. +- **범위 밖:** Community 변경, API·mock·schema 변경, 관련 없는 Comments 리팩터링. + +**Files:** + +- Modify: `src/features/comments/components/CommentThread.tsx` +- Modify: `src/features/comments/components/CommentItem.tsx` +- Modify: `src/features/comments/tests/comment-thread.test.tsx` +- Modify: `tests/e2e/comments.spec.ts` +- Test: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts` + +**Interfaces:** + +- Consumes: `CommentRecord.replyCount`, `CommentTarget.kind`, `canMutate`, `toggleReplies()`, `CommentForm`, `createComment()`. +- Produces: `CommentItem`의 optional 답글 action label과 Audio 첫 답글 진입 조건. + +**TDD 절차:** + +- [x] **RED:** `comment-thread.test.tsx`에 Audio `replyCount=0` root의 `답글 작성` 노출, 클릭 후 form, `parentId` POST와 Community·비활성·reply row 신규 action 0건을 검증하는 실패 test를 작성하고 focused 명령의 의도한 실패를 확인한다. +- [x] **GREEN:** `CommentThread`와 `CommentItem`에 action label·노출 조건만 추가해 기존 reply 조회·form·POST 흐름으로 test를 통과시킨다. +- [x] **REFACTOR:** 기존 `showRepliesButton` boolean을 optional action label로 단순화하고 새 helper·component·dependency 없이 focused·Comments 회귀를 확인한다. +- [x] 기존 Audio mock E2E에서 답글 0개 root의 첫 답글 작성 journey를 검증한다. +- [x] 검증 결과를 Progress에 기록한다. + +**검증 기준:** + +- **실행 명령:** `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`; `npm run test:run -- src/features/comments`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`. +- **기대 결과:** 모든 명령 exit 0, Audio 첫 답글 POST 1회, Community·비활성·reply row 신규 action 0건, 기존 Comments 회귀 실패 0건. +- **수동 확인:** 활성 Audio 상세에서 답글 0개 root의 `답글 작성` → form 노출 → 성공 후 목록 반영을 확인한다. Community와 비활성 Audio는 기존 동작을 유지한다. + +### 완료 조건 + +- [x] `P1-T1`의 모든 TDD·검증 체크박스가 완료됐다. +- [x] `ACR-001~005`가 구현 또는 검증 증거에 연결됐다. +- [x] API·mock·schema와 범위 밖 파일 변경이 없다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 첫 답글 journey와 Comments 경계를 최종 판정한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 아래 명령·수동 확인 통과와 Progress 기록. +- **범위 밖:** test 완화, timeout 상향과 관련 없는 수정. + +**실행 명령:** + +```bash +npm run test:run -- src/features/comments +npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium +npm run typecheck +npm run lint +git diff --check +``` + +**기대 결과:** 모든 명령 exit 0, `ACR-001~005` 위반 0건. + +**수동 확인:** 활성 Audio, 비활성 Audio와 Community에서 action 노출 경계를 +대조하고 keyboard-only로 첫 답글을 작성한다. + +### Task R1.1 완료 문서 상태 정합성 복구 + +**Goal 실행 `P1-R1`:** `REV-P1-001`의 완료·미구현 상태 모순을 제거하고 +실제 검증 증거와 PRD·plan을 일치시킨다. + +- **연결 리뷰:** [Phase 1 구현 리뷰](./reviews/phase1-audio-comment-first-reply.md) — `REV-P1-001` +- **시작 조건:** `REV-P1-001` 확정, 완료된 `P1-T1`, `P1-GATE`. +- **완료 증거:** 현재 상태·발견된 문제·PRD §14 정정, 기존 Progress 보존, 리뷰 수정 후 검증 기록과 문서 검증 통과. +- **범위 밖:** 애플리케이션 코드·test·API Contract 변경, 기존 설계·구현 Progress 삭제. + +**Files:** + +- Modify: `docs/20260805_오디오콘텐츠댓글답글/prd.md` +- Modify: `docs/20260805_오디오콘텐츠댓글답글/plan-task.md` +- Modify: `docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md` +- Test: 없음 — 구현 동작이 아닌 완료 문서 정합성 수정이다. + +**Interfaces:** + +- Consumes: `REV-P1-001`, `ACR-001~005`, `P1-T1`, `P1-GATE`와 2026-08-05 검증 증거. +- Produces: 실제 구현 상태와 일치하는 PRD 수용 기준, plan 현재 상태와 review 수정 완료 기록. + +**TDD 예외 사유:** 애플리케이션 동작을 변경하지 않는 문서 정합성 Task이므로 +실패 unit test를 추가하지 않는다. + +**대체 검증 방법:** stale 미구현 marker와 PRD §14 미완료 checkbox가 제거됐는지 +검사하고 review link와 Markdown diff를 확인한다. + +- [x] 현재 상태의 미구현 문구를 실제 구현 완료 상태로 정정한다. +- [x] PRD §14 수용·추적 체크박스를 검증 증거에 맞게 완료 처리한다. +- [x] 발견된 문제를 `REV-P1-001` 문서 정합성 보완 상태로 정정한다. +- [x] 기존 Progress를 보존하고 `P1-R1`의 무엇을/왜/어떻게 검증 기록을 누적한다. +- [x] review 상태를 `수정 완료`로 갱신하고 수정 후 검증 기록을 누적한다. + +**검증 기준:** + +- **실행 명령:** `! rg -n '^- 현재 .*답글 진입 버튼이 없다|^- 애플리케이션 코드와 test는 아직 변경하지 않았다|^- 확정: .*첫 답글 작성 진입이 없다' docs/20260805_오디오콘텐츠댓글답글/plan-task.md`; `! sed -n '/## 14\./,/## 15\./p' docs/20260805_오디오콘텐츠댓글답글/prd.md | rg -n '^- \[ \]'`; `test -f docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, stale 미구현 marker와 PRD §14 미완료 checkbox 0건, review link 존재, Markdown whitespace 오류 0건. +- **수동 확인:** 없음 — 문서 marker·checkbox·link를 명령으로 직접 판정한다. + +## 실행 순서와 의존성 + +1. `P1-T1` RED +2. `P1-T1` GREEN +3. `P1-T1` REFACTOR·회귀 +4. `P1-GATE` +5. 확정 review 후속 `P1-R1` + +- 동시에 하나의 미완료 goal만 운용한다. +- 사용자가 goal 실행을 요청하기 전에는 goal을 생성하지 않는다. + +## 변경 금지 항목 + +- Community 댓글 동작과 API Contract 변경 +- 새 dependency, state library, component 또는 speculative abstraction +- 답글의 답글, optimistic update와 form 상시 노출 +- 실패 test 삭제·skip, timeout 상향으로 Gate 통과 +- 기존 Progress와 결정 기록 삭제·덮어쓰기 + +## 의사결정 및 중단 규칙 + +- `target.kind === "audio"`, `replyCount === 0`, `canMutate === true` 경계 밖으로 신규 작성 진입을 확대하지 않는다. +- API 응답이나 오류가 [api-contract.md](./api-contract.md)와 다르면 추정 수정하지 않고 외부 의존으로 기록한다. +- 범위가 바뀌면 코드보다 PRD Decision Log와 이 계획을 먼저 갱신한다. + +## Progress + +### 2026-08-05 설계 + +- **무엇을:** 답글 0개 Audio root의 첫 답글 작성 진입 요구사항, API 재사용 계약과 단일 구현 Task를 확정했다. +- **왜:** 기존 POST·form은 있지만 `replyCount === 0`일 때 진입 버튼이 없어 첫 답글을 작성할 수 없다. +- **어떻게:** 기존 코드·OpenAPI·unit·E2E를 대조하고 사용자에게 적용 target과 form 노출 방식을 한 번에 하나씩 확인했다. 애플리케이션 코드와 test는 변경하지 않았다. + +### 2026-08-05 구현·검증 + +- **무엇을:** 활성 Audio의 `replyCount=0` 원댓글에 `답글 작성` action을 추가하고 기존 답글 영역·form·POST 흐름으로 첫 답글을 작성하게 했다. +- **왜:** 기존 `replyCount > 0` 조건만으로는 첫 답글 작성 진입이 없어 `ACR-001~005`를 충족할 수 없었다. +- **어떻게:** `CommentItem`의 답글 action을 optional label로 바꾸고, `CommentThread`에서 Audio·`canMutate`·빈 답글 root에만 `답글 작성` label을 전달했다. RED는 `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`에서 `AI 루트 댓글 답글 작성` 버튼 부재로 실패했다. GREEN·회귀는 focused 8/8, `npm run test:run -- src/features/comments` 15/15, `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` 3/3, `npm run typecheck`, `npm run lint`, `git diff --check` 통과로 확인했다. Playwright mock 수동 QA에서 1280px·320px 첫 답글 작성과 320px 수평 overflow 없음도 확인했다. + +### 2026-08-05 리뷰 후속 Task 전환 정정 + +- **무엇을:** 확정 finding `REV-P1-001`을 후속 회귀 수정 Task `P1-R1`로 계획에 추가했다. +- **왜:** 코드 수정과 달리 확정 finding의 계획 전환은 리뷰 범위에서도 필수인데 초안만 리뷰 문서에 남겨 가이드의 종료 조건을 충족하지 못했다. +- **어떻게:** [코드 리뷰 및 QA 기록 규칙](../agent-guide/review.md)의 확정 항목 전환 기준을 다시 대조하고 review ID, goal ID, 시작 조건, 완료 증거, 범위 밖, TDD 예외, 대체 검증과 검증 기록 항목을 `P1-R1`에 명시했다. 애플리케이션 코드·test·API Contract는 변경하지 않았다. + +### 2026-08-05 P1-R1 문서 정합성 보완 + +- **무엇을:** `REV-P1-001`의 stale 현재 상태·발견된 문제를 구현 완료 상태로 정정하고 PRD §14 수용·추적 체크박스와 review 상태를 완료 처리했다. +- **왜:** 기능·test·검증은 완료됐지만 문서에 미구현·미완료 표기가 남아 다음 작업자와 자동 검증이 상반된 상태를 판정했다. +- **어떻게:** 수정 전 marker 검사에서 stale plan 문구 3곳과 PRD §14 미완료 체크박스 7개를 확인했다. 수정 후 stale marker 부재, PRD §14 미완료 체크박스 부재, review 파일 존재와 `git diff --check`를 각각 실행해 모두 exit 0을 확인했다. 추가 회귀 Gate는 Comments 15/15, Chromium mock E2E 3/3, typecheck·lint exit 0이었다. 애플리케이션 코드·test·API Contract는 변경하지 않았다. + +## Decision Log + +| 날짜 | 결정 | 근거 | 영향 | +|---|---|---|---| +| 2026-08-05 | 신규 진입은 Audio에만 적용한다. | 사용자 선택 B | `ACR-001`, `ACR-004`, `P1-T1` | +| 2026-08-05 | `답글 작성` 버튼으로 기존 답글 영역과 form을 연다. | 사용자 선택 A | `ACR-001~002`, `P1-T1` | +| 2026-08-05 | 새 API·컴포넌트 없이 기존 구현을 재사용한다. | OpenAPI와 코드 확인 | `ACR-003~005`, `P1-T1` | +| 2026-08-05 | 확정 review finding `REV-P1-001`을 문서 전용 후속 Task `P1-R1`로 전환한다. | review 가이드 §4·§5 | `P1-R1`, Phase 1 리뷰 | + +## 발견된 문제 + +- 수정 완료: `REV-P1-001`의 완료 상태와 stale 현재 상태·PRD 수용 체크박스 간 모순을 `P1-R1`에서 정정했다. +- 외부 차단: 없음. + +## 최종 보고 형식 + +- 완료 Goal ID +- 변경한 파일과 최소 구현 내용 +- RED·GREEN·REFACTOR 및 Gate 명령과 실제 결과 +- 실행하지 못한 수동·server 검증과 이유 +- 남은 위험 또는 열린 질문 diff --git a/docs/20260805_오디오콘텐츠댓글답글/prd.md b/docs/20260805_오디오콘텐츠댓글답글/prd.md new file mode 100644 index 0000000..110fd7e --- /dev/null +++ b/docs/20260805_오디오콘텐츠댓글답글/prd.md @@ -0,0 +1,210 @@ +# 오디오 콘텐츠 댓글 답글 작성 진입 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 기준 확정 | +| 작성일 | 2026-08-05 | +| 최종 수정일 | 2026-08-05 | +| 대상 기능 | 오디오 콘텐츠 댓글의 첫 답글 작성 진입 | +| 작성자·결정권자 | Codex 작성, 사용자 결정 | +| 상위 제품 기준 | [AI 캐릭터 관리자 웹 PRD](../20260725_AI캐릭터관리자웹/prd.md) | +| 관련 API Contract | [api-contract.md](./api-contract.md) | +| 관련 구현 계획 | [plan-task.md](./plan-task.md) | +| 관련 review | [Phase 1 구현 리뷰](./reviews/phase1-audio-comment-first-reply.md) | + +### 요구사항 상태 + +| 상태 | 의미 | +|---|---| +| 확정 | 구현과 검증 기준으로 사용한다. | +| 미결 | 제품 결정 전에는 구현하지 않는다. | +| 외부 의존 | 외부 계약이 제공될 때까지 영향 범위를 구현 완료로 표시하지 않는다. | +| 제외 | 현재 기능 범위에 포함하지 않는다. | + +## 1. Overview + +활성 AI 캐릭터의 오디오 콘텐츠 댓글에서 직접 답글이 아직 없는 원댓글에도 +`답글 작성` 진입을 제공한다. 기존 답글 조회 영역, 작성 form과 댓글 POST를 +재사용하며 커뮤니티 댓글 동작은 변경하지 않는다. + +## 2. Problem Statement + +현재 답글 POST와 작성 form은 구현돼 있지만 원댓글의 `replyCount`가 0이면 +답글 영역을 여는 버튼이 렌더되지 않는다. 따라서 관리자는 이미 답글이 있는 +원댓글에만 추가 답글을 쓸 수 있고 첫 답글은 작성할 수 없다. + +문제를 해결했다는 판단은 답글 0개인 오디오 원댓글에서 `답글 작성`을 눌러 +기존 form을 열고 `parentId`가 포함된 POST를 한 번 전송할 수 있는지로 한다. + +## 3. Goals + +### 3.1 제품 목표 + +- 활성 오디오 콘텐츠의 모든 원댓글에 첫 답글을 작성할 수 있다. +- 기존 2단계 댓글 구조와 API 계약을 그대로 유지한다. + +### 3.2 UX 목표 + +- 답글이 0개인 원댓글에는 `답글 작성`이라는 명확한 진입점을 표시한다. +- 버튼을 누르면 기존 답글 영역과 작성 form을 펼친다. +- 기존 loading, 오류, 전송 중, 실패 후 초안 보존 동작을 유지한다. + +## 4. Non-Goals + +- 커뮤니티 댓글의 답글 진입 조건 변경 +- 답글 form 상시 노출 +- 답글의 답글을 포함한 3단계 구조 +- 새 endpoint, DTO, 상태관리 또는 UI dependency 추가 +- `isSecret=false`, Audio `languageCode=null` 작성 정책 변경 +- 답글 수정·삭제·pagination 동작 변경 + +## 5. Target Users and Permissions + +| 사용자 | 목표 | 주요 작업 | 사용 환경 | +|---|---|---|---| +| ADMIN | AI 캐릭터 명의로 팬 원댓글에 첫 답글 작성 | 답글 영역 열기, 작성, 재시도 | desktop, tablet, mobile | + +- 인증과 ADMIN 권한은 상위 제품 기준을 따른다. +- 활성 AI 캐릭터 workspace에서만 답글 작성 control을 제공한다. +- 비활성 AI 캐릭터 workspace는 기존처럼 조회 전용이다. +- 원댓글 작성자가 팬인지 AI 캐릭터인지와 관계없이 답글을 작성할 수 있다. + +## 6. 핵심 사용자 흐름 + +1. 관리자가 활성 AI 캐릭터의 오디오 콘텐츠 상세에 진입한다. +2. 답글이 0개인 원댓글에서 `답글 작성`을 누른다. +3. UI가 기존 직접 답글 GET을 실행하고 답글 영역과 작성 form을 표시한다. +4. 관리자가 내용을 입력해 등록한다. +5. 기존 댓글 POST에 원댓글 ID를 `parentId`로 보내고 성공 후 원댓글·답글 목록을 재조회한다. +6. 실패하면 오류를 표시하고 입력 초안을 유지해 재시도할 수 있다. + +## 7. 정보 구조와 라우팅 + +```text +/ai-characters/:characterId/audio-contents/:contentId + └─ 댓글 관리 + └─ 원댓글 + └─ 직접 답글 영역 및 작성 form +``` + +- 새 route와 query parameter를 추가하지 않는다. +- 오디오 콘텐츠 상세의 기존 `CommentThread` 안에서만 동작한다. + +## 8. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `ACR-001` | 확정 | 활성 Audio target의 답글 0개 원댓글에 `답글 작성` 버튼을 표시한다. | `replyCount=0`, `canMutate=true`인 Audio root에서 버튼을 찾을 수 있다. | contract 불필요, `P1-T1` | +| `ACR-002` | 확정 | `답글 작성`을 누르면 기존 직접 답글 영역과 작성 form을 연다. | 버튼 클릭 뒤 원댓글 이름과 연결된 답글 region·textarea·등록 버튼이 표시된다. | 답글 GET, `P1-T1` | +| `ACR-003` | 확정 | 첫 답글은 기존 Audio 댓글 POST를 사용한다. | body가 `comment`, root ID `parentId`, `isSecret=false`, `languageCode=null`을 포함하고 성공 후 목록을 재조회한다. | 댓글 POST, `P1-T1` | +| `ACR-004` | 확정 | 기존 경계를 유지한다. | Community의 답글 0개 root와 비활성 Audio root에는 신규 작성 진입이 없고, reply row에는 답글 action이 없다. | contract 불필요, `P1-T1` | +| `ACR-005` | 확정 | 기존 mutation 상태를 유지한다. | pending 중 중복 POST가 없고 실패 시 초안 유지, 성공 시 입력 초기화가 기존 test와 함께 통과한다. | `NullSuccess`, `P1-GATE` | + +## 9. 반응형 기능 범위 + +| 기능 | Desktop | Tablet | Mobile | 비고 | +|---|---:|---:|---:|---| +| `답글 작성` 진입 | 지원 | 지원 | 지원 | 기존 댓글 action layout 재사용 | +| 답글 작성 form | 지원 | 지원 | 지원 | 기존 form 재사용 | + +- 상위 제품의 최소 320px, keyboard-only와 touch target 기준을 유지한다. + +## 10. UI/UX Expectations + +### 10.1 디자인과 component 원칙 + +- `CommentThread`, `CommentItem`, `CommentForm`을 재사용한다. +- 새 component나 dependency를 추가하지 않는다. +- 기존 답글이 있는 원댓글의 `답글 보기` UI는 유지한다. + +### 10.2 화면 상태 + +- 클릭 직후 기존 답글 loading 상태를 표시한다. +- 빈 답글 응답 뒤에도 작성 form을 표시한다. +- 조회 오류는 기존 재시도 UI를 사용한다. +- 작성 중·성공·실패는 기존 Comments mutation 정책을 사용한다. + +### 10.3 접근성 + +- 버튼의 accessible name은 원댓글 내용과 `답글 작성`을 조합해 식별 가능해야 한다. +- form의 visible label과 오류 연결, keyboard focus 표시를 유지한다. +- 답글 region은 기존처럼 원댓글 내용과 `답글`을 조합한 accessible name을 유지한다. + +## 11. API 계약 + +### 11.1 공통 규칙 + +- 이 기능은 API를 변경하지 않는다. +- 정확한 request, response와 오류는 [기능 API Contract](./api-contract.md)를 따른다. +- 원본 OpenAPI는 [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)다. + +### 11.2 Endpoint 추적 + +| 요구사항 | Method | Path | 계약 상태 | 소유 Goal | +|---|---|---|---|---| +| `ACR-002` | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies` | 기존 제공 | `P1-T1` | +| `ACR-003` | POST | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments` | 기존 제공 | `P1-T1` | + +### 11.3 외부 제공 대기 계약 + +없음. 필요한 GET·POST와 DTO가 이미 제공돼 있다. + +## 12. 보안과 데이터 취급 + +- 기존 Bearer 인증, ADMIN 권한과 `characterId`·`contentId` target 격리를 유지한다. +- 클라이언트가 임의의 다른 target ID를 생성하지 않는다. +- 댓글 본문과 인증 정보는 console, 분석 이벤트와 영구 저장소에 기록하지 않는다. +- 401·403은 공통 인증·인가 정책을 따른다. + +## 13. 성능과 품질 요구사항 + +- 버튼 클릭 때 선택한 root의 답글 page 0만 기존 방식으로 조회한다. +- 전체 root 또는 모든 답글을 선조회하지 않는다. +- 새 dependency와 optimistic update를 추가하지 않는다. +- Vitest focused test, Comments 회귀, Chromium mock E2E, typecheck와 lint를 통과한다. +- server 404나 network error를 mock으로 자동 전환하지 않는다. + +## 14. 성공 기준 + +### 14.1 기능 수용 기준 + +- [x] 답글 0개인 활성 Audio root에서 첫 답글을 작성한다. (`ACR-001~003`) +- [x] Community, 비활성 Audio와 reply row의 기존 경계가 유지된다. (`ACR-004`) +- [x] 실패·재시도와 중복 제출 방지가 회귀하지 않는다. (`ACR-005`) + +### 14.2 UI/UX 수용 기준 + +- [x] 버튼·답글 region·form의 accessible name과 label이 연결된다. +- [x] 320px와 keyboard-only 흐름에서 작성 control을 사용할 수 있다. + +### 14.3 추적성 완료 기준 + +- [x] 모든 확정 요구사항이 API 또는 contract 불필요 판정, `P1-T1`, `P1-GATE`와 연결된다. +- [x] 구현·검증 결과가 [plan-task.md](./plan-task.md)의 Progress에 기록된다. + +## 15. Open Questions + +없음. + +## 16. 요구사항 추적표 + +| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 | +|---|---|---:|---|---|---| +| `ACR-001~005` | [api-contract.md](./api-contract.md) | 1 | `P1-T1`, `P1-GATE` | `comment-thread.test.tsx`, `comments.spec.ts` | 활성 Audio 첫 답글, Community·비활성 경계 | + +## 17. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal | +|---|---|---|---|---|---| +| 2026-08-05 | `ACR-DEC-001` | 확정 | 신규 진입은 오디오 콘텐츠 댓글에만 적용하고 커뮤니티 댓글은 변경하지 않는다. | 사용자 선택 B | `ACR-001`, `ACR-004`, `P1-T1` | +| 2026-08-05 | `ACR-DEC-002` | 확정 | 답글 0개 root에 form을 상시 노출하지 않고 `답글 작성` 버튼으로 기존 답글 영역을 연다. | 사용자 선택 A | `ACR-001~002`, `P1-T1` | +| 2026-08-05 | `ACR-DEC-003` | 확정 | 새 API·컴포넌트 없이 기존 `parentId` POST와 Comments UI를 재사용한다. | 기존 OpenAPI와 구현 확인 | `ACR-002~005`, [api-contract.md](./api-contract.md) | + +## 18. 변경 관리 + +- 범위가 바뀌면 이 문서의 Decision Log와 요구사항을 먼저 갱신한다. +- API가 바뀌면 [api-contract.md](./api-contract.md)와 원본 OpenAPI의 제공 버전을 확인한다. +- 구현 범위가 바뀌면 코드보다 [plan-task.md](./plan-task.md)를 먼저 갱신한다. +- 기존 Progress와 검증 기록은 삭제하거나 덮어쓰지 않는다. diff --git a/docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md b/docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md new file mode 100644 index 0000000..0d42d9a --- /dev/null +++ b/docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md @@ -0,0 +1,226 @@ +# Phase 1 오디오 콘텐츠 첫 답글 작성 진입 리뷰 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-GATE` | +| 기준 commit 또는 working tree | 2026-08-05 현재 uncommitted working tree | +| 리뷰 일자 | 2026-08-05 | +| 리뷰어 | Codex | +| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) | +| 리뷰 상태 | 수정 검증 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- `ACR-001~005`와 기존 Audio 댓글 API 계약이 실제 코드·test에서 충족되는지 확인한다. +- `P1-T1`, `P1-GATE` 완료 기록과 현재 working tree가 일치하는지 확인한다. + +### 포함 범위 + +- 코드: `src/features/comments/components/{CommentItem,CommentThread}.tsx`, Comments API·model과 Audio/Community 소비 경로 +- 테스트: `src/features/comments/tests`, `tests/e2e/comments.spec.ts` +- 문서: `ACR-001~005`, 기능 API Contract, `P1-T1`, `P1-GATE`, Progress +- 브라우저 검증: Chromium·mobile Chrome mock E2E와 keyboard-only 흐름 + +### 제외 범위 + +- 실제 개발 API와 ADMIN credential을 사용하는 server integration +- 댓글 수정·삭제·pagination의 기존 기능 자체 재설계 +- exact 320px Audio 첫 답글 수동 시각 QA 재수행. 현재 리뷰에서는 mobile Chrome 첫 답글과 320px Community overflow를 자동 검증했다. + +## 3. 판정 기준 + +### 심각도 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험 또는 핵심 첫 답글 흐름 불능 | +| High | `ACR-001~005` 또는 API Contract 위반 | +| Medium | 제한된 target·viewport·권한·재시도 조건의 기능 회귀 | +| Low | 문서 추적, 유지보수성 또는 비핵심 UX 불일치 | + +### 상태 + +| 상태 | 의미 | 후속 처리 | +|---|---|---| +| 후보 | 근거 발견 후 판정 전 | 재현·대조 | +| 확정 | 코드·test·문서 근거로 문제 확인 | 후속 Task 후보 | +| 오탐 | 요구사항과 실행 결과상 문제 아님 | 근거 보존 후 종료 | +| 보류 | 외부 조건이 필요함 | 담당·재개 조건 기록 | +| 수정 완료 | 수정과 회귀 검증 완료 | 검증 기록 누적 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `ACR-001~005`, PRD §10·§14 +- API Contract: 직접 답글 GET, Audio 댓글·답글 POST, `NullSuccess` +- 계획: `P1-T1`, `P1-GATE`, 2026-08-05 구현·검증 Progress +- 코드: `CommentItem.tsx:6,28`, `CommentThread.tsx:113-125,127-147,167-175` +- 테스트: `comment-thread.test.tsx:138-176`, `comments.spec.ts:33-145` + +### 실행 환경 + +```text +OS: Darwin 25.0.0 x86_64 +Node: v24.12.0 +npm: 11.7.0 +Browser/viewport: Desktop Chrome, Pixel 5 mobile Chrome, spec 내부 320x640·1280x900 +API mode: mock +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` | 성공 | exit 0, 1 file / 8 tests passed | +| `npm run test:run -- src/features/comments` | 성공 | exit 0, 3 files / 15 tests passed | +| `npm run typecheck` | 성공 | exit 0, TypeScript 오류 0건 | +| `npm run lint` | 성공 | exit 0, ESLint 오류 0건 | +| `git diff --check` | 성공 | exit 0 | +| sandbox의 `npm run e2e:mock -- tests/e2e/comments.spec.ts` | 실행 불가 | 로컬 `127.0.0.1:8889` listen `EPERM` | +| 승인된 동일 E2E 재실행 | 성공 | exit 0, Chromium 3 + mobile Chrome 3 = 6 tests passed | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P1-001` | Low | 수정 완료 | 완료 상태와 현재 상태·수용 체크박스가 서로 모순된다 | `P1-T1`, `P1-GATE` | `P1-R1` | + +기능·API·권한·반응형 범위에서는 확정 발견 사항이 없다. + +## 6. 발견 사항 상세 + +### REV-P1-001 — 완료 상태와 현재 상태·수용 체크박스가 서로 모순된다 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** `ACR-001~005`, PRD §14 +- **관련 계약:** 없음 +- **소유 Task:** `P1-T1`, `P1-GATE`; 후속 문서 Task `P1-R1` + +**관찰 내용** + +`plan-task.md`는 상태, Phase 표, Task·Gate 체크박스와 Progress에서 구현·검증 +완료라고 기록한다. 그러나 같은 문서의 현재 상태는 답글 진입 버튼이 없고 +코드·test가 변경되지 않았다고 적으며, 발견된 문제도 기능 부재를 현재형으로 +유지한다. `prd.md` §14의 수용·추적 체크박스도 모두 미완료다. + +**근거** + +- 완료 기록: `plan-task.md:5,21,84-100,159-163` +- 미구현 기록: `plan-task.md:24-25,175` +- 미완료 수용 기준: `prd.md:173-185` +- 실제 구현: `CommentThread.tsx:170`, `CommentItem.tsx:28` +- 검증: focused 8/8, Comments 15/15, mock E2E 6/6, typecheck·lint 통과 + +**재현 또는 검증 절차** + +1. `plan-task.md`의 문서 상태와 Phase 표를 확인한다. +2. 같은 문서의 현재 상태·발견된 문제와 `prd.md` §14를 확인한다. +3. 완료와 미구현·미완료 표기가 동시에 존재함을 확인한다. +4. 실제 code·test와 재실행 결과는 완료 쪽 기록과 일치한다. + +**영향** + +런타임 기능에는 영향이 없지만, 다음 작업자와 문서 검증 자동화가 구현 완료 +여부를 일관되게 판정할 수 없다. + +**권장 조치** + +코드 변경 없이 `plan-task.md`의 현재 상태와 발견된 문제를 실제 완료 상태로 +정정하고, `prd.md` §14 체크박스를 이번 리뷰 증거에 따라 완료 처리한다. 기존 +설계·구현 Progress는 삭제하지 않고 문서 정정 기록을 추가한다. + +**판정 기록** + +- 2026-08-05 — 문서 내부 대조와 현재 검증 결과로 Low 문서 정합성 문제를 확정했다. +- 2026-08-05 정정 — 확정 finding을 초안으로만 남긴 처리는 review 가이드 §4·§5와 맞지 않아 [plan-task.md](../plan-task.md)의 `P1-R1` 후속 Task로 전환했다. +- 2026-08-05 수정 완료 — `P1-R1`에서 stale 현재 상태·발견된 문제와 PRD §14 체크박스를 실제 구현·검증 상태에 맞게 정정했다. + +## 7. 확정 항목의 plan·goal 전환 + +최초 리뷰에서는 진단 범위라는 이유로 아래 Task를 초안으로만 남겼다. + +**정정 — 2026-08-05:** 확정 finding은 코드 수정 여부와 별개로 +`plan-task.md`의 후속 Task로 전환해야 하므로 [P1-R1](../plan-task.md)을 +추가했다. 아래 초안은 실제 Task의 입력으로 보존한다. + +### 신규 회귀 수정 Task 초안 + +```markdown +### Task R1.1 완료 문서 상태 정합성 복구 + +**Goal 실행 `P1-R1`:** `REV-P1-001`의 완료·미구현 상태 모순을 제거하고 실제 검증 증거와 PRD·plan을 일치시킨다. + +- **시작 조건:** `REV-P1-001`, 완료된 `P1-T1`, `P1-GATE`. +- **완료 증거:** 현재 상태·발견된 문제·PRD §14 정정, 기존 Progress 보존, 문서 링크·diff 검증. +- **범위 밖:** 애플리케이션 코드·test·API Contract 변경. +- **TDD 예외 사유:** 구현 동작이 아닌 완료 문서 정합성 수정이다. +- **대체 검증:** 완료/미구현 marker 대조, Markdown link 확인, `git diff --check`. + +- [x] 현재 상태와 발견된 문제를 실제 완료 상태로 정정한다. +- [x] PRD §14 수용·추적 체크박스를 검증 증거에 맞게 갱신한다. +- [x] 기존 Progress를 보존하고 정정 기록을 누적한다. +- [x] Markdown link와 `git diff --check`를 실행해 결과를 기록한다. +``` + +### create_goal objective 초안 + +```text +[P1-R1]의 확정 review 항목 REV-P1-001을 문서에서 수정한다. +애플리케이션 코드·test·API Contract는 변경하지 않는다. +현재 상태, PRD 수용 기준, 정정 기록과 문서 검증이 모두 끝나기 전에는 complete로 표시하지 않는다. +``` + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD·계약·plan·code·unit·E2E 대조 완료 | +| 후보 항목 판정 완료 | 충족 | `REV-P1-001` Low 수정 완료 | +| 확정 항목 plan 반영 | 충족 | [plan-task.md](../plan-task.md)에 `P1-R1` 추가 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 외부 의존 finding 없음 | +| 검증 명령과 결과 기록 | 충족 | unit 15/15, E2E 6/6, typecheck·lint와 `P1-R1` 문서 검증 통과 | + +**최종 결론:** 기능 구현 승인, `REV-P1-001` 수정 검증 완료. + +**남은 항목:** 없음. + +## 9. 수정 후 검증 기록 + +수정 검증 결과는 아래에 기존 기록을 보존하며 누적한다. + +### 1차 수정 검증 — 2026-08-05 + +- 무엇을: `REV-P1-001`의 plan stale 상태·PRD §14 미완료 체크박스와 review 상태를 실제 구현·검증 완료 상태에 맞췄다. +- 왜: 런타임 기능은 승인됐지만 완료·미구현 표기가 공존해 문서 추적과 자동 판정이 모순됐다. +- 어떻게: + - `! rg -n '^- 현재 .*답글 진입 버튼이 없다|^- 애플리케이션 코드와 test는 아직 변경하지 않았다|^- 확정: .*첫 답글 작성 진입이 없다' docs/20260805_오디오콘텐츠댓글답글/plan-task.md` — 성공, exit 0, stale marker 0건 + - `! sed -n '/## 14\./,/## 15\./p' docs/20260805_오디오콘텐츠댓글답글/prd.md | rg -n '^- \[ \]'` — 성공, exit 0, 미완료 체크박스 0건 + - `test -f docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md` — 성공, exit 0 + - `npm run test:run -- src/features/comments` — 성공, exit 0, 3 files / 15 tests passed + - `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — 성공, exit 0, Chromium 3 tests passed + - `npm run typecheck` — 성공, exit 0, TypeScript 오류 0건 + - `npm run lint` — 성공, exit 0, ESLint 오류 0건 + - `git diff --check` — 성공, exit 0 +- 남은 항목: 없음. + +### 2차 수정 재검증 — 2026-08-05 + +- 무엇을: 사용자가 반영한 `P1-R1`의 PRD·plan·review 정합성과 Comments 기능 회귀를 현재 working tree에서 다시 판정했다. +- 왜: 완료 체크박스와 과거 검증 기록만 신뢰하지 않고 실제 반영 여부와 신규 문제를 독립적으로 확인하기 위해서다. +- 어떻게: + - `! rg -n '^- 현재 .*답글 진입 버튼이 없다|^- 애플리케이션 코드와 test는 아직 변경하지 않았다|^- 확정: .*첫 답글 작성 진입이 없다' docs/20260805_오디오콘텐츠댓글답글/plan-task.md` — 성공, exit 0, stale marker 0건 + - `! sed -n '/## 14\./,/## 15\./p' docs/20260805_오디오콘텐츠댓글답글/prd.md | rg -n '^- \[ \]'` — 성공, exit 0, 미완료 체크박스 0건 + - `test -f docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md` — 성공, exit 0 + - `npm run test:run -- src/features/comments` — 성공, exit 0, 3 files / 15 tests passed + - `npm run e2e:mock -- tests/e2e/comments.spec.ts` — 성공, exit 0, Chromium 3 + mobile Chrome 3 = 6 tests passed + - `npm run typecheck` — 성공, exit 0, TypeScript 오류 0건 + - `npm run lint` — 성공, exit 0, ESLint 오류 0건 + - `git diff --check` — 성공, exit 0 +- 판정: `REV-P1-001` 수정 완료를 재확인했고 검토 범위의 신규 확정 발견 사항은 없다. +- 남은 항목: 실제 개발 API와 ADMIN credential을 사용하는 server integration은 기존 제외 범위로 유지한다. diff --git a/src/features/comments/components/CommentItem.tsx b/src/features/comments/components/CommentItem.tsx index 0631074..f438867 100644 --- a/src/features/comments/components/CommentItem.tsx +++ b/src/features/comments/components/CommentItem.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import type { CommentRecord } from "@/features/comments/model/types"; import { formatSeoulDateTime } from "@/shared/lib/formatters"; -export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, showRepliesButton }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly showRepliesButton?: boolean }) { +export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, replyActionLabel }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly replyActionLabel?: "답글 보기" | "답글 작성" }) { const [draft, setDraft] = useState(comment.comment); const [isEditing, setIsEditing] = useState(false); const label = `${comment.comment}`; @@ -25,7 +25,7 @@ export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDe

{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}

- {showRepliesButton === true && onShowReplies !== undefined ? : null} + {replyActionLabel !== undefined && onShowReplies !== undefined ? : null} {canEdit ? : null} {canDelete ? : null}
diff --git a/src/features/comments/components/CommentThread.tsx b/src/features/comments/components/CommentThread.tsx index ce0a8e2..0ae01e1 100644 --- a/src/features/comments/components/CommentThread.tsx +++ b/src/features/comments/components/CommentThread.tsx @@ -167,7 +167,7 @@ export function CommentThread({ apiClient, canMutate = true, target }: { readonl
{roots.data.items.map((comment) => (
- void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} showRepliesButton={comment.replyCount > 0 || expandedRootIds.includes(comment.id)} /> + void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} replyActionLabel={comment.replyCount > 0 || expandedRootIds.includes(comment.id) ? "답글 보기" : requestTarget.kind === "audio" && canMutate ? "답글 작성" : undefined} /> {renderReplies(comment)}
))} diff --git a/src/features/comments/tests/comment-thread.test.tsx b/src/features/comments/tests/comment-thread.test.tsx index e80b32b..a053ddb 100644 --- a/src/features/comments/tests/comment-thread.test.tsx +++ b/src/features/comments/tests/comment-thread.test.tsx @@ -12,6 +12,7 @@ type CapturedRequest = { }; const target = { kind: "audio", characterId: "101", contentId: "9001", creatorId: 101 } satisfies CommentTarget; +const communityTarget = { kind: "community", characterId: "101", postId: "7001", creatorId: 101 } satisfies CommentTarget; const fanRoot = { id: 1101, @@ -59,6 +60,9 @@ function createThreadClient(requests: CapturedRequest[]): ApiClient { if (options.path.includes("/1101/replies")) { return options.responseSchema.parse({ totalCount: 2, items: [fanReply, aiReply] }); } + if (options.path.includes("/1102/replies")) { + return options.responseSchema.parse({ totalCount: 0, items: [] }); + } return options.responseSchema.parse({ totalCount: 2, items: [fanRoot, aiRoot] }); }, @@ -131,6 +135,46 @@ function getFormForControl(control: HTMLElement): HTMLFormElement { throw new Error("expected parent form"); } +test("CommentThread opens the existing reply form for a first Audio reply only", async () => { + // Given + const requests: CapturedRequest[] = []; + render(); + expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument(); + + // When + fireEvent.click(screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" })); + const repliesRegion = await screen.findByRole("region", { name: "AI 루트 댓글 답글" }); + const replyInput = within(repliesRegion).getByLabelText("AI 루트 댓글에 답글"); + fireEvent.change(replyInput, { target: { value: "첫 답글" } }); + fireEvent.click(within(repliesRegion).getByRole("button", { name: "답글 등록" })); + + // Then + await waitFor(() => expect(requests.filter((request) => request.method === "POST" && request.body === JSON.stringify({ comment: "첫 답글", parentId: 1102, isSecret: false, languageCode: null }))).toHaveLength(1)); + expect(requests.some((request) => request.method === undefined && request.path.includes("/1102/replies?page=0&size=20"))).toBe(true); + expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument(); +}); + +test("CommentThread keeps first-reply entry out of Community and read-only Audio roots", async () => { + // Given + const communityRequests: CapturedRequest[] = []; + const readOnlyRequests: CapturedRequest[] = []; + + // When + const { unmount } = render(); + + // Then + expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument(); + unmount(); + + // When + render(); + + // Then + expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument(); +}); + test("CommentThread links and focuses empty root and reply comment errors without sending mutations", async () => { // Given const requests: CapturedRequest[] = []; diff --git a/tests/e2e/comments.spec.ts b/tests/e2e/comments.spec.ts index bf0da96..4984721 100644 --- a/tests/e2e/comments.spec.ts +++ b/tests/e2e/comments.spec.ts @@ -52,6 +52,13 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through // When await page.getByLabel("새 댓글").fill("오디오 루트 생성"); await page.getByRole("button", { name: "댓글 등록" }).click(); + await page.getByRole("button", { name: "오디오 AI 루트 댓글 답글 작성" }).click(); + const emptyReplies = page.getByRole("region", { name: "오디오 AI 루트 댓글 답글" }); + await expect(emptyReplies.getByLabel("오디오 AI 루트 댓글에 답글")).toBeVisible(); + await expect(emptyReplies.getByText("오디오 팬 루트 댓글", { exact: true })).toHaveCount(0); + await emptyReplies.getByLabel("오디오 AI 루트 댓글에 답글").fill("오디오 첫 답글 생성"); + await emptyReplies.getByRole("button", { name: "답글 등록" }).click(); + await expect(emptyReplies.getByText("오디오 첫 답글 생성", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "오디오 팬 루트 댓글 답글 보기" }).click(); const replies = page.getByRole("region", { name: "오디오 팬 루트 댓글 답글" }); await replies.getByLabel("오디오 팬 루트 댓글에 답글").fill("오디오 답글 생성"); @@ -70,6 +77,7 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through await expect(page.getByText("오디오 AI 루트 댓글", { exact: true })).toBeHidden(); await expect(replies.getByText("오디오 답글 생성", { exact: true })).toBeVisible(); expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 루트 생성", parentId: null, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" }); + expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 첫 답글 생성", parentId: 1102, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" }); expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 답글 생성", parentId: 1101, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" }); expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 AI 답글 수정" }), method: "PUT", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1202", search: "" }); expect(commentRequests).toContainEqual({ body: null, method: "DELETE", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1201", search: "" }); @@ -91,6 +99,7 @@ test("Community sheet comments keep two-level controls usable at 320px", async ( await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible(); await expect(dialog.getByLabel("새 댓글")).toBeInViewport(); await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" })).toHaveCount(0); await expectNoHorizontalOverflow(page); // When @@ -123,6 +132,7 @@ test("keyboard-only Audio comment flow reaches form and reply controls", async ( await loginThroughMockMode(page); await page.goto("/ai-characters/101/audio-contents/9001"); const rootInput = page.getByLabel("새 댓글"); + const firstReplyAction = page.getByRole("button", { name: "오디오 AI 루트 댓글 답글 작성" }); // When / Then await pressTabUntilFocused(page, rootInput); @@ -130,4 +140,7 @@ test("keyboard-only Audio comment flow reaches form and reply controls", async ( await pressTabUntilFocused(page, page.getByRole("button", { name: "댓글 등록" })); await page.keyboard.press("Enter"); await expect(page.getByText("키보드 루트 댓글", { exact: true })).toBeVisible(); + await pressTabUntilFocused(page, firstReplyAction); + await page.keyboard.press("Enter"); + await pressTabUntilFocused(page, page.getByRole("region", { name: "오디오 AI 루트 댓글 답글" }).getByLabel("오디오 AI 루트 댓글에 답글")); });